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.
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)})