conjira-cli 0.2.3__tar.gz → 0.2.5__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.3/src/conjira_cli.egg-info → conjira_cli-0.2.5}/PKG-INFO +7 -1
  2. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/README.md +6 -0
  3. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/pyproject.toml +1 -1
  4. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/__init__.py +1 -1
  5. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/cli.py +96 -1
  6. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/client.py +34 -1
  7. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/markdown_export.py +12 -1
  8. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/markdown_import.py +69 -3
  9. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/section_edit.py +74 -23
  10. {conjira_cli-0.2.3 → conjira_cli-0.2.5/src/conjira_cli.egg-info}/PKG-INFO +7 -1
  11. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_cli.py +143 -0
  12. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_client.py +81 -0
  13. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_markdown_export.py +46 -0
  14. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_markdown_import.py +145 -0
  15. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_section_edit.py +46 -1
  16. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/LICENSE +0 -0
  17. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/setup.cfg +0 -0
  18. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/setup.py +0 -0
  19. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/__main__.py +0 -0
  20. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/config.py +0 -0
  21. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/inline_comments.py +0 -0
  22. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/setup_macos.py +0 -0
  23. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli/tree_export.py +0 -0
  24. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
  25. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
  26. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/entry_points.txt +0 -0
  27. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/top_level.txt +0 -0
  28. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_config.py +0 -0
  29. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_inline_comments.py +0 -0
  30. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/tests/test_setup_macos.py +0 -0
  31. {conjira_cli-0.2.3 → conjira_cli-0.2.5}/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
3
+ Version: 0.2.5
4
4
  Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
5
5
  Author: quanttraderkim
6
6
  License-Expression: MIT
@@ -338,6 +338,12 @@ Replace one named section on an existing Confluence page:
338
338
  conjira --env-file ./local/agent.env replace-section --allow-write --page-id 100002 --heading "Rollout plan" --section-markdown-file ./notes/rollout.md
339
339
  ```
340
340
 
341
+ Insert new content immediately after a named heading:
342
+
343
+ ```bash
344
+ conjira --env-file ./local/agent.env insert-after-heading --dry-run --page-id 100002 --heading "Rollout plan" --insert-markdown-file ./notes/rollout-intro.md
345
+ ```
346
+
341
347
  Move an existing Confluence page under a different parent page:
342
348
 
343
349
  ```bash
@@ -309,6 +309,12 @@ Replace one named section on an existing Confluence page:
309
309
  conjira --env-file ./local/agent.env replace-section --allow-write --page-id 100002 --heading "Rollout plan" --section-markdown-file ./notes/rollout.md
