mcp-gitlab 0.0.1__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.
mcp_gitlab/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """MCP server for GitLab API."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ import click
7
+ from dotenv import load_dotenv
8
+
9
+
10
+ @click.command()
11
+ @click.option(
12
+ "--transport",
13
+ type=click.Choice(["stdio", "sse", "streamable-http"]),
14
+ default="stdio",
15
+ help="MCP transport type",
16
+ )
17
+ @click.option("--port", default=8000, help="Port for HTTP transports")
18
+ @click.option("--host", default="127.0.0.1", help="Host for HTTP transports")
19
+ @click.option("--gitlab-url", envvar="GITLAB_URL", help="GitLab instance URL")
20
+ @click.option("--gitlab-token", envvar="GITLAB_TOKEN", help="GitLab personal access token")
21
+ @click.option("--read-only", is_flag=True, help="Disable write operations")
22
+ def main(
23
+ transport: str,
24
+ port: int,
25
+ host: str,
26
+ gitlab_url: str | None,
27
+ gitlab_token: str | None,
28
+ read_only: bool,
29
+ ) -> None:
30
+ """Run the GitLab MCP server."""
31
+ load_dotenv()
32
+
33
+ if gitlab_url:
34
+ os.environ["GITLAB_URL"] = gitlab_url
35
+ if gitlab_token:
36
+ os.environ["GITLAB_TOKEN"] = gitlab_token
37
+ if read_only:
38
+ os.environ["GITLAB_READ_ONLY"] = "true"
39
+
40
+ from .servers.gitlab import mcp
41
+
42
+ run_kwargs: dict = {"transport": transport}
43
+ if transport != "stdio":
44
+ run_kwargs["host"] = host
45
+ run_kwargs["port"] = port
46
+
47
+ asyncio.run(mcp.run_async(**run_kwargs))
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
mcp_gitlab/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running with `python -m mcp_gitlab`."""
2
+
3
+ from mcp_gitlab import main
4
+
5
+ main()
mcp_gitlab/client.py ADDED
@@ -0,0 +1,601 @@
1
+ """GitLab API client using httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ import httpx
10
+
11
+ from .config import GitLabConfig
12
+ from .exceptions import GitLabApiError, GitLabAuthError, GitLabNotFoundError
13
+
14
+
15
+ class GitLabClient:
16
+ """Async HTTP client for the GitLab REST API v4."""
17
+
18
+ def __init__(self, config: GitLabConfig | None = None) -> None:
19
+ self.config = config or GitLabConfig.from_env()
20
+ self.config.validate()
21
+ self._client = httpx.AsyncClient(
22
+ base_url=self.config.api_url,
23
+ headers={
24
+ "PRIVATE-TOKEN": self.config.token,
25
+ "Content-Type": "application/json",
26
+ },
27
+ timeout=self.config.timeout,
28
+ verify=self.config.ssl_verify,
29
+ )
30
+
31
+ async def close(self) -> None:
32
+ await self._client.aclose()
33
+
34
+ # ── HTTP helpers ──────────────────────────────────────────────
35
+
36
+ @staticmethod
37
+ def _encode_id(project_id: str | int) -> str:
38
+ """Encode a project/group ID. Numeric IDs pass through; paths are URL-encoded."""
39
+ if isinstance(project_id, int):
40
+ return str(project_id)
41
+ try:
42
+ return str(int(project_id))
43
+ except ValueError:
44
+ return quote(project_id, safe="")
45
+
46
+ async def _request(
47
+ self,
48
+ method: str,
49
+ path: str,
50
+ *,
51
+ json_data: Any = None,
52
+ params: dict[str, Any] | None = None,
53
+ raw: bool = False,
54
+ content: bytes | None = None,
55
+ extra_headers: dict[str, str] | None = None,
56
+ ) -> Any:
57
+ """Make an API request and return parsed JSON (or raw text if raw=True)."""
58
+ headers = {}
59
+ if extra_headers:
60
+ headers.update(extra_headers)
61
+
62
+ kwargs: dict[str, Any] = {"params": params, "headers": headers}
63
+ if json_data is not None:
64
+ kwargs["json"] = json_data
65
+ if content is not None:
66
+ kwargs["content"] = content
67
+
68
+ resp = await self._client.request(method, path, **kwargs)
69
+
70
+ if resp.status_code in (401, 403):
71
+ raise GitLabAuthError(resp.status_code, resp.text)
72
+ if resp.status_code == 404:
73
+ raise GitLabNotFoundError(resp.text)
74
+ if not resp.is_success:
75
+ raise GitLabApiError(resp.status_code, resp.reason_phrase or "", resp.text)
76
+
77
+ if resp.status_code == 204 or not resp.content:
78
+ return None
79
+
80
+ if raw:
81
+ return resp.text
82
+
83
+ content_type = resp.headers.get("content-type", "")
84
+ if "text/html" in content_type:
85
+ msg = "Unexpected HTML response — check URL and authentication"
86
+ raise GitLabApiError(resp.status_code, msg, resp.text[:500])
87
+
88
+ try:
89
+ return resp.json()
90
+ except json.JSONDecodeError as e:
91
+ raise GitLabApiError(
92
+ resp.status_code,
93
+ f"JSON parse error: {e}",
94
+ resp.text[:500],
95
+ ) from e
96
+
97
+ async def get(
98
+ self, path: str, params: dict[str, Any] | None = None, *, raw: bool = False
99
+ ) -> Any:
100
+ return await self._request("GET", path, params=params, raw=raw)
101
+
102
+ async def post(self, path: str, json_data: Any = None, **kwargs: Any) -> Any:
103
+ return await self._request("POST", path, json_data=json_data, **kwargs)
104
+
105
+ async def put(self, path: str, json_data: Any = None, **kwargs: Any) -> Any:
106
+ return await self._request("PUT", path, json_data=json_data, **kwargs)
107
+
108
+ async def delete(self, path: str, **kwargs: Any) -> Any:
109
+ return await self._request("DELETE", path, **kwargs)
110
+
111
+ # ── Projects ──────────────────────────────────────────────────
112
+
113
+ async def get_project(self, project_id: str | int) -> dict:
114
+ enc = self._encode_id(project_id)
115
+ return await self.get(f"/projects/{enc}")
116
+
117
+ async def create_project(self, params: dict[str, Any]) -> dict:
118
+ return await self.post("/projects", params)
119
+
120
+ async def update_project(self, project_id: str | int, params: dict[str, Any]) -> dict:
121
+ enc = self._encode_id(project_id)
122
+ return await self.put(f"/projects/{enc}", params)
123
+
124
+ async def delete_project(self, project_id: str | int) -> None:
125
+ enc = self._encode_id(project_id)
126
+ await self.delete(f"/projects/{enc}")
127
+
128
+ # ── Project approvals ─────────────────────────────────────────
129
+
130
+ async def get_project_approvals(self, project_id: str | int) -> dict:
131
+ enc = self._encode_id(project_id)
132
+ return await self.get(f"/projects/{enc}/approvals")
133
+
134
+ async def update_project_approvals(self, project_id: str | int, params: dict[str, Any]) -> dict:
135
+ enc = self._encode_id(project_id)
136
+ return await self.post(f"/projects/{enc}/approvals", params)
137
+
138
+ async def list_project_approval_rules(self, project_id: str | int) -> list[dict]:
139
+ enc = self._encode_id(project_id)
140
+ return await self.get(f"/projects/{enc}/approval_rules")
141
+
142
+ async def create_project_approval_rule(
143
+ self, project_id: str | int, params: dict[str, Any]
144
+ ) -> dict:
145
+ enc = self._encode_id(project_id)
146
+ return await self.post(f"/projects/{enc}/approval_rules", params)
147
+
148
+ async def update_project_approval_rule(
149
+ self, project_id: str | int, rule_id: int, params: dict[str, Any]
150
+ ) -> dict:
151
+ enc = self._encode_id(project_id)
152
+ return await self.put(f"/projects/{enc}/approval_rules/{rule_id}", params)
153
+
154
+ async def delete_project_approval_rule(self, project_id: str | int, rule_id: int) -> None:
155
+ enc = self._encode_id(project_id)
156
+ await self.delete(f"/projects/{enc}/approval_rules/{rule_id}")
157
+
158
+ # ── MR approval rules ─────────────────────────────────────────
159
+
160
+ async def list_mr_approval_rules(self, project_id: str | int, mr_iid: int) -> list[dict]:
161
+ enc = self._encode_id(project_id)
162
+ return await self.get(f"/projects/{enc}/merge_requests/{mr_iid}/approval_rules")
163
+
164
+ async def create_mr_approval_rule(
165
+ self, project_id: str | int, mr_iid: int, params: dict[str, Any]
166
+ ) -> dict:
167
+ enc = self._encode_id(project_id)
168
+ return await self.post(f"/projects/{enc}/merge_requests/{mr_iid}/approval_rules", params)
169
+
170
+ async def update_mr_approval_rule(
171
+ self, project_id: str | int, mr_iid: int, rule_id: int, params: dict[str, Any]
172
+ ) -> dict:
173
+ enc = self._encode_id(project_id)
174
+ return await self.put(
175
+ f"/projects/{enc}/merge_requests/{mr_iid}/approval_rules/{rule_id}", params
176
+ )
177
+
178
+ async def delete_mr_approval_rule(
179
+ self, project_id: str | int, mr_iid: int, rule_id: int
180
+ ) -> None:
181
+ enc = self._encode_id(project_id)
182
+ await self.delete(f"/projects/{enc}/merge_requests/{mr_iid}/approval_rules/{rule_id}")
183
+
184
+ # ── Groups ────────────────────────────────────────────────────
185
+
186
+ async def list_groups(self, params: dict[str, Any] | None = None) -> list[dict]:
187
+ p = {"per_page": 50, **(params or {})}
188
+ return await self.get("/groups", params=p)
189
+
190
+ async def get_group(self, group_id: str | int) -> dict:
191
+ enc = self._encode_id(group_id)
192
+ return await self.get(f"/groups/{enc}")
193
+
194
+ async def share_project_with_group(
195
+ self, project_id: str | int, group_id: int, group_access: int
196
+ ) -> None:
197
+ enc = self._encode_id(project_id)
198
+ await self.post(
199
+ f"/projects/{enc}/share",
200
+ {"group_id": group_id, "group_access": group_access},
201
+ )
202
+
203
+ async def unshare_project_with_group(self, project_id: str | int, group_id: int) -> None:
204
+ enc = self._encode_id(project_id)
205
+ await self.delete(f"/projects/{enc}/share/{group_id}")
206
+
207
+ async def share_group_with_group(
208
+ self, target_group_id: str | int, source_group_id: int, group_access: int
209
+ ) -> None:
210
+ enc = self._encode_id(target_group_id)
211
+ await self.post(
212
+ f"/groups/{enc}/share",
213
+ {"group_id": source_group_id, "group_access": group_access},
214
+ )
215
+
216
+ async def unshare_group_with_group(
217
+ self, target_group_id: str | int, source_group_id: int
218
+ ) -> None:
219
+ enc = self._encode_id(target_group_id)
220
+ await self.delete(f"/groups/{enc}/share/{source_group_id}")
221
+
222
+ # ── Branches ──────────────────────────────────────────────────
223
+
224
+ async def list_branches(
225
+ self, project_id: str | int, params: dict[str, Any] | None = None
226
+ ) -> list[dict]:
227
+ enc = self._encode_id(project_id)
228
+ p = {"per_page": 100, **(params or {})}
229
+ return await self.get(f"/projects/{enc}/repository/branches", params=p)
230
+
231
+ async def create_branch(self, project_id: str | int, branch: str, ref: str) -> dict:
232
+ enc = self._encode_id(project_id)
233
+ return await self.post(
234
+ f"/projects/{enc}/repository/branches",
235
+ {"branch": branch, "ref": ref},
236
+ )
237
+
238
+ async def delete_branch(self, project_id: str | int, branch: str) -> None:
239
+ enc = self._encode_id(project_id)
240
+ await self.delete(f"/projects/{enc}/repository/branches/{quote(branch, safe='')}")
241
+
242
+ # ── Commits ───────────────────────────────────────────────────
243
+
244
+ async def list_commits(
245
+ self, project_id: str | int, params: dict[str, Any] | None = None
246
+ ) -> list[dict]:
247
+ enc = self._encode_id(project_id)
248
+ p = {"per_page": 40, **(params or {})}
249
+ return await self.get(f"/projects/{enc}/repository/commits", params=p)
250
+
251
+ async def get_commit(self, project_id: str | int, sha: str) -> dict:
252
+ enc = self._encode_id(project_id)
253
+ return await self.get(f"/projects/{enc}/repository/commits/{quote(sha, safe='')}")
254
+
255
+ async def get_commit_diff(self, project_id: str | int, sha: str) -> list[dict]:
256
+ enc = self._encode_id(project_id)
257
+ return await self.get(f"/projects/{enc}/repository/commits/{quote(sha, safe='')}/diff")
258
+
259
+ async def create_commit(self, project_id: str | int, params: dict[str, Any]) -> dict:
260
+ enc = self._encode_id(project_id)
261
+ return await self.post(f"/projects/{enc}/repository/commits", params)
262
+
263
+ async def compare(self, project_id: str | int, from_ref: str, to_ref: str) -> dict:
264
+ enc = self._encode_id(project_id)
265
+ return await self.get(
266
+ f"/projects/{enc}/repository/compare",
267
+ params={"from": from_ref, "to": to_ref},
268
+ )
269
+
270
+ async def list_repository_tree(
271
+ self, project_id: str | int, params: dict[str, Any] | None = None
272
+ ) -> list[dict]:
273
+ enc = self._encode_id(project_id)
274
+ p = {"per_page": 100, "recursive": True, **(params or {})}
275
+ return await self.get(f"/projects/{enc}/repository/tree", params=p)
276
+
277
+ # ── Merge Requests ────────────────────────────────────────────
278
+
279
+ async def list_merge_requests(
280
+ self, project_id: str | int, params: dict[str, Any] | None = None
281
+ ) -> list[dict]:
282
+ enc = self._encode_id(project_id)
283
+ p = {"per_page": 20, **(params or {})}
284
+ return await self.get(f"/projects/{enc}/merge_requests", params=p)
285
+
286
+ async def get_merge_request(self, project_id: str | int, mr_iid: int) -> dict:
287
+ enc = self._encode_id(project_id)
288
+ return await self.get(f"/projects/{enc}/merge_requests/{mr_iid}")
289
+
290
+ async def create_merge_request(self, project_id: str | int, params: dict[str, Any]) -> dict:
291
+ enc = self._encode_id(project_id)
292
+ return await self.post(f"/projects/{enc}/merge_requests", params)
293
+
294
+ async def update_merge_request(
295
+ self, project_id: str | int, mr_iid: int, params: dict[str, Any]
296
+ ) -> dict:
297
+ enc = self._encode_id(project_id)
298
+ return await self.put(f"/projects/{enc}/merge_requests/{mr_iid}", params)
299
+
300
+ async def merge_merge_request(
301
+ self, project_id: str | int, mr_iid: int, params: dict[str, Any] | None = None
302
+ ) -> dict:
303
+ enc = self._encode_id(project_id)
304
+ return await self.put(f"/projects/{enc}/merge_requests/{mr_iid}/merge", params or {})
305
+
306
+ async def rebase_merge_request(
307
+ self, project_id: str | int, mr_iid: int, skip_ci: bool = False
308
+ ) -> dict:
309
+ enc = self._encode_id(project_id)
310
+ return await self.put(
311
+ f"/projects/{enc}/merge_requests/{mr_iid}/rebase",
312
+ {"skip_ci": skip_ci},
313
+ )
314
+
315
+ async def get_merge_request_changes(self, project_id: str | int, mr_iid: int) -> dict:
316
+ enc = self._encode_id(project_id)
317
+ return await self.get(f"/projects/{enc}/merge_requests/{mr_iid}/changes")
318
+
319
+ # ── MR Notes ──────────────────────────────────────────────────
320
+
321
+ async def list_mr_notes(self, project_id: str | int, mr_iid: int) -> list[dict]:
322
+ enc = self._encode_id(project_id)
323
+ return await self.get(
324
+ f"/projects/{enc}/merge_requests/{mr_iid}/notes",
325
+ params={"per_page": 100},
326
+ )
327
+
328
+ async def add_mr_note(
329
+ self,
330
+ project_id: str | int,
331
+ mr_iid: int,
332
+ body: str,
333
+ internal: bool = False,
334
+ ) -> dict:
335
+ enc = self._encode_id(project_id)
336
+ data: dict[str, Any] = {"body": body}
337
+ if internal:
338
+ data["internal"] = True
339
+ return await self.post(f"/projects/{enc}/merge_requests/{mr_iid}/notes", data)
340
+
341
+ async def delete_mr_note(self, project_id: str | int, mr_iid: int, note_id: int) -> None:
342
+ enc = self._encode_id(project_id)
343
+ await self.delete(f"/projects/{enc}/merge_requests/{mr_iid}/notes/{note_id}")
344
+
345
+ async def update_mr_note(
346
+ self, project_id: str | int, mr_iid: int, note_id: int, body: str
347
+ ) -> dict:
348
+ enc = self._encode_id(project_id)
349
+ return await self.put(
350
+ f"/projects/{enc}/merge_requests/{mr_iid}/notes/{note_id}",
351
+ {"body": body},
352
+ )
353
+
354
+ async def award_emoji(
355
+ self, project_id: str | int, mr_iid: int, note_id: int, name: str
356
+ ) -> dict:
357
+ enc = self._encode_id(project_id)
358
+ return await self.post(
359
+ f"/projects/{enc}/merge_requests/{mr_iid}/notes/{note_id}/award_emoji",
360
+ {"name": name},
361
+ )
362
+
363
+ async def delete_award_emoji(
364
+ self, project_id: str | int, mr_iid: int, note_id: int, award_id: int
365
+ ) -> None:
366
+ enc = self._encode_id(project_id)
367
+ await self.delete(
368
+ f"/projects/{enc}/merge_requests/{mr_iid}/notes/{note_id}/award_emoji/{award_id}"
369
+ )
370
+
371
+ # ── MR Discussions ────────────────────────────────────────────
372
+
373
+ async def list_mr_discussions(self, project_id: str | int, mr_iid: int) -> list[dict]:
374
+ enc = self._encode_id(project_id)
375
+ return await self.get(
376
+ f"/projects/{enc}/merge_requests/{mr_iid}/discussions",
377
+ params={"per_page": 100},
378
+ )
379
+
380
+ async def create_mr_discussion(
381
+ self, project_id: str | int, mr_iid: int, params: dict[str, Any]
382
+ ) -> dict:
383
+ enc = self._encode_id(project_id)
384
+ return await self.post(f"/projects/{enc}/merge_requests/{mr_iid}/discussions", params)
385
+
386
+ async def reply_to_discussion(
387
+ self, project_id: str | int, mr_iid: int, discussion_id: str, body: str
388
+ ) -> dict:
389
+ enc = self._encode_id(project_id)
390
+ return await self.post(
391
+ f"/projects/{enc}/merge_requests/{mr_iid}/discussions/{discussion_id}/notes",
392
+ {"body": body},
393
+ )
394
+
395
+ async def resolve_discussion(
396
+ self, project_id: str | int, mr_iid: int, discussion_id: str, resolved: bool
397
+ ) -> dict:
398
+ enc = self._encode_id(project_id)
399
+ return await self.put(
400
+ f"/projects/{enc}/merge_requests/{mr_iid}/discussions/{discussion_id}",
401
+ {"resolved": resolved},
402
+ )
403
+
404
+ # ── Pipelines ─────────────────────────────────────────────────
405
+
406
+ async def list_pipelines(
407
+ self, project_id: str | int, params: dict[str, Any] | None = None
408
+ ) -> list[dict]:
409
+ enc = self._encode_id(project_id)
410
+ p = {"per_page": 20, **(params or {})}
411
+ return await self.get(f"/projects/{enc}/pipelines", params=p)
412
+
413
+ async def get_pipeline(self, project_id: str | int, pipeline_id: int) -> dict:
414
+ enc = self._encode_id(project_id)
415
+ return await self.get(f"/projects/{enc}/pipelines/{pipeline_id}")
416
+
417
+ async def list_pipeline_jobs(self, project_id: str | int, pipeline_id: int) -> list[dict]:
418
+ enc = self._encode_id(project_id)
419
+ return await self.get(
420
+ f"/projects/{enc}/pipelines/{pipeline_id}/jobs",
421
+ params={"per_page": 100},
422
+ )
423
+
424
+ async def create_pipeline(
425
+ self,
426
+ project_id: str | int,
427
+ ref: str,
428
+ variables: list[dict[str, str]] | None = None,
429
+ ) -> dict:
430
+ enc = self._encode_id(project_id)
431
+ data: dict[str, Any] = {"ref": ref}
432
+ if variables:
433
+ data["variables"] = variables
434
+ return await self.post(f"/projects/{enc}/pipeline", data)
435
+
436
+ async def retry_pipeline(self, project_id: str | int, pipeline_id: int) -> dict:
437
+ enc = self._encode_id(project_id)
438
+ return await self.post(f"/projects/{enc}/pipelines/{pipeline_id}/retry")
439
+
440
+ async def cancel_pipeline(self, project_id: str | int, pipeline_id: int) -> dict:
441
+ enc = self._encode_id(project_id)
442
+ return await self.post(f"/projects/{enc}/pipelines/{pipeline_id}/cancel")
443
+
444
+ # ── Jobs ──────────────────────────────────────────────────────
445
+
446
+ async def retry_job(self, project_id: str | int, job_id: int) -> dict:
447
+ enc = self._encode_id(project_id)
448
+ return await self.post(f"/projects/{enc}/jobs/{job_id}/retry")
449
+
450
+ async def play_job(
451
+ self,
452
+ project_id: str | int,
453
+ job_id: int,
454
+ variables: list[dict[str, str]] | None = None,
455
+ ) -> dict:
456
+ enc = self._encode_id(project_id)
457
+ data: dict[str, Any] = {}
458
+ if variables:
459
+ data["job_variables_attributes"] = variables
460
+ return await self.post(f"/projects/{enc}/jobs/{job_id}/play", data or None)
461
+
462
+ async def cancel_job(self, project_id: str | int, job_id: int) -> dict:
463
+ enc = self._encode_id(project_id)
464
+ return await self.post(f"/projects/{enc}/jobs/{job_id}/cancel")
465
+
466
+ async def get_job_log(self, project_id: str | int, job_id: int) -> str:
467
+ enc = self._encode_id(project_id)
468
+ return await self.get(
469
+ f"/projects/{enc}/jobs/{job_id}/trace",
470
+ raw=True,
471
+ )
472
+
473
+ # ── Tags ──────────────────────────────────────────────────────
474
+
475
+ async def list_tags(
476
+ self, project_id: str | int, params: dict[str, Any] | None = None
477
+ ) -> list[dict]:
478
+ enc = self._encode_id(project_id)
479
+ p = {"per_page": 20, **(params or {})}
480
+ return await self.get(f"/projects/{enc}/repository/tags", params=p)
481
+
482
+ async def get_tag(self, project_id: str | int, tag_name: str) -> dict:
483
+ enc = self._encode_id(project_id)
484
+ return await self.get(f"/projects/{enc}/repository/tags/{quote(tag_name, safe='')}")
485
+
486
+ async def create_tag(self, project_id: str | int, params: dict[str, Any]) -> dict:
487
+ enc = self._encode_id(project_id)
488
+ return await self.post(f"/projects/{enc}/repository/tags", params)
489
+
490
+ async def delete_tag(self, project_id: str | int, tag_name: str) -> None:
491
+ enc = self._encode_id(project_id)
492
+ await self.delete(f"/projects/{enc}/repository/tags/{quote(tag_name, safe='')}")
493
+
494
+ # ── Releases ──────────────────────────────────────────────────
495
+
496
+ async def list_releases(
497
+ self, project_id: str | int, params: dict[str, Any] | None = None
498
+ ) -> list[dict]:
499
+ enc = self._encode_id(project_id)
500
+ p = {"per_page": 20, **(params or {})}
501
+ return await self.get(f"/projects/{enc}/releases", params=p)
502
+
503
+ async def get_release(self, project_id: str | int, tag_name: str) -> dict:
504
+ enc = self._encode_id(project_id)
505
+ return await self.get(f"/projects/{enc}/releases/{quote(tag_name, safe='')}")
506
+
507
+ async def create_release(self, project_id: str | int, params: dict[str, Any]) -> dict:
508
+ enc = self._encode_id(project_id)
509
+ return await self.post(f"/projects/{enc}/releases", params)
510
+
511
+ async def update_release(
512
+ self, project_id: str | int, tag_name: str, params: dict[str, Any]
513
+ ) -> dict:
514
+ enc = self._encode_id(project_id)
515
+ return await self.put(f"/projects/{enc}/releases/{quote(tag_name, safe='')}", params)
516
+
517
+ async def delete_release(self, project_id: str | int, tag_name: str) -> None:
518
+ enc = self._encode_id(project_id)
519
+ await self.delete(f"/projects/{enc}/releases/{quote(tag_name, safe='')}")
520
+
521
+ # ── CI/CD Variables (Project) ─────────────────────────────────
522
+
523
+ async def list_variables(self, project_id: str | int) -> list[dict]:
524
+ enc = self._encode_id(project_id)
525
+ return await self.get(f"/projects/{enc}/variables", params={"per_page": 100})
526
+
527
+ async def create_variable(self, project_id: str | int, params: dict[str, Any]) -> dict:
528
+ enc = self._encode_id(project_id)
529
+ return await self.post(f"/projects/{enc}/variables", params)
530
+
531
+ async def update_variable(
532
+ self,
533
+ project_id: str | int,
534
+ key: str,
535
+ params: dict[str, Any],
536
+ environment_scope: str | None = None,
537
+ ) -> dict:
538
+ enc = self._encode_id(project_id)
539
+ query: dict[str, Any] = {}
540
+ if environment_scope:
541
+ query["filter[environment_scope]"] = environment_scope
542
+ return await self.put(f"/projects/{enc}/variables/{key}", params, params=query)
543
+
544
+ async def delete_variable(
545
+ self,
546
+ project_id: str | int,
547
+ key: str,
548
+ environment_scope: str | None = None,
549
+ ) -> None:
550
+ enc = self._encode_id(project_id)
551
+ query: dict[str, Any] = {}
552
+ if environment_scope:
553
+ query["filter[environment_scope]"] = environment_scope
554
+ await self.delete(f"/projects/{enc}/variables/{key}", params=query)
555
+
556
+ # ── CI/CD Variables (Group) ───────────────────────────────────
557
+
558
+ async def list_group_variables(self, group_id: str | int) -> list[dict]:
559
+ enc = self._encode_id(group_id)
560
+ return await self.get(f"/groups/{enc}/variables", params={"per_page": 100})
561
+
562
+ async def create_group_variable(self, group_id: str | int, params: dict[str, Any]) -> dict:
563
+ enc = self._encode_id(group_id)
564
+ return await self.post(f"/groups/{enc}/variables", params)
565
+
566
+ async def update_group_variable(
567
+ self, group_id: str | int, key: str, params: dict[str, Any]
568
+ ) -> dict:
569
+ enc = self._encode_id(group_id)
570
+ return await self.put(f"/groups/{enc}/variables/{key}", params)
571
+
572
+ async def delete_group_variable(self, group_id: str | int, key: str) -> None:
573
+ enc = self._encode_id(group_id)
574
+ await self.delete(f"/groups/{enc}/variables/{key}")
575
+
576
+ # ── Issues ────────────────────────────────────────────────────
577
+
578
+ async def list_issues(
579
+ self, project_id: str | int, params: dict[str, Any] | None = None
580
+ ) -> list[dict]:
581
+ enc = self._encode_id(project_id)
582
+ p = {"per_page": 20, **(params or {})}
583
+ return await self.get(f"/projects/{enc}/issues", params=p)
584
+
585
+ async def get_issue(self, project_id: str | int, issue_iid: int) -> dict:
586
+ enc = self._encode_id(project_id)
587
+ return await self.get(f"/projects/{enc}/issues/{issue_iid}")
588
+
589
+ async def create_issue(self, project_id: str | int, params: dict[str, Any]) -> dict:
590
+ enc = self._encode_id(project_id)
591
+ return await self.post(f"/projects/{enc}/issues", params)
592
+
593
+ async def update_issue(
594
+ self, project_id: str | int, issue_iid: int, params: dict[str, Any]
595
+ ) -> dict:
596
+ enc = self._encode_id(project_id)
597
+ return await self.put(f"/projects/{enc}/issues/{issue_iid}", params)
598
+
599
+ async def add_issue_comment(self, project_id: str | int, issue_iid: int, body: str) -> dict:
600
+ enc = self._encode_id(project_id)
601
+ return await self.post(f"/projects/{enc}/issues/{issue_iid}/notes", {"body": body})