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
bugcap/sync.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Sync policy: pull GitHub issues into reports, and push reports to a Destination.
|
|
2
|
+
|
|
3
|
+
`sync.py` depends only on the small `Destination` protocol and on `synced_refs`, so a
|
|
4
|
+
future object store (roadmap 6) or tracker (roadmap 9) adds a new Destination without
|
|
5
|
+
touching the CLI or the store. GitHub is the only Destination today."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Callable, Optional, Protocol
|
|
14
|
+
|
|
15
|
+
from . import config, ghcli, refs, service
|
|
16
|
+
from .store import Media, Report, Store
|
|
17
|
+
|
|
18
|
+
DEFAULT_IMAGES_PATH = "bugcap-images"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def human_size(n: int) -> str:
|
|
22
|
+
if n < 1024:
|
|
23
|
+
return f"{n} B"
|
|
24
|
+
if n < 1024 * 1024:
|
|
25
|
+
return f"{n / 1024:.1f} KB"
|
|
26
|
+
return f"{n / (1024 * 1024):.1f} MB"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- runtime refs (not stored directly) --------------------------------------
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class IssueRef:
|
|
33
|
+
slug: str
|
|
34
|
+
number: int
|
|
35
|
+
url: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FileRef:
|
|
40
|
+
slug: str
|
|
41
|
+
path: str
|
|
42
|
+
commit: str
|
|
43
|
+
permalink: str
|
|
44
|
+
|
|
45
|
+
def as_ref(self) -> str:
|
|
46
|
+
return f"{self.slug}:{self.path}@{self.commit}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- Destination seam ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
class Destination(Protocol):
|
|
52
|
+
name: str
|
|
53
|
+
|
|
54
|
+
def ensure_ready(self) -> None: ...
|
|
55
|
+
def repo_visibility(self, slug: str) -> str: ...
|
|
56
|
+
def supports_attach(self) -> bool: ...
|
|
57
|
+
def get_file(self, slug: str, path: str, branch: Optional[str]) -> Optional[tuple[str, bytes]]:
|
|
58
|
+
"""Return (blob_sha, content_bytes) if the file exists, else None."""
|
|
59
|
+
...
|
|
60
|
+
def put_file(self, slug: str, path: str, data: bytes, branch: Optional[str], message: str) -> FileRef: ...
|
|
61
|
+
def create_issue(self, slug: str, title: str, body: str, attach: Optional[str]) -> IssueRef: ...
|
|
62
|
+
def comment(self, ref: IssueRef, body: str) -> None: ...
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GitHubDestination:
|
|
66
|
+
name = "github"
|
|
67
|
+
|
|
68
|
+
def ensure_ready(self) -> None:
|
|
69
|
+
ghcli.ensure_ready()
|
|
70
|
+
|
|
71
|
+
def repo_visibility(self, slug: str) -> str:
|
|
72
|
+
return ghcli.repo_visibility(slug)
|
|
73
|
+
|
|
74
|
+
def supports_attach(self) -> bool:
|
|
75
|
+
return ghcli.supports_attach()
|
|
76
|
+
|
|
77
|
+
def get_file(self, slug: str, path: str, branch: Optional[str]) -> Optional[tuple[str, bytes]]:
|
|
78
|
+
meta = ghcli.get_contents(slug, path, ref=branch)
|
|
79
|
+
if not meta or "content" not in meta:
|
|
80
|
+
return None
|
|
81
|
+
raw = base64.b64decode("".join(meta["content"].split()))
|
|
82
|
+
return meta.get("sha", ""), raw
|
|
83
|
+
|
|
84
|
+
def put_file(self, slug, path, data, branch, message) -> FileRef:
|
|
85
|
+
result = ghcli.put_contents(
|
|
86
|
+
slug, path, base64.b64encode(data).decode("ascii"), message, branch=branch
|
|
87
|
+
)
|
|
88
|
+
commit = (result.get("commit") or {}).get("sha") or (branch or "HEAD")
|
|
89
|
+
permalink = f"https://github.com/{slug}/blob/{commit}/{path}"
|
|
90
|
+
return FileRef(slug=slug, path=path, commit=commit, permalink=permalink)
|
|
91
|
+
|
|
92
|
+
def create_issue(self, slug, title, body, attach) -> IssueRef:
|
|
93
|
+
url = ghcli.create_issue(slug, title, body, attach=attach)
|
|
94
|
+
number = _issue_number(url)
|
|
95
|
+
return IssueRef(slug=slug, number=number, url=url)
|
|
96
|
+
|
|
97
|
+
def comment(self, ref: IssueRef, body: str) -> None:
|
|
98
|
+
ghcli.comment_issue(ref.slug, ref.number, body)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _issue_number(url: str) -> int:
|
|
102
|
+
match = re.search(r"/issues/(\d+)", url or "")
|
|
103
|
+
return int(match.group(1)) if match else 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- pull ---------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _attach_answer(store: Store, report_id: int, answer) -> None:
|
|
109
|
+
"""`answer` from the --ask callback: None, a path, or (path, note); the note is appended
|
|
110
|
+
to the report's notes and tied to the new image."""
|
|
111
|
+
if not answer:
|
|
112
|
+
return
|
|
113
|
+
path, note = answer if isinstance(answer, tuple) else (answer, None)
|
|
114
|
+
media = service.attach_captured(store, report_id, str(path))
|
|
115
|
+
if note:
|
|
116
|
+
service.append_note(store, report_id, note, [media])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pull_issues(
|
|
120
|
+
store: Store,
|
|
121
|
+
slug: str,
|
|
122
|
+
labels: Optional[list[str]] = None,
|
|
123
|
+
limit: int = 30,
|
|
124
|
+
ask_cb: Optional[Callable[[Report, dict], Optional[str]]] = None,
|
|
125
|
+
) -> dict:
|
|
126
|
+
"""Import open issues as reports. Refreshes title/body only; never notes/tags/status.
|
|
127
|
+
Returns {created, updated, unchanged}."""
|
|
128
|
+
ghcli.ensure_ready()
|
|
129
|
+
issues = ghcli.list_issues(slug, labels, limit)
|
|
130
|
+
created = updated = unchanged = 0
|
|
131
|
+
for issue in issues:
|
|
132
|
+
ref = f"{slug}#{issue['number']}"
|
|
133
|
+
body = issue.get("body") or ""
|
|
134
|
+
existing = store.find_by_ref("github.issue", ref)
|
|
135
|
+
if existing is None:
|
|
136
|
+
status = "closed" if str(issue.get("state", "")).upper() == "CLOSED" else "open"
|
|
137
|
+
report = store.add(
|
|
138
|
+
title=issue["title"],
|
|
139
|
+
body=body,
|
|
140
|
+
repo=slug,
|
|
141
|
+
status=status,
|
|
142
|
+
synced_refs={"github.issue": ref},
|
|
143
|
+
)
|
|
144
|
+
created += 1
|
|
145
|
+
if ask_cb is not None:
|
|
146
|
+
_attach_answer(store, report.id, ask_cb(report, issue))
|
|
147
|
+
else:
|
|
148
|
+
if existing.title != issue["title"] or existing.body != body:
|
|
149
|
+
store.update(existing.id, title=issue["title"], body=body)
|
|
150
|
+
updated += 1
|
|
151
|
+
else:
|
|
152
|
+
unchanged += 1
|
|
153
|
+
if ask_cb is not None:
|
|
154
|
+
_attach_answer(store, existing.id, ask_cb(existing, issue))
|
|
155
|
+
return {"created": created, "updated": updated, "unchanged": unchanged}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- push (sync) --------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class ImagesTarget:
|
|
162
|
+
repo: str
|
|
163
|
+
path: str
|
|
164
|
+
branch: Optional[str] = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_images_target(
|
|
168
|
+
flag_repo: Optional[str],
|
|
169
|
+
flag_path: Optional[str],
|
|
170
|
+
flag_branch: Optional[str],
|
|
171
|
+
repo_cfg,
|
|
172
|
+
global_cfg: dict,
|
|
173
|
+
issue_slug: str,
|
|
174
|
+
) -> ImagesTarget:
|
|
175
|
+
"""Resolve where image copies are committed: flag > .bugcap.toml [sync] > global [sync]
|
|
176
|
+
> the issue repo, with default path 'bugcap-images'."""
|
|
177
|
+
cfg_repo = getattr(repo_cfg, "images_repo", None) if repo_cfg else None
|
|
178
|
+
cfg_path = getattr(repo_cfg, "images_path", None) if repo_cfg else None
|
|
179
|
+
cfg_branch = getattr(repo_cfg, "images_branch", None) if repo_cfg else None
|
|
180
|
+
repo = flag_repo or cfg_repo or global_cfg.get("images_repo") or issue_slug
|
|
181
|
+
path = flag_path or cfg_path or global_cfg.get("images_path") or DEFAULT_IMAGES_PATH
|
|
182
|
+
branch = flag_branch or cfg_branch or global_cfg.get("images_branch")
|
|
183
|
+
return ImagesTarget(repo=repo, path=path.strip("/"), branch=branch)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass
|
|
187
|
+
class SyncOptions:
|
|
188
|
+
issue_slug: str
|
|
189
|
+
images: ImagesTarget
|
|
190
|
+
assume_yes: bool = False
|
|
191
|
+
interactive: bool = True
|
|
192
|
+
confirm: Optional[Callable[[str, str], bool]] = None # (slug, visibility) -> bool
|
|
193
|
+
max_upload_mb: float = 25
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class SyncResult:
|
|
198
|
+
issue_url: Optional[str] = None
|
|
199
|
+
created_issue: bool = False
|
|
200
|
+
commented: bool = False
|
|
201
|
+
image_permalinks: list = field(default_factory=list)
|
|
202
|
+
media_links: list = field(default_factory=list) # [MediaLink], in media order
|
|
203
|
+
messages: list = field(default_factory=list)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _consent_ok(destination: Destination, images_repo: str, opts: SyncOptions) -> tuple[bool, str]:
|
|
207
|
+
visibility = destination.repo_visibility(images_repo)
|
|
208
|
+
private = visibility == "private"
|
|
209
|
+
if private and config.consent_has(images_repo):
|
|
210
|
+
return True, ""
|
|
211
|
+
if opts.assume_yes:
|
|
212
|
+
if private:
|
|
213
|
+
config.consent_add(images_repo)
|
|
214
|
+
return True, ""
|
|
215
|
+
if opts.interactive and opts.confirm and opts.confirm(images_repo, visibility):
|
|
216
|
+
if private:
|
|
217
|
+
config.consent_add(images_repo)
|
|
218
|
+
return True, ""
|
|
219
|
+
kind = "private" if private else "public"
|
|
220
|
+
return False, f"skipped image commit to {kind} repo {images_repo} (not confirmed)"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _unique_name(report_id: int, index: int, basename: str, data: bytes) -> str:
|
|
224
|
+
digest = hashlib.sha256(data).hexdigest()[:8]
|
|
225
|
+
stem, dot, ext = basename.rpartition(".")
|
|
226
|
+
suffix = f".{ext}" if dot else ""
|
|
227
|
+
return f"{report_id}-{index}-{digest}{suffix}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class MediaLink:
|
|
232
|
+
media: Media
|
|
233
|
+
name: str
|
|
234
|
+
urls: list # permalinks (several for a frames set); empty when nothing was uploaded
|
|
235
|
+
note: str = "" # why nothing was uploaded
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _permalink(images: ImagesTarget, path: str) -> str:
|
|
239
|
+
return f"https://github.com/{images.repo}/blob/{images.branch or 'HEAD'}/{path}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _upload(store, report, destination, images, ref_key, target_path, data, basename, index, result, message):
|
|
243
|
+
"""Commit one file (skipping identical content already there); returns its permalink."""
|
|
244
|
+
existing = destination.get_file(images.repo, target_path, images.branch)
|
|
245
|
+
if existing is not None:
|
|
246
|
+
blob_sha, remote_bytes = existing
|
|
247
|
+
if remote_bytes == data:
|
|
248
|
+
ref = FileRef(images.repo, target_path, blob_sha, _permalink(images, target_path))
|
|
249
|
+
store.set_ref(report.id, ref_key, ref.as_ref())
|
|
250
|
+
report.synced_refs[ref_key] = ref.as_ref()
|
|
251
|
+
return ref.permalink
|
|
252
|
+
# different content at that path: pick a unique name
|
|
253
|
+
directory = target_path.rsplit("/", 1)[0]
|
|
254
|
+
target_path = f"{directory}/{_unique_name(report.id, index, basename, data)}"
|
|
255
|
+
ref = destination.put_file(images.repo, target_path, data, images.branch, message)
|
|
256
|
+
store.set_ref(report.id, ref_key, ref.as_ref())
|
|
257
|
+
report.synced_refs[ref_key] = ref.as_ref()
|
|
258
|
+
result.image_permalinks.append(ref.permalink)
|
|
259
|
+
return ref.permalink
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _media_files(m: Media) -> list:
|
|
263
|
+
"""[(absolute path, size)] of the files a media item consists of."""
|
|
264
|
+
if m.kind == "frames":
|
|
265
|
+
from .paths import absolute_stored_path
|
|
266
|
+
return [(absolute_stored_path(f.path), f.size_bytes) for f in m.frames]
|
|
267
|
+
return [(m.abs_path, m.size_bytes)] if m.abs_path else []
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _commit_media(store: Store, report: Report, destination: Destination, opts: SyncOptions, result: SyncResult) -> list:
|
|
271
|
+
"""Commit each not-yet-committed media file, recording its ref immediately.
|
|
272
|
+
Returns a MediaLink per media item. Files above `max_upload_mb` are skipped with a warning."""
|
|
273
|
+
images = opts.images
|
|
274
|
+
limit = int(opts.max_upload_mb * 1024 * 1024)
|
|
275
|
+
links: dict = {}
|
|
276
|
+
pending = [] # (media, link) needing at least one upload
|
|
277
|
+
|
|
278
|
+
for m in report.media:
|
|
279
|
+
files = _media_files(m)
|
|
280
|
+
if not files:
|
|
281
|
+
continue
|
|
282
|
+
name = os.path.basename(files[0][0]) if m.kind != "frames" else f"frames-{m.idx}"
|
|
283
|
+
link = MediaLink(m, name, [])
|
|
284
|
+
links[m.id] = link
|
|
285
|
+
too_big = [(p, sz) for p, sz in files if sz > limit]
|
|
286
|
+
if too_big:
|
|
287
|
+
size = sum(sz for _, sz in files)
|
|
288
|
+
result.messages.append(
|
|
289
|
+
f"warning: skipped media #{m.idx} {name} ({human_size(size)}): "
|
|
290
|
+
f"above the {opts.max_upload_mb:g} MB upload limit"
|
|
291
|
+
)
|
|
292
|
+
link.note = f"not uploaded: above the {opts.max_upload_mb:g} MB limit"
|
|
293
|
+
continue
|
|
294
|
+
if m.kind == "frames":
|
|
295
|
+
done = report.synced_refs.get(f"github.frames.{m.idx}")
|
|
296
|
+
if done:
|
|
297
|
+
link.urls = [
|
|
298
|
+
_permalink(images, f"{images.path}/{report.id}-{m.idx}-frames/{n:03d}{os.path.splitext(p)[1]}")
|
|
299
|
+
for n, (p, _) in enumerate(files, start=1)
|
|
300
|
+
]
|
|
301
|
+
continue
|
|
302
|
+
else:
|
|
303
|
+
ref_key = _ref_key(m, name)
|
|
304
|
+
if ref_key in report.synced_refs:
|
|
305
|
+
link.urls = [_permalink(images, f"{images.path}/{name}")]
|
|
306
|
+
continue
|
|
307
|
+
pending.append((m, link, files, name))
|
|
308
|
+
|
|
309
|
+
if pending:
|
|
310
|
+
ok, reason = _consent_ok(destination, images.repo, opts)
|
|
311
|
+
if not ok:
|
|
312
|
+
result.messages.append(reason)
|
|
313
|
+
return [links[m.id] for m in report.media if m.id in links]
|
|
314
|
+
for m, link, files, name in pending:
|
|
315
|
+
if m.kind == "frames":
|
|
316
|
+
for n, (path, _) in enumerate(files, start=1):
|
|
317
|
+
with open(path, "rb") as fh:
|
|
318
|
+
data = fh.read()
|
|
319
|
+
fname = f"{n:03d}{os.path.splitext(path)[1]}"
|
|
320
|
+
target = f"{images.path}/{report.id}-{m.idx}-frames/{fname}"
|
|
321
|
+
link.urls.append(_upload(
|
|
322
|
+
store, report, destination, images, f"github.frame.{m.idx}.{n}", target, data, fname,
|
|
323
|
+
m.idx * 1000 + n, result, f"bugcap: add frame {n} of media #{m.idx} for report #{report.id}",
|
|
324
|
+
))
|
|
325
|
+
store.set_ref(report.id, f"github.frames.{m.idx}", f"{images.repo}:{images.path}/{report.id}-{m.idx}-frames")
|
|
326
|
+
report.synced_refs[f"github.frames.{m.idx}"] = "set"
|
|
327
|
+
else:
|
|
328
|
+
path = files[0][0]
|
|
329
|
+
with open(path, "rb") as fh:
|
|
330
|
+
data = fh.read()
|
|
331
|
+
index = report.media.index(m)
|
|
332
|
+
link.urls.append(_upload(
|
|
333
|
+
store, report, destination, images, _ref_key(m, name), f"{images.path}/{name}", data, name,
|
|
334
|
+
index, result, f"bugcap: add {name} for report #{report.id}",
|
|
335
|
+
))
|
|
336
|
+
return [links[m.id] for m in report.media if m.id in links]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _ref_key(m: Media, name: str) -> str:
|
|
340
|
+
return f"github.image.{name}" if m.kind == "image" else f"github.media.{name}"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _content_hash(report: Report) -> str:
|
|
344
|
+
basenames = sorted(os.path.basename(p) for p in report.image_paths)
|
|
345
|
+
others = sorted(
|
|
346
|
+
f"{m.kind}:{os.path.basename(m.path or '')}:{len(m.frames)}"
|
|
347
|
+
for m in report.media
|
|
348
|
+
if m.kind != "image"
|
|
349
|
+
)
|
|
350
|
+
payload = "\n".join([report.title, report.notes or "", *basenames, *others])
|
|
351
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _link_markdown(link: MediaLink) -> str:
|
|
355
|
+
"""The markdown that stands in for a media reference."""
|
|
356
|
+
if not link.urls:
|
|
357
|
+
return ""
|
|
358
|
+
label = link.media.label or link.name
|
|
359
|
+
if link.media.kind == "image":
|
|
360
|
+
return f""
|
|
361
|
+
if link.media.kind == "frames":
|
|
362
|
+
return ", ".join(f"[{label} {n}]({u})" for n, u in enumerate(link.urls, start=1))
|
|
363
|
+
return f"[{label}]({link.urls[0]})"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _issue_body(report: Report, links: list) -> str:
|
|
367
|
+
replacements: dict = {}
|
|
368
|
+
for link in links:
|
|
369
|
+
if link.urls:
|
|
370
|
+
replacements[link.media.id] = _link_markdown(link)
|
|
371
|
+
elif link.note:
|
|
372
|
+
replacements[link.media.id] = f"@{link.media.idx} ({link.note})"
|
|
373
|
+
notes = refs.substitute(report.notes, report.media, replacements) if report.notes else ""
|
|
374
|
+
referenced = {
|
|
375
|
+
found.id
|
|
376
|
+
for r in refs.parse_references(report.notes or "")
|
|
377
|
+
if r.kind in ("index", "label") and (found := refs.resolve(r, report.media)) is not None
|
|
378
|
+
}
|
|
379
|
+
parts = [notes or report.body or "_(captured with bugcap)_"]
|
|
380
|
+
for link in links:
|
|
381
|
+
if not link.urls or link.media.id in referenced:
|
|
382
|
+
continue
|
|
383
|
+
if link.media.kind == "frames":
|
|
384
|
+
parts.append("\n".join(f"{n}. [{link.name} frame {n}]({u})" for n, u in enumerate(link.urls, start=1)))
|
|
385
|
+
elif link.media.kind == "image":
|
|
386
|
+
parts.append(f"")
|
|
387
|
+
else:
|
|
388
|
+
parts.append(f"[{link.name}]({link.urls[0]})")
|
|
389
|
+
return "\n\n".join(parts)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def sync_report(store: Store, report: Report, destination: Destination, opts: SyncOptions) -> SyncResult:
|
|
393
|
+
"""Create/comment an issue and commit image copies, idempotently and resumably."""
|
|
394
|
+
result = SyncResult()
|
|
395
|
+
image_links = _commit_media(store, report, destination, opts, result)
|
|
396
|
+
|
|
397
|
+
issue_ref_str = report.synced_refs.get("github.issue")
|
|
398
|
+
content_hash = _content_hash(report)
|
|
399
|
+
|
|
400
|
+
if issue_ref_str:
|
|
401
|
+
number = int(issue_ref_str.split("#")[-1])
|
|
402
|
+
slug = issue_ref_str.rsplit("#", 1)[0]
|
|
403
|
+
ref = IssueRef(slug=slug, number=number, url=f"https://github.com/{slug}/issues/{number}")
|
|
404
|
+
result.issue_url = ref.url
|
|
405
|
+
if report.synced_refs.get("github.comment_hash") != content_hash:
|
|
406
|
+
destination.comment(ref, _issue_body(report, image_links))
|
|
407
|
+
store.set_ref(report.id, "github.comment_hash", content_hash)
|
|
408
|
+
report.synced_refs["github.comment_hash"] = content_hash
|
|
409
|
+
result.commented = True
|
|
410
|
+
else:
|
|
411
|
+
attach = None
|
|
412
|
+
if destination.supports_attach() and report.image_paths:
|
|
413
|
+
attach = report.image_paths[0]
|
|
414
|
+
ref = destination.create_issue(
|
|
415
|
+
opts.issue_slug, report.title, _issue_body(report, image_links), attach
|
|
416
|
+
)
|
|
417
|
+
store.set_ref(report.id, "github.issue", f"{ref.slug}#{ref.number}")
|
|
418
|
+
report.synced_refs["github.issue"] = f"{ref.slug}#{ref.number}"
|
|
419
|
+
store.set_ref(report.id, "github.comment_hash", content_hash)
|
|
420
|
+
report.synced_refs["github.comment_hash"] = content_hash
|
|
421
|
+
result.issue_url = ref.url
|
|
422
|
+
result.created_issue = True
|
|
423
|
+
|
|
424
|
+
return result
|
bugcap/tomlio.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Tiny TOML read/write for bugcap's flat config (strings, bools, ints, one table level)."""
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
if sys.version_info >= (3, 11):
|
|
8
|
+
import tomllib
|
|
9
|
+
else: # pragma: no cover
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load(path: Path) -> dict[str, Any]:
|
|
14
|
+
if not path.exists():
|
|
15
|
+
return {}
|
|
16
|
+
with open(path, "rb") as fh:
|
|
17
|
+
return tomllib.load(fh)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _scalar(value: Any) -> str:
|
|
21
|
+
if isinstance(value, bool):
|
|
22
|
+
return "true" if value else "false"
|
|
23
|
+
if isinstance(value, (int, float)):
|
|
24
|
+
return str(value)
|
|
25
|
+
return json.dumps(str(value), ensure_ascii=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def dumps(data: dict[str, Any]) -> str:
|
|
29
|
+
lines = [f"{k} = {_scalar(v)}" for k, v in data.items() if not isinstance(v, dict)]
|
|
30
|
+
for key, table in data.items():
|
|
31
|
+
if isinstance(table, dict):
|
|
32
|
+
lines += ["", f"[{key}]"] + [f"{k} = {_scalar(v)}" for k, v in table.items()]
|
|
33
|
+
return "\n".join(lines).strip() + "\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save(path: Path, data: dict[str, Any]) -> None:
|
|
37
|
+
path.write_text(dumps(data), encoding="utf-8")
|
bugcap/validation.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Format checks for the GitHub-related values users type (slug, images path, branch).
|
|
2
|
+
Pure functions that raise ValueError naming the bad value; the online existence checks
|
|
3
|
+
live in `ghcli.verify_repo`."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
_OWNER = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$")
|
|
10
|
+
_REPO = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
|
|
11
|
+
_BAD_BRANCH_CHARS = re.compile(r"[\s~^:?*\[\\\x00-\x1f\x7f]")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_slug(value: str, what: str = "repo") -> str:
|
|
15
|
+
"""`owner/repo`, GitHub's character rules."""
|
|
16
|
+
owner, sep, name = (value or "").partition("/")
|
|
17
|
+
if (
|
|
18
|
+
not sep
|
|
19
|
+
or "/" in name
|
|
20
|
+
or not _OWNER.fullmatch(owner)
|
|
21
|
+
or not _REPO.fullmatch(name)
|
|
22
|
+
or name in (".", "..")
|
|
23
|
+
):
|
|
24
|
+
raise ValueError(f"invalid {what} {value!r}: expected owner/repo (letters, digits, '-', '_', '.')")
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_images_path(value: str) -> str:
|
|
29
|
+
"""A relative directory inside the images repo (no '..', no absolute or backslash paths)."""
|
|
30
|
+
path = (value or "").strip("/")
|
|
31
|
+
parts = path.split("/")
|
|
32
|
+
if (
|
|
33
|
+
not path
|
|
34
|
+
or value.startswith("/")
|
|
35
|
+
or "\\" in value
|
|
36
|
+
or any(p in ("", ".", "..") for p in parts)
|
|
37
|
+
or _BAD_BRANCH_CHARS.search(path)
|
|
38
|
+
):
|
|
39
|
+
raise ValueError(f"invalid images path {value!r}: expected a relative directory such as bugcap-images")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_branch(value: str) -> str:
|
|
44
|
+
"""Git ref-name rules (the common ones): no spaces, '..', '@{', leading '-' or '/', trailing '/' '.' or '.lock'."""
|
|
45
|
+
bad = (
|
|
46
|
+
not value
|
|
47
|
+
or _BAD_BRANCH_CHARS.search(value)
|
|
48
|
+
or ".." in value
|
|
49
|
+
or "@{" in value
|
|
50
|
+
or value.startswith(("-", "/"))
|
|
51
|
+
or value.endswith(("/", ".", ".lock"))
|
|
52
|
+
or "//" in value
|
|
53
|
+
or value == "@"
|
|
54
|
+
)
|
|
55
|
+
if bad:
|
|
56
|
+
raise ValueError(f"invalid branch {value!r}: not a valid git branch name")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_repo_values(
|
|
61
|
+
github: Optional[str] = None,
|
|
62
|
+
images_repo: Optional[str] = None,
|
|
63
|
+
images_path: Optional[str] = None,
|
|
64
|
+
images_branch: Optional[str] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Validate whichever values are given; ValueError names the first bad one."""
|
|
67
|
+
if github:
|
|
68
|
+
validate_slug(github, "GitHub repo")
|
|
69
|
+
if images_repo:
|
|
70
|
+
validate_slug(images_repo, "images repo")
|
|
71
|
+
if images_path:
|
|
72
|
+
validate_images_path(images_path)
|
|
73
|
+
if images_branch:
|
|
74
|
+
validate_branch(images_branch)
|