pjdev-gitlab 5.0.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,49 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Optional
3
+
4
+ from pjdev_gitlab.models import Config, ProjectId
5
+
6
+ __ctx: Dict[str, Any] = {}
7
+
8
+
9
+ def get_config() -> Config:
10
+ if "config" not in __ctx:
11
+ raise RuntimeError("pjdev_gitlab is not initialized -- call config_service.init() first")
12
+ return __ctx["config"]
13
+
14
+
15
+ def init(
16
+ env_path: Optional[Path] = None,
17
+ token: Optional[str] = None,
18
+ gitlab_url: Optional[str] = None,
19
+ default_project_id: Optional[ProjectId] = None,
20
+ output_path: Optional[Path] = None,
21
+ http_retry_max_count: Optional[int] = None,
22
+ http_retry_delay_seconds: Optional[int] = None,
23
+ request_timeout_seconds: Optional[float] = None,
24
+ ) -> None:
25
+ if env_path is not None:
26
+ Config.model_config = {**Config.model_config, "env_file": env_path}
27
+
28
+ kwargs: Dict[str, Any] = {}
29
+ if token is not None:
30
+ kwargs["token"] = token
31
+ if gitlab_url is not None:
32
+ kwargs["gitlab_url"] = gitlab_url
33
+ if default_project_id is not None:
34
+ kwargs["default_project_id"] = default_project_id
35
+ if output_path is not None:
36
+ kwargs["output_path"] = output_path
37
+ if http_retry_max_count is not None:
38
+ kwargs["http_retry_max_count"] = http_retry_max_count
39
+ if http_retry_delay_seconds is not None:
40
+ kwargs["http_retry_delay_seconds"] = http_retry_delay_seconds
41
+ if request_timeout_seconds is not None:
42
+ kwargs["request_timeout_seconds"] = request_timeout_seconds
43
+
44
+ config = Config(**kwargs)
45
+
46
+ if config.output_path is None and env_path is not None:
47
+ config.output_path = env_path.parent / "output"
48
+
49
+ __ctx["config"] = config
@@ -0,0 +1,430 @@
1
+ import re
2
+ from collections import Counter
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Literal, Optional
5
+
6
+ from httpx import AsyncClient
7
+ from loguru import logger
8
+
9
+ from pjdev_gitlab.api_utilities import (
10
+ async_retry_http,
11
+ encode_path_segment,
12
+ http_client,
13
+ paginate,
14
+ upload_to_project,
15
+ )
16
+ from pjdev_gitlab.models import (
17
+ Discussion,
18
+ Issue,
19
+ IssueState,
20
+ IssueType,
21
+ Iteration,
22
+ Milestone,
23
+ Note,
24
+ ProjectId,
25
+ StateEvent,
26
+ )
27
+
28
+ _IGNORE_4XX = [400, 401, 403, 404]
29
+ _IMG_PATTERN = re.compile(r'<img[^>]*src="([^"]+)"[^>]*>', re.IGNORECASE)
30
+
31
+
32
+ def _project_url(project_id: ProjectId) -> str:
33
+ return f"/projects/{encode_path_segment(project_id)}"
34
+
35
+
36
+ async def _replace_image_paths_with_uploads(
37
+ project_id: ProjectId,
38
+ description: str,
39
+ image_paths: List[Path],
40
+ client: AsyncClient,
41
+ ) -> str:
42
+ """Upload local images and rewrite ``<img src=...>`` references in the description.
43
+
44
+ Images are matched positionally to ``<img>`` tags (first tag -> first path, etc.).
45
+ Images without a corresponding tag are appended to the end of the description.
46
+ Mirrors the pattern in keystone-gitlab.
47
+ """
48
+ matches = _IMG_PATTERN.findall(description)
49
+ rewritten = description
50
+
51
+ for index, image_path in enumerate(image_paths):
52
+ upload = await upload_to_project(project_id, image_path, client=client)
53
+ markdown = upload.get("markdown") or upload.get("url", "")
54
+ if index < len(matches):
55
+ rewritten = _IMG_PATTERN.sub(markdown, rewritten, count=1)
56
+ else:
57
+ rewritten = f"{rewritten}\n\n{markdown}"
58
+
59
+ return rewritten
60
+
61
+
62
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
63
+ async def search_issues(
64
+ project_id: ProjectId,
65
+ *,
66
+ state: Optional[IssueState] = None,
67
+ labels: Optional[List[str]] = None,
68
+ milestone: Optional[str] = None,
69
+ iteration_id: Optional[int] = None,
70
+ search: Optional[str] = None,
71
+ assignee_id: Optional[int] = None,
72
+ author_id: Optional[int] = None,
73
+ created_after: Optional[str] = None,
74
+ created_before: Optional[str] = None,
75
+ issue_type: Optional[IssueType] = None,
76
+ extra_params: Optional[Dict[str, Any]] = None,
77
+ paginate_results: bool = True,
78
+ page_size: int = 100,
79
+ client: Optional[AsyncClient] = None,
80
+ ) -> List[Issue]:
81
+ params: Dict[str, Any] = {}
82
+ if state is not None:
83
+ params["state"] = state.value
84
+ if labels:
85
+ params["labels"] = ",".join(labels)
86
+ if milestone is not None:
87
+ params["milestone"] = milestone
88
+ if iteration_id is not None:
89
+ params["iteration_id"] = iteration_id
90
+ if search is not None:
91
+ params["search"] = search
92
+ if assignee_id is not None:
93
+ params["assignee_id"] = assignee_id
94
+ if author_id is not None:
95
+ params["author_id"] = author_id
96
+ if created_after is not None:
97
+ params["created_after"] = created_after
98
+ if created_before is not None:
99
+ params["created_before"] = created_before
100
+ if issue_type is not None:
101
+ params["issue_type"] = issue_type.value
102
+ if extra_params:
103
+ params.update(extra_params)
104
+
105
+ url = f"{_project_url(project_id)}/issues"
106
+
107
+ async def _exec(_client: AsyncClient) -> List[Issue]:
108
+ if paginate_results:
109
+ rows = await paginate(_client, url, params=params, page_size=page_size)
110
+ else:
111
+ params["per_page"] = page_size
112
+ r = await _client.get(url, params=params)
113
+ r.raise_for_status()
114
+ rows = r.json()
115
+ return [Issue.model_validate(row) for row in rows]
116
+
117
+ if client is None:
118
+ async with http_client() as _client:
119
+ return await _exec(_client)
120
+ return await _exec(client)
121
+
122
+
123
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
124
+ async def get_issue(
125
+ project_id: ProjectId,
126
+ issue_iid: int,
127
+ client: Optional[AsyncClient] = None,
128
+ ) -> Issue:
129
+ url = f"{_project_url(project_id)}/issues/{issue_iid}"
130
+
131
+ async def _exec(_client: AsyncClient) -> Issue:
132
+ r = await _client.get(url)
133
+ r.raise_for_status()
134
+ return Issue.model_validate(r.json())
135
+
136
+ if client is None:
137
+ async with http_client() as _client:
138
+ return await _exec(_client)
139
+ return await _exec(client)
140
+
141
+
142
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
143
+ async def create_issue(
144
+ project_id: ProjectId,
145
+ title: str,
146
+ description: str = "",
147
+ *,
148
+ labels: Optional[List[str]] = None,
149
+ assignee_ids: Optional[List[int]] = None,
150
+ milestone_id: Optional[int] = None,
151
+ iteration_id: Optional[int] = None,
152
+ issue_type: Optional[IssueType] = None,
153
+ image_paths: Optional[List[Path]] = None,
154
+ confidential: Optional[bool] = None,
155
+ client: Optional[AsyncClient] = None,
156
+ ) -> Issue:
157
+ """Create an issue. Optionally uploads ``image_paths`` first and splices their
158
+ markdown into the description (replacing ``<img>`` tags positionally).
159
+
160
+ Quick actions like ``/label ~bug`` or ``/assign @me`` embedded in the
161
+ description are interpreted server-side by GitLab.
162
+ """
163
+ url = f"{_project_url(project_id)}/issues"
164
+
165
+ async def _exec(_client: AsyncClient) -> Issue:
166
+ final_description = description
167
+ if image_paths:
168
+ final_description = await _replace_image_paths_with_uploads(
169
+ project_id, description, image_paths, _client
170
+ )
171
+
172
+ payload: Dict[str, Any] = {"title": title, "description": final_description}
173
+ if labels:
174
+ payload["labels"] = ",".join(labels)
175
+ if assignee_ids:
176
+ payload["assignee_ids"] = assignee_ids
177
+ if milestone_id is not None:
178
+ payload["milestone_id"] = milestone_id
179
+ if iteration_id is not None:
180
+ payload["iteration_id"] = iteration_id
181
+ if issue_type is not None:
182
+ payload["issue_type"] = issue_type.value
183
+ if confidential is not None:
184
+ payload["confidential"] = confidential
185
+
186
+ r = await _client.post(url, json=payload)
187
+ r.raise_for_status()
188
+ issue = Issue.model_validate(r.json())
189
+ logger.info(f"Created issue !{issue.iid} in project {project_id}")
190
+ return issue
191
+
192
+ if client is None:
193
+ async with http_client() as _client:
194
+ return await _exec(_client)
195
+ return await _exec(client)
196
+
197
+
198
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
199
+ async def comment_on_issue(
200
+ project_id: ProjectId,
201
+ issue_iid: int,
202
+ body: str,
203
+ *,
204
+ image_paths: Optional[List[Path]] = None,
205
+ client: Optional[AsyncClient] = None,
206
+ ) -> Note:
207
+ """Add a single (non-threaded) note to an issue.
208
+
209
+ Quick actions in ``body`` (e.g. ``/close``, ``/label ~bug``) are processed by GitLab.
210
+ Use ``start_issue_discussion`` instead when you want a threaded reply.
211
+ """
212
+ url = f"{_project_url(project_id)}/issues/{issue_iid}/notes"
213
+
214
+ async def _exec(_client: AsyncClient) -> Note:
215
+ final_body = body
216
+ if image_paths:
217
+ final_body = await _replace_image_paths_with_uploads(
218
+ project_id, body, image_paths, _client
219
+ )
220
+ r = await _client.post(url, json={"body": final_body})
221
+ r.raise_for_status()
222
+ return Note.model_validate(r.json())
223
+
224
+ if client is None:
225
+ async with http_client() as _client:
226
+ return await _exec(_client)
227
+ return await _exec(client)
228
+
229
+
230
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
231
+ async def start_issue_discussion(
232
+ project_id: ProjectId,
233
+ issue_iid: int,
234
+ body: str,
235
+ client: Optional[AsyncClient] = None,
236
+ ) -> Discussion:
237
+ url = f"{_project_url(project_id)}/issues/{issue_iid}/discussions"
238
+
239
+ async def _exec(_client: AsyncClient) -> Discussion:
240
+ r = await _client.post(url, json={"body": body})
241
+ r.raise_for_status()
242
+ return Discussion.model_validate(r.json())
243
+
244
+ if client is None:
245
+ async with http_client() as _client:
246
+ return await _exec(_client)
247
+ return await _exec(client)
248
+
249
+
250
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
251
+ async def set_issue_state(
252
+ project_id: ProjectId,
253
+ issue_iid: int,
254
+ state_event: StateEvent,
255
+ client: Optional[AsyncClient] = None,
256
+ ) -> Issue:
257
+ url = f"{_project_url(project_id)}/issues/{issue_iid}"
258
+
259
+ async def _exec(_client: AsyncClient) -> Issue:
260
+ r = await _client.put(url, json={"state_event": state_event.value})
261
+ r.raise_for_status()
262
+ return Issue.model_validate(r.json())
263
+
264
+ if client is None:
265
+ async with http_client() as _client:
266
+ return await _exec(_client)
267
+ return await _exec(client)
268
+
269
+
270
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
271
+ async def set_issue_labels(
272
+ project_id: ProjectId,
273
+ issue_iid: int,
274
+ labels: List[str],
275
+ *,
276
+ mode: Literal["replace", "add", "remove"] = "replace",
277
+ client: Optional[AsyncClient] = None,
278
+ ) -> Issue:
279
+ url = f"{_project_url(project_id)}/issues/{issue_iid}"
280
+ label_csv = ",".join(labels)
281
+ if mode == "replace":
282
+ payload = {"labels": label_csv}
283
+ elif mode == "add":
284
+ payload = {"add_labels": label_csv}
285
+ else:
286
+ payload = {"remove_labels": label_csv}
287
+
288
+ async def _exec(_client: AsyncClient) -> Issue:
289
+ r = await _client.put(url, json=payload)
290
+ r.raise_for_status()
291
+ return Issue.model_validate(r.json())
292
+
293
+ if client is None:
294
+ async with http_client() as _client:
295
+ return await _exec(_client)
296
+ return await _exec(client)
297
+
298
+
299
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
300
+ async def set_issue_iteration(
301
+ project_id: ProjectId,
302
+ issue_iid: int,
303
+ iteration_id: int,
304
+ client: Optional[AsyncClient] = None,
305
+ ) -> Issue:
306
+ url = f"{_project_url(project_id)}/issues/{issue_iid}"
307
+
308
+ async def _exec(_client: AsyncClient) -> Issue:
309
+ r = await _client.put(url, json={"iteration_id": iteration_id})
310
+ r.raise_for_status()
311
+ return Issue.model_validate(r.json())
312
+
313
+ if client is None:
314
+ async with http_client() as _client:
315
+ return await _exec(_client)
316
+ return await _exec(client)
317
+
318
+
319
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
320
+ async def set_issue_milestone(
321
+ project_id: ProjectId,
322
+ issue_iid: int,
323
+ milestone_id: int,
324
+ client: Optional[AsyncClient] = None,
325
+ ) -> Issue:
326
+ url = f"{_project_url(project_id)}/issues/{issue_iid}"
327
+
328
+ async def _exec(_client: AsyncClient) -> Issue:
329
+ r = await _client.put(url, json={"milestone_id": milestone_id})
330
+ r.raise_for_status()
331
+ return Issue.model_validate(r.json())
332
+
333
+ if client is None:
334
+ async with http_client() as _client:
335
+ return await _exec(_client)
336
+ return await _exec(client)
337
+
338
+
339
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
340
+ async def list_iterations(
341
+ group_id: ProjectId,
342
+ *,
343
+ state: Optional[str] = None,
344
+ page_size: int = 100,
345
+ client: Optional[AsyncClient] = None,
346
+ ) -> List[Iteration]:
347
+ url = f"/groups/{encode_path_segment(group_id)}/iterations"
348
+ params: Dict[str, Any] = {}
349
+ if state is not None:
350
+ params["state"] = state
351
+
352
+ async def _exec(_client: AsyncClient) -> List[Iteration]:
353
+ rows = await paginate(_client, url, params=params, page_size=page_size)
354
+ return [Iteration.model_validate(row) for row in rows]
355
+
356
+ if client is None:
357
+ async with http_client() as _client:
358
+ return await _exec(_client)
359
+ return await _exec(client)
360
+
361
+
362
+ @async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
363
+ async def list_milestones(
364
+ project_id: ProjectId,
365
+ *,
366
+ state: Optional[str] = None,
367
+ page_size: int = 100,
368
+ client: Optional[AsyncClient] = None,
369
+ ) -> List[Milestone]:
370
+ url = f"{_project_url(project_id)}/milestones"
371
+ params: Dict[str, Any] = {}
372
+ if state is not None:
373
+ params["state"] = state
374
+
375
+ async def _exec(_client: AsyncClient) -> List[Milestone]:
376
+ rows = await paginate(_client, url, params=params, page_size=page_size)
377
+ return [Milestone.model_validate(row) for row in rows]
378
+
379
+ if client is None:
380
+ async with http_client() as _client:
381
+ return await _exec(_client)
382
+ return await _exec(client)
383
+
384
+
385
+ _GROUP_BY = Literal["state", "label", "author", "assignee", "iteration", "milestone"]
386
+
387
+
388
+ async def aggregate_issues(
389
+ project_id: ProjectId,
390
+ group_by: _GROUP_BY,
391
+ *,
392
+ state: Optional[IssueState] = None,
393
+ labels: Optional[List[str]] = None,
394
+ created_after: Optional[str] = None,
395
+ created_before: Optional[str] = None,
396
+ client: Optional[AsyncClient] = None,
397
+ ) -> Dict[str, int]:
398
+ """Return a count-by-bucket aggregation of issues matching the given filters.
399
+
400
+ For ``label``, each label of each issue is counted separately (an issue with
401
+ two labels contributes 1 to each bucket).
402
+ """
403
+ issues = await search_issues(
404
+ project_id,
405
+ state=state,
406
+ labels=labels,
407
+ created_after=created_after,
408
+ created_before=created_before,
409
+ client=client,
410
+ )
411
+ counter: Counter[str] = Counter()
412
+ for issue in issues:
413
+ if group_by == "state":
414
+ counter[issue.state.value] += 1
415
+ elif group_by == "label":
416
+ for label in issue.labels:
417
+ counter[label] += 1
418
+ elif group_by == "author":
419
+ counter[issue.author.username if issue.author else "unknown"] += 1
420
+ elif group_by == "assignee":
421
+ if not issue.assignees:
422
+ counter["unassigned"] += 1
423
+ else:
424
+ for assignee in issue.assignees:
425
+ counter[assignee.username] += 1
426
+ elif group_by == "iteration":
427
+ counter[issue.iteration.title or str(issue.iteration.id) if issue.iteration else "none"] += 1
428
+ elif group_by == "milestone":
429
+ counter[issue.milestone.title if issue.milestone else "none"] += 1
430
+ return dict(counter)