bugcap 0.3.0__tar.gz → 0.5.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.
Files changed (89) hide show
  1. {bugcap-0.3.0/src/bugcap.egg-info → bugcap-0.5.0}/PKG-INFO +2 -2
  2. {bugcap-0.3.0 → bugcap-0.5.0}/README.md +1 -1
  3. {bugcap-0.3.0 → bugcap-0.5.0}/pyproject.toml +1 -1
  4. bugcap-0.5.0/src/bugcap/__init__.py +1 -0
  5. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/cli.py +13 -6
  6. bugcap-0.5.0/src/bugcap/drafts.py +134 -0
  7. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/ingest.py +36 -4
  8. bugcap-0.5.0/src/bugcap/live.py +564 -0
  9. bugcap-0.5.0/src/bugcap/live_session.py +300 -0
  10. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/service.py +11 -3
  11. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/store.py +8 -0
  12. {bugcap-0.3.0 → bugcap-0.5.0/src/bugcap.egg-info}/PKG-INFO +2 -2
  13. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_attach.py +74 -0
  14. bugcap-0.5.0/tests/test_drafts.py +77 -0
  15. bugcap-0.5.0/tests/test_live_session.py +289 -0
  16. bugcap-0.3.0/src/bugcap/__init__.py +0 -1
  17. bugcap-0.3.0/src/bugcap/drafts.py +0 -83
  18. bugcap-0.3.0/src/bugcap/live.py +0 -350
  19. bugcap-0.3.0/src/bugcap/live_session.py +0 -125
  20. bugcap-0.3.0/tests/test_drafts.py +0 -43
  21. bugcap-0.3.0/tests/test_live_session.py +0 -125
  22. {bugcap-0.3.0 → bugcap-0.5.0}/LICENSE +0 -0
  23. {bugcap-0.3.0 → bugcap-0.5.0}/setup.cfg +0 -0
  24. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/agent_api.py +0 -0
  25. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/backends.py +0 -0
  26. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/capture.py +0 -0
  27. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/config.py +0 -0
  28. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/__init__.py +0 -0
  29. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/api.py +0 -0
  30. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/media.py +0 -0
  31. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/server.py +0 -0
  32. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/app.css +0 -0
  33. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/app.js +0 -0
  34. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/dashboard/static/index.html +0 -0
  35. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/errors.py +0 -0
  36. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/ghcli.py +0 -0
  37. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/logs.py +0 -0
  38. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/mcp_server.py +0 -0
  39. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/paths.py +0 -0
  40. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/recorder.py +0 -0
  41. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/refs.py +0 -0
  42. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/repo.py +0 -0
  43. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/sync.py +0 -0
  44. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/tomlio.py +0 -0
  45. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap/validation.py +0 -0
  46. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/SOURCES.txt +0 -0
  47. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
  48. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/entry_points.txt +0 -0
  49. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/requires.txt +0 -0
  50. {bugcap-0.3.0 → bugcap-0.5.0}/src/bugcap.egg-info/top_level.txt +0 -0
  51. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_agent_api.py +0 -0
  52. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_backends.py +0 -0
  53. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_capture.py +0 -0
  54. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_dashboard.py +0 -0
  55. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_init_capture_list.py +0 -0
  56. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_live_headless.py +0 -0
  57. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_media.py +0 -0
  58. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_no_image_delete.py +0 -0
  59. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_record.py +0 -0
  60. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_setup.py +0 -0
  61. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_cli_triage.py +0 -0
  62. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_api.py +0 -0
  63. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_media.py +0 -0
  64. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_mutations.py +0 -0
  65. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_security.py +0 -0
  66. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_dashboard_ui_static.py +0 -0
  67. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_ghcli.py +0 -0
  68. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_github_media_sync.py +0 -0
  69. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_github_pull.py +0 -0
  70. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_github_sync.py +0 -0
  71. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_image_refs.py +0 -0
  72. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_ingest.py +0 -0
  73. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_ingest_url.py +0 -0
  74. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_mcp.py +0 -0
  75. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_mcp_logging.py +0 -0
  76. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_paths.py +0 -0
  77. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_argv.py +0 -0
  78. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_devices.py +0 -0
  79. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_media_real.py +0 -0
  80. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_recorder_run.py +0 -0
  81. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_refs.py +0 -0
  82. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_refs_validate_rewrite.py +0 -0
  83. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_regression_output.py +0 -0
  84. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_repo_config.py +0 -0
  85. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_repo_values.py +0 -0
  86. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_service_media.py +0 -0
  87. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_service_refs.py +0 -0
  88. {bugcap-0.3.0 → bugcap-0.5.0}/tests/test_store_migration.py +0 -0
  89. {bugcap-0.3.0 → bugcap-0.5.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.0
3
+ Version: 0.5.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
@@ -63,7 +63,7 @@ bugcap keeps the whole loop in one place:
63
63
  | 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
64
64
  | 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
65
65
  | 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
66
- | 🪟 **Live mode** | An always-on-top window for reporting many bugs in a row. |
66
+ | 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
67
67
 
68
68
  ## Quick start
69
69
 
@@ -47,7 +47,7 @@ bugcap keeps the whole loop in one place:
47
47
  | 🔁 **Sync when ready** | Push to a GitHub issue with the usual inline image, plus a plain copy agents can read. |
48
48
  | 🎞️ **Record what happens** | Short screen recordings as animated GIF, video or keyframes, with size caps. |
49
49
  | 🖥️ **Triage in a browser** | A local dashboard on `127.0.0.1` to browse, filter and set statuses. |
50
- | 🪟 **Live mode** | An always-on-top window for reporting many bugs in a row. |
50
+ | 🪟 **Live mode** | An always-on-top window (top-right) to capture or record, stage several items, then file a new bug, add to an existing one, or log a note-only bug. |
51
51
 
52
52
  ## Quick start
53
53
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bugcap"
7
- version = "0.3.0"
7
+ version = "0.5.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.5.0"
@@ -152,12 +152,8 @@ def cmd_list(args: argparse.Namespace) -> int:
152
152
  return 0
153
153
 
154
154
 
155
- def cmd_show(args: argparse.Namespace) -> int:
156
- with Store() as store:
157
- report = resolve_report(store, args.id)
158
- if report is None:
159
- return 1
160
-
155
+ def print_report(report) -> None:
156
+ """The multi-line details of one report, as `bugcap show` prints them."""
161
157
  print(f"#{report.id} {report.title}")
162
158
  print(f" created_at: {report.created_at}")
163
159
  print(f" status: {report.status}")
@@ -181,6 +177,15 @@ def cmd_show(args: argparse.Namespace) -> int:
181
177
  print(f" - {tracker}: {ref}")
182
178
  else:
183
179
  print(" (not synced to any tracker yet)")
180
+
181
+
182
+ def cmd_show(args: argparse.Namespace) -> int:
183
+ with Store() as store:
184
+ report = resolve_report(store, args.id)
185
+ if report is None:
186
+ return 1
187
+
188
+ print_report(report)
184
189
  return 0
185
190
 
186
191
 
@@ -574,6 +579,8 @@ def cmd_attach(args: argparse.Namespace) -> int:
574
579
  print(f"Attached {shot_path} to report #{args.id}")
575
580
  elif note and added:
576
581
  print(f"Added note to report #{args.id}")
582
+ print()
583
+ print_report(current)
577
584
  return 1 if rejected or (note and not added) else 0
578
585
 
579
586
 
@@ -0,0 +1,134 @@
1
+ """Live-mode drafts: unsaved staged media (any number of images / recordings, or none) plus
2
+ whatever was typed, kept on disk until it is saved as a report or discarded. One
3
+ `<uuid>.json` sidecar per draft; its media files sit beside it as `<uuid>-*`. A draft made by
4
+ an older version is just `<uuid>.png` + `<uuid>.json` and still loads."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import shutil
9
+ import sys
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from .paths import drafts_dir
17
+
18
+ FIELDS = ("title", "notes", "tags", "status")
19
+
20
+
21
+ @dataclass
22
+ class Draft:
23
+ id: str
24
+ created_at: str
25
+ repo: Optional[str]
26
+ title: str = ""
27
+ notes: str = ""
28
+ tags: list = None # type: ignore[assignment]
29
+ status: str = "open"
30
+ # [{"kind", "path" (absolute or None), "mime", "size_bytes", "frames": [(path, size)]}]
31
+ items: list = field(default_factory=list)
32
+
33
+ def fields(self) -> dict:
34
+ return {"title": self.title, "notes": self.notes, "tags": list(self.tags or []), "status": self.status}
35
+
36
+ @property
37
+ def png_path(self) -> Optional[Path]:
38
+ """The first stored file (the screenshot of a single-image draft), if any."""
39
+ for item in self.items:
40
+ if item["path"]:
41
+ return Path(item["path"])
42
+ return None
43
+
44
+
45
+ def _move(source, target: Path) -> Path:
46
+ shutil.move(str(source), target)
47
+ return target
48
+
49
+
50
+ def save_draft(repo: Optional[str], png_path, fields: dict, items: Optional[list] = None) -> Draft:
51
+ """Move the media into drafts/ and write the sidecar. `items` are dicts shaped like
52
+ `Draft.items`; for a plain screenshot pass `png_path` instead."""
53
+ if items is None:
54
+ items = [{"kind": "image", "path": str(png_path), "mime": "image/png", "size_bytes": 0, "frames": []}] if png_path else []
55
+ draft_id = str(uuid.uuid4())
56
+ directory = drafts_dir()
57
+ listed = []
58
+ for n, item in enumerate(items, start=1):
59
+ path = item.get("path")
60
+ new_path = _move(path, directory / f"{draft_id}-{n}{Path(path).suffix}") if path else None
61
+ frames = []
62
+ for m, (frame_path, size) in enumerate(item.get("frames") or [], start=1):
63
+ frames.append([_move(frame_path, directory / f"{draft_id}-{n}-f{m:03d}.png").name, size])
64
+ listed.append({
65
+ "kind": item["kind"], "file": new_path.name if new_path else None,
66
+ "mime": item.get("mime", "image/png"), "size_bytes": item.get("size_bytes", 0), "frames": frames,
67
+ })
68
+ meta = {
69
+ "created_at": datetime.now(timezone.utc).isoformat(),
70
+ "repo": repo,
71
+ "title": fields.get("title", ""),
72
+ "notes": fields.get("notes", ""),
73
+ "tags": list(fields.get("tags") or []),
74
+ "status": fields.get("status", "open"),
75
+ "media": listed,
76
+ }
77
+ (directory / f"{draft_id}.json").write_text(json.dumps(meta, indent=2))
78
+ return load_draft(draft_id) # type: ignore[return-value]
79
+
80
+
81
+ def _items_from_meta(directory: Path, draft_id: str, meta: dict) -> list:
82
+ if "media" not in meta: # legacy: one screenshot
83
+ png = directory / f"{draft_id}.png"
84
+ if not png.is_file():
85
+ raise ValueError("incomplete draft")
86
+ return [{"kind": "image", "path": str(png), "mime": "image/png", "size_bytes": png.stat().st_size, "frames": []}]
87
+ items = []
88
+ for entry in meta["media"]:
89
+ path = directory / entry["file"] if entry.get("file") else None
90
+ frames = [(str(directory / name), int(size)) for name, size in entry.get("frames", [])]
91
+ files = ([path] if path else []) + [Path(p) for p, _ in frames]
92
+ if not files or not all(f.is_file() for f in files):
93
+ raise ValueError("incomplete draft")
94
+ items.append({
95
+ "kind": str(entry["kind"]), "path": str(path) if path else None, "mime": str(entry.get("mime", "")),
96
+ "size_bytes": int(entry.get("size_bytes", 0)), "frames": frames,
97
+ })
98
+ return items
99
+
100
+
101
+ def load_draft(draft_id: str) -> Optional[Draft]:
102
+ directory = drafts_dir()
103
+ sidecar = directory / f"{draft_id}.json"
104
+ try:
105
+ meta = json.loads(sidecar.read_text())
106
+ if not isinstance(meta, dict):
107
+ raise ValueError("incomplete draft")
108
+ return Draft(
109
+ draft_id, meta.get("created_at", ""), meta.get("repo"),
110
+ str(meta.get("title", "")), str(meta.get("notes", "")),
111
+ [str(t) for t in meta.get("tags", [])], str(meta.get("status", "open")),
112
+ _items_from_meta(directory, draft_id, meta),
113
+ )
114
+ except (OSError, ValueError, TypeError, KeyError) as exc:
115
+ print(f"warning: skipping unreadable draft {draft_id}: {exc}", file=sys.stderr)
116
+ return None
117
+
118
+
119
+ def list_drafts(repo: Optional[str]) -> list[Draft]:
120
+ """Drafts saved for this repo (or without a repo), oldest first."""
121
+ found = []
122
+ for sidecar in sorted(drafts_dir().glob("*.json")):
123
+ draft = load_draft(sidecar.stem)
124
+ if draft is not None and draft.repo == repo:
125
+ found.append(draft)
126
+ return sorted(found, key=lambda d: d.created_at)
127
+
128
+
129
+ def delete_draft(draft_id: str) -> None:
130
+ directory = drafts_dir()
131
+ (directory / f"{draft_id}.json").unlink(missing_ok=True)
132
+ (directory / f"{draft_id}.png").unlink(missing_ok=True)
133
+ for leftover in directory.glob(f"{draft_id}-*"):
134
+ leftover.unlink(missing_ok=True)
@@ -1,11 +1,13 @@
1
1
  """Bring existing images into the store: local paths, globs and http(s) URLs.
2
2
 
3
3
  Every input is validated by its magic bytes (never its extension or Content-Type), copied
4
- into the images directory under a fresh name (the original path is not kept), and written
5
- atomically. Network access is limited to plain http(s) GETs with no credentials."""
4
+ into the images directory under a name derived from its content (the original path is not
5
+ kept) and written atomically. The same image is therefore stored once, however many reports
6
+ use it. Network access is limited to plain http(s) GETs with no credentials."""
6
7
  from __future__ import annotations
7
8
 
8
9
  import glob
10
+ import hashlib
9
11
  import os
10
12
  import socket
11
13
  import urllib.error
@@ -32,6 +34,7 @@ class StoredFile:
32
34
  mime: str
33
35
  size_bytes: int
34
36
  source: str
37
+ reused: bool = False # the content was already in the store; no new file was written
35
38
 
36
39
 
37
40
  @dataclass
@@ -70,10 +73,16 @@ def sniff_image(data: bytes) -> str:
70
73
  raise ServiceError("invalid_image", "not an image (PNG, JPEG, GIF or WebP expected)")
71
74
 
72
75
 
76
+ def content_name(data: bytes, mime: str) -> str:
77
+ return f"{hashlib.sha256(data).hexdigest()}{_EXT[mime]}"
78
+
79
+
73
80
  def _store(data: bytes, source: str) -> StoredFile:
74
81
  mime = sniff_image(data)
75
- dest = images_dir() / f"{uuid.uuid4()}{_EXT[mime]}"
76
- tmp = dest.with_name(dest.name + ".part")
82
+ dest = images_dir() / content_name(data, mime)
83
+ if dest.is_file() and dest.stat().st_size == len(data):
84
+ return StoredFile(to_data_relative(dest), dest, mime, len(data), source, reused=True)
85
+ tmp = dest.with_name(f"{dest.name}.{uuid.uuid4().hex}.part")
77
86
  try:
78
87
  tmp.write_bytes(data)
79
88
  os.replace(tmp, dest)
@@ -82,6 +91,29 @@ def _store(data: bytes, source: str) -> StoredFile:
82
91
  return StoredFile(to_data_relative(dest), dest, mime, len(data), source)
83
92
 
84
93
 
94
+ def adopt(path) -> Path:
95
+ """Give a screenshot the capture tool already saved in the store its content-derived
96
+ name. If the same image is stored already, the new copy is dropped and the existing
97
+ path returned. Anything that is not a recognisable image, or sits outside the store, is
98
+ left where it is."""
99
+ path = Path(path)
100
+ if path.parent != images_dir():
101
+ return path
102
+ try:
103
+ data = path.read_bytes()
104
+ mime = sniff_image(data)
105
+ except (OSError, ServiceError):
106
+ return path
107
+ dest = images_dir() / content_name(data, mime)
108
+ if dest == path:
109
+ return path
110
+ if dest.is_file() and dest.stat().st_size == len(data):
111
+ path.unlink(missing_ok=True)
112
+ return dest
113
+ os.replace(path, dest)
114
+ return dest
115
+
116
+
85
117
  def import_local(path: Path) -> StoredFile:
86
118
  path = Path(path).expanduser()
87
119
  if path.is_dir():