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: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-publish` deterministic builtin.
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
11import os
12import subprocess
13from typing import Any
15from .contract import ToolContext, ToolResult
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 )
30TICKET_PUBLISH_TOOL_NAME = "ticket-publish"
31DEFAULT_STEP_ID = "ticket_publish"
33_COMMIT_AUTHOR_NAME = "agent-platform"
34_COMMIT_AUTHOR_EMAIL = "agent-platform@localhost"
35_COMMIT_MESSAGE_PREFIX = "agent-platform:"
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}
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
80def _git_commit_and_push(project_dir: str, branch: str) -> bool:
81 """Commit the pipeline output and push the feature branch.
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
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).
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"
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
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"
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"
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"
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
214def ticket_publish(step: dict, state: dict) -> dict:
215 """Publish the pipeline output for a TICKET run: commit → push → PR.
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.
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).
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 )
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 )
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 ""
269 pushed = _git_commit_and_push(project_dir, branch)
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}
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 }
300class _TicketPublishTool:
301 name = TICKET_PUBLISH_TOOL_NAME
303 def definition(self) -> dict[str, Any]:
304 return TICKET_PUBLISH_DEFINITION
306 def instruction(self) -> str | None:
307 return None
309 def params(self) -> list[dict[str, Any]] | None:
310 return None
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 )
335TOOL = _TicketPublishTool()