Coverage for core/langgraph/tools/ticket_review.py: 91%

112 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-04 15:34 +0000

1"""Per-tool module for the `ticket-review` PR-review classification builtin. 

2 

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. 

7 

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 

14 

15import os 

16import time 

17from typing import Any 

18 

19from .contract import ToolContext, ToolResult 

20 

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 ) 

33 

34TICKET_REVIEW_TOOL_NAME = "ticket-review" 

35DEFAULT_STEP_ID = "ticket_review" 

36 

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} 

69 

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. 

73 

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

83 

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

90 

91 

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 

99 

100 

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 

110 

111 

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 

122 

123 

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] 

134 

135 

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. 

146 

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 

159 

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) 

165 

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 ) 

174 

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 

181 

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) 

189 

190 

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

193 

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: 

198 

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 

203 

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

207 

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 ) 

223 

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 

238 

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 ) 

253 

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) 

258 

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 ) 

267 

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 ) 

284 

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

292 

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

294 if label == "approved": 

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

296 

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) 

314 

315 

316class _TicketReviewTool: 

317 name = TICKET_REVIEW_TOOL_NAME 

318 

319 def definition(self) -> dict[str, Any]: 

320 return TICKET_REVIEW_DEFINITION 

321 

322 def instruction(self) -> str | None: 

323 return None 

324 

325 def params(self) -> list[dict[str, Any]] | None: 

326 return None 

327 

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 ) 

353 

354 

355TOOL = _TicketReviewTool()