diffbutler 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. diffbutler/__init__.py +1 -0
  2. diffbutler/__main__.py +6 -0
  3. diffbutler/config/__init__.py +1 -0
  4. diffbutler/config/default.toml +91 -0
  5. diffbutler/config/diagnostics.py +90 -0
  6. diffbutler/config/file.py +30 -0
  7. diffbutler/config/instance.py +55 -0
  8. diffbutler/config/settings.py +297 -0
  9. diffbutler/config/setup.py +92 -0
  10. diffbutler/config/transport.py +65 -0
  11. diffbutler/core/__init__.py +1 -0
  12. diffbutler/core/application.py +103 -0
  13. diffbutler/core/context.py +54 -0
  14. diffbutler/core/dashboard.py +393 -0
  15. diffbutler/core/doctor.py +200 -0
  16. diffbutler/core/lifecycle.py +70 -0
  17. diffbutler/core/managed.py +441 -0
  18. diffbutler/core/native.py +301 -0
  19. diffbutler/core/polling.py +118 -0
  20. diffbutler/core/process.py +64 -0
  21. diffbutler/core/repositories.py +260 -0
  22. diffbutler/core/reset.py +34 -0
  23. diffbutler/core/resources.py +50 -0
  24. diffbutler/core/review.py +331 -0
  25. diffbutler/core/setup.py +232 -0
  26. diffbutler/core/snapshot.py +70 -0
  27. diffbutler/core/sync.py +259 -0
  28. diffbutler/core/worker.py +205 -0
  29. diffbutler/core/workspaces.py +139 -0
  30. diffbutler/domain/__init__.py +1 -0
  31. diffbutler/domain/dashboard.py +63 -0
  32. diffbutler/domain/diff.py +285 -0
  33. diffbutler/domain/forge.py +123 -0
  34. diffbutler/domain/lifecycle.py +51 -0
  35. diffbutler/domain/polling.py +92 -0
  36. diffbutler/domain/process.py +29 -0
  37. diffbutler/domain/redaction.py +61 -0
  38. diffbutler/domain/resources.py +50 -0
  39. diffbutler/domain/review.py +167 -0
  40. diffbutler/domain/sync.py +196 -0
  41. diffbutler/forges/__init__.py +1 -0
  42. diffbutler/forges/base.py +39 -0
  43. diffbutler/forges/diagnostics.py +104 -0
  44. diffbutler/forges/github/__init__.py +1 -0
  45. diffbutler/forges/github/adapter.py +352 -0
  46. diffbutler/forges/github/auth.py +49 -0
  47. diffbutler/forges/github/http.py +326 -0
  48. diffbutler/forges/github/mapping.py +157 -0
  49. diffbutler/forges/github/setup.py +86 -0
  50. diffbutler/forges/registry.py +23 -0
  51. diffbutler/py.typed +0 -0
  52. diffbutler/reviewers/__init__.py +1 -0
  53. diffbutler/reviewers/base.py +101 -0
  54. diffbutler/reviewers/extraction.py +198 -0
  55. diffbutler/reviewers/native.py +238 -0
  56. diffbutler/reviewers/parsing.py +247 -0
  57. diffbutler/reviewers/presets.toml +12 -0
  58. diffbutler/reviewers/process.py +78 -0
  59. diffbutler/reviewers/prompt.py +148 -0
  60. diffbutler/reviewers/redaction.py +30 -0
  61. diffbutler/reviewers/resources.py +162 -0
  62. diffbutler/sources/__init__.py +1 -0
  63. diffbutler/sources/diagnostics.py +100 -0
  64. diffbutler/sources/fetch.py +285 -0
  65. diffbutler/sources/git.py +461 -0
  66. diffbutler/sources/remotes.py +82 -0
  67. diffbutler/store/__init__.py +1 -0
  68. diffbutler/store/base.py +88 -0
  69. diffbutler/store/dashboard.py +201 -0
  70. diffbutler/store/database.py +172 -0
  71. diffbutler/store/lifecycle.py +247 -0
  72. diffbutler/store/migrations/__init__.py +1 -0
  73. diffbutler/store/migrations/env.py +14 -0
  74. diffbutler/store/migrations/versions/0001_initial.py +147 -0
  75. diffbutler/store/migrations/versions/0002_monitoring.py +38 -0
  76. diffbutler/store/migrations/versions/0003_reviews.py +72 -0
  77. diffbutler/store/migrations/versions/0004_claims.py +22 -0
  78. diffbutler/store/migrations/versions/0005_results.py +27 -0
  79. diffbutler/store/migrations/versions/0006_anchors.py +17 -0
  80. diffbutler/store/migrations/versions/0007_lifecycle.py +36 -0
  81. diffbutler/store/migrations/versions/0008_processes.py +30 -0
  82. diffbutler/store/migrations/versions/0009_dashboard.py +43 -0
  83. diffbutler/store/migrations/versions/0010_run_reasons.py +17 -0
  84. diffbutler/store/migrations/versions/__init__.py +1 -0
  85. diffbutler/store/models.py +290 -0
  86. diffbutler/store/process.py +75 -0
  87. diffbutler/store/queue.py +289 -0
  88. diffbutler/store/review.py +583 -0
  89. diffbutler/ui/__init__.py +1 -0
  90. diffbutler/ui/cli/__init__.py +532 -0
  91. diffbutler/ui/cli/editor.py +33 -0
  92. diffbutler/ui/tui/__init__.py +1 -0
  93. diffbutler/ui/tui/_fake_agent.py +47 -0
  94. diffbutler/ui/tui/_foreground_exec.py +23 -0
  95. diffbutler/ui/tui/app.py +34 -0
  96. diffbutler/ui/tui/browse.py +119 -0
  97. diffbutler/ui/tui/browser.py +42 -0
  98. diffbutler/ui/tui/dashboard.py +926 -0
  99. diffbutler/ui/tui/foreground.py +103 -0
  100. diffbutler/ui/tui/handoff_probe.py +184 -0
  101. diffbutler-0.5.0.dist-info/METADATA +170 -0
  102. diffbutler-0.5.0.dist-info/RECORD +105 -0
  103. diffbutler-0.5.0.dist-info/WHEEL +4 -0
  104. diffbutler-0.5.0.dist-info/entry_points.txt +3 -0
  105. diffbutler-0.5.0.dist-info/licenses/LICENSE.txt +21 -0
@@ -0,0 +1,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