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