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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
bugcap/agent_api.py ADDED
@@ -0,0 +1,221 @@
1
+ """SDK-free implementations of the agent tools. `mcp_server.py` merely registers
2
+ these, so the logic is unit-testable without the MCP SDK installed."""
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ from pathlib import Path
8
+ from typing import Callable, Optional
9
+
10
+ from . import capture, refs, repo, service, sync
11
+ from .errors import ServiceError
12
+ from .store import Report, Store
13
+
14
+ _MIME = {
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".bmp": "image/bmp",
21
+ }
22
+
23
+
24
+ def _mime(path) -> str:
25
+ return _MIME.get(Path(path).suffix.lower(), "application/octet-stream")
26
+
27
+
28
+ def _summary(report: Report) -> dict:
29
+ return {
30
+ "id": report.id,
31
+ "title": report.title,
32
+ "status": report.status,
33
+ "tags": report.tags,
34
+ "repo": report.repo,
35
+ "created_at": report.created_at,
36
+ "synced_refs": report.synced_refs,
37
+ }
38
+
39
+
40
+ def _current_repo_key() -> Optional[str]:
41
+ cfg = repo.load_repo_config()
42
+ return cfg.key if cfg else None
43
+
44
+
45
+ def list_reports(
46
+ store: Store,
47
+ all: bool = False,
48
+ status: Optional[str] = None,
49
+ limit: Optional[int] = None,
50
+ ) -> list[dict]:
51
+ scope = None if all else _current_repo_key()
52
+ reports = store.list(repo=scope)
53
+ if status is not None:
54
+ reports = [r for r in reports if r.status == status]
55
+ if limit is not None:
56
+ reports = reports[: max(0, int(limit))]
57
+ return [_summary(r) for r in reports]
58
+
59
+
60
+ def _media_meta(media) -> list[dict]:
61
+ return [
62
+ {
63
+ "index": m.idx,
64
+ "label": m.label,
65
+ "kind": m.kind,
66
+ "mime": m.mime,
67
+ "size_bytes": m.size_bytes,
68
+ **({"frames": len(m.frames)} if m.kind == "frames" else {}),
69
+ }
70
+ for m in media
71
+ ]
72
+
73
+
74
+ def get_report(store: Store, id: int) -> dict:
75
+ report = store.get(id)
76
+ if report is None:
77
+ return {"error": f"no report with id {id}"}
78
+ data = _summary(report)
79
+ data.update({
80
+ "body": report.body,
81
+ "notes": report.notes,
82
+ "image_paths": report.image_paths,
83
+ "media": _media_meta(report.media),
84
+ })
85
+ images = []
86
+ for path in report.image_paths:
87
+ p = Path(path)
88
+ if p.is_file():
89
+ images.append({"path": path, "mime": _mime(p), "bytes": p.read_bytes()})
90
+ resolved = [
91
+ {"token": r.token, "index": found.idx}
92
+ for r in refs.parse_references(report.notes)
93
+ if r.kind in ("index", "label") and (found := refs.resolve(r, report.media)) is not None
94
+ ]
95
+ return {"report": data, "images": images, "resolved_references": resolved}
96
+
97
+
98
+ def attach_image(store: Store, id: int, sources: list[str], labels: Optional[list] = None) -> dict:
99
+ """Attach images (paths, globs, http(s) URLs) to a report; partial batches are reported."""
100
+ if not sources or len(sources) > 20:
101
+ return {"code": "bad_query", "error": "sources must contain 1 to 20 entries"}
102
+ try:
103
+ result = service.add_media(store, id, list(sources), labels)
104
+ except ServiceError as exc:
105
+ return {"error": exc.message, **exc.as_dict()}
106
+ return {
107
+ "report_id": id,
108
+ "added": [
109
+ {"index": m.idx, "label": m.label, "kind": m.kind, "size_bytes": m.size_bytes, "source": m.source}
110
+ for m in result.added
111
+ ],
112
+ "rejected": [{"source": r["source"], "reason": r["reason"]} for r in result.rejected],
113
+ }
114
+
115
+
116
+ def update_notes(store: Store, id: int, notes: str) -> dict:
117
+ """Replace a report's notes, validating `@` references against its images."""
118
+ try:
119
+ report = service.set_notes(store, id, notes)
120
+ except ServiceError as exc:
121
+ return {"error": exc.message, **exc.as_dict()}
122
+ view = service.get_report_view(store, id)
123
+ return {"report_id": report.id, "notes": report.notes, "references": view.references}
124
+
125
+
126
+ def _resolve_slug(explicit: Optional[str]) -> Optional[str]:
127
+ if explicit:
128
+ return explicit
129
+ cfg = repo.load_repo_config()
130
+ return cfg.github if cfg else None
131
+
132
+
133
+ def _resolve_target(store: Store, report_id: Optional[int], issue: Optional[str]) -> Optional[Report]:
134
+ """Find (or create from an issue) the report a screenshot is being requested for."""
135
+ if report_id is not None:
136
+ return store.get(report_id)
137
+ if not issue:
138
+ return None
139
+ if "#" in issue:
140
+ slug, _, number = issue.partition("#")
141
+ slug = slug or (_resolve_slug(None) or "")
142
+ else:
143
+ slug = _resolve_slug(None) or ""
144
+ number = issue
145
+ ref = f"{slug}#{number}"
146
+ existing = store.find_by_ref("github.issue", ref)
147
+ if existing is not None:
148
+ return existing
149
+ return store.add(
150
+ title=f"Issue {ref}", repo=slug or None, synced_refs={"github.issue": ref}
151
+ )
152
+
153
+
154
+ def _capture_with_timeout(timeout_seconds: int) -> Path:
155
+ result: dict = {}
156
+
157
+ def worker():
158
+ try:
159
+ result["path"] = capture.capture_screenshot()
160
+ except BaseException as exc: # re-raised on the calling thread
161
+ result["error"] = exc
162
+
163
+ thread = threading.Thread(target=worker, daemon=True)
164
+ thread.start()
165
+ thread.join(timeout_seconds)
166
+ if thread.is_alive():
167
+ raise TimeoutError("screenshot request timed out")
168
+ if "error" in result:
169
+ raise result["error"]
170
+ return result["path"]
171
+
172
+
173
+ def request_screenshot(
174
+ store: Store,
175
+ report_id: Optional[int] = None,
176
+ issue: Optional[str] = None,
177
+ message: Optional[str] = None,
178
+ timeout_seconds: int = 300,
179
+ note: Optional[str] = None,
180
+ ) -> dict:
181
+ if not capture.has_display():
182
+ return {"status": "non_interactive", "message": "no interactive desktop UI available"}
183
+ report = _resolve_target(store, report_id, issue)
184
+ if report is None:
185
+ return {"status": "error", "message": "no target report or issue given"}
186
+ if message:
187
+ print(message, file=sys.stderr, flush=True)
188
+ print(f"bugcap: capture a screenshot for report #{report.id}...", file=sys.stderr, flush=True)
189
+ try:
190
+ path = _capture_with_timeout(timeout_seconds)
191
+ except capture.CaptureError as exc:
192
+ return {"status": "cancelled", "message": str(exc)}
193
+ except TimeoutError as exc:
194
+ return {"status": "cancelled", "message": str(exc)}
195
+ media = service.attach_captured(store, report.id, str(path))
196
+ note_error = None
197
+ if note:
198
+ try:
199
+ service.append_note(store, report.id, note, [media])
200
+ except ServiceError as exc:
201
+ note_error = exc.message
202
+ p = Path(path)
203
+ return {
204
+ **({"note_error": note_error} if note_error else {}),
205
+ "status": "captured",
206
+ "report_id": report.id,
207
+ "image": {"path": str(path), "mime": _mime(p), "bytes": p.read_bytes()},
208
+ }
209
+
210
+
211
+ def pull_issues(
212
+ store: Store,
213
+ repo: Optional[str] = None,
214
+ labels: Optional[list[str]] = None,
215
+ limit: int = 30,
216
+ ask_cb: Optional[Callable] = None,
217
+ ) -> dict:
218
+ slug = _resolve_slug(repo)
219
+ if not slug:
220
+ return {"error": "no GitHub repo given and none configured in .bugcap.toml"}
221
+ return sync.pull_issues(store, slug, labels, limit, ask_cb)
bugcap/backends.py ADDED
@@ -0,0 +1,133 @@
1
+ """Capture backends: detection, install suggestions and (opt-in) installation, per OS."""
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+
9
+
10
+ def platform_key() -> str:
11
+ if sys.platform == "win32":
12
+ return "windows"
13
+ if sys.platform == "darwin":
14
+ return "macos"
15
+ return "linux"
16
+
17
+
18
+ @dataclass
19
+ class Backend:
20
+ name: str
21
+ binaries: list[str] # every one must be on PATH
22
+ description: str
23
+ platforms: tuple[str, ...]
24
+ annotates: bool
25
+ # platform -> [(package manager binary, argv)]
26
+ installs: dict[str, list[tuple[str, list[str]]]] = field(default_factory=dict)
27
+ manual: dict[str, str] = field(default_factory=dict) # platform -> guidance
28
+
29
+ def available(self) -> bool:
30
+ if platform_key() not in self.platforms:
31
+ return False
32
+ return all(shutil.which(b) for b in self.binaries)
33
+
34
+
35
+ BACKENDS = [
36
+ Backend(
37
+ name="flameshot",
38
+ binaries=["flameshot"],
39
+ description="Cross-platform capture + annotation UI (recommended everywhere)",
40
+ platforms=("linux", "macos", "windows"),
41
+ annotates=True,
42
+ installs={
43
+ "linux": [
44
+ ("apt-get", ["apt-get", "install", "-y", "flameshot"]),
45
+ ("dnf", ["dnf", "install", "-y", "flameshot"]),
46
+ ("pacman", ["pacman", "-S", "--noconfirm", "flameshot"]),
47
+ ("zypper", ["zypper", "install", "-y", "flameshot"]),
48
+ ("brew", ["brew", "install", "flameshot"]),
49
+ ],
50
+ "macos": [("brew", ["brew", "install", "--cask", "flameshot"])],
51
+ "windows": [
52
+ ("winget", ["winget", "install", "--id", "Flameshot.Flameshot", "-e"]),
53
+ ("scoop", ["scoop", "install", "flameshot"]),
54
+ ("choco", ["choco", "install", "-y", "flameshot"]),
55
+ ],
56
+ },
57
+ manual={
58
+ "linux": "See https://flameshot.org/docs/installation/",
59
+ "macos": "brew install --cask flameshot, or https://flameshot.org/#download",
60
+ "windows": "winget install Flameshot.Flameshot, or https://flameshot.org/#download",
61
+ },
62
+ ),
63
+ Backend(
64
+ name="satty",
65
+ binaries=["satty", "grim"],
66
+ description="Wayland-native annotation (needs grim for the grab)",
67
+ platforms=("linux",),
68
+ annotates=True,
69
+ installs={
70
+ "linux": [
71
+ ("pacman", ["pacman", "-S", "--noconfirm", "satty", "grim"]),
72
+ ("brew", ["brew", "install", "satty"]),
73
+ ]
74
+ },
75
+ manual={
76
+ "linux": "Install grim from your distro, and satty from "
77
+ "https://github.com/Satty-org/Satty (or `cargo install satty`)",
78
+ },
79
+ ),
80
+ Backend(
81
+ name="screencapture",
82
+ binaries=["screencapture"],
83
+ description="macOS built-in region capture (no annotation)",
84
+ platforms=("macos",),
85
+ annotates=False,
86
+ manual={"macos": "Built in to macOS; nothing to install."},
87
+ ),
88
+ ]
89
+
90
+
91
+ def by_name(name: str) -> Backend:
92
+ for backend in BACKENDS:
93
+ if backend.name == name:
94
+ return backend
95
+ raise KeyError(name)
96
+
97
+
98
+ def detect() -> Optional[Backend]:
99
+ """First available backend, in preference order."""
100
+ return next((b for b in BACKENDS if b.available()), None)
101
+
102
+
103
+ def recommended() -> Backend:
104
+ """What to suggest installing on this OS. Flameshot is cross-platform and annotates,
105
+ so it is the recommendation on every supported OS (satty is the Wayland alternative)."""
106
+ return by_name("flameshot")
107
+
108
+
109
+ def install_command(backend: Backend) -> Optional[list[str]]:
110
+ """Full argv (with sudo when needed) for the first usable package manager, or None."""
111
+ for manager, argv in backend.installs.get(platform_key(), []):
112
+ if not shutil.which(manager):
113
+ continue
114
+ needs_root = manager in {"apt-get", "dnf", "pacman", "zypper"}
115
+ if needs_root and hasattr(os, "geteuid") and os.geteuid() != 0:
116
+ if not shutil.which("sudo"):
117
+ continue
118
+ return ["sudo", *argv]
119
+ return list(argv)
120
+ return None
121
+
122
+
123
+ def guidance(backend: Backend) -> str:
124
+ return backend.manual.get(platform_key(), "See the project's website for install steps.")
125
+
126
+
127
+ def install(backend: Backend) -> int:
128
+ cmd = install_command(backend)
129
+ if cmd is None:
130
+ raise RuntimeError(
131
+ f"No supported package manager found for {backend.name}. {guidance(backend)}"
132
+ )
133
+ return subprocess.run(cmd).returncode
bugcap/capture.py ADDED
@@ -0,0 +1,84 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ import sys
5
+ import uuid
6
+ from pathlib import Path
7
+
8
+ from . import backends
9
+ from .paths import images_dir
10
+
11
+
12
+ class CaptureError(RuntimeError):
13
+ pass
14
+
15
+
16
+ def has_display() -> bool:
17
+ """Whether an interactive graphical session is available for a capture UI.
18
+
19
+ Linux: a running X11/Wayland session (``DISPLAY`` or ``WAYLAND_DISPLAY``).
20
+ macOS/Windows: assumed present (the window server runs for logged-in users).
21
+ """
22
+ if sys.platform == "win32" or sys.platform == "darwin":
23
+ return True
24
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
25
+
26
+
27
+ def _no_backend_error() -> CaptureError:
28
+ rec = backends.recommended()
29
+ return CaptureError(
30
+ "No capture tool found. Run `bugcap setup` to detect/install one "
31
+ f"(recommended: {rec.name} - {backends.guidance(rec)}), "
32
+ "or pass an existing screenshot with --image."
33
+ )
34
+
35
+
36
+ def import_image(source: Path) -> Path:
37
+ """Copy an existing image into the store (no capture tool needed)."""
38
+ if not source.is_file():
39
+ raise CaptureError(f"Image not found: {source}")
40
+ dest = images_dir() / f"{uuid.uuid4()}{source.suffix.lower() or '.png'}"
41
+ shutil.copyfile(source, dest)
42
+ return dest
43
+
44
+
45
+ def capture_screenshot() -> Path:
46
+ """Launch the detected backend's interactive capture UI and return the saved PNG path.
47
+ Blocks until the user finishes (saves or cancels).
48
+ """
49
+ backend = backends.detect()
50
+ if backend is None:
51
+ raise _no_backend_error()
52
+ dest = images_dir() / f"{uuid.uuid4()}.png"
53
+
54
+ if backend.name == "flameshot":
55
+ result = subprocess.run(
56
+ ["flameshot", "gui", "--path", str(dest)], capture_output=True, text=True
57
+ )
58
+ if result.returncode != 0:
59
+ raise CaptureError(f"flameshot exited with {result.returncode}: {result.stderr}")
60
+ elif backend.name == "satty":
61
+ raw = images_dir() / f"{uuid.uuid4()}-raw.png"
62
+ subprocess.run(["grim", str(raw)], check=True)
63
+ result = subprocess.run(
64
+ ["satty", "--filename", str(raw), "--output-filename", str(dest)],
65
+ capture_output=True,
66
+ text=True,
67
+ )
68
+ raw.unlink(missing_ok=True)
69
+ if result.returncode != 0:
70
+ raise CaptureError(f"satty exited with {result.returncode}: {result.stderr}")
71
+ elif backend.name == "screencapture":
72
+ # -i interactive region/window selection (space toggles window mode)
73
+ result = subprocess.run(["screencapture", "-i", str(dest)], capture_output=True, text=True)
74
+ if result.returncode != 0:
75
+ raise CaptureError(f"screencapture exited with {result.returncode}: {result.stderr}")
76
+ else: # pragma: no cover
77
+ raise CaptureError(f"Unknown backend: {backend.name}")
78
+
79
+ if not dest.exists():
80
+ raise CaptureError(
81
+ "Capture backend exited without saving a file "
82
+ "(did you cancel the capture instead of saving it?)."
83
+ )
84
+ return dest