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/commands.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Command grammar shared by the new executable and legacy jsup commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CLIParser(argparse.ArgumentParser):
|
|
10
|
+
def parse_args(self, args=None, namespace=None):
|
|
11
|
+
if namespace is None:
|
|
12
|
+
namespace = argparse.Namespace()
|
|
13
|
+
# Seed the root namespace rather than setting defaults on inherited
|
|
14
|
+
# option actions, which argparse shares between parent/child parsers.
|
|
15
|
+
defaults = dict(project=None, site=None, email=None, token=None,
|
|
16
|
+
json=False, no_input=False, profile=None, csv=False, columns=None)
|
|
17
|
+
for name, value in defaults.items():
|
|
18
|
+
if not hasattr(namespace, name):
|
|
19
|
+
setattr(namespace, name, value)
|
|
20
|
+
return super().parse_args(args, namespace)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def positive_int(value: str) -> int:
|
|
24
|
+
number = int(value)
|
|
25
|
+
if number < 1:
|
|
26
|
+
raise argparse.ArgumentTypeError("must be greater than zero")
|
|
27
|
+
return number
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _options(parser: argparse.ArgumentParser, operation: str) -> None:
|
|
31
|
+
"""Define each operation once, including options shared by legacy aliases."""
|
|
32
|
+
lists = {"project-list", "board-list", "sprint-list", "comment-list", "open", "issue-list", "board-issues"}
|
|
33
|
+
if operation in lists:
|
|
34
|
+
parser.add_argument("--all", action="store_true", help="fetch every page; overrides --limit")
|
|
35
|
+
if operation not in {"open", "issue-list", "board-issues"}:
|
|
36
|
+
parser.add_argument("--limit", dest="max", type=positive_int, default=50)
|
|
37
|
+
if operation == "api":
|
|
38
|
+
parser.add_argument("path", metavar="PATH", help="Jira REST path, for example /rest/api/3/myself; or spec")
|
|
39
|
+
parser.add_argument("spec_action", metavar="ACTION", nargs="?", help="after spec: refresh or status")
|
|
40
|
+
parser.add_argument("-X", "--method", type=str.upper, default="GET",
|
|
41
|
+
choices=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
|
|
42
|
+
parser.add_argument("--query", "-q", action="append", default=[], metavar="KEY=VALUE",
|
|
43
|
+
help="query parameter; repeat for more, values are URL-encoded")
|
|
44
|
+
parser.add_argument("--header", "-H", action="append", default=[], metavar="'NAME: VALUE'",
|
|
45
|
+
help="extra request header; auth, host and framing headers are managed")
|
|
46
|
+
parser.add_argument("--data", "-d", metavar="JSON|@FILE|@-", help="JSON body, sent unchanged")
|
|
47
|
+
parser.add_argument("--raw-data", metavar="TEXT|@FILE|@-", help="non-JSON body; needs --content-type")
|
|
48
|
+
parser.add_argument("--content-type", help="body media type for --data or --raw-data")
|
|
49
|
+
parser.add_argument("--form", "-F", action="append", default=[], metavar="NAME=VALUE|NAME=@FILE",
|
|
50
|
+
help="multipart field or file; repeat for more")
|
|
51
|
+
parser.add_argument("--include", "-i", action="store_true", help="print response status and headers to stderr")
|
|
52
|
+
parser.add_argument("--output", "-o", metavar="FILE", help="save the response body to a new file")
|
|
53
|
+
parser.add_argument("--spec", action="store_true",
|
|
54
|
+
help="describe the operation from cached official specs; sends no request")
|
|
55
|
+
return
|
|
56
|
+
if operation == "issue-edit":
|
|
57
|
+
parser.add_argument("key")
|
|
58
|
+
for name in ("summary", "priority", "due-date", "parent"):
|
|
59
|
+
parser.add_argument("--" + name)
|
|
60
|
+
parser.add_argument("--description", "--desc", dest="desc")
|
|
61
|
+
parser.add_argument("--description-file", dest="desc_file")
|
|
62
|
+
parser.add_argument("--editor", action="store_true")
|
|
63
|
+
parser.add_argument("--field", action="append", default=[])
|
|
64
|
+
parser.add_argument("--fields-file")
|
|
65
|
+
for collection in ("label", "component"):
|
|
66
|
+
for prefix in ("", "add-", "remove-"):
|
|
67
|
+
parser.add_argument("--" + prefix + collection, action="append", default=[])
|
|
68
|
+
return
|
|
69
|
+
if operation in {"issue-assign", "issue-unassign", "issue-link", "issue-unlink", "attachment-list", "attachment-upload", "attachment-download", "attachment-delete", "comment-edit", "comment-delete"}:
|
|
70
|
+
if operation in {"issue-unlink", "attachment-download", "attachment-delete"}:
|
|
71
|
+
parser.add_argument("id", type=positive_int)
|
|
72
|
+
else:
|
|
73
|
+
parser.add_argument("key")
|
|
74
|
+
if operation == "issue-assign":
|
|
75
|
+
parser.add_argument("--user", required=True, help="me, id:<account ID>, or unambiguous display name/email")
|
|
76
|
+
if operation == "issue-link":
|
|
77
|
+
parser.add_argument("other")
|
|
78
|
+
parser.add_argument("--type", required=True)
|
|
79
|
+
parser.add_argument("--direction", choices=["inward", "outward"], required=True)
|
|
80
|
+
if operation == "attachment-upload":
|
|
81
|
+
parser.add_argument("files", nargs="+")
|
|
82
|
+
if operation == "attachment-download":
|
|
83
|
+
parser.add_argument("--output", required=True, help="new file path or existing directory")
|
|
84
|
+
if operation in {"comment-edit", "comment-delete"}:
|
|
85
|
+
parser.add_argument("comment_id", type=positive_int)
|
|
86
|
+
if operation == "comment-edit":
|
|
87
|
+
parser.add_argument("--message", "-m")
|
|
88
|
+
parser.add_argument("--message-file")
|
|
89
|
+
parser.add_argument("--editor", action="store_true")
|
|
90
|
+
if operation in {"issue-unlink", "attachment-delete", "comment-delete"}:
|
|
91
|
+
parser.add_argument("--yes", action="store_true")
|
|
92
|
+
return
|
|
93
|
+
if operation == "dashboard":
|
|
94
|
+
parser.add_argument("--projects", nargs="+", help="project keys to summarize")
|
|
95
|
+
elif operation in {"project-show", "project-types", "project-fields", "project-statuses"}:
|
|
96
|
+
parser.add_argument("key", nargs="?")
|
|
97
|
+
if operation in {"project-types", "project-fields"}:
|
|
98
|
+
parser.add_argument("--refresh", action="store_true")
|
|
99
|
+
if operation == "project-fields":
|
|
100
|
+
parser.add_argument("--type", required=True)
|
|
101
|
+
elif operation == "issue-create":
|
|
102
|
+
legacy = parser._defaults.get("legacy", False)
|
|
103
|
+
parser.add_argument("--summary", "-s", required=legacy)
|
|
104
|
+
parser.add_argument("--description", "--desc", "-d", dest="desc", default="" if legacy else None)
|
|
105
|
+
parser.add_argument("--description-file", "--desc-file", dest="desc_file")
|
|
106
|
+
parser.add_argument("--type", "-t", default="Task" if legacy else None, help="issue type name or id:<ID>; guided selection when omitted")
|
|
107
|
+
parser.add_argument("--priority")
|
|
108
|
+
parser.add_argument("--assignee", help="Jira account ID")
|
|
109
|
+
parser.add_argument("--label", action="append", default=[])
|
|
110
|
+
parser.add_argument("--component", action="append", default=[])
|
|
111
|
+
if not legacy:
|
|
112
|
+
parser.add_argument("--parent")
|
|
113
|
+
parser.add_argument("--field", action="append", default=[])
|
|
114
|
+
parser.add_argument("--fields-file")
|
|
115
|
+
parser.add_argument("--editor", action="store_true")
|
|
116
|
+
parser.add_argument("--refresh", action="store_true")
|
|
117
|
+
parser.add_argument("--template", help="built-in/local template name or JSON path")
|
|
118
|
+
parser.add_argument("--var", action="append", default=[], help="template variable NAME=VALUE")
|
|
119
|
+
elif operation == "intake":
|
|
120
|
+
parser.add_argument("--name", help="client name")
|
|
121
|
+
parser.add_argument("--issue", help="issue as reported")
|
|
122
|
+
parser.add_argument("--callback", default="not specified")
|
|
123
|
+
parser.add_argument("--type", default="Task")
|
|
124
|
+
parser.add_argument("--priority", default="High")
|
|
125
|
+
elif operation in ("open", "issue-list"):
|
|
126
|
+
parser.add_argument("--jql", default="", help=(
|
|
127
|
+
"extra JQL appended to the open-ticket filter" if operation == "open"
|
|
128
|
+
else "complete JQL query; cannot be combined with --open or --project"))
|
|
129
|
+
parser.add_argument("--limit", "--max", dest="max", type=positive_int, default=50)
|
|
130
|
+
if operation == "issue-list":
|
|
131
|
+
parser.add_argument("--open", action="store_true", help="exclude the Done status category")
|
|
132
|
+
parser.add_argument("--assignee", help="account ID or me")
|
|
133
|
+
parser.add_argument("--status", action="append", default=[])
|
|
134
|
+
parser.add_argument("--type")
|
|
135
|
+
parser.add_argument("--label", action="append", default=[])
|
|
136
|
+
parser.add_argument("--board", type=positive_int)
|
|
137
|
+
parser.add_argument("--order-by", choices=["updated", "created", "priority", "key"], default="updated")
|
|
138
|
+
parser.add_argument("--order", choices=["asc", "desc"], default="desc")
|
|
139
|
+
parser.add_argument("--fields", help="comma-separated Jira field IDs")
|
|
140
|
+
elif operation in ("issue-show", "issue-move", "transitions", "issue-delete", "comment-add", "comment-list"):
|
|
141
|
+
parser.add_argument("key")
|
|
142
|
+
if operation == "issue-show":
|
|
143
|
+
parser.add_argument("--web", action="store_true")
|
|
144
|
+
elif operation == "issue-move":
|
|
145
|
+
parser.add_argument("--to", help="transition name or ID")
|
|
146
|
+
if not parser._defaults.get("legacy", False):
|
|
147
|
+
parser.add_argument("--field", action="append", default=[])
|
|
148
|
+
parser.add_argument("--fields-file")
|
|
149
|
+
elif operation == "issue-delete":
|
|
150
|
+
parser.add_argument("--yes", action="store_true", help="skip confirmation")
|
|
151
|
+
elif operation == "comment-add":
|
|
152
|
+
parser.add_argument("--message", "-m", required=parser._defaults.get("legacy", False))
|
|
153
|
+
parser.add_argument("--message-file")
|
|
154
|
+
parser.add_argument("--editor", action="store_true")
|
|
155
|
+
elif operation == "board-list":
|
|
156
|
+
parser.add_argument("--jql-project", help="legacy project-filter override")
|
|
157
|
+
elif operation == "board-create":
|
|
158
|
+
parser.add_argument("--name", required=True)
|
|
159
|
+
parser.add_argument("--jql")
|
|
160
|
+
parser.add_argument("--filter-id", type=positive_int)
|
|
161
|
+
parser.add_argument("--type", default="scrum", choices=["scrum", "kanban"])
|
|
162
|
+
elif operation in ("board-show", "board-issues", "board-feature"):
|
|
163
|
+
parser.add_argument("board_id", type=positive_int)
|
|
164
|
+
if operation == "board-show":
|
|
165
|
+
parser.add_argument("--web", action="store_true")
|
|
166
|
+
elif operation == "board-issues":
|
|
167
|
+
parser.add_argument("--jql", default="")
|
|
168
|
+
parser.add_argument("--limit", "--max", dest="max", type=positive_int, default=50)
|
|
169
|
+
else:
|
|
170
|
+
parser.add_argument("--feature", required=True)
|
|
171
|
+
if "enable" not in parser._defaults:
|
|
172
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
173
|
+
group.add_argument("--enable", action="store_true")
|
|
174
|
+
group.add_argument("--disable", action="store_true")
|
|
175
|
+
elif operation in ("sprint-list", "sprint-create"):
|
|
176
|
+
parser.add_argument("--board", type=positive_int, required=parser._defaults.get("legacy", False))
|
|
177
|
+
if operation == "sprint-list":
|
|
178
|
+
parser.add_argument("--state", default="active,future")
|
|
179
|
+
else:
|
|
180
|
+
parser.add_argument("--name", required=True)
|
|
181
|
+
parser.add_argument("--goal", default="")
|
|
182
|
+
elif operation in ("sprint-state", "sprint-add"):
|
|
183
|
+
parser.add_argument("sprint_id", type=positive_int)
|
|
184
|
+
if operation == "sprint-add":
|
|
185
|
+
parser.add_argument("keys", nargs="+")
|
|
186
|
+
elif "state" not in parser._defaults:
|
|
187
|
+
parser.add_argument("state", choices=["active", "closed", "future"])
|
|
188
|
+
elif operation == "component-create":
|
|
189
|
+
parser.add_argument("--name", required=True)
|
|
190
|
+
parser.add_argument("--description", "--desc", dest="desc", default="")
|
|
191
|
+
elif operation == "browse":
|
|
192
|
+
parser.add_argument("target", help="issue key or board:<id>")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
LEGACY_COMMANDS = {
|
|
196
|
+
"me": "show authenticated user",
|
|
197
|
+
"project-list": "list projects",
|
|
198
|
+
"project-show": "show project details",
|
|
199
|
+
"issue-create": "create an issue",
|
|
200
|
+
"intake": "explicit legacy callback/support wizard",
|
|
201
|
+
"open": "list unfinished issues",
|
|
202
|
+
"issue-show": "show an issue",
|
|
203
|
+
"issue-move": "transition an issue",
|
|
204
|
+
"transitions": "list available transitions",
|
|
205
|
+
"issue-delete": "delete an issue",
|
|
206
|
+
"comment-add": "add a comment",
|
|
207
|
+
"comment-list": "list comments",
|
|
208
|
+
"board-list": "list boards",
|
|
209
|
+
"board-create": "create a board",
|
|
210
|
+
"board-issues": "list board issues",
|
|
211
|
+
"board-feature": "toggle a board feature",
|
|
212
|
+
"sprint-list": "list sprints",
|
|
213
|
+
"sprint-create": "create a sprint",
|
|
214
|
+
"sprint-state": "change sprint state",
|
|
215
|
+
"sprint-add": "add issues to a sprint",
|
|
216
|
+
"component-list": "list components",
|
|
217
|
+
"component-create": "create a component",
|
|
218
|
+
"browse": "open an issue or board in a browser",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def build_parser(prog: str = "jsup") -> argparse.ArgumentParser:
|
|
223
|
+
# Suppressed defaults on inherited options prevent a child parser from
|
|
224
|
+
# overwriting flags supplied earlier in the command line.
|
|
225
|
+
common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
|
|
226
|
+
common.add_argument("--site")
|
|
227
|
+
common.add_argument("--email")
|
|
228
|
+
common.add_argument("--token")
|
|
229
|
+
common.add_argument("--project", "-p", help="project key")
|
|
230
|
+
common.add_argument("--profile", help="named identity; never mixed with identity flags")
|
|
231
|
+
output = common.add_mutually_exclusive_group()
|
|
232
|
+
output.add_argument("--json", action="store_true", help="JSON output")
|
|
233
|
+
output.add_argument("--csv", action="store_true", help="CSV output for lists")
|
|
234
|
+
common.add_argument("--columns", help="comma-separated list columns")
|
|
235
|
+
common.add_argument("--no-input", action="store_true", help="never prompt")
|
|
236
|
+
parser = CLIParser(
|
|
237
|
+
prog=prog, parents=[common], description="Jira CLI Toolkit — Jira Cloud from your terminal",
|
|
238
|
+
epilog=f"Examples: {prog} issue list -p ENG --open; {prog} help issue create",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument("--version", action="version", version=f"{prog} {__version__}")
|
|
241
|
+
parser.set_defaults(cmd=None, legacy=False, selected_parser=parser)
|
|
242
|
+
root = parser.add_subparsers(dest="command", title="commands")
|
|
243
|
+
|
|
244
|
+
def group(subparsers, name, help_text):
|
|
245
|
+
child = subparsers.add_parser(name, parents=[common], help=help_text)
|
|
246
|
+
child.set_defaults(selected_parser=child)
|
|
247
|
+
return child, child.add_subparsers(dest=f"{name}_action")
|
|
248
|
+
|
|
249
|
+
def command(subparsers, name, operation, help_text, **defaults):
|
|
250
|
+
child = subparsers.add_parser(name, parents=[common], help=help_text)
|
|
251
|
+
child.set_defaults(cmd=operation, selected_parser=child, **defaults)
|
|
252
|
+
_options(child, operation)
|
|
253
|
+
return child
|
|
254
|
+
|
|
255
|
+
helper = root.add_parser("help", help="show help for any command")
|
|
256
|
+
helper.add_argument("path", nargs="*", help="for example: issue comment add")
|
|
257
|
+
helper.set_defaults(cmd="help")
|
|
258
|
+
command(root, "dashboard", "dashboard", "summarize selected projects")
|
|
259
|
+
completion = command(root, "completion", "completion", "generate local shell completion")
|
|
260
|
+
completion.add_argument("shell", choices=["bash", "zsh", "fish"])
|
|
261
|
+
_, templates = group(root, "template", "inspect and validate reusable JSON templates")
|
|
262
|
+
command(templates, "list", "template-list", "list built-in and local templates")
|
|
263
|
+
for action in ("show", "validate"):
|
|
264
|
+
item = command(templates, action, "template-" + action, action + " a declarative template")
|
|
265
|
+
item.add_argument("template")
|
|
266
|
+
if action == "validate":
|
|
267
|
+
item.add_argument("--type")
|
|
268
|
+
item.add_argument("--var", action="append", default=[])
|
|
269
|
+
item.add_argument("--refresh", action="store_true")
|
|
270
|
+
updater = command(root, "update", "update", "check GitHub Releases or explicitly upgrade")
|
|
271
|
+
updater.add_argument("--check", action="store_true")
|
|
272
|
+
updater.add_argument("--info", action="store_true", help="local installation details, without network")
|
|
273
|
+
updater.add_argument("--version", help="requested release version")
|
|
274
|
+
updater.add_argument("--prerelease", action="store_true")
|
|
275
|
+
updater.add_argument("--allow-downgrade", action="store_true")
|
|
276
|
+
updater.add_argument("--yes", action="store_true")
|
|
277
|
+
_, config = group(root, "config", "set up and inspect local configuration")
|
|
278
|
+
command(config, "init", "config-init", "legacy credential setup")
|
|
279
|
+
command(config, "show", "config-show", "show saved configuration with token masked")
|
|
280
|
+
migrate = command(config, "migrate", "config-migrate", "migrate legacy credentials to a named profile")
|
|
281
|
+
migrate.add_argument("--storage", choices=["keyring", "file"], default="keyring")
|
|
282
|
+
for action in ("get", "set"):
|
|
283
|
+
pref = command(config, action, "config-"+action, "inspect or change a nonsecret preference")
|
|
284
|
+
pref.add_argument("key", choices=["project", "board"])
|
|
285
|
+
if action == "set":
|
|
286
|
+
pref.add_argument("value")
|
|
287
|
+
_, auth = group(root, "auth", "guided API-token login and credential health")
|
|
288
|
+
login = command(auth, "login", "auth-login", "validate and store an API token")
|
|
289
|
+
login.add_argument("--storage", choices=["keyring", "file"], default="keyring")
|
|
290
|
+
login.add_argument("--token-stdin", action="store_true")
|
|
291
|
+
login.add_argument("--scoped", action="store_true", help="token uses API scopes and the Atlassian gateway")
|
|
292
|
+
login.add_argument("--cloud-id", help="cloud ID for a scoped token")
|
|
293
|
+
command(auth, "status", "auth-status", "validate the selected identity with Jira")
|
|
294
|
+
command(auth, "logout", "auth-logout", "delete selected local credential; upstream tokens are not revoked")
|
|
295
|
+
_, profiles = group(root, "profile", "named site/account identities")
|
|
296
|
+
command(profiles, "list", "profile-list", "list saved profiles without reading secrets")
|
|
297
|
+
for action in ("use", "remove"):
|
|
298
|
+
item = command(profiles, action, "profile-"+action, "select or remove a profile")
|
|
299
|
+
item.add_argument("name")
|
|
300
|
+
command(root, "doctor", "doctor", "redacted connectivity, identity and project diagnostics")
|
|
301
|
+
_, context = group(root, "context", "inspect resolved local context")
|
|
302
|
+
command(context, "show", "context-show", "show site and project without contacting Jira")
|
|
303
|
+
use = command(context, "use", "context-use", "select a profile/project/board after checking access")
|
|
304
|
+
use.add_argument("--board", type=positive_int)
|
|
305
|
+
use.add_argument("--select", action="store_true", help="choose accessible projects and boards interactively")
|
|
306
|
+
_, user = group(root, "user", "user identity")
|
|
307
|
+
command(user, "me", "me", "show authenticated user")
|
|
308
|
+
_, projects = group(root, "project", "projects")
|
|
309
|
+
command(projects, "list", "project-list", "list accessible projects")
|
|
310
|
+
command(projects, "view", "project-show", "view a project")
|
|
311
|
+
command(projects, "issue-types", "project-types", "discover available issue types")
|
|
312
|
+
command(projects, "fields", "project-fields", "discover creation fields and required values")
|
|
313
|
+
command(projects, "statuses", "project-statuses", "show workflow statuses by issue type")
|
|
314
|
+
_, issues = group(root, "issue", "issue operations")
|
|
315
|
+
command(issues, "list", "issue-list", "search issues, optionally excluding Done")
|
|
316
|
+
command(issues, "create", "issue-create", "create an issue")
|
|
317
|
+
command(issues, "view", "issue-show", "view an issue or open it with --web")
|
|
318
|
+
command(issues, "transition", "issue-move", "choose or apply an available transition")
|
|
319
|
+
command(issues, "transitions", "transitions", "list available transitions")
|
|
320
|
+
command(issues, "delete", "issue-delete", "delete an issue after confirmation")
|
|
321
|
+
command(issues, "edit", "issue-edit", "update editable fields and collection values")
|
|
322
|
+
command(issues, "assign", "issue-assign", "assign an issue")
|
|
323
|
+
command(issues, "unassign", "issue-unassign", "clear the assignee")
|
|
324
|
+
command(issues, "link", "issue-link", "link two issues with explicit direction")
|
|
325
|
+
command(issues, "unlink", "issue-unlink", "delete a link after confirmation")
|
|
326
|
+
_, comments = group(issues, "comment", "issue comments")
|
|
327
|
+
command(comments, "add", "comment-add", "add a comment")
|
|
328
|
+
command(comments, "list", "comment-list", "list comments")
|
|
329
|
+
command(comments, "edit", "comment-edit", "edit a comment")
|
|
330
|
+
command(comments, "delete", "comment-delete", "delete a comment after confirmation")
|
|
331
|
+
_, attachments = group(issues, "attachment", "issue attachments")
|
|
332
|
+
for action in ("list", "upload", "download", "delete"):
|
|
333
|
+
command(attachments, action, "attachment-" + action, action + " attachments")
|
|
334
|
+
_, boards = group(root, "board", "board operations")
|
|
335
|
+
command(boards, "list", "board-list", "list boards")
|
|
336
|
+
command(boards, "create", "board-create", "create a filter and board")
|
|
337
|
+
command(boards, "view", "board-show", "view a board or open it with --web")
|
|
338
|
+
command(boards, "issues", "board-issues", "list issues on a board")
|
|
339
|
+
_, features = group(boards, "feature", "toggle board features")
|
|
340
|
+
command(features, "enable", "board-feature", "enable a feature", enable=True)
|
|
341
|
+
command(features, "disable", "board-feature", "disable a feature", enable=False)
|
|
342
|
+
_, sprints = group(root, "sprint", "sprint operations")
|
|
343
|
+
command(sprints, "list", "sprint-list", "list board sprints")
|
|
344
|
+
command(sprints, "create", "sprint-create", "create a sprint")
|
|
345
|
+
command(sprints, "start", "sprint-state", "start a sprint", state="active")
|
|
346
|
+
command(sprints, "close", "sprint-state", "close a sprint", state="closed")
|
|
347
|
+
edit = command(sprints, "edit", "sprint-state", "change sprint state", state=None)
|
|
348
|
+
edit.add_argument("--state", required=True, choices=["active", "closed", "future"])
|
|
349
|
+
command(sprints, "add-issues", "sprint-add", "add issues to a sprint")
|
|
350
|
+
_, components = group(root, "component", "project components")
|
|
351
|
+
command(components, "list", "component-list", "list components")
|
|
352
|
+
command(components, "create", "component-create", "create a component")
|
|
353
|
+
command(root, "api", "api", "call any Jira REST path with the selected identity")
|
|
354
|
+
|
|
355
|
+
for name, help_text in LEGACY_COMMANDS.items():
|
|
356
|
+
command(root, name, name, f"legacy: {help_text}", legacy=True)
|
|
357
|
+
return parser
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def help_parser(parser: argparse.ArgumentParser, path: list[str]) -> argparse.ArgumentParser:
|
|
361
|
+
"""Resolve help paths through actual parser choices, including legacy names."""
|
|
362
|
+
current = parser
|
|
363
|
+
for part in path:
|
|
364
|
+
subparsers = next((action for action in current._actions
|
|
365
|
+
if isinstance(action, argparse._SubParsersAction)), None)
|
|
366
|
+
if subparsers is None or part not in subparsers.choices:
|
|
367
|
+
parser.error(f"unknown help command: {' '.join(path)}")
|
|
368
|
+
current = subparsers.choices[part]
|
|
369
|
+
return current
|
jsup/completion.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shell completions from the actual parser and local profile preferences only."""
|
|
2
|
+
import argparse
|
|
3
|
+
import shlex
|
|
4
|
+
from .commands import build_parser
|
|
5
|
+
from . import config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def candidates(words,prog='jira'):
|
|
9
|
+
prefix=words[-1] if words else ''
|
|
10
|
+
previous=words[:-1]
|
|
11
|
+
parser=build_parser(prog)
|
|
12
|
+
awaiting=None
|
|
13
|
+
for word in previous:
|
|
14
|
+
if awaiting:
|
|
15
|
+
awaiting=None; continue
|
|
16
|
+
action=next((a for a in parser._actions if word in a.option_strings),None)
|
|
17
|
+
if action:
|
|
18
|
+
if action.nargs!=0: awaiting=word
|
|
19
|
+
continue
|
|
20
|
+
sub=next((a for a in parser._actions if isinstance(a,argparse._SubParsersAction)),None)
|
|
21
|
+
if sub and word in sub.choices: parser=sub.choices[word]
|
|
22
|
+
if awaiting:
|
|
23
|
+
if awaiting in ('--profile','--project','-p'):
|
|
24
|
+
try: data=config._read_file()
|
|
25
|
+
except (ValueError,OSError): return []
|
|
26
|
+
profiles=data.get('profiles',{})
|
|
27
|
+
values=profiles.keys() if awaiting=='--profile' else [p.get('project','') for p in profiles.values()]
|
|
28
|
+
return sorted(set(v for v in values if v and v.startswith(prefix)))
|
|
29
|
+
return []
|
|
30
|
+
options=[flag for action in parser._actions for flag in action.option_strings]
|
|
31
|
+
sub=next((a for a in parser._actions if isinstance(a,argparse._SubParsersAction)),None)
|
|
32
|
+
values=options+(list(sub.choices) if sub else [])
|
|
33
|
+
return sorted(v for v in values if v.startswith(prefix))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def script(shell,prog):
|
|
37
|
+
command=shlex.quote(prog)
|
|
38
|
+
if shell=='bash':
|
|
39
|
+
return f'''_jira_cli_toolkit_complete() {{
|
|
40
|
+
local line
|
|
41
|
+
COMPREPLY=()
|
|
42
|
+
while IFS= read -r line; do COMPREPLY+=("$line"); done < <({command} --_complete "${{COMP_WORDS[@]:1}}")
|
|
43
|
+
}}
|
|
44
|
+
complete -F _jira_cli_toolkit_complete {command}
|
|
45
|
+
'''
|
|
46
|
+
if shell=='zsh':
|
|
47
|
+
return f'''#compdef {prog}
|
|
48
|
+
_jira_cli_toolkit_complete() {{
|
|
49
|
+
local -a candidates
|
|
50
|
+
candidates=("${{(@f)$({command} --_complete "${{words[@]:1}}")}}")
|
|
51
|
+
compadd -- "${{candidates[@]}}"
|
|
52
|
+
}}
|
|
53
|
+
compdef _jira_cli_toolkit_complete {command}
|
|
54
|
+
'''
|
|
55
|
+
return f'''complete -c {command} -f -a '({command} --_complete (commandline -opc)[2..-1] (commandline -ct))'
|
|
56
|
+
'''
|
jsup/config.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Config: CLI flags > env vars > ~/.config/jsup/config.json (mode 600)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
CONFIG_PATH = Path.home() / ".config" / "jsup" / "config.json"
|
|
12
|
+
KEYS = ("JIRA_SITE", "JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_PROJECT")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_file() -> dict:
|
|
16
|
+
try:
|
|
17
|
+
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
18
|
+
if not isinstance(data, dict):
|
|
19
|
+
raise ValueError("Configuration must be a JSON object.")
|
|
20
|
+
return data
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
return {}
|
|
23
|
+
except json.JSONDecodeError:
|
|
24
|
+
raise ValueError(f"Invalid configuration JSON in {CONFIG_PATH}; repair the file before continuing.") from None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def atomic_json(path, data):
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
29
|
+
fd, temp = tempfile.mkstemp(prefix=".jira-", dir=path.parent)
|
|
30
|
+
try:
|
|
31
|
+
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
32
|
+
json.dump(data, stream, indent=2)
|
|
33
|
+
stream.write("\n")
|
|
34
|
+
stream.flush()
|
|
35
|
+
os.fsync(stream.fileno())
|
|
36
|
+
os.chmod(temp, 0o600)
|
|
37
|
+
os.replace(temp, path)
|
|
38
|
+
finally:
|
|
39
|
+
if os.path.exists(temp):
|
|
40
|
+
os.unlink(temp)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_site(site):
|
|
44
|
+
url = urlsplit(site)
|
|
45
|
+
if url.scheme != "https" or not url.hostname or url.username or url.password or url.query or url.fragment or url.path not in ("", "/"):
|
|
46
|
+
raise ValueError("Use an HTTPS Jira site URL without credentials, a path, query, or fragment.")
|
|
47
|
+
return site.rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_config(args, *, read_secret=True) -> dict:
|
|
51
|
+
"""Resolve existing settings without requiring authentication or printing secrets."""
|
|
52
|
+
file_cfg = _read_file()
|
|
53
|
+
if file_cfg.get("schema_version") == 2:
|
|
54
|
+
from . import credentials
|
|
55
|
+
chosen = getattr(args, "profile", None) or os.getenv("JIRA_PROFILE")
|
|
56
|
+
explicit = [getattr(args, key, None) for key in ("site", "email", "token")]
|
|
57
|
+
env = [os.getenv(key) for key in ("JIRA_SITE", "JIRA_EMAIL", "JIRA_API_TOKEN")]
|
|
58
|
+
if chosen and any(explicit):
|
|
59
|
+
raise ValueError("--profile/JIRA_PROFILE cannot be combined with identity flags; select one identity source.")
|
|
60
|
+
source, values = None, None
|
|
61
|
+
if any(explicit):
|
|
62
|
+
if not all(explicit):
|
|
63
|
+
raise ValueError("Identity flags require --site, --email and --token together; saved credentials will not be mixed.")
|
|
64
|
+
source, values = "flags", explicit
|
|
65
|
+
elif not chosen and any(env):
|
|
66
|
+
if not all(env):
|
|
67
|
+
raise ValueError("Environment identity requires JIRA_SITE, JIRA_EMAIL and JIRA_API_TOKEN together.")
|
|
68
|
+
source, values = "environment", env
|
|
69
|
+
if values:
|
|
70
|
+
return dict(site=validate_site(values[0]), email=values[1], token=values[2],
|
|
71
|
+
project=getattr(args, "project", None) or os.getenv("JIRA_PROJECT", ""),
|
|
72
|
+
source=source, profile=None)
|
|
73
|
+
chosen = chosen or file_cfg.get("active_profile")
|
|
74
|
+
settings = file_cfg.get("profiles", {}).get(chosen)
|
|
75
|
+
if not settings:
|
|
76
|
+
if chosen:
|
|
77
|
+
raise ValueError(f"Unknown profile: {chosen}. Run profile list or auth login.")
|
|
78
|
+
return dict(site="", email="", token="", project="", source="none", profile=None)
|
|
79
|
+
site = validate_site(settings["site"])
|
|
80
|
+
allow_ui = sys.stdin.isatty() and not getattr(args, "no_input", False) and not getattr(args, "json", False)
|
|
81
|
+
result = dict(site=site, email=settings["email"], token=credentials.get(chosen, settings, CONFIG_PATH, allow_ui=allow_ui) if read_secret else "",
|
|
82
|
+
project=getattr(args, "project", None) or os.getenv("JIRA_PROJECT") or settings.get("project", ""),
|
|
83
|
+
board=settings.get("board"), profile=chosen, source=settings["storage"])
|
|
84
|
+
if settings.get("cloud_id"):
|
|
85
|
+
result["api_site"] = "https://api.atlassian.com/ex/jira/" + settings["cloud_id"]
|
|
86
|
+
return result
|
|
87
|
+
if getattr(args, "profile", None) or os.getenv("JIRA_PROFILE"):
|
|
88
|
+
raise ValueError("No named profiles exist yet. Run auth login or config migrate.")
|
|
89
|
+
cfg = {
|
|
90
|
+
"site": (getattr(args, "site", None) or os.getenv("JIRA_SITE")
|
|
91
|
+
or file_cfg.get("JIRA_SITE", "")).rstrip("/"),
|
|
92
|
+
"email": (getattr(args, "email", None) or os.getenv("JIRA_EMAIL")
|
|
93
|
+
or file_cfg.get("JIRA_EMAIL", "")),
|
|
94
|
+
"token": (getattr(args, "token", None) or os.getenv("JIRA_API_TOKEN")
|
|
95
|
+
or file_cfg.get("JIRA_API_TOKEN", "")),
|
|
96
|
+
"project": (getattr(args, "project", None) or os.getenv("JIRA_PROJECT")
|
|
97
|
+
or file_cfg.get("JIRA_PROJECT", "")),
|
|
98
|
+
}
|
|
99
|
+
cfg["source"] = ("flags" if any(getattr(args, key, None) for key in ("site", "email", "token"))
|
|
100
|
+
else "environment" if any(os.getenv(k) for k in KEYS[:3]) else "legacy-config")
|
|
101
|
+
cfg["profile"] = None
|
|
102
|
+
return cfg
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_config(args) -> dict:
|
|
106
|
+
cfg = resolve_config(args)
|
|
107
|
+
if not (cfg["site"] and cfg["email"] and cfg["token"]):
|
|
108
|
+
print("Not configured yet. Run: jsup config init", file=sys.stderr)
|
|
109
|
+
sys.exit(2)
|
|
110
|
+
return cfg
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def init_config() -> None:
|
|
114
|
+
import getpass
|
|
115
|
+
cur = _read_file()
|
|
116
|
+
if cur.get("schema_version") == 2:
|
|
117
|
+
raise ValueError("Named profiles are configured. Use auth login instead of legacy config init.")
|
|
118
|
+
site = input(f"Jira site [{cur.get('JIRA_SITE') or os.getenv('JIRA_SITE', '')}]: ").strip() \
|
|
119
|
+
or cur.get("JIRA_SITE") or os.getenv("JIRA_SITE", "")
|
|
120
|
+
email = input(f"Email [{cur.get('JIRA_EMAIL') or os.getenv('JIRA_EMAIL', '')}]: ").strip() \
|
|
121
|
+
or cur.get("JIRA_EMAIL") or os.getenv("JIRA_EMAIL", "")
|
|
122
|
+
default_token = "*****" if (cur.get("JIRA_API_TOKEN") or os.getenv("JIRA_API_TOKEN")) else ""
|
|
123
|
+
token_in = getpass.getpass(f"API token [{default_token}]: ").strip()
|
|
124
|
+
token = token_in or cur.get("JIRA_API_TOKEN") or os.getenv("JIRA_API_TOKEN", "")
|
|
125
|
+
project = input(f"Default project [{cur.get('JIRA_PROJECT') or os.getenv('JIRA_PROJECT', '')}]: ").strip() \
|
|
126
|
+
or cur.get("JIRA_PROJECT") or os.getenv("JIRA_PROJECT", "")
|
|
127
|
+
if not (site and email and token):
|
|
128
|
+
print("site, email and token are required.", file=sys.stderr)
|
|
129
|
+
sys.exit(2)
|
|
130
|
+
atomic_json(CONFIG_PATH, {
|
|
131
|
+
"JIRA_SITE": site.rstrip("/"), "JIRA_EMAIL": email,
|
|
132
|
+
"JIRA_API_TOKEN": token, "JIRA_PROJECT": project,
|
|
133
|
+
})
|
|
134
|
+
print(f"Saved to {CONFIG_PATH} (mode 600). Try: jsup me")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def show_config(as_json: bool = False) -> None:
|
|
138
|
+
cfg = _read_file()
|
|
139
|
+
if cfg.get("schema_version") == 2:
|
|
140
|
+
safe = {"file": str(CONFIG_PATH), "schema_version": 2,
|
|
141
|
+
"active_profile": cfg.get("active_profile"),
|
|
142
|
+
"profiles": {name: {k: v for k, v in settings.items() if k != "credential_id"}
|
|
143
|
+
for name, settings in cfg.get("profiles", {}).items()}}
|
|
144
|
+
print(json.dumps(safe, indent=2))
|
|
145
|
+
return
|
|
146
|
+
if as_json:
|
|
147
|
+
print(json.dumps({"file": str(CONFIG_PATH), "site": cfg.get("JIRA_SITE") or None,
|
|
148
|
+
"email": cfg.get("JIRA_EMAIL") or None,
|
|
149
|
+
"project": cfg.get("JIRA_PROJECT") or None,
|
|
150
|
+
"token_configured": bool(cfg.get("JIRA_API_TOKEN"))}, indent=2))
|
|
151
|
+
return
|
|
152
|
+
if not cfg:
|
|
153
|
+
print("No config file. Run: jsup config init")
|
|
154
|
+
return
|
|
155
|
+
tok = cfg.get("JIRA_API_TOKEN", "")
|
|
156
|
+
print(f"file: {CONFIG_PATH}")
|
|
157
|
+
print(f"site: {cfg.get('JIRA_SITE', '')}")
|
|
158
|
+
print(f"email: {cfg.get('JIRA_EMAIL', '')}")
|
|
159
|
+
print(f"token: {'*' * 8 + tok[-4:] if len(tok) > 4 else '(missing)'}")
|
|
160
|
+
print(f"project: {cfg.get('JIRA_PROJECT', '')}")
|