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/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"![{label}]({link.urls[0]})"
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"![{link.name}]({link.urls[0]})")
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)