310
310
  ```
311
311
 
312
+ Insert new content immediately after a named heading:
313
+
314
+ ```bash
315
+ conjira --env-file ./local/agent.env insert-after-heading --dry-run --page-id 100002 --heading "Rollout plan" --insert-markdown-file ./notes/rollout-intro.md
316
+ ```
317
+
312
318
  Move an existing Confluence page under a different parent page:
313
319
 
314
320
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conjira-cli"
7
- version = "0.2.3"
7
+ version = "0.2.5"
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.3"
3
+ __version__ = "0.2.4"
@@ -23,7 +23,11 @@ from conjira_cli.config import (
23
23
  from conjira_cli.inline_comments import render_inline_comment_summary_markdown
24
24
  from conjira_cli.markdown_export import MarkdownExporter
25
25
  from conjira_cli.markdown_import import markdown_to_storage_html
26
- from conjira_cli.section_edit import SectionEditError, replace_section_html
26
+ from conjira_cli.section_edit import (
27
+ SectionEditError,
28
+ insert_after_heading_html,
29
+ replace_section_html,
30
+ )
27
31
  from conjira_cli.tree_export import export_page_tree, sanitize_path_component
28
32
 
29
33
  _JIRA_SUMMARY_FIELDS = [
@@ -402,6 +406,22 @@ def _build_parser() -> argparse.ArgumentParser:
402
406
  replace_section_body_group.add_argument("--section-markdown")
403
407
  replace_section_body_group.add_argument("--section-markdown-file")
404
408
 
409
+ insert_after_heading = subparsers.add_parser(
410
+ "insert-after-heading",
411
+ help="Insert content immediately after a specific Confluence heading",
412
+ )
413
+ insert_after_heading.add_argument("--page-id", required=True)
414
+ insert_after_heading.add_argument("--heading", required=True)
415
+ insert_after_heading.add_argument("--allow-write", action="store_true")
416
+ insert_after_heading.add_argument("--dry-run", action="store_true")
417
+ insert_after_heading_body_group = insert_after_heading.add_mutually_exclusive_group(
418
+ required=True
419
+ )
420
+ insert_after_heading_body_group.add_argument("--insert-html")
421
+ insert_after_heading_body_group.add_argument("--insert-file")
422
+ insert_after_heading_body_group.add_argument("--insert-markdown")
423
+ insert_after_heading_body_group.add_argument("--insert-markdown-file")
424
+
405
425
  move_page = subparsers.add_parser(
406
426
  "move-page",
407
427
  help="Move an existing Confluence page under a different parent page",
@@ -634,6 +654,31 @@ def _confluence_replace_section_preview(
634
654
  }
635
655
 
636
656
 
657
+ def _confluence_insert_after_heading_preview(
658
+ *,
659
+ page: Dict[str, Any],
660
+ heading: str,
661
+ result: Any,
662
+ body_source: str,
663
+ ) -> Dict[str, Any]:
664
+ current_summary = ConfluenceClient.summarize_page(page)
665
+ return {
666
+ "dry_run": True,
667
+ "product": "confluence",
668
+ "action": "insert-after-heading",
669
+ "page_id": current_summary.get("id"),
670
+ "space_key": current_summary.get("space_key"),
671
+ "source_url": current_summary.get("webui_url"),
672
+ "heading": heading,
673
+ "matched_heading": result.matched_heading,
674
+ "heading_level": result.heading_level,
675
+ "body_source": body_source,
676
+ "inserted_after_heading": True,
677
+ "inserted_block_preview": _preview_html(result.inserted_html),
678
+ "resulting_body_preview": _preview_html(result.updated_body_html),
679
+ }
680
+
681
+
637
682
  def _confluence_move_page_preview(
638
683
  *,
639
684
  page: Dict[str, Any],
@@ -819,6 +864,11 @@ def _guidance_for_config_error(message: str) -> list[str]:
819
864
  "Check that the heading text exists exactly once on the live Confluence page before retrying replace-section.",
820
865
  "For the first iteration, replace-section is safest on text-first pages with clear heading structure.",
821
866
  ]
867
+ if "insert-after-heading target heading" in lowered:
868
+ return [
869
+ "Check that the heading text exists exactly once on the live Confluence page before retrying insert-after-heading.",
870
+ "insert-after-heading is safest on text-first pages with clear heading structure and a stable target heading.",
871
+ ]
822
872
  if "move-page requires different" in lowered:
823
873
  return [
824
874
  "Choose a different parent page before retrying move-page.",
@@ -1190,6 +1240,51 @@ def _handle_confluence(args: argparse.Namespace) -> Dict[str, Any]:
1190
1240
  payload["heading"] = args.heading
1191
1241
  payload["matched_heading"] = replacement.matched_heading
1192
1242
  return payload
1243
+ if args.command == "insert-after-heading":
1244
+ _require_write_intent(args.allow_write, args.dry_run)
1245
+ _assert_confluence_update_allowed(
1246
+ page_id=args.page_id,
1247
+ allowed_page_ids=settings.allowed_page_ids,
1248
+ )
1249
+ inserted_html = _read_confluence_body_arg(
1250
+ args.insert_html,
1251
+ args.insert_file,
1252
+ args.insert_markdown,
1253
+ args.insert_markdown_file,
1254
+ mermaid_macro_name=settings.mermaid_macro_name,
1255
+ )
1256
+ page = client.get_page(args.page_id, expand="body.storage,version,space")
1257
+ current_body = (((page.get("body") or {}).get("storage") or {}).get("value")) or ""
1258
+ try:
1259
+ insertion = insert_after_heading_html(
1260
+ current_body,
1261
+ heading=args.heading,
1262
+ inserted_html=inserted_html,
1263
+ )
1264
+ except SectionEditError as exc:
1265
+ raise ConfigError(str(exc)) from exc
1266
+ body_source = _confluence_body_source(
1267
+ raw_html=args.insert_html,
1268
+ html_file=args.insert_file,
1269
+ raw_markdown=args.insert_markdown,
1270
+ markdown_file=args.insert_markdown_file,
1271
+ )
1272
+ if args.dry_run:
1273
+ return _confluence_insert_after_heading_preview(
1274
+ page=page,
1275
+ heading=args.heading,
1276
+ result=insertion,
1277
+ body_source=body_source or "unknown",
1278
+ )
1279
+ updated = client.update_page_from_snapshot(
1280
+ page,
1281
+ new_body_html=insertion.updated_body_html,
1282
+ )
1283
+ payload = client.summarize_page(updated)
1284
+ payload["action"] = "insert-after-heading"
1285
+ payload["heading"] = args.heading
1286
+ payload["matched_heading"] = insertion.matched_heading
1287
+ return payload
1193
1288
  if args.command == "move-page":
1194
1289
  _require_write_intent(args.allow_write, args.dry_run)
1195
1290
  _assert_confluence_update_allowed(
@@ -5,6 +5,7 @@ import time
5
5
  import urllib.error
6
6
  import urllib.parse
7
7
  import urllib.request
8
+ import xml.etree.ElementTree as ET
8
9
  from typing import Any, Dict, Iterable, Optional
9
10
 
10
11
  from conjira_cli.inline_comments import build_inline_comment_summary
@@ -31,6 +32,28 @@ class JiraError(AtlassianError):
31
32
  pass
32
33
 
33
34
 
35
+ def validate_storage_html(body_html: str) -> None:
36
+ """Check that *body_html* is well-formed XHTML before sending to Confluence.
37
+
38
+ Raises :class:`ConfluenceError` with a descriptive message if parsing
39
+ fails, giving the caller a chance to fix the content instead of getting
40
+ a cryptic 400 from the server.
41
+ """
42
+ wrapped = (
43
+ '<root xmlns:ac="urn:ac" xmlns:ri="urn:ri"'
44
+ ' xmlns:atlassian="urn:atlassian">'
45
+ + body_html
46
+ + "</root>"
47
+ )
48
+ try:
49
+ ET.fromstring(wrapped)
50
+ except ET.ParseError as exc:
51
+ raise ConfluenceError(
52
+ f"Body HTML is not well-formed XHTML — Confluence will reject it. "
53
+ f"Detail: {exc}",
54
+ ) from exc
55
+
56
+
34
57
  class BaseAtlassianClient:
35
58
  product_name = "Atlassian"
36
59
  error_cls = AtlassianError
@@ -83,8 +106,15 @@ class BaseAtlassianClient:
83
106
  raw = response.read().decode("utf-8")
84
107
  if not raw:
85
108
  return None
86
- if "application/json" in response.headers.get("Content-Type", ""):
109
+ content_type = response.headers.get("Content-Type", "")
110
+ if "application/json" in content_type:
87
111
  return json.loads(raw)
112
+ stripped = raw.lstrip()
113
+ if stripped.startswith("{") or stripped.startswith("["):
114
+ try:
115
+ return json.loads(raw)
116
+ except json.JSONDecodeError:
117
+ pass
88
118
  return raw
89
119
  except urllib.error.HTTPError as exc:
90
120
  raw = exc.read().decode("utf-8", errors="replace")
@@ -168,6 +198,7 @@ class ConfluenceClient(BaseAtlassianClient):
168
198
  body_html: str,
169
199
  parent_id: Optional[str] = None,
170
200
  ) -> Dict[str, Any]:
201
+ validate_storage_html(body_html)
171
202
  payload: Dict[str, Any] = {
172
203
  "type": "page",
173
204
  "title": title,
@@ -215,6 +246,8 @@ class ConfluenceClient(BaseAtlassianClient):
215
246
  if append_html:
216
247
  updated_body += append_html
217
248
 
249
+ validate_storage_html(updated_body)
250
+
218
251
  payload = {
219
252
  "id": current["id"],
220
253
  "type": current["type"],
@@ -470,16 +470,27 @@ class MarkdownExporter:
470
470
  child_name = _local_name(child.tag)
471
471
  if child_name in {"ul", "ol"}:
472
472
  list_text = self._render_block(child, indent=0).strip().replace("\n", "<br>")
473
+ # Escape unescaped pipes BEFORE joining so nested content
474
+ # doesn't break the outer table column boundaries.
475
+ # Use negative lookbehind to avoid double-escaping pipes
476
+ # that were already escaped by deeper recursion.
477
+ list_text = re.sub(r"(?<!\\)\|", r"\\|", list_text)
473
478
  pieces.append(list_text)
474
479
  elif child_name == "table":
475
480
  table_text = self._render_table(child).replace("\n", "<br>")
481
+ # Escape unescaped pipes from the nested table so they
482
+ # don't create spurious columns in the parent table row.
483
+ table_text = re.sub(r"(?<!\\)\|", r"\\|", table_text)
476
484
  pieces.append(table_text)
477
485
  else:
478
486
  pieces.append(self._render_inline(child))
479
487
  if child.tail and _collapse_inline(child.tail):
480
488
  pieces.append(_collapse_inline(child.tail))
481
489
  joined = _join_inline_pieces(pieces)
482
- joined = joined.replace("|", "\\|")
490
+ # Escape any remaining unescaped pipes from inline content.
491
+ # Use a negative lookbehind to avoid double-escaping pipes that
492
+ # were already escaped above.
493
+ joined = re.sub(r"(?<!\\)\|", r"\\|", joined)
483
494
  joined = joined.replace("\n", "<br>")
484
495
  joined = re.sub(r"\s{2,}", " ", joined)
485
496
  joined = re.sub(r"^(<br>\s*)+$", "", joined)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import html
4
4
  import os
5
5
  import re
6
+ import xml.etree.ElementTree as ET
6
7
  from typing import Optional
7
8
 
8
9
 
@@ -39,7 +40,33 @@ def markdown_to_storage_html(
39
40
  markdown = _strip_frontmatter(markdown).replace("\r\n", "\n").replace("\r", "\n")
40
41
  lines = markdown.split("\n")
41
42
  html_parts, _ = _parse_blocks(lines, 0, mermaid_macro_name=mermaid_macro_name)
42
- return "".join(html_parts).strip()
43
+ result = "".join(html_parts).strip()
44
+ return _ensure_xhtml_self_closing(result)
45
+
46
+
47
+ # Tags that must be self-closing in Confluence XHTML Storage Format
48
+ _VOID_TAGS = {"br", "hr", "img"}
49
+ _VOID_TAG_RE = re.compile(
50
+ r"<(" + "|".join(_VOID_TAGS) + r")((?:\s[^>]*?)?)(\s*/?)>",
51
+ re.IGNORECASE,
52
+ )
53
+
54
+
55
+ def _ensure_xhtml_self_closing(html_str: str) -> str:
56
+ """Normalise void HTML tags to XHTML self-closing form.
57
+
58
+ Confluence Storage Format requires strict XHTML: ``<br />`` not ``<br>``.
59
+ Handles ``<br>``, ``<br/>``, ``<br />``, and variants with attributes.
60
+ """
61
+ def _fix(m: re.Match[str]) -> str:
62
+ tag = m.group(1)
63
+ attrs = (m.group(2) or "").rstrip()
64
+ # Strip any trailing slash already present in attrs
65
+ if attrs.endswith("/"):
66
+ attrs = attrs[:-1].rstrip()
67
+ return f"<{tag}{attrs} />"
68
+
69
+ return _VOID_TAG_RE.sub(_fix, html_str)
43
70
 
44
71
 
45
72
  def _strip_frontmatter(markdown: str) -> str:
@@ -345,19 +372,58 @@ def _parse_table(lines: list[str], start: int) -> tuple[str, int]:
345
372
  if has_header:
346
373
  parts.append(
347
374
  "<tr>{0}</tr>".format(
348
- "".join("<th>{0}</th>".format(_render_inline(cell)) for cell in rows[0])
375
+ "".join("<th>{0}</th>".format(_render_table_cell(cell)) for cell in rows[0])
349
376
  )
350
377
  )
351
378
  for row in body_rows:
352
379
  parts.append(
353
380
  "<tr>{0}</tr>".format(
354
- "".join("<td>{0}</td>".format(_render_inline(cell)) for cell in row)
381
+ "".join("<td>{0}</td>".format(_render_table_cell(cell)) for cell in row)
355
382
  )
356
383
  )
357
384
  parts.append("</tbody></table>")
358
385
  return "".join(parts), i
359
386
 
360
387
 
388
+ _CELL_HTML_BLOCK_PREFIX_RE = re.compile(
389
+ r"^<(ul|ol|p|div|table|blockquote|h[1-6]|ac:[\w-]+|ri:[\w-]+|atlassian:[\w-]+)\b",
390
+ re.IGNORECASE,
391
+ )
392
+ _CELL_XML_WRAPPER_PREFIX = (
393
+ '<root xmlns:ac="urn:ac" xmlns:ri="urn:ri" xmlns:atlassian="urn:atlassian">'
394
+ )
395
+
396
+
397
+ def _render_table_cell(cell: str) -> str:
398
+ """Render a markdown table cell.
399
+
400
+ Standard markdown tables only support inline content per cell, but
401
+ Confluence cells frequently need nested structures (``<ul><li>``,
402
+ ``<p>``, etc.). When the cell content is a well-formed HTML block,
403
+ we pass it through unchanged so users can hand-author rich cells
404
+ without leaving the markdown pipeline.
405
+
406
+ Pass-through covers the entire cell: any trailing content after the
407
+ HTML block is emitted verbatim and not re-rendered as markdown.
408
+ """
409
+ stripped = cell.strip()
410
+ if (
411
+ stripped.startswith("<")
412
+ and _CELL_HTML_BLOCK_PREFIX_RE.match(stripped)
413
+ and _is_well_formed_xhtml(stripped)
414
+ ):
415
+ return stripped
416
+ return _render_inline(cell)
417
+
418
+
419
+ def _is_well_formed_xhtml(fragment: str) -> bool:
420
+ try:
421
+ ET.fromstring(_CELL_XML_WRAPPER_PREFIX + fragment + "</root>")
422
+ except ET.ParseError:
423
+ return False
424
+ return True
425
+
426
+
361
427
  def _split_table_row(line: str) -> list[str]:
362
428
  stripped = line.strip()
363
429
  if stripped.startswith("|"):
@@ -27,6 +27,15 @@ class SectionReplacementResult:
27
27
  updated_body_html: str
28
28
 
29
29
 
30
+ @dataclass
31
+ class HeadingInsertionResult:
32
+ heading: str
33
+ matched_heading: str
34
+ heading_level: int
35
+ inserted_html: str
36
+ updated_body_html: str
37
+
38
+
30
39
  def replace_section_html(
31
40
  body_html: str,
32
41
  *,
@@ -35,29 +44,11 @@ def replace_section_html(
35
44
  ) -> SectionReplacementResult:
36
45
  root = _parse_fragment(body_html)
37
46
  children = list(root)
38
- normalized_target = _normalize_heading(heading)
39
-
40
- matches: list[tuple[int, ET.Element, int]] = []
41
- for index, child in enumerate(children):
42
- heading_level = _heading_level(child)
43
- if heading_level is None:
44
- continue
45
- rendered_heading = _element_text(child)
46
- if _normalize_heading(rendered_heading) == normalized_target:
47
- matches.append((index, child, heading_level))
48
-
49
- if not matches:
50
- raise SectionEditError(
51
- 'replace-section target heading "{0}" was not found.'.format(heading)
52
- )
53
- if len(matches) > 1:
54
- raise SectionEditError(
55
- 'replace-section target heading "{0}" is ambiguous because it appears multiple times.'.format(
56
- heading
57
- )
58
- )
59
-
60
- match_index, match_elem, match_level = matches[0]
47
+ match_index, match_elem, match_level = _find_unique_heading(
48
+ children,
49
+ heading=heading,
50
+ action_name="replace-section",
51
+ )
61
52
  end_index = len(children)
62
53
  for index in range(match_index + 1, len(children)):
63
54
  next_level = _heading_level(children[index])
@@ -87,6 +78,36 @@ def replace_section_html(
87
78
  )
88
79
 
89
80
 
81
+ def insert_after_heading_html(
82
+ body_html: str,
83
+ *,
84
+ heading: str,
85
+ inserted_html: str,
86
+ ) -> HeadingInsertionResult:
87
+ root = _parse_fragment(body_html)
88
+ children = list(root)
89
+ match_index, match_elem, match_level = _find_unique_heading(
90
+ children,
91
+ heading=heading,
92
+ action_name="insert-after-heading",
93
+ )
94
+
95
+ new_root = _parse_fragment(inserted_html)
96
+ new_children = [copy.deepcopy(child) for child in list(new_root)]
97
+
98
+ insert_at = match_index + 1
99
+ for offset, child in enumerate(new_children):
100
+ root.insert(insert_at + offset, child)
101
+
102
+ return HeadingInsertionResult(
103
+ heading=heading,
104
+ matched_heading=_element_text(match_elem),
105
+ heading_level=match_level,
106
+ inserted_html=_serialize_elements(new_children),
107
+ updated_body_html=_serialize_root(root),
108
+ )
109
+
110
+
90
111
  def _parse_fragment(fragment_html: str) -> ET.Element:
91
112
  wrapped = _WRAPPED_ROOT_PREFIX + fragment_html + "</root>"
92
113
  try:
@@ -103,6 +124,36 @@ def _serialize_elements(elements: list[ET.Element]) -> str:
103
124
  return "".join(ET.tostring(element, encoding="unicode") for element in elements).strip()
104
125
 
105
126
 
127
+ def _find_unique_heading(
128
+ children: list[ET.Element],
129
+ *,
130
+ heading: str,
131
+ action_name: str,
132
+ ) -> tuple[int, ET.Element, int]:
133
+ normalized_target = _normalize_heading(heading)
134
+ matches: list[tuple[int, ET.Element, int]] = []
135
+ for index, child in enumerate(children):
136
+ heading_level = _heading_level(child)
137
+ if heading_level is None:
138
+ continue
139
+ rendered_heading = _element_text(child)
140
+ if _normalize_heading(rendered_heading) == normalized_target:
141
+ matches.append((index, child, heading_level))
142
+
143
+ if not matches:
144
+ raise SectionEditError(
145
+ '{0} target heading "{1}" was not found.'.format(action_name, heading)
146
+ )
147
+ if len(matches) > 1:
148
+ raise SectionEditError(
149
+ '{0} target heading "{1}" is ambiguous because it appears multiple times.'.format(
150
+ action_name,
151
+ heading,
152
+ )
153
+ )
154
+ return matches[0]
155
+
156
+
106
157
  def _local_name(tag: str) -> str:
107
158
  if "}" in tag:
108
159
  return tag.split("}", 1)[1]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conjira-cli
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
5
5
  Author: quanttraderkim
6
6
  License-Expression: MIT
@@ -338,6 +338,12 @@ Replace one named section on an existing Confluence page:
338
338
  conjira --env-file ./local/agent.env replace-section --allow-write --page-id 100002 --heading "Rollout plan" --section-markdown-file ./notes/rollout.md
339
339
  ```
