atlassian-cli 0.10.4__tar.gz → 0.11.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 (29) hide show
  1. {atlassian_cli-0.10.4/src/atlassian_cli.egg-info → atlassian_cli-0.11.0}/PKG-INFO +12 -2
  2. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/README.md +11 -1
  3. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/pyproject.toml +1 -1
  4. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/confluence.py +87 -9
  5. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0/src/atlassian_cli.egg-info}/PKG-INFO +12 -2
  6. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/tests/test_confluence.py +155 -1
  7. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/LICENSE +0 -0
  8. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/setup.cfg +0 -0
  9. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/__init__.py +0 -0
  10. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/adf.py +0 -0
  11. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/config.py +0 -0
  12. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/hints.py +0 -0
  13. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/http.py +0 -0
  14. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/jira.py +0 -0
  15. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/jira_assets.py +0 -0
  16. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/jira_issues.py +0 -0
  17. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/output.py +0 -0
  18. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli/update_check.py +0 -0
  19. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
  20. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
  21. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
  22. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli.egg-info/requires.txt +0 -0
  23. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/src/atlassian_cli.egg-info/top_level.txt +0 -0
  24. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/tests/test_adf.py +0 -0
  25. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/tests/test_config.py +0 -0
  26. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/tests/test_http.py +0 -0
  27. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/tests/test_jira_assets.py +0 -0
  28. {atlassian_cli-0.10.4 → atlassian_cli-0.11.0}/tests/test_jira_issues.py +0 -0
  29. {atlassian_cli-0.10.4 → atlassian_cli-0.11.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.10.4
3
+ Version: 0.11.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
@@ -104,9 +104,19 @@ confluence diff 9268920323
104
104
  confluence sync POL
105
105
  confluence sync COMPLY --workers 20 --force
106
106
 
107
- # Search local page index (instant, no API call)
107
+ # Bulk-render pages as markdown (instead of ADF JSON) — handy for AI ingestion
108
+ confluence sync POL --md # writes pages/POL/<id>.md
109
+ confluence get 9268920323 --md # single page, as markdown
110
+
111
+ # Search local page index by title (instant, no API call)
108
112
  confluence search "risk assessment"
109
113
 
114
+ # Content-level search via Confluence's CQL (hits the live API)
115
+ confluence cql 'text ~ "risk register"'
116
+ confluence cql 'text ~ "risk"' --space POL --limit 10
117
+ confluence cql 'text ~ "risk"' --md # markdown bullet list with deep links
118
+ confluence --json cql 'text ~ "risk"' # JSON array
119
+
110
120
  # Rebuild the page index
111
121
  confluence index
112
122
  confluence index --space POL --space COMPLY
@@ -73,9 +73,19 @@ confluence diff 9268920323
73
73
  confluence sync POL
74
74
  confluence sync COMPLY --workers 20 --force
75
75
 
76
- # Search local page index (instant, no API call)
76
+ # Bulk-render pages as markdown (instead of ADF JSON) — handy for AI ingestion
77
+ confluence sync POL --md # writes pages/POL/<id>.md
78
+ confluence get 9268920323 --md # single page, as markdown
79
+
80
+ # Search local page index by title (instant, no API call)
77
81
  confluence search "risk assessment"
78
82
 
83
+ # Content-level search via Confluence's CQL (hits the live API)
84
+ confluence cql 'text ~ "risk register"'
85
+ confluence cql 'text ~ "risk"' --space POL --limit 10
86
+ confluence cql 'text ~ "risk"' --md # markdown bullet list with deep links
87
+ confluence --json cql 'text ~ "risk"' # JSON array
88
+
79
89
  # Rebuild the page index
80
90
  confluence index
81
91
  confluence index --space POL --space COMPLY
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "atlassian-cli"
7
- version = "0.10.4"
7
+ version = "0.11.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"
@@ -251,15 +251,29 @@ def _ver_ts(page):
251
251
  return v.get('createdAt', '') if isinstance(v, dict) else ''
252
252
 
253
253
 
254
- def save_page(page_data, space_key, pages_dir):
254
+ def save_page(page_data, space_key, pages_dir, *, output_format='adf'):
255
+ """Save a fetched page to ``pages_dir/<SPACE>/``.
256
+
257
+ output_format:
258
+ - 'adf' (default): write <id>.json containing the ADF body.
259
+ - 'md': write <id>.md containing the markdown rendering.
260
+ The <id>.meta.json sidecar is always written.
261
+ """
255
262
  page_id = page_data['id']
256
263
  space_dir = os.path.join(pages_dir, space_key)
257
264
  os.makedirs(space_dir, exist_ok=True)
258
265
 
259
266
  body = page_data.get('body', {}).get('atlas_doc_format', {}).get('value', {})
260
- adf_path = os.path.join(space_dir, f'{page_id}.json')
261
- with open(adf_path, 'w') as f:
262
- json.dump(body, f, indent=2)
267
+
268
+ if output_format == 'md':
269
+ from atlassian_cli.adf import adf_to_markdown
270
+ body_path = os.path.join(space_dir, f'{page_id}.md')
271
+ with open(body_path, 'w') as f:
272
+ f.write(adf_to_markdown(body))
273
+ else:
274
+ body_path = os.path.join(space_dir, f'{page_id}.json')
275
+ with open(body_path, 'w') as f:
276
+ json.dump(body, f, indent=2)
263
277
 
264
278
  meta = {
265
279
  'id': page_id,
@@ -274,7 +288,7 @@ def save_page(page_data, space_key, pages_dir):
274
288
  with open(meta_path, 'w') as f:
275
289
  json.dump(meta, f, indent=2)
276
290
 
277
- return adf_path, meta_path
291
+ return body_path, meta_path
278
292
 
279
293
 
280
294
  def _find_page_file(page_id, pages_dir, suffix):
@@ -312,8 +326,9 @@ def cmd_get(args):
312
326
  page = get_page(session, base, args.page_id)
313
327
  space = get_space(session, base, space_id=page['spaceId'])
314
328
  space_key = space.get('key', str(page['spaceId']))
315
- adf_path, _ = save_page(page, space_key, args.dir)
316
- emit('OK', f'{page["title"]} (v{_ver(page)}) -> {adf_path}')
329
+ output_format = 'md' if getattr(args, 'md', False) else 'adf'
330
+ body_path, _ = save_page(page, space_key, args.dir, output_format=output_format)
331
+ emit('OK', f'{page["title"]} (v{_ver(page)}) -> {body_path}')
317
332
 
318
333
 
319
334
  def cmd_create(args):
@@ -709,12 +724,14 @@ def cmd_sync(args):
709
724
 
710
725
  to_fetch = []
711
726
  skipped = 0
727
+ body_suffix = '.md' if getattr(args, 'md', False) else '.json'
712
728
  for page in pages:
713
729
  page_id = page['id']
714
730
  remote_ver = _ver(page)
715
731
  if not args.force:
716
732
  meta = load_meta(page_id, args.dir)
717
- if meta and meta.get('version', 0) >= remote_ver:
733
+ body_file = os.path.join(args.dir, space_key, f'{page_id}{body_suffix}')
734
+ if meta and meta.get('version', 0) >= remote_ver and os.path.isfile(body_file):
718
735
  skipped += 1
719
736
  continue
720
737
  to_fetch.append(page)
@@ -729,13 +746,14 @@ def cmd_sync(args):
729
746
  print(f'Fetching {len(to_fetch)} pages ({args.workers} workers)…', file=sys.stderr)
730
747
 
731
748
  errors = 0
749
+ output_format = 'md' if getattr(args, 'md', False) else 'adf'
732
750
 
733
751
  def fetch_one(page):
734
752
  nonlocal errors
735
753
  page_id = page['id']
736
754
  try:
737
755
  full_page = get_page(session, base, page_id)
738
- save_page(full_page, space_key, args.dir)
756
+ save_page(full_page, space_key, args.dir, output_format=output_format)
739
757
  return f'GET {page_id} {full_page.get("title", "")} (v{_ver(full_page)})'
740
758
  except Exception as e:
741
759
  errors += 1
@@ -749,6 +767,57 @@ def cmd_sync(args):
749
767
  emit('DONE', f'{space_key}: {len(to_fetch)} fetched, {skipped} skipped, {errors} errors')
750
768
 
751
769
 
770
+ def cmd_cql(args):
771
+ """Run a CQL query against the Confluence Search API.
772
+
773
+ Output modes:
774
+ - default: plain-text table (id [space] title)
775
+ - global --json: JSON array of {id, title, space, url, excerpt}
776
+ - --md: markdown bullet list with deep links
777
+ """
778
+ session, base = setup()
779
+ query = args.query
780
+ if args.space:
781
+ query = f'space = "{args.space}" AND ({query})'
782
+ data = api_get(session, base, f'{V1}/search', cql=query, limit=args.limit)
783
+ raw = data.get('results', [])
784
+
785
+ results = []
786
+ for r in raw:
787
+ content = r.get('content') or {}
788
+ space_key = ''
789
+ if isinstance(content.get('space'), dict):
790
+ space_key = content['space'].get('key', '')
791
+ results.append({
792
+ 'id': content.get('id', ''),
793
+ 'title': r.get('title') or content.get('title', ''),
794
+ 'space': space_key,
795
+ 'url': r.get('url', ''),
796
+ 'excerpt': r.get('excerpt', ''),
797
+ })
798
+
799
+ if getattr(args, 'md', False):
800
+ if not results:
801
+ print('No results.', file=sys.stderr)
802
+ else:
803
+ for r in results:
804
+ title = r['title'] or '(untitled)'
805
+ space = r['space'] or '?'
806
+ page_id = r['id']
807
+ print(f'- [{title}]({r["url"]}) — {space} ({page_id})')
808
+ return
809
+
810
+ if is_json_mode():
811
+ print(json.dumps(results, indent=2))
812
+ return
813
+
814
+ if not results:
815
+ print('No results.', file=sys.stderr)
816
+ return
817
+ for r in results:
818
+ print(f'{r["id"]} [{r["space"] or "?"}] {r["title"]}')
819
+
820
+
752
821
  def cmd_search(args):
753
822
  if not os.path.isfile(args.index):
754
823
  emit_error(f'Index not found: {args.index}')
@@ -1297,6 +1366,7 @@ def main():
1297
1366
  p = sub.add_parser('get', help='Download a page (ADF + metadata)')
1298
1367
  p.add_argument('page_id', help='Confluence page ID')
1299
1368
  p.add_argument('--dir', default='pages', help='Output directory (default: pages)')
1369
+ p.add_argument('--md', action='store_true', help='Save as markdown (<id>.md) instead of ADF JSON')
1300
1370
  p.set_defaults(func=cmd_get)
1301
1371
 
1302
1372
  p = sub.add_parser('create', help='Create a new page')
@@ -1389,8 +1459,16 @@ def main():
1389
1459
  p.add_argument('--dir', default='pages', help='Output directory (default: pages)')
1390
1460
  p.add_argument('--workers', type=int, default=10, help='Parallel workers (default: 10)')
1391
1461
  p.add_argument('--force', action='store_true', help='Re-download all, ignore cache')
1462
+ p.add_argument('--md', action='store_true', help='Render each page as markdown (<id>.md) instead of ADF JSON')
1392
1463
  p.set_defaults(func=cmd_sync)
1393
1464
 
1465
+ p = sub.add_parser('cql', help='Content-level search using CQL (Confluence Query Language)')
1466
+ p.add_argument('query', help='CQL query string, e.g. \'text ~ "risk register"\'')
1467
+ p.add_argument('--space', help='Restrict to a space (prepends space = "<KEY>" AND ... to the query)')
1468
+ p.add_argument('--limit', type=int, default=25, help='Max results (default: 25)')
1469
+ p.add_argument('--md', action='store_true', help='Markdown bullet-list output instead of plain text')
1470
+ p.set_defaults(func=cmd_cql)
1471
+
1394
1472
  p = sub.add_parser('search', help='Search local page index')
1395
1473
  p.add_argument('query', help='Search term (title or ID)')
1396
1474
  p.add_argument('--index', default='page-index.json', help='Index file path')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlassian-cli
3
- Version: 0.10.4
3
+ Version: 0.11.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
@@ -104,9 +104,19 @@ confluence diff 9268920323
104
104
  confluence sync POL
105
105
  confluence sync COMPLY --workers 20 --force
106
106
 
107
- # Search local page index (instant, no API call)
107
+ # Bulk-render pages as markdown (instead of ADF JSON) — handy for AI ingestion
108
+ confluence sync POL --md # writes pages/POL/<id>.md
109
+ confluence get 9268920323 --md # single page, as markdown
110
+
111
+ # Search local page index by title (instant, no API call)
108
112
  confluence search "risk assessment"
109
113
 
114
+ # Content-level search via Confluence's CQL (hits the live API)
115
+ confluence cql 'text ~ "risk register"'
116
+ confluence cql 'text ~ "risk"' --space POL --limit 10
117
+ confluence cql 'text ~ "risk"' --md # markdown bullet list with deep links
118
+ confluence --json cql 'text ~ "risk"' # JSON array
119
+
110
120
  # Rebuild the page index
111
121
  confluence index
112
122
  confluence index --space POL --space COMPLY
@@ -17,10 +17,12 @@ from atlassian_cli.confluence import (
17
17
  cmd_blog_get,
18
18
  cmd_blog_list,
19
19
  cmd_blog_update,
20
+ cmd_cql,
20
21
  cmd_diff,
21
22
  cmd_get,
22
23
  cmd_index,
23
24
  cmd_search,
25
+ cmd_sync,
24
26
  create_blogpost,
25
27
  get_blogpost,
26
28
  get_page,
@@ -39,6 +41,7 @@ from atlassian_cli.confluence import (
39
41
  from atlassian_cli.output import set_json_mode
40
42
 
41
43
  BASE = "https://test.atlassian.net"
44
+ V1 = "/wiki/rest/api"
42
45
  V2 = "/wiki/api/v2"
43
46
 
44
47
  SAMPLE_PAGE = {
@@ -165,6 +168,19 @@ class TestSavePage:
165
168
  assert meta["title"] == "Test Page"
166
169
  assert meta["version"] == 3
167
170
 
171
+ def test_md_format_writes_md_only(self, tmp_path):
172
+ body_path, meta_path = save_page(SAMPLE_PAGE, "TEST", str(tmp_path), output_format="md")
173
+ assert body_path.endswith(".md")
174
+ assert os.path.isfile(body_path)
175
+ assert os.path.isfile(meta_path)
176
+
177
+ with open(body_path) as f:
178
+ md = f.read()
179
+ assert "Hello" in md
180
+
181
+ # No ADF JSON file when --md
182
+ assert not os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.json"))
183
+
168
184
 
169
185
  class TestLoadMeta:
170
186
  def test_loads_existing(self, tmp_path):
@@ -194,11 +210,22 @@ class TestCmdGet:
194
210
  responses.GET, f"{BASE}{V2}/spaces/100",
195
211
  json=SAMPLE_SPACE,
196
212
  )
197
- cmd_get(Namespace(page_id="12345", dir=str(tmp_path)))
213
+ cmd_get(Namespace(page_id="12345", dir=str(tmp_path), md=False))
198
214
  out = capsys.readouterr().out
199
215
  assert "Test Page" in out
200
216
  assert os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.json"))
201
217
 
218
+ @responses.activate
219
+ def test_downloads_page_as_md(self, capsys, tmp_path):
220
+ responses.add(responses.GET, f"{BASE}{V2}/pages/12345", json=SAMPLE_PAGE)
221
+ responses.add(
222
+ responses.GET, f"{BASE}{V2}/spaces/100",
223
+ json=SAMPLE_SPACE,
224
+ )
225
+ cmd_get(Namespace(page_id="12345", dir=str(tmp_path), md=True))
226
+ assert os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.md"))
227
+ assert not os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.json"))
228
+
202
229
 
203
230
  class TestCmdDiff:
204
231
  @responses.activate
@@ -589,3 +616,130 @@ class TestUpdateBlogpost:
589
616
  title="New Title", body="New body")
590
617
  assert title == "New Title"
591
618
  assert ver == 2
619
+
620
+
621
+ class TestCmdSync:
622
+ @responses.activate
623
+ def test_renders_pages_as_md(self, capsys, tmp_path):
624
+ # Space lookup
625
+ responses.add(
626
+ responses.GET, f"{BASE}{V2}/spaces",
627
+ json={"results": [SAMPLE_SPACE]},
628
+ )
629
+ # Page list
630
+ responses.add(
631
+ responses.GET, f"{BASE}{V2}/spaces/100/pages",
632
+ json={"results": [{"id": "12345", "version": {"number": 3}}], "_links": {}},
633
+ )
634
+ # Page fetch
635
+ responses.add(responses.GET, f"{BASE}{V2}/pages/12345", json=SAMPLE_PAGE)
636
+
637
+ cmd_sync(Namespace(
638
+ space_key="TEST",
639
+ dir=str(tmp_path),
640
+ workers=1,
641
+ force=True,
642
+ md=True,
643
+ ))
644
+
645
+ assert os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.md"))
646
+ assert not os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.json"))
647
+
648
+
649
+ SAMPLE_CQL_RESPONSE = {
650
+ "results": [
651
+ {
652
+ "content": {
653
+ "id": "12345",
654
+ "type": "page",
655
+ "title": "Risk Policy",
656
+ "space": {"key": "POL"},
657
+ },
658
+ "title": "Risk Policy",
659
+ "url": "/spaces/POL/pages/12345",
660
+ "excerpt": "Risks are managed via …",
661
+ },
662
+ {
663
+ "content": {
664
+ "id": "67890",
665
+ "type": "page",
666
+ "title": "Risk Register",
667
+ "space": {"key": "COMPLY"},
668
+ },
669
+ "title": "Risk Register",
670
+ "url": "/spaces/COMPLY/pages/67890",
671
+ "excerpt": "Open risks: …",
672
+ },
673
+ ],
674
+ "totalSize": 2,
675
+ }
676
+
677
+
678
+ class TestCmdCql:
679
+ @responses.activate
680
+ def test_text_output(self, capsys):
681
+ responses.add(
682
+ responses.GET, f"{BASE}{V1}/search",
683
+ json=SAMPLE_CQL_RESPONSE,
684
+ )
685
+ cmd_cql(Namespace(query='text ~ "risk"', space=None, limit=25, md=False))
686
+ out = capsys.readouterr().out
687
+ assert "Risk Policy" in out
688
+ assert "12345" in out
689
+ assert "POL" in out
690
+
691
+ @responses.activate
692
+ def test_json_output(self, capsys):
693
+ from atlassian_cli.output import set_json_mode
694
+ responses.add(
695
+ responses.GET, f"{BASE}{V1}/search",
696
+ json=SAMPLE_CQL_RESPONSE,
697
+ )
698
+ set_json_mode(True)
699
+ try:
700
+ cmd_cql(Namespace(query='text ~ "risk"', space=None, limit=25, md=False))
701
+ finally:
702
+ set_json_mode(False)
703
+ out = capsys.readouterr().out
704
+ parsed = json.loads(out)
705
+ assert isinstance(parsed, list)
706
+ assert parsed[0]["id"] == "12345"
707
+ assert parsed[0]["space"] == "POL"
708
+ assert parsed[0]["title"] == "Risk Policy"
709
+
710
+ @responses.activate
711
+ def test_md_output(self, capsys):
712
+ responses.add(
713
+ responses.GET, f"{BASE}{V1}/search",
714
+ json=SAMPLE_CQL_RESPONSE,
715
+ )
716
+ cmd_cql(Namespace(query='text ~ "risk"', space=None, limit=25, md=True))
717
+ out = capsys.readouterr().out
718
+ assert "Risk Policy" in out
719
+ assert "- " in out
720
+ assert "12345" in out
721
+
722
+ @responses.activate
723
+ def test_space_filter_prepends_clause(self):
724
+ captured = {}
725
+
726
+ def callback(request):
727
+ captured["url"] = request.url
728
+ return (200, {}, json.dumps(SAMPLE_CQL_RESPONSE))
729
+
730
+ responses.add_callback(
731
+ responses.GET, f"{BASE}{V1}/search", callback=callback,
732
+ )
733
+ cmd_cql(Namespace(query='text ~ "risk"', space="POL", limit=25, md=False))
734
+ # The space clause should be present in the URL-encoded CQL
735
+ assert 'space+%3D+%22POL%22' in captured["url"] or 'space%3D%22POL%22' in captured["url"]
736
+
737
+ @responses.activate
738
+ def test_no_results(self, capsys):
739
+ responses.add(
740
+ responses.GET, f"{BASE}{V1}/search",
741
+ json={"results": [], "totalSize": 0},
742
+ )
743
+ cmd_cql(Namespace(query='text ~ "nothing"', space=None, limit=25, md=False))
744
+ err = capsys.readouterr().err
745
+ assert "No results" in err
File without changes
File without changes