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/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()