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/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
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, ` 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("&", "&").replace("<", "<") \
|
|
163
|
+
.replace(">", ">")
|
|
164
|
+
out = out.replace("@", "@\u200b")
|
|
165
|
+
out = out.replace("
|
|
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
|
codereview/hardening.py
ADDED
|
@@ -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
|