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.
- pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +150 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +101 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +83 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +96 -0
- pjdev_gitlab/__about__.py +4 -0
- pjdev_gitlab/__init__.py +17 -0
- pjdev_gitlab/api_utilities.py +201 -0
- pjdev_gitlab/config_service.py +49 -0
- pjdev_gitlab/issues_service.py +430 -0
- pjdev_gitlab/merge_requests_service.py +244 -0
- pjdev_gitlab/models.py +172 -0
- pjdev_gitlab/packages_service.py +134 -0
- pjdev_gitlab/py.typed +0 -0
- pjdev_gitlab/repo_files_service.py +145 -0
- pjdev_gitlab-5.0.0.dist-info/METADATA +129 -0
- pjdev_gitlab-5.0.0.dist-info/RECORD +18 -0
- pjdev_gitlab-5.0.0.dist-info/WHEEL +4 -0
- pjdev_gitlab-5.0.0.dist-info/licenses/LICENSE.txt +9 -0
|
@@ -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)
|