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/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:])