Coverage for core/langgraph/tools/ticket_review.py: 91%
112 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 05:04 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-25 05:04 +0000
1"""Per-tool module for the `ticket-review` PR-review classification builtin.
3Owns the tool's model-facing definition, its instruction surface (none) and the
4LLM-backed PR-review verdict itself. The definition and the execution here are
5the single source; the legacy builtin site delegates to the pure `ticket_review`
6handler.
8The classification needs the pipeline's LLM invoker. On the protocol path the
9runtime supplies it through `context.invoker`; on the
10BuiltinExecutor path the legacy builtin module binds it via `_invoker_bound`,
11which reads `executor.invoke_agent_fn` on every call.
12"""
13from __future__ import annotations
15import os
16import time
17from typing import Any
19from .contract import ToolContext, ToolResult
21try:
22 from tools._ticket_common import (
23 TicketOutcome, _as_questions, _hard_failure, _http_get,
24 _llm_json_decision, _raise_for_http_status, _repo_full_name,
25 _require_env, _response_json, _tool_content, _typed_response,
26 )
27except ImportError: # pragma: no cover - main.py run (qualified imports)
28 from langgraph.tools._ticket_common import (
29 TicketOutcome, _as_questions, _hard_failure, _http_get,
30 _llm_json_decision, _raise_for_http_status, _repo_full_name,
31 _require_env, _response_json, _tool_content, _typed_response,
32 )
34TICKET_REVIEW_TOOL_NAME = "ticket-review"
35DEFAULT_STEP_ID = "ticket_review"
37TICKET_REVIEW_DEFINITION: dict[str, Any] = {
38 "type": "function",
39 "function": {
40 "name": TICKET_REVIEW_TOOL_NAME,
41 "description": (
42 "Wait for and classify the pull-request review with `ticket-review` "
43 "after `ticket-publish`: it polls until the PR head commit carries a "
44 "review and maps it to an outcome (approved continues; fixable / "
45 "too_bad / cannot_fix stop the run). Set `poll_interval_seconds` "
46 "(default 15) and `timeout_seconds` (default 900)."
47 ),
48 "parameters": {
49 "type": "object",
50 "properties": {
51 "poll_interval_seconds": {
52 "type": "integer",
53 "description": (
54 "Seconds between poll attempts; defaults to 15 when omitted."
55 ),
56 },
57 "timeout_seconds": {
58 "type": "integer",
59 "description": (
60 "Total seconds to wait for a head-commit review before "
61 "giving up; defaults to 900 when omitted."
62 ),
63 },
64 },
65 "additionalProperties": False,
66 },
67 },
68}
70_VERDICT_SYSTEM_PROMPT = """\
71You classify a pull-request review for the agent-platform agent. The review \
72was left on the latest commit of the agent's feature branch.
74Classify the review into exactly one category:
75- approved → the review approves the change (no fixable issues) → the \
76run continues and the PR is opened (outcome 0).
77- fixable → the review raises fixable issues the agent can address in \
78a rework round (outcome 20, needs_rework + follow-up run).
79- too_bad → the review reveals problems too severe to fix — wrong \
80direction, fundamentally flawed approach (outcome 21, pr_rejected).
81- cannot_fix → the agent cannot fix the review without human input — \
82missing information or an unresolved decision (outcome 22, open_questions).
84Deliver the verdict by calling the `submit_decision` tool with exactly this \
85object as its arguments — your message text carries no decision:
86{"verdict": "approved"|"fixable"|"too_bad"|"cannot_fix", "questions": ["...", ...]}
87- questions: only for cannot_fix — the questions for the human; they must \
88state exactly what is missing; never return an empty list when cannot_fix.
89"""
92def _hard_failure_type() -> type[Exception]:
93 """The runner's hard-failure type, resolved without an import cycle."""
94 try:
95 from runner import RunHardFailure
96 except ImportError: # pragma: no cover - main.py run (qualified imports)
97 from langgraph.runner import RunHardFailure
98 return RunHardFailure
101def _pr_number_from_url(pr_url: str) -> str:
102 """Derive the PR number from a PR URL (.../pulls/{number}); fail-fast."""
103 number = pr_url.rstrip("/").split("/")[-1]
104 if not number.isdigit():
105 raise _hard_failure(
106 "VALIDATION_ERROR",
107 f"ticket-review: cannot derive PR number from pr_url {pr_url!r}",
108 )
109 return number
112def _pr_head_sha(pr_data: Any, raw_text: str) -> str:
113 """Read head.sha from a PR body; a malformed/absent shape is typed."""
114 head = pr_data.get("head") if isinstance(pr_data, dict) else None
115 sha = head.get("sha") if isinstance(head, dict) else None
116 if not isinstance(sha, str) or not sha:
117 raise _hard_failure(
118 "RESOURCE_ERROR",
119 f"ticket-review: PR GET response missing head.sha: {raw_text[:500]}",
120 )
121 return sha
124def _newest_head_review(reviews: list, head_sha: str) -> dict | None:
125 """Newest review on head_sha (submitted_at order); None when absent."""
126 head_reviews = [
127 review for review in reviews
128 if isinstance(review, dict) and review.get("commit_id") == head_sha
129 ]
130 if not head_reviews:
131 return None
132 head_reviews.sort(key=lambda review: review.get("submitted_at") or "")
133 return head_reviews[-1]
136def _wait_for_head_review(
137 base_url: str,
138 token: str,
139 repo_full_name: str,
140 pr_number: str,
141 *,
142 poll_interval_seconds: int,
143 timeout_seconds: int,
144) -> dict:
145 """Poll Gitea until the PR's head commit carries a review.
147 GET /pulls/{number} -> head.sha; GET /pulls/{number}/reviews -> keep
148 only reviews with commit_id == head.sha. Reviews on earlier commits
149 are stale (harmful) and must never trigger classification - the poll
150 continues past them. Returns the newest head-commit review
151 (submitted_at order). No review within timeout_seconds ->
152 RunHardFailure("TIMEOUT") (run-failure -> failed lane).
153 """
154 headers = {"Authorization": f"token {token}"}
155 api = f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}"
156 pr_label = "ticket-review: PR GET"
157 reviews_label = "ticket-review: reviews GET"
158 deadline = time.monotonic() + timeout_seconds
160 while True:
161 pr_resp = _http_get(api, headers=headers, error_label=pr_label)
162 _raise_for_http_status(pr_resp, pr_label)
163 pr_data = _response_json(pr_resp, pr_label)
164 head_sha = _pr_head_sha(pr_data, pr_resp.text)
166 reviews_resp = _http_get(f"{api}/reviews", headers=headers, error_label=reviews_label)
167 _raise_for_http_status(reviews_resp, reviews_label)
168 reviews = _response_json(reviews_resp, reviews_label)
169 if not isinstance(reviews, list):
170 raise _hard_failure(
171 "RESOURCE_ERROR",
172 f"ticket-review: reviews GET returned non-list JSON: {reviews_resp.text[:500]}",
173 )
175 review = _typed_response(
176 reviews_label,
177 lambda: _newest_head_review(reviews, head_sha),
178 )
179 if review is not None:
180 return review
182 if time.monotonic() >= deadline:
183 raise _hard_failure(
184 "TIMEOUT",
185 f"ticket-review: no review on the latest commit (head_sha {head_sha}) "
186 f"within {timeout_seconds}s — giving up",
187 )
188 time.sleep(poll_interval_seconds)
191def ticket_review(step: dict, state: dict, invoke_agent: Any) -> dict:
192 """Wait for the PR review and classify it (LLM, outcome codes 0/20/21/22).
194 Typically runs after ticket-publish. Polls the Gitea API until the PR's
195 head commit carries a review (only commit_id == head.sha - reviews on
196 earlier commits are treated as harmful and skipped) and classifies the
197 newest review with the LLM:
199 approved -> returns state (run continues; final PATCH exit 0 -> pr_open)
200 fixable -> TicketOutcome(20) - needs_rework (+ follow-up run)
201 too_bad -> TicketOutcome(21) - pr_rejected
202 cannot_fix -> TicketOutcome(22) + questions - open_questions
204 No review within the deadline (timeout_seconds, default 900 s;
205 poll_interval_seconds default 15 s) -> hard failure (run-failure ->
206 failed lane). Wrong RUN_PROFILE -> hard failure (ADR-0012).
208 Env vars used:
209 RUN_PROFILE - must be "TICKET" (guard; anything else raises)
210 GITEA_BASE_URL, GIT_TOKEN - Gitea REST credentials
211 GIT_REPO_URL - code repo URL (repo full name derivation)
212 State used (set by ticket-publish):
213 pr_url (top-level), ticket_publish.step result: branch / base
214 Step params:
215 poll_interval_seconds (default 15), timeout_seconds (default 900)
216 """
217 run_profile = os.environ.get("RUN_PROFILE")
218 if run_profile != "TICKET":
219 raise _hard_failure(
220 "VALIDATION_ERROR",
221 f"ticket-review requires RUN_PROFILE=TICKET (got {run_profile!r})",
222 )
224 publish_result = state.get("ticket_publish")
225 if not isinstance(publish_result, dict):
226 publish_result = state.get("ticket-publish")
227 pr_url = state.get("pr_url")
228 if publish_result:
229 pr_url = pr_url or publish_result.get("pr_url")
230 if not isinstance(pr_url, str) or not pr_url:
231 raise _hard_failure(
232 "VALIDATION_ERROR",
233 "ticket-review: state['pr_url'] is missing — "
234 "the ticket-publish step must run first (TICKET profile)",
235 )
236 branch = publish_result.get("branch") if isinstance(publish_result, dict) else None
237 base = publish_result.get("base") if isinstance(publish_result, dict) else None
239 params = step.get("params") or {}
240 try:
241 poll_interval = int(params.get("poll_interval_seconds", 15))
242 timeout = int(params.get("timeout_seconds", 900))
243 except (TypeError, ValueError) as exc:
244 raise _hard_failure(
245 "VALIDATION_ERROR",
246 f"ticket-review: invalid polling params: {exc}",
247 ) from exc
248 if poll_interval < 0 or timeout < 0:
249 raise _hard_failure(
250 "VALIDATION_ERROR",
251 "ticket-review: poll_interval_seconds and timeout_seconds must be >= 0",
252 )
254 base_url = _require_env("GITEA_BASE_URL").rstrip("/")
255 token = _require_env("GIT_TOKEN")
256 repo_full_name = _repo_full_name(_require_env("GIT_REPO_URL"))
257 pr_number = _pr_number_from_url(pr_url)
259 review = _wait_for_head_review(
260 base_url,
261 token,
262 repo_full_name,
263 pr_number,
264 poll_interval_seconds=poll_interval,
265 timeout_seconds=timeout,
266 )
268 verdict = _llm_json_decision(
269 invoke_agent,
270 step,
271 state,
272 agent_name="ticket-review",
273 system_prompt=_VERDICT_SYSTEM_PROMPT,
274 context={
275 "review": {
276 "body": review.get("body"),
277 "state": review.get("state"),
278 },
279 "pr_url": pr_url,
280 "branch": branch,
281 "base": base,
282 },
283 )
285 label = verdict.get("verdict")
286 if label not in ("approved", "fixable", "too_bad", "cannot_fix"):
287 raise _hard_failure(
288 "AGENT_ERROR",
289 f"ticket-review: unknown verdict {label!r} from LLM: {verdict!r}",
290 )
291 questions = _as_questions(verdict.get("questions"), "ticket-review")
293 step_id = step.get("id", "ticket_review")
294 if label == "approved":
295 return {**state, step_id: {"verdict": "approved", "questions": []}}
297 outcomes = {
298 "fixable": (
299 20,
300 "ticket-review: review found fixable issues — run needs rework (outcome 20)",
301 ),
302 "too_bad": (
303 21,
304 "ticket-review: review found issues too severe to fix (outcome 21)",
305 ),
306 "cannot_fix": (
307 22,
308 "ticket-review: review cannot be fixed without human input — "
309 "questions posted to the ticket (outcome 22)",
310 ),
311 }
312 code, message = outcomes[label]
313 raise TicketOutcome(code=code, message=message, questions=questions or None)
316class _TicketReviewTool:
317 name = TICKET_REVIEW_TOOL_NAME
319 def definition(self) -> dict[str, Any]:
320 return TICKET_REVIEW_DEFINITION
322 def instruction(self) -> str | None:
323 return None
325 def params(self) -> list[dict[str, Any]] | None:
326 return None
328 def execute(self, context: ToolContext) -> ToolResult:
329 step_id = (context.step or {}).get("id") or DEFAULT_STEP_ID
330 args = context.args or {}
331 params = {
332 key: args[key]
333 for key in ("poll_interval_seconds", "timeout_seconds")
334 if key in args
335 }
336 synthetic_step = {"id": step_id, "params": params}
337 try:
338 new_state = ticket_review(
339 synthetic_step, context.state or {}, context.invoker
340 )
341 except _hard_failure_type() as exc:
342 return ToolResult(
343 ok=False,
344 content=_tool_content(TICKET_REVIEW_TOOL_NAME, ok=False, error=exc.message),
345 error=exc.message,
346 )
347 output = new_state[step_id]
348 return ToolResult(
349 ok=True,
350 content=_tool_content(TICKET_REVIEW_TOOL_NAME, ok=True, output=output),
351 state_patch={step_id: output},
352 )
355TOOL = _TicketReviewTool()