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.
- diffbutler/__init__.py +1 -0
- diffbutler/__main__.py +6 -0
- diffbutler/config/__init__.py +1 -0
- diffbutler/config/default.toml +91 -0
- diffbutler/config/diagnostics.py +90 -0
- diffbutler/config/file.py +30 -0
- diffbutler/config/instance.py +55 -0
- diffbutler/config/settings.py +297 -0
- diffbutler/config/setup.py +92 -0
- diffbutler/config/transport.py +65 -0
- diffbutler/core/__init__.py +1 -0
- diffbutler/core/application.py +103 -0
- diffbutler/core/context.py +54 -0
- diffbutler/core/dashboard.py +393 -0
- diffbutler/core/doctor.py +200 -0
- diffbutler/core/lifecycle.py +70 -0
- diffbutler/core/managed.py +441 -0
- diffbutler/core/native.py +301 -0
- diffbutler/core/polling.py +118 -0
- diffbutler/core/process.py +64 -0
- diffbutler/core/repositories.py +260 -0
- diffbutler/core/reset.py +34 -0
- diffbutler/core/resources.py +50 -0
- diffbutler/core/review.py +331 -0
- diffbutler/core/setup.py +232 -0
- diffbutler/core/snapshot.py +70 -0
- diffbutler/core/sync.py +259 -0
- diffbutler/core/worker.py +205 -0
- diffbutler/core/workspaces.py +139 -0
- diffbutler/domain/__init__.py +1 -0
- diffbutler/domain/dashboard.py +63 -0
- diffbutler/domain/diff.py +285 -0
- diffbutler/domain/forge.py +123 -0
- diffbutler/domain/lifecycle.py +51 -0
- diffbutler/domain/polling.py +92 -0
- diffbutler/domain/process.py +29 -0
- diffbutler/domain/redaction.py +61 -0
- diffbutler/domain/resources.py +50 -0
- diffbutler/domain/review.py +167 -0
- diffbutler/domain/sync.py +196 -0
- diffbutler/forges/__init__.py +1 -0
- diffbutler/forges/base.py +39 -0
- diffbutler/forges/diagnostics.py +104 -0
- diffbutler/forges/github/__init__.py +1 -0
- diffbutler/forges/github/adapter.py +352 -0
- diffbutler/forges/github/auth.py +49 -0
- diffbutler/forges/github/http.py +326 -0
- diffbutler/forges/github/mapping.py +157 -0
- diffbutler/forges/github/setup.py +86 -0
- diffbutler/forges/registry.py +23 -0
- diffbutler/py.typed +0 -0
- diffbutler/reviewers/__init__.py +1 -0
- diffbutler/reviewers/base.py +101 -0
- diffbutler/reviewers/extraction.py +198 -0
- diffbutler/reviewers/native.py +238 -0
- diffbutler/reviewers/parsing.py +247 -0
- diffbutler/reviewers/presets.toml +12 -0
- diffbutler/reviewers/process.py +78 -0
- diffbutler/reviewers/prompt.py +148 -0
- diffbutler/reviewers/redaction.py +30 -0
- diffbutler/reviewers/resources.py +162 -0
- diffbutler/sources/__init__.py +1 -0
- diffbutler/sources/diagnostics.py +100 -0
- diffbutler/sources/fetch.py +285 -0
- diffbutler/sources/git.py +461 -0
- diffbutler/sources/remotes.py +82 -0
- diffbutler/store/__init__.py +1 -0
- diffbutler/store/base.py +88 -0
- diffbutler/store/dashboard.py +201 -0
- diffbutler/store/database.py +172 -0
- diffbutler/store/lifecycle.py +247 -0
- diffbutler/store/migrations/__init__.py +1 -0
- diffbutler/store/migrations/env.py +14 -0
- diffbutler/store/migrations/versions/0001_initial.py +147 -0
- diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
- diffbutler/store/migrations/versions/0003_reviews.py +72 -0
- diffbutler/store/migrations/versions/0004_claims.py +22 -0
- diffbutler/store/migrations/versions/0005_results.py +27 -0
- diffbutler/store/migrations/versions/0006_anchors.py +17 -0
- diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
- diffbutler/store/migrations/versions/0008_processes.py +30 -0
- diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
- diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
- diffbutler/store/migrations/versions/__init__.py +1 -0
- diffbutler/store/models.py +290 -0
- diffbutler/store/process.py +75 -0
- diffbutler/store/queue.py +289 -0
- diffbutler/store/review.py +583 -0
- diffbutler/ui/__init__.py +1 -0
- diffbutler/ui/cli/__init__.py +532 -0
- diffbutler/ui/cli/editor.py +33 -0
- diffbutler/ui/tui/__init__.py +1 -0
- diffbutler/ui/tui/_fake_agent.py +47 -0
- diffbutler/ui/tui/_foreground_exec.py +23 -0
- diffbutler/ui/tui/app.py +34 -0
- diffbutler/ui/tui/browse.py +119 -0
- diffbutler/ui/tui/browser.py +42 -0
- diffbutler/ui/tui/dashboard.py +926 -0
- diffbutler/ui/tui/foreground.py +103 -0
- diffbutler/ui/tui/handoff_probe.py +184 -0
- diffbutler-0.5.0.dist-info/METADATA +170 -0
- diffbutler-0.5.0.dist-info/RECORD +105 -0
- diffbutler-0.5.0.dist-info/WHEEL +4 -0
- diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
- 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."""
|
diffbutler/store/base.py
ADDED
|
@@ -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: ...
|