bugcap 0.2.2__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.
- bugcap/__init__.py +1 -0
- bugcap/agent_api.py +221 -0
- bugcap/backends.py +133 -0
- bugcap/capture.py +84 -0
- bugcap/cli.py +942 -0
- bugcap/config.py +86 -0
- bugcap/dashboard/__init__.py +3 -0
- bugcap/dashboard/api.py +105 -0
- bugcap/dashboard/media.py +73 -0
- bugcap/dashboard/server.py +213 -0
- bugcap/dashboard/static/app.css +43 -0
- bugcap/dashboard/static/app.js +179 -0
- bugcap/dashboard/static/index.html +45 -0
- bugcap/drafts.py +83 -0
- bugcap/errors.py +30 -0
- bugcap/ghcli.py +175 -0
- bugcap/ingest.py +210 -0
- bugcap/live.py +292 -0
- bugcap/live_session.py +125 -0
- bugcap/logs.py +74 -0
- bugcap/mcp_server.py +221 -0
- bugcap/paths.py +116 -0
- bugcap/recorder.py +390 -0
- bugcap/refs.py +213 -0
- bugcap/repo.py +138 -0
- bugcap/service.py +418 -0
- bugcap/store.py +485 -0
- bugcap/sync.py +424 -0
- bugcap/tomlio.py +37 -0
- bugcap/validation.py +74 -0
- bugcap-0.2.2.dist-info/METADATA +225 -0
- bugcap-0.2.2.dist-info/RECORD +36 -0
- bugcap-0.2.2.dist-info/WHEEL +5 -0
- bugcap-0.2.2.dist-info/entry_points.txt +2 -0
- bugcap-0.2.2.dist-info/licenses/LICENSE +21 -0
- bugcap-0.2.2.dist-info/top_level.txt +1 -0
bugcap/cli.py
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from . import backends, capture, config, ghcli, recorder, refs, repo, service, sync, validation
|
|
11
|
+
from .capture import CaptureError
|
|
12
|
+
from .errors import ServiceError
|
|
13
|
+
from .ghcli import GhError
|
|
14
|
+
from .store import STATUSES, Store
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- shared helpers (T014) ----------------------------------------------------
|
|
18
|
+
|
|
19
|
+
def current_repo():
|
|
20
|
+
"""The RepoConfig discovered from the working directory, or None."""
|
|
21
|
+
return repo.load_repo_config()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_report(store: Store, report_id: int):
|
|
25
|
+
"""Return the report, or print the standard not-found error and return None."""
|
|
26
|
+
report = store.get(report_id)
|
|
27
|
+
if report is None:
|
|
28
|
+
print(f"error: no report with id {report_id}", file=sys.stderr)
|
|
29
|
+
return report
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
from .sync import human_size # noqa: E402,F401
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def print_service_error(exc: ServiceError) -> None:
|
|
36
|
+
print(f"error: {exc.message}", file=sys.stderr)
|
|
37
|
+
if exc.code == "invalid_reference":
|
|
38
|
+
print("valid references: " + (", ".join(exc.details.get("valid", [])) or "(none)"), file=sys.stderr)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def print_ingest(added, rejected) -> None:
|
|
42
|
+
"""One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
|
|
43
|
+
for media in added:
|
|
44
|
+
name = os.path.basename(media.path or "")
|
|
45
|
+
print(f"added #{media.idx} {name} ({media.kind}, {human_size(media.size_bytes)})")
|
|
46
|
+
for item in rejected:
|
|
47
|
+
print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# --- capture / list / show ----------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def cmd_capture(args: argparse.Namespace) -> int:
|
|
53
|
+
cfg = current_repo()
|
|
54
|
+
captured: Optional[str] = None
|
|
55
|
+
if not args.image:
|
|
56
|
+
try:
|
|
57
|
+
captured = str(capture.capture_screenshot())
|
|
58
|
+
except CaptureError as exc:
|
|
59
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
title = args.title or input("Title: ").strip()
|
|
63
|
+
notes = args.note
|
|
64
|
+
if notes is None:
|
|
65
|
+
notes = input("Notes (optional): ").strip()
|
|
66
|
+
|
|
67
|
+
tags = list(args.tag or [])
|
|
68
|
+
repo_key: Optional[str] = None
|
|
69
|
+
if cfg is not None:
|
|
70
|
+
repo_key = cfg.key
|
|
71
|
+
if cfg.tag not in tags:
|
|
72
|
+
tags.append(cfg.tag)
|
|
73
|
+
|
|
74
|
+
labels = list(args.label or [])
|
|
75
|
+
with Store() as store:
|
|
76
|
+
try:
|
|
77
|
+
report, result = service.create_report(
|
|
78
|
+
store,
|
|
79
|
+
title=title,
|
|
80
|
+
notes=notes or "",
|
|
81
|
+
tags=tags,
|
|
82
|
+
repo=repo_key,
|
|
83
|
+
sources=list(args.image or []),
|
|
84
|
+
labels=labels if args.image else None,
|
|
85
|
+
require_media=bool(args.image),
|
|
86
|
+
captured=captured,
|
|
87
|
+
captured_label=(labels[0] if labels and not args.image else None),
|
|
88
|
+
)
|
|
89
|
+
except ServiceError as exc:
|
|
90
|
+
print_service_error(exc)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
if report is None:
|
|
94
|
+
print_ingest([], result.rejected)
|
|
95
|
+
print("error: no image could be added; no report saved", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
print(f"Saved report #{report.id}: {report.title}")
|
|
99
|
+
for path in report.image_paths:
|
|
100
|
+
print(f" image: {path}")
|
|
101
|
+
if args.image:
|
|
102
|
+
print_ingest(result.added, result.rejected)
|
|
103
|
+
return 1 if result.rejected else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
107
|
+
cfg = current_repo()
|
|
108
|
+
scope = cfg.key if (cfg is not None and not args.all) else None
|
|
109
|
+
|
|
110
|
+
with Store() as store:
|
|
111
|
+
reports = store.list(repo=scope)
|
|
112
|
+
|
|
113
|
+
if not reports:
|
|
114
|
+
print("No reports yet. Run `bugcap capture` to create one.")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
for report in reports:
|
|
118
|
+
tags = f" [{', '.join(report.tags)}]" if report.tags else ""
|
|
119
|
+
print(f"#{report.id} {report.created_at} {report.status:8s} {report.title}{tags}")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
124
|
+
with Store() as store:
|
|
125
|
+
report = resolve_report(store, args.id)
|
|
126
|
+
if report is None:
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
print(f"#{report.id} {report.title}")
|
|
130
|
+
print(f" created_at: {report.created_at}")
|
|
131
|
+
print(f" status: {report.status}")
|
|
132
|
+
if report.repo:
|
|
133
|
+
print(f" repo: {report.repo}")
|
|
134
|
+
print(f" tags: {', '.join(report.tags) or '(none)'}")
|
|
135
|
+
print(f" notes: {refs.display_notes(report.notes, report.media) or '(none)'}")
|
|
136
|
+
print(f" images:")
|
|
137
|
+
for path in report.image_paths:
|
|
138
|
+
print(f" - {path}")
|
|
139
|
+
detailed = [m for m in report.media if not (m.kind == "image" and m.source == "legacy")]
|
|
140
|
+
if detailed:
|
|
141
|
+
print(" media:")
|
|
142
|
+
for m in detailed:
|
|
143
|
+
where = m.path or f"({len(m.frames)} frames)"
|
|
144
|
+
origin = f" (source: {m.source})" if m.source else ""
|
|
145
|
+
print(f" #{m.idx} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
|
|
146
|
+
print(f" synced_refs:")
|
|
147
|
+
if report.synced_refs:
|
|
148
|
+
for tracker, ref in report.synced_refs.items():
|
|
149
|
+
print(f" - {tracker}: {ref}")
|
|
150
|
+
else:
|
|
151
|
+
print(" (not synced to any tracker yet)")
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- setup (T017) -------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _setup_capture(args: argparse.Namespace) -> int:
|
|
158
|
+
backend = backends.detect()
|
|
159
|
+
if backend is not None:
|
|
160
|
+
print(f"Capture tool found: {backend.name} ({backend.description}).")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
rec = backends.recommended()
|
|
164
|
+
cmd = backends.install_command(rec)
|
|
165
|
+
if cmd is None:
|
|
166
|
+
print(
|
|
167
|
+
f"No capture tool found and no supported package manager detected.\n"
|
|
168
|
+
f"Install {rec.name} manually: {backends.guidance(rec)}",
|
|
169
|
+
file=sys.stderr,
|
|
170
|
+
)
|
|
171
|
+
return 1
|
|
172
|
+
|
|
173
|
+
joined = " ".join(cmd)
|
|
174
|
+
if not args.yes:
|
|
175
|
+
if not sys.stdin.isatty():
|
|
176
|
+
print(
|
|
177
|
+
f"No capture tool found. Install {rec.name} with:\n {joined}\n"
|
|
178
|
+
f"(re-run with --yes to install automatically)",
|
|
179
|
+
file=sys.stderr,
|
|
180
|
+
)
|
|
181
|
+
return 1
|
|
182
|
+
answer = input(f"Install {rec.name}? [y/N] ").strip().lower()
|
|
183
|
+
if answer != "y":
|
|
184
|
+
print("Nothing installed.")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
print(f"Installing {rec.name}: {joined}")
|
|
188
|
+
rc = subprocess.run(cmd).returncode
|
|
189
|
+
if rc != 0:
|
|
190
|
+
print(f"error: install command failed (exit {rc}).", file=sys.stderr)
|
|
191
|
+
return 1
|
|
192
|
+
print(f"{rec.name} installed.")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _setup_recorder(args: argparse.Namespace) -> None:
|
|
197
|
+
"""Recorder lines are appended after the capture-tool output."""
|
|
198
|
+
found = recorder.detect_recorder()
|
|
199
|
+
if found is not None:
|
|
200
|
+
print(f"Screen recorder found: {found.name} ({found.description}).")
|
|
201
|
+
return
|
|
202
|
+
ffmpeg = recorder.by_name("ffmpeg")
|
|
203
|
+
cmd = backends.install_command(ffmpeg)
|
|
204
|
+
print("No screen recorder found (needed for `bugcap record`).")
|
|
205
|
+
print(f" Install ffmpeg: {' '.join(cmd) if cmd else backends.guidance(ffmpeg)}")
|
|
206
|
+
if backends.platform_key() == "linux" and os.environ.get("WAYLAND_DISPLAY"):
|
|
207
|
+
print(f" On Wayland, also: {backends.guidance(recorder.by_name('wf-recorder'))}")
|
|
208
|
+
if args.yes and cmd:
|
|
209
|
+
print(f"Installing ffmpeg: {' '.join(cmd)}")
|
|
210
|
+
rc = subprocess.run(cmd).returncode
|
|
211
|
+
print("ffmpeg installed." if rc == 0 else f"error: install command failed (exit {rc}).")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _setup_tkinter() -> None:
|
|
215
|
+
try:
|
|
216
|
+
import tkinter # noqa: F401
|
|
217
|
+
except ImportError:
|
|
218
|
+
hint = "install your distro's python3-tk package (e.g. sudo apt install python3-tk)" \
|
|
219
|
+
if backends.platform_key() == "linux" else "reinstall Python with Tk support"
|
|
220
|
+
print(f"Live mode (`bugcap live`) needs tkinter, which is missing: {hint}.")
|
|
221
|
+
else:
|
|
222
|
+
print("Live mode: tkinter is available.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
226
|
+
rc = _setup_capture(args)
|
|
227
|
+
_setup_recorder(args)
|
|
228
|
+
_setup_tkinter()
|
|
229
|
+
return rc
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- repo values: validation and re-homing reports -----------------------------
|
|
233
|
+
|
|
234
|
+
def check_repo_values(github=None, images_repo=None, images_path=None, images_branch=None) -> bool:
|
|
235
|
+
"""Validate the format of the given values, then verify them with `gh`. Prints the problem
|
|
236
|
+
and returns False for a definite error; offline/unauthenticated only warns."""
|
|
237
|
+
try:
|
|
238
|
+
validation.validate_repo_values(github, images_repo, images_path, images_branch)
|
|
239
|
+
except ValueError as exc:
|
|
240
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
241
|
+
return False
|
|
242
|
+
checks = []
|
|
243
|
+
if github:
|
|
244
|
+
checks.append((github, None, False))
|
|
245
|
+
if images_repo:
|
|
246
|
+
checks.append((images_repo, images_branch, True))
|
|
247
|
+
for slug, branch, need_write in checks:
|
|
248
|
+
try:
|
|
249
|
+
ghcli.verify_repo(slug, branch=branch, need_write=need_write)
|
|
250
|
+
except ghcli.GhUnavailable as exc:
|
|
251
|
+
print(f"warning: could not verify {slug} with gh ({exc}); continuing", file=sys.stderr)
|
|
252
|
+
except GhError as exc:
|
|
253
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
254
|
+
return False
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _identity(cfg) -> str:
|
|
259
|
+
return f"{cfg.key} (tag {cfg.tag})"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def plan_repo_move(store: Store, old, new) -> tuple[bool, int]:
|
|
263
|
+
"""(identity changed, number of reports that would be affected)."""
|
|
264
|
+
if old.key == new.key and old.tag == new.tag:
|
|
265
|
+
return False, 0
|
|
266
|
+
reports = store.list(repo=old.key)
|
|
267
|
+
if old.key == new.key:
|
|
268
|
+
reports = [r for r in reports if old.tag in r.tags]
|
|
269
|
+
return True, len(reports)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def decide_migration(args, old, new, count: int) -> Optional[bool]:
|
|
273
|
+
"""True = move the reports, False = leave them, None = stop (non-interactive without a flag)."""
|
|
274
|
+
if count == 0:
|
|
275
|
+
return False
|
|
276
|
+
explicit = getattr(args, "migrate", None)
|
|
277
|
+
if explicit is not None:
|
|
278
|
+
return explicit
|
|
279
|
+
summary = f"{count} existing report(s) are stored under {_identity(old)}; the new value is {_identity(new)}."
|
|
280
|
+
if sys.stdin is not None and sys.stdin.isatty():
|
|
281
|
+
answer = input(f"{summary}\nMove them (and their images/data) to the new repo? [y/N] ").strip().lower()
|
|
282
|
+
return answer == "y"
|
|
283
|
+
print(
|
|
284
|
+
f"error: {summary}\nRe-run with --migrate to move them, or --no-migrate to leave them "
|
|
285
|
+
"(they would then not show in `bugcap list` for this repo).",
|
|
286
|
+
file=sys.stderr,
|
|
287
|
+
)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def apply_migration(store: Store, old, new, move: bool, count: int) -> None:
|
|
292
|
+
if move:
|
|
293
|
+
moved = store.move_repo(old.key, new.key, old.tag, new.tag)
|
|
294
|
+
print(f"Moved {moved} report(s) from {_identity(old)} to {_identity(new)}.")
|
|
295
|
+
elif count:
|
|
296
|
+
print(
|
|
297
|
+
f"Left {count} report(s) under {_identity(old)}; they will not show in `bugcap list` "
|
|
298
|
+
"for this repo (use `bugcap list --all`).",
|
|
299
|
+
file=sys.stderr,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# --- init (T018) --------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
306
|
+
root = repo.git_root() or Path.cwd()
|
|
307
|
+
if not check_repo_values(args.github, args.images_repo, None, None):
|
|
308
|
+
return 1
|
|
309
|
+
target = root / repo.CONFIG_NAME
|
|
310
|
+
old = None
|
|
311
|
+
new = None
|
|
312
|
+
move = False
|
|
313
|
+
count = 0
|
|
314
|
+
if target.exists() and args.force:
|
|
315
|
+
old = repo._config_from_file(target)
|
|
316
|
+
new = repo.build_config(root, tag=args.tag, github=args.github, images_repo=args.images_repo)
|
|
317
|
+
with Store() as store:
|
|
318
|
+
changed, count = plan_repo_move(store, old, new)
|
|
319
|
+
if changed:
|
|
320
|
+
decision = decide_migration(args, old, new, count)
|
|
321
|
+
if decision is None:
|
|
322
|
+
return 1
|
|
323
|
+
move = decision
|
|
324
|
+
try:
|
|
325
|
+
cfg = repo.init_repo(
|
|
326
|
+
root,
|
|
327
|
+
tag=args.tag,
|
|
328
|
+
github=args.github,
|
|
329
|
+
images_repo=args.images_repo,
|
|
330
|
+
force=args.force,
|
|
331
|
+
)
|
|
332
|
+
except repo.RepoConfigError as exc:
|
|
333
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
334
|
+
return 1
|
|
335
|
+
|
|
336
|
+
print(f"Initialized {repo.CONFIG_NAME} in {cfg.root}")
|
|
337
|
+
print(f" tag: {cfg.tag}")
|
|
338
|
+
if cfg.github:
|
|
339
|
+
print(f" github: {cfg.github}")
|
|
340
|
+
if cfg.images_repo:
|
|
341
|
+
print(f" images_repo: {cfg.images_repo}")
|
|
342
|
+
if old is not None and new is not None and (old.key != cfg.key or old.tag != cfg.tag):
|
|
343
|
+
with Store() as store:
|
|
344
|
+
apply_migration(store, old, cfg, move, count)
|
|
345
|
+
return 0
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# --- config repo (show / set / unset) -----------------------------------------
|
|
349
|
+
|
|
350
|
+
def _repo_config_or_error():
|
|
351
|
+
path = repo.find_config()
|
|
352
|
+
if path is None:
|
|
353
|
+
print(f"error: no {repo.CONFIG_NAME} here; run `bugcap init` first.", file=sys.stderr)
|
|
354
|
+
return None
|
|
355
|
+
return repo._config_from_file(path)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def cmd_config_repo_show(args: argparse.Namespace) -> int:
|
|
359
|
+
cfg = _repo_config_or_error()
|
|
360
|
+
if cfg is None:
|
|
361
|
+
return 1
|
|
362
|
+
print(f"{repo.CONFIG_NAME}: {cfg.root / repo.CONFIG_NAME}")
|
|
363
|
+
for key in repo.SETTABLE_KEYS:
|
|
364
|
+
print(f" {key + ':':14s} {getattr(cfg, key) or '(not set)'}")
|
|
365
|
+
print(f" {'repo key:':14s} {cfg.key}")
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _change_repo_config(args: argparse.Namespace, key: str, value: Optional[str]) -> int:
|
|
370
|
+
import dataclasses
|
|
371
|
+
|
|
372
|
+
old = _repo_config_or_error()
|
|
373
|
+
if old is None:
|
|
374
|
+
return 1
|
|
375
|
+
if key not in repo.SETTABLE_KEYS:
|
|
376
|
+
print(f"error: unknown key {key!r}; choose one of: {', '.join(repo.SETTABLE_KEYS)}", file=sys.stderr)
|
|
377
|
+
return 2
|
|
378
|
+
if value is None and key == "tag":
|
|
379
|
+
print("error: the tag cannot be unset; set it to a new value instead.", file=sys.stderr)
|
|
380
|
+
return 2
|
|
381
|
+
if value is not None:
|
|
382
|
+
if key == "tag":
|
|
383
|
+
if not value.strip():
|
|
384
|
+
print("error: the tag must not be empty.", file=sys.stderr)
|
|
385
|
+
return 2
|
|
386
|
+
else:
|
|
387
|
+
if key == "images_path":
|
|
388
|
+
value = value.strip("/")
|
|
389
|
+
values = {"github": None, "images_repo": None, "images_path": None, "images_branch": None}
|
|
390
|
+
values[key] = value
|
|
391
|
+
# a branch is only meaningful with its repo, and a new repo is checked with its branch
|
|
392
|
+
if key == "images_branch":
|
|
393
|
+
values["images_repo"] = old.images_repo
|
|
394
|
+
if key == "images_repo":
|
|
395
|
+
values["images_branch"] = old.images_branch
|
|
396
|
+
if not check_repo_values(**values):
|
|
397
|
+
return 1
|
|
398
|
+
new = dataclasses.replace(old, **{key: value})
|
|
399
|
+
move, count = False, 0
|
|
400
|
+
with Store() as store:
|
|
401
|
+
changed, count = plan_repo_move(store, old, new)
|
|
402
|
+
if changed:
|
|
403
|
+
decision = decide_migration(args, old, new, count)
|
|
404
|
+
if decision is None:
|
|
405
|
+
return 1
|
|
406
|
+
move = decision
|
|
407
|
+
repo.write_config(new)
|
|
408
|
+
print(f"{key}: {getattr(old, key) or '(not set)'} -> {value or '(not set)'}")
|
|
409
|
+
if changed:
|
|
410
|
+
apply_migration(store, old, new, move, count)
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def cmd_config_repo_set(args: argparse.Namespace) -> int:
|
|
415
|
+
return _change_repo_config(args, args.key, args.value)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def cmd_config_repo_unset(args: argparse.Namespace) -> int:
|
|
419
|
+
return _change_repo_config(args, args.key, None)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# --- triage: edit / tag (T023) ------------------------------------------------
|
|
423
|
+
|
|
424
|
+
def cmd_edit(args: argparse.Namespace) -> int:
|
|
425
|
+
if args.title is None and args.note is None and args.status is None:
|
|
426
|
+
print("error: provide at least one of --title, --note, --status", file=sys.stderr)
|
|
427
|
+
return 2
|
|
428
|
+
with Store() as store:
|
|
429
|
+
if resolve_report(store, args.id) is None:
|
|
430
|
+
return 1
|
|
431
|
+
try:
|
|
432
|
+
with store.transaction():
|
|
433
|
+
if args.note is not None:
|
|
434
|
+
service.set_notes(store, args.id, args.note)
|
|
435
|
+
report = store.update(args.id, title=args.title, status=args.status)
|
|
436
|
+
except ServiceError as exc:
|
|
437
|
+
print_service_error(exc)
|
|
438
|
+
return 1
|
|
439
|
+
except ValueError as exc:
|
|
440
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
441
|
+
return 2
|
|
442
|
+
|
|
443
|
+
changed = []
|
|
444
|
+
if args.title is not None:
|
|
445
|
+
changed.append("title")
|
|
446
|
+
if args.note is not None:
|
|
447
|
+
changed.append("notes")
|
|
448
|
+
if args.status is not None:
|
|
449
|
+
changed.append("status")
|
|
450
|
+
print(f"Updated report #{report.id} ({', '.join(changed)}).")
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_tag(args: argparse.Namespace) -> int:
|
|
455
|
+
with Store() as store:
|
|
456
|
+
report = resolve_report(store, args.id)
|
|
457
|
+
if report is None:
|
|
458
|
+
return 1
|
|
459
|
+
tags = list(report.tags)
|
|
460
|
+
changed = []
|
|
461
|
+
if args.action == "add":
|
|
462
|
+
for tag in args.tags:
|
|
463
|
+
if tag not in tags:
|
|
464
|
+
tags.append(tag)
|
|
465
|
+
changed.append(f"+{tag}")
|
|
466
|
+
else: # remove
|
|
467
|
+
for tag in args.tags:
|
|
468
|
+
if tag in tags:
|
|
469
|
+
tags.remove(tag)
|
|
470
|
+
changed.append(f"-{tag}")
|
|
471
|
+
store.set_tags(args.id, tags)
|
|
472
|
+
|
|
473
|
+
label = ", ".join(tags) or "(none)"
|
|
474
|
+
if changed:
|
|
475
|
+
print(f"#{args.id} {' '.join(changed)} -> [{label}]")
|
|
476
|
+
else:
|
|
477
|
+
print(f"#{args.id} tags unchanged: [{label}]")
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# --- attach (T029) ------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
def _note_for_images(args: argparse.Namespace) -> Optional[str]:
|
|
484
|
+
"""`--note`, or an optional prompt on an interactive terminal (as `capture` does)."""
|
|
485
|
+
if args.note is not None:
|
|
486
|
+
return args.note
|
|
487
|
+
if sys.stdin is not None and sys.stdin.isatty():
|
|
488
|
+
return input("Note about this image (optional): ").strip()
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def cmd_attach(args: argparse.Namespace) -> int:
|
|
493
|
+
with Store() as store:
|
|
494
|
+
report = resolve_report(store, args.id)
|
|
495
|
+
if report is None:
|
|
496
|
+
return 1
|
|
497
|
+
labels = list(args.label or [])
|
|
498
|
+
added: list = []
|
|
499
|
+
rejected: list = []
|
|
500
|
+
shot_path = None
|
|
501
|
+
if args.image:
|
|
502
|
+
try:
|
|
503
|
+
result = service.add_media(store, args.id, list(args.image), labels)
|
|
504
|
+
except ServiceError as exc:
|
|
505
|
+
print_service_error(exc)
|
|
506
|
+
return 1
|
|
507
|
+
added, rejected = result.added, result.rejected
|
|
508
|
+
print_ingest(added, rejected)
|
|
509
|
+
else:
|
|
510
|
+
try:
|
|
511
|
+
shot_path = capture.capture_screenshot()
|
|
512
|
+
except CaptureError as exc:
|
|
513
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
514
|
+
return 1
|
|
515
|
+
try:
|
|
516
|
+
added = [service.attach_captured(store, args.id, str(shot_path), labels[0] if labels else None)]
|
|
517
|
+
except ServiceError as exc:
|
|
518
|
+
print_service_error(exc)
|
|
519
|
+
return 1
|
|
520
|
+
|
|
521
|
+
note = _note_for_images(args) if added else args.note
|
|
522
|
+
if note and not added:
|
|
523
|
+
print("error: no image was added, so the note was not saved.", file=sys.stderr)
|
|
524
|
+
elif note:
|
|
525
|
+
try:
|
|
526
|
+
service.append_note(store, args.id, note, added)
|
|
527
|
+
except ServiceError as exc:
|
|
528
|
+
print_service_error(exc)
|
|
529
|
+
print("warning: the image was attached but the note was not saved.", file=sys.stderr)
|
|
530
|
+
return 1
|
|
531
|
+
current = store.get(args.id)
|
|
532
|
+
if args.image:
|
|
533
|
+
try:
|
|
534
|
+
refs.validate_references(current.notes, current.media)
|
|
535
|
+
except ServiceError as exc:
|
|
536
|
+
print(f"warning: {exc.message}", file=sys.stderr)
|
|
537
|
+
|
|
538
|
+
if shot_path is not None:
|
|
539
|
+
print(f"Attached {shot_path} to report #{args.id}")
|
|
540
|
+
elif note and added:
|
|
541
|
+
print(f"Added note to report #{args.id}")
|
|
542
|
+
return 1 if rejected or (note and not added) else 0
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# --- images (list / relabel / remove) -----------------------------------------
|
|
546
|
+
|
|
547
|
+
def cmd_images(args: argparse.Namespace) -> int:
|
|
548
|
+
with Store() as store:
|
|
549
|
+
report = resolve_report(store, args.id)
|
|
550
|
+
if report is None:
|
|
551
|
+
return 1
|
|
552
|
+
if args.action is None:
|
|
553
|
+
for m in report.media:
|
|
554
|
+
where = m.path or f"({len(m.frames)} frames)"
|
|
555
|
+
print(f"#{m.idx} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
|
|
556
|
+
return 0
|
|
557
|
+
try:
|
|
558
|
+
if args.action == "relabel":
|
|
559
|
+
if len(args.args) != 2:
|
|
560
|
+
print("error: usage: bugcap images <id> relabel <idx|label> <new-label>", file=sys.stderr)
|
|
561
|
+
return 2
|
|
562
|
+
count = service.relabel_media(store, args.id, args.args[0], args.args[1], force=args.force)
|
|
563
|
+
print(f"relabelled {args.args[0]} -> {args.args[1]}; rewrote {count} reference(s)")
|
|
564
|
+
else:
|
|
565
|
+
if len(args.args) != 1:
|
|
566
|
+
print("error: usage: bugcap images <id> remove <idx|label>", file=sys.stderr)
|
|
567
|
+
return 2
|
|
568
|
+
count = service.remove_media(store, args.id, args.args[0], force=args.force)
|
|
569
|
+
print(f"removed {args.args[0]}; rewrote {count} reference(s)")
|
|
570
|
+
except ServiceError as exc:
|
|
571
|
+
print_service_error(exc)
|
|
572
|
+
if exc.code == "referenced_image":
|
|
573
|
+
print(f"notes: {report.notes}", file=sys.stderr)
|
|
574
|
+
return 1
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# --- record -------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def cmd_record(args: argparse.Namespace) -> int:
|
|
581
|
+
if args.backend:
|
|
582
|
+
chosen = recorder.by_name(args.backend)
|
|
583
|
+
if not all(shutil.which(b) for b in chosen.binaries):
|
|
584
|
+
print(f"error: {chosen.name} is not installed. {backends.guidance(chosen)}", file=sys.stderr)
|
|
585
|
+
return 3
|
|
586
|
+
else:
|
|
587
|
+
chosen = recorder.detect_recorder()
|
|
588
|
+
if chosen is None:
|
|
589
|
+
ffmpeg = recorder.by_name("ffmpeg")
|
|
590
|
+
print("error: no screen recorder found. " + backends.guidance(ffmpeg), file=sys.stderr)
|
|
591
|
+
print("Run `bugcap setup` to see install options.", file=sys.stderr)
|
|
592
|
+
return 3
|
|
593
|
+
|
|
594
|
+
cfg = current_repo()
|
|
595
|
+
with Store() as store:
|
|
596
|
+
if args.id is not None and resolve_report(store, args.id) is None:
|
|
597
|
+
return 1
|
|
598
|
+
|
|
599
|
+
fps = args.fps or (30 if args.format == "video" else 5)
|
|
600
|
+
stop = threading.Event()
|
|
601
|
+
if sys.stdin is not None and sys.stdin.isatty():
|
|
602
|
+
def _wait_enter():
|
|
603
|
+
try:
|
|
604
|
+
sys.stdin.readline()
|
|
605
|
+
finally:
|
|
606
|
+
stop.set()
|
|
607
|
+
|
|
608
|
+
threading.Thread(target=_wait_enter, daemon=True).start()
|
|
609
|
+
print(f"Recording with {chosen.name} (max {args.max_seconds} s). Press Enter or Ctrl+C to stop.", file=sys.stderr)
|
|
610
|
+
try:
|
|
611
|
+
result = recorder.run_recording(
|
|
612
|
+
chosen.name, args.format, fps, args.max_seconds, args.max_mb, wait_for_stop=stop.is_set,
|
|
613
|
+
)
|
|
614
|
+
except recorder.RecorderError as exc:
|
|
615
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
616
|
+
if exc.guidance:
|
|
617
|
+
print(exc.guidance, file=sys.stderr)
|
|
618
|
+
return 3 if exc.guidance else 1
|
|
619
|
+
|
|
620
|
+
tags = list(args.tag or [])
|
|
621
|
+
repo_key = None
|
|
622
|
+
if cfg is not None and args.id is None:
|
|
623
|
+
repo_key = cfg.key
|
|
624
|
+
if cfg.tag not in tags:
|
|
625
|
+
tags.append(cfg.tag)
|
|
626
|
+
with Store() as store:
|
|
627
|
+
try:
|
|
628
|
+
report, media = service.add_recording(
|
|
629
|
+
store, args.id, str(result.path) if result.path else None, result.kind, result.mime,
|
|
630
|
+
result.size_bytes, frame_paths=result.frames or None, title=args.title,
|
|
631
|
+
notes=args.note, tags=tags, repo=repo_key,
|
|
632
|
+
)
|
|
633
|
+
except ServiceError as exc:
|
|
634
|
+
recorder.discard(result)
|
|
635
|
+
print_service_error(exc)
|
|
636
|
+
return 1
|
|
637
|
+
print(f"recorded {result.duration:.1f} s, {human_size(result.size_bytes)} ({result.kind}), stopped: {result.reason}")
|
|
638
|
+
print(f"added #{media.idx} to report #{report.id}")
|
|
639
|
+
return 0
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# --- live ---------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
def cmd_live(args: argparse.Namespace) -> int:
|
|
645
|
+
from . import live
|
|
646
|
+
|
|
647
|
+
return live.run(args.repo_dir)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# --- dashboard ----------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
def cmd_dashboard(args: argparse.Namespace) -> int:
|
|
653
|
+
from .dashboard import server
|
|
654
|
+
|
|
655
|
+
host = args.host or "127.0.0.1"
|
|
656
|
+
explicit = args.host is not None
|
|
657
|
+
if explicit and host not in server.LOOPBACK:
|
|
658
|
+
print(f"warning: dashboard exposed on {host}; anyone on that network can read and edit your bugs",
|
|
659
|
+
file=sys.stderr)
|
|
660
|
+
try:
|
|
661
|
+
server.serve(host=host, port=args.port, open_browser=args.open, explicit_host=explicit)
|
|
662
|
+
except (ValueError, OSError) as exc:
|
|
663
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
664
|
+
return 1
|
|
665
|
+
return 0
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
# --- github pull (T029) -------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def _ask_screenshot_cb():
|
|
671
|
+
def cb(report, issue) -> Optional[str]:
|
|
672
|
+
prompt = f"Add a screenshot for issue #{issue['number']} {issue['title']!r}? [y/N/path] "
|
|
673
|
+
answer = input(prompt).strip()
|
|
674
|
+
if not answer or answer.lower() == "n":
|
|
675
|
+
return None
|
|
676
|
+
try:
|
|
677
|
+
if answer.lower() == "y":
|
|
678
|
+
path = str(capture.capture_screenshot())
|
|
679
|
+
else:
|
|
680
|
+
path = str(capture.import_image(Path(answer)))
|
|
681
|
+
except CaptureError as exc:
|
|
682
|
+
print(f" skipped: {exc}", file=sys.stderr)
|
|
683
|
+
return None
|
|
684
|
+
note = input("Note about this image (optional): ").strip()
|
|
685
|
+
return (path, note) if note else path
|
|
686
|
+
return cb
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def cmd_github_pull(args: argparse.Namespace) -> int:
|
|
690
|
+
cfg = current_repo()
|
|
691
|
+
slug = args.repo or (cfg.github if cfg else None)
|
|
692
|
+
if not slug:
|
|
693
|
+
print(
|
|
694
|
+
"error: no GitHub repo; pass --repo owner/repo or run `bugcap init` "
|
|
695
|
+
"in a repo with a GitHub origin.",
|
|
696
|
+
file=sys.stderr,
|
|
697
|
+
)
|
|
698
|
+
return 1
|
|
699
|
+
ask_cb = _ask_screenshot_cb() if args.ask else None
|
|
700
|
+
try:
|
|
701
|
+
with Store() as store:
|
|
702
|
+
summary = sync.pull_issues(store, slug, labels=args.label, limit=args.limit, ask_cb=ask_cb)
|
|
703
|
+
except GhError as exc:
|
|
704
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
705
|
+
return 1
|
|
706
|
+
|
|
707
|
+
print(
|
|
708
|
+
f"pulled from {slug}: created {summary['created']}, "
|
|
709
|
+
f"updated {summary['updated']}, unchanged {summary['unchanged']}"
|
|
710
|
+
)
|
|
711
|
+
return 0
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# --- sync (T035) --------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
_DESTINATIONS = {"github": sync.GitHubDestination}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _consent_prompt(slug: str, visibility: str) -> bool:
|
|
720
|
+
answer = input(f"Commit image copies to {visibility} repo {slug}? [y/N] ").strip().lower()
|
|
721
|
+
return answer == "y"
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def cmd_sync(args: argparse.Namespace) -> int:
|
|
725
|
+
with Store() as store:
|
|
726
|
+
report = resolve_report(store, args.id)
|
|
727
|
+
if report is None:
|
|
728
|
+
return 1
|
|
729
|
+
|
|
730
|
+
cfg = current_repo()
|
|
731
|
+
linked = report.synced_refs.get("github.issue", "")
|
|
732
|
+
issue_slug = (
|
|
733
|
+
args.repo
|
|
734
|
+
or (report.repo if report.repo and "/" in report.repo else None)
|
|
735
|
+
or (cfg.github if cfg else None)
|
|
736
|
+
or (linked.split("#")[0] if "#" in linked else None)
|
|
737
|
+
)
|
|
738
|
+
if not issue_slug:
|
|
739
|
+
print(
|
|
740
|
+
"error: no target repo; pass --repo owner/repo or sync from an initialized repo.",
|
|
741
|
+
file=sys.stderr,
|
|
742
|
+
)
|
|
743
|
+
return 1
|
|
744
|
+
|
|
745
|
+
destination = _DESTINATIONS[args.to]()
|
|
746
|
+
try:
|
|
747
|
+
destination.ensure_ready()
|
|
748
|
+
except GhError as exc:
|
|
749
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
750
|
+
return 1
|
|
751
|
+
|
|
752
|
+
images = sync.resolve_images_target(
|
|
753
|
+
args.images_repo, args.images_path, args.images_branch,
|
|
754
|
+
cfg, config.get_sync_defaults(), issue_slug,
|
|
755
|
+
)
|
|
756
|
+
if not check_repo_values(
|
|
757
|
+
github=args.repo, images_repo=images.repo, images_path=images.path, images_branch=images.branch,
|
|
758
|
+
):
|
|
759
|
+
return 1
|
|
760
|
+
opts = sync.SyncOptions(
|
|
761
|
+
issue_slug=issue_slug,
|
|
762
|
+
images=images,
|
|
763
|
+
assume_yes=args.yes,
|
|
764
|
+
interactive=sys.stdin.isatty(),
|
|
765
|
+
confirm=_consent_prompt,
|
|
766
|
+
max_upload_mb=config.get_max_upload_mb(),
|
|
767
|
+
)
|
|
768
|
+
try:
|
|
769
|
+
result = sync.sync_report(store, report, destination, opts)
|
|
770
|
+
except GhError as exc:
|
|
771
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
772
|
+
return 1
|
|
773
|
+
|
|
774
|
+
if result.issue_url:
|
|
775
|
+
print(f"issue: {result.issue_url}")
|
|
776
|
+
for link in result.image_permalinks:
|
|
777
|
+
print(f"image: {link}")
|
|
778
|
+
for message in result.messages:
|
|
779
|
+
print(message)
|
|
780
|
+
return 0
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
# --- mcp-serve (T040) ---------------------------------------------------------
|
|
784
|
+
|
|
785
|
+
def cmd_mcp_serve(args: argparse.Namespace) -> int:
|
|
786
|
+
from . import mcp_server
|
|
787
|
+
try:
|
|
788
|
+
mcp_server.run(log_level=args.log_level, log_file=args.log_file)
|
|
789
|
+
except mcp_server.MCPUnavailable as exc:
|
|
790
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
791
|
+
return 1
|
|
792
|
+
return 0
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# --- parser -------------------------------------------------------------------
|
|
796
|
+
|
|
797
|
+
def _migrate_flags(p: argparse.ArgumentParser) -> None:
|
|
798
|
+
group = p.add_mutually_exclusive_group()
|
|
799
|
+
group.add_argument("--migrate", dest="migrate", action="store_true", default=None,
|
|
800
|
+
help="When the repo identity changes, move existing reports to the new one.")
|
|
801
|
+
group.add_argument("--no-migrate", dest="migrate", action="store_false",
|
|
802
|
+
help="When the repo identity changes, leave existing reports where they are.")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
806
|
+
parser = argparse.ArgumentParser(
|
|
807
|
+
prog="bugcap",
|
|
808
|
+
description="Local-first, agent-readable bug capture: annotated screenshots + notes.",
|
|
809
|
+
)
|
|
810
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
811
|
+
|
|
812
|
+
p = sub.add_parser("setup", help="Detect a capture tool, or recommend/install one.")
|
|
813
|
+
p.add_argument("--yes", action="store_true", help="Install the recommended tool without prompting.")
|
|
814
|
+
p.set_defaults(func=cmd_setup)
|
|
815
|
+
|
|
816
|
+
p = sub.add_parser("init", help="Write .bugcap.toml so captures here are tagged/scoped.")
|
|
817
|
+
p.add_argument("--tag", help="Repo tag (defaults to the directory name).")
|
|
818
|
+
p.add_argument("--github", help="GitHub owner/repo (defaults to the origin remote).")
|
|
819
|
+
p.add_argument("--images-repo", dest="images_repo", help="Repo for committed image copies ([sync]).")
|
|
820
|
+
p.add_argument("--force", action="store_true", help="Overwrite an existing .bugcap.toml.")
|
|
821
|
+
_migrate_flags(p)
|
|
822
|
+
p.set_defaults(func=cmd_init)
|
|
823
|
+
|
|
824
|
+
p = sub.add_parser("capture", help="Capture (or import) a screenshot and save a report.")
|
|
825
|
+
p.add_argument("--title", help="Short title for the report.")
|
|
826
|
+
p.add_argument("--note", help="Free-form notes/observations.")
|
|
827
|
+
p.add_argument("--tag", action="append", help="Tag to attach (repeatable).")
|
|
828
|
+
p.add_argument("--image", action="append", metavar="SRC",
|
|
829
|
+
help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
|
|
830
|
+
p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
|
|
831
|
+
p.set_defaults(func=cmd_capture)
|
|
832
|
+
|
|
833
|
+
p = sub.add_parser("list", help="List reports (current repo only, unless --all).")
|
|
834
|
+
p.add_argument("--all", action="store_true", help="List reports from every repo.")
|
|
835
|
+
p.set_defaults(func=cmd_list)
|
|
836
|
+
|
|
837
|
+
p = sub.add_parser("show", help="Show a single report in detail.")
|
|
838
|
+
p.add_argument("id", type=int, help="Report id (see `bugcap list`).")
|
|
839
|
+
p.set_defaults(func=cmd_show)
|
|
840
|
+
|
|
841
|
+
p = sub.add_parser("edit", help="Edit a report's title, notes and/or status.")
|
|
842
|
+
p.add_argument("id", type=int)
|
|
843
|
+
p.add_argument("--title")
|
|
844
|
+
p.add_argument("--note")
|
|
845
|
+
p.add_argument("--status", help=f"One of: {', '.join(STATUSES)}")
|
|
846
|
+
p.set_defaults(func=cmd_edit)
|
|
847
|
+
|
|
848
|
+
p = sub.add_parser("tag", help="Add or remove tags on a report.")
|
|
849
|
+
p.add_argument("id", type=int)
|
|
850
|
+
p.add_argument("action", choices=["add", "remove"])
|
|
851
|
+
p.add_argument("tags", nargs="+")
|
|
852
|
+
p.set_defaults(func=cmd_tag)
|
|
853
|
+
|
|
854
|
+
p = sub.add_parser("attach", help="Attach a screenshot (captured or imported) to a report.")
|
|
855
|
+
p.add_argument("id", type=int)
|
|
856
|
+
p.add_argument("--image", action="append", metavar="SRC",
|
|
857
|
+
help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
|
|
858
|
+
p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
|
|
859
|
+
p.add_argument("--note", help="Text appended to the report's notes, tied to the image(s) added here.")
|
|
860
|
+
p.set_defaults(func=cmd_attach)
|
|
861
|
+
|
|
862
|
+
p = sub.add_parser("images", help="List a report's images, or relabel/remove one.")
|
|
863
|
+
p.add_argument("id", type=int)
|
|
864
|
+
p.add_argument("action", nargs="?", choices=["relabel", "remove"])
|
|
865
|
+
p.add_argument("args", nargs="*", help="relabel: <idx|label> <new-label>; remove: <idx|label>")
|
|
866
|
+
p.add_argument("--force", action="store_true", help="Rewrite @ references in the notes instead of refusing.")
|
|
867
|
+
p.set_defaults(func=cmd_images)
|
|
868
|
+
|
|
869
|
+
cfg_p = sub.add_parser("config", help="Inspect or change settings.")
|
|
870
|
+
cfg_sub = cfg_p.add_subparsers(dest="config_target", required=True)
|
|
871
|
+
repo_p = cfg_sub.add_parser("repo", help="This repo's .bugcap.toml values (tag, github, [sync]).")
|
|
872
|
+
repo_sub = repo_p.add_subparsers(dest="repo_action", required=True)
|
|
873
|
+
p = repo_sub.add_parser("show", help="Print the current values.")
|
|
874
|
+
p.set_defaults(func=cmd_config_repo_show)
|
|
875
|
+
p = repo_sub.add_parser("set", help="Change one value (validated; offers to move reports on identity changes).")
|
|
876
|
+
p.add_argument("key", help=f"One of: {', '.join(repo.SETTABLE_KEYS)}")
|
|
877
|
+
p.add_argument("value")
|
|
878
|
+
_migrate_flags(p)
|
|
879
|
+
p.set_defaults(func=cmd_config_repo_set)
|
|
880
|
+
p = repo_sub.add_parser("unset", help="Remove one value (not the tag).")
|
|
881
|
+
p.add_argument("key")
|
|
882
|
+
_migrate_flags(p)
|
|
883
|
+
p.set_defaults(func=cmd_config_repo_unset)
|
|
884
|
+
|
|
885
|
+
p = sub.add_parser("record", help="Record the screen (video, keyframes or animated GIF) into a report.")
|
|
886
|
+
p.add_argument("--id", type=int, help="Attach to this report (default: create a new one).")
|
|
887
|
+
p.add_argument("--format", choices=list(recorder.FORMATS), default="animated")
|
|
888
|
+
p.add_argument("--max-seconds", dest="max_seconds", type=int, default=30)
|
|
889
|
+
p.add_argument("--fps", type=int, help="Frames per second (default 5, or 30 for video).")
|
|
890
|
+
p.add_argument("--max-mb", dest="max_mb", type=float, default=25)
|
|
891
|
+
p.add_argument("--backend", choices=[b.name for b in recorder.RECORD_BACKENDS])
|
|
892
|
+
p.add_argument("--title")
|
|
893
|
+
p.add_argument("--note")
|
|
894
|
+
p.add_argument("--tag", action="append")
|
|
895
|
+
p.set_defaults(func=cmd_record)
|
|
896
|
+
|
|
897
|
+
p = sub.add_parser("live", help="Floating control window to capture and report many bugs in a row.")
|
|
898
|
+
p.add_argument("--repo-dir", dest="repo_dir", help="Directory whose .bugcap.toml scopes the reports.")
|
|
899
|
+
p.set_defaults(func=cmd_live)
|
|
900
|
+
|
|
901
|
+
p = sub.add_parser("dashboard", help="Browse and triage reports in a local web page (127.0.0.1 only).")
|
|
902
|
+
p.add_argument("--port", type=int, default=8765, help="Port (0 picks a free one).")
|
|
903
|
+
p.add_argument("--host", help="Bind address (default 127.0.0.1; anything else exposes your bugs).")
|
|
904
|
+
p.add_argument("--open", action="store_true", help="Open the page in your browser.")
|
|
905
|
+
p.set_defaults(func=cmd_dashboard)
|
|
906
|
+
|
|
907
|
+
gh = sub.add_parser("github", help="GitHub integration (via the `gh` CLI).")
|
|
908
|
+
gh_sub = gh.add_subparsers(dest="github_command", required=True)
|
|
909
|
+
pull = gh_sub.add_parser("pull", help="Import issues as reports.")
|
|
910
|
+
pull.add_argument("--repo", help="owner/repo (defaults to .bugcap.toml).")
|
|
911
|
+
pull.add_argument("--label", action="append", help="Filter by label (repeatable).")
|
|
912
|
+
pull.add_argument("--limit", type=int, default=30, help="Max issues (default 30).")
|
|
913
|
+
pull.add_argument("--ask", action="store_true", help="Ask to add a screenshot per issue.")
|
|
914
|
+
pull.set_defaults(func=cmd_github_pull)
|
|
915
|
+
|
|
916
|
+
p = sub.add_parser("sync", help="Push a report to a destination (GitHub).")
|
|
917
|
+
p.add_argument("id", type=int)
|
|
918
|
+
p.add_argument("--to", choices=list(_DESTINATIONS), default="github")
|
|
919
|
+
p.add_argument("--repo", help="Issue repo owner/repo.")
|
|
920
|
+
p.add_argument("--images-repo", dest="images_repo", help="Repo to commit image copies to.")
|
|
921
|
+
p.add_argument("--images-path", dest="images_path", help="Directory within the images repo.")
|
|
922
|
+
p.add_argument("--images-branch", dest="images_branch", help="Branch within the images repo.")
|
|
923
|
+
p.add_argument("--yes", action="store_true", help="Accept image-commit consent prompts.")
|
|
924
|
+
p.set_defaults(func=cmd_sync)
|
|
925
|
+
|
|
926
|
+
p = sub.add_parser("mcp-serve", help="Run the stdio MCP server (needs the 'mcp' extra).")
|
|
927
|
+
p.add_argument("--log-level", dest="log_level", choices=["debug", "info", "warning", "error"],
|
|
928
|
+
help="Log level (default info; or BUGCAP_LOG_LEVEL, or [log] level in config.toml).")
|
|
929
|
+
p.add_argument("--log-file", dest="log_file", help="Log file (default: logs/mcp-server.log in the data dir).")
|
|
930
|
+
p.set_defaults(func=cmd_mcp_serve)
|
|
931
|
+
|
|
932
|
+
return parser
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def main(argv: list[str] | None = None) -> int:
|
|
936
|
+
parser = build_parser()
|
|
937
|
+
args = parser.parse_args(argv)
|
|
938
|
+
return args.func(args)
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
if __name__ == "__main__":
|
|
942
|
+
raise SystemExit(main())
|