codereview-loop 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codereview/github.py ADDED
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env python3
2
+ """codereview GitHub integration — review a pull request where it
3
+ lives.
4
+
5
+ Read path (works without a token on public repos): fetch the PR's
6
+ metadata and unified diff from the GitHub API and run the review
7
+ loop on the diff.
8
+
9
+ Write path (needs GITHUB_TOKEN with access to the repo): post the
10
+ report as a PR review comment and set a commit status on the PR
11
+ head — `codereview` context: success (gate pass), failure
12
+ (blocked), pending (inconclusive). Review events are COMMENT by
13
+ default; --request-changes opts into REQUEST_CHANGES on a blocked
14
+ gate. The bot never auto-approves.
15
+
16
+ Tokens come from the environment only, are never logged, and
17
+ authenticated requests refuse redirects (same rules as providers).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import urllib.error
26
+ import urllib.request
27
+
28
+ from . import providers as _providers
29
+ from .core import run_review, run_verification
30
+
31
+ _API = "https://api.github.com"
32
+ _PR_URL_RE = re.compile(
33
+ r"^https?://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)/?$")
34
+
35
+
36
+ class GitHubError(Exception):
37
+ pass
38
+
39
+
40
+ def parse_pr_url(url):
41
+ m = _PR_URL_RE.match((url or "").strip())
42
+ if not m:
43
+ raise GitHubError(f"not a GitHub PR URL: {url!r}")
44
+ return m.group(1), m.group(2), int(m.group(3))
45
+
46
+
47
+ class GitHubClient:
48
+ def __init__(self, token=None, base=_API, opener=None):
49
+ self.token = token if token is not None \
50
+ else os.environ.get("GITHUB_TOKEN", "").strip()
51
+ self.base = base.rstrip("/")
52
+ # Set by review_pr: the PR head the run reviewed, and
53
+ # whether a commit status has been posted for it. The
54
+ # crash handler uses them to mark a crashed run pending
55
+ # (never success) — see crash.register_crash_hook.
56
+ self.head_sha = None
57
+ self.status_set = False
58
+ self._opener = opener or urllib.request.build_opener(
59
+ _providers._NoRedirect())
60
+
61
+ def _request(self, method, path, accept="application/vnd.github+json",
62
+ payload=None):
63
+ headers = {"Accept": accept,
64
+ "X-GitHub-Api-Version": "2022-11-28",
65
+ "User-Agent": "codereview"}
66
+ if self.token:
67
+ headers["Authorization"] = f"Bearer {self.token}"
68
+ data = None
69
+ if payload is not None:
70
+ data = json.dumps(payload).encode("utf-8")
71
+ headers["Content-Type"] = "application/json"
72
+ req = urllib.request.Request(self.base + path, data=data,
73
+ headers=headers, method=method)
74
+ try:
75
+ with self._opener.open(req, timeout=120) as resp:
76
+ return resp.read().decode("utf-8", errors="replace")
77
+ except urllib.error.HTTPError as e:
78
+ detail = ""
79
+ try:
80
+ detail = e.read().decode("utf-8", "replace")[:200]
81
+ except Exception: # noqa: BLE001
82
+ pass
83
+ raise GitHubError(
84
+ f"GitHub API {method} {path}: HTTP {e.code} {detail}"
85
+ ) from e
86
+ except (TimeoutError, ConnectionError, urllib.error.URLError) as e:
87
+ raise GitHubError(f"GitHub API network error: {e}") from e
88
+
89
+ def get_pr(self, owner, repo, number):
90
+ raw = self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
91
+ try:
92
+ return json.loads(raw)
93
+ except json.JSONDecodeError as e:
94
+ raise GitHubError(f"bad PR payload: {e}") from e
95
+
96
+ def get_pr_diff(self, owner, repo, number):
97
+ return self._request(
98
+ "GET", f"/repos/{owner}/{repo}/pulls/{number}",
99
+ accept="application/vnd.github.diff")
100
+
101
+ @staticmethod
102
+ def _post_result(raw, what):
103
+ """A POST's 2xx body is not always JSON (a captive
104
+ portal or proxy can answer 200 with HTML). The post
105
+ itself succeeded or failed at the HTTP layer already;
106
+ an unparseable body must surface as GitHubError — which
107
+ review_pr records in the report — never as a raw
108
+ JSONDecodeError escaping with no record written
109
+ (L6 F3, the failure P5-03 set out to prevent)."""
110
+ if not raw.strip():
111
+ return {}
112
+ try:
113
+ return json.loads(raw)
114
+ except ValueError as e:
115
+ raise GitHubError(
116
+ f"GitHub API {what}: 2xx response was not JSON"
117
+ ) from e
118
+
119
+ def post_review(self, owner, repo, number, body, event="COMMENT"):
120
+ raw = self._request(
121
+ "POST", f"/repos/{owner}/{repo}/pulls/{number}/reviews",
122
+ payload={"body": body, "event": event})
123
+ return self._post_result(raw, "post review")
124
+
125
+ def set_status(self, owner, repo, sha, state, description,
126
+ target_url=None):
127
+ payload = {"state": state, "context": "codereview",
128
+ "description": description[:140]}
129
+ if target_url:
130
+ payload["target_url"] = target_url
131
+ raw = self._request(
132
+ "POST", f"/repos/{owner}/{repo}/statuses/{sha}",
133
+ payload=payload)
134
+ return self._post_result(raw, "set status")
135
+
136
+
137
+ _GATE_STATUS = {"pass": "success", "blocked": "failure",
138
+ "inconclusive": "pending"}
139
+
140
+
141
+ def _neutralise(text):
142
+ """Make model- or diff-derived text inert in a rendered PR
143
+ comment (P5-04): a zero-width space after `@` breaks mention
144
+ parsing, `![` and `](` are split so no image or link
145
+ construct forms, and raw HTML is escaped. Applied at the
146
+ rendering boundary to untrusted text only — the comment's
147
+ own structural markdown is built separately and never
148
+ passes through here.
149
+
150
+ P5-04b: the inline forms above were not the whole link
151
+ surface. A bare URL autolinks, `#123` and `owner/repo#9`
152
+ become issue/PR cross-references, and a reference-style
153
+ link (`[click][r]` plus a `[r]: https://…` definition
154
+ line) renders as a link — all live in gate_reasons text,
155
+ which can carry repo-policy-derived (untrusted) content.
156
+ So: a zero-width space in `://` breaks autolinks, `][`
157
+ is split so no reference link forms, a line-start
158
+ `[label]:` definition is defused the same way, and a
159
+ zero-width space after `#` before a digit breaks
160
+ cross-references."""
161
+ out = str(text or "")
162
+ out = out.replace("&", "&amp;").replace("<", "&lt;") \
163
+ .replace(">", "&gt;")
164
+ out = out.replace("@", "@\u200b")
165
+ out = out.replace("![", "!\u200b[")
166
+ out = out.replace("](", "]\u200b(")
167
+ out = out.replace("][", "]\u200b[")
168
+ out = out.replace("://", ":\u200b//")
169
+ out = re.sub(r"#(?=\d)", "#\u200b", out)
170
+ out = re.sub(r"(?m)^(\s*)\[([^\]]+)\](?=:)",
171
+ lambda m: f"{m.group(1)}[\u200b{m.group(2)}]",
172
+ out)
173
+ return out
174
+
175
+
176
+ def render_pr_comment(report, owner, repo, number):
177
+ """The PR-visible summary of a report (Markdown)."""
178
+ gate = report["gate"].upper()
179
+ icon = {"PASS": "✅", "BLOCKED": "⛔",
180
+ "INCONCLUSIVE": "⚠️"}.get(gate, "")
181
+ lines = [f"## {icon} CodeReview gate: **{gate}**",
182
+ "",
183
+ _neutralise("; ".join(
184
+ report.get("gate_reasons") or [])), ""]
185
+ def cell(text):
186
+ return _neutralise(
187
+ str(text or "").replace("|", "/")
188
+ .replace("\r", " ").replace("\n", " "))
189
+
190
+ findings = [f for f in report.get("findings") or []]
191
+ if findings:
192
+ lines.append("| Severity | File | Finding | Reviewer(s) |")
193
+ lines.append("|---|---|---|---|")
194
+ for f in findings:
195
+ who = _neutralise(", ".join(f.get("sources") or []))
196
+ lines.append(f"| {f['severity']} | {cell(f.get('file'))} "
197
+ f"| {cell(f.get('title'))} | {who} |")
198
+ lines.append("")
199
+ if report.get("mode") == "verify":
200
+ lines.append(f"Cleared: {len(report.get('cleared') or [])} · "
201
+ f"Still open: {len(report.get('remaining') or [])}")
202
+ lines.append("")
203
+ if report.get("degraded"):
204
+ lines.append("_Degraded round: at least one reviewer failed "
205
+ "(see the full report)._\n")
206
+ lines.append(f"CodeReview {report.get('version')} · "
207
+ f"revision `{report.get('patch_sha256', '')[:12]}` · "
208
+ f"PR #{number}")
209
+ return "\n".join(lines)
210
+
211
+
212
+ def review_pr(client, owner, repo, number, reviewer_providers,
213
+ post=False, request_changes=False, previous=None,
214
+ builder_response="", test_results=None, strict=False,
215
+ allow_major=False):
216
+ """Fetch a PR, review (or verify) its diff, optionally post.
217
+
218
+ Returns (report, actions) where actions records what was posted.
219
+ A posting failure does NOT raise (P5-03): the completed review
220
+ must survive it, so the failure is recorded on the report as
221
+ report["posting"] = {"posted": [...], "error": "..."} and the
222
+ caller decides the exit code after writing the report. Fetch
223
+ and review failures still raise GitHubError as before.
224
+ """
225
+ if post and not client.token:
226
+ # P5-02: refuse before any fetch or review spend — the
227
+ # token check used to sit after the review had run.
228
+ raise GitHubError("--post needs GITHUB_TOKEN")
229
+ pr = client.get_pr(owner, repo, number)
230
+ diff = client.get_pr_diff(owner, repo, number)
231
+ if not diff.strip():
232
+ raise GitHubError("the PR diff is empty")
233
+ head_sha = ((pr.get("head") or {}).get("sha") or "")
234
+ try:
235
+ client.head_sha = head_sha or None
236
+ except AttributeError: # a fake client without the slot
237
+ pass
238
+ context = (f"GitHub PR {owner}/{repo}#{number}: "
239
+ f"{pr.get('title') or ''}\n"
240
+ f"Author: {(pr.get('user') or {}).get('login') or ''}\n"
241
+ f"{(pr.get('body') or '')[:1500]}")
242
+ if previous is not None:
243
+ report = run_verification(
244
+ diff, reviewer_providers, previous, builder_response,
245
+ test_results=test_results or [], allow_major=allow_major,
246
+ strict=strict)
247
+ else:
248
+ report = run_review(
249
+ diff, reviewer_providers, context=context,
250
+ test_results=test_results or [], allow_major=allow_major,
251
+ strict=strict)
252
+ report["pr"] = {"owner": owner, "repo": repo, "number": number,
253
+ "head_sha": head_sha, "title": pr.get("title") or ""}
254
+ actions = []
255
+ if post:
256
+ event = "COMMENT"
257
+ if request_changes and report["gate"] == "blocked":
258
+ event = "REQUEST_CHANGES"
259
+ body = render_pr_comment(report, owner, repo, number)
260
+ try:
261
+ client.post_review(owner, repo, number, body,
262
+ event=event)
263
+ actions.append(f"review comment posted (event={event})")
264
+ if head_sha:
265
+ state = _GATE_STATUS.get(report["gate"], "pending")
266
+ desc = ("; ".join(report.get("gate_reasons") or [])
267
+ or report["gate"])
268
+ if report["gate"] == "inconclusive":
269
+ # P5-11: `pending` is the documented state for
270
+ # an inconclusive gate, and the description
271
+ # must say so explicitly — branch protection
272
+ # waits on pending, so the human has to be
273
+ # able to see WHY it is pending.
274
+ desc = ("gate inconclusive — the review did "
275
+ f"not reach a verdict: {desc}")
276
+ client.set_status(owner, repo, head_sha, state, desc)
277
+ try:
278
+ client.status_set = True
279
+ except AttributeError:
280
+ pass
281
+ actions.append(
282
+ f"commit status set: codereview={state}")
283
+ except GitHubError as exc:
284
+ report["posting"] = {"posted": list(actions),
285
+ "error": str(exc)}
286
+ return report, actions
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env python3
2
+ """Process hardening shared by the gate's entry points.
3
+
4
+ make_non_dumpable(): on Linux, prctl(PR_SET_DUMPABLE, 0)
5
+ marks this process non-dumpable, after which /proc/<pid>/
6
+ environ (and the rest of /proc/<pid>) is no longer readable
7
+ by same-uid child processes. The evidence sandbox's env-only
8
+ mode scrubs the CHILD's environment, but a sandboxed command
9
+ could read the gate's full environment back out of
10
+ /proc/$PPID/environ (Phase 3, P3-04); namespace mode blocked
11
+ that incidentally, env-only mode did not. Applied at the CLI
12
+ and MCP entry points and lazily at the sandbox and discovery
13
+ choke points, so any host process that runs untrusted
14
+ commands gets the protection. Failure-silent (hardening is
15
+ best-effort, never a run failure), Linux-only, idempotent.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+
22
+ _APPLIED = False
23
+
24
+
25
+ def make_non_dumpable():
26
+ """Mark this process non-dumpable (Linux prctl). Returns
27
+ True when the setting is in effect. Never raises."""
28
+ global _APPLIED
29
+ if _APPLIED:
30
+ return True
31
+ if not sys.platform.startswith("linux"):
32
+ return False
33
+ try:
34
+ import ctypes
35
+ import ctypes.util
36
+ libname = ctypes.util.find_library("c")
37
+ libc = ctypes.CDLL(libname) if libname \
38
+ else ctypes.CDLL(None)
39
+ PR_SET_DUMPABLE = 4 # <sys/prctl.h>
40
+ if libc.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) != 0:
41
+ return False
42
+ except Exception: # noqa: BLE001 — best-effort by design
43
+ return False
44
+ _APPLIED = True
45
+ return True