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