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,352 @@
1
+ """Read-only GitHub adapter; search results never create tasks or start AI work."""
2
+
3
+ import json
4
+ import re
5
+
6
+ import httpx
7
+ from pydantic import ValidationError
8
+
9
+ from diffbutler.domain.forge import (
10
+ Account,
11
+ Discovery,
12
+ ForgeHealth,
13
+ PRPage,
14
+ PRQuery,
15
+ PRRef,
16
+ PullRequest,
17
+ RequestedPR,
18
+ TeamMembership,
19
+ identity_part,
20
+ )
21
+ from diffbutler.domain.sync import CommentEvent, RequestEvent, ReviewActivity, ReviewEvent
22
+ from diffbutler.forges.base import ForgeError
23
+ from diffbutler.forges.github.http import GitHubHTTP
24
+ from diffbutler.forges.github.mapping import (
25
+ Comment,
26
+ IssueEvent,
27
+ Review,
28
+ SearchResult,
29
+ Team,
30
+ User,
31
+ objects,
32
+ parse,
33
+ pull_request,
34
+ timestamp,
35
+ )
36
+
37
+
38
+ class GitHubForge:
39
+ def __init__(
40
+ self,
41
+ http: GitHubHTTP,
42
+ *,
43
+ include_team_requests: bool = True,
44
+ repositories: tuple[str, ...] = (),
45
+ public_login: str | None = None,
46
+ ) -> None:
47
+ self.http = http
48
+ self.include_team_requests = include_team_requests
49
+ self.repositories = repositories
50
+ self.public_login = public_login
51
+ self.id = f"github:{str(http.base_url).rstrip('/')}"
52
+ host = "github.com" if http.base_url.host == "api.github.com" else http.base_url.host
53
+ self.web_base = str(http.base_url.copy_with(host=host, path="/")).rstrip("/")
54
+
55
+ def ref(self, value: str) -> PRRef:
56
+ """Accept owner/repo#number or a PR URL on this configured GitHub instance."""
57
+ if value.startswith("https://"):
58
+ try:
59
+ url = httpx.URL(value)
60
+ web = httpx.URL(self.web_base)
61
+ except httpx.InvalidURL:
62
+ raise ForgeError("Invalid GitHub PR URL") from None
63
+ if (url.scheme, url.host, url.port) != (web.scheme, web.host, web.port) or url.userinfo:
64
+ raise ForgeError("PR URL belongs to a different GitHub instance")
65
+ match = re.fullmatch(r"/([^/]+)/([^/]+)/pull/([1-9][0-9]*)/?", url.path)
66
+ else:
67
+ match = re.fullmatch(r"([^/]+)/([^/#]+)#([1-9][0-9]*)", value)
68
+ if match is None:
69
+ raise ForgeError("PR reference must be owner/repo#number or a GitHub pull-request URL")
70
+ try:
71
+ return PRRef(self.id, match[1].lower(), match[2].lower(), int(match[3]))
72
+ except ValueError:
73
+ raise ForgeError("Invalid GitHub PR reference") from None
74
+
75
+ async def whoami(self) -> Account:
76
+ endpoint = "user"
77
+ if not self.http.authenticated:
78
+ if not self.public_login:
79
+ raise ForgeError("Set forges.login to your GitHub username for public access")
80
+ try:
81
+ identity_part(self.public_login)
82
+ except ValueError:
83
+ raise ForgeError("Invalid public GitHub username") from None
84
+ endpoint = f"users/{self.public_login}"
85
+ response = await self.http.get(endpoint)
86
+ user = parse(User, response.body)
87
+ try:
88
+ identity_part(user.login)
89
+ except ValueError:
90
+ raise ForgeError("GitHub returned an invalid account identity") from None
91
+ self.http.observation_source.login = user.login
92
+ return Account(self.id, user.login, user.name or user.login)
93
+
94
+ async def team_membership(self) -> TeamMembership:
95
+ if not self.http.authenticated:
96
+ return TeamMembership(
97
+ (),
98
+ False,
99
+ ("Team review requests require authentication; only direct requests are visible",),
100
+ )
101
+ teams: set[str] = set()
102
+ warnings: list[str] = []
103
+ for page in range(1, 101):
104
+ try:
105
+ response = await self.http.get("user/teams", {"per_page": 100, "page": page})
106
+ scopes = {
107
+ scope.strip() for scope in response.headers.get("x-oauth-scopes", "").split(",")
108
+ }
109
+ if not scopes.intersection({"read:org", "user", "repo", "admin:org", "write:org"}):
110
+ warnings.append(
111
+ "Team visibility is token-scoped; "
112
+ "cross-organization coverage cannot be verified"
113
+ )
114
+ if response.headers.get("x-github-sso"):
115
+ warnings.append("Organization SSO restricts team visibility")
116
+ for item in objects(response.body):
117
+ team = parse(Team, item)
118
+ if team.organization is None:
119
+ raise ForgeError("GitHub team response lacks organization identity")
120
+ try:
121
+ org = identity_part(team.organization.login).lower()
122
+ slug = identity_part(team.slug).lower()
123
+ except ValueError:
124
+ raise ForgeError("GitHub returned invalid team identity") from None
125
+ teams.add(f"{org}/{slug}")
126
+ if not response.next_page:
127
+ break
128
+ except ForgeError as exc:
129
+ warnings.append(f"Team membership is incomplete: {exc}")
130
+ break
131
+ else:
132
+ warnings.append("Team pagination limit reached")
133
+ return TeamMembership(tuple(sorted(teams)), not warnings, tuple(dict.fromkeys(warnings)))
134
+
135
+ async def get_pull_request(self, ref: PRRef) -> PullRequest:
136
+ if ref.forge != self.id:
137
+ raise ForgeError("PR identity belongs to a different forge")
138
+ response = await self.http.get(f"repos/{ref.namespace}/{ref.repository}/pulls/{ref.number}")
139
+ result = pull_request(response.body, forge=self.id, web_base=self.web_base)
140
+ if result.ref.number != ref.number:
141
+ raise ForgeError("GitHub returned a different pull request number")
142
+ return result
143
+
144
+ def search_ref(self, value: str) -> PRRef:
145
+ url = self.http.checked_url(value)
146
+ path = url.path.removeprefix(self.http.base_url.path)
147
+ match = re.fullmatch(r"repos/([^/]+)/([^/]+)/pulls/([1-9][0-9]*)", path)
148
+ if match is None:
149
+ raise ForgeError("GitHub search returned an invalid pull request URL")
150
+ return self.ref(f"{match[1]}/{match[2]}#{match[3]}")
151
+
152
+ async def search_page(
153
+ self,
154
+ query: str,
155
+ page: int,
156
+ page_size: int,
157
+ refreshed: dict[PRRef, PullRequest] | None = None,
158
+ ) -> PRPage:
159
+ response = await self.http.get(
160
+ "search/issues",
161
+ {"q": query, "per_page": page_size, "page": page, "sort": "updated", "order": "desc"},
162
+ )
163
+ result = parse(SearchResult, response.body)
164
+ warnings: list[str] = []
165
+ if result.incomplete_results:
166
+ warnings.append("GitHub search reported incomplete results")
167
+ if result.total_count > 1000:
168
+ warnings.append("GitHub search is limited to 1,000 results; narrow the filters")
169
+ if response.headers.get("x-github-sso"):
170
+ warnings.append("Organization SSO restricts search visibility")
171
+ items: dict[PRRef, PullRequest] = {}
172
+ for hit in result.items:
173
+ try:
174
+ url = hit.pull_request.get("url")
175
+ if not url:
176
+ raise ForgeError("GitHub search result lacks a pull request URL")
177
+ ref = self.search_ref(url)
178
+ pr = refreshed.get(ref) if refreshed is not None else None
179
+ if pr is None:
180
+ pr = await self.get_pull_request(ref)
181
+ if refreshed is not None:
182
+ refreshed[ref] = pr
183
+ items[pr.ref] = pr
184
+ except ForgeError as exc:
185
+ warnings.append(f"A search result could not be refreshed: {exc}")
186
+ has_next = response.next_page and page * page_size < min(result.total_count, 1000)
187
+ return PRPage(
188
+ tuple(items.values()),
189
+ str(page + 1) if has_next else None,
190
+ not warnings,
191
+ tuple(dict.fromkeys(warnings)),
192
+ )
193
+
194
+ async def search_pull_requests(self, query: PRQuery, cursor: str | None = None) -> PRPage:
195
+ try:
196
+ page = int(cursor) if cursor is not None else 1
197
+ except ValueError:
198
+ raise ForgeError("Invalid search cursor") from None
199
+ if page < 1 or (page - 1) * query.page_size >= 1000:
200
+ raise ForgeError("Search cursor is outside GitHub's 1,000-result window")
201
+ terms = ["is:pr"]
202
+ if query.state != "all":
203
+ terms.append(f"is:{query.state}")
204
+ if query.repository:
205
+ terms.append(f"repo:{query.repository}")
206
+ if query.author:
207
+ terms.append(f"author:{query.author}")
208
+ if query.text.strip():
209
+ terms.append(json.dumps(query.text.strip(), ensure_ascii=False))
210
+ return await self.search_page(" ".join(terms), page, query.page_size)
211
+
212
+ async def list_review_requests(self) -> Discovery:
213
+ account = await self.whoami()
214
+ memberships = (
215
+ await self.team_membership() if self.include_team_requests else TeamMembership((), True)
216
+ )
217
+ warnings = list(memberships.warnings)
218
+ queries = [f"is:pr is:open review-requested:{account.login}"]
219
+ queries.extend(f"is:pr is:open team-review-requested:{team}" for team in memberships.teams)
220
+ if self.repositories:
221
+ queries = [f"{query} repo:{repo}" for query in queries for repo in self.repositories]
222
+ discovered: dict[PRRef, RequestedPR] = {}
223
+ refreshed: dict[PRRef, PullRequest] = {}
224
+ # Hydration is shared between overlapping direct/team searches during one
225
+ # discovery pass. Conditional GETs otherwise refresh remote state.
226
+ for query in queries:
227
+ page = 1
228
+ while True:
229
+ try:
230
+ result = await self.search_page(query, page, 100, refreshed)
231
+ warnings.extend(result.warnings)
232
+ for pr in result.items:
233
+ if pr.state != "open":
234
+ continue
235
+ sources: list[str] = []
236
+ if account.login.lower() in pr.requested_users:
237
+ sources.append(f"user:{account.login.lower()}")
238
+ sources.extend(
239
+ f"team:{team}"
240
+ for team in memberships.teams
241
+ if team in pr.requested_teams
242
+ )
243
+ if sources:
244
+ previous = discovered.get(pr.ref)
245
+ if previous is not None:
246
+ sources = sorted(set(previous.sources).union(sources))
247
+ discovered[pr.ref] = RequestedPR(pr, tuple(sources))
248
+ if result.next_cursor is None:
249
+ break
250
+ page = int(result.next_cursor)
251
+ except ForgeError as exc:
252
+ warnings.append(f"Review-request discovery is incomplete: {exc}")
253
+ break
254
+ return Discovery(tuple(discovered.values()), not warnings, tuple(dict.fromkeys(warnings)))
255
+
256
+ async def get_review_activity(self, ref: PRRef) -> ReviewActivity:
257
+ if ref.forge != self.id:
258
+ raise ForgeError("PR identity belongs to a different forge")
259
+ account = await self.whoami()
260
+ memberships = (
261
+ await self.team_membership() if self.include_team_requests else TeamMembership((), True)
262
+ )
263
+ warnings = list(memberships.warnings)
264
+ requests: dict[str, RequestEvent] = {}
265
+ reviews: dict[str, ReviewEvent] = {}
266
+ comments: dict[str, CommentEvent] = {}
267
+ prefix = f"repos/{ref.namespace}/{ref.repository}"
268
+ endpoints = (
269
+ f"{prefix}/issues/{ref.number}/events",
270
+ f"{prefix}/pulls/{ref.number}/reviews",
271
+ f"{prefix}/pulls/{ref.number}/comments",
272
+ f"{prefix}/issues/{ref.number}/comments",
273
+ )
274
+ for endpoint in endpoints:
275
+ for page in range(1, 101):
276
+ try:
277
+ response = await self.http.get(endpoint, {"per_page": 100, "page": page})
278
+ if response.headers.get("x-github-sso"):
279
+ warnings.append("Organization SSO restricts review activity visibility")
280
+ for value in objects(response.body):
281
+ if endpoint.endswith("/events"):
282
+ event = parse(IssueEvent, value)
283
+ if event.event != "review_requested":
284
+ continue
285
+ user = event.requested_reviewer
286
+ team = event.requested_team
287
+ organization = (
288
+ team.organization.login.lower()
289
+ if team and team.organization
290
+ else ref.namespace
291
+ )
292
+ addressed = (
293
+ user is not None and user.login.lower() == account.login.lower()
294
+ ) or (
295
+ team is not None
296
+ and f"{organization}/{team.slug.lower()}" in memberships.teams
297
+ )
298
+ if addressed:
299
+ key = f"request:{event.id:020d}"
300
+ requests[key] = RequestEvent(key, timestamp(event.created_at))
301
+ elif endpoint.endswith("/comments"):
302
+ comment = parse(Comment, value)
303
+ kind = "inline" if "/pulls/" in endpoint else "discussion"
304
+ key = f"{kind}:{comment.id:020d}"
305
+ comments[key] = CommentEvent(
306
+ key,
307
+ timestamp(comment.created_at),
308
+ comment.user.login if comment.user else "[deleted]",
309
+ comment.body,
310
+ comment.commit_id or "",
311
+ f"review:{comment.pull_request_review_id:020d}"
312
+ if comment.pull_request_review_id is not None
313
+ else None,
314
+ )
315
+ else:
316
+ review = parse(Review, value)
317
+ if review.submitted_at is None or review.user is None:
318
+ continue
319
+ key = f"review:{review.id:020d}"
320
+ reviews[key] = ReviewEvent(
321
+ key,
322
+ timestamp(review.submitted_at),
323
+ review.user.login,
324
+ review.state,
325
+ review.commit_id or "",
326
+ review.body or "",
327
+ )
328
+ if not response.next_page:
329
+ break
330
+ except ForgeError, ValidationError:
331
+ warnings.append("Review activity is incomplete; activity transitions deferred")
332
+ break
333
+ else:
334
+ warnings.append("Review activity pagination limit reached")
335
+ return ReviewActivity(
336
+ account,
337
+ tuple(requests.values()),
338
+ tuple(reviews.values()),
339
+ not warnings,
340
+ tuple(dict.fromkeys(warnings)),
341
+ tuple(comments.values()),
342
+ )
343
+
344
+ async def health(self) -> ForgeHealth:
345
+ account = await self.whoami()
346
+ teams = await self.team_membership() if self.include_team_requests else None
347
+ # /user uses the core quota. A separate search quota check is deferred
348
+ # until discovery/search runs; these limits never imply full token access.
349
+ return ForgeHealth(account, tuple(self.http.limits.values()), teams)
350
+
351
+ async def close(self) -> None:
352
+ await self.http.close()
@@ -0,0 +1,49 @@
1
+ """Noninteractive auth resolution; credentials never enter config, storage, or diagnostics."""
2
+
3
+ import asyncio
4
+ import os
5
+ from collections.abc import Awaitable, Callable
6
+
7
+ from diffbutler.forges.base import ForgeError
8
+ from diffbutler.forges.github.http import api_url
9
+
10
+
11
+ async def gh_token(argv: tuple[str, ...]) -> str | None:
12
+ try:
13
+ process = await asyncio.create_subprocess_exec(
14
+ *argv,
15
+ stdin=asyncio.subprocess.DEVNULL,
16
+ stdout=asyncio.subprocess.PIPE,
17
+ stderr=asyncio.subprocess.DEVNULL,
18
+ cwd="/",
19
+ )
20
+ except OSError:
21
+ return None
22
+ try:
23
+ stdout, _ = await asyncio.wait_for(process.communicate(), timeout=10)
24
+ return stdout.decode("utf-8").strip() if process.returncode == 0 else None
25
+ except TimeoutError, UnicodeDecodeError:
26
+ return None
27
+ finally:
28
+ if process.returncode is None:
29
+ process.kill()
30
+ await process.wait()
31
+
32
+
33
+ async def resolve_token(
34
+ base_url: str,
35
+ token_env: str,
36
+ *,
37
+ run: Callable[[tuple[str, ...]], Awaitable[str | None]] = gh_token,
38
+ ) -> str:
39
+ url = api_url(base_url)
40
+ token = os.environ.get(token_env)
41
+ if not token:
42
+ hostname = "github.com" if url.host == "api.github.com" else url.host
43
+ token = await run(("gh", "auth", "token", "--hostname", hostname))
44
+ if not token or not token.isascii() or any(character.isspace() for character in token):
45
+ raise ForgeError(
46
+ "GitHub credentials unavailable; set the configured token_env "
47
+ "or log in with gh for this host"
48
+ )
49
+ return token