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,441 @@
|
|
|
1
|
+
"""Configured review workflow shared by CLI and dashboard."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
7
|
+
from contextlib import asynccontextmanager, suppress
|
|
8
|
+
from dataclasses import replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
from uuid import UUID, uuid5
|
|
12
|
+
|
|
13
|
+
from diffbutler.config.settings import ConfigError, Repository, Settings, load_settings
|
|
14
|
+
from diffbutler.config.transport import save_git_transport
|
|
15
|
+
from diffbutler.core.application import local_store
|
|
16
|
+
from diffbutler.core.context import assemble_context
|
|
17
|
+
from diffbutler.core.lifecycle import LifecycleService
|
|
18
|
+
from diffbutler.core.native import (
|
|
19
|
+
NativeExecution,
|
|
20
|
+
NativeSessionCleaner,
|
|
21
|
+
SessionLaunch,
|
|
22
|
+
ensure_session_directory,
|
|
23
|
+
prepare_native_handoff,
|
|
24
|
+
)
|
|
25
|
+
from diffbutler.core.process import ProcessRecovery
|
|
26
|
+
from diffbutler.core.repositories import RepositoryCache
|
|
27
|
+
from diffbutler.core.resources import save_json
|
|
28
|
+
from diffbutler.core.review import AIRun, ReviewResult, ReviewService
|
|
29
|
+
from diffbutler.core.sync import PullRequestRow
|
|
30
|
+
from diffbutler.core.worker import ReviewWorker
|
|
31
|
+
from diffbutler.core.workspaces import SnapshotWorkspaces
|
|
32
|
+
from diffbutler.domain.lifecycle import CleanupStatus, ForegroundExitUncertain
|
|
33
|
+
from diffbutler.domain.review import (
|
|
34
|
+
AnalysisIdentity,
|
|
35
|
+
ReviewConflict,
|
|
36
|
+
ReviewPolicy,
|
|
37
|
+
RunClaim,
|
|
38
|
+
RunRecord,
|
|
39
|
+
)
|
|
40
|
+
from diffbutler.reviewers.base import ReviewBudget, ReviewContext
|
|
41
|
+
from diffbutler.reviewers.native import native_preset
|
|
42
|
+
from diffbutler.reviewers.process import LocalHostBoot
|
|
43
|
+
from diffbutler.reviewers.prompt import PROMPT_VERSION
|
|
44
|
+
from diffbutler.reviewers.resources import OwnedDirectories, settled_io
|
|
45
|
+
from diffbutler.sources.git import GitSourceReader
|
|
46
|
+
from diffbutler.store.dashboard import SQLiteDashboardStore
|
|
47
|
+
from diffbutler.store.database import Database
|
|
48
|
+
from diffbutler.store.lifecycle import SQLiteLifecycleStore
|
|
49
|
+
from diffbutler.store.process import SQLiteProcessStore
|
|
50
|
+
from diffbutler.store.queue import SQLiteQueueStore
|
|
51
|
+
from diffbutler.store.review import SQLiteReviewStore
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def resolve_local(store: SQLiteQueueStore, value: str, login: str | None) -> PullRequestRow:
|
|
55
|
+
matches: list[PullRequestRow] = []
|
|
56
|
+
for account in await store.accounts():
|
|
57
|
+
if login is not None and account.login != login:
|
|
58
|
+
continue
|
|
59
|
+
for tracked in (await store.snapshot(account)).items:
|
|
60
|
+
pr = tracked.pull_request
|
|
61
|
+
if value in {pr.url, f"{pr.ref.namespace}/{pr.ref.repository}#{pr.ref.number}"}:
|
|
62
|
+
matches.append(PullRequestRow(account=account, tracked=tracked))
|
|
63
|
+
if len(matches) != 1:
|
|
64
|
+
raise ReviewConflict(
|
|
65
|
+
"PR is missing or ambiguous in the local queue; sync/follow or select --login"
|
|
66
|
+
)
|
|
67
|
+
return matches[0]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def owned_directories(settings: Settings) -> OwnedDirectories:
|
|
71
|
+
path = settings.general.workspace_root
|
|
72
|
+
if path.exists():
|
|
73
|
+
return await OwnedDirectories.reopen(path)
|
|
74
|
+
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
|
75
|
+
try:
|
|
76
|
+
return await OwnedDirectories.initialize(path)
|
|
77
|
+
except FileExistsError:
|
|
78
|
+
return await OwnedDirectories.reopen(path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ManagedReviews:
|
|
82
|
+
def __init__(
|
|
83
|
+
self, database: Database, settings: Settings, directories: OwnedDirectories
|
|
84
|
+
) -> None:
|
|
85
|
+
self.settings = settings
|
|
86
|
+
self.directories = directories
|
|
87
|
+
roots = [settings.general.workspace_root, settings.general.repository_root]
|
|
88
|
+
if settings.general.snapshot_root is not None:
|
|
89
|
+
roots.append(settings.general.snapshot_root)
|
|
90
|
+
for index, root in enumerate(roots):
|
|
91
|
+
for other in roots[index + 1 :]:
|
|
92
|
+
left, right = root.resolve(), other.resolve()
|
|
93
|
+
if left.is_relative_to(right) or right.is_relative_to(left):
|
|
94
|
+
raise ConfigError(
|
|
95
|
+
"Managed home, repository, and snapshot roots must be separate"
|
|
96
|
+
)
|
|
97
|
+
self.repositories = RepositoryCache(
|
|
98
|
+
settings.general.repository_root, settings.general.repository_retention_days
|
|
99
|
+
)
|
|
100
|
+
self.workspaces = SnapshotWorkspaces(directories, settings.general.snapshot_root)
|
|
101
|
+
self.store = SQLiteReviewStore(database)
|
|
102
|
+
self.leases = SQLiteLifecycleStore(database)
|
|
103
|
+
self.processes = SQLiteProcessStore(database)
|
|
104
|
+
self.recovery = ProcessRecovery(self.processes, self.store, self.leases, LocalHostBoot())
|
|
105
|
+
self.reviews = ReviewService(
|
|
106
|
+
self.store,
|
|
107
|
+
ReviewPolicy(
|
|
108
|
+
frozenset(item.id for item in settings.reviewers),
|
|
109
|
+
frozenset(key for key, repo in settings.repos.items() if not repo.enabled),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
self.lifecycle = LifecycleService(
|
|
113
|
+
self.leases,
|
|
114
|
+
NativeSessionCleaner(directories, self.repositories, self.workspaces),
|
|
115
|
+
prepare_cleanup=self._prepare_cleanup,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def _prepare_cleanup(self, token: UUID) -> None:
|
|
119
|
+
await self.recovery.prepare_launch(token, "cleanup")
|
|
120
|
+
|
|
121
|
+
async def cleanup(self) -> CleanupStatus:
|
|
122
|
+
await self.recovery.recover_rebooted()
|
|
123
|
+
while await self.lifecycle.cleanup_once():
|
|
124
|
+
pass
|
|
125
|
+
await self.repositories.prune()
|
|
126
|
+
return await self.leases.cleanup_status()
|
|
127
|
+
|
|
128
|
+
async def reset_task(
|
|
129
|
+
self, task_id: UUID, stop_review: Callable[[], Awaitable[None]]
|
|
130
|
+
) -> CleanupStatus:
|
|
131
|
+
"""Invalidate before stopping the local supervisor, then clean up after exit."""
|
|
132
|
+
await SQLiteDashboardStore(self.store.database).reset_task(task_id)
|
|
133
|
+
await stop_review()
|
|
134
|
+
return await self.cleanup()
|
|
135
|
+
|
|
136
|
+
def reviewer_command(self, reviewer_id: str | None) -> tuple[str, ...]:
|
|
137
|
+
selected = next((item for item in self.settings.reviewers if item.id == reviewer_id), None)
|
|
138
|
+
if selected is None or selected.preset not in {"codex", "claude"}:
|
|
139
|
+
raise ConfigError(
|
|
140
|
+
"Native execution requires a reviewer with preset = 'codex' or 'claude'"
|
|
141
|
+
)
|
|
142
|
+
return tuple(selected.command)
|
|
143
|
+
|
|
144
|
+
def reviewer_preset(self, reviewer_id: str | None) -> Literal["codex", "claude"]:
|
|
145
|
+
self.reviewer_command(reviewer_id)
|
|
146
|
+
selected = next(item for item in self.settings.reviewers if item.id == reviewer_id)
|
|
147
|
+
assert selected.preset is not None
|
|
148
|
+
return selected.preset
|
|
149
|
+
|
|
150
|
+
async def review(
|
|
151
|
+
self,
|
|
152
|
+
row: PullRequestRow,
|
|
153
|
+
reviewer_id: str | None = None,
|
|
154
|
+
progress: Callable[[str], None] | None = None,
|
|
155
|
+
) -> AIRun:
|
|
156
|
+
task_id = row.tracked.task_id
|
|
157
|
+
if task_id is None:
|
|
158
|
+
raise ReviewConflict("PR has no persisted review task")
|
|
159
|
+
pr = row.tracked.pull_request
|
|
160
|
+
repository_name = f"{pr.ref.namespace}/{pr.ref.repository}"
|
|
161
|
+
repository = self.settings.repos.get(repository_name)
|
|
162
|
+
reviewer_id = reviewer_id or self.settings.review.default_reviewer
|
|
163
|
+
command = self.reviewer_command(reviewer_id)
|
|
164
|
+
selected = next(item for item in self.settings.reviewers if item.id == reviewer_id)
|
|
165
|
+
instructions = "Review the pinned revisions; do not execute project code."
|
|
166
|
+
if self.settings.review.prompt is not None:
|
|
167
|
+
instructions += "\n" + await asyncio.to_thread(self.settings.review.prompt.read_text)
|
|
168
|
+
digest = hashlib.sha256(
|
|
169
|
+
(
|
|
170
|
+
selected.model_dump_json() + self.settings.review.model_dump_json() + instructions
|
|
171
|
+
).encode()
|
|
172
|
+
).hexdigest()
|
|
173
|
+
identity = AnalysisIdentity(task_id, pr.head_sha, selected.id, PROMPT_VERSION, digest)
|
|
174
|
+
run = await self.reviews.request_review(identity)
|
|
175
|
+
repository_path = repository.path if repository else None
|
|
176
|
+
sources: GitSourceReader | None = None
|
|
177
|
+
|
|
178
|
+
def report(message: str) -> None:
|
|
179
|
+
if progress is not None:
|
|
180
|
+
progress(message)
|
|
181
|
+
|
|
182
|
+
report("Review queued")
|
|
183
|
+
|
|
184
|
+
async def before_prepare(claim: RunClaim) -> None:
|
|
185
|
+
nonlocal repository_path, sources
|
|
186
|
+
# Fail before fetching or snapshotting if safe launch tracking is unavailable.
|
|
187
|
+
await self.recovery.host.current()
|
|
188
|
+
report("Preparing repository…")
|
|
189
|
+
root = await ensure_session_directory(
|
|
190
|
+
self.store, self.directories, claim, self.reviewer_preset(reviewer_id)
|
|
191
|
+
)
|
|
192
|
+
report("Preparing shared PR commits…")
|
|
193
|
+
preferred = repository.git_transport if repository else "auto"
|
|
194
|
+
repository_path, transport = await self.repositories.prepare(
|
|
195
|
+
pr.ref,
|
|
196
|
+
claim.run.session_id,
|
|
197
|
+
claim.run.id,
|
|
198
|
+
claim.run.base_sha,
|
|
199
|
+
claim.run.head_sha,
|
|
200
|
+
local_path=repository_path,
|
|
201
|
+
transport=preferred,
|
|
202
|
+
)
|
|
203
|
+
await save_json(
|
|
204
|
+
root / f"source-{claim.run.id}.json",
|
|
205
|
+
{
|
|
206
|
+
"repository": str(repository_path),
|
|
207
|
+
"format": "git",
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
if transport is None:
|
|
211
|
+
return
|
|
212
|
+
self.settings.repos.setdefault(repository_name, Repository()).git_transport = transport
|
|
213
|
+
config_file = self.settings.config_file
|
|
214
|
+
if config_file is not None:
|
|
215
|
+
try:
|
|
216
|
+
await settled_io(
|
|
217
|
+
lambda: save_git_transport(
|
|
218
|
+
config_file,
|
|
219
|
+
repository_name,
|
|
220
|
+
transport,
|
|
221
|
+
expected=preferred,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
except ConfigError:
|
|
225
|
+
logging.getLogger(__name__).warning(
|
|
226
|
+
"Git fetch succeeded, but its transport could not be saved"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
async def prepare(record: RunRecord) -> ReviewContext:
|
|
230
|
+
nonlocal sources
|
|
231
|
+
assert repository_path is not None
|
|
232
|
+
if sources is None:
|
|
233
|
+
sources = await GitSourceReader.open(
|
|
234
|
+
repository_path, base_sha=record.base_sha, head_sha=record.head_sha
|
|
235
|
+
)
|
|
236
|
+
report("Preparing AI review…")
|
|
237
|
+
return await assemble_context(
|
|
238
|
+
run=record,
|
|
239
|
+
pr=replace(pr, base_sha=record.base_sha, head_sha=record.head_sha),
|
|
240
|
+
sources=sources,
|
|
241
|
+
instructions=instructions,
|
|
242
|
+
schema_json="{}",
|
|
243
|
+
budget=ReviewBudget(timeout_s=selected.timeout_s),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
def execution(claim: RunClaim) -> NativeExecution:
|
|
247
|
+
assert sources is not None
|
|
248
|
+
return NativeExecution(
|
|
249
|
+
claim,
|
|
250
|
+
self.store,
|
|
251
|
+
self.directories,
|
|
252
|
+
sources,
|
|
253
|
+
command,
|
|
254
|
+
self.recovery,
|
|
255
|
+
self.processes,
|
|
256
|
+
lambda: run_reviews.claim_is_current(claim.token),
|
|
257
|
+
self.reviewer_preset(reviewer_id),
|
|
258
|
+
self.workspaces,
|
|
259
|
+
progress=report,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
comparisons: dict[tuple[UUID, str], bool] = {}
|
|
263
|
+
comparison_lock = asyncio.Lock()
|
|
264
|
+
|
|
265
|
+
async def compare_base(record: RunRecord, base: str) -> bool:
|
|
266
|
+
# Fetch only when the observed target changes, outside store transactions.
|
|
267
|
+
# Separate pins preserve the original run's source for history and resume.
|
|
268
|
+
async with comparison_lock:
|
|
269
|
+
key = (record.id, base)
|
|
270
|
+
if key not in comparisons:
|
|
271
|
+
assert sources is not None
|
|
272
|
+
path, _ = await self.repositories.prepare(
|
|
273
|
+
pr.ref,
|
|
274
|
+
record.session_id,
|
|
275
|
+
uuid5(record.id, base),
|
|
276
|
+
base,
|
|
277
|
+
record.head_sha,
|
|
278
|
+
local_path=repository.path if repository else None,
|
|
279
|
+
transport=repository.git_transport if repository else "auto",
|
|
280
|
+
)
|
|
281
|
+
updated = await GitSourceReader.open(
|
|
282
|
+
path, base_sha=base, head_sha=record.head_sha
|
|
283
|
+
)
|
|
284
|
+
comparisons[key] = updated.comparison_base_sha == sources.comparison_base_sha
|
|
285
|
+
return comparisons[key]
|
|
286
|
+
|
|
287
|
+
run_reviews = ReviewService(self.store, self.reviews.policy, compare_base)
|
|
288
|
+
worker = ReviewWorker(
|
|
289
|
+
run_reviews,
|
|
290
|
+
prepare,
|
|
291
|
+
execution,
|
|
292
|
+
before_prepare=before_prepare,
|
|
293
|
+
output_format=native_preset(self.reviewer_preset(reviewer_id)).output_format,
|
|
294
|
+
response_format="markdown",
|
|
295
|
+
min_confidence=self.settings.review.min_confidence,
|
|
296
|
+
findings_threshold=self.settings.review.findings_threshold,
|
|
297
|
+
)
|
|
298
|
+
try:
|
|
299
|
+
while True:
|
|
300
|
+
preferences = await SQLiteDashboardStore(self.store.database).preferences()
|
|
301
|
+
result = await worker.execute_requested(
|
|
302
|
+
run.id,
|
|
303
|
+
capacity=self.settings.general.max_concurrent_reviews,
|
|
304
|
+
paused=preferences.get("reviews_paused") == "true",
|
|
305
|
+
)
|
|
306
|
+
if result is not None:
|
|
307
|
+
await self.cleanup()
|
|
308
|
+
return result
|
|
309
|
+
current = await self.reviews.get_run(run.id)
|
|
310
|
+
if current.status != "queued":
|
|
311
|
+
return current
|
|
312
|
+
await asyncio.sleep(0.25)
|
|
313
|
+
except asyncio.CancelledError:
|
|
314
|
+
await self.reviews.cancel_review(run.id)
|
|
315
|
+
raise
|
|
316
|
+
|
|
317
|
+
async def findings(self, row: PullRequestRow) -> ReviewResult | None:
|
|
318
|
+
if row.tracked.task_id is None:
|
|
319
|
+
return None
|
|
320
|
+
run = await self.store.latest_run(row.tracked.task_id)
|
|
321
|
+
return None if run is None else await self.reviews.get_result(run.id)
|
|
322
|
+
|
|
323
|
+
async def prepare_handoff(self, row: PullRequestRow) -> SessionLaunch:
|
|
324
|
+
if (
|
|
325
|
+
row.tracked.task_id is None
|
|
326
|
+
or (run := await self.store.latest_run(row.tracked.task_id)) is None
|
|
327
|
+
):
|
|
328
|
+
raise ReviewConflict("PR has no saved native review session")
|
|
329
|
+
return await prepare_native_handoff(
|
|
330
|
+
self.store,
|
|
331
|
+
self.leases,
|
|
332
|
+
self.directories,
|
|
333
|
+
run.session_id,
|
|
334
|
+
self.reviewer_command(run.reviewer_id),
|
|
335
|
+
self.reviewer_preset(run.reviewer_id),
|
|
336
|
+
pr_number=row.tracked.pull_request.ref.number,
|
|
337
|
+
workspaces=self.workspaces,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def reviewer_choices_configured(
|
|
342
|
+
path: Path | None, repository: str | None = None
|
|
343
|
+
) -> tuple[tuple[str, ...], str | None]:
|
|
344
|
+
"""Return native reviewers in config order and the global or repository default."""
|
|
345
|
+
settings = await asyncio.to_thread(load_settings, path, repo=repository)
|
|
346
|
+
return (
|
|
347
|
+
tuple(item.id for item in settings.reviewers if item.preset in {"codex", "claude"}),
|
|
348
|
+
settings.review.default_reviewer,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
async def review_configured(
|
|
353
|
+
path: Path | None,
|
|
354
|
+
value: str,
|
|
355
|
+
login: str | None,
|
|
356
|
+
reviewer: str | None,
|
|
357
|
+
progress: Callable[[str], None] | None = None,
|
|
358
|
+
) -> AIRun:
|
|
359
|
+
async with local_store(path) as (queue, _settings):
|
|
360
|
+
row = await resolve_local(queue, value, login)
|
|
361
|
+
ref = row.tracked.pull_request.ref
|
|
362
|
+
settings = await asyncio.to_thread(
|
|
363
|
+
load_settings, path, repo=f"{ref.namespace}/{ref.repository}"
|
|
364
|
+
)
|
|
365
|
+
managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
|
|
366
|
+
await managed.cleanup()
|
|
367
|
+
return await managed.review(row, reviewer, progress)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
async def findings_configured(
|
|
371
|
+
path: Path | None, value: str, login: str | None
|
|
372
|
+
) -> ReviewResult | None:
|
|
373
|
+
async with local_store(path) as (queue, _settings):
|
|
374
|
+
row = await resolve_local(queue, value, login)
|
|
375
|
+
# Local history reads do not create filesystem workspaces or invoke native tools.
|
|
376
|
+
store = SQLiteReviewStore(queue.database)
|
|
377
|
+
if (
|
|
378
|
+
row.tracked.task_id is None
|
|
379
|
+
or (run := await store.latest_run(row.tracked.task_id)) is None
|
|
380
|
+
):
|
|
381
|
+
return None
|
|
382
|
+
return await ReviewService(store, ReviewPolicy(frozenset())).get_result(run.id)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def cleanup_configured(path: Path | None) -> CleanupStatus:
|
|
386
|
+
async with local_store(path) as (queue, settings):
|
|
387
|
+
managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
|
|
388
|
+
return await managed.cleanup()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
async def approved_configured(path: Path | None, value: str, login: str | None) -> CleanupStatus:
|
|
392
|
+
async with local_store(path) as (queue, settings):
|
|
393
|
+
row = await resolve_local(queue, value, login)
|
|
394
|
+
if row.tracked.task_id is None:
|
|
395
|
+
raise ReviewConflict("PR has no persisted review task")
|
|
396
|
+
managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
|
|
397
|
+
await managed.store.mark_approved(row.tracked.task_id)
|
|
398
|
+
return await managed.cleanup()
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def reset_task_configured(
|
|
402
|
+
path: Path | None, task_id: UUID, stop_review: Callable[[], Awaitable[None]]
|
|
403
|
+
) -> CleanupStatus:
|
|
404
|
+
async with local_store(path) as (queue, settings):
|
|
405
|
+
managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
|
|
406
|
+
return await managed.reset_task(task_id, stop_review)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@asynccontextmanager
|
|
410
|
+
async def handoff_configured(
|
|
411
|
+
path: Path | None, value: str, login: str | None
|
|
412
|
+
) -> AsyncGenerator[tuple[SessionLaunch, Callable[[int], Awaitable[None]]]]:
|
|
413
|
+
"""Own the session lifetime; the UI attaches and verifies foreground exit."""
|
|
414
|
+
async with local_store(path) as (queue, settings):
|
|
415
|
+
row = await resolve_local(queue, value, login)
|
|
416
|
+
managed = ManagedReviews(queue.database, settings, await owned_directories(settings))
|
|
417
|
+
await managed.cleanup()
|
|
418
|
+
launch = await managed.prepare_handoff(row)
|
|
419
|
+
exited = True
|
|
420
|
+
try:
|
|
421
|
+
assert row.tracked.task_id is not None
|
|
422
|
+
await SQLiteDashboardStore(queue.database).refresh_pending(row.tracked.task_id)
|
|
423
|
+
await managed.recovery.prepare_launch(launch.token, "foreground")
|
|
424
|
+
|
|
425
|
+
async def started(pid: int) -> None:
|
|
426
|
+
await managed.processes.bind_launcher(launch.token, pid)
|
|
427
|
+
|
|
428
|
+
yield launch, started
|
|
429
|
+
except ForegroundExitUncertain:
|
|
430
|
+
exited = False
|
|
431
|
+
raise
|
|
432
|
+
finally:
|
|
433
|
+
if exited:
|
|
434
|
+
await managed.lifecycle.release_foreground(launch.token)
|
|
435
|
+
from diffbutler.core.dashboard import refresh_configured
|
|
436
|
+
from diffbutler.forges.base import ForgeError
|
|
437
|
+
|
|
438
|
+
# Durable refresh_pending remains visible and polling retries reads only.
|
|
439
|
+
with suppress(ForgeError, ConfigError):
|
|
440
|
+
await refresh_configured(path, value, login)
|
|
441
|
+
await managed.cleanup()
|