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
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview first-run setup wizard (OpenClaw-style).
|
|
3
|
+
|
|
4
|
+
Run on first open — interactively by the review entry point
|
|
5
|
+
when nothing is configured, or any time via `codereview
|
|
6
|
+
setup`. The user, not the product, chooses the models:
|
|
7
|
+
|
|
8
|
+
0. discovery (Epic K1): the wizard first scans PATH for the
|
|
9
|
+
agent CLIs discovery.py knows and offers the found ones
|
|
10
|
+
as seats — they review on the user's existing CLI logins,
|
|
11
|
+
no API key needed. Discovery proposes; only a confirmed
|
|
12
|
+
pick becomes a spec, indistinguishable from a hand-typed
|
|
13
|
+
agent seat from then on;
|
|
14
|
+
1. pick providers (registry presets, custom specs, agent
|
|
15
|
+
seats, local model servers) — the registry's default
|
|
16
|
+
models are shown as SUGGESTIONS to confirm or replace,
|
|
17
|
+
never as decisions. Local presets (Epic L5: ollama,
|
|
18
|
+
lmstudio, or any local:<name>:<base-url>|<model>
|
|
19
|
+
endpoint) are listed as their own section with the
|
|
20
|
+
short recipe for each; they are keyless, so step 2
|
|
21
|
+
never prompts for a key for them — there is none;
|
|
22
|
+
2. keys: for each API seat the wizard names the environment
|
|
23
|
+
variable that must hold the user's key and reports whether
|
|
24
|
+
it is currently set. The wizard never asks for a key value,
|
|
25
|
+
never echoes one, and never writes one anywhere — the
|
|
26
|
+
saved config references env var names only;
|
|
27
|
+
3. assign a model per role (correctness, operations,
|
|
28
|
+
verifier, tech-lead, solver): each prompt shows the
|
|
29
|
+
candidate specs; the user confirms one, types another
|
|
30
|
+
spec, or leaves the role to the panel;
|
|
31
|
+
4. save to the user config (userconfig) as the defaults.
|
|
32
|
+
|
|
33
|
+
input/print are injectable so the whole flow is testable with
|
|
34
|
+
scripted answers; EOF at any prompt aborts WITHOUT saving.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
from . import discovery, registry, userconfig
|
|
42
|
+
from .providers import (ProviderError, _parse_agent_spec,
|
|
43
|
+
describe_spec)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Bound on provider-entry attempts in the wizard: without it
|
|
47
|
+
# the provider loop re-asked forever on persistently invalid
|
|
48
|
+
# input (review finding a509b806fda6). Exhausting the bound
|
|
49
|
+
# aborts through the same EOF channel as an EOF at any prompt —
|
|
50
|
+
# nothing is saved and the caller exits cleanly.
|
|
51
|
+
MAX_PROVIDER_ATTEMPTS = 3
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ask(input_fn, print_fn, prompt):
|
|
55
|
+
try:
|
|
56
|
+
return input_fn(prompt).strip()
|
|
57
|
+
except EOFError:
|
|
58
|
+
print_fn("setup aborted — nothing saved.")
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _panel_families(providers):
|
|
63
|
+
"""Distinct VENDOR families a confirmed panel covers, for
|
|
64
|
+
the discovery family warning. API seats read their family
|
|
65
|
+
from the spec; agent seats resolve their vendor through the
|
|
66
|
+
discovery table by seat name (agent:claude:* is Anthropic's
|
|
67
|
+
CLI) — an agent seat the table does not know contributes no
|
|
68
|
+
family, the same conservative reading the gate's derivation
|
|
69
|
+
makes (its family tag is 'agent', never a guessed vendor)."""
|
|
70
|
+
families = set()
|
|
71
|
+
for spec in providers:
|
|
72
|
+
family = None
|
|
73
|
+
if spec.startswith("agent:"):
|
|
74
|
+
try:
|
|
75
|
+
name, _opts, _cmd = _parse_agent_spec(spec)
|
|
76
|
+
except ProviderError:
|
|
77
|
+
continue
|
|
78
|
+
family = discovery.family_for_seat_name(name)
|
|
79
|
+
else:
|
|
80
|
+
try:
|
|
81
|
+
family = describe_spec(spec)["family"]
|
|
82
|
+
except ProviderError:
|
|
83
|
+
continue
|
|
84
|
+
if family and family not in ("agent", "unknown"):
|
|
85
|
+
families.add(family)
|
|
86
|
+
return families
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _discovery_step(input_fn, print_fn, environ, discover_fn):
|
|
90
|
+
"""Epic K1: offer the agent CLIs found on PATH as seats.
|
|
91
|
+
|
|
92
|
+
Returns the list of agent specs the user confirmed (empty
|
|
93
|
+
when nothing was found or everything was declined). Found
|
|
94
|
+
CLIs without a verified seat command are reported — with
|
|
95
|
+
their resolved path and probe outcome — but never offered:
|
|
96
|
+
the wizard pre-fills no invocation this repository has not
|
|
97
|
+
verified. EOF at the pick prompt aborts via _ask, like
|
|
98
|
+
every other wizard prompt."""
|
|
99
|
+
found = (discover_fn() if discover_fn is not None
|
|
100
|
+
else discovery.discover_clis(environ.get("PATH")))
|
|
101
|
+
if not found:
|
|
102
|
+
print_fn("No supported agent CLIs found on PATH — "
|
|
103
|
+
"continuing with API providers.")
|
|
104
|
+
return []
|
|
105
|
+
print_fn("Agent CLIs found on this machine — they review "
|
|
106
|
+
"using your existing CLI logins, no API key "
|
|
107
|
+
"needed:")
|
|
108
|
+
offerable = []
|
|
109
|
+
for entry in found:
|
|
110
|
+
if entry["probe_ok"]:
|
|
111
|
+
probe_txt = "version probe ok"
|
|
112
|
+
if entry["probe_detail"]:
|
|
113
|
+
probe_txt += f" ({entry['probe_detail']})"
|
|
114
|
+
else:
|
|
115
|
+
probe_txt = ("version probe FAILED "
|
|
116
|
+
f"({entry['probe_detail']})")
|
|
117
|
+
if entry["command"]:
|
|
118
|
+
offerable.append(entry)
|
|
119
|
+
print_fn(f" {len(offerable)}. {entry['display']} "
|
|
120
|
+
f"— {entry['path']} ({probe_txt}) — can "
|
|
121
|
+
"be added as a seat")
|
|
122
|
+
else:
|
|
123
|
+
print_fn(f" - {entry['display']} — {entry['path']} "
|
|
124
|
+
f"({probe_txt}) — found, but CodeReview "
|
|
125
|
+
"has no verified seat command for it "
|
|
126
|
+
"yet; to seat it, enter "
|
|
127
|
+
f"agent:{entry['name']}:<your command> "
|
|
128
|
+
"in the provider step below")
|
|
129
|
+
if not offerable:
|
|
130
|
+
return []
|
|
131
|
+
raw = _ask(input_fn, print_fn,
|
|
132
|
+
"Add any as seats? Numbers or names, "
|
|
133
|
+
"comma-separated (Enter for none): ")
|
|
134
|
+
chosen = []
|
|
135
|
+
for token in (t.strip() for t in raw.split(",")):
|
|
136
|
+
if not token:
|
|
137
|
+
continue
|
|
138
|
+
entry = None
|
|
139
|
+
if token.isdigit():
|
|
140
|
+
idx = int(token) - 1
|
|
141
|
+
if 0 <= idx < len(offerable):
|
|
142
|
+
entry = offerable[idx]
|
|
143
|
+
else:
|
|
144
|
+
entry = next(
|
|
145
|
+
(e for e in offerable
|
|
146
|
+
if e["name"] == token.lower()), None)
|
|
147
|
+
if entry is None:
|
|
148
|
+
print_fn(f" ! {token!r} is not one of the "
|
|
149
|
+
"choices — ignored.")
|
|
150
|
+
elif entry not in chosen:
|
|
151
|
+
chosen.append(entry)
|
|
152
|
+
picked = []
|
|
153
|
+
for entry in chosen:
|
|
154
|
+
picked.append(discovery.seat_spec(entry))
|
|
155
|
+
print_fn(f" + {entry['display']} added as an agent "
|
|
156
|
+
"seat.")
|
|
157
|
+
if picked:
|
|
158
|
+
print_fn("You can add API providers below too, or "
|
|
159
|
+
"press Enter at the provider prompt to keep "
|
|
160
|
+
"just the discovered seats.")
|
|
161
|
+
return picked
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def run_wizard(input_fn=input, print_fn=print, environ=None,
|
|
165
|
+
probe_fn=None, discover_fn=None):
|
|
166
|
+
"""Drive the wizard; returns the saved config dict
|
|
167
|
+
({"providers", "roles", "path"}), or None when aborted.
|
|
168
|
+
`probe_fn(providers_dict) -> doctor-result` is optional;
|
|
169
|
+
when given, the user is offered a live seat probe before
|
|
170
|
+
saving (the CLI wires core.run_doctor). `discover_fn()` ->
|
|
171
|
+
found-CLI list is the Epic K1 seam: None runs real PATH
|
|
172
|
+
discovery against `environ`'s PATH (discovery.discover_
|
|
173
|
+
clis); tests inject a fake machine."""
|
|
174
|
+
environ = os.environ if environ is None else environ
|
|
175
|
+
print_fn("CodeReview setup — you choose the models; nothing "
|
|
176
|
+
"is pre-picked.")
|
|
177
|
+
print_fn("CodeReview is a multi-model review gate for "
|
|
178
|
+
"AI-written code: a panel of models from different "
|
|
179
|
+
"providers reviews every change, cross-checks each "
|
|
180
|
+
"other's findings, and issues a recorded pass/block "
|
|
181
|
+
"verdict — so the model that wrote the code is never "
|
|
182
|
+
"the only one that checked it.")
|
|
183
|
+
picked = _discovery_step(input_fn, print_fn, environ,
|
|
184
|
+
discover_fn)
|
|
185
|
+
print_fn("Providers (preset[:model], a custom "
|
|
186
|
+
"name|type|base|model|KEY_ENV spec, or "
|
|
187
|
+
"agent:<name>:<command>):")
|
|
188
|
+
for name, preset in registry.PRESETS.items():
|
|
189
|
+
if preset.get("family") == registry.LOCAL_FAMILY:
|
|
190
|
+
continue # local presets get their own section
|
|
191
|
+
print_fn(f" {name:<12} suggested model: "
|
|
192
|
+
f"{preset['model']} key env: "
|
|
193
|
+
f"{preset['key_env']}")
|
|
194
|
+
print_fn("Local model servers — they run on your own "
|
|
195
|
+
"machine: no API key, and the review never "
|
|
196
|
+
"leaves the machine (no network):")
|
|
197
|
+
for name, preset in registry.local_presets().items():
|
|
198
|
+
print_fn(f" {name:<12} endpoint: "
|
|
199
|
+
f"{preset['base_url']} suggested model: "
|
|
200
|
+
f"{preset['model']}")
|
|
201
|
+
print_fn(f" {'':<12} recipe: {preset['recipe']}")
|
|
202
|
+
print_fn(" any other local server: "
|
|
203
|
+
"local:<name>:<base-url>|<model> (e.g. "
|
|
204
|
+
"local:box:http://localhost:8080/v1|my-model) — "
|
|
205
|
+
"start the server, pick the spec, then probe "
|
|
206
|
+
"with `codereview doctor`")
|
|
207
|
+
providers = []
|
|
208
|
+
attempts = 0
|
|
209
|
+
while not providers:
|
|
210
|
+
raw = _ask(input_fn, print_fn,
|
|
211
|
+
"Providers, comma-separated: ")
|
|
212
|
+
attempts += 1
|
|
213
|
+
candidates = [s.strip() for s in raw.split(",")
|
|
214
|
+
if s.strip()]
|
|
215
|
+
if not candidates and picked:
|
|
216
|
+
# Enter at the provider prompt keeps the seats the
|
|
217
|
+
# discovery step confirmed — only reachable when
|
|
218
|
+
# discovery contributed, so a hand-typed flow sees
|
|
219
|
+
# today's behaviour byte-for-byte.
|
|
220
|
+
providers = list(picked)
|
|
221
|
+
break
|
|
222
|
+
bad = []
|
|
223
|
+
for spec in candidates:
|
|
224
|
+
try:
|
|
225
|
+
describe_spec(spec)
|
|
226
|
+
except ProviderError as exc:
|
|
227
|
+
bad.append(str(exc))
|
|
228
|
+
if not candidates:
|
|
229
|
+
bad.append("no providers entered")
|
|
230
|
+
if bad:
|
|
231
|
+
for msg in bad:
|
|
232
|
+
print_fn(f" ! {msg}")
|
|
233
|
+
if attempts >= MAX_PROVIDER_ATTEMPTS:
|
|
234
|
+
print_fn("setup aborted — too many invalid "
|
|
235
|
+
"provider entries; nothing saved.")
|
|
236
|
+
raise EOFError
|
|
237
|
+
continue
|
|
238
|
+
# An exact repeat of a discovery pick (the user pasted
|
|
239
|
+
# the same agent spec by hand) would seat the identical
|
|
240
|
+
# command twice under two names — one model wearing
|
|
241
|
+
# two seat hats. Drop exact duplicates of a pick, with
|
|
242
|
+
# a note; anything else the user typed stands as typed.
|
|
243
|
+
for spec in candidates:
|
|
244
|
+
if spec in picked:
|
|
245
|
+
print_fn(f" ! {spec} is already in the panel "
|
|
246
|
+
"from discovery — duplicate skipped.")
|
|
247
|
+
providers = picked + [s for s in candidates
|
|
248
|
+
if s not in picked]
|
|
249
|
+
if picked:
|
|
250
|
+
# Family-diversity guard (Epic K1): discovery makes it
|
|
251
|
+
# easy to build a one-family panel without noticing.
|
|
252
|
+
# Warn in plain words and offer one API spec as the
|
|
253
|
+
# second family; never block saving — the gate's
|
|
254
|
+
# degraded-panel rules govern what a one-family panel
|
|
255
|
+
# may conclude.
|
|
256
|
+
families = _panel_families(providers)
|
|
257
|
+
if len(families) < 2:
|
|
258
|
+
fam_txt = (f" ({', '.join(sorted(families))})"
|
|
259
|
+
if families else "")
|
|
260
|
+
print_fn("Panel check: the seats above cover a "
|
|
261
|
+
f"single vendor family{fam_txt}. Reviews "
|
|
262
|
+
"still run, but the verification round "
|
|
263
|
+
"needs two distinct families to clear a "
|
|
264
|
+
"disputed finding — a one-family panel's "
|
|
265
|
+
"PASS can be held to INCONCLUSIVE.")
|
|
266
|
+
extra = _ask(
|
|
267
|
+
input_fn, print_fn,
|
|
268
|
+
"Add an API provider from another family? "
|
|
269
|
+
"Enter a provider spec (Enter to continue "
|
|
270
|
+
"with this panel): ")
|
|
271
|
+
if extra:
|
|
272
|
+
try:
|
|
273
|
+
describe_spec(extra)
|
|
274
|
+
except ProviderError as exc:
|
|
275
|
+
print_fn(f" ! {exc} — continuing with "
|
|
276
|
+
"the current panel.")
|
|
277
|
+
else:
|
|
278
|
+
if extra in providers:
|
|
279
|
+
print_fn(f" ! {extra} is already in "
|
|
280
|
+
"the panel — duplicate "
|
|
281
|
+
"skipped.")
|
|
282
|
+
else:
|
|
283
|
+
providers.append(extra)
|
|
284
|
+
for spec in providers:
|
|
285
|
+
desc = describe_spec(spec)
|
|
286
|
+
if desc["kind"] == "agent":
|
|
287
|
+
print_fn(f" seat {desc['seat']}: agent seat — it "
|
|
288
|
+
"authenticates as itself, no key env.")
|
|
289
|
+
elif desc["family"] == registry.LOCAL_FAMILY:
|
|
290
|
+
print_fn(f" seat {desc['seat']}: local model "
|
|
291
|
+
f"server at {desc['base_url']} — no API "
|
|
292
|
+
"key; it runs on your machine, so the "
|
|
293
|
+
"review never leaves it (no network).")
|
|
294
|
+
elif desc["key_env"]:
|
|
295
|
+
state = ("set" if environ.get(desc["key_env"])
|
|
296
|
+
else "NOT set")
|
|
297
|
+
print_fn(f" seat {desc['seat']}: key env "
|
|
298
|
+
f"{desc['key_env']} is {state}. Export it "
|
|
299
|
+
"before running reviews; the key itself is "
|
|
300
|
+
"never stored.")
|
|
301
|
+
print_fn("Assign a model per role. Enter accepts the "
|
|
302
|
+
"suggestion, type any provider spec to override, "
|
|
303
|
+
"'-' leaves the role to the panel.")
|
|
304
|
+
roles = {}
|
|
305
|
+
# The wizard assigns the serving roles; tech-lead-shadow is
|
|
306
|
+
# an opt-in evaluation seat assigned via --role or the saved
|
|
307
|
+
# config, not part of the first-run flow.
|
|
308
|
+
wizard_roles = [r for r in registry.ROLES
|
|
309
|
+
if r != "tech-lead-shadow"]
|
|
310
|
+
for i, role in enumerate(wizard_roles):
|
|
311
|
+
suggestion = providers[i % len(providers)]
|
|
312
|
+
answer = _ask(
|
|
313
|
+
input_fn, print_fn,
|
|
314
|
+
f" {role} [{suggestion}]: ")
|
|
315
|
+
if answer == "-":
|
|
316
|
+
continue
|
|
317
|
+
spec = answer or suggestion
|
|
318
|
+
try:
|
|
319
|
+
describe_spec(spec)
|
|
320
|
+
except ProviderError as exc:
|
|
321
|
+
print_fn(f" ! {exc} — role left to the panel.")
|
|
322
|
+
continue
|
|
323
|
+
roles[role] = spec
|
|
324
|
+
if probe_fn is not None:
|
|
325
|
+
answer = _ask(input_fn, print_fn,
|
|
326
|
+
"Probe the seats now? [y/N]: ")
|
|
327
|
+
if answer.lower().startswith("y"):
|
|
328
|
+
from .providers import build_providers
|
|
329
|
+
try:
|
|
330
|
+
result = probe_fn(build_providers(providers))
|
|
331
|
+
for seat in result.get("seats") or []:
|
|
332
|
+
print_fn(f" probe {seat.get('name')}: "
|
|
333
|
+
f"{'ok' if seat.get('ok') else 'FAILED'}")
|
|
334
|
+
except Exception as exc: # noqa: BLE001
|
|
335
|
+
print_fn(f" probe failed: {exc}")
|
|
336
|
+
# P6-06: persist the seat metadata the wizard derived
|
|
337
|
+
# for the confirmed panel (each spec's family, agent
|
|
338
|
+
# seats included — describe_spec resolves an agent seat
|
|
339
|
+
# to its CLI's vendor family). Runs apply the saved table
|
|
340
|
+
# over the derived baseline, so the families a saved
|
|
341
|
+
# panel is judged by are the ones setup computed.
|
|
342
|
+
seat_meta = {}
|
|
343
|
+
for spec in providers:
|
|
344
|
+
try:
|
|
345
|
+
desc = describe_spec(spec)
|
|
346
|
+
except ProviderError:
|
|
347
|
+
continue
|
|
348
|
+
seat_meta[desc["seat"]] = desc["family"]
|
|
349
|
+
path = userconfig.save_user_config(
|
|
350
|
+
providers, roles, environ=environ, seat_meta=seat_meta)
|
|
351
|
+
print_fn(f"Saved your setup to {path} — providers: "
|
|
352
|
+
f"{', '.join(providers)}; roles: "
|
|
353
|
+
f"{len(roles)} assigned.")
|
|
354
|
+
return {"providers": providers, "roles": roles,
|
|
355
|
+
"seat_meta": seat_meta, "path": path}
|
codereview/support.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Support bundle — release-gate row L18.
|
|
2
|
+
|
|
3
|
+
`codereview support-bundle` writes ONE zip a user can read and
|
|
4
|
+
attach to a bug report. It is built to be safe to read first and
|
|
5
|
+
safe to send:
|
|
6
|
+
|
|
7
|
+
* every text file passes through the same `redact_secrets` the
|
|
8
|
+
run records use;
|
|
9
|
+
* environment variables appear by NAME only (a fixed list of the
|
|
10
|
+
variables the product reads) and as set / not set — never a
|
|
11
|
+
value;
|
|
12
|
+
* nothing is sent anywhere by this module;
|
|
13
|
+
* a file over the per-file cap, or one that would push the zip
|
|
14
|
+
over the total cap, is left out and named in MANIFEST.txt —
|
|
15
|
+
never silently truncated;
|
|
16
|
+
* any step that fails (no records, a missing config, a doctor
|
|
17
|
+
that crashes) is recorded in MANIFEST.txt and the bundle is
|
|
18
|
+
still produced.
|
|
19
|
+
|
|
20
|
+
Run records hold excerpts of the code that was reviewed. Redaction
|
|
21
|
+
cannot recognise private code, so the bundle says so on screen and
|
|
22
|
+
offers --no-records.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import contextlib
|
|
28
|
+
import datetime
|
|
29
|
+
import glob
|
|
30
|
+
import io
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import platform
|
|
34
|
+
import sys
|
|
35
|
+
import zipfile
|
|
36
|
+
|
|
37
|
+
from .core import VERSION, redact_secrets
|
|
38
|
+
|
|
39
|
+
SCHEMA = "codereview.support-bundle/1"
|
|
40
|
+
MAX_FILE_BYTES = 2 * 1024 * 1024
|
|
41
|
+
MAX_TOTAL_BYTES = 8 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
WARNING = ("Read the bundle before you send it. Redaction removes "
|
|
44
|
+
"secret-shaped text, but run records contain excerpts of "
|
|
45
|
+
"the code you reviewed and redaction cannot recognise "
|
|
46
|
+
"private code. Use --no-records to leave records out. "
|
|
47
|
+
"Nothing is sent automatically.")
|
|
48
|
+
|
|
49
|
+
# Variables the product reads. Names only, ever.
|
|
50
|
+
_ENV_NAMES = (
|
|
51
|
+
"OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY",
|
|
52
|
+
"DEEPSEEK_API_KEY", "MINIMAX_API_KEY", "MOONSHOT_API_KEY",
|
|
53
|
+
"NVIDIA_API_KEY", "XAI_API_KEY", "MISTRAL_API_KEY",
|
|
54
|
+
"OPENROUTER_API_KEY", "GITHUB_TOKEN")
|
|
55
|
+
_ENV_PREFIXES = ("CODEREVIEW_", "REVIEWLOOP_")
|
|
56
|
+
_SECRET_ENV_NAME_WORDS = ("KEY", "TOKEN", "SECRET", "PASSWORD")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _Bundle:
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.files = [] # (arcname, bytes, reason)
|
|
62
|
+
self.skipped = [] # (name, reason)
|
|
63
|
+
self.notes = []
|
|
64
|
+
self._total = 0
|
|
65
|
+
|
|
66
|
+
def add_text(self, arcname, text, reason, redact=True):
|
|
67
|
+
if redact:
|
|
68
|
+
try:
|
|
69
|
+
text = redact_secrets(text)
|
|
70
|
+
except Exception as exc: # noqa: BLE001
|
|
71
|
+
self.skipped.append(
|
|
72
|
+
(arcname, f"redaction failed ({type(exc).__name__});"
|
|
73
|
+
" file left out"))
|
|
74
|
+
return
|
|
75
|
+
data = text.encode("utf-8", "replace")
|
|
76
|
+
if len(data) > MAX_FILE_BYTES:
|
|
77
|
+
self.skipped.append(
|
|
78
|
+
(arcname, f"{len(data)} bytes exceeds the "
|
|
79
|
+
f"{MAX_FILE_BYTES}-byte per-file cap"))
|
|
80
|
+
return
|
|
81
|
+
if self._total + len(data) > MAX_TOTAL_BYTES:
|
|
82
|
+
self.skipped.append(
|
|
83
|
+
(arcname, f"would exceed the {MAX_TOTAL_BYTES}-byte "
|
|
84
|
+
"bundle cap"))
|
|
85
|
+
return
|
|
86
|
+
self._total += len(data)
|
|
87
|
+
self.files.append((arcname, data, reason))
|
|
88
|
+
|
|
89
|
+
def add_file(self, arcname, path, reason):
|
|
90
|
+
try:
|
|
91
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
92
|
+
text = fh.read(MAX_FILE_BYTES + 1)
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
self.skipped.append((arcname, f"unreadable: {exc}"))
|
|
95
|
+
return
|
|
96
|
+
self.add_text(arcname, text, reason)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _env_report(environ):
|
|
100
|
+
lines = []
|
|
101
|
+
for name in sorted(set(_ENV_NAMES) | {
|
|
102
|
+
k for k in environ if k.startswith(_ENV_PREFIXES)}):
|
|
103
|
+
is_set = bool(str(environ.get(name, "")).strip())
|
|
104
|
+
lines.append(f"{name}: {'set' if is_set else 'not set'}")
|
|
105
|
+
return "\n".join(lines) + "\n"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _packages():
|
|
109
|
+
try:
|
|
110
|
+
from importlib import metadata
|
|
111
|
+
rows = sorted(
|
|
112
|
+
f"{d.metadata['Name']}=={d.version}"
|
|
113
|
+
for d in metadata.distributions()
|
|
114
|
+
if d.metadata and d.metadata["Name"])
|
|
115
|
+
return "\n".join(rows) + "\n"
|
|
116
|
+
except Exception as exc: # noqa: BLE001
|
|
117
|
+
return f"(package list unavailable: {type(exc).__name__})\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _find_records(records_dir):
|
|
121
|
+
dirs = []
|
|
122
|
+
if records_dir:
|
|
123
|
+
dirs.append(records_dir)
|
|
124
|
+
else:
|
|
125
|
+
dirs.extend(["codereview-out", "."])
|
|
126
|
+
for directory in dirs:
|
|
127
|
+
if not os.path.isdir(directory):
|
|
128
|
+
continue
|
|
129
|
+
found = [p for p in glob.glob(
|
|
130
|
+
os.path.join(directory, "codereview-*.json"))
|
|
131
|
+
if "crash" not in os.path.basename(p)
|
|
132
|
+
and not p.endswith(".sidecar.json")]
|
|
133
|
+
if found:
|
|
134
|
+
found.sort(key=lambda p: os.path.getmtime(p))
|
|
135
|
+
return directory, found[-1]
|
|
136
|
+
return None, None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_crash_notes(directories):
|
|
140
|
+
seen, notes = set(), []
|
|
141
|
+
for directory in directories:
|
|
142
|
+
if not directory:
|
|
143
|
+
continue
|
|
144
|
+
for p in glob.glob(os.path.join(
|
|
145
|
+
directory, "codereview-crash-*.json")):
|
|
146
|
+
rp = os.path.realpath(p)
|
|
147
|
+
if rp not in seen:
|
|
148
|
+
seen.add(rp)
|
|
149
|
+
notes.append(p)
|
|
150
|
+
notes.sort(key=lambda p: os.path.getmtime(p))
|
|
151
|
+
return notes[-3:]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _run_doctor(bundle):
|
|
155
|
+
from . import cli
|
|
156
|
+
out, err = io.StringIO(), io.StringIO()
|
|
157
|
+
try:
|
|
158
|
+
with contextlib.redirect_stdout(out), \
|
|
159
|
+
contextlib.redirect_stderr(err):
|
|
160
|
+
rc = cli._doctor_main([])
|
|
161
|
+
bundle.add_text("doctor.txt",
|
|
162
|
+
f"exit code: {rc}\n{out.getvalue()}\n"
|
|
163
|
+
f"{err.getvalue()}",
|
|
164
|
+
"output of 'codereview doctor'")
|
|
165
|
+
except BaseException as exc: # noqa: BLE001 — recorded, not raised
|
|
166
|
+
bundle.add_text("doctor.txt",
|
|
167
|
+
f"doctor did not complete: "
|
|
168
|
+
f"{type(exc).__name__}: {exc}\n"
|
|
169
|
+
f"{out.getvalue()}{err.getvalue()}",
|
|
170
|
+
"doctor output (it failed)")
|
|
171
|
+
bundle.notes.append(
|
|
172
|
+
"doctor failed; its failure is recorded in doctor.txt")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_bundle(path, records_dir=None, include_records=True,
|
|
176
|
+
message="", environ=None, with_doctor=False):
|
|
177
|
+
"""Write the zip at `path` and return its manifest dict."""
|
|
178
|
+
environ = os.environ if environ is None else environ
|
|
179
|
+
bundle = _Bundle()
|
|
180
|
+
now = datetime.datetime.now(datetime.timezone.utc) \
|
|
181
|
+
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
182
|
+
from .crash import build_identity
|
|
183
|
+
identity = {"schema": SCHEMA, "created_at": now,
|
|
184
|
+
**build_identity(),
|
|
185
|
+
"python": platform.python_version(),
|
|
186
|
+
"platform": platform.platform(),
|
|
187
|
+
"machine": platform.machine()}
|
|
188
|
+
bundle.add_text("identity.json",
|
|
189
|
+
json.dumps(identity, indent=2, sort_keys=True),
|
|
190
|
+
"build, Python and OS identity")
|
|
191
|
+
bundle.add_text("packages.txt", _packages(),
|
|
192
|
+
"installed Python packages", redact=False)
|
|
193
|
+
bundle.add_text("environment-names.txt", _env_report(environ),
|
|
194
|
+
"which variables the product reads are set "
|
|
195
|
+
"(names only, never values)", redact=False)
|
|
196
|
+
if message.strip():
|
|
197
|
+
bundle.add_text("MESSAGE.txt", message,
|
|
198
|
+
"your description of the problem")
|
|
199
|
+
# user config and project policy
|
|
200
|
+
from . import userconfig
|
|
201
|
+
cfg = userconfig.config_path(environ)
|
|
202
|
+
if os.path.isfile(cfg):
|
|
203
|
+
bundle.add_file("user-config.toml", cfg,
|
|
204
|
+
"your saved provider setup (redacted)")
|
|
205
|
+
else:
|
|
206
|
+
bundle.notes.append("no saved user config found")
|
|
207
|
+
policy_path = ".codereview.toml"
|
|
208
|
+
if os.path.isfile(policy_path):
|
|
209
|
+
bundle.add_file("policy.toml", policy_path,
|
|
210
|
+
"project policy (redacted)")
|
|
211
|
+
else:
|
|
212
|
+
bundle.notes.append("no .codereview.toml in the current "
|
|
213
|
+
"directory")
|
|
214
|
+
# records and crash notes
|
|
215
|
+
rec_dir = None
|
|
216
|
+
if include_records:
|
|
217
|
+
rec_dir, record = _find_records(records_dir)
|
|
218
|
+
if record:
|
|
219
|
+
bundle.add_file(os.path.join("records",
|
|
220
|
+
os.path.basename(record)),
|
|
221
|
+
record, "latest run record (JSON)")
|
|
222
|
+
md = os.path.splitext(record)[0] + ".md"
|
|
223
|
+
if os.path.isfile(md):
|
|
224
|
+
bundle.add_file(
|
|
225
|
+
os.path.join("records", os.path.basename(md)),
|
|
226
|
+
md, "latest run record (report)")
|
|
227
|
+
else:
|
|
228
|
+
bundle.notes.append("no run record found")
|
|
229
|
+
else:
|
|
230
|
+
bundle.notes.append("run records left out (--no-records)")
|
|
231
|
+
for note in _find_crash_notes([rec_dir or records_dir, "."]):
|
|
232
|
+
bundle.add_file(os.path.join("crash", os.path.basename(note)),
|
|
233
|
+
note, "crash note (redacted)")
|
|
234
|
+
if with_doctor:
|
|
235
|
+
_run_doctor(bundle)
|
|
236
|
+
else:
|
|
237
|
+
bundle.notes.append(
|
|
238
|
+
"doctor not run (it calls your providers); add "
|
|
239
|
+
"--with-doctor to include it")
|
|
240
|
+
manifest = {
|
|
241
|
+
"entries": [{"name": n, "size": len(d), "reason": r}
|
|
242
|
+
for n, d, r in bundle.files],
|
|
243
|
+
"skipped": [{"name": n, "reason": r}
|
|
244
|
+
for n, r in bundle.skipped],
|
|
245
|
+
"notes": list(bundle.notes),
|
|
246
|
+
}
|
|
247
|
+
lines = [f"CodeReview support bundle ({SCHEMA})", f"created {now}",
|
|
248
|
+
"", "Files:"]
|
|
249
|
+
for e in manifest["entries"]:
|
|
250
|
+
lines.append(f" {e['name']} ({e['size']} bytes) — "
|
|
251
|
+
f"{e['reason']}")
|
|
252
|
+
lines.append(" MANIFEST.txt — this list")
|
|
253
|
+
if manifest["skipped"]:
|
|
254
|
+
lines += ["", "Left out:"]
|
|
255
|
+
lines += [f" {s['name']} — {s['reason']}"
|
|
256
|
+
for s in manifest["skipped"]]
|
|
257
|
+
if manifest["notes"]:
|
|
258
|
+
lines += ["", "Notes:"] + [f" {n}" for n in manifest["notes"]]
|
|
259
|
+
lines += ["", WARNING, ""]
|
|
260
|
+
manifest_text = "\n".join(lines)
|
|
261
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
262
|
+
with os.fdopen(fd, "wb") as raw:
|
|
263
|
+
with zipfile.ZipFile(raw, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
264
|
+
zf.writestr("MANIFEST.txt", manifest_text)
|
|
265
|
+
for name, data, _reason in bundle.files:
|
|
266
|
+
zf.writestr(name, data)
|
|
267
|
+
manifest["text"] = manifest_text
|
|
268
|
+
return manifest
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def describe(manifest, show=False, path=None):
|
|
272
|
+
parts = [manifest["text"].rstrip()]
|
|
273
|
+
if show and path:
|
|
274
|
+
with zipfile.ZipFile(path) as zf:
|
|
275
|
+
for name in zf.namelist():
|
|
276
|
+
if name == "MANIFEST.txt":
|
|
277
|
+
continue
|
|
278
|
+
parts.append(f"\n===== {name} =====")
|
|
279
|
+
parts.append(zf.read(name).decode("utf-8", "replace"))
|
|
280
|
+
return "\n".join(parts)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _unique_path(base):
|
|
284
|
+
if not os.path.exists(base):
|
|
285
|
+
return base
|
|
286
|
+
stem, ext = os.path.splitext(base)
|
|
287
|
+
n = 2
|
|
288
|
+
while os.path.exists(f"{stem}-{n}{ext}"):
|
|
289
|
+
n += 1
|
|
290
|
+
return f"{stem}-{n}{ext}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def support_main(argv, environ=None):
|
|
294
|
+
import argparse
|
|
295
|
+
ap = argparse.ArgumentParser(
|
|
296
|
+
prog="codereview support-bundle",
|
|
297
|
+
description="Write one redacted zip to attach to a bug "
|
|
298
|
+
"report. Nothing is sent.")
|
|
299
|
+
ap.add_argument("--out", default=None,
|
|
300
|
+
help="zip path (default: "
|
|
301
|
+
"codereview-support-<time>.zip here)")
|
|
302
|
+
ap.add_argument("--records", default=None,
|
|
303
|
+
help="directory holding the run records "
|
|
304
|
+
"(default: ./codereview-out, then .)")
|
|
305
|
+
ap.add_argument("--no-records", action="store_true",
|
|
306
|
+
help="leave run records out of the bundle")
|
|
307
|
+
ap.add_argument("--with-doctor", action="store_true",
|
|
308
|
+
help="also run 'codereview doctor' (calls your "
|
|
309
|
+
"providers)")
|
|
310
|
+
ap.add_argument("--message", default="",
|
|
311
|
+
help="what went wrong, in your words")
|
|
312
|
+
args = ap.parse_args(argv)
|
|
313
|
+
stamp = datetime.datetime.now(datetime.timezone.utc) \
|
|
314
|
+
.strftime("%Y%m%d-%H%M%S")
|
|
315
|
+
path = _unique_path(args.out or f"codereview-support-{stamp}.zip")
|
|
316
|
+
try:
|
|
317
|
+
manifest = build_bundle(
|
|
318
|
+
path, records_dir=args.records,
|
|
319
|
+
include_records=not args.no_records,
|
|
320
|
+
message=args.message, environ=environ,
|
|
321
|
+
with_doctor=args.with_doctor)
|
|
322
|
+
except Exception as exc: # noqa: BLE001
|
|
323
|
+
print(f"codereview support-bundle: could not write the "
|
|
324
|
+
f"bundle: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
325
|
+
return 2
|
|
326
|
+
print(describe(manifest))
|
|
327
|
+
print(f"\nbundle written: {path}")
|
|
328
|
+
return 0
|