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.
- compforge_repokit-0.15.0.dist-info/METADATA +114 -0
- compforge_repokit-0.15.0.dist-info/RECORD +37 -0
- compforge_repokit-0.15.0.dist-info/WHEEL +4 -0
- repokit/__init__.py +40 -0
- repokit/_git.py +177 -0
- repokit/_inspect.py +70 -0
- repokit/_language.py +23 -0
- repokit/_layout.py +187 -0
- repokit/_process.py +135 -0
- repokit/_staging.py +59 -0
- repokit/budget.py +40 -0
- repokit/dependencies.py +378 -0
- repokit/forge/__init__.py +13 -0
- repokit/forge/_http_state.py +92 -0
- repokit/forge/_page.py +34 -0
- repokit/forge/_rest.py +233 -0
- repokit/forge/creation.py +88 -0
- repokit/forge/github.py +440 -0
- repokit/forge/gitlab.py +407 -0
- repokit/forge/model.py +380 -0
- repokit/forge/observation/__init__.py +9 -0
- repokit/forge/observation/context.py +255 -0
- repokit/forge/observation/model.py +40 -0
- repokit/forge/observation/service.py +283 -0
- repokit/forge/observation/store.py +136 -0
- repokit/git.py +296 -0
- repokit/git_index.py +275 -0
- repokit/git_state.py +447 -0
- repokit/languages.json +1115 -0
- repokit/model.py +59 -0
- repokit/py.typed +0 -0
- repokit/remote.py +41 -0
- repokit/repository.py +84 -0
- repokit/snapshot.py +275 -0
- repokit/stash.py +66 -0
- repokit/tree.py +191 -0
- repokit/validation.py +508 -0
repokit/forge/github.py
ADDED
|
@@ -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
|
+
)
|