jira-cli-toolkit 2.5.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.
- jira_cli_toolkit-2.5.1.dist-info/METADATA +212 -0
- jira_cli_toolkit-2.5.1.dist-info/RECORD +23 -0
- jira_cli_toolkit-2.5.1.dist-info/WHEEL +4 -0
- jira_cli_toolkit-2.5.1.dist-info/entry_points.txt +4 -0
- jira_cli_toolkit-2.5.1.dist-info/licenses/LICENSE +661 -0
- jira_cli_toolkit-2.5.1.dist-info/licenses/NOTICE +25 -0
- jsup/__init__.py +7 -0
- jsup/api.py +352 -0
- jsup/api_spec.py +309 -0
- jsup/application.py +330 -0
- jsup/auth.py +248 -0
- jsup/cli.py +103 -0
- jsup/client.py +412 -0
- jsup/commands.py +369 -0
- jsup/completion.py +56 -0
- jsup/config.py +160 -0
- jsup/credentials.py +125 -0
- jsup/data/callback.json +13 -0
- jsup/fields.py +204 -0
- jsup/maintenance.py +105 -0
- jsup/templates.py +89 -0
- jsup/ui.py +170 -0
- jsup/update.py +328 -0
jsup/client.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Thin wrapper over the official Jira Cloud REST APIs (v3 + Agile 1.0)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from contextlib import ExitStack
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class JiraError(Exception):
|
|
16
|
+
def __init__(self, method: str, path: str, status: int, body: str):
|
|
17
|
+
self.method, self.path, self.status, self.body = method, path, status, body
|
|
18
|
+
super().__init__(f"{method} {path} -> {status}: {body[:500]}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UncertainOutcome(requests.RequestException):
|
|
22
|
+
"""A mutation may have reached Jira; it must not be replayed automatically."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def identifier(value):
|
|
26
|
+
return quote(str(value), safe="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def project_jql(project: str) -> str:
|
|
30
|
+
"""Quote project identifiers in generated JQL."""
|
|
31
|
+
return f"project = {json.dumps(project, ensure_ascii=False)}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def adf(text: str) -> dict:
|
|
35
|
+
"""Plain text -> Atlassian Document Format (required by API v3)."""
|
|
36
|
+
return {"type": "doc", "version": 1,
|
|
37
|
+
"content": [{"type": "paragraph",
|
|
38
|
+
"content": [{"type": "text", "text": line or " "}]}
|
|
39
|
+
for line in (text or "").splitlines() or [""]]}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def adf_to_text(node) -> str:
|
|
43
|
+
if isinstance(node, str):
|
|
44
|
+
return node
|
|
45
|
+
if not isinstance(node, dict):
|
|
46
|
+
return ""
|
|
47
|
+
if node.get("type") == "text":
|
|
48
|
+
return node.get("text", "")
|
|
49
|
+
out = []
|
|
50
|
+
for c in node.get("content", []) or []:
|
|
51
|
+
out.append(adf_to_text(c))
|
|
52
|
+
if isinstance(c, dict) and c.get("type") == "paragraph":
|
|
53
|
+
out.append("\n")
|
|
54
|
+
return "".join(out).strip()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Jira:
|
|
58
|
+
def __init__(self, site: str, email: str, token: str):
|
|
59
|
+
self.site = site
|
|
60
|
+
self.s = requests.Session()
|
|
61
|
+
self.s.auth = (email, token)
|
|
62
|
+
self.s.headers.update({"Accept": "application/json",
|
|
63
|
+
"Content-Type": "application/json"})
|
|
64
|
+
|
|
65
|
+
def _send(self, method: str, path: str, *, safe: bool, **kw) -> requests.Response:
|
|
66
|
+
"""Send one request to the trusted site; return the final response unparsed.
|
|
67
|
+
|
|
68
|
+
Safe requests retry bounded transient failures. A write whose connection drops
|
|
69
|
+
raises UncertainOutcome and is never replayed. Redirects are not followed, so
|
|
70
|
+
credentials never travel to another destination.
|
|
71
|
+
"""
|
|
72
|
+
kw.setdefault("timeout", (10, 30))
|
|
73
|
+
kw["allow_redirects"] = False
|
|
74
|
+
for attempt in range(3):
|
|
75
|
+
try:
|
|
76
|
+
r = self.s.request(method, self.site + path, **kw)
|
|
77
|
+
except (requests.Timeout, requests.ConnectionError):
|
|
78
|
+
if not safe:
|
|
79
|
+
raise UncertainOutcome(
|
|
80
|
+
"Connection lost during a write. The outcome is unknown; inspect Jira before retrying.") from None
|
|
81
|
+
if attempt == 2:
|
|
82
|
+
raise
|
|
83
|
+
time.sleep(2 ** attempt)
|
|
84
|
+
continue
|
|
85
|
+
if safe and r.status_code in (429, 502, 503, 504) and attempt < 2:
|
|
86
|
+
try:
|
|
87
|
+
delay = float(r.headers.get("Retry-After", 2 ** attempt))
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
delay = 2 ** attempt
|
|
90
|
+
# Do not retry earlier than a long server-requested wait.
|
|
91
|
+
if 0 <= delay <= 30:
|
|
92
|
+
r.close()
|
|
93
|
+
time.sleep(delay)
|
|
94
|
+
continue
|
|
95
|
+
return r
|
|
96
|
+
|
|
97
|
+
def _req(self, method: str, path: str, **kw):
|
|
98
|
+
safe = kw.pop("retry_safe", method in ("GET", "HEAD"))
|
|
99
|
+
r = self._send(method, path, safe=safe, **kw)
|
|
100
|
+
if r.status_code == 204 or (200 <= r.status_code < 300 and not r.text):
|
|
101
|
+
return {}
|
|
102
|
+
if 200 <= r.status_code < 300:
|
|
103
|
+
try:
|
|
104
|
+
data = r.json()
|
|
105
|
+
except ValueError:
|
|
106
|
+
raise JiraError(method, path, 502, "Jira returned malformed JSON.") from None
|
|
107
|
+
if not isinstance(data, (dict, list)):
|
|
108
|
+
raise JiraError(method, path, 502, "Jira returned an unexpected response shape.")
|
|
109
|
+
return data
|
|
110
|
+
raise JiraError(method, path, r.status_code, r.text)
|
|
111
|
+
|
|
112
|
+
def _offset(self, path, key="values", *, params=None, limit=50, all_results=False):
|
|
113
|
+
"""Collect offset pages with a total-result limit, honoring server caps."""
|
|
114
|
+
collected, offset, last = [], 0, {}
|
|
115
|
+
for _ in range(10000):
|
|
116
|
+
size = 100 if all_results else min(100, limit - len(collected))
|
|
117
|
+
page = self._req("GET", path, params={**(params or {}), "startAt": offset, "maxResults": size})
|
|
118
|
+
if not isinstance(page, dict) or not isinstance(page.get(key), list):
|
|
119
|
+
raise JiraError("GET", path, 502, "Invalid paginated response.")
|
|
120
|
+
batch = page[key]
|
|
121
|
+
collected.extend(batch if all_results else batch[:size])
|
|
122
|
+
last = page
|
|
123
|
+
offset += len(batch)
|
|
124
|
+
done = page.get("isLast") is True or (isinstance(page.get("total"), int) and offset >= page["total"])
|
|
125
|
+
if not batch or done or (not all_results and len(collected) >= limit):
|
|
126
|
+
break
|
|
127
|
+
if "total" not in page and "isLast" not in page and len(batch) < size:
|
|
128
|
+
break
|
|
129
|
+
else:
|
|
130
|
+
raise JiraError("GET", path, 502, "Pagination exceeded the safety limit.")
|
|
131
|
+
last = {**last, key: collected, "startAt": 0, "fetched": len(collected)}
|
|
132
|
+
if "total" in last:
|
|
133
|
+
last["isLast"] = offset >= last["total"]
|
|
134
|
+
return last
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
self.s.close()
|
|
138
|
+
|
|
139
|
+
# -- identity / projects -------------------------------------------------
|
|
140
|
+
def me(self): return self._req("GET", "/rest/api/3/myself")
|
|
141
|
+
def projects(self, limit=50, all_results=False):
|
|
142
|
+
return self._offset("/rest/api/3/project/search", limit=limit, all_results=all_results)["values"]
|
|
143
|
+
def project_get(self, key): return self._req("GET", f"/rest/api/3/project/{identifier(key)}")
|
|
144
|
+
|
|
145
|
+
# -- issues ---------------------------------------------------------------
|
|
146
|
+
def issue_create(self, project, summary, desc="", itype="Task",
|
|
147
|
+
priority=None, labels=(), components=(), assignee=None,
|
|
148
|
+
extra_fields=None):
|
|
149
|
+
fields: dict = {"project": {"key": project}, "summary": summary,
|
|
150
|
+
"description": adf(desc), "issuetype": {"name": itype}}
|
|
151
|
+
if priority: fields["priority"] = {"name": priority}
|
|
152
|
+
if labels: fields["labels"] = list(labels)
|
|
153
|
+
if components: fields["components"] = [{"name": c} for c in components]
|
|
154
|
+
if assignee: fields["assignee"] = {"accountId": assignee}
|
|
155
|
+
if extra_fields: fields.update(extra_fields)
|
|
156
|
+
return self._req("POST", "/rest/api/3/issue", json={"fields": fields})
|
|
157
|
+
|
|
158
|
+
def issue_get(self, key):
|
|
159
|
+
return self._req("GET", f"/rest/api/3/issue/{key}")
|
|
160
|
+
|
|
161
|
+
def issue_delete(self, key, delete_subtasks=False):
|
|
162
|
+
params = {"deleteSubtasks": "true"} if delete_subtasks else {}
|
|
163
|
+
return self._req("DELETE", f"/rest/api/3/issue/{key}", params=params)
|
|
164
|
+
|
|
165
|
+
def search(self, jql, max_results=50,
|
|
166
|
+
fields="summary,status,assignee,priority,updated,components,labels", all_results=False):
|
|
167
|
+
collected, token, seen, page = [], None, set(), {}
|
|
168
|
+
path = "/rest/api/3/search/jql"
|
|
169
|
+
for _ in range(10000):
|
|
170
|
+
size = 100 if all_results else min(100, max_results - len(collected))
|
|
171
|
+
payload = {"jql": jql, "maxResults": size, "fields": fields.split(",") if isinstance(fields, str) else fields}
|
|
172
|
+
if token:
|
|
173
|
+
payload["nextPageToken"] = token
|
|
174
|
+
# POST search is read-only and avoids URL-length limits.
|
|
175
|
+
page = self._req("POST", path, json=payload, retry_safe=True)
|
|
176
|
+
if not isinstance(page, dict) or not isinstance(page.get("issues"), list):
|
|
177
|
+
raise JiraError("POST", path, 502, "Invalid search response.")
|
|
178
|
+
batch = page["issues"]
|
|
179
|
+
collected.extend(batch if all_results else batch[:size])
|
|
180
|
+
token = page.get("nextPageToken")
|
|
181
|
+
if page.get("isLast") is True or (not all_results and len(collected) >= max_results):
|
|
182
|
+
break
|
|
183
|
+
if not token:
|
|
184
|
+
if page.get("isLast") is False:
|
|
185
|
+
raise JiraError("POST", path, 502, "Search indicated another page without a cursor.")
|
|
186
|
+
break
|
|
187
|
+
if token in seen or not batch:
|
|
188
|
+
raise JiraError("POST", path, 502, "Search pagination did not advance.")
|
|
189
|
+
seen.add(token)
|
|
190
|
+
else:
|
|
191
|
+
raise JiraError("POST", path, 502, "Pagination exceeded the safety limit.")
|
|
192
|
+
return {**page, "issues": collected, "fetched": len(collected)}
|
|
193
|
+
|
|
194
|
+
def open_tickets(self, project, extra="", max_results=50, **options):
|
|
195
|
+
jql = project_jql(project) + " AND statusCategory != Done"
|
|
196
|
+
if extra:
|
|
197
|
+
jql += f" AND ({extra})"
|
|
198
|
+
return self.search(jql + " ORDER BY updated DESC", max_results, **options)
|
|
199
|
+
|
|
200
|
+
def count_issues(self, jql):
|
|
201
|
+
return self._req("POST", "/rest/api/3/search/approximate-count", json={"jql": jql}, retry_safe=True)
|
|
202
|
+
|
|
203
|
+
# -- workflow --------------------------------------------------------------
|
|
204
|
+
def transitions(self, key):
|
|
205
|
+
return self._req("GET", f"/rest/api/3/issue/{key}/transitions")
|
|
206
|
+
|
|
207
|
+
def move(self, key, to: str):
|
|
208
|
+
trs = self.transitions(key).get("transitions", [])
|
|
209
|
+
match = next((t for t in trs if t["id"] == to
|
|
210
|
+
or t["name"].lower() == to.lower()), None)
|
|
211
|
+
if not match:
|
|
212
|
+
avail = ", ".join(f'{t["name"]} [{t["id"]}]' for t in trs)
|
|
213
|
+
raise JiraError("POST", f"/rest/api/3/issue/{key}/transitions",
|
|
214
|
+
422, f"No transition '{to}'. Available: {avail}")
|
|
215
|
+
self._req("POST", f"/rest/api/3/issue/{key}/transitions",
|
|
216
|
+
json={"transition": {"id": match["id"]}})
|
|
217
|
+
return {"moved": key, "to": match["name"]}
|
|
218
|
+
|
|
219
|
+
# -- comments ---------------------------------------------------------------
|
|
220
|
+
def comment_add(self, key, body):
|
|
221
|
+
return self._req("POST", f"/rest/api/3/issue/{key}/comment",
|
|
222
|
+
json={"body": adf(body)})
|
|
223
|
+
|
|
224
|
+
def comments(self, key, limit=50, all_results=False):
|
|
225
|
+
return self._offset(f"/rest/api/3/issue/{identifier(key)}/comment", "comments", limit=limit, all_results=all_results)
|
|
226
|
+
|
|
227
|
+
# -- components ---------------------------------------------------------------
|
|
228
|
+
def components(self, project):
|
|
229
|
+
return self._req("GET", f"/rest/api/3/project/{project}/components")
|
|
230
|
+
|
|
231
|
+
def component_create(self, project, name, description=""):
|
|
232
|
+
return self._req("POST", "/rest/api/3/component",
|
|
233
|
+
json={"name": name, "description": description,
|
|
234
|
+
"project": project})
|
|
235
|
+
|
|
236
|
+
# -- project-specific metadata and editable fields -------------------------
|
|
237
|
+
def issue_types(self, project):
|
|
238
|
+
return self._offset(f"/rest/api/3/issue/createmeta/{identifier(project)}/issuetypes",
|
|
239
|
+
"issueTypes", all_results=True)["issueTypes"]
|
|
240
|
+
|
|
241
|
+
def create_fields(self, project, issue_type):
|
|
242
|
+
return self._offset(f"/rest/api/3/issue/createmeta/{identifier(project)}/issuetypes/{identifier(issue_type)}",
|
|
243
|
+
"fields", all_results=True)["fields"]
|
|
244
|
+
|
|
245
|
+
def project_statuses(self, project):
|
|
246
|
+
return self._req("GET", f"/rest/api/3/project/{identifier(project)}/statuses")
|
|
247
|
+
|
|
248
|
+
def edit_fields(self, key):
|
|
249
|
+
return self._req("GET", f"/rest/api/3/issue/{identifier(key)}/editmeta").get("fields", {})
|
|
250
|
+
|
|
251
|
+
def transition_fields(self, key):
|
|
252
|
+
return self._req("GET", f"/rest/api/3/issue/{identifier(key)}/transitions", params={"expand": "transitions.fields"})
|
|
253
|
+
|
|
254
|
+
def create_with_fields(self, fields):
|
|
255
|
+
return self._req("POST", "/rest/api/3/issue", json={"fields": fields})
|
|
256
|
+
|
|
257
|
+
def edit_issue(self, key, fields, updates=None):
|
|
258
|
+
payload = {"fields": fields}
|
|
259
|
+
if updates:
|
|
260
|
+
payload["update"] = updates
|
|
261
|
+
self._req("PUT", f"/rest/api/3/issue/{identifier(key)}", json=payload)
|
|
262
|
+
return {"updated": key, "fields": sorted(fields), "operations": sorted(updates or {})}
|
|
263
|
+
|
|
264
|
+
def transition_with_fields(self, key, transition_id, fields):
|
|
265
|
+
self._req("POST", f"/rest/api/3/issue/{identifier(key)}/transitions",
|
|
266
|
+
json={"transition": {"id": transition_id}, "fields": fields})
|
|
267
|
+
return {"moved": key, "transition_id": transition_id}
|
|
268
|
+
|
|
269
|
+
def assignable_users(self, key, query):
|
|
270
|
+
return self._req("GET", "/rest/api/3/user/assignable/search", params={"issueKey": key, "query": query, "maxResults": 100})
|
|
271
|
+
|
|
272
|
+
def assign(self, key, account_id):
|
|
273
|
+
self._req("PUT", f"/rest/api/3/issue/{identifier(key)}/assignee", json={"accountId": account_id})
|
|
274
|
+
return {"issue": key, "assignee": account_id}
|
|
275
|
+
|
|
276
|
+
def link_types(self):
|
|
277
|
+
return self._req("GET", "/rest/api/3/issueLinkType").get("issueLinkTypes", [])
|
|
278
|
+
|
|
279
|
+
def link(self, inward, outward, link_type):
|
|
280
|
+
self._req("POST", "/rest/api/3/issueLink", json={"type": {"id": link_type},
|
|
281
|
+
"inwardIssue": {"key": inward}, "outwardIssue": {"key": outward}})
|
|
282
|
+
return {"inward": inward, "outward": outward, "type_id": link_type}
|
|
283
|
+
|
|
284
|
+
def unlink(self, link_id):
|
|
285
|
+
self._req("DELETE", f"/rest/api/3/issueLink/{identifier(link_id)}")
|
|
286
|
+
return {"deleted_link": str(link_id)}
|
|
287
|
+
|
|
288
|
+
def comment_edit(self, key, comment_id, body):
|
|
289
|
+
return self._req("PUT", f"/rest/api/3/issue/{identifier(key)}/comment/{identifier(comment_id)}", json={"body": adf(body)})
|
|
290
|
+
|
|
291
|
+
def comment_delete(self, key, comment_id):
|
|
292
|
+
self._req("DELETE", f"/rest/api/3/issue/{identifier(key)}/comment/{identifier(comment_id)}")
|
|
293
|
+
return {"deleted_comment": str(comment_id), "issue": key}
|
|
294
|
+
|
|
295
|
+
def attachment_info(self, attachment_id):
|
|
296
|
+
return self._req("GET", f"/rest/api/3/attachment/{identifier(attachment_id)}")
|
|
297
|
+
|
|
298
|
+
def attachment_upload(self, key, paths):
|
|
299
|
+
settings = self._req("GET", "/rest/api/3/attachment/meta")
|
|
300
|
+
if not settings.get("enabled"):
|
|
301
|
+
raise ValueError("Attachments are disabled on this Jira site.")
|
|
302
|
+
limit = min(settings.get("uploadLimit", 50 * 1024 * 1024), 50 * 1024 * 1024)
|
|
303
|
+
with ExitStack() as stack:
|
|
304
|
+
files = []
|
|
305
|
+
total_size = 0
|
|
306
|
+
for name in paths:
|
|
307
|
+
path = Path(name)
|
|
308
|
+
if not path.is_file() or not 0 < path.stat().st_size <= limit:
|
|
309
|
+
raise ValueError(f"Attachment must be a nonempty file no larger than {limit} bytes: {path}")
|
|
310
|
+
total_size += path.stat().st_size
|
|
311
|
+
if total_size > 50 * 1024 * 1024:
|
|
312
|
+
raise ValueError("A single upload is limited to 50 MiB across all files.")
|
|
313
|
+
files.append(("file", (path.name, stack.enter_context(path.open("rb")), "application/octet-stream")))
|
|
314
|
+
return self._req("POST", f"/rest/api/3/issue/{identifier(key)}/attachments", files=files,
|
|
315
|
+
headers={"X-Atlassian-Token": "no-check", "Content-Type": None})
|
|
316
|
+
|
|
317
|
+
def attachment_download(self, attachment_id, destination):
|
|
318
|
+
metadata = self.attachment_info(attachment_id)
|
|
319
|
+
size = metadata.get("size")
|
|
320
|
+
if not isinstance(size, int) or not 0 <= size <= 250 * 1024 * 1024:
|
|
321
|
+
raise ValueError("Invalid attachment size or attachment exceeds the 250 MiB download limit.")
|
|
322
|
+
name = Path(metadata.get("filename", "attachment").replace("\\", "/")).name
|
|
323
|
+
if name in ("", ".", ".."):
|
|
324
|
+
raise ValueError("Jira returned an unsafe attachment filename.")
|
|
325
|
+
output = Path(destination)
|
|
326
|
+
if output.is_dir():
|
|
327
|
+
output = output / name
|
|
328
|
+
if output.exists():
|
|
329
|
+
raise ValueError(f"Destination already exists: {output}")
|
|
330
|
+
response = self.s.get(self.site + f"/rest/api/3/attachment/content/{identifier(attachment_id)}",
|
|
331
|
+
params={"redirect": "false"}, stream=True, timeout=(10, 60), allow_redirects=False)
|
|
332
|
+
temporary = None
|
|
333
|
+
try:
|
|
334
|
+
if response.status_code != 200:
|
|
335
|
+
raise JiraError("GET", "/attachment/content", response.status_code, "Attachment download failed.")
|
|
336
|
+
fd, temporary = tempfile.mkstemp(prefix=".jira-attachment-", dir=output.parent)
|
|
337
|
+
received = 0
|
|
338
|
+
with os.fdopen(fd, "wb") as stream:
|
|
339
|
+
for chunk in response.iter_content(1024 * 1024):
|
|
340
|
+
received += len(chunk)
|
|
341
|
+
if received > size:
|
|
342
|
+
raise ValueError("Attachment exceeds its declared size.")
|
|
343
|
+
stream.write(chunk)
|
|
344
|
+
if received != size:
|
|
345
|
+
raise ValueError("Attachment download was interrupted; no partial destination was retained.")
|
|
346
|
+
# Hard-link creation fails atomically if another download created the destination.
|
|
347
|
+
os.link(temporary, output)
|
|
348
|
+
return {"attachment_id": str(attachment_id), "file": str(output.absolute()), "bytes": received}
|
|
349
|
+
finally:
|
|
350
|
+
response.close()
|
|
351
|
+
if temporary is not None:
|
|
352
|
+
os.unlink(temporary)
|
|
353
|
+
|
|
354
|
+
def attachment_delete(self, attachment_id):
|
|
355
|
+
self._req("DELETE", f"/rest/api/3/attachment/{identifier(attachment_id)}")
|
|
356
|
+
return {"deleted_attachment": str(attachment_id)}
|
|
357
|
+
|
|
358
|
+
# -- boards (Agile API; no rename/columns write endpoints exist) --------------
|
|
359
|
+
def boards(self, project=None, name=None, limit=50, all_results=False):
|
|
360
|
+
params = {}
|
|
361
|
+
if project:
|
|
362
|
+
params["projectKeyOrId"] = project
|
|
363
|
+
if name:
|
|
364
|
+
params["name"] = name
|
|
365
|
+
return self._offset("/rest/agile/1.0/board", params=params, limit=limit, all_results=all_results)
|
|
366
|
+
|
|
367
|
+
def board_get(self, board_id):
|
|
368
|
+
return self._req("GET", f"/rest/agile/1.0/board/{board_id}")
|
|
369
|
+
|
|
370
|
+
def board_issues(self, board_id, jql="", max_results=50, all_results=False, fields=None):
|
|
371
|
+
params: dict = {"maxResults": max_results}
|
|
372
|
+
if jql:
|
|
373
|
+
params["jql"] = jql
|
|
374
|
+
if fields:
|
|
375
|
+
params["fields"] = fields
|
|
376
|
+
return self._offset(f"/rest/agile/1.0/board/{board_id}/issue", "issues", params=params,
|
|
377
|
+
limit=max_results, all_results=all_results)
|
|
378
|
+
|
|
379
|
+
def board_create(self, name, project, jql=None, filter_id=None, btype="scrum"):
|
|
380
|
+
if filter_id is None:
|
|
381
|
+
f = self._req("POST", "/rest/api/3/filter",
|
|
382
|
+
json={"name": name,
|
|
383
|
+
"jql": jql or project_jql(project) + " ORDER BY Rank ASC",
|
|
384
|
+
"description": f"Filter for board {name}"})
|
|
385
|
+
filter_id = int(f["id"])
|
|
386
|
+
return self._req("POST", "/rest/agile/1.0/board",
|
|
387
|
+
json={"name": name, "type": btype, "filterId": filter_id,
|
|
388
|
+
"location": {"type": "project",
|
|
389
|
+
"projectKeyOrId": project}})
|
|
390
|
+
|
|
391
|
+
def board_feature(self, board_id, feature, enabling: bool):
|
|
392
|
+
return self._req("PUT", f"/rest/agile/1.0/board/{board_id}/features",
|
|
393
|
+
json={"boardId": int(board_id), "enabling": enabling,
|
|
394
|
+
"feature": feature})
|
|
395
|
+
|
|
396
|
+
# -- sprints ------------------------------------------------------------------
|
|
397
|
+
def sprints(self, board_id, state="active,future", limit=50, all_results=False):
|
|
398
|
+
return self._offset(f"/rest/agile/1.0/board/{board_id}/sprint", params={"state": state},
|
|
399
|
+
limit=limit, all_results=all_results)
|
|
400
|
+
|
|
401
|
+
def sprint_create(self, board_id, name, goal=""):
|
|
402
|
+
return self._req("POST", "/rest/agile/1.0/sprint",
|
|
403
|
+
json={"name": name, "goal": goal or None,
|
|
404
|
+
"originBoardId": int(board_id)})
|
|
405
|
+
|
|
406
|
+
def sprint_set_state(self, sprint_id, state: str):
|
|
407
|
+
return self._req("POST", f"/rest/agile/1.0/sprint/{sprint_id}",
|
|
408
|
+
json={"state": state})
|
|
409
|
+
|
|
410
|
+
def sprint_add_issues(self, sprint_id, keys):
|
|
411
|
+
return self._req("POST", f"/rest/agile/1.0/sprint/{sprint_id}/issue",
|
|
412
|
+
json={"issues": list(keys)})
|