Coverage for core/langgraph/_builtins_pr.py: 95%
95 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-20 03:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-20 03:43 +0000
1"""_builtins_pr.py — PR-review builtins (lazy-loaded, needs httpx)."""
3from __future__ import annotations
5import json
6import logging
7import os
8from typing import Any
10import httpx
12log = logging.getLogger(__name__)
15def _get_findings(state: dict, findings_key: str) -> list:
16 """Hae findings state:sta dot-path-avaimella (esim. review.review_output).
18 Puuttuva polun osa tai ei-lista-arvo → tyhjä lista (manifestin policy).
19 """
20 cur: Any = state
21 for part in str(findings_key).split("."):
22 if isinstance(cur, dict):
23 cur = cur.get(part)
24 else:
25 return []
26 return cur if isinstance(cur, list) else []
29_WIP_PREFIXES = ("WIP: ",)
32def _strip_wip_prefix(title: str) -> tuple[str, bool]:
33 lower = title.lower()
34 for prefix in _WIP_PREFIXES:
35 if lower.startswith(prefix.lower()):
36 return title[len(prefix):], True
37 return title, False
40def _is_success_status(status_code: int) -> bool:
41 return 200 <= status_code < 300
44def _wip_sync_failure_reason(status_code: int) -> str:
45 if status_code == 401:
46 return "authentication failed — check bot token"
47 if status_code == 403:
48 return "forbidden — bot lacks write access to the repository (read-only bot)"
49 if status_code == 404:
50 return "pull request not found (closed or deleted?)"
51 if 500 <= status_code <= 599:
52 return "Gitea server error"
53 return "unexpected status"
56def builtin_github_pr_comment(step: dict, state: dict) -> dict:
57 """Post PR review (APPROVED / REQUEST_CHANGES) via Gitea/GitHub REST API.
59 Findings at or above `fail_on_severity` → event `REQUEST_CHANGES`,
60 otherwise `APPROVED`. Non-2xx review POST / PR GET responses raise
61 ValueError. After a successful review the WIP marker in the PR title
62 is synced: REQUEST_CHANGES → prepend "WIP: "; APPROVED → strip the
63 "WIP: " prefix. The WIP sync is best-effort: a non-2xx title PATCH
64 emits an info-level log entry with a cause classified by status
65 code (401 authentication, 403 missing repo write access, 404 PR
66 gone, 5xx server error) and the run continues.
67 No commit status is set; requiring
68 human approval is a repo admin concern (branch protection).
70 Env vars used:
71 RUN_PROFILE — must be "PR" (guard; anything else raises)
72 GITEA_BASE_URL — Gitea instance URL
73 GIT_TOKEN — personal access token
74 PR_NUMBER — pull request number
75 REPO_FULL_NAME — repository full name (owner/repo)
76 """
77 run_profile = os.environ.get("RUN_PROFILE")
78 if run_profile != "PR":
79 raise ValueError(
80 f"github-pr-comment requires RUN_PROFILE=PR (got {run_profile!r})"
81 )
83 params = step.get("params", {}) or {}
84 findings_key = params.get("findings_key", "review_output")
85 fail_on_severity = params.get("fail_on_severity", "Critical")
87 base_url = os.environ["GITEA_BASE_URL"].rstrip("/")
88 token = os.environ["GIT_TOKEN"]
89 pr_number = os.environ["PR_NUMBER"]
90 repo_full_name = os.environ["REPO_FULL_NAME"]
92 findings = _get_findings(state, findings_key)
93 headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
95 # PR review: standard Gitea/GitHub review, no commit status
96 has_blocking = any(f.get("severity") == fail_on_severity for f in findings)
97 event = "REQUEST_CHANGES" if has_blocking else "APPROVED"
99 body = _format_findings_as_markdown(findings)
100 review_resp = httpx.post(
101 f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}/reviews",
102 json={
103 "body": body,
104 "event": event,
105 },
106 headers=headers,
107 )
108 if not _is_success_status(review_resp.status_code):
109 raise ValueError(
110 f"github-pr-comment: review POST failed ({review_resp.status_code}): {review_resp.text[:500]}"
111 )
113 pr_resp = httpx.get(
114 f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}",
115 headers=headers,
116 )
117 if not _is_success_status(pr_resp.status_code):
118 raise ValueError(
119 f"github-pr-comment: PR GET failed ({pr_resp.status_code}): {pr_resp.text[:500]}"
120 )
121 try:
122 pr_data = pr_resp.json()
123 except json.JSONDecodeError:
124 raise ValueError(
125 f"github-pr-comment: PR GET returned invalid JSON: {pr_resp.text[:500]}"
126 ) from None
127 title = pr_data.get("title") if isinstance(pr_data, dict) else None
128 if not isinstance(title, str):
129 raise ValueError(
130 f"github-pr-comment: PR GET missing title: {pr_resp.text[:500]}"
131 )
133 stripped, has_wip_prefix = _strip_wip_prefix(title)
134 if event == "REQUEST_CHANGES":
135 new_title = None if has_wip_prefix else f"WIP: {title}"
136 else:
137 new_title = stripped if has_wip_prefix else None
139 wip = "unchanged"
140 wip_error = None
141 if new_title is not None:
142 patch_resp = httpx.patch(
143 f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}",
144 json={"title": new_title},
145 headers=headers,
146 )
147 if not _is_success_status(patch_resp.status_code):
148 log.info(
149 "github-pr-comment: WIP title sync failed (%s): %s — %s",
150 patch_resp.status_code,
151 patch_resp.text[:500],
152 _wip_sync_failure_reason(patch_resp.status_code),
153 )
154 wip = "sync_failed"
155 wip_error = f"{patch_resp.status_code}: {patch_resp.text[:500]}"
156 else:
157 wip = "set" if event == "REQUEST_CHANGES" else "cleared"
159 result = {**state, "review_posted": True, "findings_count": len(findings), "wip": wip}
160 if wip_error is not None:
161 result["wip_error"] = wip_error
162 else:
163 result.pop("wip_error", None)
164 return result
167def _format_findings_as_markdown(findings: list) -> str:
168 if not findings:
169 return "## PR Review\n\nNo findings."
170 lines = ["## PR Review\n"]
171 for f in findings:
172 severity = f.get("severity", "Info")
173 finding = f.get("finding", f.get("message", ""))
174 evidence = f.get("evidence_ref", f.get("evidence", ""))
175 lines.append(f"- **{severity}**: {finding}")
176 if evidence:
177 lines.append(f" - Evidence: {evidence}")
178 lines.append(f"\n_{len(findings)} finding(s)_")
179 return "\n".join(lines)
182def register_all(executor: Any) -> None:
183 executor.register("github-pr-comment", builtin_github_pr_comment)