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/repo.py ADDED
@@ -0,0 +1,138 @@
1
+ """Per-repo setup: `bugcap init` writes .bugcap.toml so captures there get a tag + GitHub slug."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import subprocess
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from . import tomlio
11
+
12
+ CONFIG_NAME = ".bugcap.toml"
13
+
14
+
15
+ class RepoConfigError(RuntimeError):
16
+ pass
17
+
18
+
19
+ @dataclass
20
+ class RepoConfig:
21
+ root: Path
22
+ tag: str
23
+ github: Optional[str] = None # "owner/repo"
24
+ images_repo: Optional[str] = None # [sync] owner/repo for committed image copies
25
+ images_path: Optional[str] = None # [sync] directory within images_repo
26
+ images_branch: Optional[str] = None # [sync] branch within images_repo
27
+
28
+ @property
29
+ def key(self) -> str:
30
+ return self.github or self.tag
31
+
32
+
33
+ def _git(args: list[str], cwd: Path) -> Optional[str]:
34
+ try:
35
+ out = subprocess.run(
36
+ ["git", *args], cwd=cwd, capture_output=True, text=True, check=True
37
+ )
38
+ except (OSError, subprocess.CalledProcessError):
39
+ return None
40
+ return out.stdout.strip() or None
41
+
42
+
43
+ def git_root(start: Optional[Path] = None) -> Optional[Path]:
44
+ root = _git(["rev-parse", "--show-toplevel"], start or Path.cwd())
45
+ return Path(root) if root else None
46
+
47
+
48
+ def detect_github_slug(root: Path) -> Optional[str]:
49
+ url = _git(["remote", "get-url", "origin"], root)
50
+ if not url:
51
+ return None
52
+ match = re.search(r"github\.com[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", url)
53
+ return f"{match.group(1)}/{match.group(2)}" if match else None
54
+
55
+
56
+ def find_config(start: Optional[Path] = None) -> Optional[Path]:
57
+ here = (start or Path.cwd()).resolve()
58
+ for folder in [here, *here.parents]:
59
+ candidate = folder / CONFIG_NAME
60
+ if candidate.is_file():
61
+ return candidate
62
+ return None
63
+
64
+
65
+ def _config_from_file(path: Path) -> RepoConfig:
66
+ data = tomlio.load(path)
67
+ sync = data.get("sync") or {}
68
+ return RepoConfig(
69
+ root=path.parent,
70
+ tag=data.get("tag") or path.parent.name,
71
+ github=data.get("github"),
72
+ images_repo=sync.get("images_repo"),
73
+ images_path=sync.get("images_path"),
74
+ images_branch=sync.get("images_branch"),
75
+ )
76
+
77
+
78
+ def load_repo_config(start: Optional[Path] = None) -> Optional[RepoConfig]:
79
+ path = find_config(start)
80
+ return _config_from_file(path) if path else None
81
+
82
+
83
+ def write_config(cfg: RepoConfig) -> None:
84
+ data: dict = {"tag": cfg.tag}
85
+ if cfg.github:
86
+ data["github"] = cfg.github
87
+ sync = {
88
+ k: v
89
+ for k, v in (
90
+ ("images_repo", cfg.images_repo),
91
+ ("images_path", cfg.images_path),
92
+ ("images_branch", cfg.images_branch),
93
+ )
94
+ if v
95
+ }
96
+ if sync:
97
+ data["sync"] = sync
98
+ tomlio.save(cfg.root / CONFIG_NAME, data)
99
+
100
+
101
+ def build_config(
102
+ root: Path,
103
+ tag: Optional[str] = None,
104
+ github: Optional[str] = None,
105
+ images_repo: Optional[str] = None,
106
+ images_path: Optional[str] = None,
107
+ images_branch: Optional[str] = None,
108
+ ) -> RepoConfig:
109
+ """The config `init` would write (nothing is written)."""
110
+ return RepoConfig(
111
+ root=root,
112
+ tag=tag or root.name,
113
+ github=github or detect_github_slug(root),
114
+ images_repo=images_repo,
115
+ images_path=images_path,
116
+ images_branch=images_branch,
117
+ )
118
+
119
+
120
+ def init_repo(
121
+ root: Path,
122
+ tag: Optional[str] = None,
123
+ github: Optional[str] = None,
124
+ images_repo: Optional[str] = None,
125
+ images_path: Optional[str] = None,
126
+ images_branch: Optional[str] = None,
127
+ force: bool = False,
128
+ ) -> RepoConfig:
129
+ if (root / CONFIG_NAME).exists() and not force:
130
+ raise RepoConfigError(
131
+ f"{CONFIG_NAME} already exists in {root}. Use --force to overwrite."
132
+ )
133
+ cfg = build_config(root, tag, github, images_repo, images_path, images_branch)
134
+ write_config(cfg)
135
+ return cfg
136
+
137
+
138
+ SETTABLE_KEYS = ("tag", "github", "images_repo", "images_path", "images_branch")
bugcap/service.py ADDED
@@ -0,0 +1,418 @@
1
+ """Shared operations used by the CLI, the MCP tools, the dashboard and live mode.
2
+
3
+ All rules that go beyond plain storage (label and reference validation, reference rewrite on
4
+ relabel/remove, media lifecycle) live here so no interface re-implements them. Functions
5
+ take a `Store`, raise `ServiceError`, and never print."""
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+ from . import ingest, refs
13
+ from .errors import ServiceError
14
+ from .paths import resolve_data_path
15
+ from .store import STATUSES, Media, Report, Store
16
+
17
+ _LABEL_HELP = "start with a letter; then letters, digits, '-' or '_'"
18
+
19
+
20
+ # --- lookups ------------------------------------------------------------------
21
+
22
+ def _require(store: Store, report_id: int) -> Report:
23
+ report = store.get(report_id)
24
+ if report is None:
25
+ raise ServiceError("not_found", f"no report with id {report_id}")
26
+ return report
27
+
28
+
29
+ def find_media(media: list[Media], ref: str) -> Media:
30
+ """Locate a media item by index (`2`) or label (`login-error`, case-insensitive)."""
31
+ text = str(ref).lstrip("@")
32
+ if text.isdigit():
33
+ for item in media:
34
+ if item.idx == int(text):
35
+ return item
36
+ else:
37
+ for item in media:
38
+ if item.label and item.label.lower() == text.lower():
39
+ return item
40
+ raise ServiceError("not_found", f"no image {ref!r} on this report")
41
+
42
+
43
+ # --- labels -------------------------------------------------------------------
44
+
45
+ def validate_label(label: str) -> str:
46
+ if not refs.LABEL_RE.match(label):
47
+ if label.isdigit():
48
+ raise ServiceError("invalid_label", f"label {label!r} cannot be only digits (those are indexes)")
49
+ raise ServiceError("invalid_label", f"invalid label {label!r}: {_LABEL_HELP}")
50
+ return label
51
+
52
+
53
+ def _check_new_labels(existing: list[Media], labels: list[Optional[str]]) -> None:
54
+ taken = {m.label.lower(): m for m in existing if m.label}
55
+ for label in labels:
56
+ if not label:
57
+ continue
58
+ validate_label(label)
59
+ clash = taken.get(label.lower())
60
+ if clash is not None:
61
+ raise ServiceError(
62
+ "duplicate_label", f"duplicate label {label!r} (already #{clash.idx})",
63
+ {"label": label, "index": clash.idx},
64
+ )
65
+ taken[label.lower()] = Media(0, 0, 0, label, "image", None, "", 0, None, "")
66
+
67
+
68
+ # --- media: add ---------------------------------------------------------------
69
+
70
+ @dataclass
71
+ class AddResult:
72
+ added: list = field(default_factory=list) # [Media]
73
+ rejected: list = field(default_factory=list) # [{"source","reason","code"}]
74
+
75
+
76
+ def _discard(stored_files) -> None:
77
+ for stored in stored_files:
78
+ try:
79
+ stored.abs_path.unlink()
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def _prepare(existing: list[Media], sources: list[str], labels):
85
+ """Expand globs and validate labels before any file is copied."""
86
+ items = ingest.expand_sources(list(sources), labels)
87
+ _check_new_labels(existing, [i.label for i in items if i.error is None])
88
+ return items
89
+
90
+
91
+ def _ingest_all(items):
92
+ ok, rejected = [], []
93
+ for item in items:
94
+ try:
95
+ ok.append((item, ingest.ingest_item(item)))
96
+ except ServiceError as exc:
97
+ rejected.append({"source": item.source, "reason": exc.message, "code": exc.code})
98
+ return ok, rejected
99
+
100
+
101
+ def add_media(store: Store, report_id: int, sources: list[str], labels=None) -> AddResult:
102
+ report = _require(store, report_id)
103
+ items = _prepare(report.media, sources, labels)
104
+ ok, rejected = _ingest_all(items)
105
+ result = AddResult(rejected=rejected)
106
+ try:
107
+ with store.transaction():
108
+ for item, stored in ok:
109
+ result.added.append(
110
+ store.insert_media(
111
+ report_id, kind="image", path=str(stored.abs_path), label=item.label,
112
+ mime=stored.mime, size_bytes=stored.size_bytes, source=item.source,
113
+ )
114
+ )
115
+ except BaseException:
116
+ _discard(s for _, s in ok)
117
+ raise
118
+ return result
119
+
120
+
121
+ # --- reports ------------------------------------------------------------------
122
+
123
+ def create_report(
124
+ store: Store,
125
+ title: str,
126
+ notes: str = "",
127
+ tags=None,
128
+ repo: Optional[str] = None,
129
+ status: str = "open",
130
+ body: str = "",
131
+ sources: Optional[list[str]] = None,
132
+ labels=None,
133
+ require_media: bool = False,
134
+ captured: Optional[str] = None,
135
+ captured_label: Optional[str] = None,
136
+ ) -> tuple[Report, AddResult]:
137
+ """Create a report, attaching `sources` as images. Notes are validated against the final
138
+ media; on any failure no report and no copied file is left behind. With `require_media`,
139
+ a report whose inputs were all rejected is not created (the report is returned as None).
140
+ `captured` is a screenshot the capture tool already saved in the store: it becomes image 1
141
+ without being copied."""
142
+ if not (title or "").strip():
143
+ raise ServiceError("invalid_title", "title must not be empty")
144
+ items = _prepare([], sources or [], list(labels or []))
145
+ if captured_label:
146
+ _check_new_labels([], [captured_label] + [i.label for i in items if i.error is None])
147
+ ok, rejected = _ingest_all(items)
148
+ result = AddResult(rejected=rejected)
149
+ if require_media and not ok and captured is None:
150
+ return None, result # type: ignore[return-value]
151
+ try:
152
+ with store.transaction():
153
+ report = store.add(title=title, notes="", tags=tags, repo=repo, body=body, status=status)
154
+ if captured is not None:
155
+ result.added.append(
156
+ store.insert_media(report.id, kind="image", path=captured,
157
+ label=captured_label or None, source="captured")
158
+ )
159
+ for item, stored in ok:
160
+ result.added.append(
161
+ store.insert_media(
162
+ report.id, kind="image", path=str(stored.abs_path), label=item.label,
163
+ mime=stored.mime, size_bytes=stored.size_bytes, source=item.source,
164
+ )
165
+ )
166
+ if notes:
167
+ refs.validate_references(notes, result.added)
168
+ store.update(report.id, notes=notes)
169
+ except BaseException:
170
+ _discard(s for _, s in ok)
171
+ raise
172
+ return store.get(report.id), result
173
+
174
+
175
+ def attach_captured(store: Store, report_id: int, path: str, label: Optional[str] = None) -> Media:
176
+ """Attach a screenshot the capture tool already saved in the store (no copy)."""
177
+ report = _require(store, report_id)
178
+ _check_new_labels(report.media, [label])
179
+ return store.insert_media(report_id, kind="image", path=str(path), label=label or None, source="captured")
180
+
181
+
182
+ def add_recording(
183
+ store: Store,
184
+ report_id: Optional[int],
185
+ file: Optional[str],
186
+ kind: str,
187
+ mime: str,
188
+ size_bytes: int,
189
+ frame_paths: Optional[list] = None,
190
+ title: Optional[str] = None,
191
+ notes: Optional[str] = None,
192
+ tags=None,
193
+ repo: Optional[str] = None,
194
+ ) -> tuple[Report, Media]:
195
+ """Attach a finished recording (video / animated file, or a set of keyframes) to an
196
+ existing report, or to a new one when `report_id` is None. `frame_paths` is
197
+ [(path, size)] for kind `frames`."""
198
+ from datetime import datetime
199
+
200
+ if kind not in ("video", "animated", "frames"):
201
+ raise ServiceError("invalid_image", f"unsupported recording kind {kind!r}")
202
+ if kind == "frames" and not frame_paths:
203
+ raise ServiceError("invalid_image", "a frames recording needs at least one keyframe")
204
+ with store.transaction():
205
+ if report_id is None:
206
+ report = store.add(
207
+ title=title or f"Recording {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
208
+ tags=tags, repo=repo,
209
+ )
210
+ else:
211
+ report = _require(store, report_id)
212
+ media = store.insert_media(
213
+ report.id, kind=kind, path=None if kind == "frames" else file, mime=mime,
214
+ size_bytes=size_bytes, source="recorded", frames=frame_paths if kind == "frames" else None,
215
+ )
216
+ if notes:
217
+ refs.validate_references(notes, store.list_media(report.id))
218
+ store.update(report.id, notes=notes)
219
+ return store.get(report.id), media
220
+
221
+
222
+ def format_note_entry(text: str, media: list, when=None) -> str:
223
+ """`[2026-10-04 10:15] @3: text`: timestamped and tied to the image(s) it was added with."""
224
+ from datetime import datetime
225
+
226
+ stamp = (when or datetime.now().astimezone()).strftime("%Y-%m-%d %H:%M")
227
+ tokens = " ".join(f"@{m.idx}" for m in media)
228
+ return f"[{stamp}] {tokens}: {text.strip()}" if tokens else f"[{stamp}] {text.strip()}"
229
+
230
+
231
+ def append_note(store: Store, report_id: int, text: str, media: Optional[list] = None, when=None) -> Report:
232
+ """Append a note to the report's notes (never replacing them), tied to the media it was
233
+ added with so it can be referenced with `@n`. The new entry is validated like any note."""
234
+ report = _require(store, report_id)
235
+ if not (text or "").strip():
236
+ return report
237
+ entry = format_note_entry(text, media or [], when)
238
+ refs.validate_references(entry, report.media)
239
+ notes = f"{report.notes.rstrip()}\n\n{entry}" if (report.notes or "").strip() else entry
240
+ return store.update(report_id, notes=notes)
241
+
242
+
243
+ def set_status(store: Store, report_id: int, status: str) -> Report:
244
+ _require(store, report_id)
245
+ if status not in STATUSES:
246
+ raise ServiceError(
247
+ "invalid_status", f"invalid status {status!r}; choose one of: {', '.join(STATUSES)}"
248
+ )
249
+ return store.update(report_id, status=status)
250
+
251
+
252
+ def set_tags(store: Store, report_id: int, add=None, remove=None) -> list[str]:
253
+ report = _require(store, report_id)
254
+ tags = list(report.tags)
255
+ for tag in add or []:
256
+ if tag not in tags:
257
+ tags.append(tag)
258
+ tags = [t for t in tags if t not in set(remove or [])]
259
+ return store.set_tags(report_id, tags).tags
260
+
261
+
262
+ def set_notes(store: Store, report_id: int, notes: str) -> Report:
263
+ report = _require(store, report_id)
264
+ refs.validate_references(notes, report.media)
265
+ return store.update(report_id, notes=notes)
266
+
267
+
268
+ # --- media: relabel / remove --------------------------------------------------
269
+
270
+ def _referencing(report: Report, item: Media, only_label: bool = False) -> list[refs.Ref]:
271
+ found = refs.references_to(report.notes, item, report.media)
272
+ return [r for r in found if r.kind == "label"] if only_label else found
273
+
274
+
275
+ def _refuse(item: Media, found: list[refs.Ref]) -> ServiceError:
276
+ tokens = [r.token for r in found]
277
+ return ServiceError(
278
+ "referenced_image",
279
+ f"image #{item.idx} is referenced in notes by {', '.join(tokens)}; use --force to rewrite them",
280
+ {"tokens": tokens},
281
+ )
282
+
283
+
284
+ def relabel_media(store: Store, report_id: int, ref: str, new_label: str, force: bool = False) -> int:
285
+ """Returns the number of rewritten references."""
286
+ report = _require(store, report_id)
287
+ item = find_media(report.media, ref)
288
+ new_label = new_label or ""
289
+ if new_label:
290
+ others = [m for m in report.media if m.id != item.id]
291
+ _check_new_labels(others, [new_label])
292
+ found = _referencing(report, item, only_label=True) if item.label else []
293
+ if found and not force:
294
+ raise _refuse(item, found)
295
+ mapping = {item.label: f"@{new_label or item.idx}"} if item.label else {}
296
+ with store.transaction():
297
+ store.update_media_label(item.id, new_label or None)
298
+ notes, count = refs.rewrite_references_counted(report.notes, mapping)
299
+ if count:
300
+ store.update(report_id, notes=notes)
301
+ return count
302
+
303
+
304
+ def remove_media(store: Store, report_id: int, ref: str, force: bool = False) -> int:
305
+ """Delete an item and replace references to it with plain text; other indexes stay as
306
+ they are (an index is never reused). Returns the number of rewritten references."""
307
+ report = _require(store, report_id)
308
+ item = find_media(report.media, ref)
309
+ found = _referencing(report, item)
310
+ if found and not force:
311
+ raise _refuse(item, found)
312
+ mapping = {str(item.idx): refs.REMOVED_TEXT}
313
+ if item.label:
314
+ mapping[item.label] = refs.REMOVED_TEXT
315
+ with store.transaction():
316
+ store.delete_media(item.id)
317
+ notes, count = refs.rewrite_references_counted(report.notes, mapping)
318
+ if count:
319
+ store.update(report_id, notes=notes)
320
+ _delete_files(item)
321
+ return count
322
+
323
+
324
+ def _delete_files(item: Media) -> None:
325
+ paths = [item.path] if item.path else []
326
+ paths += [f.path for f in item.frames]
327
+ for rel in paths:
328
+ try:
329
+ os.unlink(resolve_data_path(rel))
330
+ except (ValueError, OSError):
331
+ pass # legacy absolute paths and missing files are left alone
332
+
333
+
334
+ # --- views --------------------------------------------------------------------
335
+
336
+ def report_segments(notes: str, media: list[Media]) -> list[dict]:
337
+ """Notes as `{"text": ...}` / `{"ref": {...}}` segments (no HTML), for the dashboard."""
338
+ segments: list[dict] = []
339
+ pos = 0
340
+ text = notes or ""
341
+
342
+ def add_text(chunk: str) -> None:
343
+ if chunk:
344
+ if segments and "text" in segments[-1]:
345
+ segments[-1]["text"] += chunk
346
+ else:
347
+ segments.append({"text": chunk})
348
+
349
+ for ref in refs.parse_references(text):
350
+ if ref.kind == "escape":
351
+ add_text(text[pos : ref.start] + "@")
352
+ else:
353
+ item = refs.resolve(ref, media) if ref.kind in ("index", "label") else None
354
+ if item is None:
355
+ continue
356
+ add_text(text[pos : ref.start])
357
+ segments.append(
358
+ {"ref": {"token": ref.token, "index": item.idx, "media_id": item.id, "kind": item.kind}}
359
+ )
360
+ pos = ref.end
361
+ add_text(text[pos:])
362
+ return segments
363
+
364
+
365
+ @dataclass
366
+ class ReportView:
367
+ report: Report
368
+ media: list
369
+ references: list # [{"token", "index", "path"}]
370
+ segments: list
371
+
372
+
373
+ def get_report_view(store: Store, report_id: int) -> ReportView:
374
+ report = _require(store, report_id)
375
+ resolved = []
376
+ for ref in refs.parse_references(report.notes):
377
+ item = refs.resolve(ref, report.media) if ref.kind in ("index", "label") else None
378
+ if item is not None:
379
+ resolved.append({"token": ref.token, "index": item.idx, "path": item.path})
380
+ return ReportView(report, report.media, resolved, report_segments(report.notes, report.media))
381
+
382
+
383
+ def list_reports_query(
384
+ store: Store,
385
+ repo: Optional[str] = None,
386
+ tags: Optional[list] = None,
387
+ status: Optional[str] = None,
388
+ q: Optional[str] = None,
389
+ limit: int = 200,
390
+ offset: int = 0,
391
+ ) -> tuple[int, list[dict]]:
392
+ """Filtered, paged report summaries (newest first). Tags use AND semantics; `q` is a
393
+ case-insensitive substring match over title and notes."""
394
+ if status and status not in STATUSES:
395
+ raise ServiceError("bad_query", f"status must be one of {', '.join(STATUSES)}")
396
+ if not isinstance(limit, int) or not 1 <= limit <= 1000:
397
+ raise ServiceError("bad_query", "limit must be between 1 and 1000")
398
+ if not isinstance(offset, int) or offset < 0:
399
+ raise ServiceError("bad_query", "offset must be 0 or more")
400
+ needle = (q or "").lower()
401
+ wanted = set(tags or [])
402
+ matched = []
403
+ for report in store.list(repo=repo):
404
+ if status and report.status != status:
405
+ continue
406
+ if wanted and not wanted <= set(report.tags):
407
+ continue
408
+ if needle and needle not in report.title.lower() and needle not in (report.notes or "").lower():
409
+ continue
410
+ matched.append(report)
411
+ items = [
412
+ {
413
+ "id": r.id, "title": r.title, "status": r.status, "tags": r.tags, "repo": r.repo,
414
+ "created_at": r.created_at, "media_count": len(r.media),
415
+ }
416
+ for r in matched[offset : offset + limit]
417
+ ]
418
+ return len(matched), items