bugcap 0.3.0__tar.gz → 0.4.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.3.0/src/bugcap.egg-info → bugcap-0.4.0}/PKG-INFO +2 -2
- {bugcap-0.3.0 → bugcap-0.4.0}/README.md +1 -1
- {bugcap-0.3.0 → bugcap-0.4.0}/pyproject.toml +1 -1
- bugcap-0.4.0/src/bugcap/__init__.py +1 -0
- bugcap-0.4.0/src/bugcap/drafts.py +134 -0
- bugcap-0.4.0/src/bugcap/live.py +564 -0
- bugcap-0.4.0/src/bugcap/live_session.py +300 -0
- {bugcap-0.3.0 → bugcap-0.4.0/src/bugcap.egg-info}/PKG-INFO +2 -2
- bugcap-0.4.0/tests/test_drafts.py +77 -0
- bugcap-0.4.0/tests/test_live_session.py +289 -0
- bugcap-0.3.0/src/bugcap/__init__.py +0 -1
- bugcap-0.3.0/src/bugcap/drafts.py +0 -83
- bugcap-0.3.0/src/bugcap/live.py +0 -350
- bugcap-0.3.0/src/bugcap/live_session.py +0 -125
- bugcap-0.3.0/tests/test_drafts.py +0 -43
- bugcap-0.3.0/tests/test_live_session.py +0 -125
- {bugcap-0.3.0 → bugcap-0.4.0}/LICENSE +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/setup.cfg +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/agent_api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/backends.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/capture.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/cli.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/config.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/__init__.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/server.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/static/app.css +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/static/app.js +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/dashboard/static/index.html +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/errors.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/ghcli.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/ingest.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/logs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/mcp_server.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/paths.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/recorder.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/repo.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/service.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/store.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/sync.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/tomlio.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap/validation.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap.egg-info/SOURCES.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap.egg-info/entry_points.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap.egg-info/requires.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/src/bugcap.egg-info/top_level.txt +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_agent_api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_backends.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_capture.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_attach.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_dashboard.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_init_capture_list.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_live_headless.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_no_image_delete.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_record.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_setup.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_cli_triage.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_dashboard_api.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_dashboard_media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_dashboard_mutations.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_dashboard_security.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_dashboard_ui_static.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_ghcli.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_github_media_sync.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_github_pull.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_github_sync.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_image_refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_ingest.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_ingest_url.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_mcp.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_mcp_logging.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_paths.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_recorder_argv.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_recorder_devices.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_recorder_media_real.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_recorder_run.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_refs_validate_rewrite.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_regression_output.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_repo_config.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_repo_values.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_service_media.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_service_refs.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.0}/tests/test_store_migration.py +0 -0
- {bugcap-0.3.0 → bugcap-0.4.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.4.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
|
|
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
|
|
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.
|
|
7
|
+
version = "0.4.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.4.0"
|
|
@@ -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)
|