atlassian-cli 0.5.9__tar.gz → 0.7.0__tar.gz
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.
- {atlassian_cli-0.5.9/src/atlassian_cli.egg-info → atlassian_cli-0.7.0}/PKG-INFO +15 -3
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/README.md +14 -2
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/pyproject.toml +1 -1
- atlassian_cli-0.7.0/src/atlassian_cli/__init__.py +7 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/config.py +30 -8
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/confluence.py +214 -1
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/http.py +3 -1
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/jira.py +19 -4
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/jira_assets.py +19 -1
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/jira_issues.py +48 -12
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/output.py +3 -0
- atlassian_cli-0.7.0/src/atlassian_cli/update_check.py +77 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0/src/atlassian_cli.egg-info}/PKG-INFO +15 -3
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/SOURCES.txt +1 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_config.py +39 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_jira_assets.py +5 -2
- atlassian_cli-0.5.9/src/atlassian_cli/__init__.py +0 -3
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/LICENSE +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/setup.cfg +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/adf.py +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/hints.py +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/requires.txt +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/top_level.txt +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_adf.py +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_confluence.py +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_http.py +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_jira_issues.py +0 -0
- {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atlassian-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents
|
|
5
5
|
Author-email: Alex Fishlock <alex.fishlock@catapult.cx>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -61,17 +61,29 @@ pip install git+https://github.com/catapultcx/atlassian-cli.git
|
|
|
61
61
|
|
|
62
62
|
## Setup
|
|
63
63
|
|
|
64
|
-
Create a
|
|
64
|
+
Create a config file with your credentials:
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
|
+
mkdir -p ~/.config/atlassian-cli
|
|
68
|
+
cat > ~/.config/atlassian-cli/config <<EOF
|
|
67
69
|
ATLASSIAN_URL=https://your-site.atlassian.net
|
|
68
70
|
ATLASSIAN_EMAIL=you@example.com
|
|
69
71
|
ATLASSIAN_TOKEN=your-api-token
|
|
72
|
+
EOF
|
|
73
|
+
chmod 600 ~/.config/atlassian-cli/config
|
|
70
74
|
```
|
|
71
75
|
|
|
76
|
+
The CLI looks for credentials in this order (first match wins):
|
|
77
|
+
|
|
78
|
+
1. `$ATLASSIAN_CLI_CONFIG` (explicit path override)
|
|
79
|
+
2. `./.env` in the current working directory
|
|
80
|
+
3. `$XDG_CONFIG_HOME/atlassian-cli/config` (defaults to `~/.config/atlassian-cli/config`)
|
|
81
|
+
4. `~/.atlassian-cli/config`
|
|
82
|
+
5. Environment variables (`ATLASSIAN_URL`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN`)
|
|
83
|
+
|
|
72
84
|
Get your API token at https://id.atlassian.com/manage-profile/security/api-tokens
|
|
73
85
|
|
|
74
|
-
> Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN`
|
|
86
|
+
> Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN` are also supported.
|
|
75
87
|
|
|
76
88
|
## Confluence CLI
|
|
77
89
|
|
|
@@ -30,17 +30,29 @@ pip install git+https://github.com/catapultcx/atlassian-cli.git
|
|
|
30
30
|
|
|
31
31
|
## Setup
|
|
32
32
|
|
|
33
|
-
Create a
|
|
33
|
+
Create a config file with your credentials:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
+
mkdir -p ~/.config/atlassian-cli
|
|
37
|
+
cat > ~/.config/atlassian-cli/config <<EOF
|
|
36
38
|
ATLASSIAN_URL=https://your-site.atlassian.net
|
|
37
39
|
ATLASSIAN_EMAIL=you@example.com
|
|
38
40
|
ATLASSIAN_TOKEN=your-api-token
|
|
41
|
+
EOF
|
|
42
|
+
chmod 600 ~/.config/atlassian-cli/config
|
|
39
43
|
```
|
|
40
44
|
|
|
45
|
+
The CLI looks for credentials in this order (first match wins):
|
|
46
|
+
|
|
47
|
+
1. `$ATLASSIAN_CLI_CONFIG` (explicit path override)
|
|
48
|
+
2. `./.env` in the current working directory
|
|
49
|
+
3. `$XDG_CONFIG_HOME/atlassian-cli/config` (defaults to `~/.config/atlassian-cli/config`)
|
|
50
|
+
4. `~/.atlassian-cli/config`
|
|
51
|
+
5. Environment variables (`ATLASSIAN_URL`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN`)
|
|
52
|
+
|
|
41
53
|
Get your API token at https://id.atlassian.com/manage-profile/security/api-tokens
|
|
42
54
|
|
|
43
|
-
> Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN`
|
|
55
|
+
> Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN` are also supported.
|
|
44
56
|
|
|
45
57
|
## Confluence CLI
|
|
46
58
|
|
|
@@ -7,17 +7,39 @@ import requests
|
|
|
7
7
|
from requests.auth import HTTPBasicAuth
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
def _config_search_paths():
|
|
11
|
+
"""Return ordered list of paths to look for a .env / config file.
|
|
12
|
+
|
|
13
|
+
Search order (first match wins):
|
|
14
|
+
1. ATLASSIAN_CLI_CONFIG env var (explicit override)
|
|
15
|
+
2. ./.env in the current working directory
|
|
16
|
+
3. $XDG_CONFIG_HOME/atlassian-cli/config (XDG default ~/.config/...)
|
|
17
|
+
4. ~/.atlassian-cli/config (legacy/dotfile location)
|
|
18
|
+
5. <package_dir>/.env (last-ditch fallback for editable installs)
|
|
19
|
+
"""
|
|
20
|
+
paths = []
|
|
21
|
+
override = os.environ.get('ATLASSIAN_CLI_CONFIG')
|
|
22
|
+
if override:
|
|
23
|
+
paths.append(override)
|
|
24
|
+
paths.append(os.path.join(os.getcwd(), '.env'))
|
|
25
|
+
xdg = os.environ.get('XDG_CONFIG_HOME') or os.path.expanduser('~/.config')
|
|
26
|
+
paths.append(os.path.join(xdg, 'atlassian-cli', 'config'))
|
|
27
|
+
paths.append(os.path.expanduser('~/.atlassian-cli/config'))
|
|
28
|
+
paths.append(os.path.join(os.path.dirname(__file__), '.env'))
|
|
29
|
+
return paths
|
|
16
30
|
|
|
31
|
+
|
|
32
|
+
def load_env(path=None):
|
|
33
|
+
"""Parse a .env-style file into a dict, skipping comments and blank lines."""
|
|
34
|
+
paths = [path] if path else _config_search_paths()
|
|
17
35
|
for env_path in paths:
|
|
18
|
-
if os.path.exists(env_path):
|
|
36
|
+
if env_path and os.path.exists(env_path):
|
|
19
37
|
with open(env_path, 'r') as file:
|
|
20
|
-
return dict(
|
|
38
|
+
return dict(
|
|
39
|
+
line.strip().split('=', 1)
|
|
40
|
+
for line in file
|
|
41
|
+
if not line.startswith('#') and '=' in line
|
|
42
|
+
)
|
|
21
43
|
return {}
|
|
22
44
|
|
|
23
45
|
|
|
@@ -23,8 +23,9 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
23
23
|
|
|
24
24
|
from atlassian_cli.config import setup
|
|
25
25
|
from atlassian_cli.http import APIError, _retry, api_delete, api_get, api_post, api_put
|
|
26
|
-
from atlassian_cli.output import emit, emit_error, set_json_mode
|
|
26
|
+
from atlassian_cli.output import emit, emit_error, emit_json, is_json_mode, set_json_mode
|
|
27
27
|
|
|
28
|
+
V1 = '/wiki/rest/api'
|
|
28
29
|
V2 = '/wiki/api/v2'
|
|
29
30
|
|
|
30
31
|
|
|
@@ -677,11 +678,206 @@ def cmd_hints(args):
|
|
|
677
678
|
print(format_hints(args.topic))
|
|
678
679
|
|
|
679
680
|
|
|
681
|
+
# ---------------------------------------------------------------------------
|
|
682
|
+
# Page version changes
|
|
683
|
+
# ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
def cmd_changes(args):
|
|
686
|
+
"""Show what changed in the latest version of a page."""
|
|
687
|
+
session, base = setup()
|
|
688
|
+
page_id = args.page_id
|
|
689
|
+
|
|
690
|
+
current = api_get(session, base, f'{V2}/pages/{page_id}?body-format=atlas_doc_format&include-version=true')
|
|
691
|
+
cur_ver = current['version']['number']
|
|
692
|
+
title = current['title']
|
|
693
|
+
author_id = current['version'].get('authorId', '?')
|
|
694
|
+
created = current['version'].get('createdAt', '?')[:16]
|
|
695
|
+
|
|
696
|
+
prev_ver = args.version or cur_ver - 1
|
|
697
|
+
if prev_ver < 1:
|
|
698
|
+
emit_error('Only one version exists — nothing to compare')
|
|
699
|
+
sys.exit(1)
|
|
700
|
+
|
|
701
|
+
# v1 API required for historical version bodies
|
|
702
|
+
prev_resp = session.get(
|
|
703
|
+
f'{base}{V1}/content/{page_id}',
|
|
704
|
+
params={'status': 'historical', 'version': prev_ver, 'expand': 'body.atlas_doc_format'},
|
|
705
|
+
)
|
|
706
|
+
if not prev_resp.ok:
|
|
707
|
+
raise APIError(prev_resp.status_code, prev_resp.text)
|
|
708
|
+
|
|
709
|
+
from atlassian_cli.adf import adf_to_markdown
|
|
710
|
+
cur_body = json.loads(current['body']['atlas_doc_format']['value'])
|
|
711
|
+
prev_body = json.loads(prev_resp.json()['body']['atlas_doc_format']['value'])
|
|
712
|
+
|
|
713
|
+
cur_md = adf_to_markdown(cur_body).splitlines()
|
|
714
|
+
prev_md = adf_to_markdown(prev_body).splitlines()
|
|
715
|
+
|
|
716
|
+
diff_lines = list(difflib.unified_diff(
|
|
717
|
+
prev_md, cur_md, fromfile=f'v{prev_ver}', tofile=f'v{cur_ver}', lineterm='',
|
|
718
|
+
))
|
|
719
|
+
|
|
720
|
+
if not is_json_mode():
|
|
721
|
+
print(f'{title} — v{prev_ver} → v{cur_ver} (by {author_id[:20]} at {created})')
|
|
722
|
+
print()
|
|
723
|
+
if diff_lines:
|
|
724
|
+
print('\n'.join(diff_lines))
|
|
725
|
+
else:
|
|
726
|
+
print('No content changes between versions')
|
|
727
|
+
else:
|
|
728
|
+
emit_json({
|
|
729
|
+
'page_id': page_id, 'title': title,
|
|
730
|
+
'from_version': prev_ver, 'to_version': cur_ver,
|
|
731
|
+
'author': author_id, 'date': created,
|
|
732
|
+
'diff': '\n'.join(diff_lines) if diff_lines else None,
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
# Approvals
|
|
738
|
+
# ---------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
def _get_current_user_id(session, base):
|
|
741
|
+
data = api_get(session, base, f'{V1}/user/current')
|
|
742
|
+
return data['accountId']
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _get_approval_property(session, base, page_id):
|
|
746
|
+
try:
|
|
747
|
+
return api_get(session, base, f'{V1}/content/{page_id}/property/approvals')
|
|
748
|
+
except APIError:
|
|
749
|
+
return None
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def cmd_approvals(args):
|
|
753
|
+
"""List pages pending your approval (CQL + expand, client-side filter)."""
|
|
754
|
+
session, base = setup()
|
|
755
|
+
my_id = _get_current_user_id(session, base)
|
|
756
|
+
|
|
757
|
+
cql = 'type=page AND content.property[approvals].allDone=0'
|
|
758
|
+
if args.spaces:
|
|
759
|
+
space_clause = ' OR '.join(f'space.key={s}' for s in args.spaces)
|
|
760
|
+
cql += f' AND ({space_clause})'
|
|
761
|
+
|
|
762
|
+
# Single CQL call with expanded approval properties — client-side filter by user.
|
|
763
|
+
# CQL can't query into arrays, so we filter the 'pending' list in Python.
|
|
764
|
+
pending = []
|
|
765
|
+
seen = set()
|
|
766
|
+
start = 0
|
|
767
|
+
while True:
|
|
768
|
+
resp = session.get(f'{base}{V1}/search', params={
|
|
769
|
+
'cql': cql, 'limit': 100, 'start': start, 'excerpt': 'none',
|
|
770
|
+
'expand': 'content.metadata.properties.approvals,content.space',
|
|
771
|
+
})
|
|
772
|
+
if not resp.ok:
|
|
773
|
+
raise APIError(resp.status_code, resp.text)
|
|
774
|
+
data = resp.json()
|
|
775
|
+
results = data.get('results', [])
|
|
776
|
+
if not results:
|
|
777
|
+
break
|
|
778
|
+
|
|
779
|
+
for r in results:
|
|
780
|
+
c = r.get('content', {})
|
|
781
|
+
page_id = c.get('id')
|
|
782
|
+
if not page_id or page_id in seen:
|
|
783
|
+
continue
|
|
784
|
+
seen.add(page_id)
|
|
785
|
+
value = c.get('metadata', {}).get('properties', {}).get('approvals', {}).get('value', {})
|
|
786
|
+
if my_id in value.get('pending', []):
|
|
787
|
+
pending.append({
|
|
788
|
+
'page_id': page_id,
|
|
789
|
+
'title': c.get('title', '?'),
|
|
790
|
+
'space': c.get('space', {}).get('key', '?'),
|
|
791
|
+
'status': value.get('name', {}).get('value', '?'),
|
|
792
|
+
})
|
|
793
|
+
|
|
794
|
+
total = data.get('totalSize', 0)
|
|
795
|
+
start += len(results)
|
|
796
|
+
if start >= total:
|
|
797
|
+
break
|
|
798
|
+
|
|
799
|
+
if is_json_mode():
|
|
800
|
+
emit_json(pending)
|
|
801
|
+
elif not pending:
|
|
802
|
+
emit('OK', 'No pending approvals')
|
|
803
|
+
else:
|
|
804
|
+
for p in pending:
|
|
805
|
+
print(f'{p["page_id"]} [{p["space"]}] {p["title"]} ({p["status"]})')
|
|
806
|
+
emit('DONE', f'{len(pending)} pending approval(s)')
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def cmd_approve(args):
|
|
810
|
+
"""Approve or reject a page."""
|
|
811
|
+
import time
|
|
812
|
+
session, base = setup()
|
|
813
|
+
my_id = _get_current_user_id(session, base)
|
|
814
|
+
page_id = args.page_id
|
|
815
|
+
reject = getattr(args, 'reject', False)
|
|
816
|
+
|
|
817
|
+
prop = _get_approval_property(session, base, page_id)
|
|
818
|
+
if not prop:
|
|
819
|
+
emit_error(f'No approval property on page {page_id}')
|
|
820
|
+
sys.exit(1)
|
|
821
|
+
|
|
822
|
+
value = prop['value']
|
|
823
|
+
version = prop['version']['number']
|
|
824
|
+
|
|
825
|
+
if my_id not in value.get('pending', []):
|
|
826
|
+
emit_error(f'You are not a pending approver on page {page_id}')
|
|
827
|
+
sys.exit(1)
|
|
828
|
+
|
|
829
|
+
now = int(time.time() * 1000)
|
|
830
|
+
status_code = 2 if reject else 1 # 0=pending, 1=approved, 2=rejected
|
|
831
|
+
target_list = 'rejected' if reject else 'completed'
|
|
832
|
+
|
|
833
|
+
for a in value['approvers']:
|
|
834
|
+
if a['approverid'] == my_id:
|
|
835
|
+
a['status'] = status_code
|
|
836
|
+
a['date'] = now
|
|
837
|
+
|
|
838
|
+
value['pending'] = [p for p in value['pending'] if p != my_id]
|
|
839
|
+
value[target_list].append(my_id)
|
|
840
|
+
|
|
841
|
+
total = len(value['approvers'])
|
|
842
|
+
done = len(value['completed'])
|
|
843
|
+
rejected = len(value['rejected'])
|
|
844
|
+
|
|
845
|
+
if value['pending']:
|
|
846
|
+
label = f'Pending ({done}/{total})'
|
|
847
|
+
tooltip = 'There are pending approvals.'
|
|
848
|
+
icon = '0'
|
|
849
|
+
elif rejected:
|
|
850
|
+
label = f'Rejected ({rejected}/{total})'
|
|
851
|
+
tooltip = 'Approval was rejected.'
|
|
852
|
+
icon = '2'
|
|
853
|
+
else:
|
|
854
|
+
label = f'Approved ({done}/{total})'
|
|
855
|
+
tooltip = 'All approvals are complete.'
|
|
856
|
+
icon = '1'
|
|
857
|
+
|
|
858
|
+
value['allDone'] = '0' if value['pending'] else '1'
|
|
859
|
+
value['name']['value'] = label
|
|
860
|
+
value['tooltip']['value'] = tooltip
|
|
861
|
+
value['icon']['url'] = f'/approvalmacro/images/{icon}.svg'
|
|
862
|
+
|
|
863
|
+
api_put(session, base, f'{V1}/content/{page_id}/property/approvals', {
|
|
864
|
+
'key': 'approvals',
|
|
865
|
+
'value': value,
|
|
866
|
+
'version': {'number': version + 1},
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
action = 'Rejected' if reject else 'Approved'
|
|
870
|
+
emit('OK', f'{action} page {page_id} ({label})')
|
|
871
|
+
|
|
872
|
+
|
|
680
873
|
# ---------------------------------------------------------------------------
|
|
681
874
|
# CLI
|
|
682
875
|
# ---------------------------------------------------------------------------
|
|
683
876
|
|
|
684
877
|
def main():
|
|
878
|
+
from atlassian_cli.update_check import check_for_update
|
|
879
|
+
check_for_update()
|
|
880
|
+
|
|
685
881
|
parser = argparse.ArgumentParser(
|
|
686
882
|
prog='confluence',
|
|
687
883
|
description='Confluence Cloud CLI — fast ADF page management',
|
|
@@ -757,6 +953,23 @@ def main():
|
|
|
757
953
|
p.add_argument('topic', nargs='?', help='Topic: macros, sections, editing, adf_basics (default: all)')
|
|
758
954
|
p.set_defaults(func=cmd_hints)
|
|
759
955
|
|
|
956
|
+
p = sub.add_parser('changes', help='Show what changed in the latest version of a page')
|
|
957
|
+
p.add_argument('page_id', help='Confluence page ID')
|
|
958
|
+
p.add_argument('--version', type=int, help='Compare against this version (default: previous)')
|
|
959
|
+
p.set_defaults(func=cmd_changes)
|
|
960
|
+
|
|
961
|
+
p = sub.add_parser('approvals', help='List pages pending your approval')
|
|
962
|
+
p.add_argument('--spaces', nargs='*', help='Space keys to search (default: COMPLY POL ICOMB)')
|
|
963
|
+
p.set_defaults(func=cmd_approvals)
|
|
964
|
+
|
|
965
|
+
p = sub.add_parser('approve', help='Approve a page')
|
|
966
|
+
p.add_argument('page_id', help='Confluence page ID')
|
|
967
|
+
p.set_defaults(func=cmd_approve, reject=False)
|
|
968
|
+
|
|
969
|
+
p = sub.add_parser('reject', help='Reject a page approval')
|
|
970
|
+
p.add_argument('page_id', help='Confluence page ID')
|
|
971
|
+
p.set_defaults(func=cmd_approve, reject=True)
|
|
972
|
+
|
|
760
973
|
args = parser.parse_args()
|
|
761
974
|
if args.json_output:
|
|
762
975
|
set_json_mode(True)
|
|
@@ -51,7 +51,9 @@ def api_get(session, base, path, **params):
|
|
|
51
51
|
|
|
52
52
|
def api_post(session, base, path, data):
|
|
53
53
|
response = _retry(session.post, f'{base}{path}', json=data)
|
|
54
|
-
if response.
|
|
54
|
+
if response.status_code == 204:
|
|
55
|
+
return None
|
|
56
|
+
elif response.ok:
|
|
55
57
|
return response.json()
|
|
56
58
|
raise APIError(response.status_code, response.text)
|
|
57
59
|
|
|
@@ -15,12 +15,18 @@ from atlassian_cli.output import emit_error, set_json_mode
|
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
def main():
|
|
18
|
+
from atlassian_cli.update_check import check_for_update
|
|
19
|
+
check_for_update()
|
|
20
|
+
|
|
21
|
+
# Pull --json out early so it works at any position in the command line.
|
|
22
|
+
if '--json' in sys.argv:
|
|
23
|
+
sys.argv.remove('--json')
|
|
24
|
+
set_json_mode(True)
|
|
25
|
+
|
|
18
26
|
parser = argparse.ArgumentParser(
|
|
19
27
|
prog='jira',
|
|
20
28
|
description='Jira CLI — issue and assets management',
|
|
21
29
|
)
|
|
22
|
-
parser.add_argument('--json', action='store_true', dest='json_output',
|
|
23
|
-
help='Output as JSON for programmatic parsing')
|
|
24
30
|
|
|
25
31
|
sub = parser.add_subparsers(dest='domain', required=True)
|
|
26
32
|
|
|
@@ -42,6 +48,8 @@ def main():
|
|
|
42
48
|
p.add_argument('--labels', nargs='*', help='Labels')
|
|
43
49
|
p.add_argument('--assignee', help='Assignee account ID')
|
|
44
50
|
p.add_argument('--parent', help='Parent issue key (for sub-tasks)')
|
|
51
|
+
p.add_argument('--fields', help='JSON string of additional fields '
|
|
52
|
+
'(e.g. components, custom fields, ADF description)')
|
|
45
53
|
p.set_defaults(func=jira_issues.cmd_create)
|
|
46
54
|
|
|
47
55
|
p = issue_sub.add_parser('update', help='Update issue fields')
|
|
@@ -135,15 +143,22 @@ def main():
|
|
|
135
143
|
p.add_argument('name', help='Type name')
|
|
136
144
|
p.add_argument('--description', help='Type description')
|
|
137
145
|
p.add_argument('--parent-type-id', help='Parent object type ID')
|
|
146
|
+
p.add_argument('--icon-id', default='115', help='Icon ID (default: 115 Software Box)')
|
|
138
147
|
p.set_defaults(func=jira_assets.cmd_type_create)
|
|
139
148
|
|
|
140
149
|
p = assets_sub.add_parser('attrs', help='List attributes for a type')
|
|
141
150
|
p.add_argument('type_id', help='Object type ID')
|
|
142
151
|
p.set_defaults(func=jira_assets.cmd_attrs)
|
|
143
152
|
|
|
153
|
+
p = assets_sub.add_parser('attr-create', help='Create attribute on object type')
|
|
154
|
+
p.add_argument('type_id', help='Object type ID')
|
|
155
|
+
p.add_argument('name', help='Attribute name')
|
|
156
|
+
p.add_argument('--type', default='text', help='Attribute type (text, boolean, integer, url, select)')
|
|
157
|
+
p.add_argument('--description', help='Attribute description')
|
|
158
|
+
p.add_argument('--default-value', help='Default value')
|
|
159
|
+
p.set_defaults(func=jira_assets.cmd_attr_create)
|
|
160
|
+
|
|
144
161
|
args = parser.parse_args()
|
|
145
|
-
if args.json_output:
|
|
146
|
-
set_json_mode(True)
|
|
147
162
|
try:
|
|
148
163
|
args.func(args)
|
|
149
164
|
except APIError as e:
|
|
@@ -160,7 +160,7 @@ def cmd_type(args):
|
|
|
160
160
|
def cmd_type_create(args):
|
|
161
161
|
session, _, ab = assets_setup()
|
|
162
162
|
schema_id = resolve_schema(session, ab, args.schema_id)
|
|
163
|
-
body = {'name': args.name, 'objectSchemaId': schema_id}
|
|
163
|
+
body = {'name': args.name, 'objectSchemaId': schema_id, 'iconId': args.icon_id}
|
|
164
164
|
if hasattr(args, 'description') and args.description:
|
|
165
165
|
body['description'] = args.description
|
|
166
166
|
if hasattr(args, 'parent_type_id') and args.parent_type_id:
|
|
@@ -180,3 +180,21 @@ def cmd_attrs(args):
|
|
|
180
180
|
type_name = type_name.get('name', '?')
|
|
181
181
|
print(f'{a["id"]} {a["name"]} ({type_name}) {req}'.rstrip())
|
|
182
182
|
emit('DONE', f'{len(attrs)} attributes')
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def cmd_attr_create(args):
|
|
186
|
+
session, _, ab = assets_setup()
|
|
187
|
+
# Default type mapping: 0=Text, 1=Boolean, 6=URL
|
|
188
|
+
type_map = {'text': 0, 'boolean': 1, 'integer': 2, 'float': 3, 'date': 4, 'url': 6, 'select': 7}
|
|
189
|
+
type_value = type_map.get(args.type.lower(), args.type) if not args.type.isdigit() else int(args.type)
|
|
190
|
+
body = {
|
|
191
|
+
'name': args.name,
|
|
192
|
+
'objectTypeId': args.type_id,
|
|
193
|
+
'type': type_value,
|
|
194
|
+
}
|
|
195
|
+
if args.description:
|
|
196
|
+
body['description'] = args.description
|
|
197
|
+
if args.default_value:
|
|
198
|
+
body['defaultTypeValue'] = args.default_value
|
|
199
|
+
data = api_post(session, ab, f'/objecttype/{args.type_id}/attributes', body)
|
|
200
|
+
emit('OK', f'Created attribute {data.get("id", "")} ({args.name})')
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
+
from atlassian_cli.adf import adf_to_markdown
|
|
6
7
|
from atlassian_cli.config import setup
|
|
7
8
|
from atlassian_cli.http import api_delete, api_get, api_post, api_put
|
|
8
|
-
from atlassian_cli.output import emit, emit_error
|
|
9
|
+
from atlassian_cli.output import emit, emit_error, emit_json, is_json_mode
|
|
9
10
|
|
|
10
11
|
V3 = '/rest/api/3'
|
|
11
12
|
|
|
@@ -33,10 +34,33 @@ def _text_adf(text):
|
|
|
33
34
|
def cmd_get(args):
|
|
34
35
|
session, base = setup()
|
|
35
36
|
data = api_get(session, base, f'{V3}/issue/{args.key}')
|
|
37
|
+
if is_json_mode():
|
|
38
|
+
# preserve the full API response so callers can read components,
|
|
39
|
+
# custom fields, finding type, etc.
|
|
40
|
+
emit_json(data)
|
|
41
|
+
return
|
|
42
|
+
|
|
36
43
|
fields = data.get('fields', {})
|
|
44
|
+
key = data['key']
|
|
37
45
|
status = fields.get('status', {}).get('name', '?')
|
|
38
46
|
summary = fields.get('summary', '')
|
|
39
|
-
|
|
47
|
+
issue_type = fields.get('issuetype', {}).get('name', '?')
|
|
48
|
+
assignee = fields.get('assignee', {})
|
|
49
|
+
assignee_name = assignee.get('displayName', 'Unassigned') if assignee else 'Unassigned'
|
|
50
|
+
labels = fields.get('labels', [])
|
|
51
|
+
priority = fields.get('priority', {}).get('name', '') if fields.get('priority') else ''
|
|
52
|
+
created = fields.get('created', '')[:10]
|
|
53
|
+
updated = fields.get('updated', '')[:10]
|
|
54
|
+
description = fields.get('description')
|
|
55
|
+
|
|
56
|
+
print(f'{key} [{status}] {summary}')
|
|
57
|
+
print(f' Type: {issue_type} Priority: {priority} Assignee: {assignee_name}')
|
|
58
|
+
if labels:
|
|
59
|
+
print(f' Labels: {", ".join(labels)}')
|
|
60
|
+
print(f' Created: {created} Updated: {updated}')
|
|
61
|
+
if description:
|
|
62
|
+
print()
|
|
63
|
+
print(adf_to_markdown(description))
|
|
40
64
|
|
|
41
65
|
|
|
42
66
|
def cmd_create(args):
|
|
@@ -54,6 +78,10 @@ def cmd_create(args):
|
|
|
54
78
|
fields['assignee'] = {'accountId': args.assignee}
|
|
55
79
|
if args.parent:
|
|
56
80
|
fields['parent'] = {'key': args.parent}
|
|
81
|
+
if getattr(args, 'fields', None):
|
|
82
|
+
# extra fields override the convenience flags above; useful for
|
|
83
|
+
# components, finding-type custom fields, full-ADF descriptions, etc.
|
|
84
|
+
fields.update(json.loads(args.fields))
|
|
57
85
|
|
|
58
86
|
result = api_post(session, base, f'{V3}/issue', {'fields': fields})
|
|
59
87
|
emit('OK', f'Created {result["key"]}', data=result)
|
|
@@ -118,7 +146,13 @@ def _search_page(session, base, jql, max_results, fields, next_page_token=None):
|
|
|
118
146
|
|
|
119
147
|
def cmd_search(args):
|
|
120
148
|
session, base = setup()
|
|
121
|
-
|
|
149
|
+
# In JSON mode, default to '*all' so callers see components and custom
|
|
150
|
+
# fields. The user can still narrow via an explicit --fields.
|
|
151
|
+
user_explicit_fields = args.fields != 'summary,status,assignee,issuetype'
|
|
152
|
+
if is_json_mode() and not user_explicit_fields:
|
|
153
|
+
fields = ['*all']
|
|
154
|
+
else:
|
|
155
|
+
fields = args.fields.split(',')
|
|
122
156
|
all_issues = []
|
|
123
157
|
token = None
|
|
124
158
|
|
|
@@ -134,19 +168,21 @@ def cmd_search(args):
|
|
|
134
168
|
if not args.all and len(all_issues) >= args.max:
|
|
135
169
|
break
|
|
136
170
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
171
|
+
if not is_json_mode():
|
|
172
|
+
for issue in all_issues:
|
|
173
|
+
f = issue.get('fields', {})
|
|
174
|
+
status = f.get('status', {}).get('name', '?')
|
|
175
|
+
summary = f.get('summary', '')
|
|
176
|
+
assignee = f.get('assignee', {})
|
|
177
|
+
assignee_name = assignee.get('displayName', '') if assignee else ''
|
|
178
|
+
extra = f' ({assignee_name})' if assignee_name else ''
|
|
179
|
+
print(f'{issue["key"]} [{status}] {summary}{extra}')
|
|
145
180
|
|
|
146
181
|
if args.dump:
|
|
147
182
|
with open(args.dump, 'w') as fh:
|
|
148
183
|
json.dump({'total': len(all_issues), 'issues': all_issues}, fh, indent=2)
|
|
149
|
-
|
|
184
|
+
if not is_json_mode():
|
|
185
|
+
print(f'Saved {len(all_issues)} issues to {args.dump}')
|
|
150
186
|
|
|
151
187
|
emit('DONE', f'{len(all_issues)} issues found',
|
|
152
188
|
data={'total': len(all_issues), 'issues': all_issues})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Check PyPI for newer versions of atlassian-cli (once per day)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from atlassian_cli import __version__
|
|
10
|
+
|
|
11
|
+
CACHE_DIR = os.path.join(os.path.expanduser('~'), '.cache', 'atlassian-cli')
|
|
12
|
+
CACHE_FILE = os.path.join(CACHE_DIR, 'update-check.json')
|
|
13
|
+
CHECK_INTERVAL = 86400 # 24 hours
|
|
14
|
+
PYPI_URL = 'https://pypi.org/pypi/atlassian-cli/json'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _version_tuple(v):
|
|
18
|
+
"""Parse version string to tuple for comparison."""
|
|
19
|
+
try:
|
|
20
|
+
return tuple(int(x) for x in v.split('.'))
|
|
21
|
+
except (ValueError, AttributeError):
|
|
22
|
+
return (0,)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_editable_install():
|
|
26
|
+
"""Check if the package is installed in editable/dev mode."""
|
|
27
|
+
try:
|
|
28
|
+
from importlib.metadata import distribution
|
|
29
|
+
dist = distribution('atlassian-cli')
|
|
30
|
+
# Editable installs have a direct_url.json with dir_info
|
|
31
|
+
direct_url = dist.read_text('direct_url.json')
|
|
32
|
+
if direct_url:
|
|
33
|
+
return json.loads(direct_url).get('dir_info', {}).get('editable', False)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_for_update():
|
|
40
|
+
"""Print a notice if a newer version is available. Runs silently on error."""
|
|
41
|
+
try:
|
|
42
|
+
if __version__ == '0.0.0-dev':
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
# Check cache
|
|
46
|
+
if os.path.exists(CACHE_FILE):
|
|
47
|
+
with open(CACHE_FILE) as f:
|
|
48
|
+
cache = json.load(f)
|
|
49
|
+
if time.time() - cache.get('checked_at', 0) < CHECK_INTERVAL:
|
|
50
|
+
latest = cache.get('latest', '')
|
|
51
|
+
if _version_tuple(latest) > _version_tuple(__version__):
|
|
52
|
+
_print_notice(latest)
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Fetch from PyPI (short timeout to avoid slowing CLI)
|
|
56
|
+
req = urllib.request.Request(PYPI_URL, headers={'Accept': 'application/json'})
|
|
57
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
58
|
+
data = json.loads(resp.read())
|
|
59
|
+
latest = data.get('info', {}).get('version', '')
|
|
60
|
+
|
|
61
|
+
# Cache result
|
|
62
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
63
|
+
with open(CACHE_FILE, 'w') as f:
|
|
64
|
+
json.dump({'checked_at': time.time(), 'latest': latest}, f)
|
|
65
|
+
|
|
66
|
+
if _version_tuple(latest) > _version_tuple(__version__):
|
|
67
|
+
_print_notice(latest)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass # never break the CLI for an update check
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _print_notice(latest):
|
|
73
|
+
if _is_editable_install():
|
|
74
|
+
hint = 'Run: git pull && pip install -e .'
|
|
75
|
+
else:
|
|
76
|
+
hint = 'Run: pip install --upgrade atlassian-cli'
|
|
77
|
+
print(f'\033[33mUpdate available: {__version__} → {latest}. {hint}\033[0m', file=sys.stderr)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: atlassian-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents
|
|
5
5
|
Author-email: Alex Fishlock <alex.fishlock@catapult.cx>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -61,17 +61,29 @@ pip install git+https://github.com/catapultcx/atlassian-cli.git
|
|
|
61
61
|
|
|
62
62
|
## Setup
|
|
63
63
|
|
|
64
|
-
Create a
|
|
64
|
+
Create a config file with your credentials:
|
|
65
65
|
|
|
66
66
|
```bash
|
|
67
|
+
mkdir -p ~/.config/atlassian-cli
|
|
68
|
+
cat > ~/.config/atlassian-cli/config <<EOF
|
|
67
69
|
ATLASSIAN_URL=https://your-site.atlassian.net
|
|
68
70
|
ATLASSIAN_EMAIL=you@example.com
|
|
69
71
|
ATLASSIAN_TOKEN=your-api-token
|
|
72
|
+
EOF
|
|
73
|
+
chmod 600 ~/.config/atlassian-cli/config
|
|
70
74
|
```
|
|
71
75
|
|
|
76
|
+
The CLI looks for credentials in this order (first match wins):
|
|
77
|
+
|
|
78
|
+
1. `$ATLASSIAN_CLI_CONFIG` (explicit path override)
|
|
79
|
+
2. `./.env` in the current working directory
|
|
80
|
+
3. `$XDG_CONFIG_HOME/atlassian-cli/config` (defaults to `~/.config/atlassian-cli/config`)
|
|
81
|
+
4. `~/.atlassian-cli/config`
|
|
82
|
+
5. Environment variables (`ATLASSIAN_URL`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN`)
|
|
83
|
+
|
|
72
84
|
Get your API token at https://id.atlassian.com/manage-profile/security/api-tokens
|
|
73
85
|
|
|
74
|
-
> Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN`
|
|
86
|
+
> Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN` are also supported.
|
|
75
87
|
|
|
76
88
|
## Confluence CLI
|
|
77
89
|
|
|
@@ -11,6 +11,7 @@ src/atlassian_cli/jira.py
|
|
|
11
11
|
src/atlassian_cli/jira_assets.py
|
|
12
12
|
src/atlassian_cli/jira_issues.py
|
|
13
13
|
src/atlassian_cli/output.py
|
|
14
|
+
src/atlassian_cli/update_check.py
|
|
14
15
|
src/atlassian_cli.egg-info/PKG-INFO
|
|
15
16
|
src/atlassian_cli.egg-info/SOURCES.txt
|
|
16
17
|
src/atlassian_cli.egg-info/dependency_links.txt
|
|
@@ -60,8 +60,47 @@ class TestGetConfig:
|
|
|
60
60
|
url, _, _ = get_config()
|
|
61
61
|
assert url == "https://test.atlassian.net"
|
|
62
62
|
|
|
63
|
+
def test_loads_from_xdg_config(self, monkeypatch, tmp_path):
|
|
64
|
+
xdg = tmp_path / "xdg"
|
|
65
|
+
cfg = xdg / "atlassian-cli" / "config"
|
|
66
|
+
cfg.parent.mkdir(parents=True)
|
|
67
|
+
cfg.write_text(
|
|
68
|
+
"ATLASSIAN_URL=https://xdg.atlassian.net\n"
|
|
69
|
+
"ATLASSIAN_EMAIL=xdg@test.com\n"
|
|
70
|
+
"ATLASSIAN_TOKEN=xdgtok\n"
|
|
71
|
+
)
|
|
72
|
+
monkeypatch.chdir(tmp_path)
|
|
73
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
74
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
|
|
75
|
+
monkeypatch.delenv("ATLASSIAN_CLI_CONFIG", raising=False)
|
|
76
|
+
url, email, token = get_config()
|
|
77
|
+
assert url == "https://xdg.atlassian.net"
|
|
78
|
+
assert email == "xdg@test.com"
|
|
79
|
+
assert token == "xdgtok"
|
|
80
|
+
|
|
81
|
+
def test_explicit_override_wins(self, monkeypatch, tmp_path):
|
|
82
|
+
override = tmp_path / "override.env"
|
|
83
|
+
override.write_text(
|
|
84
|
+
"ATLASSIAN_URL=https://override.atlassian.net\n"
|
|
85
|
+
"ATLASSIAN_EMAIL=o@t.com\n"
|
|
86
|
+
"ATLASSIAN_TOKEN=otok\n"
|
|
87
|
+
)
|
|
88
|
+
# cwd has a .env that should be ignored when override is set
|
|
89
|
+
(tmp_path / ".env").write_text(
|
|
90
|
+
"ATLASSIAN_URL=https://cwd.atlassian.net\n"
|
|
91
|
+
"ATLASSIAN_EMAIL=c@t.com\n"
|
|
92
|
+
"ATLASSIAN_TOKEN=ctok\n"
|
|
93
|
+
)
|
|
94
|
+
monkeypatch.chdir(tmp_path)
|
|
95
|
+
monkeypatch.setenv("ATLASSIAN_CLI_CONFIG", str(override))
|
|
96
|
+
url, _, _ = get_config()
|
|
97
|
+
assert url == "https://override.atlassian.net"
|
|
98
|
+
|
|
63
99
|
def test_exits_when_missing(self, monkeypatch, tmp_path):
|
|
64
100
|
monkeypatch.chdir(tmp_path)
|
|
101
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
102
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
103
|
+
monkeypatch.delenv("ATLASSIAN_CLI_CONFIG", raising=False)
|
|
65
104
|
monkeypatch.delenv("ATLASSIAN_URL", raising=False)
|
|
66
105
|
monkeypatch.delenv("ATLASSIAN_EMAIL", raising=False)
|
|
67
106
|
monkeypatch.delenv("ATLASSIAN_TOKEN", raising=False)
|
|
@@ -252,7 +252,7 @@ class TestCmdTypeCreate:
|
|
|
252
252
|
responses.POST, f"{ASSETS_BASE}/objecttype/create",
|
|
253
253
|
json={"id": "10", "name": "Laptop"},
|
|
254
254
|
)
|
|
255
|
-
cmd_type_create(Namespace(schema_id="1", name="Laptop", description=None, parent_type_id=None))
|
|
255
|
+
cmd_type_create(Namespace(schema_id="1", name="Laptop", description=None, parent_type_id=None, icon_id='115'))
|
|
256
256
|
assert "Laptop" in capsys.readouterr().out
|
|
257
257
|
|
|
258
258
|
@responses.activate
|
|
@@ -265,7 +265,9 @@ class TestCmdTypeCreate:
|
|
|
265
265
|
responses.POST, f"{ASSETS_BASE}/objecttype/create",
|
|
266
266
|
json={"id": "10", "name": "Laptop"},
|
|
267
267
|
)
|
|
268
|
-
cmd_type_create(Namespace(
|
|
268
|
+
cmd_type_create(Namespace(
|
|
269
|
+
schema_id="IT Assets", name="Laptop", description=None, parent_type_id=None, icon_id='115',
|
|
270
|
+
))
|
|
269
271
|
body = json.loads(responses.calls[1].request.body)
|
|
270
272
|
assert body["objectSchemaId"] == "1"
|
|
271
273
|
|
|
@@ -278,6 +280,7 @@ class TestCmdTypeCreate:
|
|
|
278
280
|
cmd_type_create(Namespace(
|
|
279
281
|
schema_id="1", name="Desktop",
|
|
280
282
|
description="Desktop computers", parent_type_id="5",
|
|
283
|
+
icon_id='115',
|
|
281
284
|
))
|
|
282
285
|
body = json.loads(responses.calls[0].request.body)
|
|
283
286
|
assert body["description"] == "Desktop computers"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|