conjira-cli 0.2.2__tar.gz → 0.2.3__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.
- {conjira_cli-0.2.2/src/conjira_cli.egg-info → conjira_cli-0.2.3}/PKG-INFO +53 -1
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/README.md +52 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/pyproject.toml +1 -1
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/__init__.py +1 -1
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/cli.py +112 -5
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/client.py +37 -2
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/markdown_export.py +34 -1
- {conjira_cli-0.2.2 → conjira_cli-0.2.3/src/conjira_cli.egg-info}/PKG-INFO +53 -1
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_cli.py +283 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_client.py +42 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_markdown_export.py +32 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/LICENSE +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/setup.cfg +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/setup.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/__main__.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/config.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/inline_comments.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/markdown_import.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/section_edit.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/setup_macos.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/tree_export.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/entry_points.txt +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/top_level.txt +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_config.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_inline_comments.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_markdown_import.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_section_edit.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_setup_macos.py +0 -0
- {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_tree_export.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conjira-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
|
|
5
5
|
Author: quanttraderkim
|
|
6
6
|
License-Expression: MIT
|
|
@@ -140,6 +140,50 @@ conjira export-page-md --page-id 123456 --output-dir "/path/to/notes"
|
|
|
140
140
|
|
|
141
141
|
If you run the CLI from a different folder, pass the config file explicitly with `--env-file /path/to/local/agent.env`.
|
|
142
142
|
|
|
143
|
+
## Prompt templates by document type
|
|
144
|
+
|
|
145
|
+
When you ask an agent to upload or update a document, results are better if you tell it what kind of document it is, whether Markdown should remain the source of truth, and whether the Confluence page should stay plain or become more presentation-friendly.
|
|
146
|
+
|
|
147
|
+
For skill specs or evaluation docs, a good request is:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
Use conjira to upload this Markdown file as a Confluence page. Treat it as a skill spec, keep the Markdown structure as the source of truth, preserve headings and tables, and only use Confluence-native rendering where it helps readability without changing the document's meaning.
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For service planning docs or PRDs, a good request is:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
Use conjira to turn this Markdown file into a Confluence PRD. Keep the content faithful to the source, but make it easier to read in Confluence. Add status, callouts, and expand blocks where they improve readability, and organize the page around summary, background, problem, scope, flow, risks, and open questions.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
For strategy reports or review decks, a good request is:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
Use conjira to publish this Markdown file as a report-style Confluence page. Keep the source content intact, but optimize the page for presentation. Put the executive summary first, surface key decisions and risks early, and use status, info blocks, expand sections, and Mermaid where appropriate.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
If you want to stay closer to raw Markdown, say `keep this Markdown-first and avoid extra presentation macros`. If you want a more polished Confluence page, say `optimize this for Confluence readability while keeping the source content intact`.
|
|
166
|
+
|
|
167
|
+
## Document writing style guide
|
|
168
|
+
|
|
169
|
+
For Confluence uploads, structure matters as much as accuracy. Strategy memos, PRDs, planning docs, and report-style pages read much better when the writer uses a compact working-document style instead of long essay-like prose.
|
|
170
|
+
|
|
171
|
+
Prefer report-style memo wording over verbose formal phrasing. In Korean this usually means reducing repetitive `~입니다` and `~합니다`, and favoring shorter noun-phrase or judgment-oriented endings such as `~필요`, `~전제`, `~검토`, `~제안`, or `~우선`. Do not force every sentence into rigid `~함` wording; the target is a concise internal memo tone, not mechanical shorthand. Keep paragraphs short, lead with the conclusion, and make `###` headings carry the message or judgment instead of acting as generic labels.
|
|
172
|
+
|
|
173
|
+
Use bullet points only for true parallel items. Do not split a single thought into fake bullets just to reduce line length. Use tables for comparisons, summaries, options, risks, target groups, ownership, or decision support. Do not force short explanatory text into a table when a compact paragraph is clearer.
|
|
174
|
+
|
|
175
|
+
A good default order is `purpose -> summary -> key judgments / evidence -> risks / assumptions -> implications / next actions`. Strategy memos usually need `why this matters`, `key judgment`, `evidence`, and `implications`. PRDs usually need `problem`, `goal`, `scope`, `flow`, `policy`, and `open issues`. Skill specs are better when they stay literal and dry, with sections like `one-line definition`, `when to use`, `inputs`, `outputs`, `exceptions`, and `evaluation criteria`.
|
|
176
|
+
|
|
177
|
+
For longer strategy or report documents, explicit numbering helps. Top-level sections such as `A.`, `B.`, `C.` and second-level sections such as `A-1.`, `A-2.` usually make the table of contents and the body much easier to scan. As a default, stay within two levels unless the user explicitly wants a deeper document tree.
|
|
178
|
+
|
|
179
|
+
Cut abstract filler. Avoid phrases like “strategically meaningful”, “fundamentally important value”, or “meaningful impact across multiple dimensions” unless they add something precise. Prefer concrete statements such as “follow-up gaps turn into revenue loss”, “A is the right first rollout option”, or “free-user monetization is necessary but total revenue impact is limited”.
|
|
180
|
+
|
|
181
|
+
If you want an agent to follow this style explicitly, a good request is:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
Use conjira to publish this document to Confluence. Keep the source content intact, but rewrite it in a concise internal memo style. Reduce formal `~입니다/~합니다` phrasing, prefer short noun-phrase or judgment-oriented wording, put the key summary first, use stronger h3/h4 headings and tables where they help scanability, add `A.` / `A-1.` numbering when the document is long enough to benefit from it, keep bullets for true parallel items only, and remove abstract filler.
|
|
185
|
+
```
|
|
186
|
+
|
|
143
187
|
Short sample output blocks, using synthetic values:
|
|
144
188
|
|
|
145
189
|
```json
|
|
@@ -315,6 +359,14 @@ conjira --env-file ./local/agent.env jira-search --jql 'project = DEMO ORDER BY
|
|
|
315
359
|
conjira --env-file ./local/agent.env jira-get-issue --issue-key DEMO-123
|
|
316
360
|
```
|
|
317
361
|
|
|
362
|
+
Inspect updated timestamps or recent comments when needed:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
conjira jira-get-issue --issue-key DEMO-123 --include-comments --comments-limit 2
|
|
366
|
+
conjira jira-get-issue --issue-key DEMO-123 --raw --fields summary,updated,comment
|
|
367
|
+
conjira jira-search --jql 'project = DEMO ORDER BY updated DESC' --raw --fields summary,updated
|
|
368
|
+
```
|
|
369
|
+
|
|
318
370
|
Create a Jira issue or add a comment:
|
|
319
371
|
|
|
320
372
|
```bash
|
|
@@ -111,6 +111,50 @@ conjira export-page-md --page-id 123456 --output-dir "/path/to/notes"
|
|
|
111
111
|
|
|
112
112
|
If you run the CLI from a different folder, pass the config file explicitly with `--env-file /path/to/local/agent.env`.
|
|
113
113
|
|
|
114
|
+
## Prompt templates by document type
|
|
115
|
+
|
|
116
|
+
When you ask an agent to upload or update a document, results are better if you tell it what kind of document it is, whether Markdown should remain the source of truth, and whether the Confluence page should stay plain or become more presentation-friendly.
|
|
117
|
+
|
|
118
|
+
For skill specs or evaluation docs, a good request is:
|
|
119
|
+
|
|
120
|
+
```text
|
|
121
|
+
Use conjira to upload this Markdown file as a Confluence page. Treat it as a skill spec, keep the Markdown structure as the source of truth, preserve headings and tables, and only use Confluence-native rendering where it helps readability without changing the document's meaning.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For service planning docs or PRDs, a good request is:
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
Use conjira to turn this Markdown file into a Confluence PRD. Keep the content faithful to the source, but make it easier to read in Confluence. Add status, callouts, and expand blocks where they improve readability, and organize the page around summary, background, problem, scope, flow, risks, and open questions.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
For strategy reports or review decks, a good request is:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
Use conjira to publish this Markdown file as a report-style Confluence page. Keep the source content intact, but optimize the page for presentation. Put the executive summary first, surface key decisions and risks early, and use status, info blocks, expand sections, and Mermaid where appropriate.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
If you want to stay closer to raw Markdown, say `keep this Markdown-first and avoid extra presentation macros`. If you want a more polished Confluence page, say `optimize this for Confluence readability while keeping the source content intact`.
|
|
137
|
+
|
|
138
|
+
## Document writing style guide
|
|
139
|
+
|
|
140
|
+
For Confluence uploads, structure matters as much as accuracy. Strategy memos, PRDs, planning docs, and report-style pages read much better when the writer uses a compact working-document style instead of long essay-like prose.
|
|
141
|
+
|
|
142
|
+
Prefer report-style memo wording over verbose formal phrasing. In Korean this usually means reducing repetitive `~입니다` and `~합니다`, and favoring shorter noun-phrase or judgment-oriented endings such as `~필요`, `~전제`, `~검토`, `~제안`, or `~우선`. Do not force every sentence into rigid `~함` wording; the target is a concise internal memo tone, not mechanical shorthand. Keep paragraphs short, lead with the conclusion, and make `###` headings carry the message or judgment instead of acting as generic labels.
|
|
143
|
+
|
|
144
|
+
Use bullet points only for true parallel items. Do not split a single thought into fake bullets just to reduce line length. Use tables for comparisons, summaries, options, risks, target groups, ownership, or decision support. Do not force short explanatory text into a table when a compact paragraph is clearer.
|
|
145
|
+
|
|
146
|
+
A good default order is `purpose -> summary -> key judgments / evidence -> risks / assumptions -> implications / next actions`. Strategy memos usually need `why this matters`, `key judgment`, `evidence`, and `implications`. PRDs usually need `problem`, `goal`, `scope`, `flow`, `policy`, and `open issues`. Skill specs are better when they stay literal and dry, with sections like `one-line definition`, `when to use`, `inputs`, `outputs`, `exceptions`, and `evaluation criteria`.
|
|
147
|
+
|
|
148
|
+
For longer strategy or report documents, explicit numbering helps. Top-level sections such as `A.`, `B.`, `C.` and second-level sections such as `A-1.`, `A-2.` usually make the table of contents and the body much easier to scan. As a default, stay within two levels unless the user explicitly wants a deeper document tree.
|
|
149
|
+
|
|
150
|
+
Cut abstract filler. Avoid phrases like “strategically meaningful”, “fundamentally important value”, or “meaningful impact across multiple dimensions” unless they add something precise. Prefer concrete statements such as “follow-up gaps turn into revenue loss”, “A is the right first rollout option”, or “free-user monetization is necessary but total revenue impact is limited”.
|
|
151
|
+
|
|
152
|
+
If you want an agent to follow this style explicitly, a good request is:
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
Use conjira to publish this document to Confluence. Keep the source content intact, but rewrite it in a concise internal memo style. Reduce formal `~입니다/~합니다` phrasing, prefer short noun-phrase or judgment-oriented wording, put the key summary first, use stronger h3/h4 headings and tables where they help scanability, add `A.` / `A-1.` numbering when the document is long enough to benefit from it, keep bullets for true parallel items only, and remove abstract filler.
|
|
156
|
+
```
|
|
157
|
+
|
|
114
158
|
Short sample output blocks, using synthetic values:
|
|
115
159
|
|
|
116
160
|
```json
|
|
@@ -286,6 +330,14 @@ conjira --env-file ./local/agent.env jira-search --jql 'project = DEMO ORDER BY
|
|
|
286
330
|
conjira --env-file ./local/agent.env jira-get-issue --issue-key DEMO-123
|
|
287
331
|
```
|
|
288
332
|
|
|
333
|
+
Inspect updated timestamps or recent comments when needed:
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
conjira jira-get-issue --issue-key DEMO-123 --include-comments --comments-limit 2
|
|
337
|
+
conjira jira-get-issue --issue-key DEMO-123 --raw --fields summary,updated,comment
|
|
338
|
+
conjira jira-search --jql 'project = DEMO ORDER BY updated DESC' --raw --fields summary,updated
|
|
339
|
+
```
|
|
340
|
+
|
|
289
341
|
Create a Jira issue or add a comment:
|
|
290
342
|
|
|
291
343
|
```bash
|
|
@@ -26,6 +26,16 @@ from conjira_cli.markdown_import import markdown_to_storage_html
|
|
|
26
26
|
from conjira_cli.section_edit import SectionEditError, replace_section_html
|
|
27
27
|
from conjira_cli.tree_export import export_page_tree, sanitize_path_component
|
|
28
28
|
|
|
29
|
+
_JIRA_SUMMARY_FIELDS = [
|
|
30
|
+
"summary",
|
|
31
|
+
"status",
|
|
32
|
+
"issuetype",
|
|
33
|
+
"project",
|
|
34
|
+
"assignee",
|
|
35
|
+
"reporter",
|
|
36
|
+
"updated",
|
|
37
|
+
]
|
|
38
|
+
|
|
29
39
|
|
|
30
40
|
def _read_text_arg(raw_text: Optional[str], file_path: Optional[str]) -> str:
|
|
31
41
|
if raw_text is not None:
|
|
@@ -43,6 +53,21 @@ def _read_json_arg(raw_json: Optional[str], file_path: Optional[str]) -> Dict[st
|
|
|
43
53
|
return {}
|
|
44
54
|
|
|
45
55
|
|
|
56
|
+
def _merge_csv_fields(raw_fields: Optional[str], required_fields: list[str]) -> Optional[str]:
|
|
57
|
+
tokens: list[str] = []
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
for value in [raw_fields, ",".join(required_fields)]:
|
|
60
|
+
if not value:
|
|
61
|
+
continue
|
|
62
|
+
for token in value.split(","):
|
|
63
|
+
item = token.strip()
|
|
64
|
+
if not item or item in seen:
|
|
65
|
+
continue
|
|
66
|
+
seen.add(item)
|
|
67
|
+
tokens.append(item)
|
|
68
|
+
return ",".join(tokens) if tokens else None
|
|
69
|
+
|
|
70
|
+
|
|
46
71
|
def _read_confluence_body_arg(
|
|
47
72
|
raw_html: Optional[str],
|
|
48
73
|
html_file: Optional[str],
|
|
@@ -104,6 +129,62 @@ def _preview_html(value: Optional[str], limit: int = 240) -> Optional[str]:
|
|
|
104
129
|
return _preview_text(preview, limit=limit)
|
|
105
130
|
|
|
106
131
|
|
|
132
|
+
def _page_body_html(page: Dict[str, Any]) -> str:
|
|
133
|
+
return (((page.get("body") or {}).get("storage") or {}).get("value")) or ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_effectively_empty_body(body_html: Optional[str]) -> bool:
|
|
137
|
+
if not body_html:
|
|
138
|
+
return True
|
|
139
|
+
normalized = body_html.replace("\xa0", " ").replace(" ", " ")
|
|
140
|
+
normalized = re.sub(r"<!--.*?-->", "", normalized, flags=re.DOTALL)
|
|
141
|
+
normalized = re.sub(r"<p>\s*(<br\s*/?>)?\s*</p>", "", normalized, flags=re.IGNORECASE)
|
|
142
|
+
normalized = re.sub(r"<br\s*/?>", "", normalized, flags=re.IGNORECASE)
|
|
143
|
+
normalized = re.sub(r"\s+", "", normalized)
|
|
144
|
+
return normalized == ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _summarize_child_pages(child_pages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
|
|
148
|
+
return [ConfluenceClient.summarize_page(page) for page in child_pages]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fallback_confluence_page_url(page: Dict[str, Any], page_id: Optional[str]) -> Optional[str]:
|
|
152
|
+
if not page_id:
|
|
153
|
+
return None
|
|
154
|
+
base_url = ((page.get("_links") or {}).get("base")) or ""
|
|
155
|
+
if not base_url:
|
|
156
|
+
return None
|
|
157
|
+
return "{0}/pages/viewpage.action?pageId={1}".format(base_url.rstrip("/"), page_id)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _page_navigation_payload(
|
|
161
|
+
*,
|
|
162
|
+
page: Dict[str, Any],
|
|
163
|
+
child_pages: list[Dict[str, Any]],
|
|
164
|
+
) -> Dict[str, Any]:
|
|
165
|
+
body_html = _page_body_html(page)
|
|
166
|
+
body_is_effectively_empty = _is_effectively_empty_body(body_html)
|
|
167
|
+
child_summaries = _summarize_child_pages(child_pages)
|
|
168
|
+
for summary in child_summaries:
|
|
169
|
+
if not summary.get("webui_url"):
|
|
170
|
+
summary["webui_url"] = _fallback_confluence_page_url(page, summary.get("id"))
|
|
171
|
+
page_kind = "hub" if body_is_effectively_empty and child_summaries else "content"
|
|
172
|
+
|
|
173
|
+
payload: Dict[str, Any] = {
|
|
174
|
+
"page_kind": page_kind,
|
|
175
|
+
"body_is_effectively_empty": body_is_effectively_empty,
|
|
176
|
+
"child_count": len(child_summaries),
|
|
177
|
+
}
|
|
178
|
+
if child_summaries:
|
|
179
|
+
payload["children"] = child_summaries
|
|
180
|
+
if page_kind == "hub":
|
|
181
|
+
payload["read_hint"] = (
|
|
182
|
+
"This page behaves like a hub/index page. Read the listed child pages or use "
|
|
183
|
+
"`export-tree-md` for the full hierarchy."
|
|
184
|
+
)
|
|
185
|
+
return payload
|
|
186
|
+
|
|
187
|
+
|
|
107
188
|
def _sanitize_markdown_filename(title: str) -> str:
|
|
108
189
|
sanitized = "".join(
|
|
109
190
|
"_" if char in '<>:"/\\|?*' else char
|
|
@@ -156,7 +237,7 @@ def _read_export_metadata(path: Path) -> Dict[str, Any]:
|
|
|
156
237
|
|
|
157
238
|
def _page_export_payload(page: Dict[str, Any]) -> Dict[str, Any]:
|
|
158
239
|
payload = ConfluenceClient.summarize_page(page)
|
|
159
|
-
payload["body_html"] = (
|
|
240
|
+
payload["body_html"] = _page_body_html(page)
|
|
160
241
|
payload["ancestors"] = page.get("ancestors") or []
|
|
161
242
|
return payload
|
|
162
243
|
|
|
@@ -353,6 +434,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
353
434
|
jira_get_issue.add_argument("--issue-key", required=True)
|
|
354
435
|
jira_get_issue.add_argument("--fields")
|
|
355
436
|
jira_get_issue.add_argument("--expand")
|
|
437
|
+
jira_get_issue.add_argument("--include-comments", action="store_true")
|
|
438
|
+
jira_get_issue.add_argument("--comments-limit", type=int, default=3)
|
|
439
|
+
jira_get_issue.add_argument("--raw", action="store_true")
|
|
356
440
|
|
|
357
441
|
jira_search = subparsers.add_parser("jira-search", help="Search Jira with JQL")
|
|
358
442
|
jira_search.add_argument("--jql", required=True)
|
|
@@ -360,6 +444,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
360
444
|
jira_search.add_argument("--start", type=int, default=0)
|
|
361
445
|
jira_search.add_argument("--fields")
|
|
362
446
|
jira_search.add_argument("--expand")
|
|
447
|
+
jira_search.add_argument("--raw", action="store_true")
|
|
363
448
|
|
|
364
449
|
jira_get_createmeta = subparsers.add_parser(
|
|
365
450
|
"jira-get-createmeta",
|
|
@@ -793,14 +878,19 @@ def _handle_confluence(args: argparse.Namespace) -> Dict[str, Any]:
|
|
|
793
878
|
if args.command == "auth-check":
|
|
794
879
|
return client.auth_check()
|
|
795
880
|
if args.command == "get-page":
|
|
796
|
-
|
|
881
|
+
expand = _merge_csv_fields(args.expand, ["body.storage", "version", "space"])
|
|
882
|
+
page = client.get_page(args.page_id, expand=expand)
|
|
883
|
+
child_pages = client.list_child_pages(args.page_id)
|
|
797
884
|
payload = client.summarize_page(page)
|
|
885
|
+
payload.update(_page_navigation_payload(page=page, child_pages=child_pages))
|
|
798
886
|
if args.expand and "body.storage" in args.expand:
|
|
799
|
-
payload["body_html"] = (
|
|
887
|
+
payload["body_html"] = _page_body_html(page)
|
|
800
888
|
return payload
|
|
801
889
|
if args.command == "export-page-md":
|
|
802
890
|
page = client.get_page(args.page_id, expand="body.storage,version,space")
|
|
891
|
+
child_pages = client.list_child_pages(args.page_id)
|
|
803
892
|
payload = _page_export_payload(page)
|
|
893
|
+
payload.update(_page_navigation_payload(page=page, child_pages=child_pages))
|
|
804
894
|
exporter = MarkdownExporter(
|
|
805
895
|
base_url=settings.base_url,
|
|
806
896
|
page_id=args.page_id,
|
|
@@ -823,6 +913,9 @@ def _handle_confluence(args: argparse.Namespace) -> Dict[str, Any]:
|
|
|
823
913
|
"title": payload["title"],
|
|
824
914
|
"output_file": str(output_path),
|
|
825
915
|
"source_url": payload["webui_url"],
|
|
916
|
+
"page_kind": payload["page_kind"],
|
|
917
|
+
"child_count": payload["child_count"],
|
|
918
|
+
"hub_generated": payload["page_kind"] == "hub",
|
|
826
919
|
"used_staging_local": str(output_path).startswith(
|
|
827
920
|
str((Path(settings.export_staging_dir) if settings.export_staging_dir else _default_export_staging_dir()))
|
|
828
921
|
),
|
|
@@ -1193,8 +1286,20 @@ def _handle_jira(args: argparse.Namespace) -> Dict[str, Any]:
|
|
|
1193
1286
|
if args.command == "jira-auth-check":
|
|
1194
1287
|
return client.auth_check()
|
|
1195
1288
|
if args.command == "jira-get-issue":
|
|
1196
|
-
|
|
1197
|
-
|
|
1289
|
+
fields = args.fields
|
|
1290
|
+
if args.include_comments:
|
|
1291
|
+
required_fields = ["comment", "updated"]
|
|
1292
|
+
if args.fields is None:
|
|
1293
|
+
required_fields = _JIRA_SUMMARY_FIELDS + ["comment"]
|
|
1294
|
+
fields = _merge_csv_fields(args.fields, required_fields)
|
|
1295
|
+
issue = client.get_issue(args.issue_key, fields=fields, expand=args.expand)
|
|
1296
|
+
if args.raw:
|
|
1297
|
+
return issue
|
|
1298
|
+
return client.summarize_issue(
|
|
1299
|
+
issue,
|
|
1300
|
+
include_comments=args.include_comments,
|
|
1301
|
+
comments_limit=args.comments_limit,
|
|
1302
|
+
)
|
|
1198
1303
|
if args.command == "jira-search":
|
|
1199
1304
|
result = client.search(
|
|
1200
1305
|
jql=args.jql,
|
|
@@ -1203,6 +1308,8 @@ def _handle_jira(args: argparse.Namespace) -> Dict[str, Any]:
|
|
|
1203
1308
|
fields=args.fields,
|
|
1204
1309
|
expand=args.expand,
|
|
1205
1310
|
)
|
|
1311
|
+
if args.raw:
|
|
1312
|
+
return result
|
|
1206
1313
|
return client.summarize_search_results(result.get("issues", []))
|
|
1207
1314
|
if args.command == "jira-get-createmeta":
|
|
1208
1315
|
result = client.get_createmeta(
|
|
@@ -540,14 +540,32 @@ class JiraClient(BaseAtlassianClient):
|
|
|
540
540
|
return None
|
|
541
541
|
return "{0}/browse/{1}".format(base_url.rstrip("/"), issue_key)
|
|
542
542
|
|
|
543
|
-
|
|
543
|
+
@staticmethod
|
|
544
|
+
def _comment_body_preview(value: Any, limit: int = 280) -> Optional[str]:
|
|
545
|
+
if value is None:
|
|
546
|
+
return None
|
|
547
|
+
if isinstance(value, str):
|
|
548
|
+
collapsed = " ".join(value.split())
|
|
549
|
+
else:
|
|
550
|
+
collapsed = " ".join(json.dumps(value, ensure_ascii=False).split())
|
|
551
|
+
if len(collapsed) <= limit:
|
|
552
|
+
return collapsed
|
|
553
|
+
return collapsed[: limit - 1].rstrip() + "…"
|
|
554
|
+
|
|
555
|
+
def summarize_issue(
|
|
556
|
+
self,
|
|
557
|
+
issue: Dict[str, Any],
|
|
558
|
+
*,
|
|
559
|
+
include_comments: bool = False,
|
|
560
|
+
comments_limit: int = 3,
|
|
561
|
+
) -> Dict[str, Any]:
|
|
544
562
|
fields = issue.get("fields") or {}
|
|
545
563
|
status = fields.get("status") or {}
|
|
546
564
|
issue_type = fields.get("issuetype") or {}
|
|
547
565
|
project = fields.get("project") or {}
|
|
548
566
|
assignee = fields.get("assignee") or {}
|
|
549
567
|
reporter = fields.get("reporter") or {}
|
|
550
|
-
|
|
568
|
+
summary = {
|
|
551
569
|
"id": issue.get("id"),
|
|
552
570
|
"key": issue.get("key"),
|
|
553
571
|
"summary": fields.get("summary"),
|
|
@@ -556,8 +574,25 @@ class JiraClient(BaseAtlassianClient):
|
|
|
556
574
|
"project_key": project.get("key"),
|
|
557
575
|
"assignee": assignee.get("displayName"),
|
|
558
576
|
"reporter": reporter.get("displayName"),
|
|
577
|
+
"updated": fields.get("updated"),
|
|
559
578
|
"browse_url": self.browse_url(self.base_url, issue.get("key")),
|
|
560
579
|
}
|
|
580
|
+
if include_comments:
|
|
581
|
+
comment_block = fields.get("comment") or {}
|
|
582
|
+
comments = comment_block.get("comments") or []
|
|
583
|
+
limited = comments[-comments_limit:] if comments_limit > 0 else comments
|
|
584
|
+
summary["comment_count"] = comment_block.get("total", len(comments))
|
|
585
|
+
summary["recent_comments"] = [
|
|
586
|
+
{
|
|
587
|
+
"id": comment.get("id"),
|
|
588
|
+
"author": ((comment.get("author") or {}).get("displayName")),
|
|
589
|
+
"created": comment.get("created"),
|
|
590
|
+
"updated": comment.get("updated"),
|
|
591
|
+
"body_preview": self._comment_body_preview(comment.get("body")),
|
|
592
|
+
}
|
|
593
|
+
for comment in limited
|
|
594
|
+
]
|
|
595
|
+
return summary
|
|
561
596
|
|
|
562
597
|
def summarize_search_results(self, items: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
|
|
563
598
|
results = [self.summarize_issue(item) for item in items]
|
|
@@ -113,6 +113,10 @@ def _normalize_table_header(value: str) -> str:
|
|
|
113
113
|
return value
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
def _escape_table_cell(value: str) -> str:
|
|
117
|
+
return value.replace("|", "\\|").strip()
|
|
118
|
+
|
|
119
|
+
|
|
116
120
|
@dataclass
|
|
117
121
|
class MarkdownExporter:
|
|
118
122
|
base_url: str
|
|
@@ -124,8 +128,14 @@ class MarkdownExporter:
|
|
|
124
128
|
source_url = page.get("webui_url") or ""
|
|
125
129
|
version = page.get("version")
|
|
126
130
|
parent_page_id = page.get("parent_page_id")
|
|
131
|
+
page_kind = page.get("page_kind")
|
|
132
|
+
child_count = page.get("child_count")
|
|
127
133
|
body_html = page.get("body_html") or ""
|
|
128
|
-
|
|
134
|
+
children = page.get("children") or []
|
|
135
|
+
if page_kind == "hub" and children:
|
|
136
|
+
body_md = self._render_hub_page(children)
|
|
137
|
+
else:
|
|
138
|
+
body_md = self.convert_fragment(body_html)
|
|
129
139
|
|
|
130
140
|
parts = [
|
|
131
141
|
"---",
|
|
@@ -135,6 +145,10 @@ class MarkdownExporter:
|
|
|
135
145
|
]
|
|
136
146
|
if parent_page_id:
|
|
137
147
|
parts.append(f"confluence_parent_page_id: {parent_page_id}")
|
|
148
|
+
if page_kind:
|
|
149
|
+
parts.append(f"confluence_page_kind: {page_kind}")
|
|
150
|
+
if child_count:
|
|
151
|
+
parts.append(f"confluence_child_count: {child_count}")
|
|
138
152
|
parts.extend(
|
|
139
153
|
[
|
|
140
154
|
f"source_url: {source_url}",
|
|
@@ -151,6 +165,25 @@ class MarkdownExporter:
|
|
|
151
165
|
)
|
|
152
166
|
return "\n".join(parts)
|
|
153
167
|
|
|
168
|
+
def _render_hub_page(self, children: list[dict[str, Any]]) -> str:
|
|
169
|
+
lines = [
|
|
170
|
+
"> [!INFO] This is a Confluence hub/index page.",
|
|
171
|
+
"> The main content lives in the child pages listed below.",
|
|
172
|
+
"",
|
|
173
|
+
"## Child pages",
|
|
174
|
+
"",
|
|
175
|
+
"| Title | Page ID | Link |",
|
|
176
|
+
"| --- | --- | --- |",
|
|
177
|
+
]
|
|
178
|
+
for child in children:
|
|
179
|
+
title = _escape_table_cell(child.get("title") or "Untitled")
|
|
180
|
+
page_id = _escape_table_cell(str(child.get("id") or ""))
|
|
181
|
+
url = child.get("webui_url") or ""
|
|
182
|
+
link = f"[Open]({url})" if url else ""
|
|
183
|
+
lines.append(f"| {title} | {page_id} | {link} |")
|
|
184
|
+
lines.append("")
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
154
187
|
def convert_fragment(self, body_html: str) -> str:
|
|
155
188
|
wrapped = (
|
|
156
189
|
'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conjira-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
|
|
5
5
|
Author: quanttraderkim
|
|
6
6
|
License-Expression: MIT
|
|
@@ -140,6 +140,50 @@ conjira export-page-md --page-id 123456 --output-dir "/path/to/notes"
|
|
|
140
140
|
|
|
141
141
|
If you run the CLI from a different folder, pass the config file explicitly with `--env-file /path/to/local/agent.env`.
|
|
142
142
|
|
|
143
|
+
## Prompt templates by document type
|
|
144
|
+
|
|
145
|
+
When you ask an agent to upload or update a document, results are better if you tell it what kind of document it is, whether Markdown should remain the source of truth, and whether the Confluence page should stay plain or become more presentation-friendly.
|
|
146
|
+
|
|
147
|
+
For skill specs or evaluation docs, a good request is:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
Use conjira to upload this Markdown file as a Confluence page. Treat it as a skill spec, keep the Markdown structure as the source of truth, preserve headings and tables, and only use Confluence-native rendering where it helps readability without changing the document's meaning.
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
For service planning docs or PRDs, a good request is:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
Use conjira to turn this Markdown file into a Confluence PRD. Keep the content faithful to the source, but make it easier to read in Confluence. Add status, callouts, and expand blocks where they improve readability, and organize the page around summary, background, problem, scope, flow, risks, and open questions.
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
For strategy reports or review decks, a good request is:
|
|
160
|
+
|
|
161
|
+
```text
|
|
162
|
+
Use conjira to publish this Markdown file as a report-style Confluence page. Keep the source content intact, but optimize the page for presentation. Put the executive summary first, surface key decisions and risks early, and use status, info blocks, expand sections, and Mermaid where appropriate.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
If you want to stay closer to raw Markdown, say `keep this Markdown-first and avoid extra presentation macros`. If you want a more polished Confluence page, say `optimize this for Confluence readability while keeping the source content intact`.
|
|
166
|
+
|
|
167
|
+
## Document writing style guide
|
|
168
|
+
|
|
169
|
+
For Confluence uploads, structure matters as much as accuracy. Strategy memos, PRDs, planning docs, and report-style pages read much better when the writer uses a compact working-document style instead of long essay-like prose.
|
|
170
|
+
|
|
171
|
+
Prefer report-style memo wording over verbose formal phrasing. In Korean this usually means reducing repetitive `~입니다` and `~합니다`, and favoring shorter noun-phrase or judgment-oriented endings such as `~필요`, `~전제`, `~검토`, `~제안`, or `~우선`. Do not force every sentence into rigid `~함` wording; the target is a concise internal memo tone, not mechanical shorthand. Keep paragraphs short, lead with the conclusion, and make `###` headings carry the message or judgment instead of acting as generic labels.
|
|
172
|
+
|
|
173
|
+
Use bullet points only for true parallel items. Do not split a single thought into fake bullets just to reduce line length. Use tables for comparisons, summaries, options, risks, target groups, ownership, or decision support. Do not force short explanatory text into a table when a compact paragraph is clearer.
|
|
174
|
+
|
|
175
|
+
A good default order is `purpose -> summary -> key judgments / evidence -> risks / assumptions -> implications / next actions`. Strategy memos usually need `why this matters`, `key judgment`, `evidence`, and `implications`. PRDs usually need `problem`, `goal`, `scope`, `flow`, `policy`, and `open issues`. Skill specs are better when they stay literal and dry, with sections like `one-line definition`, `when to use`, `inputs`, `outputs`, `exceptions`, and `evaluation criteria`.
|
|
176
|
+
|
|
177
|
+
For longer strategy or report documents, explicit numbering helps. Top-level sections such as `A.`, `B.`, `C.` and second-level sections such as `A-1.`, `A-2.` usually make the table of contents and the body much easier to scan. As a default, stay within two levels unless the user explicitly wants a deeper document tree.
|
|
178
|
+
|
|
179
|
+
Cut abstract filler. Avoid phrases like “strategically meaningful”, “fundamentally important value”, or “meaningful impact across multiple dimensions” unless they add something precise. Prefer concrete statements such as “follow-up gaps turn into revenue loss”, “A is the right first rollout option”, or “free-user monetization is necessary but total revenue impact is limited”.
|
|
180
|
+
|
|
181
|
+
If you want an agent to follow this style explicitly, a good request is:
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
Use conjira to publish this document to Confluence. Keep the source content intact, but rewrite it in a concise internal memo style. Reduce formal `~입니다/~합니다` phrasing, prefer short noun-phrase or judgment-oriented wording, put the key summary first, use stronger h3/h4 headings and tables where they help scanability, add `A.` / `A-1.` numbering when the document is long enough to benefit from it, keep bullets for true parallel items only, and remove abstract filler.
|
|
185
|
+
```
|
|
186
|
+
|
|
143
187
|
Short sample output blocks, using synthetic values:
|
|
144
188
|
|
|
145
189
|
```json
|
|
@@ -315,6 +359,14 @@ conjira --env-file ./local/agent.env jira-search --jql 'project = DEMO ORDER BY
|
|
|
315
359
|
conjira --env-file ./local/agent.env jira-get-issue --issue-key DEMO-123
|
|
316
360
|
```
|
|
317
361
|
|
|
362
|
+
Inspect updated timestamps or recent comments when needed:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
conjira jira-get-issue --issue-key DEMO-123 --include-comments --comments-limit 2
|
|
366
|
+
conjira jira-get-issue --issue-key DEMO-123 --raw --fields summary,updated,comment
|
|
367
|
+
conjira jira-search --jql 'project = DEMO ORDER BY updated DESC' --raw --fields summary,updated
|
|
368
|
+
```
|
|
369
|
+
|
|
318
370
|
Create a Jira issue or add a comment:
|
|
319
371
|
|
|
320
372
|
```bash
|
|
@@ -240,6 +240,142 @@ class CliTests(unittest.TestCase):
|
|
|
240
240
|
mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space,ancestors")
|
|
241
241
|
mock_export_tree.assert_called_once()
|
|
242
242
|
|
|
243
|
+
def test_handle_confluence_get_page_marks_hub_and_lists_children(self) -> None:
|
|
244
|
+
args = SimpleNamespace(
|
|
245
|
+
command="get-page",
|
|
246
|
+
base_url=None,
|
|
247
|
+
token=None,
|
|
248
|
+
token_file=None,
|
|
249
|
+
token_keychain_service=None,
|
|
250
|
+
token_keychain_account=None,
|
|
251
|
+
timeout=None,
|
|
252
|
+
env_file=None,
|
|
253
|
+
page_id="12345",
|
|
254
|
+
expand="body.storage",
|
|
255
|
+
)
|
|
256
|
+
settings = ConfluenceSettings(
|
|
257
|
+
base_url="https://confluence.example.com",
|
|
258
|
+
token="token",
|
|
259
|
+
timeout_seconds=30,
|
|
260
|
+
)
|
|
261
|
+
page = {
|
|
262
|
+
"id": "12345",
|
|
263
|
+
"type": "page",
|
|
264
|
+
"status": "current",
|
|
265
|
+
"title": "AI 메시지 상세기획",
|
|
266
|
+
"space": {"key": "DOCS"},
|
|
267
|
+
"version": {"number": 7},
|
|
268
|
+
"body": {"storage": {"value": "<p><br/></p>"}},
|
|
269
|
+
"_links": {
|
|
270
|
+
"base": "https://confluence.example.com",
|
|
271
|
+
"webui": "/pages/viewpage.action?pageId=12345",
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
child_pages = [
|
|
275
|
+
{
|
|
276
|
+
"id": "20001",
|
|
277
|
+
"type": "page",
|
|
278
|
+
"status": "current",
|
|
279
|
+
"title": "메시지 AI 제안",
|
|
280
|
+
}
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
with mock.patch("conjira_cli.cli.build_confluence_settings", return_value=settings), mock.patch(
|
|
284
|
+
"conjira_cli.cli.ConfluenceClient.get_page",
|
|
285
|
+
return_value=page,
|
|
286
|
+
) as mock_get_page, mock.patch(
|
|
287
|
+
"conjira_cli.cli.ConfluenceClient.list_child_pages",
|
|
288
|
+
return_value=child_pages,
|
|
289
|
+
) as mock_list_child_pages:
|
|
290
|
+
payload = _handle_confluence(args)
|
|
291
|
+
|
|
292
|
+
self.assertEqual(payload["page_kind"], "hub")
|
|
293
|
+
self.assertTrue(payload["body_is_effectively_empty"])
|
|
294
|
+
self.assertEqual(payload["child_count"], 1)
|
|
295
|
+
self.assertEqual(payload["children"][0]["id"], "20001")
|
|
296
|
+
self.assertEqual(
|
|
297
|
+
payload["children"][0]["webui_url"],
|
|
298
|
+
"https://confluence.example.com/pages/viewpage.action?pageId=20001",
|
|
299
|
+
)
|
|
300
|
+
self.assertIn("hub/index page", payload["read_hint"])
|
|
301
|
+
self.assertEqual(payload["body_html"], "<p><br/></p>")
|
|
302
|
+
mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space")
|
|
303
|
+
mock_list_child_pages.assert_called_once_with("12345")
|
|
304
|
+
|
|
305
|
+
def test_handle_confluence_export_page_md_generates_hub_markdown(self) -> None:
|
|
306
|
+
args = SimpleNamespace(
|
|
307
|
+
command="export-page-md",
|
|
308
|
+
base_url=None,
|
|
309
|
+
token=None,
|
|
310
|
+
token_file=None,
|
|
311
|
+
token_keychain_service=None,
|
|
312
|
+
token_keychain_account=None,
|
|
313
|
+
timeout=None,
|
|
314
|
+
env_file=None,
|
|
315
|
+
page_id="12345",
|
|
316
|
+
output_file=None,
|
|
317
|
+
output_dir=None,
|
|
318
|
+
filename=None,
|
|
319
|
+
staging_local=True,
|
|
320
|
+
)
|
|
321
|
+
settings = ConfluenceSettings(
|
|
322
|
+
base_url="https://confluence.example.com",
|
|
323
|
+
token="token",
|
|
324
|
+
timeout_seconds=30,
|
|
325
|
+
export_staging_dir="/tmp/conjira-staging",
|
|
326
|
+
)
|
|
327
|
+
page = {
|
|
328
|
+
"id": "12345",
|
|
329
|
+
"type": "page",
|
|
330
|
+
"status": "current",
|
|
331
|
+
"title": "AI 메시지 상세기획",
|
|
332
|
+
"space": {"key": "DOCS"},
|
|
333
|
+
"version": {"number": 7},
|
|
334
|
+
"body": {"storage": {"value": ""}},
|
|
335
|
+
"_links": {
|
|
336
|
+
"base": "https://confluence.example.com",
|
|
337
|
+
"webui": "/pages/viewpage.action?pageId=12345",
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
child_pages = [
|
|
341
|
+
{
|
|
342
|
+
"id": "20001",
|
|
343
|
+
"type": "page",
|
|
344
|
+
"status": "current",
|
|
345
|
+
"title": "메시지 AI 제안",
|
|
346
|
+
"space": {"key": "DOCS"},
|
|
347
|
+
"version": {"number": 3},
|
|
348
|
+
"_links": {
|
|
349
|
+
"base": "https://confluence.example.com",
|
|
350
|
+
"webui": "/pages/viewpage.action?pageId=20001",
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
356
|
+
settings.export_staging_dir = tmp_dir
|
|
357
|
+
with mock.patch("conjira_cli.cli.build_confluence_settings", return_value=settings), mock.patch(
|
|
358
|
+
"conjira_cli.cli.ConfluenceClient.get_page",
|
|
359
|
+
return_value=page,
|
|
360
|
+
) as mock_get_page, mock.patch(
|
|
361
|
+
"conjira_cli.cli.ConfluenceClient.list_child_pages",
|
|
362
|
+
return_value=child_pages,
|
|
363
|
+
) as mock_list_child_pages:
|
|
364
|
+
payload = _handle_confluence(args)
|
|
365
|
+
|
|
366
|
+
output_file = Path(payload["output_file"])
|
|
367
|
+
markdown = output_file.read_text(encoding="utf-8")
|
|
368
|
+
|
|
369
|
+
self.assertEqual(payload["page_kind"], "hub")
|
|
370
|
+
self.assertEqual(payload["child_count"], 1)
|
|
371
|
+
self.assertTrue(payload["hub_generated"])
|
|
372
|
+
self.assertIn("confluence_page_kind: hub", markdown)
|
|
373
|
+
self.assertIn("confluence_child_count: 1", markdown)
|
|
374
|
+
self.assertIn("> [!INFO] This is a Confluence hub/index page.", markdown)
|
|
375
|
+
self.assertIn("| 메시지 AI 제안 | 20001 |", markdown)
|
|
376
|
+
mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space")
|
|
377
|
+
mock_list_child_pages.assert_called_once_with("12345")
|
|
378
|
+
|
|
243
379
|
def test_handle_confluence_update_page_dry_run_uses_live_page_without_write(self) -> None:
|
|
244
380
|
args = SimpleNamespace(
|
|
245
381
|
command="update-page",
|
|
@@ -611,6 +747,153 @@ class CliTests(unittest.TestCase):
|
|
|
611
747
|
mock_get_issue.assert_called_once_with("DEMO-123")
|
|
612
748
|
mock_add_comment.assert_not_called()
|
|
613
749
|
|
|
750
|
+
def test_handle_jira_get_issue_can_include_comments(self) -> None:
|
|
751
|
+
args = SimpleNamespace(
|
|
752
|
+
command="jira-get-issue",
|
|
753
|
+
base_url=None,
|
|
754
|
+
token=None,
|
|
755
|
+
token_file=None,
|
|
756
|
+
token_keychain_service=None,
|
|
757
|
+
token_keychain_account=None,
|
|
758
|
+
timeout=None,
|
|
759
|
+
env_file=None,
|
|
760
|
+
issue_key="DEMO-123",
|
|
761
|
+
fields=None,
|
|
762
|
+
expand=None,
|
|
763
|
+
include_comments=True,
|
|
764
|
+
comments_limit=2,
|
|
765
|
+
raw=False,
|
|
766
|
+
)
|
|
767
|
+
settings = JiraSettings(
|
|
768
|
+
base_url="https://jira.example.com",
|
|
769
|
+
token="token",
|
|
770
|
+
timeout_seconds=30,
|
|
771
|
+
)
|
|
772
|
+
issue = {
|
|
773
|
+
"id": "9000",
|
|
774
|
+
"key": "DEMO-123",
|
|
775
|
+
"fields": {
|
|
776
|
+
"summary": "Demo issue",
|
|
777
|
+
"status": {"name": "In Progress"},
|
|
778
|
+
"issuetype": {"name": "Task"},
|
|
779
|
+
"project": {"key": "DEMO"},
|
|
780
|
+
"updated": "2026-04-09T18:20:00.000+0900",
|
|
781
|
+
"comment": {
|
|
782
|
+
"total": 1,
|
|
783
|
+
"comments": [
|
|
784
|
+
{
|
|
785
|
+
"id": "1001",
|
|
786
|
+
"author": {"displayName": "Alex"},
|
|
787
|
+
"created": "2026-04-09T09:00:00.000+0900",
|
|
788
|
+
"updated": "2026-04-09T09:10:00.000+0900",
|
|
789
|
+
"body": "Latest comment body",
|
|
790
|
+
}
|
|
791
|
+
],
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
with mock.patch("conjira_cli.cli.build_jira_settings", return_value=settings), mock.patch(
|
|
797
|
+
"conjira_cli.cli.JiraClient.get_issue",
|
|
798
|
+
return_value=issue,
|
|
799
|
+
) as mock_get_issue:
|
|
800
|
+
payload = _handle_jira(args)
|
|
801
|
+
|
|
802
|
+
self.assertEqual(payload["key"], "DEMO-123")
|
|
803
|
+
self.assertEqual(payload["updated"], "2026-04-09T18:20:00.000+0900")
|
|
804
|
+
self.assertEqual(payload["comment_count"], 1)
|
|
805
|
+
self.assertEqual(payload["recent_comments"][0]["body_preview"], "Latest comment body")
|
|
806
|
+
mock_get_issue.assert_called_once_with(
|
|
807
|
+
"DEMO-123",
|
|
808
|
+
fields="summary,status,issuetype,project,assignee,reporter,updated,comment",
|
|
809
|
+
expand=None,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
def test_handle_jira_get_issue_raw_returns_full_payload(self) -> None:
|
|
813
|
+
args = SimpleNamespace(
|
|
814
|
+
command="jira-get-issue",
|
|
815
|
+
base_url=None,
|
|
816
|
+
token=None,
|
|
817
|
+
token_file=None,
|
|
818
|
+
token_keychain_service=None,
|
|
819
|
+
token_keychain_account=None,
|
|
820
|
+
timeout=None,
|
|
821
|
+
env_file=None,
|
|
822
|
+
issue_key="DEMO-123",
|
|
823
|
+
fields="summary,updated",
|
|
824
|
+
expand="changelog",
|
|
825
|
+
include_comments=False,
|
|
826
|
+
comments_limit=3,
|
|
827
|
+
raw=True,
|
|
828
|
+
)
|
|
829
|
+
settings = JiraSettings(
|
|
830
|
+
base_url="https://jira.example.com",
|
|
831
|
+
token="token",
|
|
832
|
+
timeout_seconds=30,
|
|
833
|
+
)
|
|
834
|
+
issue = {
|
|
835
|
+
"id": "9000",
|
|
836
|
+
"key": "DEMO-123",
|
|
837
|
+
"fields": {"summary": "Demo issue", "updated": "2026-04-09T18:20:00.000+0900"},
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
with mock.patch("conjira_cli.cli.build_jira_settings", return_value=settings), mock.patch(
|
|
841
|
+
"conjira_cli.cli.JiraClient.get_issue",
|
|
842
|
+
return_value=issue,
|
|
843
|
+
) as mock_get_issue:
|
|
844
|
+
payload = _handle_jira(args)
|
|
845
|
+
|
|
846
|
+
self.assertEqual(payload, issue)
|
|
847
|
+
mock_get_issue.assert_called_once_with("DEMO-123", fields="summary,updated", expand="changelog")
|
|
848
|
+
|
|
849
|
+
def test_handle_jira_search_raw_returns_full_payload(self) -> None:
|
|
850
|
+
args = SimpleNamespace(
|
|
851
|
+
command="jira-search",
|
|
852
|
+
base_url=None,
|
|
853
|
+
token=None,
|
|
854
|
+
token_file=None,
|
|
855
|
+
token_keychain_service=None,
|
|
856
|
+
token_keychain_account=None,
|
|
857
|
+
timeout=None,
|
|
858
|
+
env_file=None,
|
|
859
|
+
jql="project = DEMO",
|
|
860
|
+
limit=5,
|
|
861
|
+
start=0,
|
|
862
|
+
fields="summary,updated",
|
|
863
|
+
expand=None,
|
|
864
|
+
raw=True,
|
|
865
|
+
)
|
|
866
|
+
settings = JiraSettings(
|
|
867
|
+
base_url="https://jira.example.com",
|
|
868
|
+
token="token",
|
|
869
|
+
timeout_seconds=30,
|
|
870
|
+
)
|
|
871
|
+
result = {
|
|
872
|
+
"issues": [
|
|
873
|
+
{
|
|
874
|
+
"id": "9000",
|
|
875
|
+
"key": "DEMO-123",
|
|
876
|
+
"fields": {"summary": "Demo issue"},
|
|
877
|
+
}
|
|
878
|
+
],
|
|
879
|
+
"total": 1,
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
with mock.patch("conjira_cli.cli.build_jira_settings", return_value=settings), mock.patch(
|
|
883
|
+
"conjira_cli.cli.JiraClient.search",
|
|
884
|
+
return_value=result,
|
|
885
|
+
) as mock_search:
|
|
886
|
+
payload = _handle_jira(args)
|
|
887
|
+
|
|
888
|
+
self.assertEqual(payload, result)
|
|
889
|
+
mock_search.assert_called_once_with(
|
|
890
|
+
jql="project = DEMO",
|
|
891
|
+
limit=5,
|
|
892
|
+
start=0,
|
|
893
|
+
fields="summary,updated",
|
|
894
|
+
expand=None,
|
|
895
|
+
)
|
|
896
|
+
|
|
614
897
|
def test_handle_confluence_upload_attachment_dry_run_detects_replace_mode(self) -> None:
|
|
615
898
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
616
899
|
file_path = Path(tmp_dir) / "chart.png"
|
|
@@ -109,6 +109,7 @@ class ClientTests(unittest.TestCase):
|
|
|
109
109
|
"project": {"key": "TEST"},
|
|
110
110
|
"assignee": {"displayName": "Assignee User"},
|
|
111
111
|
"reporter": {"displayName": "Reporter User"},
|
|
112
|
+
"updated": "2026-04-09T18:20:00.000+0900",
|
|
112
113
|
},
|
|
113
114
|
}
|
|
114
115
|
|
|
@@ -117,8 +118,49 @@ class ClientTests(unittest.TestCase):
|
|
|
117
118
|
self.assertEqual(summary["key"], "TEST-9")
|
|
118
119
|
self.assertEqual(summary["status"], "In Progress")
|
|
119
120
|
self.assertEqual(summary["issue_type"], "Task")
|
|
121
|
+
self.assertEqual(summary["updated"], "2026-04-09T18:20:00.000+0900")
|
|
120
122
|
self.assertEqual(summary["browse_url"], "https://jira.example.com/browse/TEST-9")
|
|
121
123
|
|
|
124
|
+
def test_summarize_issue_can_include_recent_comments(self) -> None:
|
|
125
|
+
client = JiraClient(base_url="https://jira.example.com", token="token")
|
|
126
|
+
issue = {
|
|
127
|
+
"id": "456",
|
|
128
|
+
"key": "TEST-9",
|
|
129
|
+
"fields": {
|
|
130
|
+
"summary": "Demo issue",
|
|
131
|
+
"status": {"name": "In Progress"},
|
|
132
|
+
"issuetype": {"name": "Task"},
|
|
133
|
+
"project": {"key": "TEST"},
|
|
134
|
+
"updated": "2026-04-09T18:20:00.000+0900",
|
|
135
|
+
"comment": {
|
|
136
|
+
"total": 2,
|
|
137
|
+
"comments": [
|
|
138
|
+
{
|
|
139
|
+
"id": "1001",
|
|
140
|
+
"author": {"displayName": "Alex"},
|
|
141
|
+
"created": "2026-04-08T10:00:00.000+0900",
|
|
142
|
+
"updated": "2026-04-08T10:00:00.000+0900",
|
|
143
|
+
"body": "First comment",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"id": "1002",
|
|
147
|
+
"author": {"displayName": "Taylor"},
|
|
148
|
+
"created": "2026-04-09T09:00:00.000+0900",
|
|
149
|
+
"updated": "2026-04-09T09:10:00.000+0900",
|
|
150
|
+
"body": "Second comment with a longer body that should still remain readable.",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
summary = client.summarize_issue(issue, include_comments=True, comments_limit=1)
|
|
158
|
+
|
|
159
|
+
self.assertEqual(summary["comment_count"], 2)
|
|
160
|
+
self.assertEqual(len(summary["recent_comments"]), 1)
|
|
161
|
+
self.assertEqual(summary["recent_comments"][0]["id"], "1002")
|
|
162
|
+
self.assertEqual(summary["recent_comments"][0]["author"], "Taylor")
|
|
163
|
+
|
|
122
164
|
def test_list_inline_comments_fetches_all_pages(self) -> None:
|
|
123
165
|
client = ConfluenceClient(base_url="https://confluence.example.com", token="token")
|
|
124
166
|
|
|
@@ -38,6 +38,38 @@ class MarkdownExportTests(unittest.TestCase):
|
|
|
38
38
|
|
|
39
39
|
self.assertIn("confluence_parent_page_id: 100", result)
|
|
40
40
|
|
|
41
|
+
def test_convert_page_renders_hub_children_as_index_content(self) -> None:
|
|
42
|
+
exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
|
|
43
|
+
|
|
44
|
+
result = exporter.convert_page(
|
|
45
|
+
{
|
|
46
|
+
"title": "상세기획 루트",
|
|
47
|
+
"version": 7,
|
|
48
|
+
"webui_url": "https://confluence.example.com/pages/123",
|
|
49
|
+
"body_html": "",
|
|
50
|
+
"page_kind": "hub",
|
|
51
|
+
"child_count": 2,
|
|
52
|
+
"children": [
|
|
53
|
+
{
|
|
54
|
+
"id": "20001",
|
|
55
|
+
"title": "메시지 AI 제안",
|
|
56
|
+
"webui_url": "https://confluence.example.com/pages/20001",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "20002",
|
|
60
|
+
"title": "메시지 요약",
|
|
61
|
+
"webui_url": "https://confluence.example.com/pages/20002",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
self.assertIn("confluence_page_kind: hub", result)
|
|
68
|
+
self.assertIn("confluence_child_count: 2", result)
|
|
69
|
+
self.assertIn("## Child pages", result)
|
|
70
|
+
self.assertIn("| 메시지 AI 제안 | 20001 | [Open](https://confluence.example.com/pages/20001) |", result)
|
|
71
|
+
self.assertIn("| 메시지 요약 | 20002 | [Open](https://confluence.example.com/pages/20002) |", result)
|
|
72
|
+
|
|
41
73
|
def test_structured_table_renders_as_sections(self) -> None:
|
|
42
74
|
exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
|
|
43
75
|
html = (
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|