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.
- {atlassian_cli-0.10.4/src/atlassian_cli.egg-info → atlassian_cli-0.12.0}/PKG-INFO +19 -2
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/README.md +18 -1
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/pyproject.toml +1 -1
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/confluence.py +103 -32
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/jira_issues.py +13 -6
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0/src/atlassian_cli.egg-info}/PKG-INFO +19 -2
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_confluence.py +169 -1
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_jira_issues.py +16 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/LICENSE +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/setup.cfg +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/__init__.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/adf.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/config.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/hints.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/http.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/jira.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/jira_assets.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/output.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli/update_check.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/requires.txt +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/top_level.txt +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_adf.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_config.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_http.py +0 -0
- {atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/tests/test_jira_assets.py +0 -0
- {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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
@@ -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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
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
|
{atlassian_cli-0.10.4 → atlassian_cli-0.12.0}/src/atlassian_cli.egg-info/dependency_links.txt
RENAMED
|
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
|