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.
- {atlassian_cli-0.10.1/src/atlassian_cli.egg-info → atlassian_cli-0.10.2}/PKG-INFO +31 -1
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/README.md +30 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/pyproject.toml +1 -1
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/confluence.py +360 -11
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2/src/atlassian_cli.egg-info}/PKG-INFO +31 -1
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_confluence.py +201 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/LICENSE +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/setup.cfg +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/__init__.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/adf.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/config.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/hints.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/http.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/jira.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/jira_assets.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/jira_issues.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/output.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli/update_check.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/requires.txt +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/src/atlassian_cli.egg-info/top_level.txt +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_adf.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_config.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_http.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_jira_assets.py +0 -0
- {atlassian_cli-0.10.1 → atlassian_cli-0.10.2}/tests/test_jira_issues.py +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
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
|
|
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.1 → atlassian_cli-0.10.2}/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
|
|
File without changes
|