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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
CLI Toolkit for Jira
|
|
2
|
+
Copyright (C) 2026 Abhishek Aggarwal
|
|
3
|
+
SPDX-License-Identifier: AGPL-3.0-only
|
|
4
|
+
|
|
5
|
+
Original project code is licensed under version 3 only of the GNU Affero
|
|
6
|
+
General Public License, except for separately identified third-party material.
|
|
7
|
+
|
|
8
|
+
This program is free software: you can redistribute it and/or modify it
|
|
9
|
+
under the terms of the GNU Affero General Public License as published by
|
|
10
|
+
the Free Software Foundation, version 3 of the License.
|
|
11
|
+
|
|
12
|
+
This program is distributed in the hope that it will be useful,
|
|
13
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
GNU Affero General Public License for more details.
|
|
16
|
+
|
|
17
|
+
You should have received a copy of the GNU Affero General Public License
|
|
18
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
19
|
+
|
|
20
|
+
Third-party components and assets retain their copyright notices and
|
|
21
|
+
applicable license terms. Preserve those notices when redistributing them.
|
|
22
|
+
Website font/component/icon notices are in web/public/third-party-notices.txt.
|
|
23
|
+
Dependencies and separately identified third-party material are not relicensed
|
|
24
|
+
by this notice. Applicable AGPL obligations for covered combined works remain.
|
|
25
|
+
The project license does not grant rights to third-party trademarks.
|
jsup/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Jira CLI Toolkit — Jira Cloud CLI with a compatible jsup entry point."""
|
|
2
|
+
from .config import CONFIG_PATH, get_config, init_config, show_config
|
|
3
|
+
from .client import Jira, JiraError, adf, adf_to_text
|
|
4
|
+
|
|
5
|
+
__all__ = ["CONFIG_PATH", "get_config", "init_config", "show_config",
|
|
6
|
+
"Jira", "JiraError", "adf", "adf_to_text"]
|
|
7
|
+
__version__ = "2.5.1"
|
jsup/api.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""`jira api`: authenticated requests to any Jira Cloud REST path, without a token in the command.
|
|
2
|
+
|
|
3
|
+
The selected identity, trusted site and scoped-token gateway come from the existing
|
|
4
|
+
resolver. Callers choose the method, path, query, headers and body; the CLI keeps control
|
|
5
|
+
of the destination, authentication, content framing and transport.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from contextlib import ExitStack
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from urllib.parse import quote, urlsplit
|
|
19
|
+
|
|
20
|
+
from .client import Jira
|
|
21
|
+
|
|
22
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
23
|
+
# Methods retried after transient failures. A POST is never retried, even for searches.
|
|
24
|
+
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
25
|
+
BODYLESS_METHODS = {"GET", "HEAD", "OPTIONS"}
|
|
26
|
+
MAX_DATA = 10 * 1024 * 1024
|
|
27
|
+
MAX_UPLOAD = 50 * 1024 * 1024
|
|
28
|
+
MAX_RESPONSE = 250 * 1024 * 1024
|
|
29
|
+
MAX_ERROR_BODY = 64 * 1024
|
|
30
|
+
# The CLI owns authentication, destination, framing and transport.
|
|
31
|
+
RESERVED_HEADERS = {
|
|
32
|
+
"authorization", "proxy-authorization", "cookie", "host", "content-length", "content-type",
|
|
33
|
+
"transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade", "expect",
|
|
34
|
+
"forwarded", "via",
|
|
35
|
+
}
|
|
36
|
+
RESERVED_PREFIXES = ("proxy-", "x-forwarded-", "sec-")
|
|
37
|
+
HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
38
|
+
TEXT_TYPES = re.compile(r"^(text/|application/([\w.+-]*\+)?(json|xml|javascript|x-www-form-urlencoded)\b)", re.I)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ApiError(Exception):
|
|
42
|
+
"""A failed request, reported as a redacted JSON object on stderr."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, exit_code: int, code: str, message: str, **details):
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.exit_code, self.code, self.details = exit_code, code, details
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def invalid(message: str) -> ApiError:
|
|
50
|
+
return ApiError(2, "invalid_input", message)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Request:
|
|
55
|
+
method: str
|
|
56
|
+
path: str
|
|
57
|
+
params: list[tuple[str, str]] = field(default_factory=list)
|
|
58
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
body: bytes | None = None
|
|
60
|
+
content_type: str | None = None
|
|
61
|
+
form: list[tuple[str, str | None, Path | None]] = field(default_factory=list)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_path(path: str) -> str:
|
|
65
|
+
if path == "spec" or not path.startswith("/"):
|
|
66
|
+
raise invalid("PATH must be a Jira REST path such as /rest/api/3/myself. "
|
|
67
|
+
"Use jira api spec refresh or jira api spec status for discovery.")
|
|
68
|
+
if any(ord(ch) < 0x21 or ord(ch) > 0x7E for ch in path):
|
|
69
|
+
raise invalid("PATH must be printable ASCII without spaces; percent-encode other characters.")
|
|
70
|
+
if "?" in path or "#" in path:
|
|
71
|
+
raise invalid("PATH must not contain a query or fragment; pass query values with --query KEY=VALUE.")
|
|
72
|
+
if "\\" in path or "//" in path:
|
|
73
|
+
raise invalid("PATH must not contain backslashes or empty segments.")
|
|
74
|
+
if not path.startswith("/rest/"):
|
|
75
|
+
raise invalid("PATH must start with /rest/, for example /rest/api/3/myself or /rest/agile/1.0/board.")
|
|
76
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", path):
|
|
77
|
+
raise invalid("PATH contains an invalid percent-encoding.")
|
|
78
|
+
# Encoded separators and dot segments could address a different resource than the one shown.
|
|
79
|
+
if re.search(r"%(2[fF]|5[cC])", path):
|
|
80
|
+
raise invalid("PATH must not contain encoded slashes or backslashes.")
|
|
81
|
+
segments = re.sub(r"%2[eE]", ".", path).split("/")
|
|
82
|
+
if any(segment in (".", "..") for segment in segments):
|
|
83
|
+
raise invalid("PATH must not contain . or .. segments.")
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_query(values: list[str]) -> list[tuple[str, str]]:
|
|
88
|
+
params = []
|
|
89
|
+
for item in values:
|
|
90
|
+
key, sep, value = item.partition("=")
|
|
91
|
+
if not sep or not key:
|
|
92
|
+
raise invalid(f"--query expects KEY=VALUE, got {item!r}.")
|
|
93
|
+
params.append((key, value))
|
|
94
|
+
return params
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_headers(values: list[str]) -> dict[str, str]:
|
|
98
|
+
headers: dict[str, str] = {}
|
|
99
|
+
for item in values:
|
|
100
|
+
name, sep, value = item.partition(":")
|
|
101
|
+
name, value = name.strip(), value.strip()
|
|
102
|
+
if not sep or not HEADER_NAME.match(name):
|
|
103
|
+
raise invalid(f"--header expects 'Name: value', got {item!r}.")
|
|
104
|
+
if any(ch in value for ch in "\r\n\0"):
|
|
105
|
+
raise invalid(f"Header {name} contains a line break or NUL character.")
|
|
106
|
+
lower = name.lower()
|
|
107
|
+
if lower in RESERVED_HEADERS or lower.startswith(RESERVED_PREFIXES):
|
|
108
|
+
hint = " Use --content-type." if lower == "content-type" else ""
|
|
109
|
+
raise invalid(f"Header {name} is managed by the CLI and cannot be set.{hint}")
|
|
110
|
+
headers[name] = value
|
|
111
|
+
return headers
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _Inputs:
|
|
115
|
+
"""Reads @file and @- sources with size limits; stdin may be consumed only once."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, stdin=None):
|
|
118
|
+
self.stdin = stdin if stdin is not None else sys.stdin.buffer
|
|
119
|
+
self.used_stdin = False
|
|
120
|
+
|
|
121
|
+
def read(self, value: str, option: str, limit: int) -> bytes:
|
|
122
|
+
if value == "@-":
|
|
123
|
+
if self.used_stdin:
|
|
124
|
+
raise invalid("Only one option can read from stdin (@-).")
|
|
125
|
+
self.used_stdin = True
|
|
126
|
+
data = self.stdin.read(limit + 1)
|
|
127
|
+
elif value.startswith("@"):
|
|
128
|
+
path = Path(value[1:])
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
raise invalid(f"{option}: file not found: {path}")
|
|
131
|
+
if path.stat().st_size > limit:
|
|
132
|
+
raise invalid(f"{option}: {path} is larger than {limit} bytes.")
|
|
133
|
+
data = path.read_bytes()
|
|
134
|
+
else:
|
|
135
|
+
data = value.encode("utf-8")
|
|
136
|
+
if len(data) > limit:
|
|
137
|
+
raise invalid(f"{option} is larger than {limit} bytes.")
|
|
138
|
+
return data
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_request(args, stdin=None) -> Request:
|
|
142
|
+
method = args.method.upper()
|
|
143
|
+
request = Request(method=method, path=validate_path(args.path),
|
|
144
|
+
params=parse_query(args.query), headers=parse_headers(args.header))
|
|
145
|
+
bodies = [name for name, value in (("--data", args.data), ("--raw-data", args.raw_data), ("--form", args.form)) if value]
|
|
146
|
+
if len(bodies) > 1:
|
|
147
|
+
raise invalid(f"Choose one body option; got {' and '.join(bodies)}.")
|
|
148
|
+
# A body never silently changes the method.
|
|
149
|
+
if bodies and method in BODYLESS_METHODS:
|
|
150
|
+
raise invalid(f"{bodies[0]} needs an explicit method that accepts a body, such as -X POST.")
|
|
151
|
+
if args.content_type and not (args.data or args.raw_data):
|
|
152
|
+
raise invalid("--content-type applies only to --data or --raw-data.")
|
|
153
|
+
if args.content_type and any(ch in args.content_type for ch in "\r\n\0"):
|
|
154
|
+
raise invalid("--content-type contains a line break or NUL character.")
|
|
155
|
+
inputs = _Inputs(stdin)
|
|
156
|
+
if args.data:
|
|
157
|
+
body = inputs.read(args.data, "--data", MAX_DATA)
|
|
158
|
+
try:
|
|
159
|
+
json.loads(body)
|
|
160
|
+
except (ValueError, UnicodeDecodeError):
|
|
161
|
+
raise invalid("--data must be valid JSON; use --raw-data with --content-type for other formats.") from None
|
|
162
|
+
# Send the caller's bytes unchanged so no endpoint field is added, reordered or dropped.
|
|
163
|
+
request.body, request.content_type = body, args.content_type or "application/json"
|
|
164
|
+
elif args.raw_data:
|
|
165
|
+
if not args.content_type:
|
|
166
|
+
raise invalid("--raw-data needs --content-type.")
|
|
167
|
+
request.body = inputs.read(args.raw_data, "--raw-data", MAX_DATA)
|
|
168
|
+
request.content_type = args.content_type
|
|
169
|
+
total = 0
|
|
170
|
+
for item in args.form:
|
|
171
|
+
name, sep, value = item.partition("=")
|
|
172
|
+
if not sep or not name:
|
|
173
|
+
raise invalid(f"--form expects NAME=VALUE or NAME=@FILE, got {item!r}.")
|
|
174
|
+
if value.startswith("@"):
|
|
175
|
+
path = Path(value[1:])
|
|
176
|
+
if value == "@-" or not path.is_file():
|
|
177
|
+
raise invalid(f"--form {name}: expected an existing file, got {value!r}.")
|
|
178
|
+
total += path.stat().st_size
|
|
179
|
+
if total > MAX_UPLOAD:
|
|
180
|
+
raise invalid(f"Uploads are limited to {MAX_UPLOAD} bytes per request.")
|
|
181
|
+
request.form.append((name, None, path))
|
|
182
|
+
else:
|
|
183
|
+
request.form.append((name, value, None))
|
|
184
|
+
return request
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def secret_values(email, token) -> list[str]:
|
|
188
|
+
"""The token and every form derived from it that could appear in output."""
|
|
189
|
+
if not token:
|
|
190
|
+
return []
|
|
191
|
+
values = [token, quote(token, safe="")]
|
|
192
|
+
if email:
|
|
193
|
+
basic = base64.b64encode(f"{email}:{token}".encode()).decode()
|
|
194
|
+
values += [basic, basic.rstrip("=")]
|
|
195
|
+
return sorted({v for v in values if v}, key=len, reverse=True)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _redactor(cfg: dict):
|
|
199
|
+
secrets = secret_values(cfg.get("email"), cfg.get("token"))
|
|
200
|
+
|
|
201
|
+
def redact(text: str) -> str:
|
|
202
|
+
for secret in secrets:
|
|
203
|
+
text = text.replace(secret, "[redacted]")
|
|
204
|
+
return text
|
|
205
|
+
return redact
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _read(response, limit: int) -> tuple[bytes, bool]:
|
|
209
|
+
"""Read at most limit bytes; report whether more remained."""
|
|
210
|
+
data = bytearray()
|
|
211
|
+
for chunk in response.iter_content(64 * 1024):
|
|
212
|
+
data += chunk
|
|
213
|
+
if len(data) > limit:
|
|
214
|
+
return bytes(data[:limit]), True
|
|
215
|
+
return bytes(data), False
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _error_body(response, redact):
|
|
219
|
+
raw, _ = _read(response, MAX_ERROR_BODY)
|
|
220
|
+
text = redact(raw.decode(response.encoding or "utf-8", errors="replace"))
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(text)
|
|
223
|
+
except ValueError:
|
|
224
|
+
return text
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_output(destination: str) -> Path:
|
|
228
|
+
output = Path(destination)
|
|
229
|
+
if output.exists():
|
|
230
|
+
raise invalid(f"Destination already exists: {output}")
|
|
231
|
+
if not output.parent.is_dir():
|
|
232
|
+
raise invalid(f"Destination folder does not exist: {output.parent}")
|
|
233
|
+
return output
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _write_file(response, destination: str) -> int:
|
|
237
|
+
# Checked again here in case the destination appeared during the request.
|
|
238
|
+
output = check_output(destination)
|
|
239
|
+
fd, temporary = tempfile.mkstemp(prefix=".jira-api-", dir=output.parent)
|
|
240
|
+
received = 0
|
|
241
|
+
try:
|
|
242
|
+
with os.fdopen(fd, "wb") as stream:
|
|
243
|
+
for chunk in response.iter_content(1024 * 1024):
|
|
244
|
+
received += len(chunk)
|
|
245
|
+
if received > MAX_RESPONSE:
|
|
246
|
+
raise ApiError(1, "response_too_large", f"Response exceeds {MAX_RESPONSE} bytes; nothing was saved.")
|
|
247
|
+
stream.write(chunk)
|
|
248
|
+
# Hard-link creation fails atomically if the destination appeared meanwhile.
|
|
249
|
+
os.link(temporary, output)
|
|
250
|
+
finally:
|
|
251
|
+
os.unlink(temporary)
|
|
252
|
+
return received
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _write_stdout(response, stdout) -> None:
|
|
256
|
+
content_type = response.headers.get("Content-Type", "")
|
|
257
|
+
terminal = getattr(stdout, "isatty", lambda: False)()
|
|
258
|
+
if terminal and content_type and not TEXT_TYPES.match(content_type):
|
|
259
|
+
length = response.headers.get("Content-Length", "unknown")
|
|
260
|
+
print(f"Binary response ({content_type}, {length} bytes) not printed to the terminal; rerun with --output FILE.",
|
|
261
|
+
file=sys.stderr)
|
|
262
|
+
return
|
|
263
|
+
buffer = stdout.buffer if hasattr(stdout, "buffer") else stdout
|
|
264
|
+
if terminal and "json" in content_type.lower():
|
|
265
|
+
# Humans get indented JSON; pipes always get the upstream bytes.
|
|
266
|
+
body, truncated = _read(response, MAX_RESPONSE)
|
|
267
|
+
if truncated:
|
|
268
|
+
raise ApiError(1, "response_too_large", f"Response exceeds {MAX_RESPONSE} bytes.")
|
|
269
|
+
try:
|
|
270
|
+
body = (json.dumps(json.loads(body), indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
271
|
+
except ValueError:
|
|
272
|
+
pass
|
|
273
|
+
buffer.write(body)
|
|
274
|
+
buffer.flush()
|
|
275
|
+
return
|
|
276
|
+
received = 0
|
|
277
|
+
for chunk in response.iter_content(64 * 1024):
|
|
278
|
+
received += len(chunk)
|
|
279
|
+
if received > MAX_RESPONSE:
|
|
280
|
+
raise ApiError(1, "response_too_large", f"Response exceeds {MAX_RESPONSE} bytes; output is incomplete.")
|
|
281
|
+
buffer.write(chunk)
|
|
282
|
+
buffer.flush()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _print_metadata(response, redact) -> None:
|
|
286
|
+
print(f"HTTP {response.status_code} {response.reason or ''}".rstrip(), file=sys.stderr)
|
|
287
|
+
for name, value in response.headers.items():
|
|
288
|
+
# Session cookies from the gateway are credentials too.
|
|
289
|
+
shown = "[redacted]" if name.lower() == "set-cookie" else redact(value)
|
|
290
|
+
print(f"{name}: {shown}", file=sys.stderr)
|
|
291
|
+
print(file=sys.stderr)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def execute(jira: Jira, request: Request, args, cfg: dict, stdout=None) -> None:
|
|
295
|
+
stdout = stdout or sys.stdout
|
|
296
|
+
redact = _redactor(cfg)
|
|
297
|
+
headers = dict(request.headers)
|
|
298
|
+
headers.setdefault("Accept", "application/json")
|
|
299
|
+
kw = dict(params=request.params, headers=headers, stream=True)
|
|
300
|
+
with ExitStack() as stack:
|
|
301
|
+
if request.form:
|
|
302
|
+
# requests writes the multipart boundary itself.
|
|
303
|
+
headers["Content-Type"] = None
|
|
304
|
+
kw["files"] = [(name, (path.name, stack.enter_context(path.open("rb")), "application/octet-stream")) if path
|
|
305
|
+
else (name, (None, value)) for name, value, path in request.form]
|
|
306
|
+
elif request.body is not None:
|
|
307
|
+
headers["Content-Type"] = request.content_type
|
|
308
|
+
kw["data"] = request.body
|
|
309
|
+
else:
|
|
310
|
+
headers["Content-Type"] = None
|
|
311
|
+
response = jira._send(request.method, request.path, safe=request.method in SAFE_METHODS, **kw)
|
|
312
|
+
with response:
|
|
313
|
+
# The prepared URL must still point at the trusted base after encoding.
|
|
314
|
+
sent, base = urlsplit(response.request.url if response.request else jira.site + request.path), urlsplit(jira.site)
|
|
315
|
+
if (sent.scheme, sent.netloc) != (base.scheme, base.netloc):
|
|
316
|
+
raise ApiError(2, "invalid_input", "The request did not resolve to the selected Jira site.")
|
|
317
|
+
if args.include:
|
|
318
|
+
_print_metadata(response, redact)
|
|
319
|
+
status = response.status_code
|
|
320
|
+
if 300 <= status < 400:
|
|
321
|
+
# Signed download URLs carry access tokens in the query, so only the address is shown.
|
|
322
|
+
target = urlsplit(response.headers.get("Location", ""))
|
|
323
|
+
raise ApiError(1, "jira_error", "Jira answered with a redirect, which is not followed so credentials stay "
|
|
324
|
+
"on the selected site. For endpoints that support it, such as attachment content, "
|
|
325
|
+
"pass --query redirect=false.", status=status, method=request.method, path=request.path,
|
|
326
|
+
location=redact(f"{target.scheme}://{target.netloc}{target.path}" if target.netloc else target.path))
|
|
327
|
+
if status >= 400:
|
|
328
|
+
raise ApiError(1, "jira_error", f"{request.method} {request.path} -> {status}", status=status,
|
|
329
|
+
method=request.method, path=request.path, body=_error_body(response, redact))
|
|
330
|
+
if request.method == "HEAD" or status == 204:
|
|
331
|
+
return
|
|
332
|
+
if args.output:
|
|
333
|
+
received = _write_file(response, args.output)
|
|
334
|
+
print(f"Saved {received} bytes to {Path(args.output).absolute()}", file=sys.stderr)
|
|
335
|
+
else:
|
|
336
|
+
_write_stdout(response, stdout)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def run(args, cfg: dict) -> None:
|
|
340
|
+
if args.csv or args.columns:
|
|
341
|
+
raise invalid("--csv and --columns do not apply to api; the response is written as Jira returns it.")
|
|
342
|
+
request = build_request(args)
|
|
343
|
+
if args.output:
|
|
344
|
+
# Fail before sending, so nothing is downloaded or changed for an unusable destination.
|
|
345
|
+
check_output(args.output)
|
|
346
|
+
if not (cfg["site"] and cfg["email"] and cfg["token"]):
|
|
347
|
+
raise invalid("No Jira identity is configured. Run: jira auth login")
|
|
348
|
+
jira = Jira(cfg.get("api_site", cfg["site"]), cfg["email"], cfg["token"])
|
|
349
|
+
try:
|
|
350
|
+
execute(jira, request, args, cfg)
|
|
351
|
+
finally:
|
|
352
|
+
jira.close()
|