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/gitlab.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""GitLabForge — the GitLab adapter for the `Forge` port.
|
|
2
|
+
|
|
3
|
+
Maps GitLab's merge-request REST surface onto the neutral domain: `iid`→`number`,
|
|
4
|
+
`opened`→`open`, `/merge_requests` paths. All GitLab-specific shape lives here; callers
|
|
5
|
+
see only `PullRequest` / `Comment`. The recent-window *policy* is not here — it's
|
|
6
|
+
the caller, composed over `recent` + `get`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import urllib.parse
|
|
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
|
+
MergeReadiness,
|
|
27
|
+
Page,
|
|
28
|
+
PullRequest,
|
|
29
|
+
Release,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# GitLab persisted state → neutral.
|
|
33
|
+
_STATE_IN = {"opened": "open", "merged": "merged", "closed": "closed", "locked": "closed"}
|
|
34
|
+
|
|
35
|
+
# GitLab `detailed_merge_status` → neutral MergeReadiness. Anything unlisted (checking,
|
|
36
|
+
# preparing, unchecked, need_rebase, broken_status, …) falls through to UNKNOWN — the safe
|
|
37
|
+
# value while GitLab is still computing, or for unknown provider statuses.
|
|
38
|
+
_READINESS_IN = {
|
|
39
|
+
"mergeable": MergeReadiness.READY,
|
|
40
|
+
"conflict": MergeReadiness.CONFLICT,
|
|
41
|
+
"discussions_not_resolved": MergeReadiness.DISCUSSIONS_UNRESOLVED,
|
|
42
|
+
"draft_status": MergeReadiness.DRAFT,
|
|
43
|
+
"not_approved": MergeReadiness.NEEDS_APPROVAL,
|
|
44
|
+
"ci_must_pass": MergeReadiness.CI_BLOCKED,
|
|
45
|
+
"ci_still_running": MergeReadiness.CI_BLOCKED,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GitLabForge(Forge):
|
|
50
|
+
provider = "gitlab"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
host: str,
|
|
55
|
+
path: str,
|
|
56
|
+
token: str,
|
|
57
|
+
*,
|
|
58
|
+
timeout: int = 10,
|
|
59
|
+
state_dir: str | Path | None = None,
|
|
60
|
+
):
|
|
61
|
+
self.host, self.path, self._token, self.timeout = host, path, token, timeout
|
|
62
|
+
self.state_dir = state_dir
|
|
63
|
+
enc = urllib.parse.quote(path, safe="")
|
|
64
|
+
self.c = RestClient(
|
|
65
|
+
f"https://{host}/api/v4/projects/{enc}",
|
|
66
|
+
{"PRIVATE-TOKEN": token},
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
state=HttpState(Path(state_dir), host, token) if state_dir is not None else None,
|
|
69
|
+
)
|
|
70
|
+
self._instance = RestClient(
|
|
71
|
+
f"https://{host}/api/v4", {"PRIVATE-TOKEN": token}, timeout=timeout, state=self.c.state
|
|
72
|
+
)
|
|
73
|
+
self._diff_refs_memo: dict[
|
|
74
|
+
int, dict[str, Any]
|
|
75
|
+
] = {} # MR iid → diff_refs(同一轮 N 条 inline 共用)
|
|
76
|
+
|
|
77
|
+
def list_repositories(
|
|
78
|
+
self, *, page_size: int | None = None, cursor: str | None = None
|
|
79
|
+
) -> Page[Repository]:
|
|
80
|
+
scope = f"gitlab:{self.host}:repositories"
|
|
81
|
+
page, size = page_position(scope, page_size, cursor)
|
|
82
|
+
rows = self._instance.get(
|
|
83
|
+
"projects",
|
|
84
|
+
membership="true",
|
|
85
|
+
simple="true",
|
|
86
|
+
order_by="id",
|
|
87
|
+
sort="asc",
|
|
88
|
+
per_page=size,
|
|
89
|
+
page=page,
|
|
90
|
+
)
|
|
91
|
+
if not isinstance(rows, list):
|
|
92
|
+
raise ForgeError("GitLab repository page must be a list")
|
|
93
|
+
if len(rows) > size:
|
|
94
|
+
raise ForgeError("Repository response exceeds requested page size")
|
|
95
|
+
items = []
|
|
96
|
+
for row in rows:
|
|
97
|
+
path = row.get("path_with_namespace") if isinstance(row, dict) else None
|
|
98
|
+
if not isinstance(path, str) or any(p in ("", ".", "..") for p in path.split("/")):
|
|
99
|
+
raise ForgeError("Invalid GitLab repository path_with_namespace")
|
|
100
|
+
items.append(
|
|
101
|
+
Repository(
|
|
102
|
+
ForgeIdentity("gitlab" if self.host == "gitlab.com" else self.host), path
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return Page(tuple(items), page_continuation(scope, page, size, len(rows) >= size))
|
|
106
|
+
|
|
107
|
+
def repository(self) -> Repository:
|
|
108
|
+
data = self.c.get("")
|
|
109
|
+
parent = (data.get("forked_from_project") or {}).get("path_with_namespace") or None
|
|
110
|
+
return Repository(
|
|
111
|
+
ForgeIdentity("gitlab" if self.host == "gitlab.com" else self.host),
|
|
112
|
+
data["path_with_namespace"],
|
|
113
|
+
forked_from=parent,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def for_repository(self, path: str) -> GitLabForge:
|
|
117
|
+
if not path or ":" in path or path.startswith("/"):
|
|
118
|
+
raise ValueError(f"invalid GitLab repository path: {path!r}")
|
|
119
|
+
return GitLabForge(
|
|
120
|
+
self.host, path, self._token, timeout=self.timeout, state_dir=self.state_dir
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _repository_client(self, repository: Repository) -> GitLabForge:
|
|
124
|
+
forge = ForgeIdentity("gitlab" if self.host == "gitlab.com" else self.host)
|
|
125
|
+
if repository.forge != forge:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"repository {repository.id()!r} does not belong to Forge {forge.name!r}"
|
|
128
|
+
)
|
|
129
|
+
return self.for_repository(repository.path)
|
|
130
|
+
|
|
131
|
+
def _to_pr(self, d: dict[str, Any]) -> PullRequest:
|
|
132
|
+
return PullRequest(
|
|
133
|
+
number=int(d["iid"]),
|
|
134
|
+
title=d.get("title", ""),
|
|
135
|
+
state=_STATE_IN.get(d.get("state", ""), d.get("state", "")),
|
|
136
|
+
source_branch=d.get("source_branch", ""),
|
|
137
|
+
target_branch=d.get("target_branch", ""),
|
|
138
|
+
web_url=d.get("web_url", ""),
|
|
139
|
+
sha=d.get("sha", "") or "",
|
|
140
|
+
updated_at=d.get("updated_at"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _list(self, **params: Any) -> list[PullRequest]:
|
|
144
|
+
params.setdefault("order_by", "created_at")
|
|
145
|
+
params.setdefault("sort", "desc")
|
|
146
|
+
out = self.c.get("merge_requests", **params)
|
|
147
|
+
return [self._to_pr(d) for d in out] if isinstance(out, list) else []
|
|
148
|
+
|
|
149
|
+
def prs_for_branch(
|
|
150
|
+
self, branch: str, *, source_repository: Repository | None = None
|
|
151
|
+
) -> list[PullRequest]:
|
|
152
|
+
source_client = self._repository_client(source_repository) if source_repository else self
|
|
153
|
+
source = source_client.c.get("")
|
|
154
|
+
rows = self.c.get_all(
|
|
155
|
+
"merge_requests", source_branch=branch, state="all", order_by="created_at", sort="desc"
|
|
156
|
+
)
|
|
157
|
+
# The project endpoint includes incoming forks; branch names alone are ambiguous.
|
|
158
|
+
result = []
|
|
159
|
+
for row in rows:
|
|
160
|
+
if row.get("source_project_id") == source["id"]:
|
|
161
|
+
pr = self._to_pr(row)
|
|
162
|
+
pr.source_repository = source_client.path
|
|
163
|
+
pr.target_repository = self.path
|
|
164
|
+
result.append(pr)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
def recent(self, limit: int) -> list[PullRequest]:
|
|
168
|
+
return self._list(state="all", per_page=limit)
|
|
169
|
+
|
|
170
|
+
def open_pull_requests(self) -> list[PullRequest]:
|
|
171
|
+
source = self.c.get("")
|
|
172
|
+
rows = self.c.get_all("merge_requests", state="opened", order_by="created_at", sort="desc")
|
|
173
|
+
result = []
|
|
174
|
+
for row in rows:
|
|
175
|
+
if row.get("source_project_id") == source["id"]:
|
|
176
|
+
pr = self._to_pr(row)
|
|
177
|
+
pr.source_repository = source["path_with_namespace"]
|
|
178
|
+
pr.target_repository = self.path
|
|
179
|
+
result.append(pr)
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
def request_counts(self) -> dict[str, int]:
|
|
183
|
+
return {
|
|
184
|
+
"requests": self.c.requests + self._instance.requests,
|
|
185
|
+
"not_modified": self.c.not_modified + self._instance.not_modified,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def merge(self, number: int, *, expected_sha: str) -> PullRequest:
|
|
189
|
+
if not expected_sha:
|
|
190
|
+
raise ValueError("merge requires expected_sha")
|
|
191
|
+
return self._to_pr(self.c.put(f"merge_requests/{number}/merge", {"sha": expected_sha}))
|
|
192
|
+
|
|
193
|
+
def get(self, number: int) -> PullRequest:
|
|
194
|
+
data = self.c.get(f"merge_requests/{number}")
|
|
195
|
+
pr = self._to_pr(data)
|
|
196
|
+
pr.target_repository = self.path
|
|
197
|
+
source_id = data.get("source_project_id")
|
|
198
|
+
if source_id:
|
|
199
|
+
pr.source_repository = self.for_repository(str(source_id)).c.get("")[
|
|
200
|
+
"path_with_namespace"
|
|
201
|
+
]
|
|
202
|
+
return pr
|
|
203
|
+
|
|
204
|
+
def default_branch(self) -> str:
|
|
205
|
+
return (self.c.get("") or {}).get("default_branch") or "" # GET /projects/{id}
|
|
206
|
+
|
|
207
|
+
def merge_readiness(self, number: int) -> MergeReadiness:
|
|
208
|
+
return self._readiness(self.c.get(f"merge_requests/{number}"))
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _readiness(d: dict[str, Any]) -> MergeReadiness:
|
|
212
|
+
"""Map a raw MR dict to neutral readiness. Prefer the modern `detailed_merge_status`;
|
|
213
|
+
fall back to the boolean `has_conflicts` (older GitLab, or an unmapped status). Anything
|
|
214
|
+
else → UNKNOWN, which includes the async 'checking'/'unchecked' window."""
|
|
215
|
+
status = _READINESS_IN.get(d.get("detailed_merge_status") or "")
|
|
216
|
+
if status is not None:
|
|
217
|
+
return status
|
|
218
|
+
if d.get("has_conflicts"):
|
|
219
|
+
return MergeReadiness.CONFLICT
|
|
220
|
+
return MergeReadiness.UNKNOWN
|
|
221
|
+
|
|
222
|
+
def description(self, number: int) -> str:
|
|
223
|
+
return self.c.get(f"merge_requests/{number}").get("description") or ""
|
|
224
|
+
|
|
225
|
+
def create(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
source_repository: Repository,
|
|
229
|
+
source_branch: str,
|
|
230
|
+
target_repository: Repository,
|
|
231
|
+
target_branch: str,
|
|
232
|
+
title: str,
|
|
233
|
+
body: str = "",
|
|
234
|
+
) -> PullRequest:
|
|
235
|
+
source = self._repository_client(source_repository)
|
|
236
|
+
target_client = self._repository_client(target_repository)
|
|
237
|
+
target = target_client.c.get("")
|
|
238
|
+
result = self._to_pr(
|
|
239
|
+
source.c.post(
|
|
240
|
+
"merge_requests",
|
|
241
|
+
{
|
|
242
|
+
"source_branch": source_branch,
|
|
243
|
+
"target_branch": target_branch,
|
|
244
|
+
"target_project_id": target["id"],
|
|
245
|
+
"title": title,
|
|
246
|
+
"description": body,
|
|
247
|
+
"remove_source_branch": True,
|
|
248
|
+
"squash": False,
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
result.source_repository = source.path
|
|
253
|
+
result.target_repository = target_client.path
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
def update(self, number: int, **fields: str) -> PullRequest:
|
|
257
|
+
body = {}
|
|
258
|
+
if "title" in fields:
|
|
259
|
+
body["title"] = fields["title"]
|
|
260
|
+
if "body" in fields:
|
|
261
|
+
body["description"] = fields["body"]
|
|
262
|
+
if "target_branch" in fields:
|
|
263
|
+
body["target_branch"] = fields["target_branch"]
|
|
264
|
+
return self._to_pr(self.c.put(f"merge_requests/{number}", body))
|
|
265
|
+
|
|
266
|
+
def close(self, number: int) -> PullRequest:
|
|
267
|
+
return self._to_pr(self.c.put(f"merge_requests/{number}", {"state_event": "close"}))
|
|
268
|
+
|
|
269
|
+
def _to_release(self, d: dict[str, Any]) -> Release:
|
|
270
|
+
links = d.get("_links") or {}
|
|
271
|
+
commit = d.get("commit") or {}
|
|
272
|
+
return Release(
|
|
273
|
+
tag=d.get("tag_name", ""),
|
|
274
|
+
name=d.get("name") or d.get("tag_name", "") or "",
|
|
275
|
+
target=commit.get("id", "") or "", # GitLab resolves ref → the tagged commit sha
|
|
276
|
+
web_url=links.get("self", "") or "",
|
|
277
|
+
created_at=d.get("released_at") or d.get("created_at"),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def create_release(self, *, tag: str, target: str, name: str = "", notes: str = "") -> Release:
|
|
281
|
+
# `ref` creates the tag server-side when it doesn't exist yet (branch name or sha).
|
|
282
|
+
return self._to_release(
|
|
283
|
+
self.c.post(
|
|
284
|
+
"releases",
|
|
285
|
+
{
|
|
286
|
+
"tag_name": tag,
|
|
287
|
+
"ref": target,
|
|
288
|
+
"name": name or tag,
|
|
289
|
+
"description": notes,
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def latest_release(self) -> Release | None:
|
|
295
|
+
# GitLab lists releases released_at-desc by default → first entry is the latest.
|
|
296
|
+
out = self.c.get("releases", per_page=1)
|
|
297
|
+
rels = out if isinstance(out, list) else []
|
|
298
|
+
return self._to_release(rels[0]) if rels else None
|
|
299
|
+
|
|
300
|
+
def comments(self, number: int) -> list[Comment]:
|
|
301
|
+
# One endpoint covers both surfaces: /discussions returns plain notes (as
|
|
302
|
+
# single-note, `individual_note` discussions) AND positioned ones — so unlike
|
|
303
|
+
# GitHub this needs no second fetch. `system` notes are GitLab's activity log
|
|
304
|
+
# ("changed the description"), not comments.
|
|
305
|
+
discussions = self.c.get_all(f"merge_requests/{number}/discussions")
|
|
306
|
+
comments: list[Comment] = []
|
|
307
|
+
for discussion in discussions:
|
|
308
|
+
notes = [note for note in discussion.get("notes", []) if not note.get("system")]
|
|
309
|
+
if not notes:
|
|
310
|
+
continue
|
|
311
|
+
if discussion.get("individual_note"):
|
|
312
|
+
comments.extend(self._to_comment(note) for note in notes)
|
|
313
|
+
continue
|
|
314
|
+
root = notes[0]
|
|
315
|
+
comment = self._to_comment(root)
|
|
316
|
+
comment.replies = [self._to_comment(note) for note in notes[1:]]
|
|
317
|
+
comment.reply_ref = str(discussion.get("id") or "")
|
|
318
|
+
# The MR discussion endpoint is the authority for resolution. Some self-managed
|
|
319
|
+
# GitLab versions omit `resolvable` from the list response even though PUT on the
|
|
320
|
+
# discussion works, so do not let that advisory field hide the mutation handle.
|
|
321
|
+
comment.resolve_ref = comment.reply_ref
|
|
322
|
+
if "resolved" in root:
|
|
323
|
+
comment.resolution = (
|
|
324
|
+
CommentResolution.RESOLVED
|
|
325
|
+
if root.get("resolved")
|
|
326
|
+
else CommentResolution.UNRESOLVED
|
|
327
|
+
)
|
|
328
|
+
comments.append(comment)
|
|
329
|
+
return sorted(comments, key=lambda comment: comment.created_at)
|
|
330
|
+
|
|
331
|
+
@staticmethod
|
|
332
|
+
def _to_comment(note: dict[str, Any]) -> Comment:
|
|
333
|
+
pos = note.get("position") or {}
|
|
334
|
+
return Comment(
|
|
335
|
+
author=(note.get("author") or {}).get("username", "?"),
|
|
336
|
+
body=note.get("body") or "",
|
|
337
|
+
id=str(note.get("id") or ""),
|
|
338
|
+
path=pos.get("new_path") or "",
|
|
339
|
+
line=pos.get("new_line"),
|
|
340
|
+
created_at=note.get("created_at") or "",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def comment(
|
|
344
|
+
self,
|
|
345
|
+
number: int,
|
|
346
|
+
body: str,
|
|
347
|
+
*,
|
|
348
|
+
replyable: bool = False,
|
|
349
|
+
path: str = "",
|
|
350
|
+
line: int | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
if not replyable:
|
|
353
|
+
if path or line is not None:
|
|
354
|
+
raise ForgeError(f"MR !{number}: a standalone comment cannot have a diff anchor")
|
|
355
|
+
self.c.post(f"merge_requests/{number}/notes", {"body": body})
|
|
356
|
+
return
|
|
357
|
+
if not path:
|
|
358
|
+
if line is not None:
|
|
359
|
+
raise ForgeError(f"MR !{number}: a diff line requires a path")
|
|
360
|
+
self.c.post(f"merge_requests/{number}/discussions", {"body": body})
|
|
361
|
+
return
|
|
362
|
+
# Positioned discussion — GitLab re-anchors it on every push and folds it as
|
|
363
|
+
# "outdated" once the lines change; with the project setting
|
|
364
|
+
# `resolve_outdated_diff_discussions` it even auto-resolves then.
|
|
365
|
+
refs = self._diff_refs(number)
|
|
366
|
+
pos = {
|
|
367
|
+
# `file` was introduced in GitLab 16.4, so an older self-managed instance is
|
|
368
|
+
# expected to reject it. Not version-probed: the caller already degrades past a
|
|
369
|
+
# ForgeError, which costs one wasted request instead of one per-instance GET.
|
|
370
|
+
"position_type": "file" if line is None else "text",
|
|
371
|
+
"base_sha": refs.get("base_sha"),
|
|
372
|
+
"start_sha": refs.get("start_sha"),
|
|
373
|
+
"head_sha": refs.get("head_sha"),
|
|
374
|
+
"new_path": path,
|
|
375
|
+
}
|
|
376
|
+
if line is not None:
|
|
377
|
+
pos["new_line"] = line
|
|
378
|
+
self.c.post(f"merge_requests/{number}/discussions", {"body": body, "position": pos})
|
|
379
|
+
|
|
380
|
+
def reply(self, number: int, target: Comment, body: str) -> None:
|
|
381
|
+
if not target.reply_ref:
|
|
382
|
+
raise ForgeError(
|
|
383
|
+
f"MR !{number}: comment {target.id or '?'} is a plain note — "
|
|
384
|
+
"GitLab can only reply inside a discussion"
|
|
385
|
+
)
|
|
386
|
+
self.c.post(f"merge_requests/{number}/discussions/{target.reply_ref}/notes", {"body": body})
|
|
387
|
+
|
|
388
|
+
def resolve_comment(self, number: int, target: Comment) -> None:
|
|
389
|
+
if not target.resolve_ref:
|
|
390
|
+
raise ForgeError(f"MR !{number}: comment {target.id or '?'} is not resolvable")
|
|
391
|
+
self.c.put(f"merge_requests/{number}/discussions/{target.resolve_ref}", {"resolved": True})
|
|
392
|
+
|
|
393
|
+
def _diff_refs(self, number: int) -> dict[str, Any]:
|
|
394
|
+
"""The MR's current diff version (base/start/head sha) a position anchors against.
|
|
395
|
+
Memoized per MR: one review round posts N findings against the same diff."""
|
|
396
|
+
if number not in self._diff_refs_memo:
|
|
397
|
+
refs = self.c.get(f"merge_requests/{number}").get("diff_refs") or {}
|
|
398
|
+
# A text position needs ALL THREE shas — a partial diff_refs would leak None
|
|
399
|
+
# into the position and come back as an opaque HTTP 400. Fail early instead.
|
|
400
|
+
missing = [k for k in ("base_sha", "start_sha", "head_sha") if not refs.get(k)]
|
|
401
|
+
if missing:
|
|
402
|
+
raise ForgeError(
|
|
403
|
+
f"MR !{number} diff_refs missing {'/'.join(missing)} — "
|
|
404
|
+
"cannot anchor a diff comment"
|
|
405
|
+
)
|
|
406
|
+
self._diff_refs_memo[number] = refs
|
|
407
|
+
return self._diff_refs_memo[number]
|