Coverage for core/langgraph/tools/ticket_publish.py: 96%

130 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 05:49 +0000

1"""Per-tool module for the `ticket-publish` deterministic builtin. 

2 

3Owns the tool's model-facing definition, its instruction surface (none) and the 

4TICKET publish execution itself: commit the workspace, plain-push the feature 

5branch (only when a commit was created) and ensure the pull request 

6idempotently. The definition and the execution here are the single source; the 

7legacy builtin site delegates to the pure `ticket_publish` handler. 

8""" 

9from __future__ import annotations 

10 

11import os 

12import subprocess 

13from typing import Any 

14 

15from .contract import ToolContext, ToolResult 

16 

17try: 

18 from tools._ticket_common import ( 

19 _hard_failure, _http_get, _http_patch, _http_post, _json_list_response, 

20 _raise_for_http_status, _repo_full_name, _require_env, _response_json, 

21 _tool_content, _typed_response, 

22 ) 

23except ImportError: # pragma: no cover — main.py run (qualified imports) 

24 from langgraph.tools._ticket_common import ( 

25 _hard_failure, _http_get, _http_patch, _http_post, _json_list_response, 

26 _raise_for_http_status, _repo_full_name, _require_env, _response_json, 

27 _tool_content, _typed_response, 

28 ) 

29 

30TICKET_PUBLISH_TOOL_NAME = "ticket-publish" 

31DEFAULT_STEP_ID = "ticket_publish" 

32 

33_COMMIT_AUTHOR_NAME = "agent-platform" 

34_COMMIT_AUTHOR_EMAIL = "agent-platform@localhost" 

35_COMMIT_MESSAGE_PREFIX = "agent-platform:" 

36 

37TICKET_PUBLISH_DEFINITION: dict[str, Any] = { 

38 "type": "function", 

39 "function": { 

40 "name": TICKET_PUBLISH_TOOL_NAME, 

41 "description": ( 

42 "Publish the TICKET run's output with `ticket-publish`: it commits the " 

43 "workspace, pushes the feature branch, and ensures the pull request. " 

44 "It reads `project_dir`, `ticket_branch` and `ticket_base_branch` " 

45 "(set by `git-checkout`); the result reports `branch`, `base`, " 

46 "`pushed`, `pr_action` and, once a PR is ensured, `pr_url`." 

47 ), 

48 "parameters": { 

49 "type": "object", 

50 "properties": {}, 

51 "additionalProperties": False, 

52 }, 

53 }, 

54} 

55 

56 

