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/feedback.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""User feedback — release-gate row L19. Opt-in only.
|
|
2
|
+
|
|
3
|
+
Nothing in this module runs, or sends anything, unless the user
|
|
4
|
+
asks for it:
|
|
5
|
+
|
|
6
|
+
codereview report build the redacted support bundle, show
|
|
7
|
+
exactly what it holds, and send it only
|
|
8
|
+
after the user confirms (or passes --yes);
|
|
9
|
+
codereview telemetry enable | disable | status for the optional
|
|
10
|
+
anonymous crash ping. OFF by default.
|
|
11
|
+
|
|
12
|
+
The crash ping carries a fixed, minimal field set (see
|
|
13
|
+
`ping_payload`) and never code, diffs, findings, paths, repository
|
|
14
|
+
names, policy contents, environment values or hostnames. The
|
|
15
|
+
random install id is generated when the user enables the ping and
|
|
16
|
+
is deleted when they disable it.
|
|
17
|
+
|
|
18
|
+
Rules that protect the gate (pinned by tests):
|
|
19
|
+
* sending never changes an exit code, never raises, and uses one
|
|
20
|
+
short attempt;
|
|
21
|
+
* HTTPS only, certificate verification on, no redirects; the one
|
|
22
|
+
exception is plain http to a loopback host, which exists so the
|
|
23
|
+
client can be tested against a local server;
|
|
24
|
+
* the endpoint is the owner's, set in ENDPOINT at release; the
|
|
25
|
+
user can override it with CODEREVIEW_FEEDBACK_URL. While no
|
|
26
|
+
endpoint is configured nothing can be sent, and the report
|
|
27
|
+
command says so and shows the manual route;
|
|
28
|
+
* hooks, probes, evidence commands and agent seats run with a
|
|
29
|
+
scrubbed environment and a relocated HOME, so they find no
|
|
30
|
+
telemetry state and cannot send.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import datetime
|
|
36
|
+
import hashlib
|
|
37
|
+
import ipaddress
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
import urllib.error
|
|
42
|
+
import urllib.parse
|
|
43
|
+
import urllib.request
|
|
44
|
+
import uuid
|
|
45
|
+
|
|
46
|
+
from . import providers as _providers
|
|
47
|
+
from .core import VERSION
|
|
48
|
+
|
|
49
|
+
PING_SCHEMA = "codereview.crash-ping/1"
|
|
50
|
+
ENDPOINT = "https://phpatch.com/codereview/report" # owner, at release
|
|
51
|
+
ISSUES_URL = "https://gitlab.com/superstartelecom/codereview/-/issues"
|
|
52
|
+
TIMEOUT_S = 3.0
|
|
53
|
+
DAILY_CAP = 5
|
|
54
|
+
_MAX_RESPONSE = 65536
|
|
55
|
+
|
|
56
|
+
PING_FIELDS = ("schema", "version", "git_hash", "python",
|
|
57
|
+
"platform", "machine", "command", "exception_type",
|
|
58
|
+
"top_frame", "exit_code", "install_id")
|
|
59
|
+
|
|
60
|
+
_OFF = ("0", "false", "off", "no")
|
|
61
|
+
_ON = ("1", "true", "on", "yes")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------ state
|
|
65
|
+
|
|
66
|
+
def state_dir(environ=None):
|
|
67
|
+
environ = os.environ if environ is None else environ
|
|
68
|
+
override = environ.get("CODEREVIEW_STATE_DIR", "").strip()
|
|
69
|
+
if override:
|
|
70
|
+
return override
|
|
71
|
+
xdg = environ.get("XDG_STATE_HOME", "").strip()
|
|
72
|
+
base = xdg or os.path.join(environ.get("HOME", "~"), ".local",
|
|
73
|
+
"state")
|
|
74
|
+
return os.path.join(base, "codereview")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _state_path(environ=None):
|
|
78
|
+
return os.path.join(state_dir(environ), "telemetry.json")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ratelimit_path(environ=None):
|
|
82
|
+
return os.path.join(state_dir(environ), "ping-limit.json")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_json(path):
|
|
86
|
+
try:
|
|
87
|
+
with open(path, encoding="utf-8") as fh:
|
|
88
|
+
data = json.load(fh)
|
|
89
|
+
return data if isinstance(data, dict) else {}
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_json(path, data):
|
|
95
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
96
|
+
tmp = f"{path}.tmp-{os.getpid()}"
|
|
97
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
98
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
99
|
+
json.dump(data, fh, indent=2, sort_keys=True)
|
|
100
|
+
fh.write("\n")
|
|
101
|
+
os.replace(tmp, path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def telemetry_mode(environ=None):
|
|
105
|
+
"""-> ("off"|"on", why). The environment variable, when set to
|
|
106
|
+
an off value, always wins over any stored consent."""
|
|
107
|
+
environ = os.environ if environ is None else environ
|
|
108
|
+
raw = environ.get("CODEREVIEW_TELEMETRY", "").strip().lower()
|
|
109
|
+
if raw in _OFF:
|
|
110
|
+
return "off", "CODEREVIEW_TELEMETRY is set to off"
|
|
111
|
+
if raw in _ON:
|
|
112
|
+
return "on", "CODEREVIEW_TELEMETRY is set to on"
|
|
113
|
+
state = _read_json(_state_path(environ))
|
|
114
|
+
if state.get("enabled") is True and state.get("install_id"):
|
|
115
|
+
return "on", "enabled with 'codereview telemetry enable'"
|
|
116
|
+
return "off", "not enabled (the default)"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def install_id(environ=None):
|
|
120
|
+
state = _read_json(_state_path(environ))
|
|
121
|
+
value = state.get("install_id")
|
|
122
|
+
return value if isinstance(value, str) and value else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --------------------------------------------------------- endpoint
|
|
126
|
+
|
|
127
|
+
def resolve_endpoint(environ=None):
|
|
128
|
+
"""-> (url or None, problem or None)."""
|
|
129
|
+
environ = os.environ if environ is None else environ
|
|
130
|
+
url = environ.get("CODEREVIEW_FEEDBACK_URL", "").strip() \
|
|
131
|
+
or ENDPOINT
|
|
132
|
+
if not url:
|
|
133
|
+
return None, "no feedback endpoint is configured in this build"
|
|
134
|
+
problem = _endpoint_problem(url)
|
|
135
|
+
return (None, problem) if problem else (url, None)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_loopback(host):
|
|
139
|
+
if host in ("localhost",):
|
|
140
|
+
return True
|
|
141
|
+
try:
|
|
142
|
+
return ipaddress.ip_address(host).is_loopback
|
|
143
|
+
except ValueError:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _endpoint_problem(url):
|
|
148
|
+
parts = urllib.parse.urlsplit(url)
|
|
149
|
+
if parts.scheme == "https" and parts.hostname:
|
|
150
|
+
return None
|
|
151
|
+
if parts.scheme == "http" and parts.hostname \
|
|
152
|
+
and _is_loopback(parts.hostname):
|
|
153
|
+
return None
|
|
154
|
+
return ("the feedback endpoint must be https:// "
|
|
155
|
+
"(plain http is accepted only for a loopback host)")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _post(url, body, content_type, headers=None, timeout=TIMEOUT_S):
|
|
159
|
+
"""One POST, no redirects, bounded response. -> (status, text).
|
|
160
|
+
Raises on network failure; callers swallow it."""
|
|
161
|
+
opener = urllib.request.build_opener(_providers._NoRedirect())
|
|
162
|
+
req_headers = {"Content-Type": content_type,
|
|
163
|
+
"User-Agent": f"codereview/{VERSION}"}
|
|
164
|
+
req_headers.update(headers or {})
|
|
165
|
+
req = urllib.request.Request(url, data=body, headers=req_headers,
|
|
166
|
+
method="POST")
|
|
167
|
+
try:
|
|
168
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
169
|
+
text = resp.read(_MAX_RESPONSE).decode("utf-8", "replace")
|
|
170
|
+
return resp.status, text
|
|
171
|
+
except urllib.error.HTTPError as exc:
|
|
172
|
+
return exc.code, ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------- crash ping
|
|
176
|
+
|
|
177
|
+
def _top_frame(exc):
|
|
178
|
+
"""module:function of the innermost frame inside this package,
|
|
179
|
+
or 'external'. Never a path, a line number or source text."""
|
|
180
|
+
import traceback
|
|
181
|
+
try:
|
|
182
|
+
frames = traceback.extract_tb(exc.__traceback__)
|
|
183
|
+
except Exception: # noqa: BLE001
|
|
184
|
+
return "unknown"
|
|
185
|
+
package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
186
|
+
for frame in reversed(frames):
|
|
187
|
+
try:
|
|
188
|
+
if os.path.abspath(frame.filename).startswith(package_dir):
|
|
189
|
+
module = os.path.splitext(
|
|
190
|
+
os.path.basename(frame.filename))[0]
|
|
191
|
+
return f"codereview.{module}:{frame.name}"
|
|
192
|
+
except Exception: # noqa: BLE001
|
|
193
|
+
continue
|
|
194
|
+
return "external"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def ping_payload(note, exc, install):
|
|
198
|
+
payload = {
|
|
199
|
+
"schema": PING_SCHEMA,
|
|
200
|
+
"version": note.get("version"),
|
|
201
|
+
"git_hash": note.get("git_hash"),
|
|
202
|
+
"python": note.get("python"),
|
|
203
|
+
"platform": note.get("platform"),
|
|
204
|
+
"machine": note.get("machine"),
|
|
205
|
+
"command": note.get("command"),
|
|
206
|
+
"exception_type": note.get("exception_type"),
|
|
207
|
+
"top_frame": _top_frame(exc),
|
|
208
|
+
"exit_code": 2,
|
|
209
|
+
"install_id": install,
|
|
210
|
+
}
|
|
211
|
+
return {k: payload[k] for k in PING_FIELDS}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _today():
|
|
215
|
+
return datetime.datetime.now(datetime.timezone.utc) \
|
|
216
|
+
.strftime("%Y-%m-%d")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _allow_by_rate_limit(signature, environ=None):
|
|
220
|
+
"""At most one ping per signature per day and DAILY_CAP a day.
|
|
221
|
+
Records the attempt. Returns False when limited."""
|
|
222
|
+
path = _ratelimit_path(environ)
|
|
223
|
+
data = _read_json(path)
|
|
224
|
+
today = _today()
|
|
225
|
+
if data.get("day") != today:
|
|
226
|
+
data = {"day": today, "count": 0, "seen": []}
|
|
227
|
+
seen = data.get("seen") if isinstance(data.get("seen"), list) \
|
|
228
|
+
else []
|
|
229
|
+
if signature in seen or int(data.get("count") or 0) >= DAILY_CAP:
|
|
230
|
+
return False
|
|
231
|
+
data["seen"] = seen + [signature]
|
|
232
|
+
data["count"] = int(data.get("count") or 0) + 1
|
|
233
|
+
try:
|
|
234
|
+
_write_json(path, data)
|
|
235
|
+
except OSError:
|
|
236
|
+
return False
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def maybe_send_crash_ping(note, exc, environ=None):
|
|
241
|
+
"""Send the minimal ping if (and only if) the user enabled it.
|
|
242
|
+
Never raises; returns True when a request was attempted."""
|
|
243
|
+
try:
|
|
244
|
+
mode, _why = telemetry_mode(environ)
|
|
245
|
+
if mode != "on":
|
|
246
|
+
return False
|
|
247
|
+
url, problem = resolve_endpoint(environ)
|
|
248
|
+
if problem:
|
|
249
|
+
return False
|
|
250
|
+
install = install_id(environ) or str(uuid.uuid4())
|
|
251
|
+
payload = ping_payload(note, exc, install)
|
|
252
|
+
signature = hashlib.sha256(
|
|
253
|
+
f"{payload['exception_type']}|{payload['top_frame']}"
|
|
254
|
+
.encode()).hexdigest()[:16]
|
|
255
|
+
if not _allow_by_rate_limit(signature, environ):
|
|
256
|
+
return False
|
|
257
|
+
_post(url, json.dumps(payload).encode("utf-8"),
|
|
258
|
+
"application/json")
|
|
259
|
+
return True
|
|
260
|
+
except Exception: # noqa: BLE001 — feedback never breaks a run
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------ telemetry command
|
|
265
|
+
|
|
266
|
+
def _confirm(prompt, assume_yes, stdin=None):
|
|
267
|
+
if assume_yes:
|
|
268
|
+
return True
|
|
269
|
+
stdin = sys.stdin if stdin is None else stdin
|
|
270
|
+
try:
|
|
271
|
+
interactive = stdin.isatty()
|
|
272
|
+
except Exception: # noqa: BLE001
|
|
273
|
+
interactive = False
|
|
274
|
+
if not interactive:
|
|
275
|
+
return False
|
|
276
|
+
try:
|
|
277
|
+
return input(prompt).strip().lower() in ("y", "yes")
|
|
278
|
+
except EOFError:
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
FIELDS_TEXT = (
|
|
283
|
+
"The crash ping sends only: product version, git hash, Python "
|
|
284
|
+
"version, OS family and architecture, command name, exception "
|
|
285
|
+
"type, the module and function where the crash happened, the "
|
|
286
|
+
"exit code, and a random install id. It never sends code, "
|
|
287
|
+
"diffs, findings, file paths, repository or branch names, "
|
|
288
|
+
"policy contents, environment values, host or user names. "
|
|
289
|
+
"At most one ping per kind of crash per day.")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def telemetry_main(argv, environ=None, stdin=None):
|
|
293
|
+
import argparse
|
|
294
|
+
ap = argparse.ArgumentParser(
|
|
295
|
+
prog="codereview telemetry",
|
|
296
|
+
description="Opt-in anonymous crash ping. Off by default.")
|
|
297
|
+
ap.add_argument("action", choices=("enable", "disable", "status"))
|
|
298
|
+
ap.add_argument("--yes", action="store_true",
|
|
299
|
+
help="confirm without prompting (scripts)")
|
|
300
|
+
args = ap.parse_args(argv)
|
|
301
|
+
environ = os.environ if environ is None else environ
|
|
302
|
+
if args.action == "status":
|
|
303
|
+
mode, why = telemetry_mode(environ)
|
|
304
|
+
url, problem = resolve_endpoint(environ)
|
|
305
|
+
print(f"crash ping: {mode} ({why})")
|
|
306
|
+
print("endpoint: " + (url if url else f"none ({problem})"))
|
|
307
|
+
print(FIELDS_TEXT)
|
|
308
|
+
return 0
|
|
309
|
+
if args.action == "disable":
|
|
310
|
+
removed = False
|
|
311
|
+
for path in (_state_path(environ), _ratelimit_path(environ)):
|
|
312
|
+
try:
|
|
313
|
+
os.unlink(path)
|
|
314
|
+
removed = True
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
print("crash ping disabled; the install id was deleted."
|
|
318
|
+
if removed else "crash ping was not enabled.")
|
|
319
|
+
return 0
|
|
320
|
+
print(FIELDS_TEXT)
|
|
321
|
+
if not _confirm("Enable the crash ping? [y/N] ", args.yes, stdin):
|
|
322
|
+
print("codereview telemetry: not enabled (confirm at a "
|
|
323
|
+
"prompt, or pass --yes).", file=sys.stderr)
|
|
324
|
+
return 2
|
|
325
|
+
try:
|
|
326
|
+
_write_json(_state_path(environ), {
|
|
327
|
+
"enabled": True, "install_id": str(uuid.uuid4()),
|
|
328
|
+
"enabled_at": _today()})
|
|
329
|
+
except OSError as exc:
|
|
330
|
+
print(f"codereview telemetry: cannot save the setting: "
|
|
331
|
+
f"{exc}", file=sys.stderr)
|
|
332
|
+
return 2
|
|
333
|
+
print("crash ping enabled. Turn it off any time with "
|
|
334
|
+
"'codereview telemetry disable'.")
|
|
335
|
+
return 0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ----------------------------------------------------- report command
|
|
339
|
+
|
|
340
|
+
def report_main(argv, environ=None, stdin=None):
|
|
341
|
+
import argparse
|
|
342
|
+
import tempfile
|
|
343
|
+
from . import support
|
|
344
|
+
ap = argparse.ArgumentParser(
|
|
345
|
+
prog="codereview report",
|
|
346
|
+
description="Send a redacted bug report. Nothing is sent "
|
|
347
|
+
"until you have seen what it holds and "
|
|
348
|
+
"confirmed.")
|
|
349
|
+
ap.add_argument("--message", default="",
|
|
350
|
+
help="what went wrong, in your words")
|
|
351
|
+
ap.add_argument("--records", default=None,
|
|
352
|
+
help="directory holding the run records "
|
|
353
|
+
"(default: ./codereview-out, then .)")
|
|
354
|
+
ap.add_argument("--no-records", action="store_true",
|
|
355
|
+
help="leave run records out of the report")
|
|
356
|
+
ap.add_argument("--yes", action="store_true",
|
|
357
|
+
help="send without prompting (scripts)")
|
|
358
|
+
ap.add_argument("--show", action="store_true",
|
|
359
|
+
help="print the full contents that would be sent")
|
|
360
|
+
args = ap.parse_args(argv)
|
|
361
|
+
environ = os.environ if environ is None else environ
|
|
362
|
+
with tempfile.TemporaryDirectory(prefix="codereview-report-") \
|
|
363
|
+
as tmp:
|
|
364
|
+
path = os.path.join(tmp, "report.zip")
|
|
365
|
+
try:
|
|
366
|
+
manifest = support.build_bundle(
|
|
367
|
+
path, records_dir=args.records,
|
|
368
|
+
include_records=not args.no_records,
|
|
369
|
+
message=args.message, environ=environ)
|
|
370
|
+
except Exception as exc: # noqa: BLE001
|
|
371
|
+
print(f"codereview report: could not build the report: "
|
|
372
|
+
f"{exc}", file=sys.stderr)
|
|
373
|
+
return 2
|
|
374
|
+
print(support.describe(manifest, show=args.show, path=path))
|
|
375
|
+
print(support.WARNING)
|
|
376
|
+
url, problem = resolve_endpoint(environ)
|
|
377
|
+
if url is None:
|
|
378
|
+
print(f"codereview report: nothing was sent — {problem}.",
|
|
379
|
+
file=sys.stderr)
|
|
380
|
+
_print_manual_route()
|
|
381
|
+
return 2
|
|
382
|
+
if not _confirm(f"Send this report to {url}? [y/N] ",
|
|
383
|
+
args.yes, stdin):
|
|
384
|
+
print("codereview report: nothing was sent (confirm at "
|
|
385
|
+
"a prompt, or pass --yes).", file=sys.stderr)
|
|
386
|
+
return 2
|
|
387
|
+
try:
|
|
388
|
+
with open(path, "rb") as fh:
|
|
389
|
+
body = fh.read()
|
|
390
|
+
status, text = _post(url, body, "application/zip",
|
|
391
|
+
{"X-CodeReview-Version": VERSION},
|
|
392
|
+
timeout=15.0)
|
|
393
|
+
except Exception as exc: # noqa: BLE001
|
|
394
|
+
print(f"codereview report: sending failed ({type(exc).__name__})"
|
|
395
|
+
f"; nothing was received.", file=sys.stderr)
|
|
396
|
+
_print_manual_route()
|
|
397
|
+
return 2
|
|
398
|
+
if 200 <= status < 300:
|
|
399
|
+
rid = ""
|
|
400
|
+
try:
|
|
401
|
+
rid = str(json.loads(text).get("id") or "")
|
|
402
|
+
except (ValueError, AttributeError):
|
|
403
|
+
pass
|
|
404
|
+
print("report sent" + (f" (id {rid})." if rid else "."))
|
|
405
|
+
return 0
|
|
406
|
+
print(f"codereview report: the server answered {status}; "
|
|
407
|
+
f"nothing was received.", file=sys.stderr)
|
|
408
|
+
_print_manual_route()
|
|
409
|
+
return 2
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _print_manual_route():
|
|
413
|
+
where = ISSUES_URL or "the project's issue tracker (see README)"
|
|
414
|
+
print(f"You can still report by hand: run 'codereview "
|
|
415
|
+
f"support-bundle', read the file, and attach it at {where}. "
|
|
416
|
+
f"Security problems: see SECURITY.md.", file=sys.stderr)
|