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.
@@ -0,0 +1,179 @@
1
+ "use strict";
2
+ (function () {
3
+ var STATUSES = ["open", "in-progress", "resolved", "closed", "wontfix"];
4
+ var token = null;
5
+ var current = null;
6
+ var $ = function (id) { return document.getElementById(id); };
7
+
8
+ function el(tag, text, cls) {
9
+ var node = document.createElement(tag);
10
+ if (text !== undefined) node.textContent = text;
11
+ if (cls) node.className = cls;
12
+ return node;
13
+ }
14
+
15
+ function getJSON(url) {
16
+ return fetch(url).then(function (r) {
17
+ return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; });
18
+ });
19
+ }
20
+
21
+ function send(method, url, payload) {
22
+ var go = function () {
23
+ return fetch(url, {
24
+ method: method,
25
+ headers: { "Content-Type": "application/json", "X-Bugcap-Token": token },
26
+ body: JSON.stringify(payload)
27
+ }).then(function (r) {
28
+ return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; });
29
+ });
30
+ };
31
+ if (token) return go();
32
+ return getJSON("/api/session").then(function (s) { token = s.body.token; return go(); });
33
+ }
34
+
35
+ function fillStatus(select, withAny) {
36
+ STATUSES.forEach(function (s) { select.appendChild(el("option", s)).value = s; });
37
+ }
38
+
39
+ function query() {
40
+ var p = new URLSearchParams();
41
+ if ($("f-q").value) p.set("q", $("f-q").value);
42
+ if ($("f-status").value) p.set("status", $("f-status").value);
43
+ if ($("f-repo").value) p.set("repo", $("f-repo").value);
44
+ $("f-tag").value.split(",").forEach(function (t) { t = t.trim(); if (t) p.append("tag", t); });
45
+ return p.toString();
46
+ }
47
+
48
+ var timer = null;
49
+ function refreshList() {
50
+ getJSON("/api/reports?" + query()).then(function (res) {
51
+ var ul = $("reports");
52
+ ul.textContent = "";
53
+ if (!res.ok) { $("total").textContent = res.body.message || "error"; return; }
54
+ $("total").textContent = res.body.total + " reports";
55
+ $("empty").hidden = res.body.items.length > 0;
56
+ res.body.items.forEach(function (r) {
57
+ var li = el("li");
58
+ var title = el("span", "#" + r.id + " " + r.title, "t");
59
+ li.appendChild(title);
60
+ li.appendChild(el("span", r.status, "badge"));
61
+ r.tags.forEach(function (t) { li.appendChild(el("span", t, "badge")); });
62
+ if (r.media_count) li.appendChild(el("span", r.media_count + " media", "badge"));
63
+ li.addEventListener("click", function () { openReport(r.id); });
64
+ ul.appendChild(li);
65
+ });
66
+ });
67
+ }
68
+
69
+ function renderNotes(report) {
70
+ var box = $("d-notes");
71
+ box.textContent = "";
72
+ var byId = {};
73
+ report.media.forEach(function (m) { byId[m.id] = m; });
74
+ if (!report.notes_segments.length) { box.appendChild(el("span", "(no notes)", "muted")); return; }
75
+ report.notes_segments.forEach(function (seg) {
76
+ if (seg.text !== undefined) { box.appendChild(document.createTextNode(seg.text)); return; }
77
+ var m = byId[seg.ref.media_id];
78
+ if (!m) { box.appendChild(el("span", seg.ref.token, "ref-missing")); return; }
79
+ box.appendChild(mediaNode(report, m, seg.ref.token));
80
+ });
81
+ }
82
+
83
+ function mediaNode(report, m, caption) {
84
+ var fig = el("figure");
85
+ if (m.kind === "video") {
86
+ var v = document.createElement("video");
87
+ v.controls = true; v.src = m.url; fig.appendChild(v);
88
+ } else if (m.kind === "frames") {
89
+ (report.frames[String(m.id)] || []).forEach(function (f) {
90
+ var img = document.createElement("img");
91
+ img.src = f.url; img.alt = "frame " + f.frame_no; fig.appendChild(img);
92
+ });
93
+ } else {
94
+ var i = document.createElement("img");
95
+ i.src = m.url; i.alt = m.label || ("image " + m.index); fig.appendChild(i);
96
+ }
97
+ var label = caption || ("#" + m.index);
98
+ if (m.label) label += " (" + m.label + ")";
99
+ fig.appendChild(el("figcaption", label + " · " + m.kind + " · " + Math.round(m.size_bytes / 1024) + " KB"));
100
+ return fig;
101
+ }
102
+
103
+ function renderDetail(report) {
104
+ current = report;
105
+ $("d-title").textContent = "#" + report.id + " " + report.title;
106
+ $("d-meta").textContent = [report.repo, report.created_at].filter(Boolean).join(" · ");
107
+ $("d-status").value = report.status;
108
+ var tags = $("d-tags");
109
+ tags.textContent = "";
110
+ report.tags.forEach(function (t) {
111
+ var b = el("span", t + " ×", "badge");
112
+ b.title = "Remove tag";
113
+ b.addEventListener("click", function () { changeTags({ remove: [t] }); });
114
+ tags.appendChild(b);
115
+ });
116
+ renderNotes(report);
117
+ $("notes-edit").value = report.notes_raw;
118
+ $("notes-msg").textContent = "";
119
+ var media = $("d-media");
120
+ media.textContent = "";
121
+ report.media.forEach(function (m) { media.appendChild(mediaNode(report, m)); });
122
+ var refs = Object.keys(report.synced_refs || {}).map(function (k) { return k + ": " + report.synced_refs[k]; });
123
+ var sync = $("d-sync");
124
+ sync.textContent = "";
125
+ (report.links || []).forEach(function (l) {
126
+ var a = el("a", l.label);
127
+ a.href = l.url; a.target = "_blank"; a.rel = "noopener noreferrer";
128
+ sync.appendChild(a);
129
+ sync.appendChild(document.createTextNode(" · "));
130
+ });
131
+ sync.appendChild(document.createTextNode(refs.join(" · ")));
132
+ }
133
+
134
+ function openReport(id) {
135
+ getJSON("/api/reports/" + id).then(function (res) {
136
+ if (!res.ok) return;
137
+ renderDetail(res.body);
138
+ $("list-view").hidden = true;
139
+ $("detail-view").hidden = false;
140
+ location.hash = "#" + id;
141
+ });
142
+ }
143
+
144
+ function changeTags(payload) {
145
+ send("POST", "/api/reports/" + current.id + "/tags", payload).then(function () { openReport(current.id); });
146
+ }
147
+
148
+ function init() {
149
+ fillStatus($("d-status"));
150
+ var f = $("f-status");
151
+ STATUSES.forEach(function (s) { var o = el("option", s); o.value = s; f.appendChild(o); });
152
+ ["f-q", "f-status", "f-tag", "f-repo"].forEach(function (id) {
153
+ $(id).addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(refreshList, 150); });
154
+ });
155
+ $("filters").addEventListener("submit", function (e) { e.preventDefault(); refreshList(); });
156
+ $("back").addEventListener("click", function () {
157
+ $("detail-view").hidden = true; $("list-view").hidden = false; location.hash = ""; refreshList();
158
+ });
159
+ $("d-status").addEventListener("change", function () {
160
+ send("POST", "/api/reports/" + current.id + "/status", { status: $("d-status").value });
161
+ });
162
+ $("tag-form").addEventListener("submit", function (e) {
163
+ e.preventDefault();
164
+ var v = $("tag-input").value.trim();
165
+ if (v) { $("tag-input").value = ""; changeTags({ add: [v] }); }
166
+ });
167
+ $("notes-save").addEventListener("click", function () {
168
+ send("PUT", "/api/reports/" + current.id + "/notes", { notes: $("notes-edit").value }).then(function (res) {
169
+ if (res.ok) { renderDetail(res.body); return; }
170
+ var b = res.body;
171
+ $("notes-msg").textContent = b.message + (b.valid ? " (valid: " + b.valid.join(", ") + ")" : "");
172
+ });
173
+ });
174
+ refreshList();
175
+ var id = parseInt(location.hash.slice(1), 10);
176
+ if (id) openReport(id);
177
+ }
178
+ init();
179
+ })();
@@ -0,0 +1,45 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; media-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'">
7
+ <title>bugcap</title>
8
+ <link rel="stylesheet" href="/static/app.css">
9
+ </head>
10
+ <body>
11
+ <header class="bar"><h1>bugcap</h1><span id="total" class="muted"></span></header>
12
+ <main>
13
+ <section id="list-view">
14
+ <form id="filters" class="filters">
15
+ <input id="f-q" type="search" placeholder="Search title and notes" aria-label="Search">
16
+ <select id="f-status" aria-label="Status"><option value="">Any status</option></select>
17
+ <input id="f-tag" type="text" placeholder="Tag (comma separated)" aria-label="Tags">
18
+ <input id="f-repo" type="text" placeholder="Repo" aria-label="Repo">
19
+ </form>
20
+ <ul id="reports" class="reports"></ul>
21
+ <p id="empty" class="muted" hidden>No reports match.</p>
22
+ </section>
23
+ <section id="detail-view" hidden>
24
+ <button id="back" type="button">&larr; All reports</button>
25
+ <h2 id="d-title"></h2>
26
+ <p class="muted" id="d-meta"></p>
27
+ <div class="row">
28
+ <label>Status <select id="d-status"></select></label>
29
+ <span id="d-tags" class="tags"></span>
30
+ <form id="tag-form" class="inline"><input id="tag-input" placeholder="add tag" aria-label="Add tag"><button>Add</button></form>
31
+ </div>
32
+ <h3>Notes</h3>
33
+ <div id="d-notes" class="notes"></div>
34
+ <details><summary>Edit notes</summary>
35
+ <textarea id="notes-edit" rows="6"></textarea>
36
+ <div><button id="notes-save" type="button">Save notes</button> <span id="notes-msg" class="error" role="alert"></span></div>
37
+ </details>
38
+ <h3>Media</h3>
39
+ <div id="d-media" class="media"></div>
40
+ <p id="d-sync" class="muted"></p>
41
+ </section>
42
+ </main>
43
+ <script src="/static/app.js"></script>
44
+ </body>
45
+ </html>
bugcap/drafts.py ADDED
@@ -0,0 +1,83 @@
1
+ """Live-mode drafts: an unsaved capture plus whatever was typed, kept on disk until it is
2
+ saved as a report or discarded. One `<uuid>.png` and one `<uuid>.json` per draft."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .paths import drafts_dir
15
+
16
+ FIELDS = ("title", "notes", "tags", "status")
17
+
18
+
19
+ @dataclass
20
+ class Draft:
21
+ id: str
22
+ png_path: Path
23
+ created_at: str
24
+ repo: Optional[str]
25
+ title: str = ""
26
+ notes: str = ""
27
+ tags: list = None # type: ignore[assignment]
28
+ status: str = "open"
29
+
30
+ def fields(self) -> dict:
31
+ return {"title": self.title, "notes": self.notes, "tags": list(self.tags or []), "status": self.status}
32
+
33
+
34
+ def save_draft(repo: Optional[str], png_path, fields: dict) -> Draft:
35
+ """Move the screenshot into drafts/ and write its sidecar."""
36
+ draft_id = str(uuid.uuid4())
37
+ directory = drafts_dir()
38
+ target = directory / f"{draft_id}.png"
39
+ shutil.move(str(png_path), target)
40
+ meta = {
41
+ "created_at": datetime.now(timezone.utc).isoformat(),
42
+ "repo": repo,
43
+ "title": fields.get("title", ""),
44
+ "notes": fields.get("notes", ""),
45
+ "tags": list(fields.get("tags") or []),
46
+ "status": fields.get("status", "open"),
47
+ }
48
+ (directory / f"{draft_id}.json").write_text(json.dumps(meta, indent=2))
49
+ return Draft(draft_id, target, **meta)
50
+
51
+
52
+ def load_draft(draft_id: str) -> Optional[Draft]:
53
+ directory = drafts_dir()
54
+ sidecar = directory / f"{draft_id}.json"
55
+ png = directory / f"{draft_id}.png"
56
+ try:
57
+ meta = json.loads(sidecar.read_text())
58
+ if not png.is_file() or not isinstance(meta, dict):
59
+ raise ValueError("incomplete draft")
60
+ return Draft(
61
+ draft_id, png, meta.get("created_at", ""), meta.get("repo"),
62
+ str(meta.get("title", "")), str(meta.get("notes", "")),
63
+ [str(t) for t in meta.get("tags", [])], str(meta.get("status", "open")),
64
+ )
65
+ except (OSError, ValueError, TypeError) as exc:
66
+ print(f"warning: skipping unreadable draft {draft_id}: {exc}", file=sys.stderr)
67
+ return None
68
+
69
+
70
+ def list_drafts(repo: Optional[str]) -> list[Draft]:
71
+ """Drafts saved for this repo (or without a repo), oldest first."""
72
+ found = []
73
+ for sidecar in sorted(drafts_dir().glob("*.json")):
74
+ draft = load_draft(sidecar.stem)
75
+ if draft is not None and draft.repo == repo:
76
+ found.append(draft)
77
+ return sorted(found, key=lambda d: d.created_at)
78
+
79
+
80
+ def delete_draft(draft_id: str) -> None:
81
+ directory = drafts_dir()
82
+ for suffix in (".png", ".json"):
83
+ (directory / f"{draft_id}{suffix}").unlink(missing_ok=True)
bugcap/errors.py ADDED
@@ -0,0 +1,30 @@
1
+ """Errors raised by the shared service layer; each interface maps `code` to its own output."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ CODES = (
7
+ "not_found",
8
+ "invalid_reference",
9
+ "duplicate_label",
10
+ "invalid_label",
11
+ "invalid_title",
12
+ "invalid_image",
13
+ "too_large",
14
+ "timeout",
15
+ "referenced_image",
16
+ "invalid_status",
17
+ "bad_query",
18
+ "bad_token",
19
+ )
20
+
21
+
22
+ class ServiceError(Exception):
23
+ def __init__(self, code: str, message: str, details: Optional[dict] = None):
24
+ super().__init__(message)
25
+ self.code = code
26
+ self.message = message
27
+ self.details = details or {}
28
+
29
+ def as_dict(self) -> dict:
30
+ return {"code": self.code, "message": self.message, **self.details}
bugcap/ghcli.py ADDED
@@ -0,0 +1,175 @@
1
+ """Thin wrapper over the GitHub CLI (`gh`). Always argv lists (never shell strings);
2
+ large JSON payloads go to `gh api` via stdin, never argv (Windows ~32k limit)."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from typing import Optional
10
+
11
+
12
+ class GhError(RuntimeError):
13
+ pass
14
+
15
+
16
+ def _install_hint() -> str:
17
+ if sys.platform == "win32":
18
+ return "winget install GitHub.cli (or https://cli.github.com)"
19
+ if sys.platform == "darwin":
20
+ return "brew install gh (or https://cli.github.com)"
21
+ return "see https://github.com/cli/cli#installation (apt install gh, dnf install gh, ...)"
22
+
23
+
24
+ def ensure_ready() -> None:
25
+ """Raise GhError unless `gh` is installed and authenticated."""
26
+ if not shutil.which("gh"):
27
+ raise GhError(f"GitHub CLI (`gh`) not found. Install: {_install_hint()}")
28
+ result = subprocess.run(
29
+ ["gh", "auth", "status"], capture_output=True, text=True
30
+ )
31
+ if result.returncode != 0:
32
+ raise GhError("`gh` is not logged in. Run `gh auth login`.")
33
+
34
+
35
+ def run_gh(args: list[str], stdin: Optional[str] = None) -> subprocess.CompletedProcess:
36
+ """Run `gh <args>` capturing text output; `stdin` is fed to the process if given."""
37
+ return subprocess.run(
38
+ ["gh", *args], input=stdin, capture_output=True, text=True
39
+ )
40
+
41
+
42
+ def _checked(args: list[str], stdin: Optional[str] = None) -> str:
43
+ result = run_gh(args, stdin=stdin)
44
+ if result.returncode != 0:
45
+ raise GhError((result.stderr or result.stdout or "gh failed").strip())
46
+ return result.stdout
47
+
48
+
49
+ def list_issues(slug: str, labels: Optional[list[str]] = None, limit: int = 30) -> list[dict]:
50
+ args = [
51
+ "issue", "list", "--repo", slug, "--state", "open",
52
+ "--limit", str(limit),
53
+ "--json", "number,title,body,url,state,labels",
54
+ ]
55
+ for label in labels or []:
56
+ args += ["--label", label]
57
+ out = _checked(args)
58
+ return json.loads(out or "[]")
59
+
60
+
61
+ def repo_visibility(slug: str) -> str:
62
+ """Repo visibility ('public'/'private'/'internal'); any failure is treated as 'public'."""
63
+ result = run_gh(["repo", "view", slug, "--json", "visibility"])
64
+ if result.returncode != 0:
65
+ return "public"
66
+ try:
67
+ return str(json.loads(result.stdout).get("visibility", "public")).lower()
68
+ except (ValueError, AttributeError):
69
+ return "public"
70
+
71
+
72
+ def supports_attach() -> bool:
73
+ """Feature-detect `gh issue create --attach` (present in recent gh releases)."""
74
+ result = run_gh(["issue", "create", "--help"])
75
+ return "--attach" in (result.stdout or "")
76
+
77
+
78
+ def create_issue(
79
+ slug: str, title: str, body: str, attach: Optional[str] = None
80
+ ) -> str:
81
+ """Create an issue; return the issue URL printed by gh (parsed from stdout)."""
82
+ args = ["issue", "create", "--repo", slug, "--title", title, "--body-file", "-"]
83
+ if attach:
84
+ args += ["--attach", attach]
85
+ result = run_gh(args, stdin=body)
86
+ url = _first_url(result.stdout) or _first_url(result.stderr)
87
+ if result.returncode != 0 and not url:
88
+ raise GhError((result.stderr or result.stdout or "gh issue create failed").strip())
89
+ if not url:
90
+ raise GhError("could not determine the created issue URL from gh output")
91
+ return url
92
+
93
+
94
+ def comment_issue(slug: str, number: int, body: str) -> str:
95
+ args = ["issue", "comment", str(number), "--repo", slug, "--body-file", "-"]
96
+ out = _checked(args, stdin=body)
97
+ return _first_url(out) or ""
98
+
99
+
100
+ def get_contents(slug: str, path: str, ref: Optional[str] = None) -> Optional[dict]:
101
+ """GET repo contents; None on 404 (missing file)."""
102
+ endpoint = f"repos/{slug}/contents/{path}"
103
+ if ref:
104
+ endpoint += f"?ref={ref}"
105
+ result = run_gh(["api", endpoint])
106
+ if result.returncode != 0:
107
+ return None
108
+ try:
109
+ return json.loads(result.stdout)
110
+ except ValueError:
111
+ return None
112
+
113
+
114
+ def put_contents(
115
+ slug: str,
116
+ path: str,
117
+ content_b64: str,
118
+ message: str,
119
+ branch: Optional[str] = None,
120
+ sha: Optional[str] = None,
121
+ ) -> dict:
122
+ """PUT repo contents (create/update a file). Payload (incl. base64) goes via stdin."""
123
+ payload: dict = {"message": message, "content": content_b64}
124
+ if branch:
125
+ payload["branch"] = branch
126
+ if sha:
127
+ payload["sha"] = sha
128
+ endpoint = f"repos/{slug}/contents/{path}"
129
+ out = _checked(
130
+ ["api", "--method", "PUT", endpoint, "--input", "-"],
131
+ stdin=json.dumps(payload),
132
+ )
133
+ return json.loads(out or "{}")
134
+
135
+
136
+ def _first_url(text: Optional[str]) -> Optional[str]:
137
+ for token in (text or "").split():
138
+ if token.startswith("http://") or token.startswith("https://"):
139
+ return token.strip()
140
+ return None
141
+
142
+
143
+ class GhUnavailable(RuntimeError):
144
+ """Cannot verify (no `gh`, not logged in, offline): callers warn and carry on."""
145
+
146
+
147
+ def verify_repo(slug: str, branch: Optional[str] = None, need_write: bool = False) -> None:
148
+ """Check that `slug` exists (and, with `need_write`, that the current login can push) and
149
+ that `branch` exists. Raises GhError for a definite problem naming the bad value and
150
+ GhUnavailable when the check itself cannot be done."""
151
+ try:
152
+ ensure_ready()
153
+ except GhError as exc:
154
+ raise GhUnavailable(str(exc)) from exc
155
+
156
+ def fetch(endpoint: str):
157
+ result = run_gh(["api", endpoint])
158
+ if result.returncode == 0:
159
+ try:
160
+ return json.loads(result.stdout or "{}")
161
+ except ValueError:
162
+ return {}
163
+ text = f"{result.stderr} {result.stdout}"
164
+ if "404" in text or "Not Found" in text:
165
+ return None
166
+ raise GhUnavailable(text.strip() or "gh api failed")
167
+
168
+ info = fetch(f"repos/{slug}")
169
+ if info is None:
170
+ raise GhError(f"repo {slug!r} does not exist, or your `gh` login cannot see it")
171
+ permissions = info.get("permissions") if isinstance(info, dict) else None
172
+ if need_write and isinstance(permissions, dict) and permissions.get("push") is False:
173
+ raise GhError(f"repo {slug!r} is not writable with the current `gh` login")
174
+ if branch and fetch(f"repos/{slug}/branches/{branch}") is None:
175
+ raise GhError(f"branch {branch!r} does not exist in {slug}")