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.
Files changed (30) hide show
  1. {atlassian_cli-0.5.9/src/atlassian_cli.egg-info → atlassian_cli-0.7.0}/PKG-INFO +15 -3
  2. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/README.md +14 -2
  3. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/pyproject.toml +1 -1
  4. atlassian_cli-0.7.0/src/atlassian_cli/__init__.py +7 -0
  5. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/config.py +30 -8
  6. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/confluence.py +214 -1
  7. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/http.py +3 -1
  8. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/jira.py +19 -4
  9. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/jira_assets.py +19 -1
  10. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/jira_issues.py +48 -12
  11. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/output.py +3 -0
  12. atlassian_cli-0.7.0/src/atlassian_cli/update_check.py +77 -0
  13. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0/src/atlassian_cli.egg-info}/PKG-INFO +15 -3
  14. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/SOURCES.txt +1 -0
  15. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_config.py +39 -0
  16. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_jira_assets.py +5 -2
  17. atlassian_cli-0.5.9/src/atlassian_cli/__init__.py +0 -3
  18. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/LICENSE +0 -0
  19. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/setup.cfg +0 -0
  20. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/adf.py +0 -0
  21. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli/hints.py +0 -0
  22. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
  23. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
  24. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/requires.txt +0 -0
  25. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/src/atlassian_cli.egg-info/top_level.txt +0 -0
  26. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_adf.py +0 -0
  27. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_confluence.py +0 -0
  28. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_http.py +0 -0
  29. {atlassian_cli-0.5.9 → atlassian_cli-0.7.0}/tests/test_jira_issues.py +0 -0
  30. {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.5.9
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 `.env` file (or export environment variables):
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` env vars are also supported.
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 `.env` file (or export environment variables):
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` env vars are also supported.
55
+ > Legacy `CONFLUENCE_URL` / `CONFLUENCE_EMAIL` / `CONFLUENCE_TOKEN` are also supported.
44
56
 
45
57
  ## Confluence CLI
46
58
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "atlassian-cli"
7
- version = "0.5.9"
7
+ version = "0.7.0"
8
8
  description = "Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,7 @@
1
+ """Atlassian Cloud CLI tools for Confluence and Jira."""
2
+
3
+ try:
4
+ from importlib.metadata import version
5
+ __version__ = version('atlassian-cli')
6
+ except Exception:
7
+ __version__ = '0.0.0-dev'
@@ -7,17 +7,39 @@ import requests
7
7
  from requests.auth import HTTPBasicAuth
8
8
 
9
9
 
10
- def load_env(path=None):
11
- """Manually parse a .env file into a dict, skipping comments and blank lines."""
12
- if path is None:
13
- paths = [os.path.join(os.getcwd(), '.env'), os.path.join(os.path.dirname(__file__), '.env')]
14
- else:
15
- paths = [path]
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(line.strip().split('=', 1) for line in file if not line.startswith('#') and '=' in line)
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.ok:
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
- emit('OK', f'{data["key"]} [{status}] {summary}', data={'key': data['key'], 'id': data['id']})
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
- fields = args.fields.split(',')
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
- for issue in all_issues:
138
- f = issue.get('fields', {})
139
- status = f.get('status', {}).get('name', '?')
140
- summary = f.get('summary', '')
141
- assignee = f.get('assignee', {})
142
- assignee_name = assignee.get('displayName', '') if assignee else ''
143
- extra = f' ({assignee_name})' if assignee_name else ''
144
- print(f'{issue["key"]} [{status}] {summary}{extra}')
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
- print(f'Saved {len(all_issues)} issues to {args.dump}')
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})
@@ -9,6 +9,9 @@ def set_json_mode(enabled: bool):
9
9
  global _json_mode
10
10
  _json_mode = enabled
11
11
 
12
+ def is_json_mode():
13
+ return _json_mode
14
+
12
15
  def emit(prefix, message, data=None):
13
16
  if _json_mode:
14
17
  output_data = {'status': prefix.lower(), 'message': message}
@@ -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.5.9
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 `.env` file (or export environment variables):
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` env vars are also supported.
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(schema_id="IT Assets", name="Laptop", description=None, parent_type_id=None))
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"
@@ -1,3 +0,0 @@
1
- """Atlassian Cloud CLI tools for Confluence and Jira."""
2
-
3
- __version__ = '0.3.0'
File without changes
File without changes