atlassian-cli 0.10.4__tar.gz → 0.12.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.12.0}/PKG-INFO +19 -2
  2. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/README.md +18 -1
  3. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/pyproject.toml +1 -1
  4. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/confluence.py +103 -32
  5. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/jira_issues.py +13 -6
  6. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0/src/atlassian_cli.egg-info}/PKG-INFO +19 -2
  7. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_confluence.py +169 -1
  8. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_jira_issues.py +16 -0
  9. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/LICENSE +0 -0
  10. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/setup.cfg +0 -0
  11. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/__init__.py +0 -0
  12. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/adf.py +0 -0
  13. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/config.py +0 -0
  14. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/hints.py +0 -0
  15. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/http.py +0 -0
  16. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/jira.py +0 -0
  17. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/jira_assets.py +0 -0
  18. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/output.py +0 -0
  19. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/update_check.py +0 -0
  20. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
  21. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
  22. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
  23. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/requires.txt +0 -0
  24. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/top_level.txt +0 -0
  25. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_adf.py +0 -0
  26. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_config.py +0 -0
  27. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_http.py +0 -0
  28. {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_jira_assets.py +0 -0
  29. {atlassian_cli-0.10.4 → atlassian_cli-0.12.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.12.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,26 @@ 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
+ # `--body` accepts markdown — headings, lists, tables, code blocks, links,
112
+ # emphasis. Use `--file` for raw ADF when you need macros / panel boxes.
113
+ confluence create POL "Quarterly update" --body "# Q2 Highlights
114
+
115
+ - Shipped X
116
+ - Improved Y"
117
+
118
+ # Search local page index by title (instant, no API call)
108
119
  confluence search "risk assessment"
109
120
 
121
+ # Content-level search via Confluence's CQL (hits the live API)
122
+ confluence cql 'text ~ "risk register"'
123
+ confluence cql 'text ~ "risk"' --space POL --limit 10
124
+ confluence cql 'text ~ "risk"' --md # markdown bullet list with deep links
125
+ confluence --json cql 'text ~ "risk"' # JSON array
126
+
110
127
  # Rebuild the page index
111
128
  confluence index
112
129
  confluence index --space POL --space COMPLY
@@ -73,9 +73,26 @@ 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
+ # `--body` accepts markdown — headings, lists, tables, code blocks, links,
81
+ # emphasis. Use `--file` for raw ADF when you need macros / panel boxes.
82
+ confluence create POL "Quarterly update" --body "# Q2 Highlights
83
+
84
+ - Shipped X
85
+ - Improved Y"
86
+
87
+ # Search local page index by title (instant, no API call)
77
88
  confluence search "risk assessment"
78
89
 
90
+ # Content-level search via Confluence's CQL (hits the live API)
91
+ confluence cql 'text ~ "risk register"'
92
+ confluence cql 'text ~ "risk"' --space POL --limit 10
93
+ confluence cql 'text ~ "risk"' --md # markdown bullet list with deep links
94
+ confluence --json cql 'text ~ "risk"' # JSON array
95
+
79
96
  # Rebuild the page index
80
97
  confluence index
81
98
  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.12.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"
@@ -131,11 +131,7 @@ def create_blogpost(session, base, space_id, title, *, body=None, file=None,
131
131
  elif body:
132
132
  body_payload = {
133
133
  'representation': 'atlas_doc_format',
134
- 'value': json.dumps({
135
- 'type': 'doc', 'version': 1,
136
- 'content': [{'type': 'paragraph',
137
- 'content': [{'type': 'text', 'text': body}]}],
138
- }),
134
+ 'value': json.dumps(_make_adf_body(body)),
139
135
  }
140
136
 
141
137
  payload = {
@@ -184,11 +180,7 @@ def update_blogpost(session, base, blogpost_id, *,
184
180
  elif body is not None:
185
181
  body_payload = {
186
182
  'representation': 'atlas_doc_format',
187
- 'value': json.dumps({
188
- 'type': 'doc', 'version': 1,
189
- 'content': [{'type': 'paragraph',
190
- 'content': [{'type': 'text', 'text': body}]}],
191
- }),
183
+ 'value': json.dumps(_make_adf_body(body)),
192
184
  }
193
185
  else:
194
186
  body_value = remote.get('body', {}).get('atlas_doc_format', {}).get('value')
@@ -251,15 +243,29 @@ def _ver_ts(page):
251
243
  return v.get('createdAt', '') if isinstance(v, dict) else ''
252
244
 
253
245
 
254
- def save_page(page_data, space_key, pages_dir):
246
+ def save_page(page_data, space_key, pages_dir, *, output_format='adf'):
247
+ """Save a fetched page to ``pages_dir/<SPACE>/``.
248
+
249
+ output_format:
250
+ - 'adf' (default): write <id>.json containing the ADF body.
251
+ - 'md': write <id>.md containing the markdown rendering.
252
+ The <id>.meta.json sidecar is always written.
253
+ """
255
254
  page_id = page_data['id']
256
255
  space_dir = os.path.join(pages_dir, space_key)
257
256
  os.makedirs(space_dir, exist_ok=True)
258
257
 
259
258
  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)
259
+
260
+ if output_format == 'md':
261
+ from atlassian_cli.adf import adf_to_markdown
262
+ body_path = os.path.join(space_dir, f'{page_id}.md')
263
+ with open(body_path, 'w') as f:
264
+ f.write(adf_to_markdown(body))
265
+ else:
266
+ body_path = os.path.join(space_dir, f'{page_id}.json')
267
+ with open(body_path, 'w') as f:
268
+ json.dump(body, f, indent=2)
263
269
 
264
270
  meta = {
265
271
  'id': page_id,
@@ -274,7 +280,7 @@ def save_page(page_data, space_key, pages_dir):
274
280
  with open(meta_path, 'w') as f:
275
281
  json.dump(meta, f, indent=2)
276
282
 
277
- return adf_path, meta_path
283
+ return body_path, meta_path
278
284
 
279
285
 
280
286
  def _find_page_file(page_id, pages_dir, suffix):
@@ -312,8 +318,9 @@ def cmd_get(args):
312
318
  page = get_page(session, base, args.page_id)
313
319
  space = get_space(session, base, space_id=page['spaceId'])
314
320
  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}')
321
+ output_format = 'md' if getattr(args, 'md', False) else 'adf'
322
+ body_path, _ = save_page(page, space_key, args.dir, output_format=output_format)
323
+ emit('OK', f'{page["title"]} (v{_ver(page)}) -> {body_path}')
317
324
 
318
325
 
319
326
  def cmd_create(args):
@@ -332,11 +339,7 @@ def cmd_create(args):
332
339
  elif args.body:
333
340
  body_payload = {
334
341
  'representation': 'atlas_doc_format',
335
- 'value': json.dumps({
336
- 'type': 'doc', 'version': 1,
337
- 'content': [{'type': 'paragraph',
338
- 'content': [{'type': 'text', 'text': args.body}]}],
339
- }),
342
+ 'value': json.dumps(_make_adf_body(args.body)),
340
343
  }
341
344
 
342
345
  payload = {
@@ -709,12 +712,14 @@ def cmd_sync(args):
709
712
 
710
713
  to_fetch = []
711
714
  skipped = 0
715
+ body_suffix = '.md' if getattr(args, 'md', False) else '.json'
712
716
  for page in pages:
713
717
  page_id = page['id']
714
718
  remote_ver = _ver(page)
715
719
  if not args.force:
716
720
  meta = load_meta(page_id, args.dir)
717
- if meta and meta.get('version', 0) >= remote_ver:
721
+ body_file = os.path.join(args.dir, space_key, f'{page_id}{body_suffix}')
722
+ if meta and meta.get('version', 0) >= remote_ver and os.path.isfile(body_file):
718
723
  skipped += 1
719
724
  continue
720
725
  to_fetch.append(page)
@@ -729,13 +734,14 @@ def cmd_sync(args):
729
734
  print(f'Fetching {len(to_fetch)} pages ({args.workers} workers)…', file=sys.stderr)
730
735
 
731
736
  errors = 0
737
+ output_format = 'md' if getattr(args, 'md', False) else 'adf'
732
738
 
733
739
  def fetch_one(page):
734
740
  nonlocal errors
735
741
  page_id = page['id']
736
742
  try:
737
743
  full_page = get_page(session, base, page_id)
738
- save_page(full_page, space_key, args.dir)
744
+ save_page(full_page, space_key, args.dir, output_format=output_format)
739
745
  return f'GET {page_id} {full_page.get("title", "")} (v{_ver(full_page)})'
740
746
  except Exception as e:
741
747
  errors += 1
@@ -749,6 +755,57 @@ def cmd_sync(args):
749
755
  emit('DONE', f'{space_key}: {len(to_fetch)} fetched, {skipped} skipped, {errors} errors')
750
756
 
751
757
 
758
+ def cmd_cql(args):
759
+ """Run a CQL query against the Confluence Search API.
760
+
761
+ Output modes:
762
+ - default: plain-text table (id [space] title)
763
+ - global --json: JSON array of {id, title, space, url, excerpt}
764
+ - --md: markdown bullet list with deep links
765
+ """
766
+ session, base = setup()
767
+ query = args.query
768
+ if args.space:
769
+ query = f'space = "{args.space}" AND ({query})'
770
+ data = api_get(session, base, f'{V1}/search', cql=query, limit=args.limit)
771
+ raw = data.get('results', [])
772
+
773
+ results = []
774
+ for r in raw:
775
+ content = r.get('content') or {}
776
+ space_key = ''
777
+ if isinstance(content.get('space'), dict):
778
+ space_key = content['space'].get('key', '')
779
+ results.append({
780
+ 'id': content.get('id', ''),
781
+ 'title': r.get('title') or content.get('title', ''),
782
+ 'space': space_key,
783
+ 'url': r.get('url', ''),
784
+ 'excerpt': r.get('excerpt', ''),
785
+ })
786
+
787
+ if getattr(args, 'md', False):
788
+ if not results:
789
+ print('No results.', file=sys.stderr)
790
+ else:
791
+ for r in results:
792
+ title = r['title'] or '(untitled)'
793
+ space = r['space'] or '?'
794
+ page_id = r['id']
795
+ print(f'- [{title}]({r["url"]}) — {space} ({page_id})')
796
+ return
797
+
798
+ if is_json_mode():
799
+ print(json.dumps(results, indent=2))
800
+ return
801
+
802
+ if not results:
803
+ print('No results.', file=sys.stderr)
804
+ return
805
+ for r in results:
806
+ print(f'{r["id"]} [{r["space"] or "?"}] {r["title"]}')
807
+
808
+
752
809
  def cmd_search(args):
753
810
  if not os.path.isfile(args.index):
754
811
  emit_error(f'Index not found: {args.index}')
@@ -837,14 +894,19 @@ def _adf_to_text(node):
837
894
 
838
895
 
839
896
  def _make_adf_body(text):
840
- """Create a simple ADF document from plain text."""
841
- return {
842
- 'type': 'doc',
843
- 'version': 1,
844
- 'content': [
845
- {'type': 'paragraph', 'content': [{'type': 'text', 'text': text}]}
846
- ],
847
- }
897
+ """Convert ``text`` (treated as markdown) to a full ADF document.
898
+
899
+ Plain strings round-trip unchanged (single paragraph). Markdown
900
+ constructs — headings, lists, tables, code blocks, links, emphasis —
901
+ are parsed into structured ADF nodes via ``md_to_adf``. If you need
902
+ raw ADF (e.g. for Confluence macros / panel boxes), use ``--file``
903
+ on commands that support it instead of this code path.
904
+ """
905
+ from atlassian_cli.adf import md_to_adf
906
+ content = md_to_adf(text) if text else []
907
+ if not content:
908
+ content = [{'type': 'paragraph', 'content': []}]
909
+ return {'type': 'doc', 'version': 1, 'content': content}
848
910
 
849
911
 
850
912
  def list_comments(session, base, page_id, comment_type='inline'):
@@ -1297,6 +1359,7 @@ def main():
1297
1359
  p = sub.add_parser('get', help='Download a page (ADF + metadata)')
1298
1360
  p.add_argument('page_id', help='Confluence page ID')
1299
1361
  p.add_argument('--dir', default='pages', help='Output directory (default: pages)')
1362
+ p.add_argument('--md', action='store_true', help='Save as markdown (<id>.md) instead of ADF JSON')
1300
1363
  p.set_defaults(func=cmd_get)
1301
1364
 
1302
1365
  p = sub.add_parser('create', help='Create a new page')
@@ -1389,8 +1452,16 @@ def main():
1389
1452
  p.add_argument('--dir', default='pages', help='Output directory (default: pages)')
1390
1453
  p.add_argument('--workers', type=int, default=10, help='Parallel workers (default: 10)')
1391
1454
  p.add_argument('--force', action='store_true', help='Re-download all, ignore cache')
1455
+ p.add_argument('--md', action='store_true', help='Render each page as markdown (<id>.md) instead of ADF JSON')
1392
1456
  p.set_defaults(func=cmd_sync)
1393
1457
 
1458
+ p = sub.add_parser('cql', help='Content-level search using CQL (Confluence Query Language)')
1459
+ p.add_argument('query', help='CQL query string, e.g. \'text ~ "risk register"\'')
1460
+ p.add_argument('--space', help='Restrict to a space (prepends space = "<KEY>" AND ... to the query)')
1461
+ p.add_argument('--limit', type=int, default=25, help='Max results (default: 25)')
1462
+ p.add_argument('--md', action='store_true', help='Markdown bullet-list output instead of plain text')
1463
+ p.set_defaults(func=cmd_cql)
1464
+
1394
1465
  p = sub.add_parser('search', help='Search local page index')
1395
1466
  p.add_argument('query', help='Search term (title or ID)')
1396
1467
  p.add_argument('--index', default='page-index.json', help='Index file path')
@@ -3,7 +3,7 @@
3
3
  import json
4
4
  import sys
5
5
 
6
- from atlassian_cli.adf import adf_to_markdown
6
+ from atlassian_cli.adf import adf_to_markdown, md_to_adf
7
7
  from atlassian_cli.config import setup
8
8
  from atlassian_cli.http import api_delete, api_get, api_post, api_put
9
9
  from atlassian_cli.output import emit, emit_error, emit_json, is_json_mode
@@ -24,11 +24,18 @@ def _extract_text(adf_body):
24
24
 
25
25
 
26
26
  def _text_adf(text):
27
- """Wrap plain text in minimal ADF document."""
28
- return {
29
- 'type': 'doc', 'version': 1,
30
- 'content': [{'type': 'paragraph', 'content': [{'type': 'text', 'text': text}]}],
31
- }
27
+ """Convert ``text`` (treated as markdown) to a full ADF document.
28
+
29
+ Plain strings round-trip unchanged (single paragraph). Markdown
30
+ constructs — headings, lists, tables, code blocks, links, emphasis —
31
+ are parsed into structured ADF nodes via ``md_to_adf``. If you need
32
+ to send raw ADF (e.g. for macros), use ``--file`` on commands that
33
+ support it instead of this code path.
34
+ """
35
+ content = md_to_adf(text) if text else []
36
+ if not content:
37
+ content = [{'type': 'paragraph', 'content': []}]
38
+ return {'type': 'doc', 'version': 1, 'content': content}
32
39
 
33
40
 
34
41
  def cmd_get(args):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlassian-cli
3
- Version: 0.10.4
3
+ Version: 0.12.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,26 @@ 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
+ # `--body` accepts markdown — headings, lists, tables, code blocks, links,
112
+ # emphasis. Use `--file` for raw ADF when you need macros / panel boxes.
113
+ confluence create POL "Quarterly update" --body "# Q2 Highlights
114
+
115
+ - Shipped X
116
+ - Improved Y"
117
+
118
+ # Search local page index by title (instant, no API call)
108
119
  confluence search "risk assessment"
109
120
 
121
+ # Content-level search via Confluence's CQL (hits the live API)
122
+ confluence cql 'text ~ "risk register"'
123
+ confluence cql 'text ~ "risk"' --space POL --limit 10
124
+ confluence cql 'text ~ "risk"' --md # markdown bullet list with deep links
125
+ confluence --json cql 'text ~ "risk"' # JSON array
126
+
110
127
  # Rebuild the page index
111
128
  confluence index
112
129
  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
@@ -270,6 +297,20 @@ class TestMakeAdfBody:
270
297
  assert result["type"] == "doc"
271
298
  assert result["content"][0]["content"][0]["text"] == "Hello"
272
299
 
300
+ def test_markdown_heading_and_list(self):
301
+ md = "# Title\n\n- item one\n- item two"
302
+ result = _make_adf_body(md)
303
+ types = [n["type"] for n in result["content"]]
304
+ assert types == ["heading", "bulletList"]
305
+ assert result["content"][0]["attrs"]["level"] == 1
306
+ items = result["content"][1]["content"]
307
+ assert len(items) == 2
308
+
309
+ def test_empty_text(self):
310
+ result = _make_adf_body("")
311
+ assert result["type"] == "doc"
312
+ assert len(result["content"]) >= 1
313
+
273
314
 
274
315
  class TestListComments:
275
316
  @responses.activate
@@ -589,3 +630,130 @@ class TestUpdateBlogpost:
589
630
  title="New Title", body="New body")
590
631
  assert title == "New Title"
591
632
  assert ver == 2
633
+
634
+
635
+ class TestCmdSync:
636
+ @responses.activate
637
+ def test_renders_pages_as_md(self, capsys, tmp_path):
638
+ # Space lookup
639
+ responses.add(
640
+ responses.GET, f"{BASE}{V2}/spaces",
641
+ json={"results": [SAMPLE_SPACE]},
642
+ )
643
+ # Page list
644
+ responses.add(
645
+ responses.GET, f"{BASE}{V2}/spaces/100/pages",
646
+ json={"results": [{"id": "12345", "version": {"number": 3}}], "_links": {}},
647
+ )
648
+ # Page fetch
649
+ responses.add(responses.GET, f"{BASE}{V2}/pages/12345", json=SAMPLE_PAGE)
650
+
651
+ cmd_sync(Namespace(
652
+ space_key="TEST",
653
+ dir=str(tmp_path),
654
+ workers=1,
655
+ force=True,
656
+ md=True,
657
+ ))
658
+
659
+ assert os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.md"))
660
+ assert not os.path.isfile(os.path.join(str(tmp_path), "TEST", "12345.json"))
661
+
662
+
663
+ SAMPLE_CQL_RESPONSE = {
664
+ "results": [
665
+ {
666
+ "content": {
667
+ "id": "12345",
668
+ "type": "page",
669
+ "title": "Risk Policy",
670
+ "space": {"key": "POL"},
671
+ },
672
+ "title": "Risk Policy",
673
+ "url": "/spaces/POL/pages/12345",
674
+ "excerpt": "Risks are managed via …",
675
+ },
676
+ {
677
+ "content": {
678
+ "id": "67890",
679
+ "type": "page",
680
+ "title": "Risk Register",
681
+ "space": {"key": "COMPLY"},
682
+ },
683
+ "title": "Risk Register",
684
+ "url": "/spaces/COMPLY/pages/67890",
685
+ "excerpt": "Open risks: …",
686
+ },
687
+ ],
688
+ "totalSize": 2,
689
+ }
690
+
691
+
692
+ class TestCmdCql:
693
+ @responses.activate
694
+ def test_text_output(self, capsys):
695
+ responses.add(
696
+ responses.GET, f"{BASE}{V1}/search",
697
+ json=SAMPLE_CQL_RESPONSE,
698
+ )
699
+ cmd_cql(Namespace(query='text ~ "risk"', space=None, limit=25, md=False))
700
+ out = capsys.readouterr().out
701
+ assert "Risk Policy" in out
702
+ assert "12345" in out
703
+ assert "POL" in out
704
+
705
+ @responses.activate
706
+ def test_json_output(self, capsys):
707
+ from atlassian_cli.output import set_json_mode
708
+ responses.add(
709
+ responses.GET, f"{BASE}{V1}/search",
710
+ json=SAMPLE_CQL_RESPONSE,
711
+ )
712
+ set_json_mode(True)
713
+ try:
714
+ cmd_cql(Namespace(query='text ~ "risk"', space=None, limit=25, md=False))
715
+ finally:
716
+ set_json_mode(False)
717
+ out = capsys.readouterr().out
718
+ parsed = json.loads(out)
719
+ assert isinstance(parsed, list)
720
+ assert parsed[0]["id"] == "12345"
721
+ assert parsed[0]["space"] == "POL"
722
+ assert parsed[0]["title"] == "Risk Policy"
723
+
724
+ @responses.activate
725
+ def test_md_output(self, capsys):
726
+ responses.add(
727
+ responses.GET, f"{BASE}{V1}/search",
728
+ json=SAMPLE_CQL_RESPONSE,
729
+ )
730
+ cmd_cql(Namespace(query='text ~ "risk"', space=None, limit=25, md=True))
731
+ out = capsys.readouterr().out
732
+ assert "Risk Policy" in out
733
+ assert "- " in out
734
+ assert "12345" in out
735
+
736
+ @responses.activate
737
+ def test_space_filter_prepends_clause(self):
738
+ captured = {}
739
+
740
+ def callback(request):
741
+ captured["url"] = request.url
742
+ return (200, {}, json.dumps(SAMPLE_CQL_RESPONSE))
743
+
744
+ responses.add_callback(
745
+ responses.GET, f"{BASE}{V1}/search", callback=callback,
746
+ )
747
+ cmd_cql(Namespace(query='text ~ "risk"', space="POL", limit=25, md=False))
748
+ # The space clause should be present in the URL-encoded CQL
749
+ assert 'space+%3D+%22POL%22' in captured["url"] or 'space%3D%22POL%22' in captured["url"]
750
+
751
+ @responses.activate
752
+ def test_no_results(self, capsys):
753
+ responses.add(
754
+ responses.GET, f"{BASE}{V1}/search",
755
+ json={"results": [], "totalSize": 0},
756
+ )
757
+ cmd_cql(Namespace(query='text ~ "nothing"', space=None, limit=25, md=False))
758
+ err = capsys.readouterr().err
759
+ assert "No results" in err
@@ -39,6 +39,22 @@ class TestTextAdf:
39
39
  assert result["type"] == "doc"
40
40
  assert result["content"][0]["content"][0]["text"] == "Hello world"
41
41
 
42
+ def test_markdown_heading_and_list(self):
43
+ md = "# Title\n\n- item one\n- item two"
44
+ result = _text_adf(md)
45
+ types = [n["type"] for n in result["content"]]
46
+ assert types == ["heading", "bulletList"]
47
+ assert result["content"][0]["attrs"]["level"] == 1
48
+ assert result["content"][0]["content"][0]["text"] == "Title"
49
+ items = result["content"][1]["content"]
50
+ assert len(items) == 2
51
+
52
+ def test_empty_text(self):
53
+ result = _text_adf("")
54
+ assert result["type"] == "doc"
55
+ # Empty input still yields a valid doc the API will accept
56
+ assert len(result["content"]) >= 1
57
+
42
58
  def test_extracts_text(self):
43
59
  adf = _text_adf("Hello world")
44
60
  assert _extract_text(adf) == "Hello world"
File without changes
File without changes