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/api_spec.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""`jira api --spec`: look up endpoint requirements in cached official OpenAPI documents.
|
|
2
|
+
|
|
3
|
+
Discovery is public and advisory. It never resolves a Jira identity or sends credentials,
|
|
4
|
+
fetches only the fixed official spec URLs, resolves references only inside the fetched
|
|
5
|
+
document, and never blocks a raw request when a spec is missing or stale.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from .api import ApiError, invalid, validate_path
|
|
21
|
+
|
|
22
|
+
SOURCES = {
|
|
23
|
+
"platform": ("Jira Cloud platform REST API", "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"),
|
|
24
|
+
"software": ("Jira Software Cloud API", "https://developer.atlassian.com/cloud/jira/software/swagger.v3.json"),
|
|
25
|
+
"service-management": ("Jira Service Management REST API", "https://developer.atlassian.com/cloud/jira/service-desk/swagger.v3.json"),
|
|
26
|
+
}
|
|
27
|
+
# Path prefixes served by each document; anything else under /rest/ belongs to Jira Software.
|
|
28
|
+
PLATFORM_PREFIXES = ("/rest/api/", "/rest/atlassian-connect/", "/rest/forge/", "/rest/internal/")
|
|
29
|
+
SERVICE_MANAGEMENT_PREFIXES = ("/rest/servicedeskapi/",)
|
|
30
|
+
MAX_SPEC = 20 * 1024 * 1024
|
|
31
|
+
STALE_AFTER = 30 * 24 * 3600
|
|
32
|
+
MAX_DEPTH = 6
|
|
33
|
+
MAX_NODES = 4000
|
|
34
|
+
RUNTIME_NOTE = ("Specs describe the API, not your site. Required fields, allowed values, transitions, screens and "
|
|
35
|
+
"permissions vary by project and account; discover them at runtime, for example with "
|
|
36
|
+
"jira project fields KEY --type TYPE or jira issue transitions KEY.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cache_dir() -> Path:
|
|
40
|
+
if os.name == "nt":
|
|
41
|
+
base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
42
|
+
else:
|
|
43
|
+
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
44
|
+
return base / "jira-cli-toolkit" / "api-specs"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def family_for(path: str) -> str:
|
|
48
|
+
if path.startswith(PLATFORM_PREFIXES):
|
|
49
|
+
return "platform"
|
|
50
|
+
if path.startswith(SERVICE_MANAGEMENT_PREFIXES):
|
|
51
|
+
return "service-management"
|
|
52
|
+
return "software"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now() -> str:
|
|
56
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _index(folder: Path) -> dict:
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads((folder / "index.json").read_text(encoding="utf-8"))
|
|
62
|
+
return data if isinstance(data, dict) else {}
|
|
63
|
+
except (FileNotFoundError, ValueError):
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _atomic_write(path: Path, data: bytes) -> None:
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
70
|
+
try:
|
|
71
|
+
temporary.write_bytes(data)
|
|
72
|
+
os.replace(temporary, path)
|
|
73
|
+
finally:
|
|
74
|
+
if temporary.exists():
|
|
75
|
+
temporary.unlink()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate(raw: bytes, family: str) -> dict:
|
|
79
|
+
try:
|
|
80
|
+
document = json.loads(raw)
|
|
81
|
+
except ValueError:
|
|
82
|
+
raise ApiError(1, "spec_invalid", f"The {family} spec is not valid JSON; the cached copy was kept.") from None
|
|
83
|
+
if not (isinstance(document, dict) and str(document.get("openapi", "")).startswith("3.")
|
|
84
|
+
and isinstance(document.get("paths"), dict) and isinstance(document.get("info"), dict)):
|
|
85
|
+
raise ApiError(1, "spec_invalid", f"The {family} spec is not an OpenAPI 3 document; the cached copy was kept.")
|
|
86
|
+
return document
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def refresh(families=None, folder: Path | None = None, session=None) -> list[dict]:
|
|
90
|
+
"""Fetch official specs without credentials, using conditional requests when cached."""
|
|
91
|
+
folder = folder or cache_dir()
|
|
92
|
+
index = _index(folder)
|
|
93
|
+
session = session or requests.Session()
|
|
94
|
+
results = []
|
|
95
|
+
for family in families or SOURCES:
|
|
96
|
+
title, url = SOURCES[family]
|
|
97
|
+
entry = index.get(family, {})
|
|
98
|
+
cached = (folder / f"{family}.json").exists()
|
|
99
|
+
# The CDN sends an ETag only for uncompressed responses, and conditional refresh needs it.
|
|
100
|
+
headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
|
|
101
|
+
if cached and entry.get("etag"):
|
|
102
|
+
headers["If-None-Match"] = entry["etag"]
|
|
103
|
+
try:
|
|
104
|
+
# No auth, no redirects: only the fixed official address is trusted.
|
|
105
|
+
response = session.get(url, headers=headers, timeout=(10, 60), stream=True, allow_redirects=False)
|
|
106
|
+
except requests.RequestException:
|
|
107
|
+
raise ApiError(1, "network_error", f"Could not download the {family} spec from {url}. "
|
|
108
|
+
+ ("The cached copy is unchanged." if cached else "No cached copy exists yet.")) from None
|
|
109
|
+
with response:
|
|
110
|
+
if response.status_code == 304 and cached:
|
|
111
|
+
entry["checked_at"] = _now()
|
|
112
|
+
index[family] = entry
|
|
113
|
+
results.append({"family": family, "status": "unchanged", **_summary(entry)})
|
|
114
|
+
continue
|
|
115
|
+
if response.status_code != 200:
|
|
116
|
+
raise ApiError(1, "spec_unavailable", f"{url} answered HTTP {response.status_code}; "
|
|
117
|
+
+ ("the cached copy is unchanged." if cached else "no cached copy exists yet."))
|
|
118
|
+
raw = bytearray()
|
|
119
|
+
for chunk in response.iter_content(1024 * 1024):
|
|
120
|
+
raw += chunk
|
|
121
|
+
if len(raw) > MAX_SPEC:
|
|
122
|
+
raise ApiError(1, "spec_invalid", f"The {family} spec exceeds {MAX_SPEC} bytes; the cached copy was kept.")
|
|
123
|
+
document = _validate(bytes(raw), family)
|
|
124
|
+
_atomic_write(folder / f"{family}.json", bytes(raw))
|
|
125
|
+
entry = {"title": title, "url": url, "etag": response.headers.get("ETag"),
|
|
126
|
+
"last_modified": response.headers.get("Last-Modified"), "fetched_at": _now(), "checked_at": _now(),
|
|
127
|
+
"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw),
|
|
128
|
+
"version": document["info"].get("version"), "paths": len(document["paths"])}
|
|
129
|
+
index[family] = entry
|
|
130
|
+
results.append({"family": family, "status": "updated", **_summary(entry)})
|
|
131
|
+
_atomic_write(folder / "index.json", (json.dumps(index, indent=2) + "\n").encode("utf-8"))
|
|
132
|
+
return results
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _summary(entry: dict) -> dict:
|
|
136
|
+
return {k: entry.get(k) for k in ("title", "url", "version", "paths", "fetched_at", "checked_at", "sha256")}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _age(entry: dict) -> float | None:
|
|
140
|
+
try:
|
|
141
|
+
checked = datetime.fromisoformat(entry.get("checked_at") or entry["fetched_at"])
|
|
142
|
+
except (KeyError, TypeError, ValueError):
|
|
143
|
+
return None
|
|
144
|
+
return time.time() - checked.timestamp()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def status(folder: Path | None = None) -> list[dict]:
|
|
148
|
+
folder = folder or cache_dir()
|
|
149
|
+
index = _index(folder)
|
|
150
|
+
rows = []
|
|
151
|
+
for family, (title, url) in SOURCES.items():
|
|
152
|
+
entry = index.get(family)
|
|
153
|
+
if not entry or not (folder / f"{family}.json").exists():
|
|
154
|
+
rows.append({"family": family, "title": title, "url": url, "cached": False})
|
|
155
|
+
continue
|
|
156
|
+
age = _age(entry)
|
|
157
|
+
rows.append({"family": family, "cached": True, "stale": age is None or age > STALE_AFTER, **_summary(entry)})
|
|
158
|
+
return rows
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load(family: str, folder: Path | None = None, session=None) -> tuple[dict, dict]:
|
|
162
|
+
"""Return (document, cache entry); download once if nothing is cached."""
|
|
163
|
+
folder = folder or cache_dir()
|
|
164
|
+
if not (folder / f"{family}.json").exists() or family not in _index(folder):
|
|
165
|
+
refresh([family], folder, session)
|
|
166
|
+
entry = _index(folder)[family]
|
|
167
|
+
raw = (folder / f"{family}.json").read_bytes()
|
|
168
|
+
if hashlib.sha256(raw).hexdigest() != entry.get("sha256"):
|
|
169
|
+
raise ApiError(1, "spec_invalid", f"The cached {family} spec does not match its recorded hash. "
|
|
170
|
+
"Run: jira api spec refresh")
|
|
171
|
+
return _validate(raw, family), entry
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _segments(path: str) -> list[str]:
|
|
175
|
+
return path.strip("/").split("/")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def match(document: dict, path: str, method: str) -> tuple[str, dict, dict]:
|
|
179
|
+
"""Find the operation for a concrete path, preferring literal segments over templates."""
|
|
180
|
+
wanted = _segments(path)
|
|
181
|
+
candidates = []
|
|
182
|
+
for template, item in document["paths"].items():
|
|
183
|
+
parts = _segments(template)
|
|
184
|
+
if len(parts) != len(wanted):
|
|
185
|
+
continue
|
|
186
|
+
params, score = {}, []
|
|
187
|
+
for part, value in zip(parts, wanted):
|
|
188
|
+
name = re.fullmatch(r"\{([^{}]+)\}", part)
|
|
189
|
+
if name:
|
|
190
|
+
params[name.group(1)] = value
|
|
191
|
+
score.append(0)
|
|
192
|
+
elif part == value:
|
|
193
|
+
score.append(1)
|
|
194
|
+
else:
|
|
195
|
+
break
|
|
196
|
+
else:
|
|
197
|
+
candidates.append((score, template, item, params))
|
|
198
|
+
candidates.sort(key=lambda c: c[0], reverse=True)
|
|
199
|
+
for _, template, item, params in candidates:
|
|
200
|
+
if isinstance(item.get(method.lower()), dict):
|
|
201
|
+
return template, item, params
|
|
202
|
+
if candidates:
|
|
203
|
+
methods = sorted({m.upper() for _, _, item, _ in candidates for m, op in item.items() if isinstance(op, dict)})
|
|
204
|
+
raise ApiError(1, "spec_not_found", f"{path} is documented, but not for {method}. Documented methods: "
|
|
205
|
+
f"{', '.join(methods)}. Pass one with -X.", methods=methods)
|
|
206
|
+
raise ApiError(1, "spec_not_found", f"No documented operation matches {method} {path}. The cached spec may be "
|
|
207
|
+
"older than the endpoint; run jira api spec refresh. The request itself can still be sent "
|
|
208
|
+
"with jira api.")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Resolver:
|
|
212
|
+
"""Inline local #/components references with depth, size and cycle limits."""
|
|
213
|
+
|
|
214
|
+
def __init__(self, document: dict):
|
|
215
|
+
self.document, self.nodes = document, 0
|
|
216
|
+
|
|
217
|
+
def target(self, ref: str):
|
|
218
|
+
if not ref.startswith("#/"):
|
|
219
|
+
return None
|
|
220
|
+
node = self.document
|
|
221
|
+
for part in ref[2:].split("/"):
|
|
222
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
223
|
+
if not isinstance(node, dict) or part not in node:
|
|
224
|
+
return None
|
|
225
|
+
node = node[part]
|
|
226
|
+
return node
|
|
227
|
+
|
|
228
|
+
def __call__(self, node, depth=0, seen=()):
|
|
229
|
+
self.nodes += 1
|
|
230
|
+
if isinstance(node, list):
|
|
231
|
+
return [self(item, depth, seen) for item in node]
|
|
232
|
+
if not isinstance(node, dict):
|
|
233
|
+
return node
|
|
234
|
+
ref = node.get("$ref")
|
|
235
|
+
if isinstance(ref, str):
|
|
236
|
+
name = ref.rsplit("/", 1)[-1]
|
|
237
|
+
if ref in seen:
|
|
238
|
+
return {"$ref": ref, "x-note": f"{name} repeats here (recursive); see its first expansion."}
|
|
239
|
+
if depth >= MAX_DEPTH or self.nodes > MAX_NODES:
|
|
240
|
+
return {"$ref": ref, "x-note": f"{name} not expanded to keep the output bounded."}
|
|
241
|
+
target = self.target(ref)
|
|
242
|
+
if target is None:
|
|
243
|
+
# Only references inside the official document are followed; nothing is fetched.
|
|
244
|
+
return {"$ref": ref, "x-note": "Reference outside this document; not followed."}
|
|
245
|
+
return {"x-schema": name, **self(target, depth + 1, seen + (ref,))}
|
|
246
|
+
return {key: self(value, depth, seen) for key, value in node.items()}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _permissions(description: str) -> str | None:
|
|
250
|
+
found = re.search(r"\*\*\[?Permissions\]?(?:\([^)]*\))? required:?\*\*:?\s*(.+?)(?:\n\n|$)", description or "", re.S)
|
|
251
|
+
return re.sub(r"\s+", " ", found.group(1)).strip() if found else None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def describe(path: str, method: str, folder: Path | None = None, session=None) -> dict:
|
|
255
|
+
family = family_for(path)
|
|
256
|
+
document, entry = load(family, folder, session)
|
|
257
|
+
template, item, params = match(document, path, method)
|
|
258
|
+
operation = item[method.lower()]
|
|
259
|
+
resolve = _Resolver(document)
|
|
260
|
+
parameters = resolve([*item.get("parameters", []), *operation.get("parameters", [])])
|
|
261
|
+
for parameter in parameters:
|
|
262
|
+
if parameter.get("in") == "path" and parameter.get("name") in params:
|
|
263
|
+
parameter["x-value"] = params[parameter["name"]]
|
|
264
|
+
responses = {}
|
|
265
|
+
for code, body in operation.get("responses", {}).items():
|
|
266
|
+
body = resolve(body)
|
|
267
|
+
responses[code] = body if str(code).startswith("2") else {"description": body.get("description")}
|
|
268
|
+
age = _age(entry)
|
|
269
|
+
return {
|
|
270
|
+
"method": method, "path": path, "template": template, "pathParameters": params,
|
|
271
|
+
"operationId": operation.get("operationId"), "summary": operation.get("summary"),
|
|
272
|
+
"deprecated": bool(operation.get("deprecated")), "experimental": bool(operation.get("x-experimental")),
|
|
273
|
+
"permissions": _permissions(operation.get("description", "")),
|
|
274
|
+
"scopes": {"oauth2": operation.get("x-atlassian-oauth2-scopes", []), "connect": operation.get("x-atlassian-connect-scope")},
|
|
275
|
+
"description": operation.get("description"),
|
|
276
|
+
"parameters": parameters,
|
|
277
|
+
"requestBody": resolve(operation["requestBody"]) if "requestBody" in operation else None,
|
|
278
|
+
"responses": responses,
|
|
279
|
+
"source": {"family": family, **_summary(entry), "stale": age is None or age > STALE_AFTER},
|
|
280
|
+
"note": RUNTIME_NOTE,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _emit(data) -> None:
|
|
285
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def run(args) -> None:
|
|
289
|
+
"""Handle --spec and the spec subcommands. Never resolves a Jira identity."""
|
|
290
|
+
if args.path == "spec":
|
|
291
|
+
action = args.spec_action
|
|
292
|
+
if action == "refresh":
|
|
293
|
+
_emit(refresh())
|
|
294
|
+
elif action in (None, "status"):
|
|
295
|
+
_emit(status())
|
|
296
|
+
else:
|
|
297
|
+
raise invalid(f"Unknown spec action {action!r}; use jira api spec refresh or jira api spec status.")
|
|
298
|
+
return
|
|
299
|
+
if args.spec_action:
|
|
300
|
+
raise invalid(f"Unexpected argument {args.spec_action!r}; pass query values with --query KEY=VALUE.")
|
|
301
|
+
used = [flag for flag, value in (("--query", args.query), ("--header", args.header), ("--data", args.data),
|
|
302
|
+
("--raw-data", args.raw_data), ("--form", args.form), ("--include", args.include),
|
|
303
|
+
("--output", args.output), ("--content-type", args.content_type)) if value]
|
|
304
|
+
if used:
|
|
305
|
+
raise invalid(f"--spec describes an operation and sends nothing; remove {', '.join(used)}.")
|
|
306
|
+
result = describe(validate_path(args.path), args.method.upper())
|
|
307
|
+
if result["source"]["stale"]:
|
|
308
|
+
print("The cached spec is more than 30 days old; run jira api spec refresh for current details.", file=sys.stderr)
|
|
309
|
+
_emit(result)
|
jsup/application.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Application operations shared by grouped commands and legacy aliases."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import nullcontext
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from . import config, ui
|
|
11
|
+
from .client import Jira, JiraError, project_jql
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommandError(Exception):
|
|
15
|
+
"""An actionable input error, reported by the CLI entry point."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def require_input(args, message: str) -> None:
|
|
19
|
+
if args.no_input or args.json or not sys.stdin.isatty():
|
|
20
|
+
raise CommandError(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def show_context(args, prog: str, include_help: bool = False) -> None:
|
|
24
|
+
cfg = config.resolve_config(args, read_secret=False)
|
|
25
|
+
data = {"site": cfg["site"] or None, "project": cfg["project"] or None}
|
|
26
|
+
if cfg.get("profile"):
|
|
27
|
+
data.update(profile=cfg["profile"], board=cfg.get("board"))
|
|
28
|
+
if args.json:
|
|
29
|
+
ui.dump_json(data)
|
|
30
|
+
else:
|
|
31
|
+
ui.console.print(f"{prog}: site={data['site'] or '(unset)'} project={data['project'] or '(unset)'}",
|
|
32
|
+
markup=False)
|
|
33
|
+
if include_help:
|
|
34
|
+
ui.console.print(f"Try: {prog} --help · {prog} config init · {prog} issue list --open",
|
|
35
|
+
markup=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _project(cfg: dict, key: str | None = None) -> str:
|
|
39
|
+
result = key or cfg["project"]
|
|
40
|
+
if not result:
|
|
41
|
+
raise CommandError("Pass --project KEY or set a default with config init.")
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _open_browser(args) -> bool:
|
|
46
|
+
"""Browser-only operations need a site URL, not API credentials."""
|
|
47
|
+
if args.cmd == "browse":
|
|
48
|
+
if args.target.startswith("board:"):
|
|
49
|
+
try:
|
|
50
|
+
board_id = int(args.target[6:])
|
|
51
|
+
if board_id < 1:
|
|
52
|
+
raise ValueError
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise CommandError("Use board:<positive board ID>.") from None
|
|
55
|
+
path = f"/secure/RapidBoard.jspa?rapidView={board_id}"
|
|
56
|
+
else:
|
|
57
|
+
path = f"/browse/{quote(args.target, safe='')}"
|
|
58
|
+
elif args.cmd == "issue-show" and args.web:
|
|
59
|
+
path = f"/browse/{quote(args.key, safe='')}"
|
|
60
|
+
elif args.cmd == "board-show" and args.web:
|
|
61
|
+
# Jira resolves the actual board location, without a project guess.
|
|
62
|
+
path = f"/secure/RapidBoard.jspa?rapidView={args.board_id}"
|
|
63
|
+
else:
|
|
64
|
+
return False
|
|
65
|
+
site = config.resolve_config(args, read_secret=False)["site"]
|
|
66
|
+
if not site:
|
|
67
|
+
raise CommandError("Set JIRA_SITE or pass --site to open Jira in a browser.")
|
|
68
|
+
url = site + path
|
|
69
|
+
if not webbrowser.open(url):
|
|
70
|
+
raise CommandError(f"Could not open a browser. Open this URL manually: {url}")
|
|
71
|
+
if args.json:
|
|
72
|
+
ui.dump_json({"url": url, "opened": True})
|
|
73
|
+
else:
|
|
74
|
+
ui.console.print(f"Opening {url}", markup=False)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _prepare_intake(args) -> None:
|
|
79
|
+
if not args.name or not args.issue:
|
|
80
|
+
require_input(args, "intake needs --name and --issue when input is unavailable.")
|
|
81
|
+
name = args.name or input("Client name: ").strip()
|
|
82
|
+
issue = args.issue or input("Issue as reported: ").strip()
|
|
83
|
+
if not (name and issue):
|
|
84
|
+
raise CommandError("intake needs a name and an issue.")
|
|
85
|
+
from .templates import load, render
|
|
86
|
+
values = render(load("callback"), {"name": name, "issue": issue, "callback": args.callback})
|
|
87
|
+
args.summary, args.desc = values["summary"], values["description"]
|
|
88
|
+
args.label, args.component, args.assignee = values["labels"], values["components"], None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dashboard(j: Jira, args, cfg: dict) -> dict:
|
|
93
|
+
keys = args.projects or ([cfg["project"]] if cfg["project"] else [])
|
|
94
|
+
if not keys:
|
|
95
|
+
raise CommandError("Select dashboard projects with --projects KEY ... or --project KEY.")
|
|
96
|
+
rows = []
|
|
97
|
+
for key in dict.fromkeys(keys):
|
|
98
|
+
try:
|
|
99
|
+
count = j.count_issues(project_jql(key) + " AND statusCategory != Done")["count"]
|
|
100
|
+
rows.append({"project": key, "count": count, "approximate": True})
|
|
101
|
+
except JiraError as error:
|
|
102
|
+
if error.status in (401, 403):
|
|
103
|
+
raise
|
|
104
|
+
rows.append({"project": key, "count": None, "error": f"Jira returned HTTP {error.status}"})
|
|
105
|
+
return {"site": cfg["site"], "projects": rows}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fetch(j: Jira, args, cfg: dict):
|
|
109
|
+
project = lambda key=None: _project(cfg, key)
|
|
110
|
+
cmd = args.cmd
|
|
111
|
+
if cmd in {"sprint-list", "sprint-create"} and not args.board:
|
|
112
|
+
args.board = cfg.get("board")
|
|
113
|
+
if not args.board:
|
|
114
|
+
raise CommandError("Pass --board ID or select a default with context use --board ID.")
|
|
115
|
+
pagination = {}
|
|
116
|
+
if getattr(args, "all", False):
|
|
117
|
+
pagination["all_results"] = True
|
|
118
|
+
if cmd in {"project-list", "board-list", "sprint-list", "comment-list"} and args.max != 50:
|
|
119
|
+
pagination["limit"] = args.max
|
|
120
|
+
if cmd == "dashboard":
|
|
121
|
+
return _dashboard(j, args, cfg)
|
|
122
|
+
if cmd in ("issue-create", "intake"):
|
|
123
|
+
if cmd == "issue-create" and not args.legacy:
|
|
124
|
+
from .fields import create
|
|
125
|
+
return create(j, args, cfg)
|
|
126
|
+
desc = (Path(args.desc_file).read_text() if cmd == "issue-create" and args.desc_file
|
|
127
|
+
else args.desc)
|
|
128
|
+
return j.issue_create(project(), args.summary, desc, args.type,
|
|
129
|
+
args.priority, args.label, args.component, args.assignee)
|
|
130
|
+
if cmd == "issue-list":
|
|
131
|
+
from .maintenance import issue_list
|
|
132
|
+
return issue_list(j, args, cfg)
|
|
133
|
+
if cmd in {"issue-edit", "issue-assign", "issue-unassign", "issue-link", "issue-unlink", "comment-add", "comment-edit", "comment-delete", "attachment-list", "attachment-upload", "attachment-download", "attachment-delete"}:
|
|
134
|
+
from .maintenance import handle
|
|
135
|
+
return handle(j, args, cfg)
|
|
136
|
+
if cmd == "template-validate":
|
|
137
|
+
from . import fields, templates
|
|
138
|
+
from types import SimpleNamespace
|
|
139
|
+
creation = SimpleNamespace(**{**vars(args), "summary": None, "desc": None, "desc_file": None,
|
|
140
|
+
"editor": False, "priority": None, "assignee": None, "label": [], "component": [],
|
|
141
|
+
"parent": None, "field": [], "fields_file": None})
|
|
142
|
+
templates.apply(creation, validating=True)
|
|
143
|
+
fields.prepare_create(j, creation, cfg)
|
|
144
|
+
return {"valid": True, "template": args.template, "project": cfg["project"], "type": creation.type}
|
|
145
|
+
if cmd == "issue-move":
|
|
146
|
+
if not args.legacy:
|
|
147
|
+
from .fields import transition
|
|
148
|
+
return transition(j, args, cfg)
|
|
149
|
+
return j.move(args.key, args.to)
|
|
150
|
+
if cmd in {"project-types", "project-fields", "project-statuses"}:
|
|
151
|
+
from .fields import cached, choose
|
|
152
|
+
selected = project(args.key)
|
|
153
|
+
if cmd == "project-statuses":
|
|
154
|
+
return j.project_statuses(selected)
|
|
155
|
+
types = cached(cfg, selected, "types", lambda: j.issue_types(selected), args.refresh)
|
|
156
|
+
if cmd == "project-types":
|
|
157
|
+
return types
|
|
158
|
+
issue_type = choose(types, args.type, "--type")
|
|
159
|
+
return cached(cfg, selected, "create:" + issue_type["id"],
|
|
160
|
+
lambda: j.create_fields(selected, issue_type["id"]), args.refresh)
|
|
161
|
+
if cmd == "issue-delete":
|
|
162
|
+
return j.issue_delete(args.key)
|
|
163
|
+
operations = {
|
|
164
|
+
"me": lambda: j.me(),
|
|
165
|
+
"project-list": lambda: j.projects(**pagination),
|
|
166
|
+
"project-show": lambda: j.project_get(project(args.key)),
|
|
167
|
+
"open": lambda: j.open_tickets(project(), args.jql, args.max, **pagination),
|
|
168
|
+
"issue-show": lambda: j.issue_get(args.key),
|
|
169
|
+
"transitions": lambda: j.transitions(args.key),
|
|
170
|
+
"comment-add": lambda: j.comment_add(args.key, args.message),
|
|
171
|
+
"comment-list": lambda: j.comments(args.key, **pagination),
|
|
172
|
+
"board-list": lambda: j.boards(project=(args.jql_project or
|
|
173
|
+
(None if args.legacy else cfg["project"] or None)), **pagination),
|
|
174
|
+
"board-show": lambda: j.board_get(args.board_id),
|
|
175
|
+
"board-create": lambda: j.board_create(args.name, project(), args.jql, args.filter_id, args.type),
|
|
176
|
+
"board-issues": lambda: j.board_issues(args.board_id, args.jql, args.max, **pagination),
|
|
177
|
+
"board-feature": lambda: j.board_feature(args.board_id, args.feature, args.enable),
|
|
178
|
+
"sprint-list": lambda: j.sprints(args.board, args.state, **pagination),
|
|
179
|
+
"sprint-create": lambda: j.sprint_create(args.board, args.name, args.goal),
|
|
180
|
+
"sprint-state": lambda: j.sprint_set_state(args.sprint_id, args.state),
|
|
181
|
+
"sprint-add": lambda: j.sprint_add_issues(args.sprint_id, args.keys),
|
|
182
|
+
"component-list": lambda: j.components(project()),
|
|
183
|
+
"component-create": lambda: j.component_create(project(), args.name, args.desc),
|
|
184
|
+
}
|
|
185
|
+
return operations[cmd]()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _render(data, args, cfg: dict, prog: str) -> None:
|
|
189
|
+
if args.csv:
|
|
190
|
+
ui.csv_data(data, args.columns)
|
|
191
|
+
return
|
|
192
|
+
if args.json:
|
|
193
|
+
ui.dump_json(data)
|
|
194
|
+
return
|
|
195
|
+
cmd = args.cmd
|
|
196
|
+
if cmd == "dashboard":
|
|
197
|
+
ui.console.print(f"{prog} → {cfg['site']}", markup=False)
|
|
198
|
+
for row in data["projects"]:
|
|
199
|
+
value = (f"approximately {row['count']} open" if row["count"] is not None
|
|
200
|
+
else f"unavailable: {row['error']}")
|
|
201
|
+
ui.console.print(f" {row['project']}: {value}", markup=False)
|
|
202
|
+
elif cmd == "me":
|
|
203
|
+
ui.console.print(f"✓ {data.get('displayName')} ({data.get('emailAddress')})", markup=False)
|
|
204
|
+
elif cmd in ("open", "issue-list"):
|
|
205
|
+
ui.issues_table(data, "Open issues" if cmd == "open" or args.open else "Issues", args.columns)
|
|
206
|
+
elif cmd == "issue-show":
|
|
207
|
+
ui.issue_panel(data)
|
|
208
|
+
elif cmd == "board-issues":
|
|
209
|
+
ui.issues_table(data, f"Board {args.board_id}", args.columns)
|
|
210
|
+
elif cmd == "comment-list":
|
|
211
|
+
ui.comments_table(data)
|
|
212
|
+
elif cmd == "board-list":
|
|
213
|
+
ui.boards_table(data)
|
|
214
|
+
elif cmd == "sprint-list":
|
|
215
|
+
ui.sprints_table(data)
|
|
216
|
+
elif cmd == "component-list":
|
|
217
|
+
ui.simple_table("Components", [{"name": c.get("name"), "desc": c.get("description") or ""}
|
|
218
|
+
for c in data], ["name", "desc"])
|
|
219
|
+
elif cmd == "project-list":
|
|
220
|
+
ui.simple_table("Projects", [{"key": p["key"], "name": p.get("name", "")} for p in data],
|
|
221
|
+
["key", "name"])
|
|
222
|
+
elif cmd == "issue-move":
|
|
223
|
+
ui.console.print(f"Moved {data['moved']} → {data['to']}", markup=False)
|
|
224
|
+
elif cmd == "issue-delete":
|
|
225
|
+
ui.console.print(f"Deleted {args.key}", markup=False)
|
|
226
|
+
else:
|
|
227
|
+
ui.console.print(f"✓ {data}", markup=False)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run(args, prog: str) -> None:
|
|
231
|
+
if args.json and args.csv:
|
|
232
|
+
raise CommandError("Choose --json or --csv, not both.")
|
|
233
|
+
if args.csv and args.cmd not in {"issue-list", "open", "board-list", "board-issues", "project-list", "comment-list", "sprint-list", "component-list", "project-types", "project-fields", "attachment-list"}:
|
|
234
|
+
raise CommandError("--csv is supported only for list commands.")
|
|
235
|
+
if args.cmd == "completion":
|
|
236
|
+
from .completion import script
|
|
237
|
+
print(script(args.shell, prog), end="")
|
|
238
|
+
return
|
|
239
|
+
if args.cmd in {"template-list", "template-show"}:
|
|
240
|
+
from . import templates
|
|
241
|
+
data = templates.list_templates() if args.cmd == "template-list" else templates.load(args.template)
|
|
242
|
+
if args.json:
|
|
243
|
+
ui.dump_json(data)
|
|
244
|
+
else:
|
|
245
|
+
ui.console.print(data, markup=False)
|
|
246
|
+
return
|
|
247
|
+
if args.cmd == "update":
|
|
248
|
+
from . import update
|
|
249
|
+
data = update.handle(args)
|
|
250
|
+
if args.json:
|
|
251
|
+
ui.dump_json(data)
|
|
252
|
+
else:
|
|
253
|
+
ui.console.print(data, markup=False)
|
|
254
|
+
return
|
|
255
|
+
if args.cmd in {"auth-login", "auth-status", "auth-logout", "config-migrate", "profile-list", "profile-use", "profile-remove", "context-use", "config-get", "config-set", "doctor"}:
|
|
256
|
+
from . import auth
|
|
257
|
+
data = auth.handle(args)
|
|
258
|
+
if args.json:
|
|
259
|
+
ui.dump_json(data)
|
|
260
|
+
else:
|
|
261
|
+
ui.console.print(data, markup=False)
|
|
262
|
+
return
|
|
263
|
+
if args.cmd == "config-init":
|
|
264
|
+
require_input(args, "config init requires interactive input; use environment variables for scripts.")
|
|
265
|
+
if args.json:
|
|
266
|
+
raise CommandError("config init is interactive; omit --json.")
|
|
267
|
+
config.init_config()
|
|
268
|
+
return
|
|
269
|
+
if args.cmd == "config-show":
|
|
270
|
+
config.show_config(as_json=args.json)
|
|
271
|
+
return
|
|
272
|
+
if args.cmd == "context-show":
|
|
273
|
+
show_context(args, prog)
|
|
274
|
+
return
|
|
275
|
+
if _open_browser(args):
|
|
276
|
+
return
|
|
277
|
+
if args.cmd == "dashboard" and args.json and prog == "jsup":
|
|
278
|
+
cfg = config.resolve_config(args)
|
|
279
|
+
ui.dump_json({"project": cfg["project"], "hint": "use open/board-list"})
|
|
280
|
+
return
|
|
281
|
+
if args.cmd == "issue-move" and not args.to:
|
|
282
|
+
require_input(args, "Pass --to <transition name or ID> when input is unavailable.")
|
|
283
|
+
if args.cmd == "issue-delete" and not args.yes:
|
|
284
|
+
require_input(args, "Pass --yes to confirm deletion when input is unavailable.")
|
|
285
|
+
if not ui.confirm(f"Permanently delete {args.key}?"):
|
|
286
|
+
raise CommandError("Aborted.")
|
|
287
|
+
if args.cmd in {"issue-unlink", "comment-delete", "attachment-delete"} and not args.yes:
|
|
288
|
+
require_input(args, "Pass --yes to confirm deletion when input is unavailable.")
|
|
289
|
+
if not ui.confirm("Permanently delete the selected item?"):
|
|
290
|
+
raise CommandError("Aborted.")
|
|
291
|
+
if args.cmd == "api":
|
|
292
|
+
# No credential-replacement prompt or retried writes: the request is the caller's.
|
|
293
|
+
from . import api
|
|
294
|
+
if args.spec or args.path == "spec":
|
|
295
|
+
# Discovery is public; no identity or credential store is touched.
|
|
296
|
+
from . import api_spec
|
|
297
|
+
api_spec.run(args)
|
|
298
|
+
return
|
|
299
|
+
if args.spec_action:
|
|
300
|
+
raise api.invalid(f"Unexpected argument {args.spec_action!r}; pass query values with --query KEY=VALUE.")
|
|
301
|
+
api.run(args, config.resolve_config(args))
|
|
302
|
+
return
|
|
303
|
+
if args.cmd == "intake":
|
|
304
|
+
_prepare_intake(args)
|
|
305
|
+
cfg = config.resolve_config(args)
|
|
306
|
+
if not (cfg["site"] and cfg["email"] and cfg["token"]):
|
|
307
|
+
raise CommandError(f"Not configured yet. Run: {prog} config init")
|
|
308
|
+
j = Jira(cfg.get("api_site", cfg["site"]), cfg["email"], cfg["token"])
|
|
309
|
+
try:
|
|
310
|
+
if args.cmd == "issue-move" and not args.to:
|
|
311
|
+
transitions = j.transitions(args.key).get("transitions", [])
|
|
312
|
+
if not transitions:
|
|
313
|
+
raise CommandError("No transitions available.")
|
|
314
|
+
selection = ui.pick("Move to", [f"{t['name']} [{t['id']}]" for t in transitions])
|
|
315
|
+
args.to = transitions[selection]["id"]
|
|
316
|
+
with ui.err_console.status("Talking to Jira…", spinner="dots") if not args.json else nullcontext():
|
|
317
|
+
data = _fetch(j, args, cfg)
|
|
318
|
+
_render(data, args, cfg, prog)
|
|
319
|
+
except JiraError as error:
|
|
320
|
+
if error.status == 401 and cfg.get("profile") and not args.no_input and not args.json and sys.stdin.isatty():
|
|
321
|
+
if ui.confirm("Authentication failed. Replace this profile's API token now?"):
|
|
322
|
+
from . import auth
|
|
323
|
+
from types import SimpleNamespace
|
|
324
|
+
recovery = SimpleNamespace(profile=cfg["profile"], site=None, email=None, token=None,
|
|
325
|
+
project=None, json=False, no_input=False)
|
|
326
|
+
auth.login(recovery, replace=True)
|
|
327
|
+
ui.err_console.print("Token replaced. Run the command again after checking the previous operation's outcome.")
|
|
328
|
+
raise
|
|
329
|
+
finally:
|
|
330
|
+
j.close()
|