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