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,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
+ ...