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,205 @@
|
|
|
1
|
+
"""Worker orchestration for explicit requests, including native CLI execution."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from diffbutler.core.review import AIRun, ReviewService
|
|
12
|
+
from diffbutler.domain.review import ReviewConflict, RunClaim, RunRecord
|
|
13
|
+
from diffbutler.reviewers.base import ReviewContext, ReviewOutput
|
|
14
|
+
from diffbutler.reviewers.extraction import OutputFormat
|
|
15
|
+
from diffbutler.reviewers.parsing import ResponseFormat, Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReviewExecution(Protocol):
|
|
19
|
+
async def run(
|
|
20
|
+
self,
|
|
21
|
+
context: ReviewContext,
|
|
22
|
+
backend_session_id: str | None,
|
|
23
|
+
checkpoint: Callable[[str], Awaitable[None]],
|
|
24
|
+
) -> ReviewOutput:
|
|
25
|
+
"""Capture output under the configured native permission profile, never a terminal.
|
|
26
|
+
|
|
27
|
+
Implementations must await checkpoint when a backend identity is learned.
|
|
28
|
+
Creating the execution object must not start a process; only run may start
|
|
29
|
+
it. A native adapter must reassert its permission profile and durable ownership,
|
|
30
|
+
including resumes from formerly foreground conversations.
|
|
31
|
+
"""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def partial_output(self) -> ReviewOutput:
|
|
35
|
+
"""Read captured bytes after stop_and_wait has established exit."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def stop_and_wait(self) -> None:
|
|
39
|
+
"""Establish whole process-tree exit, including a spawn still in progress.
|
|
40
|
+
|
|
41
|
+
Required on normal return too: protocol completion and EOF do not prove
|
|
42
|
+
exit. Raise if exit cannot be established, leaving durable ownership held.
|
|
43
|
+
This must also settle the run coroutine; no detached I/O may remain.
|
|
44
|
+
"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _settle[T](task: asyncio.Task[T]) -> tuple[T, bool]:
|
|
49
|
+
cancelled = False
|
|
50
|
+
while True:
|
|
51
|
+
try:
|
|
52
|
+
return await asyncio.shield(task), cancelled
|
|
53
|
+
except asyncio.CancelledError:
|
|
54
|
+
if task.done():
|
|
55
|
+
return task.result(), True
|
|
56
|
+
cancelled = True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ReviewWorker:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
reviews: ReviewService,
|
|
63
|
+
prepare: Callable[[RunRecord], Awaitable[ReviewContext]],
|
|
64
|
+
execution_factory: Callable[[RunClaim], ReviewExecution],
|
|
65
|
+
*,
|
|
66
|
+
before_prepare: Callable[[RunClaim], Awaitable[None]] | None = None,
|
|
67
|
+
output_format: OutputFormat | None = None,
|
|
68
|
+
response_format: ResponseFormat = "json",
|
|
69
|
+
poll_interval: float = 0.25,
|
|
70
|
+
min_confidence: float = 0.5,
|
|
71
|
+
findings_threshold: Severity = "minor",
|
|
72
|
+
) -> None:
|
|
73
|
+
if not math.isfinite(poll_interval) or poll_interval <= 0:
|
|
74
|
+
raise ValueError("poll interval must be positive and finite")
|
|
75
|
+
self.reviews = reviews
|
|
76
|
+
self.before_prepare = before_prepare
|
|
77
|
+
self.prepare = prepare
|
|
78
|
+
self.execution_factory = execution_factory
|
|
79
|
+
self.output_format = output_format
|
|
80
|
+
self.response_format: ResponseFormat = response_format
|
|
81
|
+
self.poll_interval = poll_interval
|
|
82
|
+
self.min_confidence = min_confidence
|
|
83
|
+
self.findings_threshold: Severity = findings_threshold
|
|
84
|
+
|
|
85
|
+
async def execute_requested(
|
|
86
|
+
self,
|
|
87
|
+
run_id: UUID,
|
|
88
|
+
*,
|
|
89
|
+
capacity: int = 2,
|
|
90
|
+
paused: bool = False,
|
|
91
|
+
) -> AIRun | None:
|
|
92
|
+
"""Consume one already requested run, never create or requeue analysis.
|
|
93
|
+
|
|
94
|
+
Factory/prepare are trusted dependencies; native execution relies on the
|
|
95
|
+
installed agent sandbox and process-group lifecycle contract. Cancellation
|
|
96
|
+
releases ownership
|
|
97
|
+
only after stop_and_wait; stop failure deliberately retains the claim.
|
|
98
|
+
"""
|
|
99
|
+
claim = await self.reviews.claim_review(run_id, capacity=capacity, paused=paused)
|
|
100
|
+
if claim is None:
|
|
101
|
+
return None
|
|
102
|
+
exited = True # No execution object/process exists during preparation.
|
|
103
|
+
execution: ReviewExecution | None = None
|
|
104
|
+
context: ReviewContext | None = None
|
|
105
|
+
phase = "preparation"
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
if self.before_prepare is not None:
|
|
109
|
+
await self.before_prepare(claim)
|
|
110
|
+
context = await self.prepare(claim.run)
|
|
111
|
+
if (context.run_id, context.session_id, context.pr.base_sha, context.pr.head_sha) != (
|
|
112
|
+
claim.run.id,
|
|
113
|
+
claim.run.session_id,
|
|
114
|
+
claim.run.base_sha,
|
|
115
|
+
claim.run.head_sha,
|
|
116
|
+
):
|
|
117
|
+
raise ReviewConflict("Prepared context does not match worker ownership")
|
|
118
|
+
if context.diff_index is None or (
|
|
119
|
+
context.diff_index.base_sha,
|
|
120
|
+
context.diff_index.head_sha,
|
|
121
|
+
) != (claim.run.base_sha, claim.run.head_sha):
|
|
122
|
+
raise ReviewConflict("Worker requires a pinned original diff index")
|
|
123
|
+
if not await self.reviews.claim_is_current(claim.token):
|
|
124
|
+
return await self.reviews.release_review(claim.token)
|
|
125
|
+
session = await self.reviews.get_session(claim.run.session_id)
|
|
126
|
+
|
|
127
|
+
async def checkpoint(identity: str) -> None:
|
|
128
|
+
await self.reviews.bind_backend_session(claim.token, identity)
|
|
129
|
+
|
|
130
|
+
phase = "agent execution"
|
|
131
|
+
execution = self.execution_factory(claim)
|
|
132
|
+
exited = False
|
|
133
|
+
running = asyncio.create_task(
|
|
134
|
+
execution.run(context, session.backend_session_id, checkpoint)
|
|
135
|
+
)
|
|
136
|
+
try:
|
|
137
|
+
async with asyncio.timeout(context.budget.timeout_s):
|
|
138
|
+
while not running.done():
|
|
139
|
+
await asyncio.wait((running,), timeout=self.poll_interval)
|
|
140
|
+
if not await self.reviews.claim_is_current(claim.token):
|
|
141
|
+
break
|
|
142
|
+
if not running.done():
|
|
143
|
+
output = None
|
|
144
|
+
else:
|
|
145
|
+
output = await running
|
|
146
|
+
finally:
|
|
147
|
+
|
|
148
|
+
async def stop() -> None:
|
|
149
|
+
await execution.stop_and_wait()
|
|
150
|
+
# Exit is established; settle the coroutine as well.
|
|
151
|
+
running.cancel()
|
|
152
|
+
await asyncio.gather(running, return_exceptions=True)
|
|
153
|
+
|
|
154
|
+
_, cancelled = await _settle(asyncio.create_task(stop()))
|
|
155
|
+
exited = True
|
|
156
|
+
if cancelled:
|
|
157
|
+
raise asyncio.CancelledError
|
|
158
|
+
if output is None:
|
|
159
|
+
return await self.reviews.release_review(
|
|
160
|
+
claim.token, reason="Review invalidated", output=execution.partial_output()
|
|
161
|
+
)
|
|
162
|
+
phase = "result processing"
|
|
163
|
+
result = await self.reviews.finalize_review(
|
|
164
|
+
claim.token,
|
|
165
|
+
output,
|
|
166
|
+
diff_index=context.diff_index,
|
|
167
|
+
output_format=self.output_format,
|
|
168
|
+
response_format=self.response_format,
|
|
169
|
+
min_confidence=self.min_confidence,
|
|
170
|
+
findings_threshold=self.findings_threshold,
|
|
171
|
+
)
|
|
172
|
+
if result.status_reason:
|
|
173
|
+
logging.getLogger(__name__).warning(
|
|
174
|
+
"Review ended run=%s status=%s reason=%s",
|
|
175
|
+
result.id,
|
|
176
|
+
result.status,
|
|
177
|
+
result.status_reason,
|
|
178
|
+
)
|
|
179
|
+
return result
|
|
180
|
+
except BaseException as exc:
|
|
181
|
+
if exited:
|
|
182
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
183
|
+
reason = "Review cancelled"
|
|
184
|
+
elif isinstance(exc, TimeoutError) and context is not None:
|
|
185
|
+
seconds = context.budget.timeout_s
|
|
186
|
+
duration = (
|
|
187
|
+
f"{seconds // 60} minutes" if seconds % 60 == 0 else f"{seconds} seconds"
|
|
188
|
+
)
|
|
189
|
+
reason = f"Review timed out after {duration}"
|
|
190
|
+
else:
|
|
191
|
+
# Exception messages may contain PR content, paths or credentials.
|
|
192
|
+
reason = f"Review failed during {phase} ({type(exc).__name__})"
|
|
193
|
+
# An uncertain finalization commit may already have consumed this
|
|
194
|
+
# token. Never retry finalization or overwrite the retained result.
|
|
195
|
+
with suppress(ReviewConflict):
|
|
196
|
+
await _settle(
|
|
197
|
+
asyncio.create_task(
|
|
198
|
+
self.reviews.release_review(
|
|
199
|
+
claim.token,
|
|
200
|
+
reason=reason,
|
|
201
|
+
output=execution.partial_output() if execution else None,
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
raise
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Reconstructible snapshots; persistent native homes retain their identities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from diffbutler.core.repositories import file_lock
|
|
9
|
+
from diffbutler.core.resources import save_json
|
|
10
|
+
from diffbutler.core.snapshot import write_snapshot
|
|
11
|
+
from diffbutler.domain.resources import OwnedDirectory
|
|
12
|
+
from diffbutler.reviewers.resources import OwnedDirectories, settled_io
|
|
13
|
+
from diffbutler.sources.fetch import git_fetch_command
|
|
14
|
+
from diffbutler.sources.git import GitSourceReader
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SnapshotWorkspaces:
|
|
18
|
+
def __init__(self, homes: OwnedDirectories, path: Path | None = None) -> None:
|
|
19
|
+
self.homes = homes
|
|
20
|
+
self.path = path
|
|
21
|
+
|
|
22
|
+
async def directories(self, *, create: bool) -> OwnedDirectories | None:
|
|
23
|
+
record = self.homes.path / ".snapshot-location.json"
|
|
24
|
+
async with file_lock(self.homes.path / ".snapshot-lock"):
|
|
25
|
+
if record.exists():
|
|
26
|
+
data = json.loads(await settled_io(record.read_text))
|
|
27
|
+
path = Path(data["path"])
|
|
28
|
+
identity = UUID(data["root_id"])
|
|
29
|
+
if not path.is_absolute():
|
|
30
|
+
raise ValueError("Invalid snapshot root")
|
|
31
|
+
if self.path is not None and self.path.absolute() != path:
|
|
32
|
+
raise ValueError("Snapshot root cannot change while using this managed home")
|
|
33
|
+
elif self.path is None or not create:
|
|
34
|
+
return None
|
|
35
|
+
else:
|
|
36
|
+
path = self.path.absolute()
|
|
37
|
+
identity = uuid4()
|
|
38
|
+
# Do not adopt preexisting directories, even if they have a marker.
|
|
39
|
+
if path.exists() or path.is_symlink():
|
|
40
|
+
raise ValueError("Snapshot root must be a new private directory")
|
|
41
|
+
|
|
42
|
+
await save_json(record, {"path": str(path), "root_id": str(identity)})
|
|
43
|
+
if not path.exists() and not path.is_symlink():
|
|
44
|
+
if not create:
|
|
45
|
+
return None
|
|
46
|
+
await settled_io(lambda: path.parent.mkdir(parents=True, exist_ok=True))
|
|
47
|
+
return await OwnedDirectories.initialize(path, root_id=identity)
|
|
48
|
+
directories = await OwnedDirectories.reopen(path)
|
|
49
|
+
if directories.root_id != identity:
|
|
50
|
+
raise ValueError("Snapshot root identity mismatch")
|
|
51
|
+
return directories
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def resource(directories: OwnedDirectories, home: OwnedDirectory) -> OwnedDirectory:
|
|
55
|
+
return OwnedDirectory(directories.root_id, home.session_id, home.resource_id)
|
|
56
|
+
|
|
57
|
+
async def session_path(self, home: OwnedDirectory) -> Path:
|
|
58
|
+
directories = await self.directories(create=True)
|
|
59
|
+
if directories is None:
|
|
60
|
+
return self.homes.path / home.name
|
|
61
|
+
resource = self.resource(directories, home)
|
|
62
|
+
path = directories.path / resource.name
|
|
63
|
+
if path.is_symlink():
|
|
64
|
+
raise ValueError("Snapshot workspace was replaced by a symlink")
|
|
65
|
+
if not path.exists():
|
|
66
|
+
await directories.create(resource)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
async def cleanup(self, home: OwnedDirectory) -> None:
|
|
70
|
+
directories = await self.directories(create=False)
|
|
71
|
+
if directories is not None:
|
|
72
|
+
await directories.delete(self.resource(directories, home))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def materialize(
|
|
76
|
+
reader: GitSourceReader,
|
|
77
|
+
destination: Path,
|
|
78
|
+
*,
|
|
79
|
+
progress: Callable[[str], None] | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""Publish only complete snapshots; an interrupted preparation is reconstructible."""
|
|
82
|
+
temporary = destination.with_name(f".{destination.name}-{uuid4().hex}.tmp")
|
|
83
|
+
await settled_io(lambda: destination.parent.mkdir(mode=0o700, parents=True, exist_ok=True))
|
|
84
|
+
await write_snapshot(reader, temporary, progress=progress)
|
|
85
|
+
await settled_io(lambda: temporary.rename(destination))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def materialize_checkout(
|
|
89
|
+
reader: GitSourceReader,
|
|
90
|
+
destination: Path,
|
|
91
|
+
*,
|
|
92
|
+
progress: Callable[[str], None] | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Create a private head checkout backed by the session-pinned object cache."""
|
|
95
|
+
temporary = destination.with_name(f".{destination.name}-{uuid4().hex}.tmp")
|
|
96
|
+
common = await reader.common_directory()
|
|
97
|
+
if progress is not None:
|
|
98
|
+
progress("Checking out PR sources…")
|
|
99
|
+
await settled_io(lambda: temporary.mkdir(mode=0o700, parents=True))
|
|
100
|
+
await git_fetch_command(
|
|
101
|
+
temporary,
|
|
102
|
+
"init",
|
|
103
|
+
"--template=",
|
|
104
|
+
f"--object-format={'sha256' if len(reader.head_sha) == 64 else 'sha1'}",
|
|
105
|
+
local=True,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def configure() -> None:
|
|
109
|
+
gitdir = temporary / ".git"
|
|
110
|
+
# Quote the alternate path using Git's C-style path syntax.
|
|
111
|
+
(gitdir / "objects/info/alternates").write_text(
|
|
112
|
+
json.dumps(str(common / "objects"), ensure_ascii=False) + "\n"
|
|
113
|
+
)
|
|
114
|
+
with (gitdir / "config").open("a") as config:
|
|
115
|
+
config.write(
|
|
116
|
+
"\n[core]\n\thooksPath = /dev/null\n\tfsmonitor = false\n\tautocrlf = false\n"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
await settled_io(configure)
|
|
120
|
+
await git_fetch_command(
|
|
121
|
+
temporary, "update-ref", "refs/heads/diffbutler-base", reader.base_sha, local=True
|
|
122
|
+
)
|
|
123
|
+
await git_fetch_command(
|
|
124
|
+
temporary,
|
|
125
|
+
"update-ref",
|
|
126
|
+
"refs/heads/diffbutler-merge-base",
|
|
127
|
+
reader.comparison_base_sha,
|
|
128
|
+
local=True,
|
|
129
|
+
)
|
|
130
|
+
await git_fetch_command(
|
|
131
|
+
temporary,
|
|
132
|
+
"checkout",
|
|
133
|
+
"--no-recurse-submodules",
|
|
134
|
+
"-b",
|
|
135
|
+
"diffbutler-pr",
|
|
136
|
+
reader.head_sha,
|
|
137
|
+
local=True,
|
|
138
|
+
)
|
|
139
|
+
await settled_io(lambda: temporary.rename(destination))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure domain entities and workflow rules."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Local workflow and historical cycle values, without rendering or I/O."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from diffbutler.domain.forge import Account, PullRequest
|
|
8
|
+
from diffbutler.domain.review import RunRecord, SessionRecord
|
|
9
|
+
from diffbutler.domain.sync import CommentEvent, ReviewEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CycleRecord:
|
|
14
|
+
task_id: UUID
|
|
15
|
+
account: Account
|
|
16
|
+
pull_request: PullRequest
|
|
17
|
+
cycle: int
|
|
18
|
+
state: str
|
|
19
|
+
completed_at: datetime | None
|
|
20
|
+
completion_reason: str | None
|
|
21
|
+
completed_head_sha: str | None
|
|
22
|
+
cleanup_status: str
|
|
23
|
+
cleanup_error: str | None
|
|
24
|
+
opened_at: datetime | None
|
|
25
|
+
runs: tuple[RunRecord, ...]
|
|
26
|
+
sessions: tuple[SessionRecord, ...]
|
|
27
|
+
receipts: tuple[ReviewEvent | CommentEvent, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
COLUMN_IDS = (
|
|
31
|
+
"state",
|
|
32
|
+
"attention",
|
|
33
|
+
"repo",
|
|
34
|
+
"number",
|
|
35
|
+
"title",
|
|
36
|
+
"author",
|
|
37
|
+
"ai_status",
|
|
38
|
+
"findings",
|
|
39
|
+
"max_severity",
|
|
40
|
+
"age",
|
|
41
|
+
"updated",
|
|
42
|
+
"branch",
|
|
43
|
+
"draft",
|
|
44
|
+
"reviewers",
|
|
45
|
+
"unverified",
|
|
46
|
+
"url",
|
|
47
|
+
"additions",
|
|
48
|
+
"deletions",
|
|
49
|
+
"files",
|
|
50
|
+
"labels",
|
|
51
|
+
"ci",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
STATE_LABELS = {
|
|
55
|
+
"PENDING_AI_REVIEW": "pending",
|
|
56
|
+
"QUEUED": "queued",
|
|
57
|
+
"AI_RUNNING": "running",
|
|
58
|
+
"PENDING_HUMAN_REVIEW": "approve",
|
|
59
|
+
"FINDINGS_TRIAGE": "check",
|
|
60
|
+
"PENDING_AUTHOR_CHANGES": "author",
|
|
61
|
+
"DONE": "done",
|
|
62
|
+
"DONE_PLUS": "done+",
|
|
63
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Forge-neutral anchors and validation outcomes; no source text is retained."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from diffbutler.domain.review import Side, SourceFile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DiffAnchor:
|
|
12
|
+
path: str
|
|
13
|
+
line: int
|
|
14
|
+
side: Side
|
|
15
|
+
start_line: int | None = None
|
|
16
|
+
old_path: str | None = None
|
|
17
|
+
base_sha: str = ""
|
|
18
|
+
head_sha: str = ""
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
SourceFile(self.path, self.side)
|
|
22
|
+
if self.old_path is not None:
|
|
23
|
+
SourceFile(self.old_path, Side.BASE)
|
|
24
|
+
if self.line < 1 or (self.start_line is not None and not 1 <= self.start_line <= self.line):
|
|
25
|
+
raise ValueError("anchor lines must be positive and ordered")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class AnchorValidation:
|
|
30
|
+
original: DiffAnchor
|
|
31
|
+
anchor: DiffAnchor | None
|
|
32
|
+
status: Literal["exact", "snapped", "demoted"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_HUNK = re.compile(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?")
|
|
36
|
+
_BINARY = re.compile(
|
|
37
|
+
r'Binary files ("(?:[^"\\]|\\.)*"|a/.+?|/dev/null)'
|
|
38
|
+
r' and ("(?:[^"\\]|\\.)*"|b/.+|/dev/null) differ'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InvalidDiff(ValueError):
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
super().__init__("Malformed or unsupported unified diff")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _path(value: str, prefix: str) -> str | None:
|
|
48
|
+
if value == "/dev/null":
|
|
49
|
+
return None
|
|
50
|
+
# Git quotes tabs, newlines, non-ASCII bytes and backslashes using C escapes.
|
|
51
|
+
if value.startswith('"'):
|
|
52
|
+
if not value.endswith('"'):
|
|
53
|
+
raise InvalidDiff()
|
|
54
|
+
raw = bytearray()
|
|
55
|
+
text = value[1:-1]
|
|
56
|
+
offset = 0
|
|
57
|
+
escapes = {"a": 7, "b": 8, "t": 9, "n": 10, "v": 11, "f": 12, "r": 13, '"': 34, "\\": 92}
|
|
58
|
+
while offset < len(text):
|
|
59
|
+
char = text[offset]
|
|
60
|
+
offset += 1
|
|
61
|
+
if char != "\\":
|
|
62
|
+
raw.extend(char.encode("utf-8"))
|
|
63
|
+
continue
|
|
64
|
+
if offset == len(text):
|
|
65
|
+
raise InvalidDiff()
|
|
66
|
+
char = text[offset]
|
|
67
|
+
if char in escapes:
|
|
68
|
+
raw.append(escapes[char])
|
|
69
|
+
offset += 1
|
|
70
|
+
elif re.fullmatch(r"[0-3][0-7]{2}", text[offset : offset + 3]):
|
|
71
|
+
raw.append(int(text[offset : offset + 3], 8))
|
|
72
|
+
offset += 3
|
|
73
|
+
else:
|
|
74
|
+
raise InvalidDiff()
|
|
75
|
+
try:
|
|
76
|
+
value = raw.decode("utf-8")
|
|
77
|
+
except UnicodeDecodeError:
|
|
78
|
+
raise InvalidDiff() from None
|
|
79
|
+
else:
|
|
80
|
+
value = value.removesuffix("\t")
|
|
81
|
+
if not value.startswith(prefix):
|
|
82
|
+
raise InvalidDiff()
|
|
83
|
+
path = value[len(prefix) :]
|
|
84
|
+
try:
|
|
85
|
+
SourceFile(path, Side.HEAD)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise InvalidDiff() from None
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _Hunk:
|
|
93
|
+
path: str
|
|
94
|
+
old_path: str | None
|
|
95
|
+
base_start: int
|
|
96
|
+
base_count: int
|
|
97
|
+
head_start: int
|
|
98
|
+
head_count: int
|
|
99
|
+
|
|
100
|
+
def bounds(self, side: Side) -> tuple[int, int]:
|
|
101
|
+
start, count = (
|
|
102
|
+
(self.base_start, self.base_count)
|
|
103
|
+
if side == Side.BASE
|
|
104
|
+
else (self.head_start, self.head_count)
|
|
105
|
+
)
|
|
106
|
+
return start, start + count - 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class DiffIndex:
|
|
111
|
+
"""Index the original diff before redaction. No source text is stored.
|
|
112
|
+
|
|
113
|
+
Paths use the head name, or the base name for a deletion. Context lines are
|
|
114
|
+
commentable on both sides. Ranges must fit a single hunk on one side. Snapping
|
|
115
|
+
clips a range overlapping exactly one hunk, within the endpoint window; it
|
|
116
|
+
never guesses a hunk for a location wholly outside the diff or crosses sides.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
hunks: tuple[_Hunk, ...]
|
|
120
|
+
base_sha: str = ""
|
|
121
|
+
head_sha: str = ""
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def parse(cls, diff: str, *, base_sha: str = "", head_sha: str = "") -> DiffIndex:
|
|
125
|
+
if "\x00" in diff:
|
|
126
|
+
raise InvalidDiff()
|
|
127
|
+
lines = diff.split("\n")
|
|
128
|
+
if lines[-1] == "":
|
|
129
|
+
lines.pop()
|
|
130
|
+
hunks: list[_Hunk] = []
|
|
131
|
+
old: str | None = None
|
|
132
|
+
new: str | None = None
|
|
133
|
+
in_file = headers = has_hunk = False
|
|
134
|
+
seen: set[str] = set()
|
|
135
|
+
offset = 0
|
|
136
|
+
while offset < len(lines):
|
|
137
|
+
line = lines[offset]
|
|
138
|
+
offset += 1
|
|
139
|
+
if line.startswith("diff --git "):
|
|
140
|
+
if headers and not has_hunk:
|
|
141
|
+
raise InvalidDiff()
|
|
142
|
+
in_file, headers, has_hunk = True, False, False
|
|
143
|
+
old = new = None
|
|
144
|
+
elif (
|
|
145
|
+
line.startswith(
|
|
146
|
+
(
|
|
147
|
+
"index ",
|
|
148
|
+
"old mode ",
|
|
149
|
+
"new mode ",
|
|
150
|
+
"new file mode ",
|
|
151
|
+
"deleted file mode ",
|
|
152
|
+
"similarity index ",
|
|
153
|
+
"rename from ",
|
|
154
|
+
"rename to ",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
and in_file
|
|
158
|
+
and not headers
|
|
159
|
+
):
|
|
160
|
+
continue
|
|
161
|
+
elif (binary := _BINARY.fullmatch(line)) is not None and in_file and not headers:
|
|
162
|
+
old = _path(binary[1], "a/")
|
|
163
|
+
new = _path(binary[2], "b/")
|
|
164
|
+
path = new or old
|
|
165
|
+
if path is None or path in seen:
|
|
166
|
+
raise InvalidDiff()
|
|
167
|
+
seen.add(path)
|
|
168
|
+
# A summary has no commentable lines and ends this file.
|
|
169
|
+
in_file = False
|
|
170
|
+
elif line.startswith("--- ") and in_file and not headers:
|
|
171
|
+
if offset >= len(lines) or not lines[offset].startswith("+++ "):
|
|
172
|
+
raise InvalidDiff()
|
|
173
|
+
old = _path(line[4:], "a/")
|
|
174
|
+
new = _path(lines[offset][4:], "b/")
|
|
175
|
+
offset += 1
|
|
176
|
+
if old is None and new is None:
|
|
177
|
+
raise InvalidDiff()
|
|
178
|
+
path = new or old
|
|
179
|
+
assert path is not None
|
|
180
|
+
if path in seen:
|
|
181
|
+
raise InvalidDiff()
|
|
182
|
+
seen.add(path)
|
|
183
|
+
headers = True
|
|
184
|
+
elif (match := _HUNK.fullmatch(line)) is not None and headers:
|
|
185
|
+
bs, bc, hs, hc = (
|
|
186
|
+
int(match[1]),
|
|
187
|
+
int(match[2] or 1),
|
|
188
|
+
int(match[3]),
|
|
189
|
+
int(match[4] or 1),
|
|
190
|
+
)
|
|
191
|
+
if (bc and bs < 1) or (hc and hs < 1) or not bc + hc:
|
|
192
|
+
raise InvalidDiff()
|
|
193
|
+
if (old is None and bc) or (new is None and hc):
|
|
194
|
+
raise InvalidDiff()
|
|
195
|
+
path = new or old
|
|
196
|
+
assert path is not None
|
|
197
|
+
hunk = _Hunk(path, old, bs, bc, hs, hc)
|
|
198
|
+
if hunks and hunks[-1].path == path:
|
|
199
|
+
prev = hunks[-1]
|
|
200
|
+
if (
|
|
201
|
+
bs < prev.base_start + prev.base_count
|
|
202
|
+
or hs < prev.head_start + prev.head_count
|
|
203
|
+
):
|
|
204
|
+
raise InvalidDiff()
|
|
205
|
+
b = h = 0
|
|
206
|
+
marker_allowed = False
|
|
207
|
+
while b < bc or h < hc:
|
|
208
|
+
if offset >= len(lines):
|
|
209
|
+
raise InvalidDiff()
|
|
210
|
+
body = lines[offset]
|
|
211
|
+
offset += 1
|
|
212
|
+
if body == "\" and marker_allowed:
|
|
213
|
+
marker_allowed = False
|
|
214
|
+
continue
|
|
215
|
+
if not body or body[0] not in " +-":
|
|
216
|
+
raise InvalidDiff()
|
|
217
|
+
b += body[0] in " -"
|
|
218
|
+
h += body[0] in " +"
|
|
219
|
+
if b > bc or h > hc:
|
|
220
|
+
raise InvalidDiff()
|
|
221
|
+
marker_allowed = True
|
|
222
|
+
if offset < len(lines) and lines[offset] == "\":
|
|
223
|
+
offset += 1
|
|
224
|
+
hunks.append(hunk)
|
|
225
|
+
has_hunk = True
|
|
226
|
+
else:
|
|
227
|
+
raise InvalidDiff()
|
|
228
|
+
if headers and not has_hunk:
|
|
229
|
+
raise InvalidDiff()
|
|
230
|
+
return cls(tuple(hunks), base_sha, head_sha)
|
|
231
|
+
|
|
232
|
+
def _matching(self, anchor: DiffAnchor) -> tuple[_Hunk, ...]:
|
|
233
|
+
if (anchor.base_sha and anchor.base_sha != self.base_sha) or (
|
|
234
|
+
anchor.head_sha and anchor.head_sha != self.head_sha
|
|
235
|
+
):
|
|
236
|
+
return ()
|
|
237
|
+
return tuple(
|
|
238
|
+
hunk
|
|
239
|
+
for hunk in self.hunks
|
|
240
|
+
if hunk.path == anchor.path
|
|
241
|
+
and (anchor.old_path is None or anchor.old_path == hunk.old_path)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def is_commentable(self, anchor: DiffAnchor) -> bool:
|
|
245
|
+
start = anchor.start_line or anchor.line
|
|
246
|
+
return any(
|
|
247
|
+
low <= start <= anchor.line <= high
|
|
248
|
+
for low, high in (hunk.bounds(anchor.side) for hunk in self._matching(anchor))
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def snap(self, anchor: DiffAnchor, *, window: int = 3) -> DiffAnchor | None:
|
|
252
|
+
if window < 0:
|
|
253
|
+
raise ValueError("snapping window must be nonnegative")
|
|
254
|
+
start = anchor.start_line or anchor.line
|
|
255
|
+
matches = [
|
|
256
|
+
hunk
|
|
257
|
+
for hunk in self._matching(anchor)
|
|
258
|
+
if (bounds := hunk.bounds(anchor.side))[0] <= bounds[1]
|
|
259
|
+
and start <= bounds[1]
|
|
260
|
+
and anchor.line >= bounds[0]
|
|
261
|
+
]
|
|
262
|
+
if len(matches) != 1:
|
|
263
|
+
return None
|
|
264
|
+
hunk = matches[0]
|
|
265
|
+
low, high = hunk.bounds(anchor.side)
|
|
266
|
+
first, last = max(start, low), min(anchor.line, high)
|
|
267
|
+
if first - start > window or anchor.line - last > window:
|
|
268
|
+
return None
|
|
269
|
+
return replace(
|
|
270
|
+
anchor,
|
|
271
|
+
line=last,
|
|
272
|
+
start_line=first if anchor.start_line else None,
|
|
273
|
+
old_path=hunk.old_path,
|
|
274
|
+
base_sha=self.base_sha,
|
|
275
|
+
head_sha=self.head_sha,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
def validate(self, anchor: DiffAnchor, *, window: int = 3) -> AnchorValidation:
|
|
279
|
+
snapped = self.snap(anchor, window=window)
|
|
280
|
+
status = (
|
|
281
|
+
"demoted"
|
|
282
|
+
if snapped is None
|
|
283
|
+
else ("exact" if self.is_commentable(anchor) else "snapped")
|
|
284
|
+
)
|
|
285
|
+
return AnchorValidation(anchor, snapped, status)
|