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,200 @@
|
|
|
1
|
+
"""Shared local and configured-forge diagnostics; never launches AI reviewers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, Protocol
|
|
10
|
+
|
|
11
|
+
from sqlalchemy.exc import SQLAlchemyError
|
|
12
|
+
|
|
13
|
+
from diffbutler.config.settings import ConfigError, Forge, load_settings
|
|
14
|
+
from diffbutler.forges.base import ForgeError, ReadForge
|
|
15
|
+
from diffbutler.forges.registry import open_forge
|
|
16
|
+
from diffbutler.store.database import Database, DatabaseError, DatabaseStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiagnosticStore(Protocol):
|
|
20
|
+
async def initialize(self, *, auto_migrate: bool = True) -> DatabaseStatus: ...
|
|
21
|
+
async def close(self) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Check:
|
|
26
|
+
name: str
|
|
27
|
+
status: Literal["PASS", "FAIL", "SKIP", "WARN"]
|
|
28
|
+
detail: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class DoctorReport:
|
|
33
|
+
checks: tuple[Check, ...]
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def passed(self) -> bool:
|
|
37
|
+
return all(check.status != "FAIL" for check in self.checks)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def run_doctor(
|
|
41
|
+
path: Path | None = None,
|
|
42
|
+
*,
|
|
43
|
+
repo: str | None = None,
|
|
44
|
+
overrides: dict[str, Any] | None = None,
|
|
45
|
+
store_factory: Callable[[str], DiagnosticStore] = Database,
|
|
46
|
+
forge_factory: Callable[[Forge], Awaitable[ReadForge]] = open_forge,
|
|
47
|
+
offline: bool = False,
|
|
48
|
+
) -> DoctorReport:
|
|
49
|
+
try:
|
|
50
|
+
settings = await asyncio.to_thread(load_settings, path, repo=repo, overrides=overrides)
|
|
51
|
+
except ConfigError as exc:
|
|
52
|
+
return DoctorReport((Check("config", "FAIL", str(exc)),))
|
|
53
|
+
checks = [Check("config", "PASS", "Configuration loaded and validated")]
|
|
54
|
+
store: DiagnosticStore | None = None
|
|
55
|
+
try:
|
|
56
|
+
store = store_factory(settings.db.url)
|
|
57
|
+
status = await store.initialize(auto_migrate=settings.db.auto_migrate)
|
|
58
|
+
detail = f"Schema {status.revision} current"
|
|
59
|
+
if status.migrated:
|
|
60
|
+
detail += "; migrations applied"
|
|
61
|
+
if status.backup is not None:
|
|
62
|
+
detail += "; pre-migration backup saved beside database"
|
|
63
|
+
checks.append(Check("database", "PASS", detail))
|
|
64
|
+
except DatabaseError as exc:
|
|
65
|
+
checks.append(Check("database", "FAIL", str(exc)))
|
|
66
|
+
except OSError, SQLAlchemyError:
|
|
67
|
+
checks.append(
|
|
68
|
+
Check(
|
|
69
|
+
"database",
|
|
70
|
+
"FAIL",
|
|
71
|
+
"Cannot initialize database; check file permissions, free space, "
|
|
72
|
+
"locks, and SQLite file integrity",
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
finally:
|
|
76
|
+
if store is not None:
|
|
77
|
+
await store.close()
|
|
78
|
+
|
|
79
|
+
for name, repository in settings.repos.items():
|
|
80
|
+
if repository.path is not None:
|
|
81
|
+
exists = await asyncio.to_thread(repository.path.is_dir)
|
|
82
|
+
checks.append(
|
|
83
|
+
Check(
|
|
84
|
+
f"repo:{name}",
|
|
85
|
+
"PASS" if exists else "FAIL",
|
|
86
|
+
"Repository directory exists"
|
|
87
|
+
if exists
|
|
88
|
+
else "Configured repository directory is missing or inaccessible",
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
if repository.prompt is not None:
|
|
92
|
+
exists = await asyncio.to_thread(repository.prompt.is_file)
|
|
93
|
+
checks.append(
|
|
94
|
+
Check(
|
|
95
|
+
f"prompt:{name}",
|
|
96
|
+
"PASS" if exists else "FAIL",
|
|
97
|
+
"Prompt file exists"
|
|
98
|
+
if exists
|
|
99
|
+
else "Configured prompt file is missing or inaccessible",
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
if settings.review.prompt is not None:
|
|
103
|
+
exists = await asyncio.to_thread(settings.review.prompt.is_file)
|
|
104
|
+
checks.append(
|
|
105
|
+
Check(
|
|
106
|
+
"prompt",
|
|
107
|
+
"PASS" if exists else "FAIL",
|
|
108
|
+
"Prompt file exists"
|
|
109
|
+
if exists
|
|
110
|
+
else "Configured prompt file is missing or inaccessible",
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
opener = "open" if sys.platform == "darwin" else "xdg-open"
|
|
115
|
+
resolved = await asyncio.to_thread(shutil.which, opener)
|
|
116
|
+
checks.append(
|
|
117
|
+
Check(
|
|
118
|
+
"browser",
|
|
119
|
+
"PASS" if resolved else "WARN",
|
|
120
|
+
f"{opener} resolves (launch not tested)"
|
|
121
|
+
if resolved
|
|
122
|
+
else f"{opener} is not on PATH; browser launch unavailable",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
if not settings.forges or offline:
|
|
126
|
+
checks.append(
|
|
127
|
+
Check(
|
|
128
|
+
"forges",
|
|
129
|
+
"SKIP",
|
|
130
|
+
"Offline mode; forge checks skipped" if offline else "No forge configured",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
for index, configured in enumerate(settings.forges, start=1):
|
|
135
|
+
name = f"forge:{index}:{configured.kind}"
|
|
136
|
+
forge: ReadForge | None = None
|
|
137
|
+
if configured.kind != "github":
|
|
138
|
+
checks.append(Check(name, "SKIP", "GitLab adapter is not implemented"))
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
forge = await forge_factory(configured)
|
|
142
|
+
health = await forge.health()
|
|
143
|
+
matches = (
|
|
144
|
+
configured.login is None
|
|
145
|
+
or configured.login.lower() == health.account.login.lower()
|
|
146
|
+
)
|
|
147
|
+
checks.append(
|
|
148
|
+
Check(
|
|
149
|
+
name,
|
|
150
|
+
"PASS" if matches else "FAIL",
|
|
151
|
+
(
|
|
152
|
+
f"Authenticated as {health.account.login}"
|
|
153
|
+
if configured.token_env is not None
|
|
154
|
+
else f"Public access for review requests to {health.account.login}"
|
|
155
|
+
)
|
|
156
|
+
if matches
|
|
157
|
+
else "Authenticated login does not match configured login",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
if not health.limits:
|
|
161
|
+
checks.append(Check(f"{name}:quota", "WARN", "Rate-limit headers unavailable"))
|
|
162
|
+
for limit in health.limits:
|
|
163
|
+
checks.append(
|
|
164
|
+
Check(
|
|
165
|
+
f"{name}:{limit.resource}",
|
|
166
|
+
"PASS" if limit.remaining > 0 else "WARN",
|
|
167
|
+
f"{limit.remaining}/{limit.limit} requests remaining",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
if health.teams is not None:
|
|
171
|
+
checks.append(
|
|
172
|
+
Check(
|
|
173
|
+
f"{name}:teams",
|
|
174
|
+
"PASS" if health.teams.complete else "WARN",
|
|
175
|
+
f"{len(health.teams.teams)} teams visible"
|
|
176
|
+
+ (
|
|
177
|
+
"; " + "; ".join(health.teams.warnings)
|
|
178
|
+
if health.teams.warnings
|
|
179
|
+
else ""
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
except ForgeError as exc:
|
|
184
|
+
detail = str(exc)
|
|
185
|
+
if exc.retry_after is not None:
|
|
186
|
+
detail += f"; retry in {exc.retry_after:.0f}s"
|
|
187
|
+
checks.append(Check(name, "FAIL", detail))
|
|
188
|
+
finally:
|
|
189
|
+
if forge is not None:
|
|
190
|
+
await forge.close()
|
|
191
|
+
checks.append(
|
|
192
|
+
Check(
|
|
193
|
+
"reviewers",
|
|
194
|
+
"SKIP",
|
|
195
|
+
"Reviewer execution, parsing, resume, and cleanup are not checked; no agent launched"
|
|
196
|
+
if settings.reviewers
|
|
197
|
+
else "No reviewer configured",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return DoctorReport(tuple(checks))
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""UI-independent foreground ownership and automatic completed-task cleanup."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from diffbutler.domain.lifecycle import CleanupSession, ForegroundLease, HeldOperation
|
|
8
|
+
from diffbutler.store.base import LifecycleStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionCleaner(Protocol):
|
|
12
|
+
async def cleanup(self, session: CleanupSession) -> None:
|
|
13
|
+
"""Idempotently delete only session-owned data, raising if unsupported.
|
|
14
|
+
|
|
15
|
+
Return only after all deletion operations have settled. A partial failure
|
|
16
|
+
may be retried with the same manifest. Never delete the repository home or
|
|
17
|
+
infer a backend filesystem path from its opaque conversation ID.
|
|
18
|
+
"""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LifecycleService:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
store: LifecycleStore,
|
|
26
|
+
cleaner: SessionCleaner,
|
|
27
|
+
*,
|
|
28
|
+
prepare_cleanup: Callable[[UUID], Awaitable[None]] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.store = store
|
|
31
|
+
self.cleaner = cleaner
|
|
32
|
+
self.prepare_cleanup = prepare_cleanup
|
|
33
|
+
|
|
34
|
+
async def held_operations(self) -> tuple[HeldOperation, ...]:
|
|
35
|
+
return await self.store.held_operations()
|
|
36
|
+
|
|
37
|
+
async def acknowledge_cleanup_exit(self, token: UUID) -> None:
|
|
38
|
+
"""After verified orphan operation exit, schedule an idempotent retry."""
|
|
39
|
+
await self.store.finish_cleanup(token, succeeded=False)
|
|
40
|
+
|
|
41
|
+
async def acquire_foreground(self, session_id: UUID) -> ForegroundLease:
|
|
42
|
+
"""Explicit human resume action; no launch, re-review, or posting authorization."""
|
|
43
|
+
return await self.store.acquire_foreground(session_id)
|
|
44
|
+
|
|
45
|
+
async def release_foreground(self, token: UUID) -> None:
|
|
46
|
+
"""Only after verified process-tree exit or an established launch failure."""
|
|
47
|
+
await self.store.release_foreground(token)
|
|
48
|
+
|
|
49
|
+
async def cleanup_once(self) -> bool:
|
|
50
|
+
"""Consume one durable completion job without another user action.
|
|
51
|
+
|
|
52
|
+
Ordinary failures schedule a retry; cancellation deliberately leaves the
|
|
53
|
+
claim held until the supervisor establishes operation exit. A new service
|
|
54
|
+
cannot steal an orphan claim based only on age. No scheduler is installed
|
|
55
|
+
by constructing this service.
|
|
56
|
+
"""
|
|
57
|
+
claim = await self.store.claim_cleanup()
|
|
58
|
+
if claim is None:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
if self.prepare_cleanup is not None:
|
|
62
|
+
await self.prepare_cleanup(claim.token)
|
|
63
|
+
for session in claim.sessions:
|
|
64
|
+
await self.cleaner.cleanup(session)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Persist only a fixed diagnostic; backend errors may contain secrets.
|
|
67
|
+
await self.store.finish_cleanup(claim.token, succeeded=False)
|
|
68
|
+
else:
|
|
69
|
+
await self.store.finish_cleanup(claim.token, succeeded=True)
|
|
70
|
+
return True
|