Coverage for core/langgraph/_builtins_ticket.py: 93%

414 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-16 06:41 +0000

1"""_builtins_ticket.py — TICKET-profile builtins (lazy-loaded, needs httpx). 

2 

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). 

13 

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. 

18 

19Decision builtins (ticket-dor / ticket-review) stop the run with 

20outcome-coded TicketOutcome exceptions instead of returning state: 

21 

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 | 

30 

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 

39 

40import json 

41import os 

42import re 

43import subprocess 

44import time 

45from typing import Any 

46from urllib.parse import urlparse 

47 

48import httpx 

49 

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 

54 

55_COMMIT_AUTHOR_NAME = "agent-platform" 

56_COMMIT_AUTHOR_EMAIL = "agent-platform@localhost" 

57_COMMIT_MESSAGE_PREFIX = "agent-platform:" 

58 

59 

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 

65 

66 

67def _repo_full_name(git_repo_url: str) -> str: 

68 """Derive owner/repo from the code-repo URL (repo fact). 

69 

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 

81 

82 

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 

98 

99 

100def _git_commit_and_push(project_dir: str, branch: str) -> bool: 

101 """Commit the pipeline output and push the feature branch. 

102 

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 

128 

129 

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). 

141 

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" 

148 

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 

154 

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" 

163 

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" 

182 

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" 

203 

204 

205def builtin_ticket_publish(step: dict, state: dict) -> dict: 

206 """Publish the pipeline output for a TICKET run: commit → push → PR. 

207 

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. 

216 

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). 

221 

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 ) 

238 

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 ) 

252 

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 "" 

256 

257 pushed = _git_commit_and_push(project_dir, branch) 

258 

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} 

267 

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 } 

286 

287 

288class TicketOutcome(Exception): 

289 """Outcome-coded stop for TICKET-profile runs (exit-code lanes). 

290 

291 Raised by the decision builtins (ticket-dor / ticket-review) to end 

292 the run with a defined outcome instead of a generic failure: 

293 

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 """ 

303 

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 

314 

315 

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. 

319 

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. 

331 

332Respond with ONLY the agent output envelope JSON object, no other text: 

333{"status": "ok", "error": null, "payload": "<decision JSON>", "calls": null} 

334- "payload" 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""" 

341 

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. 

345 

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). 

355 

356Respond with ONLY the agent output envelope JSON object, no other text: 

357{"status": "ok", "error": null, "payload": "<decision JSON>", "calls": null} 

358- "payload" 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""" 

363 

364 

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. 

375 

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 ``payload``. Anything else (non-envelope 

381 step result, error envelope, missing/invalid payload) 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 payload = out.get("payload") 

405 if not isinstance(payload, str) or not payload: 

406 raise ValueError( 

407 f"{agent_name}: LLM envelope has no JSON payload " 

408 f"(got {type(payload).__name__})" 

409 ) 

410 try: 

411 decision = json.loads(payload) 

412 except json.JSONDecodeError as exc: 

413 raise ValueError( 

414 f"{agent_name}: LLM envelope payload is not valid JSON: {exc}" 

415 ) from exc 

416 if not isinstance(decision, dict): 

417 raise ValueError( 

418 f"{agent_name}: LLM envelope payload is not a JSON object " 

419 f"(got {type(decision).__name__})" 

420 ) 

421 return decision 

422 

423 

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 

433 

434 

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). 

437 

438 LLM arvioi tiketin lähtötietojen (TICKET_CONTEXT: kuvaus + kommentit) 

439 riittävyyden implementaatioon: 

440 

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. 

447 

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 ) 

458 

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 ) 

473 

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 ) 

482 

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") 

489 

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}} 

501 

502 

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 

511 

512 

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. 

523 

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 

534 

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 ) 

552 

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 ) 

568 

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] 

576 

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) 

583 

584 

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). 

587 

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ä: 

592 

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 

597 

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). 

601 

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 ) 

616 

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 

630 

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 ) 

641 

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) 

646 

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 ) 

655 

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 ) 

672 

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") 

679 

680 step_id = step.get("id", "ticket_review") 

681 if label == "approved": 

682 return {**state, step_id: {"verdict": "approved", "questions": []}} 

683 

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) 

701 

702 

703def builtin_ticket_context(step: dict, state: dict) -> dict: 

704 """Tallenna TICKET_CONTEXT-envi stateen pipeline-agentin inputiksi. 

705 

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). 

711 

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 ) 

723 

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 ) 

756 

757 step_id = step.get("id", "ticket_context") 

758 return {**state, step_id: ticket_context, "ticket_context": ticket_context} 

759 

760 

761def builtin_ticket_lane(step: dict, state: dict) -> dict: 

762 """Mapaa agentin päätöksen (step.input) lane-outcome-koodiin. 

763 

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. 

774 

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). 

787 

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 ) 

796 

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 ) 

825 

826 if lane == "pr_open": 

827 step_id = step.get("id", "ticket_lane") 

828 return {**state, step_id: {"lane": "pr_open"}} 

829 

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) 

837 

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}") 

867 

868 

869_APPROVAL_MARKER = re.compile(r"^\s*APPROVED\s*$", re.IGNORECASE) 

870 

871 

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 

878 

879 

880def builtin_ticket_approval(step: dict, state: dict) -> dict: 

881 """Deterministinen Stage A -hyväksyntämarkkerin tunnistus (ei LLM-kutsua). 

882 

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. 

892 

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 ) 

901 

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 ) 

923 

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}} 

932 

933 step_id = step.get("id", "ticket_approval") 

934 return {**state, step_id: {"passed": False, "evidence": None}} 

935 

936 

937def _require_pm_env(builtin: str) -> tuple[str, str, str]: 

938 """Guard RUN_PROFILE=TICKET and require the PM API envs (fail-fast). 

939 

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 ) 

958 

959 

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 

974 

975 

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). 

978 

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 

997 

998 

999def builtin_ticket_search(step: dict, state: dict) -> dict: 

1000 """Hae PM-järjestelmän avoimet työkohteet API:n internal-endpointista. 

1001 

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). 

1008 

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}} 

1023 

1024 

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 

1043 

1044 

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. 

1047 

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 

1057 

1058 

1059def builtin_ticket_create(step: dict, state: dict) -> dict: 

1060 """Luo yksi PM-työkohde API:n internal-endpointista (yksi per kutsu). 

1061 

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). 

1069 

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). 

1075 

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") 

1086 

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 ) 

1107 

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]}} 

1118 

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 

1122 

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": []}} 

1150 

1151 

1152def _invoker_bound(builtin_fn: Any, executor: Any) -> Any: 

1153 """Bind a decision builtin to its executor's LLM invoker. 

1154 

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 """ 

1162 

1163 def wrapper(step: dict, state: dict) -> dict: 

1164 return builtin_fn(step, state, executor.invoke_agent_fn) 

1165 

1166 return wrapper 

1167 

1168 

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)