diffbutler 0.5.0__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.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,461 @@
1
+ """Mediated Git object reads. This is not a sandbox for an agent executable."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import math
6
+ import os
7
+ import re
8
+ import shutil
9
+ import signal
10
+ import sys
11
+ from collections.abc import AsyncGenerator, Mapping
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from types import MappingProxyType
15
+
16
+ from diffbutler.domain.review import ChangedFile, Side, SourceFile
17
+ from diffbutler.sources.diagnostics import GitTrace
18
+
19
+
20
+ class SourceError(Exception):
21
+ """Safe source diagnostic; never include Git stderr or local credentials."""
22
+
23
+
24
+ async def _terminate_group(pid: int) -> None:
25
+ # Darwin returns EPERM for a group containing only unreaped zombies. Give
26
+ # the child watcher time to reap, then require successful signalling or ESRCH.
27
+ # Never interpret persistent EPERM as proof that the group has exited.
28
+ for attempt in range(100):
29
+ try:
30
+ os.killpg(pid, signal.SIGKILL)
31
+ return
32
+ except ProcessLookupError:
33
+ return
34
+ except PermissionError:
35
+ if sys.platform != "darwin" or attempt == 99:
36
+ raise
37
+ await asyncio.sleep(0.01)
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class SourceLimits:
42
+ timeout_s: float = 30
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.timeout_s <= 0 or not math.isfinite(self.timeout_s):
46
+ raise ValueError("source limits must be positive")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _Entry:
51
+ mode: str
52
+ oid: str
53
+
54
+
55
+ class GitSourceReader:
56
+ """Read only blobs present in two pinned commits from a trusted local repo.
57
+
58
+ Symlinks return their stored target text, never the target's contents. Gitlinks
59
+ are explicitly unavailable. No checkout, fetch, filters, or project execution.
60
+ Local Git administration is trusted; untrusted repository *contents* are data.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ repository: Path,
66
+ executable: str,
67
+ limits: SourceLimits,
68
+ trees: Mapping[Side, Mapping[str, _Entry]],
69
+ ) -> None:
70
+ self._repository = repository
71
+ self._executable = executable
72
+ self._limits = limits
73
+ self._trees = trees
74
+ self._base_sha = ""
75
+ self._head_sha = ""
76
+ self._comparison_base_sha = ""
77
+
78
+ async def common_directory(self) -> Path:
79
+ """Locate the trusted object store, including for linked worktrees."""
80
+ raw = await self._git("rev-parse", "--path-format=absolute", "--git-common-dir")
81
+ return Path(os.fsdecode(raw.rstrip(b"\n")))
82
+
83
+ @classmethod
84
+ async def open(
85
+ cls,
86
+ repository: Path,
87
+ *,
88
+ base_sha: str,
89
+ head_sha: str,
90
+ limits: SourceLimits | None = None,
91
+ ) -> GitSourceReader:
92
+ # Full object IDs only: no ref movement, revision syntax, or option injection.
93
+ for revision in (base_sha, head_sha):
94
+ if re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", revision) is None:
95
+ raise ValueError("source revisions must be full lowercase Git object IDs")
96
+ limits = limits or SourceLimits()
97
+ executable = shutil.which("git")
98
+ if executable is None:
99
+ raise SourceError("Git is unavailable")
100
+ reader = cls(repository.absolute(), executable, limits, {})
101
+ for revision in (base_sha, head_sha):
102
+ if await reader._git("cat-file", "-t", revision) != b"commit\n":
103
+ raise SourceError("Source revision is not a commit")
104
+ if await reader._git("rev-parse", "--is-shallow-repository") != b"false\n":
105
+ raise SourceError("Review requires complete Git history; prepare the repository again")
106
+ try:
107
+ raw_bases = await reader._git("merge-base", "--all", base_sha, head_sha)
108
+ bases = raw_bases.decode("ascii").split()
109
+ except SourceError:
110
+ raise SourceError("Could not establish the PR merge base from local history") from None
111
+ if len(bases) != 1 or re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", bases[0]) is None:
112
+ raise SourceError("Review requires a unique PR merge base")
113
+ reader._comparison_base_sha = bases[0]
114
+ trees: dict[Side, Mapping[str, _Entry]] = {}
115
+ for side, revision in ((Side.BASE, reader.comparison_base_sha), (Side.HEAD, head_sha)):
116
+ raw = await reader._git(
117
+ "ls-tree",
118
+ "-r",
119
+ "-z",
120
+ "--full-tree",
121
+ revision,
122
+ )
123
+ entries: dict[str, _Entry] = {}
124
+ try:
125
+ for record in raw.split(b"\0"):
126
+ if not record:
127
+ continue
128
+ metadata, name = record.split(b"\t", 1)
129
+ mode, kind, oid = metadata.decode("ascii").split(" ")
130
+ path = name.decode("utf-8")
131
+ SourceFile(path, side)
132
+ if (
133
+ (mode, kind)
134
+ not in {
135
+ ("100644", "blob"),
136
+ ("100755", "blob"),
137
+ ("120000", "blob"),
138
+ ("160000", "commit"),
139
+ }
140
+ or re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", oid) is None
141
+ or path in entries
142
+ ):
143
+ raise ValueError("invalid tree entry")
144
+ entries[path] = _Entry(mode, oid)
145
+ except ValueError, UnicodeError:
146
+ raise SourceError("Source tree contains unsupported paths or entries") from None
147
+ trees[side] = MappingProxyType(entries)
148
+ reader._trees = MappingProxyType(trees)
149
+ reader._base_sha = base_sha
150
+ reader._head_sha = head_sha
151
+ return reader
152
+
153
+ @property
154
+ def base_sha(self) -> str:
155
+ return self._base_sha
156
+
157
+ @property
158
+ def comparison_base_sha(self) -> str:
159
+ """Unique merge base used for BASE source, changed files, and diff hunks."""
160
+ return self._comparison_base_sha
161
+
162
+ @property
163
+ def head_sha(self) -> str:
164
+ return self._head_sha
165
+
166
+ @property
167
+ def limitations(self) -> tuple[str, ...]:
168
+ return (
169
+ f"PR diff and base-side source use merge base {self.comparison_base_sha}.",
170
+ "Renames are represented as deletion and addition; submodule contents are unavailable.",
171
+ "Binary and non-UTF-8 diff content is summarized without line anchors; "
172
+ "original file bytes remain available.",
173
+ )
174
+
175
+ async def read_diff(self) -> str:
176
+ """Merge-base-to-head diff with summaries for binary/non-UTF-8 changes.
177
+
178
+ Force text so working-tree attributes cannot hide changes as binary or
179
+ invoke custom drivers. No textconv, external diff, or project execution.
180
+ """
181
+ if not self.base_sha or not self.head_sha:
182
+ raise SourceError("Source reader has not been prepared")
183
+ raw = await self._git(
184
+ "diff-tree",
185
+ "-r",
186
+ "-p",
187
+ "--no-commit-id",
188
+ "--no-ext-diff",
189
+ "--no-textconv",
190
+ "--no-renames",
191
+ "--text",
192
+ "--no-color",
193
+ "--no-relative",
194
+ "--src-prefix=a/",
195
+ "--dst-prefix=b/",
196
+ "--unified=3",
197
+ "--diff-algorithm=myers",
198
+ "--no-indent-heuristic",
199
+ "--ignore-submodules=none",
200
+ "--submodule=short",
201
+ self.comparison_base_sha,
202
+ self.head_sha,
203
+ "--",
204
+ )
205
+ sections: list[str] = []
206
+ # Hunk lines always have a prefix, and Git quotes newlines in paths,
207
+ # so only file boundaries can begin with this unprefixed header.
208
+ for section in re.split(rb"(?=^diff --git )", raw, flags=re.MULTILINE):
209
+ try:
210
+ text = section.decode("utf-8")
211
+ except UnicodeDecodeError:
212
+ text = None
213
+ if text is not None and "\x00" not in text:
214
+ sections.append(text)
215
+ continue
216
+ headers = re.search(rb"^--- (.+)\n\+\+\+ (.+)\n", section, re.MULTILINE)
217
+ if headers is None:
218
+ raise SourceError("Source diff contains unsupported file headers")
219
+ # Preserve modes and object IDs, but omit the entire file's hunks:
220
+ # partially decoded binary data must never create line anchors.
221
+ summary = (
222
+ section[: headers.start()]
223
+ + b"Binary files "
224
+ + headers[1].removesuffix(b"\t")
225
+ + b" and "
226
+ + headers[2].removesuffix(b"\t")
227
+ + b" differ\n"
228
+ )
229
+ try:
230
+ sections.append(summary.decode("utf-8"))
231
+ except UnicodeDecodeError:
232
+ raise SourceError("Source diff contains unsupported file headers") from None
233
+ return "".join(sections)
234
+
235
+ def paths(self, side: Side) -> tuple[str, ...]:
236
+ """All tracked paths, including unchanged surrounding sources."""
237
+ return tuple(sorted(self._trees[side]))
238
+
239
+ @property
240
+ def changed_files(self) -> tuple[ChangedFile, ...]:
241
+ """Renames are represented as deletion plus addition; mode changes count."""
242
+ base, head = self._trees[Side.BASE], self._trees[Side.HEAD]
243
+ return tuple(
244
+ ChangedFile(path)
245
+ for path in sorted(base.keys() | head.keys())
246
+ if base.get(path) != head.get(path)
247
+ )
248
+
249
+ async def read_file(self, source: SourceFile) -> bytes | None:
250
+ entry = self._trees[source.side].get(source.path)
251
+ if entry is None:
252
+ return None
253
+ if entry.mode == "160000":
254
+ raise SourceError("Submodule source is unavailable in this repository context")
255
+ # The object ID came from the pinned tree, not from a caller's expression.
256
+ return await self._git("cat-file", "blob", entry.oid)
257
+
258
+ async def read_blobs(self) -> AsyncGenerator[tuple[tuple[SourceFile, ...], bytes]]:
259
+ """Stream each distinct pinned blob once; callers must close the generator.
260
+
261
+ Object contents are streamed without a repository-sized cache. Requests contain
262
+ only verified object IDs, never filenames or revision expressions.
263
+ Lazy fetch stays disabled.
264
+ """
265
+ objects: dict[str, list[SourceFile]] = {}
266
+ for side in Side:
267
+ for path, entry in self._trees[side].items():
268
+ if entry.mode == "160000":
269
+ raise SourceError("Submodule source is unavailable in this repository context")
270
+ objects.setdefault(entry.oid, []).append(SourceFile(path, side))
271
+ if not objects:
272
+ return
273
+ env = self._environment()
274
+ argv = (
275
+ self._executable,
276
+ "--no-pager",
277
+ "-C",
278
+ str(self._repository),
279
+ "-c",
280
+ "core.fsmonitor=false",
281
+ "-c",
282
+ "gc.auto=0",
283
+ "cat-file",
284
+ "--batch",
285
+ )
286
+ trace = GitTrace(logging.getLogger(__name__), argv, env)
287
+ spawn = asyncio.create_task(
288
+ asyncio.create_subprocess_exec(
289
+ *argv,
290
+ env=env,
291
+ stdin=asyncio.subprocess.PIPE,
292
+ stdout=asyncio.subprocess.PIPE,
293
+ stderr=asyncio.subprocess.PIPE,
294
+ start_new_session=True,
295
+ )
296
+ )
297
+ errors: asyncio.Task[None] | None = None
298
+ outcome = "interrupted"
299
+
300
+ async def drain(stream: asyncio.StreamReader, *, diagnostic: bool = False) -> None:
301
+ while chunk := await stream.read(65536):
302
+ if diagnostic:
303
+ trace.capture(chunk)
304
+
305
+ async def cleanup() -> None:
306
+ try:
307
+ process = await spawn
308
+ except OSError:
309
+ trace.finish(None, outcome)
310
+ return
311
+ await _terminate_group(process.pid)
312
+ assert (
313
+ process.stdin is not None
314
+ and process.stdout is not None
315
+ and process.stderr is not None
316
+ )
317
+ process.stdin.close()
318
+ # Drain after killing, even if cancellation interrupted the initial spawn.
319
+ await asyncio.gather(
320
+ drain(process.stdout),
321
+ errors if errors is not None else drain(process.stderr, diagnostic=True),
322
+ return_exceptions=True,
323
+ )
324
+ await process.wait()
325
+ trace.finish(process.returncode, outcome)
326
+
327
+ try:
328
+ process = await asyncio.shield(spawn)
329
+ assert (
330
+ process.stdin is not None
331
+ and process.stdout is not None
332
+ and process.stderr is not None
333
+ )
334
+ errors = asyncio.create_task(drain(process.stderr, diagnostic=True))
335
+ for oid, sources in objects.items():
336
+ async with asyncio.timeout(self._limits.timeout_s):
337
+ process.stdin.write(oid.encode("ascii") + b"\n")
338
+ await process.stdin.drain()
339
+ header = await process.stdout.readline()
340
+ fields = header.rstrip(b"\n").split(b" ")
341
+ if (
342
+ len(fields) != 3
343
+ or fields[0] != oid.encode("ascii")
344
+ or fields[1] != b"blob"
345
+ or not fields[2].isdigit()
346
+ ):
347
+ raise SourceError(
348
+ "Git batch could not read the requested local source objects"
349
+ )
350
+ content = await process.stdout.readexactly(int(fields[2]))
351
+ if await process.stdout.readexactly(1) != b"\n":
352
+ raise SourceError("Git batch returned an invalid source object boundary")
353
+ yield tuple(sources), content
354
+ process.stdin.close()
355
+ async with asyncio.timeout(self._limits.timeout_s):
356
+ await asyncio.shield(errors)
357
+ status = await process.wait()
358
+ outcome = "exited"
359
+ if status != 0:
360
+ raise SourceError("Git batch source retrieval failed")
361
+ except TimeoutError:
362
+ outcome = "timeout"
363
+ raise SourceError("Git batch source retrieval timed out") from None
364
+ except asyncio.IncompleteReadError, OSError, ValueError:
365
+ raise SourceError(
366
+ "Git batch could not read the requested local source objects"
367
+ ) from None
368
+ finally:
369
+ closing = asyncio.create_task(cleanup())
370
+ cancelled = False
371
+ while not closing.done():
372
+ try:
373
+ await asyncio.shield(closing)
374
+ except asyncio.CancelledError:
375
+ cancelled = True
376
+ closing.result()
377
+ if cancelled:
378
+ raise asyncio.CancelledError
379
+
380
+ @staticmethod
381
+ def _environment() -> dict[str, str]:
382
+ # No inherited tokens, Git overrides, HOME configuration, or SSH agents.
383
+ # Protocol denial also protects Git versions without GIT_NO_LAZY_FETCH.
384
+ return {
385
+ "PATH": os.defpath,
386
+ "LC_ALL": "C",
387
+ "GIT_CONFIG_NOSYSTEM": "1",
388
+ "GIT_CONFIG_GLOBAL": os.devnull,
389
+ "GIT_TERMINAL_PROMPT": "0",
390
+ "GIT_NO_REPLACE_OBJECTS": "1",
391
+ "GIT_NO_LAZY_FETCH": "1",
392
+ "GIT_ALLOW_PROTOCOL": "",
393
+ "GIT_OPTIONAL_LOCKS": "0",
394
+ }
395
+
396
+ async def _git(self, *args: str) -> bytes:
397
+ env = self._environment()
398
+ argv = (
399
+ self._executable,
400
+ "--no-pager",
401
+ "-C",
402
+ str(self._repository),
403
+ "-c",
404
+ "core.fsmonitor=false",
405
+ "-c",
406
+ "gc.auto=0",
407
+ *args,
408
+ )
409
+ trace = GitTrace(logging.getLogger(__name__), argv, env)
410
+ outcome = "interrupted"
411
+ try:
412
+ process = await asyncio.create_subprocess_exec(
413
+ *argv,
414
+ env=env,
415
+ stdin=asyncio.subprocess.DEVNULL,
416
+ stdout=asyncio.subprocess.PIPE,
417
+ stderr=asyncio.subprocess.PIPE,
418
+ start_new_session=True,
419
+ )
420
+ except OSError as exc:
421
+ trace.finish(None, f"os-error errno={exc.errno}")
422
+ raise SourceError("Could not start Git source retrieval") from None
423
+ assert process.stdout is not None and process.stderr is not None
424
+
425
+ async def collect(stream: asyncio.StreamReader, *, diagnostic: bool = False) -> bytes:
426
+ result = bytearray()
427
+ while chunk := await stream.read(65536):
428
+ result.extend(chunk)
429
+ if diagnostic:
430
+ trace.capture(chunk)
431
+ return bytes(result)
432
+
433
+ stdout = asyncio.create_task(collect(process.stdout))
434
+ stderr = asyncio.create_task(collect(process.stderr, diagnostic=True))
435
+ try:
436
+ async with asyncio.timeout(self._limits.timeout_s):
437
+ output, _ = await asyncio.gather(stdout, stderr)
438
+ status = await process.wait()
439
+ outcome = "exited"
440
+ if status != 0:
441
+ raise SourceError("Git could not read the requested local source objects")
442
+ return output
443
+ except TimeoutError:
444
+ outcome = "timeout"
445
+ raise SourceError("Git source retrieval timed out") from None
446
+ finally:
447
+ # Terminate the owned group even if a child retained the output pipes.
448
+ await _terminate_group(process.pid)
449
+ stdout.cancel()
450
+ stderr.cancel()
451
+ await asyncio.gather(stdout, stderr, return_exceptions=True)
452
+
453
+ async def discard(stream: asyncio.StreamReader) -> None:
454
+ while await stream.read(65536):
455
+ pass
456
+
457
+ # Cancellation can leave a paused pipe transport with buffered data.
458
+ # Drain after killing so EOF closes both transports before returning.
459
+ await asyncio.gather(discard(process.stdout), discard(process.stderr))
460
+ await process.wait()
461
+ trace.finish(process.returncode, outcome)
@@ -0,0 +1,82 @@
1
+ """Inspect local Git metadata without fetching or executing repository code."""
2
+
3
+ import asyncio
4
+ import re
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from urllib.parse import urlsplit
9
+
10
+ from diffbutler.domain.forge import PRQuery
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class GitRepository:
15
+ host: str
16
+ repository: str
17
+ path: Path
18
+
19
+
20
+ def remote_identity(value: str) -> tuple[str, str] | None:
21
+ if "://" not in value:
22
+ match = re.fullmatch(r"(?:[^@/\s]+@)?([^:/\s]+):(.+)", value)
23
+ if not match:
24
+ return None
25
+ value = f"ssh://{match[1]}/{match[2]}"
26
+ try:
27
+ url = urlsplit(value)
28
+ if url.scheme not in {"https", "http", "ssh", "git"} or not url.hostname:
29
+ return None
30
+ if url.query or url.fragment:
31
+ return None
32
+ repository = url.path.strip("/").removesuffix(".git")
33
+ PRQuery(repository=repository)
34
+ return url.hostname.lower(), repository
35
+ except ValueError:
36
+ return None
37
+
38
+
39
+ async def discover_repository(directory: Path) -> GitRepository | None:
40
+ executable = shutil.which("git")
41
+ if executable is None:
42
+ return None
43
+
44
+ async def git(*arguments: str) -> str | None:
45
+ try:
46
+ process = await asyncio.create_subprocess_exec(
47
+ executable,
48
+ "-C",
49
+ str(directory),
50
+ *arguments,
51
+ stdin=asyncio.subprocess.DEVNULL,
52
+ stdout=asyncio.subprocess.PIPE,
53
+ stderr=asyncio.subprocess.DEVNULL,
54
+ )
55
+ except OSError:
56
+ return None
57
+ try:
58
+ async with asyncio.timeout(10):
59
+ output, _ = await process.communicate()
60
+ return output.decode("utf-8").strip() if process.returncode == 0 else None
61
+ except TimeoutError, UnicodeError:
62
+ return None
63
+ finally:
64
+ if process.returncode is None:
65
+ process.kill()
66
+ await process.wait()
67
+
68
+ root = await git("rev-parse", "--show-toplevel")
69
+ if not root:
70
+ return None
71
+ remotes = await git("config", "--local", "--no-includes", "--get-regexp", r"^remote\..*\.url$")
72
+ if not remotes:
73
+ return None
74
+ entries = [line.split(maxsplit=1) for line in remotes.splitlines()]
75
+ origins = [entry[1] for entry in entries if len(entry) == 2 and entry[0] == "remote.origin.url"]
76
+ values = origins or [entry[1] for entry in entries if len(entry) == 2]
77
+ identities = {remote_identity(value) for value in values}
78
+ if len(identities) != 1 or None in identities:
79
+ return None
80
+ identity = next(iter(identities))
81
+ assert identity is not None
82
+ return GitRepository(*identity, Path(root))
@@ -0,0 +1 @@
1
+ """Persistence and migration support."""
@@ -0,0 +1,88 @@
1
+ """Persistence seam for account-scoped atomic queue updates."""
2
+
3
+ from typing import Protocol
4
+ from uuid import UUID
5
+
6
+ from diffbutler.domain.forge import Account, PRRef
7
+ from diffbutler.domain.lifecycle import CleanupClaim, ForegroundLease, HeldOperation
8
+ from diffbutler.domain.process import ProcessLaunch
9
+ from diffbutler.domain.resources import OwnedDirectory
10
+ from diffbutler.domain.review import (
11
+ AnalysisIdentity,
12
+ ComparisonCheck,
13
+ ReviewPolicy,
14
+ ReviewResultRecord,
15
+ RunClaim,
16
+ RunRecord,
17
+ SessionRecord,
18
+ )
19
+ from diffbutler.domain.sync import QueueSnapshot, TrackedPR
20
+
21
+
22
+ class SyncConflict(Exception):
23
+ """Another process committed after the sync snapshot was read; refresh first."""
24
+
25
+
26
+ class QueueStore(Protocol):
27
+ async def snapshot(self, account: Account) -> QueueSnapshot: ...
28
+ async def apply(
29
+ self,
30
+ account: Account,
31
+ version: int,
32
+ items: tuple[TrackedPR, ...],
33
+ *,
34
+ refreshed: frozenset[PRRef] = frozenset(),
35
+ ) -> None: ...
36
+ async def accounts(self) -> tuple[Account, ...]: ...
37
+
38
+
39
+ class ReviewStore(Protocol):
40
+ async def latest_run(self, task_id: UUID) -> RunRecord | None: ...
41
+ async def mark_approved(self, task_id: UUID) -> None: ...
42
+
43
+ async def session_resources(self, session_id: UUID) -> tuple[OwnedDirectory, ...]: ...
44
+
45
+ async def register_resource(self, token: UUID, resource: OwnedDirectory) -> None: ...
46
+
47
+ async def get_session(self, session_id: UUID) -> SessionRecord: ...
48
+ async def bind_backend_session(self, token: UUID, backend_session_id: str) -> SessionRecord: ...
49
+ async def suspend_session(self, session_id: UUID) -> SessionRecord: ...
50
+ async def get_run(self, run_id: UUID) -> RunRecord: ...
51
+ async def request_review(
52
+ self, identity: AnalysisIdentity, policy: ReviewPolicy
53
+ ) -> RunRecord: ...
54
+ async def claim_review(
55
+ self, run_id: UUID, *, capacity: int, policy: ReviewPolicy
56
+ ) -> RunClaim | None: ...
57
+ async def comparison_snapshot(self, token: UUID) -> tuple[RunRecord, str]: ...
58
+ async def claim_is_current(
59
+ self, token: UUID, policy: ReviewPolicy, comparison: ComparisonCheck | None = None
60
+ ) -> bool: ...
61
+ async def cancel_review(self, run_id: UUID) -> RunRecord: ...
62
+ async def release_review(
63
+ self, token: UUID, failure: ReviewResultRecord | None = None
64
+ ) -> RunRecord: ...
65
+ async def get_result(self, run_id: UUID) -> ReviewResultRecord | None: ...
66
+ async def finalize_review(
67
+ self,
68
+ token: UUID,
69
+ result: ReviewResultRecord,
70
+ *,
71
+ backend_session_id: str | None,
72
+ no_findings: bool,
73
+ comparison: ComparisonCheck | None = None,
74
+ ) -> RunRecord: ...
75
+
76
+
77
+ class LifecycleStore(Protocol):
78
+ async def held_operations(self) -> tuple[HeldOperation, ...]: ...
79
+ async def acquire_foreground(self, session_id: UUID) -> ForegroundLease: ...
80
+ async def release_foreground(self, token: UUID) -> None: ...
81
+ async def claim_cleanup(self) -> CleanupClaim | None: ...
82
+ async def finish_cleanup(self, token: UUID, *, succeeded: bool) -> None: ...
83
+
84
+
85
+ class ProcessStore(Protocol):
86
+ async def get_launch(self, token: UUID) -> ProcessLaunch | None: ...
87
+ async def reserve_launch(self, launch: ProcessLaunch) -> None: ...
88
+ async def bind_launcher(self, token: UUID, pid: int) -> None: ...