compforge-repokit 0.15.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.
@@ -0,0 +1,440 @@
1
+ """GitHubForge — the GitHub adapter for the `Forge` port.
2
+
3
+ Maps GitHub's pull-request REST surface onto the neutral domain: PR `number` (already
4
+ neutral), `/pulls` paths, `Authorization: Bearer` auth. GitHub's state model (open/closed +
5
+ a separate `merged`/`merged_at`) collapses to the neutral `open|merged|closed` here — that
6
+ normalization is exactly what this adapter exists for. The recent-window *policy* is not
7
+ here — it's the caller, composed over `recent` + `get`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from harness_common import Forge as ForgeIdentity
16
+ from harness_common import Repository
17
+
18
+ from ._http_state import HttpState
19
+ from ._page import page_continuation, page_position
20
+ from ._rest import RestClient
21
+ from .model import (
22
+ Comment,
23
+ CommentResolution,
24
+ Forge,
25
+ ForgeError,
26
+ ForgeNotFound,
27
+ Page,
28
+ PullRequest,
29
+ Release,
30
+ )
31
+
32
+
33
+ class GitHubForge(Forge):
34
+ provider = "github"
35
+
36
+ def __init__(
37
+ self,
38
+ host: str,
39
+ owner: str,
40
+ name: str,
41
+ token: str,
42
+ *,
43
+ timeout: int = 10,
44
+ state_dir: str | Path | None = None,
45
+ ):
46
+ # github.com → api.github.com; GitHub Enterprise → https://<host>/api/v3
47
+ api = "https://api.github.com" if host == "github.com" else f"https://{host}/api/v3"
48
+ graphql_api = (
49
+ "https://api.github.com/graphql"
50
+ if host == "github.com"
51
+ else f"https://{host}/api/graphql"
52
+ )
53
+ self.owner, self.name = owner, name
54
+ self.host, self._token, self.timeout = host, token, timeout
55
+ self.state_dir = state_dir
56
+ self.path = f"{owner}/{name}"
57
+ headers = {
58
+ "Authorization": f"Bearer {token}",
59
+ "Accept": "application/vnd.github+json",
60
+ "X-GitHub-Api-Version": "2022-11-28",
61
+ }
62
+ state = HttpState(Path(state_dir), host, token) if state_dir is not None else None
63
+ self.c = RestClient(f"{api}/repos/{owner}/{name}", headers, timeout=timeout, state=state)
64
+ self.g = RestClient(graphql_api, headers, timeout=timeout, state=state, resource="graphql")
65
+ self._instance = RestClient(api, headers, timeout=timeout, state=state)
66
+ self._head_sha_memo: dict[int, str] = {} # PR number → head sha(同一轮 N 条 inline 共用)
67
+
68
+ def list_repositories(
69
+ self, *, page_size: int | None = None, cursor: str | None = None
70
+ ) -> Page[Repository]:
71
+ scope = f"github:{self.host}:repositories"
72
+ page, size = page_position(scope, page_size, cursor)
73
+ rows = self._instance.get(
74
+ "user/repos", sort="full_name", direction="asc", per_page=size, page=page
75
+ )
76
+ if not isinstance(rows, list):
77
+ raise ForgeError("GitHub repository page must be a list")
78
+ if len(rows) > size:
79
+ raise ForgeError("Repository response exceeds requested page size")
80
+ items = []
81
+ for row in rows:
82
+ path = row.get("full_name") if isinstance(row, dict) else None
83
+ if (
84
+ not isinstance(path, str)
85
+ or len(path.split("/")) != 2
86
+ or any(p in ("", ".", "..") for p in path.split("/"))
87
+ ):
88
+ raise ForgeError("Invalid GitHub repository full_name")
89
+ items.append(
90
+ Repository(
91
+ ForgeIdentity("github" if self.host == "github.com" else self.host), path
92
+ )
93
+ )
94
+ return Page(tuple(items), page_continuation(scope, page, size, len(rows) >= size))
95
+
96
+ def repository(self) -> Repository:
97
+ data = self.c.get("")
98
+ parent = (data.get("parent") or {}).get("full_name") or None
99
+ return Repository(
100
+ ForgeIdentity("github" if self.host == "github.com" else self.host),
101
+ data["full_name"],
102
+ forked_from=parent,
103
+ )
104
+
105
+ def for_repository(self, path: str) -> GitHubForge:
106
+ owner, sep, name = path.partition("/")
107
+ if not sep or not owner or not name or "/" in name:
108
+ raise ValueError(f"invalid GitHub repository path: {path!r}")
109
+ return GitHubForge(
110
+ self.host,
111
+ owner,
112
+ name,
113
+ self._token,
114
+ timeout=self.timeout,
115
+ state_dir=self.state_dir,
116
+ )
117
+
118
+ def _repository_client(self, repository: Repository) -> GitHubForge:
119
+ forge = ForgeIdentity("github" if self.host == "github.com" else self.host)
120
+ if repository.forge != forge:
121
+ raise ValueError(
122
+ f"repository {repository.id()!r} does not belong to Forge {forge.name!r}"
123
+ )
124
+ return self.for_repository(repository.path)
125
+
126
+ def _graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
127
+ response = self.g.post("", {"query": query, "variables": variables})
128
+ if not isinstance(response, dict):
129
+ raise ForgeError("GitHub GraphQL returned a non-object response")
130
+ errors = response.get("errors") or []
131
+ if errors:
132
+ messages = [
133
+ str(error.get("message") or error) if isinstance(error, dict) else str(error)
134
+ for error in errors
135
+ ]
136
+ raise ForgeError(f"GitHub GraphQL: {'; '.join(messages)}")
137
+ data = response.get("data")
138
+ if not isinstance(data, dict):
139
+ raise ForgeError("GitHub GraphQL response has no data object")
140
+ return data
141
+
142
+ def _review_threads(self, number: int) -> dict[str, tuple[str, bool]]:
143
+ """REST exposes review-comment ids but only GraphQL exposes resolvable threads."""
144
+ query = """
145
+ query ReviewThreads($owner: String!, $name: String!, $number: Int!, $cursor: String) {
146
+ repository(owner: $owner, name: $name) {
147
+ pullRequest(number: $number) {
148
+ reviewThreads(first: 100, after: $cursor) {
149
+ nodes {
150
+ id
151
+ isResolved
152
+ comments(first: 1) { nodes { fullDatabaseId } }
153
+ }
154
+ pageInfo { hasNextPage endCursor }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ """
160
+ threads: dict[str, tuple[str, bool]] = {}
161
+ cursor = None
162
+ while True:
163
+ data = self._graphql(
164
+ query,
165
+ {
166
+ "owner": self.owner,
167
+ "name": self.name,
168
+ "number": number,
169
+ "cursor": cursor,
170
+ },
171
+ )
172
+ connection = ((data.get("repository") or {}).get("pullRequest") or {}).get(
173
+ "reviewThreads"
174
+ )
175
+ if not isinstance(connection, dict):
176
+ raise ForgeError(f"PR #{number}: GitHub GraphQL response has no reviewThreads")
177
+ for thread in connection.get("nodes") or []:
178
+ nodes = (thread.get("comments") or {}).get("nodes") or []
179
+ comment_id = str((nodes[0] if nodes else {}).get("fullDatabaseId") or "")
180
+ thread_id = str(thread.get("id") or "")
181
+ if comment_id and thread_id:
182
+ threads[comment_id] = (thread_id, bool(thread.get("isResolved")))
183
+ page_info = connection.get("pageInfo") or {}
184
+ if not page_info.get("hasNextPage"):
185
+ return threads
186
+ cursor = page_info.get("endCursor")
187
+ if not cursor:
188
+ raise ForgeError(f"PR #{number}: GitHub reviewThreads pagination has no cursor")
189
+
190
+ def _to_pr(self, d: dict[str, Any]) -> PullRequest:
191
+ # `merged` is only on the single-PR response; list items carry `merged_at`.
192
+ merged = bool(d.get("merged") or d.get("merged_at"))
193
+ gh_state = d.get("state", "")
194
+ state = "merged" if merged else ("open" if gh_state == "open" else "closed")
195
+ return PullRequest(
196
+ number=int(d["number"]),
197
+ title=d.get("title", ""),
198
+ state=state,
199
+ source_branch=(d.get("head") or {}).get("ref", ""),
200
+ target_branch=(d.get("base") or {}).get("ref", ""),
201
+ web_url=d.get("html_url", ""),
202
+ sha=(d.get("head") or {}).get("sha", "") or "",
203
+ updated_at=d.get("updated_at"),
204
+ source_repository=((d.get("head") or {}).get("repo") or {}).get("full_name", ""),
205
+ target_repository=((d.get("base") or {}).get("repo") or {}).get("full_name", ""),
206
+ )
207
+
208
+ def _list(self, **params: Any) -> list[PullRequest]:
209
+ params.setdefault("state", "all")
210
+ params.setdefault("sort", "created")
211
+ params.setdefault("direction", "desc")
212
+ out = self.c.get("pulls", **params)
213
+ return [self._to_pr(d) for d in out] if isinstance(out, list) else []
214
+
215
+ def prs_for_branch(
216
+ self, branch: str, *, source_repository: Repository | None = None
217
+ ) -> list[PullRequest]:
218
+ source = self._repository_client(source_repository) if source_repository else self
219
+ owner = source.path.split("/", 1)[0]
220
+ rows = self.c.get_all(
221
+ "pulls", head=f"{owner}:{branch}", state="all", sort="created", direction="desc"
222
+ )
223
+ # GitHub filters by owner, not repository; same-owner forks can share branch names.
224
+ return [
225
+ self._to_pr(row)
226
+ for row in rows
227
+ if ((row.get("head") or {}).get("repo") or {}).get("full_name", "").lower()
228
+ == source.path.lower()
229
+ ]
230
+
231
+ def recent(self, limit: int) -> list[PullRequest]:
232
+ return self._list(per_page=limit)
233
+
234
+ def open_pull_requests(self) -> list[PullRequest]:
235
+ rows = self.c.get_all("pulls", state="open", sort="created", direction="desc")
236
+ return [
237
+ self._to_pr(row)
238
+ for row in rows
239
+ if ((row.get("head") or {}).get("repo") or {}).get("full_name", "").lower()
240
+ == self.path.lower()
241
+ ]
242
+
243
+ def request_counts(self) -> dict[str, int]:
244
+ return {
245
+ "requests": self.c.requests + self.g.requests + self._instance.requests,
246
+ "not_modified": self.c.not_modified + self.g.not_modified + self._instance.not_modified,
247
+ }
248
+
249
+ def merge(self, number: int, *, expected_sha: str) -> PullRequest:
250
+ if not expected_sha:
251
+ raise ValueError("merge requires expected_sha")
252
+ result = self.c.put(f"pulls/{number}/merge", {"sha": expected_sha})
253
+ if not isinstance(result, dict) or result.get("merged") is not True:
254
+ raise ForgeError("GitHub did not confirm merge")
255
+ return self.get(number)
256
+
257
+ def get(self, number: int) -> PullRequest:
258
+ return self._to_pr(self.c.get(f"pulls/{number}"))
259
+
260
+ def default_branch(self) -> str:
261
+ return (self.c.get("") or {}).get("default_branch") or "" # GET /repos/{owner}/{name}
262
+
263
+ def description(self, number: int) -> str:
264
+ return self.c.get(f"pulls/{number}").get("body") or ""
265
+
266
+ def create(
267
+ self,
268
+ *,
269
+ source_repository: Repository,
270
+ source_branch: str,
271
+ target_repository: Repository,
272
+ target_branch: str,
273
+ title: str,
274
+ body: str = "",
275
+ ) -> PullRequest:
276
+ source = self._repository_client(source_repository)
277
+ target = self._repository_client(target_repository)
278
+ return self._to_pr(
279
+ target.c.post(
280
+ "pulls",
281
+ {
282
+ "title": title,
283
+ "head": f"{source.path.split('/', 1)[0]}:{source_branch}",
284
+ "head_repo": source.path.split("/", 1)[1],
285
+ "base": target_branch,
286
+ "body": body,
287
+ },
288
+ )
289
+ )
290
+
291
+ def update(self, number: int, **fields: str) -> PullRequest:
292
+ body = {}
293
+ if "title" in fields:
294
+ body["title"] = fields["title"]
295
+ if "body" in fields:
296
+ body["body"] = fields["body"]
297
+ if "target_branch" in fields:
298
+ body["base"] = fields["target_branch"]
299
+ return self._to_pr(self.c.patch(f"pulls/{number}", body))
300
+
301
+ def close(self, number: int) -> PullRequest:
302
+ return self._to_pr(self.c.patch(f"pulls/{number}", {"state": "closed"}))
303
+
304
+ def _to_release(self, d: dict[str, Any]) -> Release:
305
+ return Release(
306
+ tag=d.get("tag_name", ""),
307
+ name=d.get("name") or d.get("tag_name", "") or "",
308
+ target=d.get("target_commitish", "") or "",
309
+ web_url=d.get("html_url", ""),
310
+ created_at=d.get("published_at") or d.get("created_at"),
311
+ )
312
+
313
+ def create_release(self, *, tag: str, target: str, name: str = "", notes: str = "") -> Release:
314
+ return self._to_release(
315
+ self.c.post(
316
+ "releases",
317
+ {
318
+ "tag_name": tag,
319
+ "target_commitish": target,
320
+ "name": name or tag,
321
+ "body": notes,
322
+ },
323
+ )
324
+ )
325
+
326
+ def latest_release(self) -> Release | None:
327
+ # /releases/latest is the newest full release (excludes drafts + prereleases) — the
328
+ # right baseline for an increment check. 404 = no releases yet → the first release.
329
+ try:
330
+ return self._to_release(self.c.get("releases/latest"))
331
+ except ForgeNotFound:
332
+ return None
333
+
334
+ def comments(self, number: int) -> list[Comment]:
335
+ # GitHub splits a PR's comments across two endpoints — conversation comments live on
336
+ # the ISSUE surface, while review roots and replies live on the PULLS surface. Group
337
+ # review replies here so callers receive one top-level Comment per interaction.
338
+ issue = self.c.get_all(f"issues/{number}/comments")
339
+ review = self.c.get_all(f"pulls/{number}/comments")
340
+ thread_refs = self._review_threads(number)
341
+ replies: dict[str, list[dict[str, Any]]] = {}
342
+ roots = []
343
+ for row in review:
344
+ parent = str(row.get("in_reply_to_id") or "")
345
+ if parent:
346
+ replies.setdefault(parent, []).append(row)
347
+ else:
348
+ roots.append(row)
349
+
350
+ comments = [self._to_comment(row) for row in issue]
351
+ for root in roots:
352
+ cid = str(root.get("id") or "")
353
+ comment = self._to_comment(root, anchored=True)
354
+ comment.reply_ref = cid
355
+ thread_ref = thread_refs.get(cid)
356
+ if thread_ref:
357
+ comment.resolve_ref = thread_ref[0]
358
+ comment.resolution = (
359
+ CommentResolution.RESOLVED if thread_ref[1] else CommentResolution.UNRESOLVED
360
+ )
361
+ comment.replies = [
362
+ self._to_comment(row, anchored=True)
363
+ for row in sorted(
364
+ replies.get(cid, []),
365
+ key=lambda item: item.get("created_at") or "",
366
+ )
367
+ ]
368
+ comments.append(comment)
369
+ return sorted(comments, key=lambda comment: comment.created_at)
370
+
371
+ @staticmethod
372
+ def _to_comment(n: dict[str, Any], *, anchored: bool = False) -> Comment:
373
+ return Comment(
374
+ author=(n.get("user") or {}).get("login", "?"),
375
+ body=n.get("body") or "",
376
+ id=str(n.get("id") or ""),
377
+ path=(n.get("path") or "") if anchored else "",
378
+ # `line` goes null once a push makes the anchor outdated — `original_line` still
379
+ # says where it was written, which is what a reader needs.
380
+ line=(n.get("line") or n.get("original_line")) if anchored else None,
381
+ created_at=n.get("created_at") or "",
382
+ )
383
+
384
+ def comment(
385
+ self,
386
+ number: int,
387
+ body: str,
388
+ *,
389
+ replyable: bool = False,
390
+ path: str = "",
391
+ line: int | None = None,
392
+ ) -> None:
393
+ if not replyable:
394
+ if path or line is not None:
395
+ raise ForgeError(f"PR #{number}: a standalone comment cannot have a diff anchor")
396
+ self.c.post(f"issues/{number}/comments", {"body": body})
397
+ return
398
+ if not path:
399
+ raise ForgeError(f"PR #{number}: GitHub replyable comments require a diff path")
400
+ # GitHub replyable comments live on the review surface. Anchoring lets GitHub mark
401
+ # them outdated after later pushes; one posting round shares the current head sha.
402
+ if number not in self._head_sha_memo:
403
+ sha = (self.c.get(f"pulls/{number}").get("head") or {}).get("sha") or ""
404
+ if not sha:
405
+ raise ForgeError(f"PR #{number} has no head sha — cannot anchor a diff comment")
406
+ self._head_sha_memo[number] = sha
407
+ req: dict[str, Any] = {"body": body, "commit_id": self._head_sha_memo[number], "path": path}
408
+ if line is None:
409
+ # File-level. `line`/`side` are omitted rather than nulled: the docs only say
410
+ # line is "required unless using subject_type:file", so a null is untested
411
+ # ground — sending no key is the shape the documented contract describes.
412
+ req["subject_type"] = "file"
413
+ else:
414
+ req |= {"line": line, "side": "RIGHT"}
415
+ self.c.post(f"pulls/{number}/comments", req)
416
+
417
+ def reply(self, number: int, target: Comment, body: str) -> None:
418
+ if not target.reply_ref:
419
+ raise ForgeError(
420
+ f"PR #{number}: comment {target.id or '?'} is a conversation "
421
+ "comment — GitHub can only reply to review comments"
422
+ )
423
+ self.c.post(f"pulls/{number}/comments/{target.reply_ref}/replies", {"body": body})
424
+
425
+ def resolve_comment(self, number: int, target: Comment) -> None:
426
+ if not target.resolve_ref:
427
+ raise ForgeError(f"PR #{number}: comment {target.id or '?'} is not resolvable")
428
+ mutation = """
429
+ mutation ResolveReviewThread($thread: ID!) {
430
+ resolveReviewThread(input: {threadId: $thread}) {
431
+ thread { id isResolved }
432
+ }
433
+ }
434
+ """
435
+ data = self._graphql(mutation, {"thread": target.resolve_ref})
436
+ thread = (data.get("resolveReviewThread") or {}).get("thread") or {}
437
+ if not thread.get("isResolved"):
438
+ raise ForgeError(
439
+ f"PR #{number}: GitHub did not resolve review thread {target.resolve_ref}"
440
+ )