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/credentials.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Credential storage with no implicit plaintext fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from threading import RLock
|
|
8
|
+
|
|
9
|
+
SERVICE = "jira-cli-toolkit"
|
|
10
|
+
_STORE_LOCK = RLock()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CredentialError(ValueError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _keyring():
|
|
18
|
+
try:
|
|
19
|
+
import keyring
|
|
20
|
+
backend = keyring.get_keyring()
|
|
21
|
+
module = type(backend).__module__
|
|
22
|
+
# Accept only native OS stores; chained/plaintext third-party stores
|
|
23
|
+
# are not an implicit downgrade from secure storage.
|
|
24
|
+
trusted={"keyring.backends.macOS", "keyring.backends.Windows",
|
|
25
|
+
"keyring.backends.SecretService", "keyring.backends.kwallet"}
|
|
26
|
+
if module in trusted:
|
|
27
|
+
return backend
|
|
28
|
+
for candidate in getattr(backend, "backends", []):
|
|
29
|
+
if type(candidate).__module__ in trusted and candidate.priority > 0:
|
|
30
|
+
return candidate
|
|
31
|
+
raise CredentialError("No native credential store available. Use JIRA_API_TOKEN for headless use, or explicitly choose --storage file.")
|
|
32
|
+
except ImportError:
|
|
33
|
+
raise CredentialError("Credential-store support is missing; reinstall the package or use environment authentication.") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def _store(allow_ui):
|
|
38
|
+
"""Keep OS unlock dialogs out of scripted commands.
|
|
39
|
+
|
|
40
|
+
keyring 25 targets the macOS file-based Keychain, whose process-wide
|
|
41
|
+
interaction flag must be restored even if an operation fails.
|
|
42
|
+
"""
|
|
43
|
+
backend = _keyring()
|
|
44
|
+
module = type(backend).__module__
|
|
45
|
+
if allow_ui or module == "keyring.backends.Windows":
|
|
46
|
+
yield backend
|
|
47
|
+
return
|
|
48
|
+
if module == "keyring.backends.macOS":
|
|
49
|
+
import ctypes
|
|
50
|
+
from keyring.backends.macOS import api
|
|
51
|
+
get_ui = api._sec.SecKeychainGetUserInteractionAllowed
|
|
52
|
+
set_ui = api._sec.SecKeychainSetUserInteractionAllowed
|
|
53
|
+
get_ui.argtypes = [ctypes.POINTER(ctypes.c_ubyte)]
|
|
54
|
+
set_ui.argtypes = [ctypes.c_ubyte]
|
|
55
|
+
get_ui.restype = set_ui.restype = ctypes.c_int32
|
|
56
|
+
with _STORE_LOCK:
|
|
57
|
+
previous = ctypes.c_ubyte()
|
|
58
|
+
api.Error.raise_for_status(get_ui(ctypes.byref(previous)))
|
|
59
|
+
api.Error.raise_for_status(set_ui(False))
|
|
60
|
+
try:
|
|
61
|
+
yield backend
|
|
62
|
+
finally:
|
|
63
|
+
api.Error.raise_for_status(set_ui(previous.value))
|
|
64
|
+
return
|
|
65
|
+
# Linux backends can create/unlock a wallet through an unbounded D-Bus
|
|
66
|
+
# prompt. Scripted use must choose an explicit noninteractive source.
|
|
67
|
+
raise CredentialError("This OS store can request an unlock dialog. Use environment authentication or explicitly choose --storage file for headless use; run auth login in a terminal for native storage.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get(profile, settings, config_path, *, allow_ui=False):
|
|
71
|
+
storage = settings.get("storage", "keyring")
|
|
72
|
+
if storage == "keyring":
|
|
73
|
+
try:
|
|
74
|
+
with _store(allow_ui) as backend:
|
|
75
|
+
return backend.get_password(SERVICE, settings["credential_id"]) or ""
|
|
76
|
+
except CredentialError:
|
|
77
|
+
raise
|
|
78
|
+
except Exception:
|
|
79
|
+
raise CredentialError("Could not read the OS credential store without approved access. Run auth login interactively or use environment authentication.") from None
|
|
80
|
+
path = Path(config_path).with_name("credentials.json")
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
if not isinstance(data, dict):
|
|
84
|
+
raise CredentialError("Invalid credential file.")
|
|
85
|
+
return data.get(settings["credential_id"], "")
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
return ""
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
raise CredentialError("Invalid credential file.") from None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def put(settings, token, config_path, *, allow_ui=False):
|
|
93
|
+
if settings["storage"] == "keyring":
|
|
94
|
+
try:
|
|
95
|
+
with _store(allow_ui) as backend:
|
|
96
|
+
backend.set_password(SERVICE, settings["credential_id"], token)
|
|
97
|
+
return
|
|
98
|
+
except CredentialError:
|
|
99
|
+
raise
|
|
100
|
+
except Exception:
|
|
101
|
+
raise CredentialError("Could not save to the OS credential store. Run auth login interactively to approve access, use environment authentication, or explicitly choose --storage file (POSIX only). No plaintext fallback was written.") from None
|
|
102
|
+
import os
|
|
103
|
+
if os.name == "nt":
|
|
104
|
+
raise CredentialError("File storage on Windows is unsupported until ACL protection is implemented; use the Windows credential store or environment variables.")
|
|
105
|
+
from .config import atomic_json
|
|
106
|
+
path = Path(config_path).with_name("credentials.json")
|
|
107
|
+
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
108
|
+
data[settings["credential_id"]] = token
|
|
109
|
+
atomic_json(path, data)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def delete(settings, config_path, *, allow_ui=False):
|
|
113
|
+
if settings["storage"] == "keyring":
|
|
114
|
+
try:
|
|
115
|
+
with _store(allow_ui) as backend:
|
|
116
|
+
backend.delete_password(SERVICE, settings["credential_id"])
|
|
117
|
+
except Exception:
|
|
118
|
+
raise CredentialError("Could not remove the OS credential. Unlock the store and retry logout.") from None
|
|
119
|
+
else:
|
|
120
|
+
from .config import atomic_json
|
|
121
|
+
path = Path(config_path).with_name("credentials.json")
|
|
122
|
+
if path.exists():
|
|
123
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
124
|
+
data.pop(settings["credential_id"], None)
|
|
125
|
+
atomic_json(path, data)
|
jsup/data/callback.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"name": "callback",
|
|
4
|
+
"type": "Task",
|
|
5
|
+
"summary": "Callback: {name} — {issue}",
|
|
6
|
+
"description": "Client: {name}\nIssue as reported: {issue}\nRequested callback: {callback}\nSource: jsup intake. Assumed open — team to review & update.",
|
|
7
|
+
"fields": {"priority": "High", "labels": ["callback"], "components": ["ops-runbook"]},
|
|
8
|
+
"variables": [
|
|
9
|
+
{"name": "name", "prompt": "Client name", "required": true},
|
|
10
|
+
{"name": "issue", "prompt": "Issue as reported", "required": true},
|
|
11
|
+
{"name": "callback", "prompt": "Requested callback", "default": "not specified"}
|
|
12
|
+
]
|
|
13
|
+
}
|
jsup/fields.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Metadata-aware field input; arbitrary validators remain Jira's responsibility."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import date
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from . import config, ui
|
|
16
|
+
from .client import adf, JiraError
|
|
17
|
+
|
|
18
|
+
TTL=300
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cached(cfg,project,kind,fetch,refresh=False):
|
|
22
|
+
identity=[cfg['site'],cfg.get('email'),cfg.get('profile'),project,kind]
|
|
23
|
+
key=hashlib.sha256(json.dumps(identity).encode()).hexdigest()
|
|
24
|
+
path=config.CONFIG_PATH.parent/'cache'/(key+'.json')
|
|
25
|
+
if not refresh:
|
|
26
|
+
try:
|
|
27
|
+
entry=json.loads(path.read_text(encoding="utf-8"))
|
|
28
|
+
if 0<=time.time()-entry['saved_at']<TTL:
|
|
29
|
+
return entry['data']
|
|
30
|
+
except (OSError,ValueError,KeyError,TypeError): pass
|
|
31
|
+
data=fetch()
|
|
32
|
+
try: config.atomic_json(path,{'saved_at':time.time(),'data':data})
|
|
33
|
+
except OSError: pass # Cache availability must not block Jira operations.
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def invalidate(cfg):
|
|
38
|
+
# Invalidate this CLI's metadata cache after a validation failure.
|
|
39
|
+
for path in (config.CONFIG_PATH.parent/'cache').glob('*.json'):
|
|
40
|
+
try: path.unlink()
|
|
41
|
+
except OSError: pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def choose(items,value,label):
|
|
45
|
+
if not isinstance(items,list) or any(not isinstance(i,dict) or 'id' not in i for i in items):
|
|
46
|
+
raise ValueError('Jira returned invalid value metadata.')
|
|
47
|
+
if value is None: raise ValueError(f'Pass {label}; available values: '+', '.join(f"{i['name']} [id:{i['id']}]" for i in items))
|
|
48
|
+
raw=str(value).removeprefix('id:')
|
|
49
|
+
exact=[i for i in items if str(i.get('id'))==raw]
|
|
50
|
+
matches=exact or [i for i in items if str(i.get('name',i.get('value',''))).casefold()==raw.casefold()]
|
|
51
|
+
if len(matches)!=1:
|
|
52
|
+
raise ValueError(f'{label} {value!r} is unavailable or ambiguous; use an explicit ID. Available: '+', '.join(f"{i.get('name',i.get('value',''))} [id:{i.get('id')}]" for i in items))
|
|
53
|
+
return matches[0]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize(meta):
|
|
57
|
+
if isinstance(meta,dict):
|
|
58
|
+
if any(not isinstance(value,dict) for value in meta.values()):
|
|
59
|
+
raise ValueError('Jira returned invalid field metadata.')
|
|
60
|
+
return meta
|
|
61
|
+
if not isinstance(meta,list) or any(not isinstance(item,dict) or not (item.get('fieldId') or item.get('key')) for item in meta):
|
|
62
|
+
raise ValueError('Jira returned invalid field metadata.')
|
|
63
|
+
return {item.get('fieldId') or item['key']:item for item in meta}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def field_key(meta,key):
|
|
67
|
+
if key in meta: return key
|
|
68
|
+
matches=[fid for fid,f in meta.items() if f.get('name','').casefold()==key.casefold()]
|
|
69
|
+
if len(matches)!=1: raise ValueError(f'Unknown or ambiguous field {key!r}; use its field ID (project fields).')
|
|
70
|
+
return matches[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def typed(field,value):
|
|
74
|
+
schema=field.get('schema',{}); kind=schema.get('type')
|
|
75
|
+
if value is None: return None
|
|
76
|
+
if isinstance(value,(dict,list,int,float,bool)):
|
|
77
|
+
return value # Explicit structured values are passed intact for Jira validation.
|
|
78
|
+
if kind=='array':
|
|
79
|
+
values=value.split(',')
|
|
80
|
+
element={**field,'schema':{**schema,'type':schema.get('items')}}
|
|
81
|
+
return [typed(element,v.strip()) for v in values if v.strip()]
|
|
82
|
+
allowed=field.get('allowedValues',[])
|
|
83
|
+
if allowed:
|
|
84
|
+
if isinstance(allowed[0],str):
|
|
85
|
+
matches=[option for option in allowed if option.casefold()==value.casefold()]
|
|
86
|
+
if len(matches)!=1: raise ValueError(f"{field.get('name')}: value is not allowed.")
|
|
87
|
+
return matches[0]
|
|
88
|
+
item=choose(allowed,value,field.get('name','field'))
|
|
89
|
+
if isinstance(item,dict):
|
|
90
|
+
return {'id':str(item['id'])} if 'id' in item else {'value':item.get('value',item.get('name'))}
|
|
91
|
+
return item
|
|
92
|
+
if kind in ('number','integer'):
|
|
93
|
+
try: return int(value) if kind=='integer' else float(value)
|
|
94
|
+
except ValueError: raise ValueError(f"{field.get('name')}: expected a number.") from None
|
|
95
|
+
if kind=='date':
|
|
96
|
+
try: return date.fromisoformat(value).isoformat()
|
|
97
|
+
except ValueError: raise ValueError(f"{field.get('name')}: use YYYY-MM-DD.") from None
|
|
98
|
+
if kind=='user': return {'accountId':value.removeprefix('id:')}
|
|
99
|
+
if kind in ('priority','component','option','issuetype','resolution','version'):
|
|
100
|
+
if not value.startswith('id:'): raise ValueError(f"{field.get('name')}: pass id:<ID> or structured JSON; no allowed-value names were returned.")
|
|
101
|
+
return {'id':value[3:]}
|
|
102
|
+
if field.get('key')=='description' or schema.get('system')=='description' or schema.get('custom','').endswith(':textarea'):
|
|
103
|
+
return adf(value)
|
|
104
|
+
if kind in ('string',None): return value
|
|
105
|
+
if kind=='issuelink': return {'key':value}
|
|
106
|
+
raise ValueError(f"{field.get('name')}: unsupported field format ({kind}); supply a structured JSON value using --field FIELD:=JSON or --fields-file.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def supplied(args,meta):
|
|
110
|
+
values={}
|
|
111
|
+
if getattr(args,'fields_file',None):
|
|
112
|
+
values=json.loads(Path(args.fields_file).read_text(encoding="utf-8"))
|
|
113
|
+
if not isinstance(values,dict): raise ValueError('--fields-file must contain a JSON object of field values.')
|
|
114
|
+
for entry in getattr(args,'field',[]):
|
|
115
|
+
separator=':=' if ':=' in entry else '='
|
|
116
|
+
key,found,value=entry.partition(separator)
|
|
117
|
+
if not found or not key: raise ValueError('Use --field FIELD=VALUE or FIELD:=JSON.')
|
|
118
|
+
values[key]=json.loads(value) if separator==':=' else value
|
|
119
|
+
result={}
|
|
120
|
+
for key,value in values.items():
|
|
121
|
+
fid=field_key(meta,key)
|
|
122
|
+
if fid in result: raise ValueError(f'Duplicate values refer to field {fid}; use one ID/name.')
|
|
123
|
+
result[fid]=typed(meta[fid],value)
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def description(args):
|
|
128
|
+
if getattr(args,'desc_file',None): return Path(args.desc_file).read_text(encoding="utf-8")
|
|
129
|
+
if getattr(args,'editor',False):
|
|
130
|
+
if args.no_input or args.json or not sys.stdin.isatty(): raise ValueError('--editor requires interactive input without --json.')
|
|
131
|
+
editor=os.getenv('VISUAL') or os.getenv('EDITOR')
|
|
132
|
+
if not editor: raise ValueError('Set VISUAL or EDITOR before using --editor.')
|
|
133
|
+
with tempfile.TemporaryDirectory(prefix='jira-editor-') as temp:
|
|
134
|
+
path=Path(temp)/'description.txt'; path.write_text(getattr(args,'desc',None) or '', encoding='utf-8')
|
|
135
|
+
try: subprocess.run([*shlex.split(editor),str(path)],check=True)
|
|
136
|
+
except subprocess.SubprocessError: raise ValueError('Editor failed; no Jira operation was submitted.') from None
|
|
137
|
+
return path.read_text(encoding="utf-8")
|
|
138
|
+
return getattr(args,'desc',None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def require_fields(args,meta,fields):
|
|
142
|
+
missing=[key for key,f in meta.items() if f.get('required') and not f.get('hasDefaultValue') and fields.get(key) in (None,'',[])]
|
|
143
|
+
if missing and (args.no_input or args.json or not sys.stdin.isatty()):
|
|
144
|
+
raise ValueError('Required fields missing: '+', '.join(f"{meta[k].get('name',k)} ({k})" for k in missing)+'. Supply --field FIELD=VALUE or --fields-file.')
|
|
145
|
+
for key in missing:
|
|
146
|
+
field=meta[key]; allowed=field.get('allowedValues',[])
|
|
147
|
+
if allowed:
|
|
148
|
+
index=ui.pick(field.get('name',key),[f"{v.get('name',v.get('value'))} [id:{v.get('id')}]" for v in allowed])
|
|
149
|
+
fields[key]=typed(field,'id:'+str(allowed[index]['id']))
|
|
150
|
+
else:
|
|
151
|
+
fields[key]=typed(field,input(f"{field.get('name',key)} ({key}): ").strip())
|
|
152
|
+
if fields[key] in (None,'',[]): raise ValueError(f'Required field {key} cannot be empty.')
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def prepare_create(j,args,cfg):
|
|
156
|
+
project=cfg['project']
|
|
157
|
+
if not project: raise ValueError('Select a project with --project or context use.')
|
|
158
|
+
types=cached(cfg,project,'types',lambda:j.issue_types(project),args.refresh)
|
|
159
|
+
issue_type=args.type
|
|
160
|
+
if not issue_type and not args.no_input and not args.json and sys.stdin.isatty():
|
|
161
|
+
issue_type=types[ui.pick('Issue type',[f"{i['name']} [{i['id']}]" for i in types])]['id']
|
|
162
|
+
selected=choose(types,issue_type,'--type')
|
|
163
|
+
meta=normalize(cached(cfg,project,'create:'+selected['id'],lambda:j.create_fields(project,selected['id']),args.refresh))
|
|
164
|
+
provided=supplied(args,meta)
|
|
165
|
+
explicit={'summary':args.summary,'description':description(args),'priority':args.priority,
|
|
166
|
+
'assignee':args.assignee,'labels':args.label or None,'components':args.component or None,'parent':args.parent}
|
|
167
|
+
fields={}
|
|
168
|
+
for key,value in getattr(args,'template_fields',{}).items():
|
|
169
|
+
fid=field_key(meta,key)
|
|
170
|
+
if fid in provided or explicit.get(fid) is not None:
|
|
171
|
+
continue
|
|
172
|
+
if isinstance(value,list) and all(isinstance(v,str) for v in value): value=','.join(value)
|
|
173
|
+
fields[fid]=typed(meta[fid],value)
|
|
174
|
+
fields.update(provided)
|
|
175
|
+
if 'project' in fields or 'issuetype' in fields: raise ValueError('Select project/type with --project and --type, not --field.')
|
|
176
|
+
fields.update(project={'key':project},issuetype={'id':selected['id']})
|
|
177
|
+
for key,value in explicit.items():
|
|
178
|
+
if value is not None:
|
|
179
|
+
if key not in meta: raise ValueError(f'Field {key} is not available for creation in this project/type.')
|
|
180
|
+
if key in ('parent',): fields[key]={'key':value}
|
|
181
|
+
else: fields[key]=typed(meta[key],','.join(value) if isinstance(value,list) else value)
|
|
182
|
+
require_fields(args,meta,fields)
|
|
183
|
+
return fields
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def create(j,args,cfg):
|
|
187
|
+
if getattr(args,'template',None):
|
|
188
|
+
from .templates import apply
|
|
189
|
+
apply(args)
|
|
190
|
+
fields=prepare_create(j,args,cfg)
|
|
191
|
+
try: return j.create_with_fields(fields)
|
|
192
|
+
except JiraError as error:
|
|
193
|
+
if error.status in (400,422): invalidate(cfg)
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def transition(j,args,cfg):
|
|
198
|
+
transitions=j.transition_fields(args.key).get('transitions',[])
|
|
199
|
+
selected=choose(transitions,args.to,'--to')
|
|
200
|
+
meta=normalize(selected.get('fields',{}))
|
|
201
|
+
fields=supplied(args,meta)
|
|
202
|
+
require_fields(args,meta,fields)
|
|
203
|
+
result=j.transition_with_fields(args.key,selected['id'],fields)
|
|
204
|
+
return {**result, 'moved':args.key, 'to':selected['name']}
|
jsup/maintenance.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Issue maintenance with metadata validation and explicit destructive actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
|
|
8
|
+
from . import fields
|
|
9
|
+
from .client import JiraError, project_jql
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def edit(j,args,cfg):
|
|
13
|
+
meta=fields.normalize(j.edit_fields(args.key))
|
|
14
|
+
values=fields.supplied(args,meta)
|
|
15
|
+
explicit={'summary':args.summary,'description':fields.description(args),'priority':args.priority,
|
|
16
|
+
'duedate':args.due_date,'parent':args.parent}
|
|
17
|
+
for key,value in explicit.items():
|
|
18
|
+
if value is not None:
|
|
19
|
+
if key not in meta: raise ValueError(f'Field {key} is not editable on this issue.')
|
|
20
|
+
values[key]={'key':value} if key=='parent' else fields.typed(meta[key],value)
|
|
21
|
+
updates={}
|
|
22
|
+
for singular,plural in [('label','labels'),('component','components')]:
|
|
23
|
+
replace=getattr(args,singular); add=getattr(args,'add_'+singular); remove=getattr(args,'remove_'+singular)
|
|
24
|
+
if replace and (add or remove): raise ValueError(f'Use either --{singular} replacement or add/remove operations.')
|
|
25
|
+
if (replace or add or remove) and plural not in meta: raise ValueError(f'Field {plural} is not editable.')
|
|
26
|
+
if replace: values[plural]=fields.typed(meta[plural],','.join(replace))
|
|
27
|
+
if add or remove:
|
|
28
|
+
if plural in values: raise ValueError(f'Field {plural} cannot be both replaced and incrementally updated.')
|
|
29
|
+
permitted=meta[plural].get('operations',[])
|
|
30
|
+
for operation,items in [('add',add),('remove',remove)]:
|
|
31
|
+
if items and operation not in permitted: raise ValueError(f'Jira does not allow {operation} on {plural} for this issue.')
|
|
32
|
+
element={**meta[plural],'schema':{**meta[plural].get('schema',{}),'type':meta[plural].get('schema',{}).get('items')}}
|
|
33
|
+
updates.setdefault(plural,[]).extend({operation:fields.typed(element,item)} for item in items)
|
|
34
|
+
if not values and not updates: raise ValueError('No changes supplied. Use issue edit --help.')
|
|
35
|
+
for key,value in values.items():
|
|
36
|
+
if meta[key].get('required') and value in (None,'',[]): raise ValueError(f'Required field {key} cannot be cleared.')
|
|
37
|
+
if 'set' not in meta[key].get('operations',['set']): raise ValueError(f'Jira does not allow setting {key}.')
|
|
38
|
+
try: return j.edit_issue(args.key,values,updates or None)
|
|
39
|
+
except JiraError as error:
|
|
40
|
+
if error.status in (400,422): fields.invalidate(cfg)
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def body(args):
|
|
45
|
+
if args.message is not None: return args.message
|
|
46
|
+
if getattr(args,'message_file',None): return Path(args.message_file).read_text(encoding="utf-8")
|
|
47
|
+
if getattr(args,'editor',False):
|
|
48
|
+
proxy=SimpleNamespace(desc=None,desc_file=None,editor=True,json=args.json,no_input=args.no_input)
|
|
49
|
+
return fields.description(proxy)
|
|
50
|
+
raise ValueError('Supply --message, --message-file, or interactive --editor.')
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def issue_list(j,args,cfg):
|
|
54
|
+
filters=bool(args.open or args.project or args.assignee or args.status or args.type or args.label or args.board)
|
|
55
|
+
if args.jql:
|
|
56
|
+
if filters or args.order_by!='updated' or args.order!='desc': raise ValueError('--jql is a complete query; omit generated filters and ordering flags.')
|
|
57
|
+
jql=args.jql
|
|
58
|
+
else:
|
|
59
|
+
clauses=[]
|
|
60
|
+
project=args.project or (None if args.board else cfg['project'])
|
|
61
|
+
if not project and not args.board: raise ValueError('Select --project or --board, or supply complete --jql.')
|
|
62
|
+
if project: clauses.append(project_jql(project))
|
|
63
|
+
if args.open: clauses.append('statusCategory != Done')
|
|
64
|
+
if args.assignee: clauses.append('assignee = '+('currentUser()' if args.assignee=='me' else json.dumps(args.assignee.removeprefix('id:'))))
|
|
65
|
+
if args.status: clauses.append('status IN ('+', '.join(json.dumps(s) for s in args.status)+')')
|
|
66
|
+
if args.type: clauses.append('issuetype = '+json.dumps(args.type))
|
|
67
|
+
for label in args.label: clauses.append('labels = '+json.dumps(label))
|
|
68
|
+
jql=' AND '.join(clauses)+f' ORDER BY {args.order_by} {args.order.upper()}'
|
|
69
|
+
jql=jql.strip()
|
|
70
|
+
options={}
|
|
71
|
+
if getattr(args,'all',False): options['all_results']=True
|
|
72
|
+
if args.fields: options['fields']=args.fields
|
|
73
|
+
if args.board: return j.board_issues(args.board,jql,args.max,**options)
|
|
74
|
+
return j.search(jql,args.max,**options)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def handle(j,args,cfg):
|
|
78
|
+
cmd=args.cmd
|
|
79
|
+
if cmd=='issue-edit': return edit(j,args,cfg)
|
|
80
|
+
if cmd=='issue-unassign': return j.assign(args.key,None)
|
|
81
|
+
if cmd=='issue-assign':
|
|
82
|
+
if args.user=='me': account=j.me()['accountId']
|
|
83
|
+
elif args.user.startswith('id:'): account=args.user[3:]
|
|
84
|
+
else:
|
|
85
|
+
candidates=j.assignable_users(args.key,args.user)
|
|
86
|
+
matches=[u for u in candidates if args.user.casefold() in (u.get('displayName','').casefold(),u.get('emailAddress','').casefold())]
|
|
87
|
+
if len(matches)!=1: raise ValueError('User is unavailable or ambiguous; use --user id:<account ID>.')
|
|
88
|
+
account=matches[0]['accountId']
|
|
89
|
+
if not account: raise ValueError('A nonempty account ID is required.')
|
|
90
|
+
return j.assign(args.key,account)
|
|
91
|
+
if cmd=='issue-link':
|
|
92
|
+
kind=fields.choose(j.link_types(),args.type,'--type')
|
|
93
|
+
inward,outward=(args.other,args.key) if args.direction=='outward' else (args.key,args.other)
|
|
94
|
+
return j.link(inward,outward,kind['id'])
|
|
95
|
+
if cmd=='issue-unlink': return j.unlink(args.id)
|
|
96
|
+
if cmd in ('comment-add','comment-edit'):
|
|
97
|
+
text=body(args)
|
|
98
|
+
if not text.strip(): raise ValueError('Comment body cannot be empty.')
|
|
99
|
+
return j.comment_add(args.key,text) if cmd=='comment-add' else j.comment_edit(args.key,args.comment_id,text)
|
|
100
|
+
if cmd=='comment-delete': return j.comment_delete(args.key,args.comment_id)
|
|
101
|
+
if cmd=='attachment-list': return j.issue_get(args.key).get('fields',{}).get('attachment',[])
|
|
102
|
+
if cmd=='attachment-upload': return j.attachment_upload(args.key,args.files)
|
|
103
|
+
if cmd=='attachment-download': return j.attachment_download(args.id,args.output)
|
|
104
|
+
if cmd=='attachment-delete': return j.attachment_delete(args.id)
|
|
105
|
+
raise ValueError('Unsupported maintenance operation.')
|
jsup/templates.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Versioned, non-executable team templates."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import string
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import config
|
|
13
|
+
|
|
14
|
+
ALLOWED={'schema_version','name','type','summary','description','fields','variables'}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load(name):
|
|
18
|
+
if name=='callback':
|
|
19
|
+
data=json.loads(files('jsup').joinpath('data/callback.json').read_text(encoding="utf-8"))
|
|
20
|
+
else:
|
|
21
|
+
path=Path(name)
|
|
22
|
+
if path.suffix!='.json' and len(path.parts)==1:
|
|
23
|
+
if not re.fullmatch(r'[a-zA-Z0-9_-]+',name): raise ValueError('Invalid template name.')
|
|
24
|
+
path=config.CONFIG_PATH.parent/'templates'/(name+'.json')
|
|
25
|
+
data=json.loads(path.read_text(encoding="utf-8"))
|
|
26
|
+
validate(data)
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate(data):
|
|
31
|
+
if not isinstance(data,dict) or data.get('schema_version')!=1 or set(data)-ALLOWED:
|
|
32
|
+
raise ValueError('Unsupported template schema/keys. Templates cannot contain hooks or credentials.')
|
|
33
|
+
if not isinstance(data.get('fields',{}),dict) or not isinstance(data.get('variables',[]),list):
|
|
34
|
+
raise ValueError('Template fields must be an object and variables a list.')
|
|
35
|
+
names=set()
|
|
36
|
+
for variable in data.get('variables',[]):
|
|
37
|
+
if not isinstance(variable,dict) or set(variable)-{'name','prompt','required','default'}:
|
|
38
|
+
raise ValueError('Invalid template variable.')
|
|
39
|
+
name=variable.get('name','')
|
|
40
|
+
if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*',name) or name in names:
|
|
41
|
+
raise ValueError('Invalid or duplicate template variable name.')
|
|
42
|
+
names.add(name)
|
|
43
|
+
for key in ('summary','description'):
|
|
44
|
+
if key in data:
|
|
45
|
+
if not isinstance(data[key],str): raise ValueError('Template summary/description must be strings.')
|
|
46
|
+
try:
|
|
47
|
+
for literal,name,spec,conversion in string.Formatter().parse(data[key]):
|
|
48
|
+
if name is not None and (name not in names or spec or conversion):
|
|
49
|
+
raise ValueError('Template placeholders must be declared simple variable names; expressions are unsupported.')
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise ValueError('Invalid template pattern or placeholder.') from None
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render(data,variables):
|
|
56
|
+
validate(data)
|
|
57
|
+
declared={v['name']:v for v in data.get('variables',[])}
|
|
58
|
+
if set(variables)-set(declared): raise ValueError('Unknown template variables: '+', '.join(sorted(set(variables)-set(declared))))
|
|
59
|
+
resolved={name:variables.get(name,entry.get('default','')) for name,entry in declared.items()}
|
|
60
|
+
missing=[name for name,entry in declared.items() if entry.get('required') and not resolved[name]]
|
|
61
|
+
if missing: raise ValueError('Missing template variables: '+', '.join(missing))
|
|
62
|
+
fields=copy.deepcopy(data.get('fields',{}))
|
|
63
|
+
if 'summary' in data: fields['summary']=data['summary'].format_map(resolved)
|
|
64
|
+
if 'description' in data: fields['description']=data['description'].format_map(resolved)
|
|
65
|
+
return fields
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def apply(args, *, validating=False):
|
|
69
|
+
data=load(args.template)
|
|
70
|
+
values={}
|
|
71
|
+
for item in args.var:
|
|
72
|
+
key,sep,value=item.partition('=')
|
|
73
|
+
if not sep: raise ValueError('Use --var NAME=VALUE.')
|
|
74
|
+
values[key]=value
|
|
75
|
+
for variable in data.get('variables',[]):
|
|
76
|
+
name=variable['name']
|
|
77
|
+
if name not in values and variable.get('required'):
|
|
78
|
+
if validating:
|
|
79
|
+
values[name]='example'
|
|
80
|
+
elif not args.no_input and not args.json and sys.stdin.isatty():
|
|
81
|
+
values[name]=input(variable.get('prompt',name)+': ').strip()
|
|
82
|
+
args.template_fields=render(data,values)
|
|
83
|
+
if not args.type: args.type=data.get('type')
|
|
84
|
+
return args
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_templates():
|
|
88
|
+
local=config.CONFIG_PATH.parent/'templates'
|
|
89
|
+
return {'templates':['callback']+sorted(p.stem for p in local.glob('*.json') if p.stem!='callback')}
|