atlassian-cli 0.10.1__tar.gz → 0.10.2__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.1/src/atlassian_cli.egg-info → atlassian_cli-0.10.2}/PKG-INFO +31 -1
  2. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/README.md +30 -0
  3. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/pyproject.toml +1 -1
  4. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/confluence.py +360 -11
  5. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2/src/atlassian_cli.egg-info}/PKG-INFO +31 -1
  6. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_confluence.py +201 -0
  7. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/LICENSE +0 -0
  8. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/setup.cfg +0 -0
  9. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/__init__.py +0 -0
  10. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/adf.py +0 -0
  11. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/config.py +0 -0
  12. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/hints.py +0 -0
  13. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/http.py +0 -0
  14. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/jira.py +0 -0
  15. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/jira_assets.py +0 -0
  16. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/jira_issues.py +0 -0
  17. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/output.py +0 -0
  18. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/update_check.py +0 -0
  19. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
  20. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
  21. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
  22. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/requires.txt +0 -0
  23. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/top_level.txt +0 -0
  24. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_adf.py +0 -0
  25. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_config.py +0 -0
  26. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_http.py +0 -0
  27. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_jira_assets.py +0 -0
  28. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_jira_issues.py +0 -0
  29. {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/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.1
3
+ Version: 0.10.2
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
@@ -127,6 +127,36 @@ pages/
127
127
  page-index.json # searchable index
128
128
  ```
129
129
 
130
+ ### Blog posts
131
+
132
+ Create, read, update, and delete blog posts in any Confluence space.
133
+
134
+ ```bash
135
+ # Create a blog post
136
+ confluence blog-create POL "Q1 Product Update"
137
+ confluence blog-create POL "Release Notes" --body "What's new this quarter..."
138
+ confluence blog-create POL "Deep Dive" --file /path/to/adf.json
139
+
140
+ # Download a blog post
141
+ confluence blog-get 123456
142
+
143
+ # Edit locally and upload
144
+ confluence blog-get 123456
145
+ # ... edit the ADF JSON file ...
146
+ confluence blog-put 123456 --message "Added screenshots"
147
+
148
+ # Update title and/or body directly
149
+ confluence blog-update 123456 --title "Q1 Update (Revised)"
150
+ confluence blog-update 123456 --body "Updated content here" -m "Corrections"
151
+
152
+ # List blog posts in a space
153
+ confluence blog-list POL
154
+ confluence blog-list POL --include-draft
155
+
156
+ # Delete a blog post
157
+ confluence blog-delete 123456
158
+ ```
159
+
130
160
  ### Comments
131
161
 
132
162
  Read, reply to, and resolve inline and footer comments on pages.
@@ -96,6 +96,36 @@ pages/
96
96
  page-index.json # searchable index
97
97
  ```
98
98
 
99
+ ### Blog posts
100
+
101
+ Create, read, update, and delete blog posts in any Confluence space.
102
+
103
+ ```bash
104
+ # Create a blog post
105
+ confluence blog-create POL "Q1 Product Update"
106
+ confluence blog-create POL "Release Notes" --body "What's new this quarter..."
107
+ confluence blog-create POL "Deep Dive" --file /path/to/adf.json
108
+
109
+ # Download a blog post
110
+ confluence blog-get 123456
111
+
112
+ # Edit locally and upload
113
+ confluence blog-get 123456
114
+ # ... edit the ADF JSON file ...
115
+ confluence blog-put 123456 --message "Added screenshots"
116
+
117
+ # Update title and/or body directly
118
+ confluence blog-update 123456 --title "Q1 Update (Revised)"
119
+ confluence blog-update 123456 --body "Updated content here" -m "Corrections"
120
+
121
+ # List blog posts in a space
122
+ confluence blog-list POL
123
+ confluence blog-list POL --include-draft
124
+
125
+ # Delete a blog post
126
+ confluence blog-delete 123456
127
+ ```
128
+
99
129
  ### Comments
100
130
 
101
131
  Read, reply to, and resolve inline and footer comments on pages.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "atlassian-cli"
7
- version = "0.10.1"
7
+ version = "0.10.2"
8
8
  description = "Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,17 +1,36 @@
1
1
  #!/usr/bin/env python3
2
- """Confluence Cloud CLI — fast ADF page management via REST API v2.
2
+ """Confluence Cloud CLI — fast ADF page and blog-post management via REST API v2.
3
3
 
4
4
  Commands:
5
- get Download a page (ADF + metadata)
6
- put Upload local ADF to Confluence
7
- diff Compare local vs remote ADF
8
- sync Bulk-download all pages in a space
9
- delete Delete a page
10
- search Search local page index
11
- index Rebuild page-index.json from API
12
- comments List inline and footer comments on a page
13
- comment Reply to a comment
14
- resolve Resolve an inline comment
5
+ Pages:
6
+ get Download a page (ADF + metadata)
7
+ create Create a new page
8
+ put Upload local ADF to Confluence
9
+ diff Compare local vs remote ADF
10
+ sync Bulk-download all pages in a space
11
+ delete Delete a page
12
+ move Move a page to a new parent (optionally cross-space)
13
+ rename Rename a page
14
+ archive Archive a page and its descendants
15
+ search Search local page index
16
+ index Rebuild page-index.json from API
17
+ Blog Posts:
18
+ blog-create Create a blog post
19
+ blog-get Download a blog post (ADF + metadata)
20
+ blog-update Update a blog post title and/or body
21
+ blog-delete Delete a blog post
22
+ blog-list List blog posts in a space
23
+ blog-put Upload local ADF for a blog post
24
+ Comments:
25
+ comments List inline and footer comments on a page
26
+ comment Reply to a comment
27
+ resolve Resolve an inline comment
28
+ Other:
29
+ hints Show hints for working with ADF and Confluence macros
30
+ changes Show what changed in the latest version of a page
31
+ approvals List pages pending your approval
32
+ approve Approve a page
33
+ reject Reject a page approval
15
34
  """
16
35
 
17
36
  import argparse
@@ -94,6 +113,124 @@ def list_pages(session, base, space_id, statuses=('current',)):
94
113
  return pages
95
114
 
96
115
 
116
+ # ---------------------------------------------------------------------------
117
+ # Blog posts (v2)
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def create_blogpost(session, base, space_id, title, *, body=None, file=None):
121
+ """Create a blog post in a Confluence space."""
122
+ body_payload = {}
123
+ if file:
124
+ with open(file) as f:
125
+ adf = json.load(f)
126
+ body_payload = {
127
+ 'representation': 'atlas_doc_format',
128
+ 'value': json.dumps(adf),
129
+ }
130
+ elif body:
131
+ body_payload = {
132
+ 'representation': 'atlas_doc_format',
133
+ 'value': json.dumps({
134
+ 'type': 'doc', 'version': 1,
135
+ 'content': [{'type': 'paragraph',
136
+ 'content': [{'type': 'text', 'text': body}]}],
137
+ }),
138
+ }
139
+
140
+ payload = {
141
+ 'spaceId': space_id,
142
+ 'title': title,
143
+ 'status': 'current',
144
+ }
145
+ if body_payload:
146
+ payload['body'] = body_payload
147
+
148
+ return api_post(session, base, f'{V2}/blogposts', payload)
149
+
150
+
151
+ def get_blogpost(session, base, blogpost_id):
152
+ """Fetch a single blog post with ADF body."""
153
+ data = api_get(session, base, f'{V2}/blogposts/{blogpost_id}',
154
+ **{'body-format': 'atlas_doc_format'})
155
+ body = data.get('body', {}).get('atlas_doc_format', {})
156
+ if isinstance(body.get('value'), str):
157
+ try:
158
+ body['value'] = json.loads(body['value'])
159
+ except json.JSONDecodeError:
160
+ pass
161
+ return data
162
+
163
+
164
+ def update_blogpost(session, base, blogpost_id, *,
165
+ title=None, body=None, file=None, message=None):
166
+ """Update a blog post's title and/or body."""
167
+ remote = get_blogpost(session, base, blogpost_id)
168
+
169
+ new_title = title if title is not None else remote.get('title', '')
170
+ new_version = _ver(remote) + 1
171
+
172
+ body_payload = None
173
+ if file:
174
+ with open(file) as f:
175
+ adf = json.load(f)
176
+ body_payload = {
177
+ 'representation': 'atlas_doc_format',
178
+ 'value': json.dumps(adf),
179
+ }
180
+ elif body is not None:
181
+ body_payload = {
182
+ 'representation': 'atlas_doc_format',
183
+ 'value': json.dumps({
184
+ 'type': 'doc', 'version': 1,
185
+ 'content': [{'type': 'paragraph',
186
+ 'content': [{'type': 'text', 'text': body}]}],
187
+ }),
188
+ }
189
+ else:
190
+ body_value = remote.get('body', {}).get('atlas_doc_format', {}).get('value')
191
+ if body_value is not None:
192
+ body_payload = {
193
+ 'representation': 'atlas_doc_format',
194
+ 'value': json.dumps(body_value) if not isinstance(body_value, str) else body_value,
195
+ }
196
+
197
+ payload = {
198
+ 'id': str(blogpost_id),
199
+ 'status': 'current',
200
+ 'title': new_title,
201
+ 'version': {'number': new_version,
202
+ 'message': message or 'Updated blog post'},
203
+ }
204
+ if body_payload:
205
+ payload['body'] = body_payload
206
+
207
+ api_put(session, base, f'{V2}/blogposts/{blogpost_id}', payload)
208
+ return new_title, new_version
209
+
210
+
211
+ def delete_blogpost(session, base, blogpost_id):
212
+ """Delete a blog post."""
213
+ api_delete(session, base, f'{V2}/blogposts/{blogpost_id}')
214
+
215
+
216
+ def list_blogposts(session, base, space_id, statuses=('current',)):
217
+ """Cursor-paginated listing of blog posts in a space, filtered by status."""
218
+ blogposts = []
219
+ status_q = '&'.join(f'status={s}' for s in statuses)
220
+ url = f'{base}{V2}/spaces/{space_id}/blogposts?limit=250&sort=id&{status_q}'
221
+ while url:
222
+ resp = _retry(session.get, url)
223
+ resp.raise_for_status()
224
+ data = resp.json()
225
+ blogposts.extend(data.get('results', []))
226
+ next_link = data.get('_links', {}).get('next')
227
+ if next_link:
228
+ url = f'{base}{next_link}' if next_link.startswith('/') else next_link
229
+ else:
230
+ url = None
231
+ return blogposts
232
+
233
+
97
234
  # ---------------------------------------------------------------------------
98
235
  # Local file I/O
99
236
  # ---------------------------------------------------------------------------
@@ -309,6 +446,178 @@ def cmd_delete(args):
309
446
  emit('OK', f'Deleted {title} ({args.page_id})')
310
447
 
311
448
 
449
+ # ---------------------------------------------------------------------------
450
+ # Blog post commands
451
+ # ---------------------------------------------------------------------------
452
+
453
+ def _save_blogpost(page_data, space_key, pages_dir, suffix=''):
454
+ blogpost_id = page_data['id']
455
+ space_dir = os.path.join(pages_dir, space_key)
456
+ os.makedirs(space_dir, exist_ok=True)
457
+
458
+ body = page_data.get('body', {}).get('atlas_doc_format', {}).get('value', {})
459
+ filename = f'blog-{blogpost_id}{suffix}.json'
460
+ adf_path = os.path.join(space_dir, filename)
461
+ with open(adf_path, 'w') as f:
462
+ json.dump(body, f, indent=2)
463
+
464
+ meta = {
465
+ 'id': blogpost_id,
466
+ 'title': page_data.get('title', ''),
467
+ 'spaceId': page_data.get('spaceId', ''),
468
+ 'spaceKey': space_key,
469
+ 'version': _ver(page_data),
470
+ 'updatedAt': _ver_ts(page_data),
471
+ }
472
+ meta_filename = f'blog-{blogpost_id}{suffix}.meta.json'
473
+ meta_path = os.path.join(space_dir, meta_filename)
474
+ with open(meta_path, 'w') as f:
475
+ json.dump(meta, f, indent=2)
476
+
477
+ return adf_path, meta_path
478
+
479
+
480
+ def _load_blog_meta(blogpost_id, pages_dir):
481
+ if not os.path.isdir(pages_dir):
482
+ return None
483
+ for entry in os.listdir(pages_dir):
484
+ candidate = os.path.join(pages_dir, entry, f'blog-{blogpost_id}.meta.json')
485
+ if os.path.isfile(candidate):
486
+ with open(candidate) as f:
487
+ return json.load(f)
488
+ return None
489
+
490
+
491
+ def _load_blog_adf(blogpost_id, pages_dir):
492
+ if not os.path.isdir(pages_dir):
493
+ return None
494
+ for entry in os.listdir(pages_dir):
495
+ candidate = os.path.join(pages_dir, entry, f'blog-{blogpost_id}.json')
496
+ if os.path.isfile(candidate):
497
+ with open(candidate) as f:
498
+ return json.load(f)
499
+ return None
500
+
501
+
502
+ def cmd_blog_create(args):
503
+ session, base = setup()
504
+ space = get_space(session, base, key=args.space_key)
505
+ space_id = space['id']
506
+ space_key = space.get('key', args.space_key)
507
+
508
+ result = create_blogpost(session, base, space_id, args.title,
509
+ body=args.body, file=args.file)
510
+ blogpost_id = result['id']
511
+
512
+ if args.dir:
513
+ full_post = get_blogpost(session, base, blogpost_id)
514
+ _save_blogpost(full_post, space_key, args.dir)
515
+
516
+ emit('OK', f'Created {result.get("title", args.title)} ({blogpost_id})')
517
+
518
+
519
+ def cmd_blog_get(args):
520
+ session, base = setup()
521
+ blogpost = get_blogpost(session, base, args.blogpost_id)
522
+ space = get_space(session, base, space_id=blogpost['spaceId'])
523
+ space_key = space.get('key', str(blogpost['spaceId']))
524
+ adf_path, _ = _save_blogpost(blogpost, space_key, args.dir)
525
+ emit('OK', f'{blogpost["title"]} (v{_ver(blogpost)}) -> {adf_path}')
526
+
527
+
528
+ def cmd_blog_update(args):
529
+ session, base = setup()
530
+ new_title, new_version = update_blogpost(
531
+ session, base, args.blogpost_id,
532
+ title=args.title, body=args.body, file=args.file,
533
+ message=getattr(args, 'message', None),
534
+ )
535
+
536
+ if args.dir:
537
+ full_post = get_blogpost(session, base, args.blogpost_id)
538
+ space = get_space(session, base, space_id=full_post['spaceId'])
539
+ _save_blogpost(full_post, space.get('key', ''), args.dir)
540
+
541
+ emit('OK', f'Updated blog post {args.blogpost_id} -> {new_title!r} (v{new_version})')
542
+
543
+
544
+ def cmd_blog_delete(args):
545
+ session, base = setup()
546
+ blogpost = get_blogpost(session, base, args.blogpost_id)
547
+ title = blogpost.get('title', args.blogpost_id)
548
+ delete_blogpost(session, base, args.blogpost_id)
549
+ emit('OK', f'Deleted blog post {title} ({args.blogpost_id})')
550
+
551
+
552
+ def cmd_blog_list(args):
553
+ session, base = setup()
554
+ space = get_space(session, base, key=args.space_key)
555
+ space_id = space['id']
556
+ space_key = space.get('key', args.space_key)
557
+
558
+ statuses = ('current',) if not getattr(args, 'include_draft', False) else ('current', 'draft')
559
+ blogposts = list_blogposts(session, base, space_id, statuses=statuses)
560
+
561
+ if is_json_mode():
562
+ emit_json([{
563
+ 'id': b['id'],
564
+ 'title': b.get('title', ''),
565
+ 'version': _ver(b),
566
+ 'updatedAt': _ver_ts(b),
567
+ 'status': b.get('status', 'current'),
568
+ } for b in blogposts])
569
+ else:
570
+ for b in blogposts:
571
+ print(f'{b["id"]} [{b.get("status", "current")}] {b.get("title", "")} (v{_ver(b)})')
572
+ emit('DONE', f'{len(blogposts)} blog post(s) in {space_key}')
573
+
574
+
575
+ def cmd_blog_put(args):
576
+ session, base = setup()
577
+
578
+ meta = _load_blog_meta(args.blogpost_id, args.dir)
579
+ if not meta:
580
+ emit_error(f'No local metadata for blog post {args.blogpost_id}')
581
+ sys.exit(1)
582
+ adf = _load_blog_adf(args.blogpost_id, args.dir)
583
+ if not adf:
584
+ emit_error(f'No local ADF for blog post {args.blogpost_id}')
585
+ sys.exit(1)
586
+
587
+ remote = get_blogpost(session, base, args.blogpost_id)
588
+ remote_ver = _ver(remote)
589
+ local_ver = meta.get('version', 0)
590
+
591
+ if not args.force and remote_ver != local_ver:
592
+ emit_error(f'Version conflict: local v{local_ver}, remote v{remote_ver}. Use --force to overwrite.')
593
+ sys.exit(1)
594
+
595
+ new_version = remote_ver + 1
596
+ payload = {
597
+ 'id': str(args.blogpost_id),
598
+ 'status': 'current',
599
+ 'title': meta['title'],
600
+ 'body': {
601
+ 'representation': 'atlas_doc_format',
602
+ 'value': json.dumps(adf),
603
+ },
604
+ 'version': {
605
+ 'number': new_version,
606
+ 'message': getattr(args, 'message', None) or 'Updated via confluence CLI',
607
+ },
608
+ }
609
+ result = api_put(session, base, f'{V2}/blogposts/{args.blogpost_id}', payload)
610
+
611
+ meta['version'] = new_version
612
+ meta['updatedAt'] = _ver_ts(result)
613
+ space_key = meta.get('spaceKey', '')
614
+ meta_path = os.path.join(args.dir, space_key, f'blog-{args.blogpost_id}.meta.json')
615
+ with open(meta_path, 'w') as f:
616
+ json.dump(meta, f, indent=2)
617
+
618
+ emit('OK', f'{meta["title"]} updated to v{new_version}')
619
+
620
+
312
621
  def cmd_put(args):
313
622
  session, base = setup()
314
623
 
@@ -995,6 +1304,46 @@ def main():
995
1304
  p.add_argument('page_id', help='Confluence page ID')
996
1305
  p.set_defaults(func=cmd_delete)
997
1306
 
1307
+ # -- blog posts ----------------------------------------------------------
1308
+
1309
+ p = sub.add_parser('blog-create', help='Create a blog post')
1310
+ p.add_argument('space_key', help='Space key (e.g. POL, COMPLY)')
1311
+ p.add_argument('title', help='Blog post title')
1312
+ p.add_argument('--body', help='Plain text body')
1313
+ p.add_argument('--file', '-f', help='ADF JSON file for blog body')
1314
+ p.add_argument('--dir', default='pages', help='Pages directory (default: pages)')
1315
+ p.set_defaults(func=cmd_blog_create)
1316
+
1317
+ p = sub.add_parser('blog-get', help='Download a blog post (ADF + metadata)')
1318
+ p.add_argument('blogpost_id', help='Blog post ID')
1319
+ p.add_argument('--dir', default='pages', help='Output directory (default: pages)')
1320
+ p.set_defaults(func=cmd_blog_get)
1321
+
1322
+ p = sub.add_parser('blog-update', help='Update a blog post title and/or body')
1323
+ p.add_argument('blogpost_id', help='Blog post ID')
1324
+ p.add_argument('--title', help='New title')
1325
+ p.add_argument('--body', help='New plain text body')
1326
+ p.add_argument('--file', '-f', help='ADF JSON file for new blog body')
1327
+ p.add_argument('--message', '-m', help='Version message')
1328
+ p.add_argument('--dir', default='pages', help='Pages directory (default: pages)')
1329
+ p.set_defaults(func=cmd_blog_update)
1330
+
1331
+ p = sub.add_parser('blog-delete', help='Delete a blog post')
1332
+ p.add_argument('blogpost_id', help='Blog post ID')
1333
+ p.set_defaults(func=cmd_blog_delete)
1334
+
1335
+ p = sub.add_parser('blog-list', help='List blog posts in a space')
1336
+ p.add_argument('space_key', help='Space key (e.g. POL, COMPLY)')
1337
+ p.add_argument('--include-draft', action='store_true', help='Include draft blog posts')
1338
+ p.set_defaults(func=cmd_blog_list)
1339
+
1340
+ p = sub.add_parser('blog-put', help='Upload local ADF for a blog post')
1341
+ p.add_argument('blogpost_id', help='Blog post ID')
1342
+ p.add_argument('--dir', default='pages', help='Pages directory (default: pages)')
1343
+ p.add_argument('--force', action='store_true', help='Skip version conflict check')
1344
+ p.add_argument('--message', '-m', help='Version message (shown in history)')
1345
+ p.set_defaults(func=cmd_blog_put)
1346
+
998
1347
  p = sub.add_parser('move', help='Move a page to a new parent (preserves body); '
999
1348
  'optionally to a different space')
1000
1349
  p.add_argument('page_id', help='Confluence page ID to move')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlassian-cli
3
- Version: 0.10.1
3
+ Version: 0.10.2
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
@@ -127,6 +127,36 @@ pages/
127
127
  page-index.json # searchable index
128
128
  ```
129
129
 
130
+ ### Blog posts
131
+
132
+ Create, read, update, and delete blog posts in any Confluence space.
133
+
134
+ ```bash
135
+ # Create a blog post
136
+ confluence blog-create POL "Q1 Product Update"
137
+ confluence blog-create POL "Release Notes" --body "What's new this quarter..."
138
+ confluence blog-create POL "Deep Dive" --file /path/to/adf.json
139
+
140
+ # Download a blog post
141
+ confluence blog-get 123456
142
+
143
+ # Edit locally and upload
144
+ confluence blog-get 123456
145
+ # ... edit the ADF JSON file ...
146
+ confluence blog-put 123456 --message "Added screenshots"
147
+
148
+ # Update title and/or body directly
149
+ confluence blog-update 123456 --title "Q1 Update (Revised)"
150
+ confluence blog-update 123456 --body "Updated content here" -m "Corrections"
151
+
152
+ # List blog posts in a space
153
+ confluence blog-list POL
154
+ confluence blog-list POL --include-draft
155
+
156
+ # Delete a blog post
157
+ confluence blog-delete 123456
158
+ ```
159
+
130
160
  ### Comments
131
161
 
132
162
  Read, reply to, and resolve inline and footer comments on pages.
@@ -12,12 +12,20 @@ from atlassian_cli.confluence import (
12
12
  _make_adf_body,
13
13
  _ver,
14
14
  _ver_ts,
15
+ cmd_blog_create,
16
+ cmd_blog_delete,
17
+ cmd_blog_get,
18
+ cmd_blog_list,
19
+ cmd_blog_update,
15
20
  cmd_diff,
16
21
  cmd_get,
17
22
  cmd_index,
18
23
  cmd_search,
24
+ create_blogpost,
25
+ get_blogpost,
19
26
  get_page,
20
27
  get_space,
28
+ list_blogposts,
21
29
  list_comment_replies,
22
30
  list_comments,
23
31
  list_pages,
@@ -26,6 +34,7 @@ from atlassian_cli.confluence import (
26
34
  reply_to_comment,
27
35
  resolve_comment,
28
36
  save_page,
37
+ update_blogpost,
29
38
  )
30
39
  from atlassian_cli.output import set_json_mode
31
40
 
@@ -388,3 +397,195 @@ class TestCmdIndex:
388
397
  with open(output_path) as f:
389
398
  index = json.load(f)
390
399
  assert len(index["TEST"]) == 1
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # Blog post tests
404
+ # ---------------------------------------------------------------------------
405
+
406
+ BLOG_SAMPLE = {
407
+ "id": "55555",
408
+ "title": "Test Blog Post",
409
+ "spaceId": "100",
410
+ "status": "current",
411
+ "version": {"number": 1, "createdAt": "2025-02-01T10:00:00Z"},
412
+ "body": {
413
+ "atlas_doc_format": {
414
+ "value": {"type": "doc", "version": 1, "content": [
415
+ {"type": "paragraph", "content": [{"type": "text", "text": "Blog content"}]}
416
+ ]},
417
+ },
418
+ },
419
+ }
420
+
421
+
422
+ class TestCreateBlogpost:
423
+ @responses.activate
424
+ def test_creates_blogpost(self, mock_session):
425
+ responses.add(
426
+ responses.POST, f"{BASE}{V2}/blogposts",
427
+ json=BLOG_SAMPLE,
428
+ )
429
+ result = create_blogpost(mock_session, BASE, "100", "Test Blog Post",
430
+ body="Blog content")
431
+ assert result["id"] == "55555"
432
+ assert result["title"] == "Test Blog Post"
433
+
434
+ @responses.activate
435
+ def test_creates_with_file(self, mock_session, tmp_path):
436
+ adf_file = tmp_path / "blog.adf.json"
437
+ adf_file.write_text(json.dumps(BLOG_SAMPLE["body"]["atlas_doc_format"]["value"]))
438
+ responses.add(
439
+ responses.POST, f"{BASE}{V2}/blogposts",
440
+ json=BLOG_SAMPLE,
441
+ )
442
+ result = create_blogpost(mock_session, BASE, "100", "Test Blog Post",
443
+ file=str(adf_file))
444
+ assert result["id"] == "55555"
445
+
446
+
447
+ class TestGetBlogpost:
448
+ @responses.activate
449
+ def test_fetches_blogpost(self, mock_session):
450
+ responses.add(
451
+ responses.GET, f"{BASE}{V2}/blogposts/55555",
452
+ json=BLOG_SAMPLE,
453
+ )
454
+ result = get_blogpost(mock_session, BASE, "55555")
455
+ assert result["id"] == "55555"
456
+ assert isinstance(result["body"]["atlas_doc_format"]["value"], dict)
457
+
458
+
459
+ class TestListBlogposts:
460
+ @responses.activate
461
+ def test_lists_blogposts(self, mock_session):
462
+ responses.add(
463
+ responses.GET, f"{BASE}{V2}/spaces/100/blogposts",
464
+ json={"results": [BLOG_SAMPLE], "_links": {}},
465
+ )
466
+ result = list_blogposts(mock_session, BASE, "100")
467
+ assert len(result) == 1
468
+ assert result[0]["id"] == "55555"
469
+
470
+
471
+ class TestCmdBlogCreate:
472
+ @responses.activate
473
+ def test_creates_blogpost(self, capsys, tmp_path):
474
+ responses.add(
475
+ responses.GET, f"{BASE}{V2}/spaces",
476
+ json={"results": [SAMPLE_SPACE]},
477
+ )
478
+ responses.add(
479
+ responses.POST, f"{BASE}{V2}/blogposts",
480
+ json=BLOG_SAMPLE,
481
+ )
482
+ responses.add(
483
+ responses.GET, f"{BASE}{V2}/blogposts/55555",
484
+ json=BLOG_SAMPLE,
485
+ )
486
+ responses.add(
487
+ responses.GET, f"{BASE}{V2}/spaces/100",
488
+ json=SAMPLE_SPACE,
489
+ )
490
+ cmd_blog_create(Namespace(
491
+ space_key="TEST", title="Test Blog Post",
492
+ body="Blog content", file=None,
493
+ dir=str(tmp_path),
494
+ ))
495
+ out = capsys.readouterr().out
496
+ assert "Test Blog Post" in out
497
+ assert os.path.isfile(os.path.join(str(tmp_path), "TEST", "blog-55555.json"))
498
+
499
+
500
+ class TestCmdBlogGet:
501
+ @responses.activate
502
+ def test_downloads_blogpost(self, capsys, tmp_path):
503
+ responses.add(
504
+ responses.GET, f"{BASE}{V2}/blogposts/55555",
505
+ json=BLOG_SAMPLE,
506
+ )
507
+ responses.add(
508
+ responses.GET, f"{BASE}{V2}/spaces/100",
509
+ json=SAMPLE_SPACE,
510
+ )
511
+ cmd_blog_get(Namespace(blogpost_id="55555", dir=str(tmp_path)))
512
+ out = capsys.readouterr().out
513
+ assert "Test Blog Post" in out
514
+ assert os.path.isfile(os.path.join(str(tmp_path), "TEST", "blog-55555.json"))
515
+
516
+
517
+ class TestCmdBlogDelete:
518
+ @responses.activate
519
+ def test_deletes_blogpost(self, capsys):
520
+ responses.add(
521
+ responses.GET, f"{BASE}{V2}/blogposts/55555",
522
+ json=BLOG_SAMPLE,
523
+ )
524
+ responses.add(
525
+ responses.DELETE, f"{BASE}{V2}/blogposts/55555",
526
+ status=204,
527
+ )
528
+ cmd_blog_delete(Namespace(blogpost_id="55555"))
529
+ out = capsys.readouterr().out
530
+ assert "Deleted blog post" in out
531
+
532
+
533
+ class TestCmdBlogList:
534
+ @responses.activate
535
+ def test_lists_blogposts(self, capsys):
536
+ responses.add(
537
+ responses.GET, f"{BASE}{V2}/spaces",
538
+ json={"results": [SAMPLE_SPACE]},
539
+ )
540
+ responses.add(
541
+ responses.GET, f"{BASE}{V2}/spaces/100/blogposts",
542
+ json={"results": [BLOG_SAMPLE], "_links": {}},
543
+ )
544
+ cmd_blog_list(Namespace(space_key="TEST"))
545
+ out = capsys.readouterr().out
546
+ assert "Test Blog Post" in out
547
+ assert "1 blog post(s)" in out
548
+
549
+
550
+ class TestCmdBlogUpdate:
551
+ @responses.activate
552
+ def test_updates_blogpost(self, capsys, tmp_path):
553
+ responses.add(
554
+ responses.GET, f"{BASE}{V2}/blogposts/55555",
555
+ json=BLOG_SAMPLE,
556
+ )
557
+ responses.add(
558
+ responses.PUT, f"{BASE}{V2}/blogposts/55555",
559
+ json={**BLOG_SAMPLE, "title": "Updated Title",
560
+ "version": {"number": 2, "createdAt": "2025-02-02T10:00:00Z"}},
561
+ )
562
+ responses.add(
563
+ responses.GET, f"{BASE}{V2}/spaces/100",
564
+ json=SAMPLE_SPACE,
565
+ )
566
+ cmd_blog_update(Namespace(
567
+ blogpost_id="55555", title="Updated Title",
568
+ body=None, file=None,
569
+ dir=str(tmp_path),
570
+ ))
571
+ out = capsys.readouterr().out
572
+ assert "Updated Title" in out
573
+ assert "(v2)" in out
574
+
575
+
576
+ class TestUpdateBlogpost:
577
+ @responses.activate
578
+ def test_updates_title_and_body(self, mock_session):
579
+ responses.add(
580
+ responses.GET, f"{BASE}{V2}/blogposts/55555",
581
+ json=BLOG_SAMPLE,
582
+ )
583
+ responses.add(
584
+ responses.PUT, f"{BASE}{V2}/blogposts/55555",
585
+ json={**BLOG_SAMPLE, "title": "New Title",
586
+ "version": {"number": 2, "createdAt": "2025-02-02T10:00:00Z"}},
587
+ )
588
+ title, ver = update_blogpost(mock_session, BASE, "55555",
589
+ title="New Title", body="New body")
590
+ assert title == "New Title"
591
+ assert ver == 2
File without changes
File without changes