Coverage for core/langgraph/_builtins_pr.py: 93%
74 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-08 05:24 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-08 05:24 +0000
1"""_builtins_pr.py — PR-review builtins (lazy-loaded, needs httpx)."""
3from __future__ import annotations
5import json
6import os
7from typing import Any
9import httpx
12def _get_findings(state: dict, findings_key: str) -> list:
13 """Hae findings state:sta dot-path-avaimella (esim. review.review_output).
15 Puuttuva polun osa tai ei-lista-arvo → tyhjä lista (manifestin policy).
16 """
17 cur: Any = state
18 for part in str(findings_key).split("."):
19 if isinstance(cur, dict):
20 cur = cur.get(part)
21 else:
22 return []
23 return cur if isinstance(cur, list) else []
26_WIP_PREFIXES = ("WIP: ",)
29def _strip_wip_prefix(title: str) -> tuple[str, bool]:
30 lower = title.lower()
31 for prefix in _WIP_PREFIXES:
32 if lower.startswith(prefix.lower()):
33 return title[len(prefix):], True
34 return title, False
37def builtin_github_pr_comment(step: dict, state: dict) -> dict:
38 """Post PR review (APPROVED / REQUEST_CHANGES) via Gitea/GitHub REST API.
40 Findings at or above `fail_on_severity` → event `REQUEST_CHANGES`,
41 otherwise `APPROVED`. Non-2xx HTTP responses raise ValueError. After a
42 successful review the WIP marker in the PR title is synced:
43 REQUEST_CHANGES → prepend "WIP: "; APPROVED → strip the "WIP: " prefix.
44 No commit status is set; requiring
45 human approval is a repo admin concern (branch protection).
47 Env vars used:
48 RUN_PROFILE — must be "PR" (guard; anything else raises)
49 GITEA_BASE_URL — Gitea instance URL
50 GIT_TOKEN — personal access token
51 PR_NUMBER — pull request number
52 REPO_FULL_NAME — repository full name (owner/repo)
53 """
54 run_profile = os.environ.get("RUN_PROFILE")
55 if run_profile != "PR":
56 raise ValueError(
57 f"github-pr-comment requires RUN_PROFILE=PR (got {run_profile!r})"
58 )
60 params = step.get("params", {}) or {}
61 findings_key = params.get("findings_key", "review_output")
62 fail_on_severity = params.get("fail_on_severity", "Critical")
64 base_url = os.environ["GITEA_BASE_URL"].rstrip("/")
65 token = os.environ["GIT_TOKEN"]
66 pr_number = os.environ["PR_NUMBER"]
67 repo_full_name = os.environ["REPO_FULL_NAME"]
69 findings = _get_findings(state, findings_key)
70 headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
72 # PR review: standard Gitea/GitHub review, no commit status
73 has_blocking = any(f.get("severity") == fail_on_severity for f in findings)
74 event = "REQUEST_CHANGES" if has_blocking else "APPROVED"
76 body = _format_findings_as_markdown(findings)
77 review_resp = httpx.post(
78 f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}/reviews",
79 json={
80 "body": body,
81 "event": event,
82 },
83 headers=headers,
84 )
85 if review_resp.status_code >= 400:
86 raise ValueError(
87 f"github-pr-comment: review POST failed ({review_resp.status_code}): {review_resp.text[:500]}"
88 )
90 pr_resp = httpx.get(
91 f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}",
92 headers=headers,
93 )
94 if pr_resp.status_code >= 400:
95 raise ValueError(
96 f"github-pr-comment: PR GET failed ({pr_resp.status_code}): {pr_resp.text[:500]}"
97 )
98 try:
99 pr_data = pr_resp.json()
100 except json.JSONDecodeError:
101 raise ValueError(
102 f"github-pr-comment: PR GET returned invalid JSON: {pr_resp.text[:500]}"
103 ) from None
104 title = pr_data.get("title") if isinstance(pr_data, dict) else None
105 if not isinstance(title, str):
106 raise ValueError(
107 f"github-pr-comment: PR GET missing title: {pr_resp.text[:500]}"
108 )
110 stripped, has_wip_prefix = _strip_wip_prefix(title)
111 if event == "REQUEST_CHANGES":
112 new_title = None if has_wip_prefix else f"WIP: {title}"
113 else:
114 new_title = stripped if has_wip_prefix else None
116 wip = "unchanged"
117 if new_title is not None:
118 patch_resp = httpx.patch(
119 f"{base_url}/api/v1/repos/{repo_full_name}/pulls/{pr_number}",
120 json={"title": new_title},
121 headers=headers,
122 )
123 if patch_resp.status_code >= 400:
124 raise ValueError(
125 f"github-pr-comment: title PATCH failed ({patch_resp.status_code}): {patch_resp.text[:500]}"
126 )
127 wip = "set" if event == "REQUEST_CHANGES" else "cleared"
129 return {**state, "review_posted": True, "findings_count": len(findings), "wip": wip}
132def _format_findings_as_markdown(findings: list) -> str:
133 if not findings:
134 return "## PR Review\n\nNo findings."
135 lines = ["## PR Review\n"]
136 for f in findings:
137 severity = f.get("severity", "Info")
138 finding = f.get("finding", f.get("message", ""))
139 evidence = f.get("evidence_ref", f.get("evidence", ""))
140 lines.append(f"- **{severity}**: {finding}")
141 if evidence:
142 lines.append(f" - Evidence: {evidence}")
143 lines.append(f"\n_{len(findings)} finding(s)_")
144 return "\n".join(lines)
147def register_all(executor: Any) -> None:
148 executor.register("github-pr-comment", builtin_github_pr_comment)