57def _git(project_dir: str, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess: 

58 """Run a git command in the project dir; failures raise clear errors.""" 

59 try: 

60 return subprocess.run( 

61 ["git", "-c", "safe.directory=*", *args], 

62 cwd=project_dir, 

63 capture_output=True, 

64 text=True, 

65 check=check, 

66 ) 

67 except subprocess.CalledProcessError as exc: 

68 raise _hard_failure( 

69 "EXECUTION_ERROR", 

70 f"ticket-publish: 'git {' '.join(args)}' failed " 

71 f"(exit {exc.returncode}): {(exc.stderr or exc.stdout or '').strip()}", 

72 ) from exc 

73 except OSError as exc: 

74 raise _hard_failure( 

75 "EXECUTION_ERROR", 

76 f"ticket-publish: failed to run 'git {' '.join(args)}': {exc}", 

77 ) from exc 

78 

79 

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

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

82 

83 The push is plain (never a forced push) and happens ONLY when a commit 

84 was created — nothing staged → no commit → no push (no empty branch 

85 publish). The branch is born with the first real commit; later steps 

86 and continuation runs continue on the same branch and PR. Returns 

87 True when a commit was created and pushed, False otherwise. A failed 

88 push is a hard error (resolved MVP decision). 

89 """ 

90 _git(project_dir, ["add", "-A"]) 

91 staged = _git(project_dir, ["diff", "--cached", "--quiet"], check=False) 

92 if staged.returncode not in (0, 1): 

93 raise _hard_failure( 

94 "EXECUTION_ERROR", 

95 f"ticket-publish: 'git diff --cached --quiet' failed " 

96 f"(exit {staged.returncode}): {(staged.stderr or '').strip()}", 

97 ) 

98 if staged.returncode == 0: 

99 return False 

100 author_name = os.environ.get("GIT_AUTHOR_NAME", _COMMIT_AUTHOR_NAME) 

101 author_email = os.environ.get("GIT_AUTHOR_EMAIL", _COMMIT_AUTHOR_EMAIL) 

102 _git(project_dir, [ 

103 "-c", f"user.name={author_name}", 

104 "-c", f"user.email={author_email}", 

105 "commit", "-m", f"{_COMMIT_MESSAGE_PREFIX} {branch}", 

106 ]) 

107 _git(project_dir, ["push", "origin", branch]) 

108 return True 

109 

110 

111def _ensure_pr( 

112 base_url: str, 

113 token: str, 

114 repo_full_name: str, 

115 *, 

116 head: str, 

117 base: str, 

118 title: str, 

119 body: str, 

120) -> tuple[str, str]: 

121 """Ensure the PR for the feature branch exists (idempotent, LOW-2). 

122 

123 Order: open PR for the branch → reuse; closed PR for the branch → 

124 reopen; otherwise create. Returns (pr_url, action) where action is 

125 "reused" | "reopened" | "created". 

126 """ 

127 headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} 

128 api = f"{base_url}/api/v1/repos/{repo_full_name}/pulls" 

129 

130 def _find(pulls: list) -> dict | None: 

131 for pr in pulls: 

132 head_ref = pr.get("head") if isinstance(pr, dict) else None 

133 if isinstance(head_ref, dict) and head_ref.get("ref") == head: 

134 return pr 

135 return None 

136 

137 open_label = "ticket-publish: open PRs GET" 

138 open_resp = _http_get(f"{api}?state=open&limit=50", headers=headers, error_label=open_label) 

139 _raise_for_http_status(open_resp, open_label) 

140 open_pulls = _json_list_response(open_resp, open_label) 

141 existing = _typed_response(open_label, lambda: _find(open_pulls)) 

142 if existing is not None: 

143 html_url = existing.get("html_url") 

144 if not isinstance(html_url, str) or not html_url: 

145 raise _hard_failure( 

146 "RESOURCE_ERROR", 

147 f"ticket-publish: open PR response missing html_url: {open_resp.text[:500]}", 

148 ) 

149 return html_url, "reused" 

150 

151 closed_label = "ticket-publish: closed PRs GET" 

152 closed_resp = _http_get(f"{api}?state=closed&limit=50", headers=headers, error_label=closed_label) 

153 _raise_for_http_status(closed_resp, closed_label) 

154 closed_pulls = _json_list_response(closed_resp, closed_label) 

155 closed = _typed_response(closed_label, lambda: _find(closed_pulls)) 

156 if closed is not None: 

157 number = closed.get("number") 

158 if not isinstance(number, int): 

159 raise _hard_failure( 

160 "RESOURCE_ERROR", 

161 f"ticket-publish: closed PR response missing number: {closed_resp.text[:500]}", 

162 ) 

163 reopen_label = "ticket-publish: PR reopen PATCH" 

164 reopen_resp = _http_patch( 

165 f"{api}/{number}", 

166 headers=headers, 

167 payload={"state": "open"}, 

168 error_label=reopen_label, 

169 ) 

170 _raise_for_http_status(reopen_resp, reopen_label) 

171 reopen_pr = _response_json(reopen_resp, reopen_label) 

172 reopen_url = reopen_pr.get("html_url") if isinstance(reopen_pr, dict) else None 

173 if not isinstance(reopen_url, str) or not reopen_url: 

174 reopen_url = closed.get("html_url") 

175 if not isinstance(reopen_url, str) or not reopen_url: 

176 raise _hard_failure( 

177 "RESOURCE_ERROR", 

178 f"ticket-publish: PR reopen response missing html_url: {reopen_resp.text[:500]}", 

179 ) 

180 return reopen_url, "reopened" 

181 

182 create_label = "ticket-publish: PR create POST" 

183 create_resp = _http_post( 

184 api, 

185 headers=headers, 

186 payload={"title": title, "body": body, "head": head, "base": base}, 

187 error_label=create_label, 

188 ) 

189 _raise_for_http_status(create_resp, create_label) 

190 create_pr = _response_json(create_resp, create_label) 

191 if not isinstance(create_pr, dict): 

192 raise _hard_failure( 

193 "RESOURCE_ERROR", 

194 f"ticket-publish: PR create POST returned non-object JSON: {create_resp.text[:500]}", 

195 ) 

196 pr_url = create_pr.get("html_url") 

197 if not isinstance(pr_url, str) or not pr_url: 

198 raise _hard_failure( 

199 "RESOURCE_ERROR", 

200 f"ticket-publish: PR create POST response missing html_url: {create_resp.text[:500]}", 

201 ) 

202 return pr_url, "created" 

203 

204 

205def _hard_failure_type() -> type[Exception]: 

206 """The runner's hard-failure type, resolved without an import cycle.""" 

207 try: 

208 from runner import RunHardFailure 

209 except ImportError: # pragma: no cover — main.py run (qualified imports) 

210 from langgraph.runner import RunHardFailure 

211 return RunHardFailure 

212 

213 

214def ticket_publish(step: dict, state: dict) -> dict: 

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

216 

217 The first commit step performs branch publish + PR open: the first 

218 real code commit is pushed (no empty branch publish, never a forced 

219 push), then the PR is ensured idempotently (reuse open → 

220 "reused", closed → "reopened", else create → "created"; base = the 

221 ticket's base branch). Later steps and continuation runs continue on 

222 the SAME branch and PR. A push conflict is a hard error (resolved MVP 

223 decision). Nothing staged → no commit, no push, no PR — the step 

224 result reports pushed: False. 

225 

226 Step result: {"branch", "base", "pushed", "pr_action"} + "pr_url" 

227 only when a PR was ensured; the ensured pr_url is also promoted to 

228 the top-level state key (the entrypoint reads it for the final 

229 status-callback PATCH, TICKET profile). 

230 

231 Env vars used: 

232 RUN_PROFILE — must be "TICKET" (guard; anything else raises) 

233 GIT_REPO_URL — code repo URL (owner/repo derivation) 

234 GITEA_BASE_URL, GIT_TOKEN — Gitea REST credentials (PR ensure) 

235 PR_TITLE, PR_DESCRIPTION — PR title/body from the ticket 

236 (PR_DESCRIPTION may be empty) 

237 GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL — commit identity overrides 

238 (optional; defaults agent-platform / agent-platform@localhost) 

239 State used (set by git-checkout): 

240 project_dir, ticket_branch, ticket_base_branch 

241 """ 

242 run_profile = os.environ.get("RUN_PROFILE") 

243 if run_profile != "TICKET": 

244 raise _hard_failure( 

245 "VALIDATION_ERROR", 

246 f"ticket-publish requires RUN_PROFILE=TICKET (got {run_profile!r})", 

247 ) 

248 

249 project_dir = state.get("project_dir", "/project") 

250 branch = state.get("ticket_branch") 

251 base = state.get("ticket_base_branch") 

252 if not isinstance(branch, str) or not branch: 

253 raise _hard_failure( 

254 "VALIDATION_ERROR", 

255 "ticket-publish: state['ticket_branch'] is missing — " 

256 "the git-checkout step must run first (TICKET profile)", 

257 ) 

258 if not isinstance(base, str) or not base: 

259 raise _hard_failure( 

260 "VALIDATION_ERROR", 

261 "ticket-publish: state['ticket_base_branch'] is missing — " 

262 "the git-checkout step must run first (TICKET profile)", 

263 ) 

264 

265 repo_full_name = _repo_full_name(_require_env("GIT_REPO_URL")) 

266 title = _require_env("PR_TITLE") 

267 body = os.environ.get("PR_DESCRIPTION") or "" 

268 

269 pushed = _git_commit_and_push(project_dir, branch) 

270 

271 step_result = { 

272 "branch": branch, 

273 "base": base, 

274 "pushed": pushed, 

275 "pr_action": None, 

276 } 

277 if not pushed: 

278 return {**state, step.get("id", "ticket_publish"): step_result} 

279 

280 base_url = _require_env("GITEA_BASE_URL").rstrip("/") 

281 token = _require_env("GIT_TOKEN") 

282 pr_url, action = _ensure_pr( 

283 base_url, 

284 token, 

285 repo_full_name, 

286 head=branch, 

287 base=base, 

288 title=title, 

289 body=body, 

290 ) 

291 step_result["pr_url"] = pr_url 

292 step_result["pr_action"] = action 

293 return { 

294 **state, 

295 step.get("id", "ticket_publish"): step_result, 

296 "pr_url": pr_url, 

297 } 

298 

299 

300class _TicketPublishTool: 

301 name = TICKET_PUBLISH_TOOL_NAME 

302 

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

304 return TICKET_PUBLISH_DEFINITION 

305 

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

307 return None 

308 

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

310 return None 

311 

312 def execute(self, context: ToolContext) -> ToolResult: 

313 step = context.step or {} 

314 step_id = step.get("id", DEFAULT_STEP_ID) 

315 synthetic_step = {"id": step_id} 

316 try: 

317 new_state = ticket_publish(synthetic_step, context.state or {}) 

318 except _hard_failure_type() as exc: 

319 return ToolResult( 

320 ok=False, 

321 content=_tool_content(TICKET_PUBLISH_TOOL_NAME, ok=False, error=exc.message), 

322 error=exc.message, 

323 ) 

324 output = new_state[step_id] 

325 state_patch: dict[str, Any] = {step_id: output} 

326 if "pr_url" in output: 

327 state_patch["pr_url"] = new_state["pr_url"] 

328 return ToolResult( 

329 ok=True, 

330 content=_tool_content(TICKET_PUBLISH_TOOL_NAME, ok=True, output=output), 

331 state_patch=state_patch, 

332 ) 

333 

334 

335TOOL = _TicketPublishTool()