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.
Files changed (31) hide show
  1. {conjira_cli-0.2.2/src/conjira_cli.egg-info → conjira_cli-0.2.3}/PKG-INFO +53 -1
  2. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/README.md +52 -0
  3. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/pyproject.toml +1 -1
  4. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/__init__.py +1 -1
  5. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/cli.py +112 -5
  6. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/client.py +37 -2
  7. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/markdown_export.py +34 -1
  8. {conjira_cli-0.2.2 → conjira_cli-0.2.3/src/conjira_cli.egg-info}/PKG-INFO +53 -1
  9. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_cli.py +283 -0
  10. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_client.py +42 -0
  11. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_markdown_export.py +32 -0
  12. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/LICENSE +0 -0
  13. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/setup.cfg +0 -0
  14. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/setup.py +0 -0
  15. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/__main__.py +0 -0
  16. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/config.py +0 -0
  17. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/inline_comments.py +0 -0
  18. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/markdown_import.py +0 -0
  19. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/section_edit.py +0 -0
  20. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/setup_macos.py +0 -0
  21. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli/tree_export.py +0 -0
  22. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
  23. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
  24. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/entry_points.txt +0 -0
  25. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/src/conjira_cli.egg-info/top_level.txt +0 -0
  26. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_config.py +0 -0
  27. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_inline_comments.py +0 -0
  28. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_markdown_import.py +0 -0
  29. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_section_edit.py +0 -0
  30. {conjira_cli-0.2.2 → conjira_cli-0.2.3}/tests/test_setup_macos.py +0 -0
  31. {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.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conjira-cli"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Unofficial agent-friendly CLI for self-hosted Confluence and Jira"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -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"] = (((page.get("body") or {}).get("storage") or {}).get("value")) or ""
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
- page = client.get_page(args.page_id, expand=args.expand)
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"] = (((page.get("body") or {}).get("storage") or {}).get("value"))
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
- issue = client.get_issue(args.issue_key, fields=args.fields, expand=args.expand)
1197
- return client.summarize_issue(issue)
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
- def summarize_issue(self, issue: Dict[str, Any]) -> Dict[str, Any]:
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
- return {
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
- body_md = self.convert_fragment(body_html)
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.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