340
340
 
341
+ Insert new content immediately after a named heading:
342
+
343
+ ```bash
344
+ conjira --env-file ./local/agent.env insert-after-heading --dry-run --page-id 100002 --heading "Rollout plan" --insert-markdown-file ./notes/rollout-intro.md
345
+ ```
346
+
341
347
  Move an existing Confluence page under a different parent page:
342
348
 
343
349
  ```bash
@@ -570,6 +570,141 @@ class CliTests(unittest.TestCase):
570
570
  self.assertEqual(mock_update_page.call_args.args[0]["id"], "12345")
571
571
  self.assertIn("<p>Replacement</p>", mock_update_page.call_args.kwargs["new_body_html"])
572
572
 
573
+ def test_handle_confluence_insert_after_heading_dry_run_returns_preview(self) -> None:
574
+ args = SimpleNamespace(
575
+ command="insert-after-heading",
576
+ base_url=None,
577
+ token=None,
578
+ token_file=None,
579
+ token_keychain_service=None,
580
+ token_keychain_account=None,
581
+ timeout=None,
582
+ env_file=None,
583
+ page_id="12345",
584
+ heading="Install",
585
+ allow_write=False,
586
+ dry_run=True,
587
+ insert_html=None,
588
+ insert_file=None,
589
+ insert_markdown="Prepended install note",
590
+ insert_markdown_file=None,
591
+ )
592
+ settings = ConfluenceSettings(
593
+ base_url="https://confluence.example.com",
594
+ token="token",
595
+ timeout_seconds=30,
596
+ )
597
+ page = {
598
+ "id": "12345",
599
+ "type": "page",
600
+ "title": "Guide",
601
+ "space": {"key": "DOCS"},
602
+ "version": {"number": 7},
603
+ "body": {
604
+ "storage": {
605
+ "value": (
606
+ "<h1>Guide</h1>"
607
+ "<h2>Install</h2>"
608
+ "<p>Old step</p>"
609
+ "<h2>Usage</h2>"
610
+ "<p>Run command</p>"
611
+ )
612
+ }
613
+ },
614
+ "_links": {
615
+ "base": "https://confluence.example.com",
616
+ "webui": "/pages/viewpage.action?pageId=12345",
617
+ },
618
+ }
619
+
620
+ with mock.patch("conjira_cli.cli.build_confluence_settings", return_value=settings), mock.patch(
621
+ "conjira_cli.cli.ConfluenceClient.get_page",
622
+ return_value=page,
623
+ ) as mock_get_page, mock.patch(
624
+ "conjira_cli.cli.ConfluenceClient.update_page_from_snapshot"
625
+ ) as mock_update_page:
626
+ payload = _handle_confluence(args)
627
+
628
+ self.assertTrue(payload["dry_run"])
629
+ self.assertEqual(payload["action"], "insert-after-heading")
630
+ self.assertEqual(payload["page_id"], "12345")
631
+ self.assertEqual(payload["heading"], "Install")
632
+ self.assertEqual(payload["matched_heading"], "Install")
633
+ self.assertEqual(payload["body_source"], "markdown")
634
+ self.assertIn("Prepended install note", payload["inserted_block_preview"])
635
+ mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space")
636
+ mock_update_page.assert_not_called()
637
+
638
+ def test_handle_confluence_insert_after_heading_write_updates_page(self) -> None:
639
+ args = SimpleNamespace(
640
+ command="insert-after-heading",
641
+ base_url=None,
642
+ token=None,
643
+ token_file=None,
644
+ token_keychain_service=None,
645
+ token_keychain_account=None,
646
+ timeout=None,
647
+ env_file=None,
648
+ page_id="12345",
649
+ heading="Install",
650
+ allow_write=True,
651
+ dry_run=False,
652
+ insert_html="<p>Inserted</p>",
653
+ insert_file=None,
654
+ insert_markdown=None,
655
+ insert_markdown_file=None,
656
+ )
657
+ settings = ConfluenceSettings(
658
+ base_url="https://confluence.example.com",
659
+ token="token",
660
+ timeout_seconds=30,
661
+ )
662
+ page = {
663
+ "id": "12345",
664
+ "type": "page",
665
+ "title": "Guide",
666
+ "space": {"key": "DOCS"},
667
+ "version": {"number": 7},
668
+ "body": {
669
+ "storage": {
670
+ "value": "<h2>Install</h2><p>Old step</p><h2>Usage</h2><p>Run command</p>"
671
+ }
672
+ },
673
+ "_links": {
674
+ "base": "https://confluence.example.com",
675
+ "webui": "/pages/viewpage.action?pageId=12345",
676
+ },
677
+ }
678
+ updated_summary = {
679
+ "id": "12345",
680
+ "type": "page",
681
+ "status": "current",
682
+ "title": "Guide",
683
+ "space": {"key": "DOCS"},
684
+ "version": {"number": 8},
685
+ "_links": {
686
+ "base": "https://confluence.example.com",
687
+ "webui": "/pages/viewpage.action?pageId=12345",
688
+ },
689
+ }
690
+
691
+ with mock.patch("conjira_cli.cli.build_confluence_settings", return_value=settings), mock.patch(
692
+ "conjira_cli.cli.ConfluenceClient.get_page",
693
+ return_value=page,
694
+ ) as mock_get_page, mock.patch(
695
+ "conjira_cli.cli.ConfluenceClient.update_page_from_snapshot",
696
+ return_value=updated_summary,
697
+ ) as mock_update_page:
698
+ payload = _handle_confluence(args)
699
+
700
+ self.assertEqual(payload["action"], "insert-after-heading")
701
+ self.assertEqual(payload["heading"], "Install")
702
+ self.assertEqual(payload["matched_heading"], "Install")
703
+ mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space")
704
+ mock_update_page.assert_called_once()
705
+ self.assertEqual(mock_update_page.call_args.args[0]["id"], "12345")
706
+ self.assertIn("<h2>Install</h2><p>Inserted</p><p>Old step</p>", mock_update_page.call_args.kwargs["new_body_html"])
707
+
573
708
  def test_handle_confluence_move_page_dry_run_returns_preview(self) -> None:
574
709
  args = SimpleNamespace(
575
710
  command="move-page",
@@ -691,6 +826,14 @@ class CliTests(unittest.TestCase):
691
826
  self.assertEqual(payload["error_type"], "ConfigError")
692
827
  self.assertTrue(any("heading" in item.lower() for item in payload["guidance"]))
693
828
 
829
+ def test_build_error_payload_adds_insert_after_heading_guidance(self) -> None:
830
+ payload = _build_error_payload(
831
+ ConfigError('insert-after-heading target heading "Install" was not found.')
832
+ )
833
+
834
+ self.assertEqual(payload["error_type"], "ConfigError")
835
+ self.assertTrue(any("insert-after-heading" in item.lower() for item in payload["guidance"]))
836
+
694
837
  def test_build_error_payload_adds_move_page_guidance(self) -> None:
695
838
  payload = _build_error_payload(
696
839
  ConfigError("move-page requires different current and new parent IDs.")
@@ -4,6 +4,21 @@ from unittest import mock
4
4
  from conjira_cli.client import ConfluenceClient, JiraClient
5
5
 
6
6
 
7
+ class _FakeHTTPResponse:
8
+ def __init__(self, body: str, *, content_type: str) -> None:
9
+ self._body = body.encode("utf-8")
10
+ self.headers = {"Content-Type": content_type}
11
+
12
+ def read(self) -> bytes:
13
+ return self._body
14
+
15
+ def __enter__(self) -> "_FakeHTTPResponse":
16
+ return self
17
+
18
+ def __exit__(self, exc_type, exc, tb) -> bool:
19
+ return False
20
+
21
+
7
22
  class ClientTests(unittest.TestCase):
8
23
  def test_update_page_from_snapshot_uses_snapshot_version_and_id(self) -> None:
9
24
  client = ConfluenceClient(base_url="https://confluence.example.com", token="token")
@@ -192,3 +207,69 @@ class ClientTests(unittest.TestCase):
192
207
 
193
208
  self.assertEqual([page["id"] for page in pages], ["1", "2", "3"])
194
209
  self.assertEqual(mock_get_child_pages.call_count, 2)
210
+
211
+ class ValidateStorageHtmlTests(unittest.TestCase):
212
+ def test_valid_xhtml_passes(self) -> None:
213
+ from conjira_cli.client import validate_storage_html
214
+
215
+ # Should not raise
216
+ validate_storage_html("<p>Hello <strong>world</strong></p>")
217
+
218
+ def test_invalid_xhtml_raises(self) -> None:
219
+ from conjira_cli.client import validate_storage_html, ConfluenceError
220
+
221
+ with self.assertRaises(ConfluenceError) as ctx:
222
+ validate_storage_html("<p>Unclosed paragraph")
223
+ self.assertIn("well-formed XHTML", str(ctx.exception))
224
+
225
+ def test_bare_br_is_invalid(self) -> None:
226
+ from conjira_cli.client import validate_storage_html, ConfluenceError
227
+
228
+ with self.assertRaises(ConfluenceError):
229
+ validate_storage_html("<p>line<br>break</p>")
230
+
231
+ def test_valid_xhtml_with_confluence_macros_passes(self) -> None:
232
+ from conjira_cli.client import validate_storage_html
233
+
234
+ validate_storage_html(
235
+ '<ac:structured-macro ac:name="code" ac:schema-version="1">'
236
+ '<ac:parameter ac:name="language">python</ac:parameter>'
237
+ '<ac:plain-text-body><![CDATA[print("hi")]]></ac:plain-text-body>'
238
+ "</ac:structured-macro>"
239
+ )
240
+
241
+ def test_valid_xhtml_with_atlassian_namespace_passes(self) -> None:
242
+ from conjira_cli.client import validate_storage_html
243
+
244
+ validate_storage_html(
245
+ '<td atlassian:data-highlight-colour="blue">text</td>'
246
+ )
247
+
248
+ def test_get_page_parses_json_even_when_content_type_is_not_json(self) -> None:
249
+ client = ConfluenceClient(base_url="https://confluence.example.com", token="token")
250
+ body = (
251
+ '{"id":"123","type":"page","status":"current","title":"Demo",'
252
+ '"space":{"key":"TEST"},"version":{"number":7}}'
253
+ )
254
+
255
+ with mock.patch(
256
+ "conjira_cli.client.urllib.request.urlopen",
257
+ return_value=_FakeHTTPResponse(body, content_type="text/plain; charset=utf-8"),
258
+ ):
259
+ page = client.get_page("123")
260
+
261
+ self.assertEqual(page["id"], "123")
262
+ self.assertEqual(ConfluenceClient.summarize_page(page)["title"], "Demo")
263
+
264
+ def test_jira_auth_check_parses_json_even_when_content_type_is_not_json(self) -> None:
265
+ client = JiraClient(base_url="https://jira.example.com", token="token")
266
+ body = '{"version":"10.3.16","buildNumber":10030016,"deploymentType":"Server"}'
267
+
268
+ with mock.patch(
269
+ "conjira_cli.client.urllib.request.urlopen",
270
+ return_value=_FakeHTTPResponse(body, content_type="text/plain; charset=utf-8"),
271
+ ):
272
+ payload = client.auth_check()
273
+
274
+ self.assertEqual(payload["version"], "10.3.16")
275
+ self.assertEqual(payload["build_number"], 10030016)
@@ -249,3 +249,49 @@ class MarkdownExportTests(unittest.TestCase):
249
249
  result = exporter.convert_fragment(html)
250
250
 
251
251
  self.assertIn("- Description emphasis", result)
252
+
253
+ def test_nested_table_pipes_escaped_in_cell(self) -> None:
254
+ """A nested table inside a cell must not break the outer table columns."""
255
+ exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
256
+ # Outer 2-column table with headers that do NOT trigger structured
257
+ # detection so we get a regular pipe table.
258
+ html = (
259
+ "<table><tbody>"
260
+ "<tr><th>Host</th><th>Config</th></tr>"
261
+ "<tr>"
262
+ "<td>SSL cert</td>"
263
+ "<td><table><tbody>"
264
+ "<tr><td>Type</td><td>Wildcard</td></tr>"
265
+ "<tr><td>Issuer</td><td>DigiCert</td></tr>"
266
+ "</tbody></table></td>"
267
+ "</tr>"
268
+ "</tbody></table>"
269
+ )
270
+ result = exporter.convert_fragment(html)
271
+
272
+ # The outer table row for "SSL cert" should have nested table pipes
273
+ # escaped so it stays as a 2-column row.
274
+ for line in result.splitlines():
275
+ if "SSL cert" in line and line.strip().startswith("|"):
276
+ self.assertIn("\\|", line, "Nested table pipes should be escaped")
277
+ break
278
+ else:
279
+ self.fail("Could not find 'SSL cert' row in rendered table")
280
+
281
+ def test_nested_list_pipes_escaped_in_cell(self) -> None:
282
+ """A nested list with pipe chars inside a cell must escape them."""
283
+ exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
284
+ html = (
285
+ "<table><tbody>"
286
+ "<tr><th>Task</th><th>Notes</th></tr>"
287
+ "<tr>"
288
+ "<td>Review</td>"
289
+ "<td><ul><li>Option A | Option B</li></ul></td>"
290
+ "</tr>"
291
+ "</tbody></table>"
292
+ )
293
+ result = exporter.convert_fragment(html)
294
+
295
+ for line in result.splitlines():
296
+ if "Review" in line and line.strip().startswith("|"):
297
+ self.assertIn("\\|", line, "Pipe in list item should be escaped")
@@ -153,3 +153,148 @@ class MarkdownImportTests(unittest.TestCase):
153
153
  self.assertIn('<ac:structured-macro ac:name="status"', result)
154
154
  self.assertIn('<ac:parameter ac:name="colour">Blue</ac:parameter>', result)
155
155
  self.assertIn('<ac:parameter ac:name="title">Planned</ac:parameter>', result)
156
+
157
+ def test_xhtml_self_closing_br_tags(self) -> None:
158
+ """<br> must be emitted as <br /> for Confluence XHTML strict mode."""
159
+ # The current renderer doesn't emit bare <br> from markdown, but
160
+ # if inline HTML sneaks through we need the post-processor to fix it.
161
+ from conjira_cli.markdown_import import _ensure_xhtml_self_closing
162
+
163
+ self.assertEqual(_ensure_xhtml_self_closing("<br>"), "<br />")
164
+ self.assertEqual(_ensure_xhtml_self_closing("<br/>"), "<br />")
165
+ self.assertEqual(_ensure_xhtml_self_closing("<br />"), "<br />")
166
+ self.assertEqual(_ensure_xhtml_self_closing("<hr>"), "<hr />")
167
+ self.assertEqual(
168
+ _ensure_xhtml_self_closing("<p>text<br>more</p>"),
169
+ "<p>text<br />more</p>",
170
+ )
171
+
172
+ def test_xhtml_self_closing_preserves_attributes(self) -> None:
173
+ from conjira_cli.markdown_import import _ensure_xhtml_self_closing
174
+
175
+ self.assertEqual(
176
+ _ensure_xhtml_self_closing('<hr class="divider">'),
177
+ '<hr class="divider" />',
178
+ )
179
+
180
+ def test_xhtml_self_closing_img_tag(self) -> None:
181
+ from conjira_cli.markdown_import import _ensure_xhtml_self_closing
182
+
183
+ self.assertEqual(
184
+ _ensure_xhtml_self_closing('<img src="x.png">'),
185
+ '<img src="x.png" />',
186
+ )
187
+
188
+ def test_output_is_valid_xhtml(self) -> None:
189
+ """Full markdown-to-HTML output must be well-formed XHTML."""
190
+ import xml.etree.ElementTree as ET
191
+
192
+ result = markdown_to_storage_html(
193
+ "# Hello\n\nParagraph with **bold**.\n\n---\n"
194
+ )
195
+ wrapped = f'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">{result}</root>'
196
+ # Should not raise
197
+ ET.fromstring(wrapped)
198
+
199
+ def test_table_cell_passes_through_raw_html_list(self) -> None:
200
+ """Cells containing well-formed HTML blocks (e.g. nested <ul>) survive."""
201
+ result = markdown_to_storage_html(
202
+ "\n".join(
203
+ [
204
+ "| 구분 | 내용 |",
205
+ "| --- | --- |",
206
+ "| 포인트 지급 | <ul><li>휴대폰 포인트 지급<ul><li>MDN 기준</li></ul></li></ul> |",
207
+ ]
208
+ )
209
+ )
210
+
211
+ self.assertIn(
212
+ "<td><ul><li>휴대폰 포인트 지급<ul><li>MDN 기준</li></ul></li></ul></td>",
213
+ result,
214
+ )
215
+ self.assertNotIn("&lt;ul&gt;", result)
216
+
217
+ def test_table_cell_passes_through_storage_macro(self) -> None:
218
+ """Cells containing ac:* / ri:* storage-format macros pass through."""
219
+ cell_html = (
220
+ '<ac:structured-macro ac:name="status" ac:schema-version="1">'
221
+ '<ac:parameter ac:name="colour">Green</ac:parameter>'
222
+ '<ac:parameter ac:name="title">Done</ac:parameter>'
223
+ "</ac:structured-macro>"
224
+ )
225
+ result = markdown_to_storage_html(
226
+ "\n".join(
227
+ [
228
+ "| Item | Status |",
229
+ "| --- | --- |",
230
+ "| API | {0} |".format(cell_html),
231
+ ]
232
+ )
233
+ )
234
+
235
+ self.assertIn(cell_html, result)
236
+ self.assertNotIn("&lt;ac:", result)
237
+
238
+ def test_table_cell_with_inline_text_still_escapes(self) -> None:
239
+ """Cells without HTML block content still get inline escaping."""
240
+ result = markdown_to_storage_html(
241
+ "\n".join(
242
+ [
243
+ "| Title | Note |",
244
+ "| --- | --- |",
245
+ "| 5 < 10 | not html |",
246
+ ]
247
+ )
248
+ )
249
+
250
+ self.assertIn("<td>5 &lt; 10</td>", result)
251
+ self.assertIn("<td>not html</td>", result)
252
+
253
+ def test_table_cell_with_malformed_html_falls_back_to_inline(self) -> None:
254
+ """If a cell looks like HTML but isn't well-formed, escape it instead of breaking the page."""
255
+ result = markdown_to_storage_html(
256
+ "\n".join(
257
+ [
258
+ "| Item | Note |",
259
+ "| --- | --- |",
260
+ "| <ul><li>unclosed | broken |",
261
+ ]
262
+ )
263
+ )
264
+
265
+ # Malformed HTML must not pass through; it should be escaped.
266
+ self.assertNotIn("<td><ul><li>unclosed", result)
267
+ self.assertIn("&lt;ul&gt;", result)
268
+
269
+ def test_table_with_html_cells_is_valid_xhtml(self) -> None:
270
+ """Tables with HTML pass-through cells must produce valid XHTML."""
271
+ import xml.etree.ElementTree as ET
272
+
273
+ result = markdown_to_storage_html(
274
+ "\n".join(
275
+ [
276
+ "| 구분 | 내용 |",
277
+ "| --- | --- |",
278
+ "| A | <ul><li>x<ul><li>y</li></ul></li></ul> |",
279
+ "| B | plain text |",
280
+ ]
281
+ )
282
+ )
283
+ wrapped = f'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">{result}</root>'
284
+ ET.fromstring(wrapped)
285
+
286
+ def test_table_cell_html_block_emits_trailing_content_verbatim(self) -> None:
287
+ """Trailing content after an HTML block in a cell is emitted verbatim, not re-rendered."""
288
+ result = markdown_to_storage_html(
289
+ "\n".join(
290
+ [
291
+ "| A | B |",
292
+ "| --- | --- |",
293
+ "| 1 | <ul><li>x</li></ul> trailing [link](http://example.com) |",
294
+ ]
295
+ )
296
+ )
297
+
298
+ # Whole cell goes through as raw HTML; trailing markdown is NOT rendered.
299
+ self.assertIn("<ul><li>x</li></ul> trailing [link](http://example.com)", result)
300
+ self.assertNotIn('<a href="http://example.com">', result)
@@ -1,6 +1,10 @@
1
1
  import unittest
2
2
 
3
- from conjira_cli.section_edit import SectionEditError, replace_section_html
3
+ from conjira_cli.section_edit import (
4
+ SectionEditError,
5
+ insert_after_heading_html,
6
+ replace_section_html,
7
+ )
4
8
 
5
9
 
6
10
  class SectionEditTests(unittest.TestCase):
@@ -46,3 +50,44 @@ class SectionEditTests(unittest.TestCase):
46
50
  heading="Install",
47
51
  replacement_html="<p>Replacement</p>",
48
52
  )
53
+
54
+ def test_insert_after_heading_html_inserts_immediately_after_heading(self) -> None:
55
+ body_html = (
56
+ "<h1>Guide</h1>"
57
+ "<p>Intro</p>"
58
+ "<h2>Install</h2>"
59
+ "<p>Old install step</p>"
60
+ "<h2>Usage</h2>"
61
+ "<p>Run command</p>"
62
+ )
63
+
64
+ result = insert_after_heading_html(
65
+ body_html,
66
+ heading="Install",
67
+ inserted_html="<p>New note</p><ul><li>Check this first</li></ul>",
68
+ )
69
+
70
+ self.assertEqual(result.matched_heading, "Install")
71
+ self.assertEqual(result.heading_level, 2)
72
+ self.assertIn("<p>New note</p>", result.inserted_html)
73
+ self.assertIn("<ul><li>Check this first</li></ul>", result.updated_body_html)
74
+ self.assertIn(
75
+ "<h2>Install</h2><p>New note</p><ul><li>Check this first</li></ul><p>Old install step</p>",
76
+ result.updated_body_html,
77
+ )
78
+
79
+ def test_insert_after_heading_html_fails_when_heading_missing(self) -> None:
80
+ with self.assertRaises(SectionEditError):
81
+ insert_after_heading_html(
82
+ "<h1>Guide</h1><p>Body</p>",
83
+ heading="Install",
84
+ inserted_html="<p>Inserted</p>",
85
+ )
86
+
87
+ def test_insert_after_heading_html_fails_when_heading_ambiguous(self) -> None:
88
+ with self.assertRaises(SectionEditError):
89
+ insert_after_heading_html(
90
+ "<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
91
+ heading="Install",
92
+ inserted_html="<p>Inserted</p>",
93
+ )
File without changes
File without changes
File without changes