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 +1 -0
- bugcap/agent_api.py +221 -0
- bugcap/backends.py +133 -0
- bugcap/capture.py +84 -0
- bugcap/cli.py +942 -0
- bugcap/config.py +86 -0
- bugcap/dashboard/__init__.py +3 -0
- bugcap/dashboard/api.py +105 -0
- bugcap/dashboard/media.py +73 -0
- bugcap/dashboard/server.py +213 -0
- bugcap/dashboard/static/app.css +43 -0
- bugcap/dashboard/static/app.js +179 -0
- bugcap/dashboard/static/index.html +45 -0
- bugcap/drafts.py +83 -0
- bugcap/errors.py +30 -0
- bugcap/ghcli.py +175 -0
- bugcap/ingest.py +210 -0
- bugcap/live.py +292 -0
- bugcap/live_session.py +125 -0
- bugcap/logs.py +74 -0
- bugcap/mcp_server.py +221 -0
- bugcap/paths.py +116 -0
- bugcap/recorder.py +390 -0
- bugcap/refs.py +213 -0
- bugcap/repo.py +138 -0
- bugcap/service.py +418 -0
- bugcap/store.py +485 -0
- bugcap/sync.py +424 -0
- bugcap/tomlio.py +37 -0
- bugcap/validation.py +74 -0
- bugcap-0.2.2.dist-info/METADATA +225 -0
- bugcap-0.2.2.dist-info/RECORD +36 -0
- bugcap-0.2.2.dist-info/WHEEL +5 -0
- bugcap-0.2.2.dist-info/entry_points.txt +2 -0
- bugcap-0.2.2.dist-info/licenses/LICENSE +21 -0
- bugcap-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -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">← 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}")
|