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.
@@ -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