Coverage for core/langgraph/_builtins_ticket.py: 93%
414 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-20 03:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-20 03:43 +0000
1"""_builtins_ticket.py — TICKET-profile builtins (lazy-loaded, needs httpx).
3The TICKET profile (RUN_PROFILE=TICKET) is the Vikunja-driven run shape:
4checkout from the base branch (git-checkout builtin) → per-run feature
5branch `ticket/<issue_key>-<slug(title)>` → ticket-publish commits the
6pipeline output and pushes the branch (only when a commit was created —
7the branch is born with the first real commit; no empty branch publish
8and never a forced push) → the PR is ensured idempotently (reuse open →
9"reused", closed → "reopened", else create → "created"; base = the
10ticket's base branch). Later steps and continuation runs continue on the
11SAME branch and PR. A push conflict is a hard error (resolved MVP
12decision). PR title/body come from the ticket (PR_TITLE / PR_DESCRIPTION).
14The publish builtin promotes the ensured PR url to the top-level `pr_url`
15state key — the entrypoint's final status-callback PATCH
16(UpdateRunRequest.pr_url, TICKET profile) reads it for the exit-0 pr_open
17lane transition.
19Decision builtins (ticket-dor / ticket-review) stop the run with
20outcome-coded TicketOutcome exceptions instead of returning state:
22 | code | meaning | lane (API-owned mapping) |
23 |------|----------------------------|--------------------------|
24 | 10 | DoR inputs insufficient | open_questions |
25 | 11 | agent: blocked | blocked |
26 | 20 | review: fixable issues | needs_rework |
27 | 21 | review: too severe | pr_rejected |
28 | 22 | review: cannot fix | open_questions |
29 | 0 | approved → state returned | pr_open |
31The entrypoint maps TicketOutcome → phase=failed + exit_code + optional
32`questions` in the final status-callback PATCH (UpdateRunRequest.questions;
33API posts them as a ticket comment when the lane mapping yields
34open_questions). The LLM decisions use the pipeline's invoke_agent_fn —
35GraphBuilder stores it on the BuiltinExecutor (invoke_agent_fn) and
36register_all binds it to the builtins.
37"""
38from __future__ import annotations
40import json
41import os
42import re
43import subprocess
44import time
45from typing import Any
46from urllib.parse import urlparse
48import httpx
50try:
51 from _envelope import is_error_envelope
52except ImportError: # pragma: no cover — main.py-ajossa (qualified importit)
53 from langgraph._envelope import is_error_envelope
55_COMMIT_AUTHOR_NAME = "agent-platform"
56_COMMIT_AUTHOR_EMAIL = "agent-platform@localhost"
57_COMMIT_MESSAGE_PREFIX = "agent-platform:"
60def _require_env(name: str) -> str:
61 value = os.environ.get(name)
62 if not value:
63 raise ValueError(f"ticket-publish: required env var {name!r} is not set")
64 return value
67def _repo_full_name(git_repo_url: str) -> str:
68 """Derive owner/repo from the code-repo URL (repo fact).
70 The API sets REPO_FULL_NAME only in the PR profile; the TICKET profile
71 derives it deterministically from GIT_REPO_URL (path without .git).
72 """
73 path = urlparse(git_repo_url).path.strip("/")
74 if path.endswith(".git"):
75 path = path[:-len(".git")]
76 if not path or "/" not in path:
77 raise ValueError(
78 f"ticket-publish: cannot derive repo full name from GIT_REPO_URL {git_repo_url!r}"
79 )
80 return path
83def _git(project_dir: str, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
84 """Run a git command in the project dir; failures raise clear errors."""
85 try:
86 return subprocess.run(
87 ["git", "-c", "safe.directory=*", *args],
88 cwd=project_dir,
89 capture_output=True,
90 text=True,
91 check=check,
92 )
93 except subprocess.CalledProcessError as exc:
94 raise ValueError(
95 f"ticket-publish: 'git {' '.join(args)}' failed "
96 f"(exit {exc.returncode}): {(exc.stderr or exc.stdout or '').strip()}"
97 ) from exc
100def _git_commit_and_push(project_dir: str, branch: str) -> bool:
101 """Commit the pipeline output and push the feature branch.
103 The push is plain (never a forced push) and happens ONLY when a commit
104 was created — nothing staged → no commit → no push (no empty branch
105 publish). The branch is born with the first real commit; later steps
106 and continuation runs continue on the same branch and PR. Returns
107 True when a commit was created and pushed, False otherwise. A failed
108 push is a hard error (resolved MVP decision).
109 """
110 _git(project_dir, ["add", "-A"])
111 staged = _git(project_dir, ["diff", "--cached", "--quiet"], check=False)
112 if staged.returncode not in (0, 1):
113 raise ValueError(
114 f"ticket-publish: 'git diff --cached --quiet' failed "
115 f"(exit {staged.returncode}): {(staged.stderr or '').strip()}"
116 )
117 if staged.returncode == 0:
118 return False
119 author_name = os.environ.get("GIT_AUTHOR_NAME", _COMMIT_AUTHOR_NAME)
120 author_email = os.environ.get("GIT_AUTHOR_EMAIL", _COMMIT_AUTHOR_EMAIL)
121 _git(project_dir, [
122 "-c", f"user.name={author_name}",
123 "-c", f"user.email={author_email}",
124 "commit", "-m", f"{_COMMIT_MESSAGE_PREFIX} {branch}",
125 ])
126 _git(project_dir, ["push", "origin", branch])
127 return True
130def _ensure_pr(
131 base_url: str,
132 token: str,
133 repo_full_name: str,
134 *,
135 head: str,
136 base: str,
137 title: str,
138 body: str,
139) -> tuple[str, str]:
140 """Ensure the PR for the feature branch exists (idempotent, LOW-2).
142 Order: open PR for the branch → reuse; closed PR for the branch →
143 reopen; otherwise create. Returns (pr_url, action) where action is
144 "reused" | "reopened" | "created".
145 """
146 headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
147 api = f"{base_url}/api/v1/repos/{repo_full_name}/pulls"
149 def _find(pulls: list) -> dict | None:
150 for pr in pulls:
151 if isinstance(pr, dict) and (pr.get("head") or {}).get("ref") == head:
152 return pr
153 return None
155 open_resp = httpx.get(f"{api}?state=open&limit=50", headers=headers)
156 if open_resp.status_code >= 400:
157 raise ValueError(
158 f"ticket-publish: open PRs GET failed ({open_resp.status_code}): {open_resp.text[:500]}"
159 )
160 existing = _find(open_resp.json())
161 if existing is not None:
162 return existing["html_url"], "reused"
164 closed_resp = httpx.get(f"{api}?state=closed&limit=50", headers=headers)
165 if closed_resp.status_code >= 400:
166 raise ValueError(
167 f"ticket-publish: closed PRs GET failed ({closed_resp.status_code}): {closed_resp.text[:500]}"
168 )
169 closed = _find(closed_resp.json())
170 if closed is not None:
171 number = closed["number"]
172 reopen_resp = httpx.patch(
173 f"{api}/{number}",
174 json={"state": "open"},
175 headers=headers,
176 )
177 if reopen_resp.status_code >= 400:
178 raise ValueError(
179 f"ticket-publish: PR reopen PATCH failed ({reopen_resp.status_code}): {reopen_resp.text[:500]}"
180 )
181 return reopen_resp.json().get("html_url", closed["html_url"]), "reopened"
183 create_resp = httpx.post(
184 api,
185 json={"title": title, "body": body, "head": head, "base": base},
186 headers=headers,
187 )
188 if create_resp.status_code >= 400:
189 raise ValueError(
190 f"ticket-publish: PR create POST failed ({create_resp.status_code}): {create_resp.text[:500]}"
191 )
192 try:
193 pr_url = create_resp.json().get("html_url")
194 except ValueError:
195 raise ValueError(
196 f"ticket-publish: PR create POST returned invalid JSON: {create_resp.text[:500]}"
197 ) from None
198 if not isinstance(pr_url, str) or not pr_url:
199 raise ValueError(
200 f"ticket-publish: PR create POST response missing html_url: {create_resp.text[:500]}"
201 )
202 return pr_url, "created"
205def builtin_ticket_publish(step: dict, state: dict) -> dict:
206 """Publish the pipeline output for a TICKET run: commit → push → PR.
208 The first commit step performs branch publish + PR open: the first
209 real code commit is pushed (no empty branch publish, never a forced
210 push), then the PR is ensured idempotently (reuse open →
211 "reused", closed → "reopened", else create → "created"; base = the
212 ticket's base branch). Later steps and continuation runs continue on
213 the SAME branch and PR. A push conflict is a hard error (resolved MVP
214 decision). Nothing staged → no commit, no push, no PR — the step
215 result reports pushed: False.
217 Step result: {"branch", "base", "pushed", "pr_action"} + "pr_url"
218 only when a PR was ensured; the ensured pr_url is also promoted to
219 the top-level state key (the entrypoint reads it for the final
220 status-callback PATCH, TICKET profile).
222 Env vars used:
223 RUN_PROFILE — must be "TICKET" (guard; anything else raises)
224 GIT_REPO_URL — code repo URL (owner/repo derivation)
225 GITEA_BASE_URL, GIT_TOKEN — Gitea REST credentials (PR ensure)
226 PR_TITLE, PR_DESCRIPTION — PR title/body from the ticket
227 (PR_DESCRIPTION may be empty)
228 GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL — commit identity overrides
229 (optional; defaults agent-platform / agent-platform@localhost)
230 State used (set by git-checkout):
231 project_dir, ticket_branch, ticket_base_branch
232 """
233 run_profile = os.environ.get("RUN_PROFILE")
234 if run_profile != "TICKET":
235 raise ValueError(
236 f"ticket-publish requires RUN_PROFILE=TICKET (got {run_profile!r})"
237 )
239 project_dir = state.get("project_dir", "/project")
240 branch = state.get("ticket_branch")
241 base = state.get("ticket_base_branch")
242 if not isinstance(branch, str) or not branch:
243 raise ValueError(
244 "ticket-publish: state['ticket_branch'] is missing — "
245 "the git-checkout step must run first (TICKET profile)"
246 )
247 if not isinstance(base, str) or not base:
248 raise ValueError(
249 "ticket-publish: state['ticket_base_branch'] is missing — "
250 "the git-checkout step must run first (TICKET profile)"
251 )
253 repo_full_name = _repo_full_name(_require_env("GIT_REPO_URL"))
254 title = _require_env("PR_TITLE")
255 body = os.environ.get("PR_DESCRIPTION") or ""
257 pushed = _git_commit_and_push(project_dir, branch)
259 step_result = {
260 "branch": branch,
261 "base": base,
262 "pushed": pushed,
263 "pr_action": None,
264 }
265 if not pushed:
266 return {**state, step.get("id", "ticket_publish"): step_result}
268 base_url = _require_env("GITEA_BASE_URL").rstrip("/")
269 token = _require_env("GIT_TOKEN")
270 pr_url, action = _ensure_pr(
271 base_url,
272 token,
273 repo_full_name,
274 head=branch,
275 base=base,
276 title=title,
277 body=body,
278 )
279 step_result["pr_url"] = pr_url
280 step_result["pr_action"] = action
281 return {
282 **state,
283 step.get("id", "ticket_publish"): step_result,
284 "pr_url": pr_url,
285 }
288class TicketOutcome(Exception):
289 """Outcome-coded stop for TICKET-profile runs (exit-code lanes).
291 Raised by the decision builtins (ticket-dor / ticket-review) to end
292 the run with a defined outcome instead of a generic failure:
294 code → exit_code in the final status-callback PATCH (the API
295 owns the exit-code → lane mapping, e.g. 10/22 →
296 open_questions, 20 → needs_rework, 21 → pr_rejected)
297 message → error.message in the final PATCH
298 questions → optional list of open questions; in the TICKET profile
299 the entrypoint includes them as UpdateRunRequest.questions
300 (non-empty only) so the API can post them as a ticket
301 comment instead of the generic result comment
302 """
304 def __init__(
305 self,
306 code: int,
307 message: str,
308 questions: list[str] | None = None,
309 ) -> None:
310 super().__init__(message)
311 self.code = code
312 self.message = message
313 self.questions = questions
316_DOR_SYSTEM_PROMPT = """\
317You are the Definition-of-Ready (DoR) gatekeeper in agent-platform. Decide \
318whether a ticket's inputs are sufficient for the agent to implement it.
320Assess the ticket context (description + comments):
321- Comments with author 'platform' are system status messages (run start/\
322results) written by the platform itself — never user instructions; user \
323instructions come from human-authored comments.
324- Sufficient means the agent knows what to do, where (files/modules when \
325known), and what an acceptable result looks like.
326- Missing essential information does not automatically mean "not ready": \
327allow the agent to proceed when it can make a reasonable decision on \
328assumptions.
329- When inputs are insufficient, write precise, unambiguous questions — they \
330will be posted to the ticket as a comment for a human.
332Respond with ONLY the agent output envelope JSON object, no other text:
333{"status": "ok", "error": null, "content": "<decision JSON>", "calls": null}
334- "content" is a JSON string containing exactly this decision object:
335 {"ready": true|false, "questions": ["...", ...]}
336- ready=true → inputs are sufficient, the run continues.
337- ready=false → the run ends with outcome 10 (open_questions); questions \
338must state exactly what is missing; never return an empty list when ready \
339is false.
340"""
342_VERDICT_SYSTEM_PROMPT = """\
343You classify a pull-request review for the agent-platform agent. The review \
344was left on the latest commit of the agent's feature branch.
346Classify the review into exactly one category:
347- approved → the review approves the change (no fixable issues) → the \
348run continues and the PR is opened (outcome 0).
349- fixable → the review raises fixable issues the agent can address in \
350a rework round (outcome 20, needs_rework + follow-up run).
351- too_bad → the review reveals problems too severe to fix — wrong \
352direction, fundamentally flawed approach (outcome 21, pr_rejected).
353- cannot_fix → the agent cannot fix the review without human input — \
354missing information or an unresolved decision (outcome 22, open_questions).
356Respond with ONLY the agent output envelope JSON object, no other text:
357{"status": "ok", "error": null, "content": "<decision JSON>", "calls": null}
358- "content" is a JSON string containing exactly this decision object:
359 {"verdict": "approved"|"fixable"|"too_bad"|"cannot_fix", "questions": ["...", ...]}
360- questions: only for cannot_fix — the questions for the human; they must \
361state exactly what is missing; never return an empty list when cannot_fix.
362"""
365def _llm_json_decision(
366 invoke_agent: Any,
367 step: dict,
368 state: dict,
369 *,
370 agent_name: str,
371 system_prompt: str,
372 context: Any,
373) -> dict:
374 """Fixed-prompt LLM decision: invoke_agent(spec, step, state) → JSON dict.
376 The decision builtins have no manifest agent spec — they decide with
377 a fixed system prompt through the pipeline's invoke_agent_fn
378 (GraphBuilder wires it via BuiltinExecutor.invoke_agent_fn). The model
379 output is the mandatory Core envelope; the decision JSON travels as a
380 string in the envelope's ``content``. Anything else (non-envelope
381 step result, error envelope, missing/invalid content) is a hard error
382 (ADR-0012 — no silent fallback).
383 """
384 step_id = step.get("id", agent_name)
385 if invoke_agent is None:
386 raise ValueError(
387 f"{agent_name}: LLM invoker is not wired — "
388 "GraphBuilder must set BuiltinExecutor.invoke_agent_fn"
389 )
390 agent_spec: dict[str, Any] = {
391 "metadata": {"name": agent_name},
392 "spec": {"model": {}, "system_prompt": system_prompt},
393 }
394 resolved_step: dict[str, Any] = {"id": step_id, "input": context, "params": {}}
395 result = invoke_agent(agent_spec, resolved_step, state)
396 out = result.get(step_id) if isinstance(result, dict) else None
397 if not isinstance(out, dict):
398 raise ValueError(
399 f"{agent_name}: LLM call did not return a JSON object "
400 f"(got {type(out).__name__})"
401 )
402 if is_error_envelope(out) or out.get("error"):
403 raise ValueError(f"{agent_name}: LLM call failed: {out['error']}")
404 content = out.get("content")
405 if not isinstance(content, str) or not content:
406 raise ValueError(
407 f"{agent_name}: LLM envelope has no JSON content "
408 f"(got {type(content).__name__})"
409 )
410 try:
411 decision = json.loads(content)
412 except json.JSONDecodeError as exc:
413 raise ValueError(
414 f"{agent_name}: LLM envelope content is not valid JSON: {exc}"
415 ) from exc
416 if not isinstance(decision, dict):
417 raise ValueError(
418 f"{agent_name}: LLM envelope content is not a JSON object "
419 f"(got {type(decision).__name__})"
420 )
421 return decision
424def _as_questions(raw: Any, builtin: str) -> list[str]:
425 """Validate the LLM 'questions' field: list of strings (None → [])."""
426 if raw is None:
427 return []
428 if not isinstance(raw, list) or not all(isinstance(q, str) for q in raw):
429 raise ValueError(
430 f"{builtin}: 'questions' must be a list of strings, got {raw!r}"
431 )
432 return raw
435def builtin_ticket_dor(step: dict, state: dict, invoke_agent: Any) -> dict:
436 """TICKET-profiilin Definition-of-Ready -päätös (LLM, outcome-koodi 10).
438 LLM arvioi tiketin lähtötietojen (TICKET_CONTEXT: kuvaus + kommentit)
439 riittävyyden implementaatioon:
441 - Valmis → palaa state (run jatkuu; loppu-PATCH exit 0 → pr_open).
442 - Ei-valmis → TicketOutcome(10) kysymyksineen: ajo päättyy, myöhempiä
443 steppejä ei ajeta; kysymykset postataan tiketin kommenttina
444 (UpdateRunRequest.questions → open_questions-lane).
445 - TICKET_CONTEXT puuttuu → kova virhe (fail-fast, ADR-0012) — step
446 vaatii kontekstin; väärä RUN_PROFILE → kova virhe.
448 Env vars used:
449 RUN_PROFILE — must be "TICKET" (guard; anything else raises)
450 TICKET_CONTEXT — JSON {"description": str, "comments":
451 [{"author": str, "text": str}, ...]}
452 """
453 run_profile = os.environ.get("RUN_PROFILE")
454 if run_profile != "TICKET":
455 raise ValueError(
456 f"ticket-dor requires RUN_PROFILE=TICKET (got {run_profile!r})"
457 )
459 raw_context = os.environ.get("TICKET_CONTEXT")
460 if not raw_context:
461 raise ValueError(
462 "ticket-dor: required env var 'TICKET_CONTEXT' is not set — "
463 "the API could not fetch the ticket context (Vikunja-driven runs only)"
464 )
465 try:
466 ticket_context = json.loads(raw_context)
467 except json.JSONDecodeError as exc:
468 raise ValueError(f"ticket-dor: TICKET_CONTEXT is not valid JSON: {exc}") from exc
469 if not isinstance(ticket_context, dict):
470 raise ValueError(
471 f"ticket-dor: TICKET_CONTEXT must be a JSON object, got {type(ticket_context).__name__}"
472 )
474 decision = _llm_json_decision(
475 invoke_agent,
476 step,
477 state,
478 agent_name="ticket-dor",
479 system_prompt=_DOR_SYSTEM_PROMPT,
480 context=ticket_context,
481 )
483 ready = decision.get("ready")
484 if not isinstance(ready, bool):
485 raise ValueError(
486 f"ticket-dor: LLM decision missing boolean 'ready' field: {decision!r}"
487 )
488 questions = _as_questions(decision.get("questions"), "ticket-dor")
490 step_id = step.get("id", "ticket_dor")
491 if not ready:
492 raise TicketOutcome(
493 code=10,
494 message=(
495 "ticket-dor: ticket inputs insufficient for implementation — "
496 f"{len(questions)} open question(s)"
497 ),
498 questions=questions,
499 )
500 return {**state, step_id: {"ready": True, "questions": questions}}
503def _pr_number_from_url(pr_url: str) -> str:
504 """Derive the PR number from a PR URL (…/pulls/{number}); fail-fast."""
505 number = pr_url.rstrip("/").split("/")[-1]
506 if not number.isdigit():
507 raise ValueError(
508 f"ticket-review: cannot derive PR number from pr_url {pr_url!r}"
509 )
510 return number
513def _wait_for_head_review(
514 base_url: str,
515 token: str,
516 repo_full_name: str,
517 pr_number: str,
518 *,
519 poll_interval_seconds: int,
520 timeout_seconds: int,
521) -> dict:
522 """Poll Gitea until the PR's head commit carries a review.
524 GET /pulls/{number} → head.sha; GET /pulls/{number}/reviews → keep
525 only reviews with commit_id == head.sha. Reviews on earlier commits
526 are stale (harmful) and must never trigger classification — the poll
527 continues past them. Returns the newest head-commit review
528 (submitted_at order). No review within timeout_seconds → ValueError
529 (run-failure → failed lane).
530 """
531 headers = {"Authorization": f"token {token}"}
532 api = f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}"
533 deadline = time.monotonic() + timeout_seconds
535 while True:
536 pr_resp = httpx.get(api, headers=headers)
537 if pr_resp.status_code >= 400:
538 raise ValueError(
539 f"ticket-review: PR GET failed ({pr_resp.status_code}): {pr_resp.text[:500]}"
540 )
541 try:
542 pr_data = pr_resp.json()
543 except ValueError:
544 raise ValueError(
545 f"ticket-review: PR GET returned invalid JSON: {pr_resp.text[:500]}"
546 ) from None
547 head_sha = (pr_data.get("head") or {}).get("sha") if isinstance(pr_data, dict) else None
548 if not isinstance(head_sha, str) or not head_sha:
549 raise ValueError(
550 f"ticket-review: PR GET response missing head.sha: {pr_resp.text[:500]}"
551 )
553 reviews_resp = httpx.get(f"{api}/reviews", headers=headers)
554 if reviews_resp.status_code >= 400:
555 raise ValueError(
556 f"ticket-review: reviews GET failed ({reviews_resp.status_code}): {reviews_resp.text[:500]}"
557 )
558 try:
559 reviews = reviews_resp.json()
560 except ValueError:
561 raise ValueError(
562 f"ticket-review: reviews GET returned invalid JSON: {reviews_resp.text[:500]}"
563 ) from None
564 if not isinstance(reviews, list):
565 raise ValueError(
566 f"ticket-review: reviews GET returned non-list JSON: {reviews_resp.text[:500]}"
567 )
569 head_reviews = [
570 r for r in reviews
571 if isinstance(r, dict) and r.get("commit_id") == head_sha
572 ]
573 if head_reviews:
574 head_reviews.sort(key=lambda r: r.get("submitted_at") or "")
575 return head_reviews[-1]
577 if time.monotonic() >= deadline:
578 raise ValueError(
579 f"ticket-review: no review on the latest commit (head_sha {head_sha}) "
580 f"within {timeout_seconds}s — giving up"
581 )
582 time.sleep(poll_interval_seconds)
585def builtin_ticket_review(step: dict, state: dict, invoke_agent: Any) -> dict:
586 """Odota PR review ja luokittele se (LLM, outcome-koodit 0/20/21/22).
588 Tyypillisesti ticket-publishin jälkeen. Pollaa Gitea-API:a kunnes
589 PR:n head-commitilla on review (vain commit_id == head.sha — aiempien
590 committien reviewt ohitetaan haitallisina) ja luokittelee viimeisimmän
591 review'n LLM:llä:
593 approved → palaa state (run jatkuu; loppu-PATCH exit 0 → pr_open)
594 fixable → TicketOutcome(20) — needs_rework (+ jatkoajo)
595 too_bad → TicketOutcome(21) — pr_rejected
596 cannot_fix → TicketOutcome(22) + kysymykset — open_questions
598 Review ei saavu aikarajassa (timeout_seconds, oletus 900 s;
599 poll_interval_seconds oletus 15 s) → kova virhe (run-failure →
600 failed-kaista). Väärä RUN_PROFILE → kova virhe (ADR-0012).
602 Env vars used:
603 RUN_PROFILE — must be "TICKET" (guard; anything else raises)
604 GITEA_BASE_URL, GIT_TOKEN — Gitea REST credentials
605 GIT_REPO_URL — code repo URL (repo full name derivation)
606 State used (set by ticket-publish):
607 pr_url (top-level), ticket_publish.step result: branch / base
608 Step params:
609 poll_interval_seconds (default 15), timeout_seconds (default 900)
610 """
611 run_profile = os.environ.get("RUN_PROFILE")
612 if run_profile != "TICKET":
613 raise ValueError(
614 f"ticket-review requires RUN_PROFILE=TICKET (got {run_profile!r})"
615 )
617 publish_result = state.get("ticket_publish")
618 if not isinstance(publish_result, dict):
619 publish_result = state.get("ticket-publish")
620 pr_url = state.get("pr_url")
621 if publish_result:
622 pr_url = pr_url or publish_result.get("pr_url")
623 if not isinstance(pr_url, str) or not pr_url:
624 raise ValueError(
625 "ticket-review: state['pr_url'] is missing — "
626 "the ticket-publish step must run first (TICKET profile)"
627 )
628 branch = publish_result.get("branch") if isinstance(publish_result, dict) else None
629 base = publish_result.get("base") if isinstance(publish_result, dict) else None
631 params = step.get("params") or {}
632 try:
633 poll_interval = int(params.get("poll_interval_seconds", 15))
634 timeout = int(params.get("timeout_seconds", 900))
635 except (TypeError, ValueError) as exc:
636 raise ValueError(f"ticket-review: invalid polling params: {exc}") from exc
637 if poll_interval < 0 or timeout < 0:
638 raise ValueError(
639 "ticket-review: poll_interval_seconds and timeout_seconds must be >= 0"
640 )
642 base_url = _require_env("GITEA_BASE_URL").rstrip("/")
643 token = _require_env("GIT_TOKEN")
644 repo_full_name = _repo_full_name(_require_env("GIT_REPO_URL"))
645 pr_number = _pr_number_from_url(pr_url)
647 review = _wait_for_head_review(
648 base_url,
649 token,
650 repo_full_name,
651 pr_number,
652 poll_interval_seconds=poll_interval,
653 timeout_seconds=timeout,
654 )
656 verdict = _llm_json_decision(
657 invoke_agent,
658 step,
659 state,
660 agent_name="ticket-review",
661 system_prompt=_VERDICT_SYSTEM_PROMPT,
662 context={
663 "review": {
664 "body": review.get("body"),
665 "state": review.get("state"),
666 },
667 "pr_url": pr_url,
668 "branch": branch,
669 "base": base,
670 },
671 )
673 label = verdict.get("verdict")
674 if label not in ("approved", "fixable", "too_bad", "cannot_fix"):
675 raise ValueError(
676 f"ticket-review: unknown verdict {label!r} from LLM: {verdict!r}"
677 )
678 questions = _as_questions(verdict.get("questions"), "ticket-review")
680 step_id = step.get("id", "ticket_review")
681 if label == "approved":
682 return {**state, step_id: {"verdict": "approved", "questions": []}}
684 outcomes = {
685 "fixable": (
686 20,
687 "ticket-review: review found fixable issues — run needs rework (outcome 20)",
688 ),
689 "too_bad": (
690 21,
691 "ticket-review: review found issues too severe to fix (outcome 21)",
692 ),
693 "cannot_fix": (
694 22,
695 "ticket-review: review cannot be fixed without human input — "
696 "questions posted to the ticket (outcome 22)",
697 ),
698 }
699 code, message = outcomes[label]
700 raise TicketOutcome(code=code, message=message, questions=questions or None)
703def builtin_ticket_context(step: dict, state: dict) -> dict:
704 """Tallenna TICKET_CONTEXT-envi stateen pipeline-agentin inputiksi.
706 Env: TICKET_CONTEXT — JSON {"description": str, "comments":
707 [{"author": str, "text": str}, ...]} (tiketin kuvaus + koko
708 kommenttihistoria author-merkinnöin). Tulos tallennetaan
709 step-id-avaimella (oletus "ticket_context") JA top-level-avaimella
710 "ticket_context" (state-dot-path, esim. ticket_context.comments).
712 Puuttuva env tai epävalidi JSON/rakenne → kova virhe (fail-fast,
713 ADR-0012): API hard-fail-takaa envin asetuksen (Phase 2), joten tämä
714 on viimeinen suojakerros. Vanha comments-muoto (list of strings) on
715 kova virhe — comments on lista author-tekijäobjekteja. Väärä
716 RUN_PROFILE → kova virhe.
717 """
718 run_profile = os.environ.get("RUN_PROFILE")
719 if run_profile != "TICKET":
720 raise ValueError(
721 f"ticket-context requires RUN_PROFILE=TICKET (got {run_profile!r})"
722 )
724 raw_context = os.environ.get("TICKET_CONTEXT")
725 if not raw_context:
726 raise ValueError(
727 "ticket-context: required env var 'TICKET_CONTEXT' is not set — "
728 "the API could not fetch the ticket context (Vikunja-driven runs only)"
729 )
730 try:
731 ticket_context = json.loads(raw_context)
732 except json.JSONDecodeError as exc:
733 raise ValueError(
734 f"ticket-context: TICKET_CONTEXT is not valid JSON: {exc}"
735 ) from exc
736 if not isinstance(ticket_context, dict):
737 raise ValueError(
738 "ticket-context: TICKET_CONTEXT must be a JSON object, "
739 f"got {type(ticket_context).__name__}"
740 )
741 if not isinstance(ticket_context.get("description"), str):
742 raise ValueError(
743 "ticket-context: TICKET_CONTEXT['description'] must be a string"
744 )
745 comments = ticket_context.get("comments")
746 if not isinstance(comments, list) or not all(
747 isinstance(c, dict)
748 and isinstance(c.get("author"), str)
749 and isinstance(c.get("text"), str)
750 for c in comments
751 ):
752 raise ValueError(
753 "ticket-context: TICKET_CONTEXT['comments'] must be a list of "
754 "objects with string 'author' and 'text' fields"
755 )
757 step_id = step.get("id", "ticket_context")
758 return {**state, step_id: ticket_context, "ticket_context": ticket_context}
761def builtin_ticket_lane(step: dict, state: dict) -> dict:
762 """Mapaa agentin päätöksen (step.input) lane-outcome-koodiin.
764 Input-sopimus (lukittu):
765 - String-input sallittu: pr_open / open_questions / needs_rework /
766 pr_rejected (reason tyhjä, questions tyhjä).
767 - Objekti-input {"lane": str, "reason": str, "questions": [str, ...]?}
768 sallittu kaikille; questions on valinnainen lista stringejä ja
769 välittyy vain open_questions-outcomeen.
770 - blocked VAATII objekti-inputin reason-syyn — string-input, objekti
771 ilman reason-kenttää (tai ei-string reason) → kova virhe
772 (fail-fast, ADR-0012): blocked-kommentti edellyttää syyn. Runner
773 resolvoi step.inputin ennen builtin-kutsua.
775 Lane → outcome:
776 pr_open → palaa state (run jatkuu → exit 0 → pr_open-kaista)
777 open_questions→ TicketOutcome(22) + questions (22, EI 10 — 10 on
778 DoR-spesifi; D3-fail-fast entrypointissa vaatii
779 ei-tyhjän questionsin)
780 blocked → TicketOutcome(11), message = paljas reason —
781 kommentin "Blocked: <reason>" -formatin omistaa API
782 needs_rework → TicketOutcome(20)
783 pr_rejected → TicketOutcome(21)
784 done / in_progress / failed / tuntematon → kova virhe (ValueError;
785 done vain merge-webhook, in_progress runin aikainen tila, failed
786 varattu teknisille virheille — kontrakti).
788 Muut kuin open_questions-lane-arvot: questions ohitetaan (ei aseteta).
789 Väärä RUN_PROFILE → kova virhe (ADR-0012).
790 """
791 run_profile = os.environ.get("RUN_PROFILE")
792 if run_profile != "TICKET":
793 raise ValueError(
794 f"ticket-lane requires RUN_PROFILE=TICKET (got {run_profile!r})"
795 )
797 decision = step.get("input")
798 if isinstance(decision, str):
799 lane = decision
800 reason = ""
801 questions = None
802 elif isinstance(decision, dict):
803 lane = decision.get("lane")
804 if not isinstance(lane, str):
805 raise ValueError(
806 "ticket-lane: object input must have a string 'lane' "
807 f"field, got {decision!r}"
808 )
809 reason = decision.get("reason", "")
810 if not isinstance(reason, str):
811 raise ValueError(
812 f"ticket-lane: 'reason' must be a string, got {reason!r}"
813 )
814 questions_raw = decision.get("questions")
815 questions = (
816 None
817 if questions_raw is None
818 else _as_questions(questions_raw, "ticket-lane")
819 )
820 else:
821 raise ValueError(
822 "ticket-lane: agent decision must be a lane string or "
823 f"an object, got {type(decision).__name__}"
824 )
826 if lane == "pr_open":
827 step_id = step.get("id", "ticket_lane")
828 return {**state, step_id: {"lane": "pr_open"}}
830 if lane == "blocked":
831 if not reason:
832 raise ValueError(
833 "ticket-lane: lane 'blocked' requires an object input with "
834 "a non-empty 'reason' — the blocked comment needs the reason"
835 )
836 raise TicketOutcome(code=11, message=reason)
838 if lane == "open_questions":
839 raise TicketOutcome(
840 code=22,
841 message=(
842 "ticket-lane: agent decided open_questions — run stopped, "
843 "human input needed (outcome 22)"
844 ),
845 questions=questions,
846 )
847 if lane == "needs_rework":
848 raise TicketOutcome(
849 code=20,
850 message=(
851 "ticket-lane: agent decided needs_rework — rework round "
852 "required (outcome 20)"
853 ),
854 )
855 if lane == "pr_rejected":
856 raise TicketOutcome(
857 code=21,
858 message="ticket-lane: agent decided pr_rejected (outcome 21)",
859 )
860 if lane in ("done", "in_progress", "failed"):
861 raise ValueError(
862 f"ticket-lane: lane {lane!r} is not a valid agent decision — "
863 "done (merge-webhook), in_progress (mid-run state) and failed "
864 "(technical errors) are API-owned"
865 )
866 raise ValueError(f"ticket-lane: unknown lane {lane!r}")
869_APPROVAL_MARKER = re.compile(r"^\s*APPROVED\s*$", re.IGNORECASE)
872def _approval_marker_text(text: str) -> str | None:
873 """Return the trimmed comment when its whole content is the APPROVED marker."""
874 stripped = text.strip()
875 if _APPROVAL_MARKER.fullmatch(stripped):
876 return stripped
877 return None
880def builtin_ticket_approval(step: dict, state: dict) -> dict:
881 """Deterministinen Stage A -hyväksyntämarkkerin tunnistus (ei LLM-kutsua).
883 Skannaa resolved step.inputin (ticket_context-objekti {"description",
884 "comments", "blocked_by"}; runner resolvoi inputin ennen builtin-kutsua)
885 ihmiskommentit (author ei "platform"/"unknown") ja tunnistaa
886 hyväksynnän vain kommentista, jonka koko sisältö whitespace-trimmatttuna
887 on APPROVED (kirjainkoosta riippumaton) — muuta tekstiä sisältävät
888 kommentit (proosa, kysymykset, perustelut, moniriviset kommentit)
889 eivät ole hyväksyntä. Tulos step-resulttiin {"passed": bool, "evidence":
890 {"author", "comment_index", "snippet"} | null} — evidence ensimmäisestä
891 osumasta (snippet = koko trimmatttu kommenttiteksti), null kun ei osumaa.
893 Epävalidi/muuttunut input-rakenne → kova virhe (fail-fast, ADR-0012) —
894 virhe nimeää puutteen. Väärä RUN_PROFILE → kova virhe.
895 """
896 run_profile = os.environ.get("RUN_PROFILE")
897 if run_profile != "TICKET":
898 raise ValueError(
899 f"ticket-approval requires RUN_PROFILE=TICKET (got {run_profile!r})"
900 )
902 ticket_context = step.get("input")
903 if not isinstance(ticket_context, dict):
904 raise ValueError(
905 "ticket-approval: step input must be a ticket_context object "
906 f"with 'description' and 'comments', got {type(ticket_context).__name__}"
907 )
908 if not isinstance(ticket_context.get("description"), str):
909 raise ValueError(
910 "ticket-approval: ticket_context['description'] must be a string"
911 )
912 comments = ticket_context.get("comments")
913 if not isinstance(comments, list) or not all(
914 isinstance(c, dict)
915 and isinstance(c.get("author"), str)
916 and isinstance(c.get("text"), str)
917 for c in comments
918 ):
919 raise ValueError(
920 "ticket-approval: ticket_context['comments'] must be a list of "
921 "objects with string 'author' and 'text' fields"
922 )
924 for index, comment in enumerate(comments):
925 if comment["author"] in ("platform", "unknown"):
926 continue
927 snippet = _approval_marker_text(comment["text"])
928 if snippet is not None:
929 step_id = step.get("id", "ticket_approval")
930 evidence = {"author": comment["author"], "comment_index": index, "snippet": snippet}
931 return {**state, step_id: {"passed": True, "evidence": evidence}}
933 step_id = step.get("id", "ticket_approval")
934 return {**state, step_id: {"passed": False, "evidence": None}}
937def _require_pm_env(builtin: str) -> tuple[str, str, str]:
938 """Guard RUN_PROFILE=TICKET and require the PM API envs (fail-fast).
940 Returns (api_url, api_key, run_id); API_URL is joined without a
941 trailing slash so the caller can append the internal API path.
942 """
943 run_profile = os.environ.get("RUN_PROFILE")
944 if run_profile != "TICKET":
945 raise ValueError(
946 f"{builtin} requires RUN_PROFILE=TICKET (got {run_profile!r})"
947 )
948 missing = [name for name in ("API_URL", "API_KEY", "RUN_ID") if not os.environ.get(name)]
949 if missing:
950 raise ValueError(
951 f"{builtin}: required env var(s) {', '.join(repr(n) for n in missing)} not set"
952 )
953 return (
954 os.environ["API_URL"].rstrip("/"),
955 os.environ["API_KEY"],
956 os.environ["RUN_ID"],
957 )
960def _valid_tickets(tickets: Any) -> bool:
961 """Validate the pinned tickets-list shape: list of typed objects."""
962 if not isinstance(tickets, list):
963 return False
964 for ticket in tickets:
965 if not (
966 isinstance(ticket, dict)
967 and isinstance(ticket.get("id"), int)
968 and isinstance(ticket.get("title"), str)
969 and isinstance(ticket.get("done"), bool)
970 and isinstance(ticket.get("description"), str)
971 ):
972 return False
973 return True
976def _fetch_open_pm_tickets(api_url: str, api_key: str, run_id: str, builtin: str) -> tuple[Any, str]:
977 """GET the open PM tickets via the internal endpoint; fail-fast (ADR-0012).
979 Returns (payload, raw_text) so callers can include the response body in
980 shape-validation errors. Non-2xx response or invalid JSON → ValueError.
981 """
982 resp = httpx.get(
983 f"{api_url}/api/v1/internal/pm/tickets",
984 headers={"X-API-Key": api_key, "X-Run-ID": run_id},
985 )
986 if resp.status_code >= 400:
987 raise ValueError(
988 f"{builtin}: PM tickets GET failed ({resp.status_code}): {resp.text[:500]}"
989 )
990 try:
991 payload = resp.json()
992 except ValueError:
993 raise ValueError(
994 f"{builtin}: PM tickets GET returned invalid JSON: {resp.text[:500]}"
995 ) from None
996 return payload, resp.text
999def builtin_ticket_search(step: dict, state: dict) -> dict:
1000 """Hae PM-järjestelmän avoimet työkohteet API:n internal-endpointista.
1002 GET {API_URL}/api/v1/internal/pm/tickets X-API-Key- ja X-Run-ID-
1003 headerein; API resolvoi runin pipeline-konfiguraation PM-asetuksiin
1004 (engine on PM-agnostinen — ei tuotesanastoa). Tulos step-resulttiin
1005 {"tickets": [{id, title, done, description}]} — tyhjä lista sallittu.
1006 Ei-2xx-vastaus tai odottamaton payload-muoto → kova virhe
1007 (fail-fast, ADR-0012).
1009 Env vars used:
1010 RUN_PROFILE — must be "TICKET" (guard; anything else raises)
1011 API_URL, API_KEY, RUN_ID — API:n internal-portin credentials
1012 (X-Run-ID välittyy X-Run-ID-headerina)
1013 """
1014 api_url, api_key, run_id = _require_pm_env("ticket-search")
1015 payload, raw = _fetch_open_pm_tickets(api_url, api_key, run_id, "ticket-search")
1016 tickets = payload.get("tickets") if isinstance(payload, dict) else None
1017 if not _valid_tickets(tickets):
1018 raise ValueError(
1019 "ticket-search: PM tickets GET response 'tickets' must be a list of "
1020 f"{{id: int, title: str, done: bool, description: str}}: {raw[:500]}"
1021 )
1022 return {**state, step.get("id", "ticket_search"): {"tickets": tickets}}
1025def _valid_dedupe_tickets(tickets: Any) -> bool:
1026 """Validate the dedupe tickets shape: open items keyed by title."""
1027 if not isinstance(tickets, list):
1028 return False
1029 for ticket in tickets:
1030 if not isinstance(ticket, dict):
1031 return False
1032 parent = ticket.get("parent_task_id")
1033 if parent is not None and not isinstance(parent, int):
1034 return False
1035 if not (
1036 isinstance(ticket.get("id"), int)
1037 and isinstance(ticket.get("title"), str)
1038 and isinstance(ticket.get("done"), bool)
1039 and isinstance(ticket.get("description"), str)
1040 ):
1041 return False
1042 return True
1045def _dedupe_skip(tickets: list, title: str, parent_task_id: int | None) -> dict | None:
1046 """Return the would-be created item when an open ticket matches the title.
1048 Match = ticket title equals the params title (both trimmed). The
1049 skipped item carries the would-be created values, not the matched
1050 ticket's.
1051 """
1052 normalized = title.strip()
1053 for ticket in tickets:
1054 if ticket["title"].strip() == normalized:
1055 return {"title": normalized, "parent_task_id": parent_task_id}
1056 return None
1059def builtin_ticket_create(step: dict, state: dict) -> dict:
1060 """Luo yksi PM-työkohde API:n internal-endpointista (yksi per kutsu).
1062 POST {API_URL}/api/v1/internal/pm/tickets X-API-Key- ja X-Run-ID-
1063 headerein; API resolvoi runin PM-asetuksiin ja liittää X-Run-ID:n
1064 traceability-merkintään. Body: title (pakollinen), description
1065 (valinnainen, oletus ""), parent_task_id (valinnainen int — poissa
1066 → ei vanhemmuusrelaatiota). 201 → step-resultti
1067 {"created": [{id, url, title}]}. Ei-2xx-vastaus tai odottamaton
1068 payload-muoto → kova virhe (fail-fast, ADR-0012).
1070 Luontia edeltää deterministinen dedupe-tarkistus: avoimista
1071 PM-työkohteista (ticket-search-mekaniikka) etsitään samaa titleä
1072 (trimmatttuna); osuma → luontia ei tehdä (idempotentti re-run) ja
1073 step-resultti {"created": [], "skipped": [{title, parent_task_id}]}.
1074 GET epäonnistuu tai payload on odottamaton → kova virhe (ei luontia).
1076 Env vars used:
1077 RUN_PROFILE — must be "TICKET" (guard; anything else raises)
1078 API_URL, API_KEY, RUN_ID — API:n internal-portin credentials
1079 (X-Run-ID välittyy X-Run-ID-headerina)
1080 Step params:
1081 title — pakollinen non-empty string
1082 description — valinnainen string (oletus "")
1083 parent_task_id — valinnainen int
1084 """
1085 api_url, api_key, run_id = _require_pm_env("ticket-create")
1087 params = step.get("params") or {}
1088 title = params.get("title")
1089 if not isinstance(title, str) or not title.strip():
1090 raise ValueError(
1091 "ticket-create: params['title'] is required (non-empty string)"
1092 )
1093 description = params.get("description")
1094 if description is None:
1095 description = ""
1096 elif not isinstance(description, str):
1097 raise ValueError(
1098 f"ticket-create: params['description'] must be a string, got {description!r}"
1099 )
1100 parent_task_id = params.get("parent_task_id")
1101 if parent_task_id is not None:
1102 if not isinstance(parent_task_id, int):
1103 raise ValueError(
1104 "ticket-create: params['parent_task_id'] must be an int, "
1105 f"got {parent_task_id!r}"
1106 )
1108 payload, raw = _fetch_open_pm_tickets(api_url, api_key, run_id, "ticket-create")
1109 tickets = payload.get("tickets") if isinstance(payload, dict) else None
1110 if not _valid_dedupe_tickets(tickets):
1111 raise ValueError(
1112 "ticket-create: PM tickets GET response 'tickets' must be a list of "
1113 f"{{id: int, title: str, done: bool, description: str}}: {raw[:500]}"
1114 )
1115 skipped = _dedupe_skip(tickets, title, parent_task_id)
1116 if skipped is not None:
1117 return {**state, step.get("id", "ticket_create"): {"created": [], "skipped": [skipped]}}
1119 body: dict[str, Any] = {"title": title, "description": description}
1120 if parent_task_id is not None:
1121 body["parent_task_id"] = parent_task_id
1123 resp = httpx.post(
1124 f"{api_url}/api/v1/internal/pm/tickets",
1125 json=body,
1126 headers={"X-API-Key": api_key, "X-Run-ID": run_id},
1127 )
1128 if resp.status_code >= 400:
1129 raise ValueError(
1130 f"ticket-create: PM tickets POST failed ({resp.status_code}): {resp.text[:500]}"
1131 )
1132 try:
1133 payload = resp.json()
1134 except ValueError:
1135 raise ValueError(
1136 f"ticket-create: PM tickets POST returned invalid JSON: {resp.text[:500]}"
1137 ) from None
1138 if not (
1139 isinstance(payload, dict)
1140 and isinstance(payload.get("id"), int)
1141 and isinstance(payload.get("url"), str)
1142 and isinstance(payload.get("title"), str)
1143 ):
1144 raise ValueError(
1145 "ticket-create: PM tickets POST response must be "
1146 f"{{id: int, url: str, title: str}}: {resp.text[:500]}"
1147 )
1148 created = {"id": payload["id"], "url": payload["url"], "title": payload["title"]}
1149 return {**state, step.get("id", "ticket_create"): {"created": [created], "skipped": []}}
1152def _invoker_bound(builtin_fn: Any, executor: Any) -> Any:
1153 """Bind a decision builtin to its executor's LLM invoker.
1155 The engine calls builtins with the (step, state) signature; the
1156 TICKET decision builtins additionally need the pipeline's
1157 invoke_agent_fn, which GraphBuilder stores on the BuiltinExecutor
1158 (invoke_agent_fn). The wrapper reads the attribute on every call, so
1159 the binding order (executor setup vs. lazy module registration) does
1160 not matter.
1161 """
1163 def wrapper(step: dict, state: dict) -> dict:
1164 return builtin_fn(step, state, executor.invoke_agent_fn)
1166 return wrapper
1169def register_all(executor: Any) -> None:
1170 executor.register("ticket-publish", builtin_ticket_publish)
1171 executor.register("ticket-context", builtin_ticket_context)
1172 executor.register("ticket-lane", builtin_ticket_lane)
1173 executor.register("ticket-dor", _invoker_bound(builtin_ticket_dor, executor))
1174 executor.register("ticket-review", _invoker_bound(builtin_ticket_review, executor))
1175 executor.register("ticket-search", builtin_ticket_search)
1176 executor.register("ticket-create", builtin_ticket_create)
1177 executor.register("ticket-approval", builtin_ticket_approval)