bugcap 0.2.2__tar.gz → 0.3.0__tar.gz
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-0.2.2/src/bugcap.egg-info → bugcap-0.3.0}/PKG-INFO +6 -6
- {bugcap-0.2.2 → bugcap-0.3.0}/README.md +5 -5
- {bugcap-0.2.2 → bugcap-0.3.0}/pyproject.toml +1 -1
- bugcap-0.3.0/src/bugcap/__init__.py +1 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/agent_api.py +2 -1
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/cli.py +48 -5
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/api.py +4 -2
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/static/app.js +2 -1
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/live.py +87 -29
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/mcp_server.py +2 -2
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/refs.py +67 -10
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/service.py +36 -10
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/store.py +10 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/sync.py +26 -6
- {bugcap-0.2.2 → bugcap-0.3.0/src/bugcap.egg-info}/PKG-INFO +6 -6
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/SOURCES.txt +2 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_agent_api.py +1 -1
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_attach.py +8 -8
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_media.py +7 -7
- bugcap-0.3.0/tests/test_cli_no_image_delete.py +95 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_mutations.py +1 -1
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_github_media_sync.py +18 -2
- bugcap-0.3.0/tests/test_image_refs.py +82 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_refs_validate_rewrite.py +1 -1
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_service_media.py +1 -1
- bugcap-0.2.2/src/bugcap/__init__.py +0 -1
- {bugcap-0.2.2 → bugcap-0.3.0}/LICENSE +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/setup.cfg +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/backends.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/capture.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/config.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/media.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/server.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/drafts.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/ingest.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/live_session.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/paths.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/recorder.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/repo.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_backends.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_capture.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_setup.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_triage.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_drafts.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_ingest.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_live_session.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_mcp.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_mcp_logging.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_paths.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_argv.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_run.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_refs.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_regression_output.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_repo_config.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_service_refs.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_store_migration.py +0 -0
- {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_sync_consent.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugcap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
|
|
5
5
|
Author: Konrad Tagnon Amen ALAHASSA
|
|
6
6
|
License: MIT
|
|
@@ -68,8 +68,8 @@ bugcap keeps the whole loop in one place:
|
|
|
68
68
|
## Quick start
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
# install
|
|
72
|
-
pipx install 'bugcap[mcp]
|
|
71
|
+
# install from PyPI
|
|
72
|
+
pipx install 'bugcap[mcp]'
|
|
73
73
|
|
|
74
74
|
bugcap setup --yes # detects or installs a capture tool
|
|
75
75
|
cd ~/code/myproject && bugcap init # scope reports to this repo
|
|
@@ -128,8 +128,8 @@ Requires Python 3.10 or newer. The core CLI uses only the standard library (plus
|
|
|
128
128
|
Python 3.11). The MCP server is an optional extra.
|
|
129
129
|
|
|
130
130
|
```bash
|
|
131
|
-
pipx install 'bugcap[mcp]
|
|
132
|
-
uv tool install 'bugcap[mcp]
|
|
131
|
+
pipx install 'bugcap[mcp]' # recommended
|
|
132
|
+
uv tool install 'bugcap[mcp]'
|
|
133
133
|
pip install -e '.[dev,mcp]' # development
|
|
134
134
|
```
|
|
135
135
|
|
|
@@ -146,7 +146,7 @@ pull and sync, MCP, and where data lives per OS.
|
|
|
146
146
|
|
|
147
147
|
Notes on the more involved behaviour:
|
|
148
148
|
|
|
149
|
-
-
|
|
149
|
+
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
150
150
|
- **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
|
|
151
151
|
- **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
|
|
152
152
|
|
|
@@ -52,8 +52,8 @@ bugcap keeps the whole loop in one place:
|
|
|
52
52
|
## Quick start
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
|
-
# install
|
|
56
|
-
pipx install 'bugcap[mcp]
|
|
55
|
+
# install from PyPI
|
|
56
|
+
pipx install 'bugcap[mcp]'
|
|
57
57
|
|
|
58
58
|
bugcap setup --yes # detects or installs a capture tool
|
|
59
59
|
cd ~/code/myproject && bugcap init # scope reports to this repo
|
|
@@ -112,8 +112,8 @@ Requires Python 3.10 or newer. The core CLI uses only the standard library (plus
|
|
|
112
112
|
Python 3.11). The MCP server is an optional extra.
|
|
113
113
|
|
|
114
114
|
```bash
|
|
115
|
-
pipx install 'bugcap[mcp]
|
|
116
|
-
uv tool install 'bugcap[mcp]
|
|
115
|
+
pipx install 'bugcap[mcp]' # recommended
|
|
116
|
+
uv tool install 'bugcap[mcp]'
|
|
117
117
|
pip install -e '.[dev,mcp]' # development
|
|
118
118
|
```
|
|
119
119
|
|
|
@@ -130,7 +130,7 @@ pull and sync, MCP, and where data lives per OS.
|
|
|
130
130
|
|
|
131
131
|
Notes on the more involved behaviour:
|
|
132
132
|
|
|
133
|
-
-
|
|
133
|
+
- **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
|
|
134
134
|
- **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
|
|
135
135
|
- **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
|
|
136
136
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bugcap"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -120,7 +120,8 @@ def update_notes(store: Store, id: int, notes: str) -> dict:
|
|
|
120
120
|
except ServiceError as exc:
|
|
121
121
|
return {"error": exc.message, **exc.as_dict()}
|
|
122
122
|
view = service.get_report_view(store, id)
|
|
123
|
-
|
|
123
|
+
warnings = service.report_ref_warnings(store, report.notes)
|
|
124
|
+
return {"report_id": report.id, "notes": report.notes, "references": view.references, "warnings": warnings}
|
|
124
125
|
|
|
125
126
|
|
|
126
127
|
def _resolve_slug(explicit: Optional[str]) -> Optional[str]:
|
|
@@ -42,17 +42,26 @@ def print_ingest(added, rejected) -> None:
|
|
|
42
42
|
"""One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
|
|
43
43
|
for media in added:
|
|
44
44
|
name = os.path.basename(media.path or "")
|
|
45
|
-
print(f"added
|
|
45
|
+
print(f"added {refs.media_token(media)} {name} ({media.kind}, {human_size(media.size_bytes)})")
|
|
46
46
|
for item in rejected:
|
|
47
47
|
print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
# --- capture / list / show ----------------------------------------------------
|
|
51
51
|
|
|
52
|
+
def _warn_report_refs(store: Store, notes) -> None:
|
|
53
|
+
"""Soft warnings for `#N` references to reports that do not exist (never a failure)."""
|
|
54
|
+
for line in service.report_ref_warnings(store, notes or ""):
|
|
55
|
+
print(line, file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
52
58
|
def cmd_capture(args: argparse.Namespace) -> int:
|
|
53
59
|
cfg = current_repo()
|
|
54
60
|
captured: Optional[str] = None
|
|
55
|
-
if
|
|
61
|
+
if args.no_image and (args.image or args.label):
|
|
62
|
+
print("error: --no-image cannot be combined with --image or --label", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
if not args.image and not args.no_image:
|
|
56
65
|
try:
|
|
57
66
|
captured = str(capture.capture_screenshot())
|
|
58
67
|
except CaptureError as exc:
|
|
@@ -89,6 +98,8 @@ def cmd_capture(args: argparse.Namespace) -> int:
|
|
|
89
98
|
except ServiceError as exc:
|
|
90
99
|
print_service_error(exc)
|
|
91
100
|
return 1
|
|
101
|
+
if report is not None:
|
|
102
|
+
_warn_report_refs(store, report.notes)
|
|
92
103
|
|
|
93
104
|
if report is None:
|
|
94
105
|
print_ingest([], result.rejected)
|
|
@@ -103,6 +114,27 @@ def cmd_capture(args: argparse.Namespace) -> int:
|
|
|
103
114
|
return 1 if result.rejected else 0
|
|
104
115
|
|
|
105
116
|
|
|
117
|
+
def cmd_delete(args: argparse.Namespace) -> int:
|
|
118
|
+
with Store() as store:
|
|
119
|
+
report = resolve_report(store, args.id)
|
|
120
|
+
if report is None:
|
|
121
|
+
return 1
|
|
122
|
+
if not args.yes:
|
|
123
|
+
if sys.stdin is None or not sys.stdin.isatty():
|
|
124
|
+
print("error: refusing to delete without confirmation; re-run with --yes", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
answer = input(
|
|
127
|
+
f"Delete report #{report.id} {report.title!r} and its {len(report.media)} image(s)? [y/N] "
|
|
128
|
+
).strip().lower()
|
|
129
|
+
if answer != "y":
|
|
130
|
+
print("Nothing deleted.")
|
|
131
|
+
return 1
|
|
132
|
+
service.delete_report(store, args.id)
|
|
133
|
+
|
|
134
|
+
print(f"Deleted report #{report.id}: {report.title}")
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
106
138
|
def cmd_list(args: argparse.Namespace) -> int:
|
|
107
139
|
cfg = current_repo()
|
|
108
140
|
scope = cfg.key if (cfg is not None and not args.all) else None
|
|
@@ -142,7 +174,7 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
142
174
|
for m in detailed:
|
|
143
175
|
where = m.path or f"({len(m.frames)} frames)"
|
|
144
176
|
origin = f" (source: {m.source})" if m.source else ""
|
|
145
|
-
print(f"
|
|
177
|
+
print(f" {refs.media_token(m)} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
|
|
146
178
|
print(f" synced_refs:")
|
|
147
179
|
if report.synced_refs:
|
|
148
180
|
for tracker, ref in report.synced_refs.items():
|
|
@@ -440,6 +472,8 @@ def cmd_edit(args: argparse.Namespace) -> int:
|
|
|
440
472
|
print(f"error: {exc}", file=sys.stderr)
|
|
441
473
|
return 2
|
|
442
474
|
|
|
475
|
+
_warn_report_refs(store, report.notes)
|
|
476
|
+
|
|
443
477
|
changed = []
|
|
444
478
|
if args.title is not None:
|
|
445
479
|
changed.append("title")
|
|
@@ -529,6 +563,7 @@ def cmd_attach(args: argparse.Namespace) -> int:
|
|
|
529
563
|
print("warning: the image was attached but the note was not saved.", file=sys.stderr)
|
|
530
564
|
return 1
|
|
531
565
|
current = store.get(args.id)
|
|
566
|
+
_warn_report_refs(store, current.notes)
|
|
532
567
|
if args.image:
|
|
533
568
|
try:
|
|
534
569
|
refs.validate_references(current.notes, current.media)
|
|
@@ -552,7 +587,7 @@ def cmd_images(args: argparse.Namespace) -> int:
|
|
|
552
587
|
if args.action is None:
|
|
553
588
|
for m in report.media:
|
|
554
589
|
where = m.path or f"({len(m.frames)} frames)"
|
|
555
|
-
print(f"
|
|
590
|
+
print(f"{refs.media_token(m)} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
|
|
556
591
|
return 0
|
|
557
592
|
try:
|
|
558
593
|
if args.action == "relabel":
|
|
@@ -634,8 +669,9 @@ def cmd_record(args: argparse.Namespace) -> int:
|
|
|
634
669
|
recorder.discard(result)
|
|
635
670
|
print_service_error(exc)
|
|
636
671
|
return 1
|
|
672
|
+
_warn_report_refs(store, report.notes)
|
|
637
673
|
print(f"recorded {result.duration:.1f} s, {human_size(result.size_bytes)} ({result.kind}), stopped: {result.reason}")
|
|
638
|
-
print(f"added
|
|
674
|
+
print(f"added {refs.media_token(media)} to report #{report.id}")
|
|
639
675
|
return 0
|
|
640
676
|
|
|
641
677
|
|
|
@@ -828,8 +864,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
828
864
|
p.add_argument("--image", action="append", metavar="SRC",
|
|
829
865
|
help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
|
|
830
866
|
p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
|
|
867
|
+
p.add_argument("--no-image", dest="no_image", action="store_true",
|
|
868
|
+
help="Save a text-only report: no capture tool and no image.")
|
|
831
869
|
p.set_defaults(func=cmd_capture)
|
|
832
870
|
|
|
871
|
+
p = sub.add_parser("delete", help="Permanently delete a report and its images (not synced GitHub issues).")
|
|
872
|
+
p.add_argument("id", type=int, help="Report id (see `bugcap list`).")
|
|
873
|
+
p.add_argument("--yes", action="store_true", help="Delete without the confirmation prompt.")
|
|
874
|
+
p.set_defaults(func=cmd_delete)
|
|
875
|
+
|
|
833
876
|
p = sub.add_parser("list", help="List reports (current repo only, unless --all).")
|
|
834
877
|
p.add_argument("--all", action="store_true", help="List reports from every repo.")
|
|
835
878
|
p.set_defaults(func=cmd_list)
|
|
@@ -101,5 +101,7 @@ def set_notes(store, report_id: int, body: dict) -> dict:
|
|
|
101
101
|
notes = body.get("notes")
|
|
102
102
|
if not isinstance(notes, str):
|
|
103
103
|
raise ServiceError("bad_query", "body must be {\"notes\": \"...\"}")
|
|
104
|
-
service.set_notes(store, report_id, notes)
|
|
105
|
-
|
|
104
|
+
report = service.set_notes(store, report_id, notes)
|
|
105
|
+
view = get_report(store, report_id)
|
|
106
|
+
view["warnings"] = service.report_ref_warnings(store, report.notes)
|
|
107
|
+
return view
|
|
@@ -94,7 +94,8 @@
|
|
|
94
94
|
var i = document.createElement("img");
|
|
95
95
|
i.src = m.url; i.alt = m.label || ("image " + m.index); fig.appendChild(i);
|
|
96
96
|
}
|
|
97
|
-
var
|
|
97
|
+
var letters = {image: "i", video: "v", animated: "g", frames: "f"};
|
|
98
|
+
var label = caption || ("@" + (letters[m.kind] || "i") + m.index);
|
|
98
99
|
if (m.label) label += " (" + m.label + ")";
|
|
99
100
|
fig.appendChild(el("figcaption", label + " · " + m.kind + " · " + Math.round(m.size_bytes / 1024) + " KB"));
|
|
100
101
|
return fig;
|
|
@@ -22,6 +22,10 @@ TK_HINT = (
|
|
|
22
22
|
)
|
|
23
23
|
NO_DESKTOP = "Live mode needs a desktop session (no DISPLAY or WAYLAND_DISPLAY is set)."
|
|
24
24
|
THUMB = 420
|
|
25
|
+
# Light palette; the control and details windows share it (see `_App.style`).
|
|
26
|
+
BG, CARD, TEXT, MUTED = "#f4f5f7", "#ffffff", "#111827", "#6b7280"
|
|
27
|
+
ACCENT, ACCENT_ACTIVE, BORDER = "#2563eb", "#1d4ed8", "#d1d5db"
|
|
28
|
+
DANGER, WARNING = "#b91c1c", "#b45309"
|
|
25
29
|
|
|
26
30
|
|
|
27
31
|
def check_environment() -> tuple[int, str]:
|
|
@@ -69,25 +73,67 @@ class _App:
|
|
|
69
73
|
self.widgets: dict = {}
|
|
70
74
|
self.pending_drafts: list = []
|
|
71
75
|
|
|
76
|
+
# --- styling ---------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def style(self) -> None:
|
|
79
|
+
"""One consistent look for every window: a flat light theme with an accent button."""
|
|
80
|
+
tk, ttk = self.tk, self.ttk
|
|
81
|
+
import tkinter.font as tkfont
|
|
82
|
+
|
|
83
|
+
base = tkfont.nametofont("TkDefaultFont")
|
|
84
|
+
self.body_font = base
|
|
85
|
+
self.title_font = base.copy()
|
|
86
|
+
self.title_font.configure(size=base.cget("size") + 4, weight="bold")
|
|
87
|
+
self.heading_font = base.copy()
|
|
88
|
+
self.heading_font.configure(weight="bold")
|
|
89
|
+
self.root.configure(background=BG)
|
|
90
|
+
style = ttk.Style(self.root)
|
|
91
|
+
if "clam" in style.theme_names():
|
|
92
|
+
style.theme_use("clam")
|
|
93
|
+
style.configure(".", background=BG, foreground=TEXT, font=base)
|
|
94
|
+
style.configure("TFrame", background=BG)
|
|
95
|
+
style.configure("Card.TFrame", background=CARD, bordercolor=BORDER, relief="solid", borderwidth=1)
|
|
96
|
+
style.configure("TLabel", background=BG, foreground=TEXT)
|
|
97
|
+
style.configure("Card.TLabel", background=CARD)
|
|
98
|
+
style.configure("Title.TLabel", font=self.title_font)
|
|
99
|
+
style.configure("Heading.TLabel", font=self.heading_font)
|
|
100
|
+
style.configure("Muted.TLabel", foreground=MUTED)
|
|
101
|
+
style.configure("Muted.Card.TLabel", background=CARD, foreground=MUTED)
|
|
102
|
+
style.configure("Error.TLabel", foreground=DANGER)
|
|
103
|
+
style.configure("Warning.TLabel", foreground=WARNING)
|
|
104
|
+
style.configure("TButton", padding=(14, 6), background=CARD, bordercolor=BORDER, lightcolor=CARD,
|
|
105
|
+
darkcolor=CARD, focuscolor=CARD)
|
|
106
|
+
style.map("TButton", background=[("active", BG), ("disabled", BG)], foreground=[("disabled", MUTED)])
|
|
107
|
+
style.configure("Accent.TButton", padding=(18, 8), background=ACCENT, foreground="#ffffff",
|
|
108
|
+
bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT, focuscolor=ACCENT)
|
|
109
|
+
style.map("Accent.TButton", background=[("active", ACCENT_ACTIVE), ("disabled", BORDER)],
|
|
110
|
+
foreground=[("disabled", MUTED)])
|
|
111
|
+
style.configure("TEntry", fieldbackground=CARD, bordercolor=BORDER, lightcolor=BORDER,
|
|
112
|
+
darkcolor=BORDER, padding=4)
|
|
113
|
+
style.map("TEntry", bordercolor=[("focus", ACCENT)], lightcolor=[("focus", ACCENT)])
|
|
114
|
+
style.configure("TCombobox", fieldbackground=CARD, bordercolor=BORDER, padding=3)
|
|
115
|
+
|
|
72
116
|
# --- control window --------------------------------------------------------
|
|
73
117
|
|
|
74
118
|
def start_ui(self) -> None:
|
|
75
119
|
tk, ttk, root = self.tk, self.ttk, self.root
|
|
76
120
|
root.title("bugcap live")
|
|
77
121
|
root.resizable(False, False)
|
|
122
|
+
self.style()
|
|
78
123
|
notice = ""
|
|
79
124
|
try:
|
|
80
125
|
root.attributes("-topmost", True)
|
|
81
126
|
except tk.TclError:
|
|
82
127
|
notice = "Always-on-top is not supported here; the window may be hidden by others."
|
|
83
|
-
frame = ttk.Frame(root, padding=
|
|
128
|
+
frame = ttk.Frame(root, padding=16)
|
|
84
129
|
frame.grid()
|
|
85
|
-
|
|
86
|
-
self.start_button.
|
|
87
|
-
self.
|
|
88
|
-
self.counter.
|
|
89
|
-
self.
|
|
90
|
-
self.status.
|
|
130
|
+
ttk.Label(frame, text="bugcap live", style="Title.TLabel").grid(row=0, column=0, sticky="w")
|
|
131
|
+
self.start_button = ttk.Button(frame, text="Start capture", style="Accent.TButton", command=self.on_start)
|
|
132
|
+
self.start_button.grid(row=1, column=0, sticky="ew", pady=(12, 0))
|
|
133
|
+
self.counter = ttk.Label(frame, text="0 saved this session", style="Muted.TLabel")
|
|
134
|
+
self.counter.grid(row=2, column=0, pady=(10, 0))
|
|
135
|
+
self.status = ttk.Label(frame, text=notice, wraplength=260, style="Warning.TLabel")
|
|
136
|
+
self.status.grid(row=3, column=0, pady=(6, 0), sticky="w")
|
|
91
137
|
root.protocol("WM_DELETE_WINDOW", self.on_close_control)
|
|
92
138
|
self.pending_drafts = drafts.list_drafts(self.cfg.key if self.cfg else None)
|
|
93
139
|
root.after(200, self.offer_drafts)
|
|
@@ -166,44 +212,56 @@ class _App:
|
|
|
166
212
|
win.attributes("-topmost", True)
|
|
167
213
|
except tk.TclError:
|
|
168
214
|
pass
|
|
169
|
-
|
|
215
|
+
self.style()
|
|
216
|
+
frame = ttk.Frame(win, padding=16)
|
|
170
217
|
frame.grid()
|
|
171
218
|
w = self.widgets = {}
|
|
219
|
+
ttk.Label(frame, text="New bug report", style="Title.TLabel").grid(row=0, column=0, columnspan=2, sticky="w")
|
|
172
220
|
|
|
221
|
+
preview = ttk.Frame(frame, style="Card.TFrame", padding=6)
|
|
222
|
+
preview.grid(row=1, column=0, columnspan=2, pady=(10, 12))
|
|
173
223
|
try:
|
|
174
224
|
image = tk.PhotoImage(file=str(self.session.screenshot))
|
|
175
225
|
factor = max(1, -(-max(image.width(), image.height()) // THUMB))
|
|
176
226
|
w["photo"] = image.subsample(factor, factor) if factor > 1 else image
|
|
177
|
-
ttk.Label(
|
|
227
|
+
ttk.Label(preview, image=w["photo"], style="Card.TLabel").grid()
|
|
178
228
|
except tk.TclError:
|
|
179
|
-
ttk.Label(
|
|
229
|
+
ttk.Label(preview, text="(preview unavailable)", style="Muted.Card.TLabel").grid()
|
|
180
230
|
|
|
181
231
|
prefill = prefill or {}
|
|
182
|
-
ttk.
|
|
183
|
-
|
|
184
|
-
|
|
232
|
+
form = ttk.Frame(frame)
|
|
233
|
+
form.grid(row=2, column=0, columnspan=2, sticky="ew")
|
|
234
|
+
form.columnconfigure(1, weight=1)
|
|
235
|
+
ttk.Label(form, text="Title", style="Heading.TLabel").grid(row=0, column=0, sticky="nw", padx=(0, 12), pady=4)
|
|
236
|
+
w["title"] = ttk.Entry(form, width=48)
|
|
237
|
+
w["title"].grid(row=0, column=1, sticky="ew", pady=4)
|
|
185
238
|
w["title"].insert(0, prefill.get("title", ""))
|
|
186
|
-
ttk.Label(
|
|
187
|
-
w["notes"] = tk.Text(
|
|
188
|
-
|
|
239
|
+
ttk.Label(form, text="Notes", style="Heading.TLabel").grid(row=1, column=0, sticky="nw", padx=(0, 12), pady=4)
|
|
240
|
+
w["notes"] = tk.Text(
|
|
241
|
+
form, width=48, height=6, wrap="word", relief="flat", background=CARD, foreground=TEXT,
|
|
242
|
+
highlightthickness=1, highlightbackground=BORDER, highlightcolor=ACCENT, padx=6, pady=4,
|
|
243
|
+
font=self.body_font,
|
|
244
|
+
)
|
|
245
|
+
w["notes"].grid(row=1, column=1, sticky="ew", pady=4)
|
|
189
246
|
w["notes"].insert("1.0", prefill.get("notes", ""))
|
|
190
247
|
w["notes"].bind("<KeyRelease>", lambda e: self.check_notes())
|
|
191
|
-
ttk.Label(
|
|
192
|
-
w["tags"] = ttk.Entry(
|
|
193
|
-
w["tags"].grid(row=
|
|
248
|
+
ttk.Label(form, text="Tags", style="Heading.TLabel").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=4)
|
|
249
|
+
w["tags"] = ttk.Entry(form, width=48)
|
|
250
|
+
w["tags"].grid(row=2, column=1, sticky="ew", pady=4)
|
|
194
251
|
w["tags"].insert(0, ", ".join(initial_tags(self.cfg, prefill)))
|
|
195
|
-
ttk.Label(
|
|
196
|
-
w["status"] = ttk.Combobox(
|
|
252
|
+
ttk.Label(form, text="Status", style="Heading.TLabel").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=4)
|
|
253
|
+
w["status"] = ttk.Combobox(form, values=list(STATUSES), state="readonly", width=16)
|
|
197
254
|
w["status"].set(prefill.get("status", "open"))
|
|
198
|
-
w["status"].grid(row=
|
|
199
|
-
ttk.Label(
|
|
200
|
-
ttk.Label(
|
|
201
|
-
|
|
202
|
-
w["error"].
|
|
255
|
+
w["status"].grid(row=3, column=1, sticky="w", pady=4)
|
|
256
|
+
ttk.Label(form, text="Repo", style="Heading.TLabel").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=4)
|
|
257
|
+
ttk.Label(form, text=(self.cfg.key if self.cfg else "(none)"), style="Muted.TLabel").grid(
|
|
258
|
+
row=4, column=1, sticky="w", pady=4)
|
|
259
|
+
w["error"] = ttk.Label(frame, text="", style="Error.TLabel", wraplength=420)
|
|
260
|
+
w["error"].grid(row=3, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
|
203
261
|
buttons = ttk.Frame(frame)
|
|
204
|
-
buttons.grid(row=
|
|
205
|
-
ttk.Button(buttons, text="Discard", command=self.on_discard).grid(row=0, column=0, padx=
|
|
206
|
-
ttk.Button(buttons, text="Save", command=self.on_save).grid(row=0, column=1)
|
|
262
|
+
buttons.grid(row=4, column=0, columnspan=2, sticky="e", pady=(12, 0))
|
|
263
|
+
ttk.Button(buttons, text="Discard", command=self.on_discard).grid(row=0, column=0, padx=(0, 8))
|
|
264
|
+
ttk.Button(buttons, text="Save", style="Accent.TButton", command=self.on_save).grid(row=0, column=1)
|
|
207
265
|
win.protocol("WM_DELETE_WINDOW", self.on_close_details)
|
|
208
266
|
w["title"].focus_set()
|
|
209
267
|
self.check_notes()
|
|
@@ -182,13 +182,13 @@ def build_server():
|
|
|
182
182
|
@tool()
|
|
183
183
|
def attach_image(id: int, sources: list[str], labels: Optional[list[Optional[str]]] = None) -> str:
|
|
184
184
|
"""Attach images (file paths, globs or http(s) URLs) to a report, with optional labels
|
|
185
|
-
(one per source, null for none). Notes can then refer to them as @
|
|
185
|
+
(one per source, null for none). Notes can then refer to them as @i1 or @label."""
|
|
186
186
|
with Store() as store:
|
|
187
187
|
return json.dumps(agent_api.attach_image(store, id, sources, labels))
|
|
188
188
|
|
|
189
189
|
@tool()
|
|
190
190
|
def update_notes(id: int, notes: str) -> str:
|
|
191
|
-
"""Replace a report's notes. @
|
|
191
|
+
"""Replace a report's notes. @i1 / @label references must point at the report's images."""
|
|
192
192
|
with Store() as store:
|
|
193
193
|
return json.dumps(agent_api.update_notes(store, id, notes))
|
|
194
194
|
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""References in notes.
|
|
2
|
+
|
|
3
|
+
`@` points at media, by kind letter and its number in the report: `@i3` image, `@v2` video,
|
|
4
|
+
`@g4` animated GIF, `@f5` keyframes. `@login-error` is a media label and `@@` a literal `@`.
|
|
5
|
+
The older `@3` form still reads as media 3, so existing notes keep working.
|
|
6
|
+
|
|
7
|
+
`#N` points at report N (soft: an unknown number is kept as text and reported as a warning).
|
|
2
8
|
|
|
3
9
|
References are ignored inside fenced code blocks, inline code spans and email-like text.
|
|
4
10
|
Everything here is pure text processing; the rules for what a reference may point at
|
|
@@ -13,13 +19,19 @@ from .errors import ServiceError
|
|
|
13
19
|
|
|
14
20
|
REMOVED_TEXT = "[image removed]"
|
|
15
21
|
LABEL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*$")
|
|
22
|
+
MEDIA_LETTERS = {"image": "i", "video": "v", "animated": "g", "frames": "f"}
|
|
23
|
+
# `v2`, `G4`, ...: a kind letter and a number. Labels may not look like this (see service.validate_label).
|
|
24
|
+
INDEX_RE = re.compile(r"^([ivgfIVGF])([1-9][0-9]*)$")
|
|
25
|
+
RESERVED_LABEL_RE = re.compile(r"^[ivgfIVGF][0-9]+$") # any kind letter + digits, even i0
|
|
26
|
+
_LEGACY_INDEX_RE = re.compile(r"^([1-9][0-9]*)$")
|
|
27
|
+
_REPORT_RE = re.compile(r"(?:^|(?<=[\s(\[]))#([1-9][0-9]*)(?![\w\-])", re.M)
|
|
16
28
|
_REF_RE = re.compile(r"@@|(?<![\w.%+\-])@([A-Za-z0-9_\-]+)(?![\w\-])")
|
|
17
29
|
_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
|
|
18
30
|
|
|
19
31
|
|
|
20
32
|
@dataclass
|
|
21
33
|
class Ref:
|
|
22
|
-
token: str # text as written, e.g. "@
|
|
34
|
+
token: str # text as written, e.g. "@i1", "@login-error", "@@"
|
|
23
35
|
start: int
|
|
24
36
|
end: int
|
|
25
37
|
kind: Literal["index", "label", "escape", "unknown"]
|
|
@@ -28,6 +40,14 @@ class Ref:
|
|
|
28
40
|
def name(self) -> str:
|
|
29
41
|
return self.token[1:]
|
|
30
42
|
|
|
43
|
+
@property
|
|
44
|
+
def key(self) -> str:
|
|
45
|
+
"""What the reference points at: the image number for an index (`@i1` and `@1` both
|
|
46
|
+
give "1"), otherwise the name as written. Used to match rewrite mappings."""
|
|
47
|
+
if self.kind == "index":
|
|
48
|
+
return index_number(self.name) or self.name
|
|
49
|
+
return self.name
|
|
50
|
+
|
|
31
51
|
|
|
32
52
|
def _blank(text: str) -> str:
|
|
33
53
|
return "".join(ch if ch == "\n" else " " for ch in text)
|
|
@@ -103,8 +123,43 @@ def _mask_spans(text: str) -> str:
|
|
|
103
123
|
return "".join(chars)
|
|
104
124
|
|
|
105
125
|
|
|
126
|
+
def index_number(name: str) -> Optional[str]:
|
|
127
|
+
"""`v3`, `I3` or the older `3` -> "3"; anything else -> None."""
|
|
128
|
+
match = INDEX_RE.match(name)
|
|
129
|
+
if match:
|
|
130
|
+
return match.group(2)
|
|
131
|
+
match = _LEGACY_INDEX_RE.match(name)
|
|
132
|
+
return match.group(1) if match else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def index_letter(name: str) -> Optional[str]:
|
|
136
|
+
"""The lower-case kind letter of `v3` ("v"); None for the older `3` form."""
|
|
137
|
+
match = INDEX_RE.match(name)
|
|
138
|
+
return match.group(1).lower() if match else None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def media_token(item) -> str:
|
|
142
|
+
"""The reference that points at a media item as written in notes: `@i1`, `@v2`, `@g3`, `@f4`."""
|
|
143
|
+
return f"@{MEDIA_LETTERS.get(item.kind, 'i')}{item.idx}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def report_refs(notes: str) -> list[int]:
|
|
147
|
+
"""Report numbers referenced by `#N` in the notes (code and emails excluded)."""
|
|
148
|
+
return [int(m.group(1)) for m in _REPORT_RE.finditer(mask(notes or ""))]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def substitute_reports(notes: str, mapping: dict) -> str:
|
|
152
|
+
"""Replace `#N` by `mapping[N]` where present (other `#N` are left as written)."""
|
|
153
|
+
out = notes or ""
|
|
154
|
+
for m in reversed(list(_REPORT_RE.finditer(mask(out)))):
|
|
155
|
+
number = int(m.group(1))
|
|
156
|
+
if number in mapping:
|
|
157
|
+
out = out[: m.start()] + mapping[number] + out[m.end():]
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
106
161
|
def _classify(name: str) -> str:
|
|
107
|
-
if name
|
|
162
|
+
if index_number(name) is not None:
|
|
108
163
|
return "index"
|
|
109
164
|
if LABEL_RE.match(name):
|
|
110
165
|
return "label"
|
|
@@ -123,10 +178,12 @@ def parse_references(notes: str) -> list[Ref]:
|
|
|
123
178
|
|
|
124
179
|
|
|
125
180
|
def resolve(ref: Ref, media: list):
|
|
126
|
-
"""The media item a reference points at (label match is case-sensitive), or None.
|
|
181
|
+
"""The media item a reference points at (label match is case-sensitive), or None.
|
|
182
|
+
`@v2` only matches a video; the older `@2` matches whatever item is number 2."""
|
|
127
183
|
if ref.kind == "index":
|
|
184
|
+
letter = index_letter(ref.name)
|
|
128
185
|
for item in media:
|
|
129
|
-
if item.idx == int(ref.
|
|
186
|
+
if item.idx == int(ref.key) and (letter is None or MEDIA_LETTERS.get(item.kind) == letter):
|
|
130
187
|
return item
|
|
131
188
|
elif ref.kind == "label":
|
|
132
189
|
for item in media:
|
|
@@ -136,7 +193,7 @@ def resolve(ref: Ref, media: list):
|
|
|
136
193
|
|
|
137
194
|
|
|
138
195
|
def valid_tokens(media: list) -> list[str]:
|
|
139
|
-
indexes = [
|
|
196
|
+
indexes = [media_token(m) for m in sorted(media, key=lambda m: m.idx)]
|
|
140
197
|
labels = [f"@{m.label}" for m in sorted(media, key=lambda m: m.idx) if m.label]
|
|
141
198
|
return indexes + labels
|
|
142
199
|
|
|
@@ -158,8 +215,8 @@ def _rewrite(notes: str, mapping: dict[str, str]) -> tuple[str, int]:
|
|
|
158
215
|
out = notes
|
|
159
216
|
count = 0
|
|
160
217
|
for ref in reversed(parse_references(notes)):
|
|
161
|
-
if ref.kind in ("index", "label") and ref.
|
|
162
|
-
replacement = mapping[ref.
|
|
218
|
+
if ref.kind in ("index", "label") and ref.key in mapping:
|
|
219
|
+
replacement = mapping[ref.key]
|
|
163
220
|
if replacement != ref.token:
|
|
164
221
|
out = out[: ref.start] + replacement + out[ref.end :]
|
|
165
222
|
count += 1
|
|
@@ -167,7 +224,7 @@ def _rewrite(notes: str, mapping: dict[str, str]) -> tuple[str, int]:
|
|
|
167
224
|
|
|
168
225
|
|
|
169
226
|
def rewrite_references(notes: str, mapping: dict[str, str]) -> str:
|
|
170
|
-
"""Replace references by
|
|
227
|
+
"""Replace references by key (`{"2": "@i1", "old": "@new", "3": "[image removed]"}`).
|
|
171
228
|
Code, emails and `@@` are never touched; other text is preserved exactly."""
|
|
172
229
|
return _rewrite(notes, mapping)[0]
|
|
173
230
|
|
|
@@ -186,7 +243,7 @@ def references_to(notes: str, item, media_all: list) -> list[Ref]:
|
|
|
186
243
|
|
|
187
244
|
|
|
188
245
|
def display_notes(notes: str, media: list) -> str:
|
|
189
|
-
"""Notes for terminal display: `@
|
|
246
|
+
"""Notes for terminal display: `@i1` becomes `@i1 (images/…)` and `@@` prints as `@`."""
|
|
190
247
|
out = notes or ""
|
|
191
248
|
for ref in reversed(parse_references(out)):
|
|
192
249
|
if ref.kind == "escape":
|