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/auth.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Guided personal API-token setup and profile operations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import getpass
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
|
|
14
|
+
from . import config, credentials, ui
|
|
15
|
+
from .client import Jira, JiraError
|
|
16
|
+
|
|
17
|
+
TOKEN_URL = 'https://id.atlassian.com/manage-profile/security/api-tokens'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def interactive(args):
|
|
21
|
+
return not args.no_input and sys.stdin.isatty() and not args.json
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def profile_name(name):
|
|
25
|
+
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}', name):
|
|
26
|
+
raise ValueError('Profile names use 1–64 letters, digits, underscores or hyphens.')
|
|
27
|
+
return name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def document():
|
|
31
|
+
current = config._read_file()
|
|
32
|
+
if current.get('schema_version') == 2:
|
|
33
|
+
return current
|
|
34
|
+
if current:
|
|
35
|
+
raise ValueError('Legacy configuration exists. Run config migrate before saving profiles; your old credentials are unchanged.')
|
|
36
|
+
return {'schema_version':2, 'active_profile':None, 'profiles':{}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def explain():
|
|
40
|
+
ui.err_console.print(Panel(
|
|
41
|
+
'[bold cyan]Connect your Jira account[/bold cyan]\n\n'
|
|
42
|
+
f'1. Open [link={TOKEN_URL}]{TOKEN_URL}[/link]\n'
|
|
43
|
+
'2. Create a token for Jira and choose an expiration date.\n'
|
|
44
|
+
'3. Paste it below; input is hidden.\n\n'
|
|
45
|
+
'[bold]Permissions[/bold]: tokens inherit your account’s project permissions. '
|
|
46
|
+
'Scoped tokens also need scopes for each operation. Browse, create, edit, '
|
|
47
|
+
'transition, attachments, and Jira Software operations can require different access.\n'
|
|
48
|
+
'A token does not grant administrator access.\n\n'
|
|
49
|
+
'[dim]Store: native OS credential store by default. Headless use: environment '
|
|
50
|
+
'variables. Plaintext file storage requires --storage file (POSIX only).[/dim]',
|
|
51
|
+
title='Jira CLI Toolkit', border_style='cyan'))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def api_site(site, scoped=False, cloud_id=None):
|
|
55
|
+
if not scoped and not cloud_id:
|
|
56
|
+
return site, None
|
|
57
|
+
if not cloud_id:
|
|
58
|
+
try:
|
|
59
|
+
r=requests.get(site+'/_edge/tenant_info', timeout=(10,30))
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
cloud_id=r.json().get('cloudId')
|
|
62
|
+
except (requests.RequestException, ValueError):
|
|
63
|
+
raise ValueError('Could not discover the cloud ID. Pass --cloud-id for a scoped token.') from None
|
|
64
|
+
if not cloud_id or not re.fullmatch(r'[a-zA-Z0-9-]+', cloud_id):
|
|
65
|
+
raise ValueError('Invalid cloud ID.')
|
|
66
|
+
return 'https://api.atlassian.com/ex/jira/'+cloud_id, cloud_id
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate(site, email, token):
|
|
70
|
+
client=Jira(site,email,token)
|
|
71
|
+
try:
|
|
72
|
+
identity=client.me()
|
|
73
|
+
if not isinstance(identity,dict) or not identity.get('accountId'):
|
|
74
|
+
raise ValueError('Jira returned an invalid account identity; no credentials were saved.')
|
|
75
|
+
return identity
|
|
76
|
+
finally:
|
|
77
|
+
client.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def persist(doc, name, settings, token, *, allow_ui=False):
|
|
81
|
+
# New credential ID preserves the old credential on a failed config write.
|
|
82
|
+
old=doc['profiles'].get(name)
|
|
83
|
+
settings={**settings,'credential_id':str(uuid.uuid4())}
|
|
84
|
+
credentials.put(settings,token,config.CONFIG_PATH,allow_ui=allow_ui)
|
|
85
|
+
doc['profiles'][name]=settings
|
|
86
|
+
doc['active_profile']=name
|
|
87
|
+
try:
|
|
88
|
+
config.atomic_json(config.CONFIG_PATH,doc)
|
|
89
|
+
except OSError:
|
|
90
|
+
credentials.delete(settings,config.CONFIG_PATH,allow_ui=allow_ui)
|
|
91
|
+
raise
|
|
92
|
+
if old:
|
|
93
|
+
try:
|
|
94
|
+
credentials.delete(old,config.CONFIG_PATH,allow_ui=allow_ui)
|
|
95
|
+
except credentials.CredentialError:
|
|
96
|
+
ui.err_console.print('The replaced credential could not be removed from its old store. Local config uses the new credential.')
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def login(args, *, replace=False):
|
|
100
|
+
doc=document()
|
|
101
|
+
name=profile_name(args.profile or os.getenv('JIRA_PROFILE') or doc.get('active_profile') or 'default')
|
|
102
|
+
old=doc['profiles'].get(name,{})
|
|
103
|
+
can_prompt=interactive(args)
|
|
104
|
+
if can_prompt:
|
|
105
|
+
explain()
|
|
106
|
+
site=(getattr(args,'site',None) or (None if replace else os.getenv('JIRA_SITE')) or old.get('site',''))
|
|
107
|
+
email=(getattr(args,'email',None) or (None if replace else os.getenv('JIRA_EMAIL')) or old.get('email',''))
|
|
108
|
+
if can_prompt and not replace:
|
|
109
|
+
site=input(f'Jira site [{site}]: ').strip() or site
|
|
110
|
+
email=input(f'Account email [{email}]: ').strip() or email
|
|
111
|
+
site=config.validate_site(site)
|
|
112
|
+
token=getattr(args,'token',None) or (None if replace else os.getenv('JIRA_API_TOKEN'))
|
|
113
|
+
if getattr(args,'token_stdin',False):
|
|
114
|
+
token=sys.stdin.readline().strip()
|
|
115
|
+
if not token and can_prompt:
|
|
116
|
+
token=getpass.getpass('Paste API token (hidden): ').strip()
|
|
117
|
+
if not email or not token:
|
|
118
|
+
raise ValueError('Login needs a site, email and token. Use interactive auth login or JIRA_SITE/JIRA_EMAIL/JIRA_API_TOKEN with --no-input.')
|
|
119
|
+
if replace and (site!=old.get('site') or email!=old.get('email')):
|
|
120
|
+
raise ValueError('Token recovery cannot change the selected site/account. Use an explicit new profile.')
|
|
121
|
+
scoped=getattr(args,'scoped',False) or bool(old.get('cloud_id'))
|
|
122
|
+
endpoint,cloud_id=api_site(site,scoped,getattr(args,'cloud_id',None) or old.get('cloud_id'))
|
|
123
|
+
identity=validate(endpoint,email,token)
|
|
124
|
+
selected_project=args.project or old.get('project','')
|
|
125
|
+
if selected_project or can_prompt:
|
|
126
|
+
j=Jira(endpoint,email,token)
|
|
127
|
+
try:
|
|
128
|
+
if selected_project:
|
|
129
|
+
selected_project=j.project_get(selected_project)['key']
|
|
130
|
+
elif can_prompt:
|
|
131
|
+
projects=j.projects(all_results=True)
|
|
132
|
+
if projects:
|
|
133
|
+
index=ui.pick('Default project', ['Skip for now']+[f"{p['key']} — {p['name']}" for p in projects])
|
|
134
|
+
selected_project=projects[index-1]['key'] if index else ''
|
|
135
|
+
finally:
|
|
136
|
+
j.close()
|
|
137
|
+
settings=dict(site=site,email=email,account_id=identity['accountId'],
|
|
138
|
+
project=selected_project,board=old.get('board'),
|
|
139
|
+
storage=getattr(args,'storage',None) or old.get('storage','keyring'))
|
|
140
|
+
if replace and old.get('account_id') and identity['accountId']!=old['account_id']:
|
|
141
|
+
raise ValueError('Replacement token belongs to a different account; create another profile explicitly.')
|
|
142
|
+
if cloud_id:
|
|
143
|
+
settings['cloud_id']=cloud_id
|
|
144
|
+
persist(doc,name,settings,token,allow_ui=can_prompt)
|
|
145
|
+
return dict(authenticated=True,profile=name,site=site,account_id=identity['accountId'],
|
|
146
|
+
storage=settings['storage'],project=settings['project'],
|
|
147
|
+
expiry='unknown; API tokens cannot be refreshed')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def migrate(args):
|
|
151
|
+
old=config._read_file()
|
|
152
|
+
if old.get('schema_version')==2:
|
|
153
|
+
return {'migrated':False,'schema_version':2}
|
|
154
|
+
site=config.validate_site(old.get('JIRA_SITE',''))
|
|
155
|
+
email,token=old.get('JIRA_EMAIL'),old.get('JIRA_API_TOKEN')
|
|
156
|
+
if not email or not token:
|
|
157
|
+
raise ValueError('Legacy configuration has no complete identity to migrate. Use auth login for a new installation.')
|
|
158
|
+
identity=validate(site,email,token)
|
|
159
|
+
name=profile_name(args.profile or 'default')
|
|
160
|
+
doc={'schema_version':2,'active_profile':name,'profiles':{}}
|
|
161
|
+
settings=dict(site=site,email=email,account_id=identity['accountId'],project=old.get('JIRA_PROJECT',''),storage=args.storage)
|
|
162
|
+
# Original plaintext remains intact until both secure storage and atomic config write succeed.
|
|
163
|
+
persist(doc,name,settings,token,allow_ui=interactive(args))
|
|
164
|
+
return {'migrated':True,'profile':name,'schema_version':2,'storage':args.storage}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def status(args, doctor=False):
|
|
168
|
+
cfg=config.resolve_config(args)
|
|
169
|
+
result={k:cfg.get(k) for k in ('site','email','profile','source','project','board')}
|
|
170
|
+
result.update(authenticated=False, token_configured=bool(cfg['token']), expiry='unknown', refresh_supported=False)
|
|
171
|
+
if not all(cfg.get(k) for k in ('site','email','token')):
|
|
172
|
+
raise ValueError('No complete credentials. Run auth login or config migrate.')
|
|
173
|
+
identity=validate(cfg.get('api_site',cfg['site']),cfg['email'],cfg['token'])
|
|
174
|
+
result.update(authenticated=True,account_id=identity['accountId'],display_name=identity.get('displayName'))
|
|
175
|
+
if doctor and cfg.get('project'):
|
|
176
|
+
j=Jira(cfg.get('api_site',cfg['site']),cfg['email'],cfg['token'])
|
|
177
|
+
try:
|
|
178
|
+
result['project_access']=j.project_get(cfg['project']).get('key')
|
|
179
|
+
finally:
|
|
180
|
+
j.close()
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def local(args):
|
|
185
|
+
doc=document()
|
|
186
|
+
profiles=doc['profiles']
|
|
187
|
+
name=args.profile or os.getenv('JIRA_PROFILE') or doc.get('active_profile')
|
|
188
|
+
if args.cmd=='profile-list':
|
|
189
|
+
return {'active_profile':doc.get('active_profile'), 'profiles':[
|
|
190
|
+
{'name':n,**{k:v for k,v in p.items() if k!='credential_id'}} for n,p in profiles.items()]}
|
|
191
|
+
if args.cmd in ('profile-use','profile-remove'):
|
|
192
|
+
name=args.name
|
|
193
|
+
if name not in profiles:
|
|
194
|
+
raise ValueError('Select an existing profile; use profile list.')
|
|
195
|
+
if args.cmd=='profile-use':
|
|
196
|
+
doc['active_profile']=name
|
|
197
|
+
elif args.cmd in ('profile-remove','auth-logout'):
|
|
198
|
+
credentials.delete(profiles[name],config.CONFIG_PATH,allow_ui=interactive(args))
|
|
199
|
+
if args.cmd=='profile-remove':
|
|
200
|
+
del profiles[name]
|
|
201
|
+
if doc.get('active_profile')==name:
|
|
202
|
+
doc['active_profile']=None # Never silently switch identities.
|
|
203
|
+
config.atomic_json(config.CONFIG_PATH,doc)
|
|
204
|
+
return {'profile':name,'local_credential_removed':True,'upstream_revoked':False,
|
|
205
|
+
'external_credentials_unchanged':True}
|
|
206
|
+
elif args.cmd=='config-get':
|
|
207
|
+
return {'profile':name,args.key:profiles[name].get(args.key)}
|
|
208
|
+
elif args.cmd=='config-set':
|
|
209
|
+
# Validate contextual preferences through context use rather than arbitrary edits.
|
|
210
|
+
args.project=args.value if args.key=='project' else None
|
|
211
|
+
args.board=int(args.value) if args.key=='board' else None
|
|
212
|
+
args.cmd='context-use'
|
|
213
|
+
if args.cmd=='context-use':
|
|
214
|
+
cfg=config.resolve_config(SimpleNamespace(profile=name,site=None,email=None,token=None,project=None,
|
|
215
|
+
no_input=args.no_input,json=args.json))
|
|
216
|
+
j=Jira(cfg.get('api_site',cfg['site']),cfg['email'],cfg['token'])
|
|
217
|
+
try:
|
|
218
|
+
if getattr(args,'select',False):
|
|
219
|
+
if not interactive(args): raise ValueError('--select requires interactive input without --json.')
|
|
220
|
+
projects=j.projects(all_results=True)
|
|
221
|
+
if not projects: raise ValueError('No accessible projects.')
|
|
222
|
+
selected=projects[ui.pick('Project',[f"{p['key']} — {p['name']}" for p in projects])]
|
|
223
|
+
args.project=selected['key']
|
|
224
|
+
if selected.get('projectTypeKey')=='software':
|
|
225
|
+
boards=j.boards(project=args.project,all_results=True).get('values',[])
|
|
226
|
+
index=ui.pick('Default board',['No default board']+[f"{b['name']} [{b['id']}]" for b in boards])
|
|
227
|
+
args.board=boards[index-1]['id'] if index else None
|
|
228
|
+
if args.project:
|
|
229
|
+
p=j.project_get(args.project)
|
|
230
|
+
if profiles[name].get('project') != p['key']:
|
|
231
|
+
profiles[name]['board']=None
|
|
232
|
+
profiles[name]['project']=p['key']
|
|
233
|
+
if args.board:
|
|
234
|
+
if args.board<1: raise ValueError('Board IDs must be positive.')
|
|
235
|
+
j.board_get(args.board)
|
|
236
|
+
profiles[name]['board']=args.board
|
|
237
|
+
doc['active_profile']=name
|
|
238
|
+
finally:
|
|
239
|
+
j.close()
|
|
240
|
+
config.atomic_json(config.CONFIG_PATH,doc)
|
|
241
|
+
return {'profile':name,'project':profiles[name].get('project'),'board':profiles[name].get('board')}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def handle(args):
|
|
245
|
+
if args.cmd=='auth-login': return login(args)
|
|
246
|
+
if args.cmd=='config-migrate': return migrate(args)
|
|
247
|
+
if args.cmd in ('auth-status','doctor'): return status(args, args.cmd=='doctor')
|
|
248
|
+
return local(args)
|
jsup/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Entry points for Jira CLI Toolkit and the compatible jsup executable."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from . import ui
|
|
10
|
+
from .api import ApiError, secret_values
|
|
11
|
+
from .application import CommandError, run, show_context
|
|
12
|
+
from .client import JiraError, UncertainOutcome
|
|
13
|
+
from .commands import build_parser, help_parser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None, *, prog: str = "jsup", default_dashboard: bool = True) -> None:
|
|
17
|
+
raw = sys.argv[1:] if argv is None else argv
|
|
18
|
+
if raw and raw[0] == "--_complete":
|
|
19
|
+
from .completion import candidates
|
|
20
|
+
print("\n".join(candidates(raw[1:], prog)))
|
|
21
|
+
return
|
|
22
|
+
if raw and raw[0] == "--_apply-update":
|
|
23
|
+
from .update import apply_windows_update
|
|
24
|
+
apply_windows_update(raw[1:])
|
|
25
|
+
return
|
|
26
|
+
if prog in {"jsup", "jira-cli-toolkit"}:
|
|
27
|
+
ui.err_console.print(
|
|
28
|
+
f"Warning: the tool has moved to the 'jira' command. '{prog}' is running "
|
|
29
|
+
"under legacy support (supported throughout v2.x) and may be deprecated "
|
|
30
|
+
"in a future major release. Update your commands to use 'jira'.",
|
|
31
|
+
style="yellow", markup=False,
|
|
32
|
+
)
|
|
33
|
+
parser = build_parser(prog)
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
def fail(code, kind, message, **details):
|
|
36
|
+
# Server errors can echo submitted credentials. Never print them.
|
|
37
|
+
from .config import resolve_config
|
|
38
|
+
secrets = secret_values(getattr(args, "email", None), getattr(args, "token", None))
|
|
39
|
+
# Spec discovery never resolves an identity, not even to redact an error.
|
|
40
|
+
discovery = args.cmd == "api" and (getattr(args, "spec", False) or getattr(args, "path", None) == "spec")
|
|
41
|
+
try:
|
|
42
|
+
if not discovery:
|
|
43
|
+
cfg = resolve_config(args)
|
|
44
|
+
secrets += secret_values(cfg.get("email"), cfg.get("token"))
|
|
45
|
+
except (ValueError, OSError):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def redact(text):
|
|
49
|
+
for secret in secrets:
|
|
50
|
+
text = text.replace(secret, "[redacted]")
|
|
51
|
+
return text
|
|
52
|
+
message = redact(message)
|
|
53
|
+
if args.cmd == "api":
|
|
54
|
+
# stdout carries only response bodies, so api errors always go to stderr as JSON.
|
|
55
|
+
payload = json.dumps({"error": {"code": kind, "message": message, **details}}, indent=2, ensure_ascii=False)
|
|
56
|
+
print(redact(payload), file=sys.stderr)
|
|
57
|
+
elif args.json and not args.legacy and prog != "jsup":
|
|
58
|
+
ui.dump_json({"error": {"code": kind, "message": message, **details}})
|
|
59
|
+
else:
|
|
60
|
+
ui.err_console.print(message, markup=False)
|
|
61
|
+
raise SystemExit(code) from None
|
|
62
|
+
if args.cmd == "help":
|
|
63
|
+
help_parser(parser, args.path).print_help()
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
if not args.cmd:
|
|
67
|
+
if args.command:
|
|
68
|
+
args.selected_parser.print_help()
|
|
69
|
+
return
|
|
70
|
+
if not default_dashboard:
|
|
71
|
+
show_context(args, prog, include_help=True)
|
|
72
|
+
return
|
|
73
|
+
args.cmd, args.projects = "dashboard", None
|
|
74
|
+
run(args, prog)
|
|
75
|
+
except ApiError as error:
|
|
76
|
+
fail(error.exit_code, error.code, str(error), **error.details)
|
|
77
|
+
except (CommandError, ValueError) as error:
|
|
78
|
+
fail(2, "invalid_input", str(error))
|
|
79
|
+
except JiraError as error:
|
|
80
|
+
hint = {401: " (authentication failed; replace the token with auth login, or migrate legacy credentials with config migrate)",
|
|
81
|
+
403: " (permission denied for this operation)",
|
|
82
|
+
404: " (not found; check the key or ID)"}.get(error.status, "")
|
|
83
|
+
fail(1, "jira_error", f"Error: {error}{hint}", status=error.status)
|
|
84
|
+
except UncertainOutcome as error:
|
|
85
|
+
fail(1, "uncertain_outcome", str(error))
|
|
86
|
+
except requests.RequestException:
|
|
87
|
+
fail(1, "network_error", "Could not reach Jira. Check the site URL and network connection.")
|
|
88
|
+
except OSError as error:
|
|
89
|
+
fail(2, "file_error", f"File operation failed: {error}")
|
|
90
|
+
except (KeyboardInterrupt, EOFError):
|
|
91
|
+
fail(130, "aborted", "Aborted.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def toolkit_main(argv=None) -> None:
|
|
95
|
+
main(argv, prog="jira-cli-toolkit", default_dashboard=False)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def jira_main(argv=None) -> None:
|
|
99
|
+
main(argv, prog="jira", default_dashboard=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main(sys.argv[1:])
|