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,326 @@
|
|
|
1
|
+
"""Bounded async GET transport with conditional reads and origin confinement."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from email.utils import parsedate_to_datetime
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from diffbutler.domain.forge import RateLimit
|
|
18
|
+
from diffbutler.forges.base import ForgeError
|
|
19
|
+
from diffbutler.forges.diagnostics import (
|
|
20
|
+
RateLimitSource,
|
|
21
|
+
observe_cooldown,
|
|
22
|
+
observe_rate_limit,
|
|
23
|
+
observe_request,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def api_url(value: str) -> httpx.URL:
|
|
28
|
+
try:
|
|
29
|
+
url = httpx.URL(value)
|
|
30
|
+
except httpx.InvalidURL:
|
|
31
|
+
raise ForgeError("GitHub base_url must be a valid HTTPS API URL") from None
|
|
32
|
+
if (
|
|
33
|
+
url.scheme != "https"
|
|
34
|
+
or not url.host
|
|
35
|
+
or url.userinfo
|
|
36
|
+
or url.query
|
|
37
|
+
or url.fragment
|
|
38
|
+
or url.path not in {"", "/", "/api/v3", "/api/v3/"}
|
|
39
|
+
):
|
|
40
|
+
raise ForgeError(
|
|
41
|
+
"GitHub base_url must be HTTPS, with no credentials/query and an optional /api/v3 path"
|
|
42
|
+
)
|
|
43
|
+
return url.copy_with(path=url.path.rstrip("/") + "/")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ReadResponse:
|
|
48
|
+
body: object
|
|
49
|
+
headers: httpx.Headers
|
|
50
|
+
next_page: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GitHubHTTP:
|
|
54
|
+
DEFAULT_REQUEST_INTERVAL: ClassVar[float] = 1.0
|
|
55
|
+
AUTHENTICATED_REQUEST_INTERVAL: ClassVar[float] = 0.1
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
base_url: str,
|
|
60
|
+
token: str | None,
|
|
61
|
+
*,
|
|
62
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
63
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
64
|
+
now: Callable[[], float] = time.time,
|
|
65
|
+
request_interval: float | None = None,
|
|
66
|
+
max_pacing_wait: float | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.base_url = api_url(base_url)
|
|
69
|
+
if token is not None and (
|
|
70
|
+
not token or not token.isascii() or any(character.isspace() for character in token)
|
|
71
|
+
):
|
|
72
|
+
raise ForgeError("GitHub token is empty or contains whitespace")
|
|
73
|
+
self.authenticated = token is not None
|
|
74
|
+
self.observation_source = RateLimitSource(
|
|
75
|
+
str(self.base_url).rstrip("/"), self.authenticated
|
|
76
|
+
)
|
|
77
|
+
self.max_pacing_wait = max_pacing_wait
|
|
78
|
+
if max_pacing_wait is not None and (
|
|
79
|
+
not math.isfinite(max_pacing_wait) or max_pacing_wait < 0
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("Maximum pacing wait must be finite and nonnegative")
|
|
82
|
+
default_interval = (
|
|
83
|
+
self.AUTHENTICATED_REQUEST_INTERVAL
|
|
84
|
+
if self.authenticated
|
|
85
|
+
else self.DEFAULT_REQUEST_INTERVAL
|
|
86
|
+
)
|
|
87
|
+
self.request_interval = default_interval if request_interval is None else request_interval
|
|
88
|
+
if not math.isfinite(self.request_interval) or self.request_interval < 0:
|
|
89
|
+
raise ValueError("Request interval must be finite and nonnegative")
|
|
90
|
+
self.client = httpx.AsyncClient(
|
|
91
|
+
base_url=self.base_url,
|
|
92
|
+
transport=transport,
|
|
93
|
+
timeout=20,
|
|
94
|
+
follow_redirects=False,
|
|
95
|
+
headers={
|
|
96
|
+
**({"Authorization": f"Bearer {token}"} if token is not None else {}),
|
|
97
|
+
"Accept": "application/vnd.github+json",
|
|
98
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
99
|
+
"User-Agent": "DiffButler",
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
self.sleep = sleep
|
|
103
|
+
self.now = now
|
|
104
|
+
self.last_request: float | None = None
|
|
105
|
+
self.last_resource_request: dict[str, float] = {}
|
|
106
|
+
self.limits: dict[str, RateLimit] = {}
|
|
107
|
+
self.cache: OrderedDict[str, ReadResponse] = OrderedDict()
|
|
108
|
+
self.blocked_until = 0.0
|
|
109
|
+
self.lock = asyncio.Lock()
|
|
110
|
+
|
|
111
|
+
async def paced_get(self, url: httpx.URL, headers: dict[str, str]) -> httpx.Response:
|
|
112
|
+
"""Pace every wire request, including redirects and retry attempts."""
|
|
113
|
+
resource = "search" if url.path.endswith("/search/issues") else "core"
|
|
114
|
+
limit = self.limits.get(resource)
|
|
115
|
+
if limit and limit.remaining <= 0 and limit.reset_at.timestamp() > self.now():
|
|
116
|
+
raise ForgeError(
|
|
117
|
+
"GitHub rate limit is exhausted",
|
|
118
|
+
retry_after=limit.reset_at.timestamp() - self.now(),
|
|
119
|
+
)
|
|
120
|
+
if self.blocked_until > self.now():
|
|
121
|
+
raise ForgeError(
|
|
122
|
+
"GitHub rate limit cooldown is active",
|
|
123
|
+
retry_after=self.blocked_until - self.now(),
|
|
124
|
+
)
|
|
125
|
+
if self.request_interval:
|
|
126
|
+
current = self.now()
|
|
127
|
+
ready = current
|
|
128
|
+
if self.last_request is not None:
|
|
129
|
+
ready = max(ready, self.last_request + self.request_interval)
|
|
130
|
+
previous = self.last_resource_request.get(resource)
|
|
131
|
+
if previous is not None:
|
|
132
|
+
interval = self.request_interval
|
|
133
|
+
if resource == "search" and not self.authenticated:
|
|
134
|
+
interval = max(interval, 6.1)
|
|
135
|
+
if limit and 0 < limit.remaining <= limit.limit * 0.2:
|
|
136
|
+
interval = max(
|
|
137
|
+
interval, (limit.reset_at.timestamp() - previous) / limit.remaining
|
|
138
|
+
)
|
|
139
|
+
ready = max(ready, previous + interval)
|
|
140
|
+
if ready > current:
|
|
141
|
+
logging.getLogger(__name__).info(
|
|
142
|
+
"GitHub pacing resource=%s delay_s=%.3f", resource, ready - current
|
|
143
|
+
)
|
|
144
|
+
if self.max_pacing_wait is not None and ready - current > self.max_pacing_wait:
|
|
145
|
+
delay = math.ceil(
|
|
146
|
+
max(ready - current, limit.reset_at.timestamp() - current if limit else 0)
|
|
147
|
+
)
|
|
148
|
+
raise ForgeError(
|
|
149
|
+
f"GitHub quota is low; retry after {delay} seconds. "
|
|
150
|
+
"Resetting DiffButler does not reset GitHub quota.",
|
|
151
|
+
retry_after=delay,
|
|
152
|
+
)
|
|
153
|
+
await self.sleep(ready - current)
|
|
154
|
+
started = self.now()
|
|
155
|
+
self.last_request = started
|
|
156
|
+
self.last_resource_request[resource] = started
|
|
157
|
+
began = time.monotonic()
|
|
158
|
+
observe_request(self.observation_source, resource)
|
|
159
|
+
try:
|
|
160
|
+
response = await self.client.get(url, headers=headers)
|
|
161
|
+
except httpx.RequestError:
|
|
162
|
+
logging.getLogger(__name__).exception("GitHub transport failed resource=%s", resource)
|
|
163
|
+
raise
|
|
164
|
+
self.record_limit(response.headers)
|
|
165
|
+
quota = self.limits.get(resource)
|
|
166
|
+
logging.getLogger(__name__).log(
|
|
167
|
+
logging.DEBUG if response.is_success or response.status_code == 304 else logging.INFO,
|
|
168
|
+
"GitHub response resource=%s status=%d elapsed_s=%.3f "
|
|
169
|
+
"limit=%s remaining=%s reset=%s retry_after_s=%s",
|
|
170
|
+
resource,
|
|
171
|
+
response.status_code,
|
|
172
|
+
time.monotonic() - began,
|
|
173
|
+
quota.limit if quota else None,
|
|
174
|
+
quota.remaining if quota else None,
|
|
175
|
+
quota.reset_at.timestamp() if quota else None,
|
|
176
|
+
self.retry_delay(response),
|
|
177
|
+
)
|
|
178
|
+
return response
|
|
179
|
+
|
|
180
|
+
def checked_url(self, value: str | httpx.URL) -> httpx.URL:
|
|
181
|
+
try:
|
|
182
|
+
url = self.base_url.join(value)
|
|
183
|
+
except httpx.InvalidURL:
|
|
184
|
+
raise ForgeError("GitHub returned an invalid API URL") from None
|
|
185
|
+
if (
|
|
186
|
+
(url.scheme, url.host, url.port)
|
|
187
|
+
!= (self.base_url.scheme, self.base_url.host, self.base_url.port)
|
|
188
|
+
or not url.path.startswith(self.base_url.path)
|
|
189
|
+
or url.userinfo
|
|
190
|
+
or url.fragment
|
|
191
|
+
):
|
|
192
|
+
raise ForgeError("GitHub returned a URL outside the configured API origin")
|
|
193
|
+
return url
|
|
194
|
+
|
|
195
|
+
def record_limit(self, headers: httpx.Headers) -> None:
|
|
196
|
+
try:
|
|
197
|
+
resource = headers["x-ratelimit-resource"]
|
|
198
|
+
limit = RateLimit(
|
|
199
|
+
resource,
|
|
200
|
+
int(headers["x-ratelimit-remaining"]),
|
|
201
|
+
int(headers["x-ratelimit-limit"]),
|
|
202
|
+
datetime.fromtimestamp(int(headers["x-ratelimit-reset"]), UTC),
|
|
203
|
+
)
|
|
204
|
+
if limit.limit <= 0 or not 0 <= limit.remaining <= limit.limit:
|
|
205
|
+
return
|
|
206
|
+
self.limits[resource] = limit
|
|
207
|
+
except KeyError, ValueError, OverflowError, OSError:
|
|
208
|
+
return
|
|
209
|
+
observe_rate_limit(limit, self.observation_source, observed_at=self.now())
|
|
210
|
+
if limit.remaining == 0:
|
|
211
|
+
observe_cooldown(limit.reset_at.timestamp() + 1)
|
|
212
|
+
|
|
213
|
+
def retry_delay(self, response: httpx.Response) -> float | None:
|
|
214
|
+
headers = response.headers
|
|
215
|
+
if response.status_code not in {403, 429}:
|
|
216
|
+
return None
|
|
217
|
+
if "retry-after" in headers:
|
|
218
|
+
try:
|
|
219
|
+
delay = float(headers["retry-after"])
|
|
220
|
+
return max(0, delay) if math.isfinite(delay) else 60
|
|
221
|
+
except ValueError:
|
|
222
|
+
try:
|
|
223
|
+
return max(
|
|
224
|
+
0, parsedate_to_datetime(headers["retry-after"]).timestamp() - self.now()
|
|
225
|
+
)
|
|
226
|
+
except ValueError, TypeError, OverflowError:
|
|
227
|
+
return 60
|
|
228
|
+
if headers.get("x-ratelimit-remaining") == "0":
|
|
229
|
+
try:
|
|
230
|
+
delay = float(headers["x-ratelimit-reset"]) - self.now()
|
|
231
|
+
return max(0, delay) if math.isfinite(delay) else 60
|
|
232
|
+
except KeyError, ValueError:
|
|
233
|
+
return 60
|
|
234
|
+
# The response body is inspected only to classify the error, never surfaced.
|
|
235
|
+
if response.status_code == 429 or "rate limit" in response.text.lower():
|
|
236
|
+
return 60
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
async def get(self, path: str, params: dict[str, str | int] | None = None) -> ReadResponse:
|
|
240
|
+
url = self.checked_url(path)
|
|
241
|
+
if params:
|
|
242
|
+
url = url.copy_merge_params(params)
|
|
243
|
+
async with self.lock:
|
|
244
|
+
return await self.read(url)
|
|
245
|
+
|
|
246
|
+
async def read(self, url: httpx.URL) -> ReadResponse:
|
|
247
|
+
key = str(url)
|
|
248
|
+
cached = self.cache.get(key)
|
|
249
|
+
headers: dict[str, str] = {}
|
|
250
|
+
if cached is not None:
|
|
251
|
+
if "etag" in cached.headers:
|
|
252
|
+
headers["If-None-Match"] = cached.headers["etag"]
|
|
253
|
+
elif "last-modified" in cached.headers:
|
|
254
|
+
headers["If-Modified-Since"] = cached.headers["last-modified"]
|
|
255
|
+
redirects = 0
|
|
256
|
+
for attempt in range(3):
|
|
257
|
+
try:
|
|
258
|
+
response = await self.paced_get(url, headers)
|
|
259
|
+
while response.status_code in {301, 302, 303, 307, 308}:
|
|
260
|
+
redirects += 1
|
|
261
|
+
if redirects > 3 or "location" not in response.headers:
|
|
262
|
+
raise ForgeError("GitHub redirect limit exceeded or location missing")
|
|
263
|
+
url = self.checked_url(url.join(response.headers["location"]))
|
|
264
|
+
headers = {}
|
|
265
|
+
response = await self.paced_get(url, {})
|
|
266
|
+
except httpx.RequestError:
|
|
267
|
+
if attempt < 2:
|
|
268
|
+
await self.sleep(2**attempt + random.random())
|
|
269
|
+
continue
|
|
270
|
+
raise ForgeError(
|
|
271
|
+
"GitHub request failed; check network, TLS, and API availability"
|
|
272
|
+
) from None
|
|
273
|
+
delay = self.retry_delay(response)
|
|
274
|
+
if delay is not None:
|
|
275
|
+
delay = max(delay, 1)
|
|
276
|
+
self.blocked_until = self.now() + delay
|
|
277
|
+
observe_cooldown(self.blocked_until)
|
|
278
|
+
# Return long waits to the caller instead of holding doctor open.
|
|
279
|
+
if delay > 5 or attempt == 2:
|
|
280
|
+
raise ForgeError(
|
|
281
|
+
"GitHub rate limit reached; retry after the reported cooldown",
|
|
282
|
+
status=response.status_code,
|
|
283
|
+
retry_after=delay,
|
|
284
|
+
)
|
|
285
|
+
await self.sleep(delay + random.random())
|
|
286
|
+
continue
|
|
287
|
+
if response.status_code >= 500 and attempt < 2:
|
|
288
|
+
await self.sleep(2**attempt + random.random())
|
|
289
|
+
continue
|
|
290
|
+
if response.status_code == 304:
|
|
291
|
+
self.blocked_until = 0
|
|
292
|
+
if cached is None or redirects:
|
|
293
|
+
raise ForgeError("GitHub returned 304 without a cached representation")
|
|
294
|
+
merged = httpx.Headers(cached.headers)
|
|
295
|
+
merged.update(response.headers)
|
|
296
|
+
result = ReadResponse(cached.body, merged, cached.next_page)
|
|
297
|
+
self.cache[key] = result
|
|
298
|
+
self.cache.move_to_end(key)
|
|
299
|
+
return result
|
|
300
|
+
if response.status_code != 200:
|
|
301
|
+
messages = {
|
|
302
|
+
401: "GitHub authentication failed",
|
|
303
|
+
403: "GitHub access denied; check token permissions and organization SSO",
|
|
304
|
+
404: "GitHub resource is missing or inaccessible",
|
|
305
|
+
422: "GitHub rejected the search or request parameters",
|
|
306
|
+
}
|
|
307
|
+
raise ForgeError(
|
|
308
|
+
messages.get(response.status_code, "GitHub request failed"),
|
|
309
|
+
status=response.status_code,
|
|
310
|
+
)
|
|
311
|
+
try:
|
|
312
|
+
body: object = response.json()
|
|
313
|
+
except ValueError:
|
|
314
|
+
raise ForgeError("GitHub returned invalid JSON") from None
|
|
315
|
+
result = ReadResponse(body, response.headers, "next" in response.links)
|
|
316
|
+
self.blocked_until = 0
|
|
317
|
+
self.cache[key] = result
|
|
318
|
+
self.cache.move_to_end(key)
|
|
319
|
+
while len(self.cache) > 256:
|
|
320
|
+
self.cache.popitem(last=False)
|
|
321
|
+
return result
|
|
322
|
+
raise ForgeError("GitHub retry budget exhausted")
|
|
323
|
+
|
|
324
|
+
async def close(self) -> None:
|
|
325
|
+
self.cache.clear()
|
|
326
|
+
await self.client.aclose()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Validate GitHub response shapes before exposing immutable domain values."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, ValidationError
|
|
7
|
+
|
|
8
|
+
from diffbutler.domain.forge import PRRef, PullRequest
|
|
9
|
+
from diffbutler.forges.base import ForgeError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResponseModel(BaseModel):
|
|
13
|
+
model_config = ConfigDict(extra="ignore", strict=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class User(ResponseModel):
|
|
17
|
+
login: str
|
|
18
|
+
name: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Organization(ResponseModel):
|
|
22
|
+
login: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Team(ResponseModel):
|
|
26
|
+
slug: str
|
|
27
|
+
organization: Organization | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Repository(ResponseModel):
|
|
31
|
+
full_name: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Branch(ResponseModel):
|
|
35
|
+
sha: str
|
|
36
|
+
ref: str
|
|
37
|
+
repo: Repository | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Label(ResponseModel):
|
|
41
|
+
name: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Pull(ResponseModel):
|
|
45
|
+
additions: int | None = Field(default=None, ge=0)
|
|
46
|
+
deletions: int | None = Field(default=None, ge=0)
|
|
47
|
+
changed_files: int | None = Field(default=None, ge=0)
|
|
48
|
+
labels: list[Label] = Field(default_factory=list[Label])
|
|
49
|
+
|
|
50
|
+
number: int = Field(gt=0)
|
|
51
|
+
title: str
|
|
52
|
+
user: User | None
|
|
53
|
+
state: str
|
|
54
|
+
merged: bool
|
|
55
|
+
draft: bool = False
|
|
56
|
+
head: Branch
|
|
57
|
+
base: Branch
|
|
58
|
+
# HTTP JSON dates are strings. Parse them separately rather than coercing
|
|
59
|
+
# malformed booleans, IDs, or list contents via non-strict model validation.
|
|
60
|
+
created_at: str
|
|
61
|
+
updated_at: str
|
|
62
|
+
assignees: list[User] = Field(default_factory=list[User])
|
|
63
|
+
requested_reviewers: list[User]
|
|
64
|
+
requested_teams: list[Team]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class DateValue(BaseModel):
|
|
68
|
+
value: AwareDatetime
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def timestamp(value: str) -> datetime:
|
|
72
|
+
return DateValue.model_validate({"value": value}).value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SearchHit(ResponseModel):
|
|
76
|
+
pull_request: dict[str, str | None]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SearchResult(ResponseModel):
|
|
80
|
+
total_count: int = Field(ge=0)
|
|
81
|
+
incomplete_results: bool
|
|
82
|
+
items: list[SearchHit]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse[T: BaseModel](model: type[T], value: object) -> T:
|
|
86
|
+
try:
|
|
87
|
+
return model.model_validate(value)
|
|
88
|
+
except ValidationError:
|
|
89
|
+
raise ForgeError("GitHub returned an unexpected response shape") from None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def objects(value: object) -> list[object]:
|
|
93
|
+
if not isinstance(value, list):
|
|
94
|
+
raise ForgeError("GitHub returned an unexpected list response")
|
|
95
|
+
return cast(list[object], value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def pull_request(value: object, *, forge: str, web_base: str) -> PullRequest:
|
|
99
|
+
data = parse(Pull, value)
|
|
100
|
+
try:
|
|
101
|
+
if data.base.repo is None:
|
|
102
|
+
raise ValueError("base repository missing")
|
|
103
|
+
owner, name = data.base.repo.full_name.split("/")
|
|
104
|
+
ref = PRRef(forge, owner.lower(), name.lower(), data.number)
|
|
105
|
+
if data.state not in {"open", "closed"}:
|
|
106
|
+
raise ValueError("unknown pull request state")
|
|
107
|
+
return PullRequest(
|
|
108
|
+
ref=ref,
|
|
109
|
+
title=data.title,
|
|
110
|
+
author=data.user.login if data.user else "[deleted]",
|
|
111
|
+
url=f"{web_base}/{owner}/{name}/pull/{data.number}",
|
|
112
|
+
state="merged" if data.merged else ("closed" if data.state == "closed" else "open"),
|
|
113
|
+
head_sha=data.head.sha,
|
|
114
|
+
base_sha=data.base.sha,
|
|
115
|
+
head_ref=data.head.ref,
|
|
116
|
+
base_ref=data.base.ref,
|
|
117
|
+
is_draft=data.draft,
|
|
118
|
+
created_at=timestamp(data.created_at),
|
|
119
|
+
updated_at=timestamp(data.updated_at),
|
|
120
|
+
additions=data.additions,
|
|
121
|
+
deletions=data.deletions,
|
|
122
|
+
files=data.changed_files,
|
|
123
|
+
labels=tuple(label.name for label in data.labels),
|
|
124
|
+
assignees=tuple(user.login.lower() for user in data.assignees),
|
|
125
|
+
requested_users=tuple(user.login.lower() for user in data.requested_reviewers),
|
|
126
|
+
requested_teams=tuple(
|
|
127
|
+
f"{owner.lower()}/{team.slug.lower()}" for team in data.requested_teams
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise ForgeError("GitHub returned invalid pull request identity or dates") from None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class IssueEvent(ResponseModel):
|
|
135
|
+
id: int
|
|
136
|
+
event: str
|
|
137
|
+
created_at: str
|
|
138
|
+
requested_reviewer: User | None = None
|
|
139
|
+
requested_team: Team | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Review(ResponseModel):
|
|
143
|
+
body: str | None = None
|
|
144
|
+
id: int
|
|
145
|
+
user: User | None
|
|
146
|
+
state: str
|
|
147
|
+
submitted_at: str | None = None
|
|
148
|
+
commit_id: str | None = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class Comment(ResponseModel):
|
|
152
|
+
id: int
|
|
153
|
+
user: User | None
|
|
154
|
+
body: str
|
|
155
|
+
created_at: str
|
|
156
|
+
commit_id: str | None = None
|
|
157
|
+
pull_request_review_id: int | None = None
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Read-only visibility and credential checks before first-run configuration is saved."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from diffbutler.domain.forge import PRQuery, identity_part
|
|
6
|
+
from diffbutler.forges.base import ForgeError
|
|
7
|
+
from diffbutler.forges.github.http import GitHubHTTP
|
|
8
|
+
from diffbutler.forges.github.mapping import ResponseModel, User, parse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RepositoryAccess(ResponseModel):
|
|
12
|
+
full_name: str
|
|
13
|
+
private: bool
|
|
14
|
+
visibility: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def detect_login(base_url: str, token: str) -> str | None:
|
|
18
|
+
"""Best-effort identity lookup; failures leave setup's manual prompt available."""
|
|
19
|
+
try:
|
|
20
|
+
async with asyncio.timeout(10):
|
|
21
|
+
http = GitHubHTTP(base_url, token, max_pacing_wait=5)
|
|
22
|
+
try:
|
|
23
|
+
response = await http.get("user")
|
|
24
|
+
login = parse(User, response.body).login
|
|
25
|
+
identity_part(login)
|
|
26
|
+
return login
|
|
27
|
+
finally:
|
|
28
|
+
await http.close()
|
|
29
|
+
except ForgeError, ValueError, TimeoutError:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def repository_access(http: GitHubHTTP, repository: str) -> RepositoryAccess:
|
|
34
|
+
PRQuery(repository=repository)
|
|
35
|
+
response = await http.get(f"repos/{repository}")
|
|
36
|
+
access = parse(RepositoryAccess, response.body)
|
|
37
|
+
if access.full_name.lower() != repository.lower():
|
|
38
|
+
raise ForgeError("GitHub returned a different repository; check owner/name")
|
|
39
|
+
return access
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def repository_is_public(base_url: str, repository: str) -> bool:
|
|
43
|
+
http = GitHubHTTP(base_url, None, max_pacing_wait=5)
|
|
44
|
+
try:
|
|
45
|
+
access = await repository_access(http, repository)
|
|
46
|
+
return not access.private and access.visibility in {None, "public"}
|
|
47
|
+
except ForgeError as exc:
|
|
48
|
+
if exc.status in {401, 403, 404} and exc.retry_after is None:
|
|
49
|
+
return False
|
|
50
|
+
raise
|
|
51
|
+
finally:
|
|
52
|
+
await http.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def validate_access(
|
|
56
|
+
base_url: str,
|
|
57
|
+
repository: str,
|
|
58
|
+
login: str,
|
|
59
|
+
token: str | None,
|
|
60
|
+
) -> str:
|
|
61
|
+
identity_part(login)
|
|
62
|
+
http = GitHubHTTP(base_url, token, max_pacing_wait=5)
|
|
63
|
+
try:
|
|
64
|
+
try:
|
|
65
|
+
response = await http.get("user" if token is not None else f"users/{login}")
|
|
66
|
+
except ForgeError as exc:
|
|
67
|
+
if exc.status == 401:
|
|
68
|
+
raise ForgeError(
|
|
69
|
+
"Invalid GitHub token in the selected environment variable"
|
|
70
|
+
) from None
|
|
71
|
+
if exc.status == 404 and token is None:
|
|
72
|
+
raise ForgeError("GitHub username was not found") from None
|
|
73
|
+
raise
|
|
74
|
+
user = parse(User, response.body)
|
|
75
|
+
try:
|
|
76
|
+
identity_part(user.login)
|
|
77
|
+
except ValueError:
|
|
78
|
+
raise ForgeError("GitHub returned an invalid account identity") from None
|
|
79
|
+
if user.login.lower() != login.lower():
|
|
80
|
+
raise ForgeError("GitHub token belongs to a different username")
|
|
81
|
+
await repository_access(http, repository)
|
|
82
|
+
# Validate pull-request read permissions, not just repository metadata access.
|
|
83
|
+
await http.get(f"repos/{repository}/pulls", {"per_page": 1, "state": "open"})
|
|
84
|
+
return user.login
|
|
85
|
+
finally:
|
|
86
|
+
await http.close()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Construct configured read adapters without exposing credentials to the core."""
|
|
2
|
+
|
|
3
|
+
from diffbutler.config.settings import Forge
|
|
4
|
+
from diffbutler.forges.base import ForgeError, ReadForge
|
|
5
|
+
from diffbutler.forges.github.adapter import GitHubForge
|
|
6
|
+
from diffbutler.forges.github.auth import resolve_token
|
|
7
|
+
from diffbutler.forges.github.http import GitHubHTTP
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def open_forge(config: Forge) -> ReadForge:
|
|
11
|
+
if config.kind != "github":
|
|
12
|
+
raise ForgeError("GitLab adapter is not implemented")
|
|
13
|
+
token = (
|
|
14
|
+
await resolve_token(config.base_url, config.token_env)
|
|
15
|
+
if config.token_env is not None
|
|
16
|
+
else None
|
|
17
|
+
)
|
|
18
|
+
return GitHubForge(
|
|
19
|
+
GitHubHTTP(config.base_url, token),
|
|
20
|
+
include_team_requests=config.include_team_requests,
|
|
21
|
+
repositories=tuple(config.repositories),
|
|
22
|
+
public_login=config.login if token is None else None,
|
|
23
|
+
)
|
diffbutler/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Background review and native session adapters."""
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Background-only adapter seam. Contracts alone do not enforce process isolation."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from diffbutler.domain.diff import DiffIndex
|
|
10
|
+
from diffbutler.domain.forge import PullRequest
|
|
11
|
+
from diffbutler.domain.review import ChangedFile, SourceFile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SourceReader(Protocol):
|
|
15
|
+
"""Pinned PR source: BASE means the comparison merge base, HEAD the PR head."""
|
|
16
|
+
|
|
17
|
+
async def read_file(self, source: SourceFile) -> bytes | None: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReviewSources(SourceReader, Protocol):
|
|
21
|
+
"""Prepared sources pinned to the PR revisions."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def base_sha(self) -> str: ...
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def comparison_base_sha(self) -> str: ...
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def head_sha(self) -> str: ...
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def changed_files(self) -> tuple[ChangedFile, ...]: ...
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def limitations(self) -> tuple[str, ...]: ...
|
|
37
|
+
|
|
38
|
+
async def read_diff(self) -> str: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ReviewBudget:
|
|
43
|
+
timeout_s: int = 900
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if self.timeout_s <= 0:
|
|
47
|
+
raise ValueError("review budgets must be positive")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ReviewContext:
|
|
52
|
+
pr: PullRequest
|
|
53
|
+
session_id: UUID
|
|
54
|
+
run_id: UUID
|
|
55
|
+
diff: str
|
|
56
|
+
changed_files: tuple[ChangedFile, ...]
|
|
57
|
+
sources: SourceReader
|
|
58
|
+
instructions: str
|
|
59
|
+
schema_json: str
|
|
60
|
+
budget: ReviewBudget
|
|
61
|
+
comparison_base_sha: str
|
|
62
|
+
diff_index: DiffIndex | None = None
|
|
63
|
+
checkout_path: Path | None = None
|
|
64
|
+
limitations: tuple[str, ...] = ()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ReviewerCapabilities:
|
|
69
|
+
resume: bool = False
|
|
70
|
+
discussion: bool = False
|
|
71
|
+
delete_session: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ReviewProgress:
|
|
76
|
+
text: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class ReviewOutput:
|
|
81
|
+
"""Untrusted output, not parsed findings or evidence of forge publication."""
|
|
82
|
+
|
|
83
|
+
stdout: bytes
|
|
84
|
+
stderr: bytes
|
|
85
|
+
exit_code: int
|
|
86
|
+
backend_session_id: str | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
type ReviewEvent = ReviewProgress | ReviewOutput
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Reviewer(Protocol):
|
|
93
|
+
@property
|
|
94
|
+
def id(self) -> str: ...
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def capabilities(self) -> ReviewerCapabilities: ...
|
|
98
|
+
|
|
99
|
+
def review(self, context: ReviewContext) -> AsyncIterator[ReviewEvent]:
|
|
100
|
+
"""Stream captured progress and one final output; never own a terminal."""
|
|
101
|
+
...
|