"""Minimal Atlassian Document Format (ADF) <-> markdown conversion. Jira Cloud v3 issue descriptions and comments are ADF JSON, not markdown. These helpers convert the node types Atlassian actually emits in descriptions/comments so bodies read as markdown in prompts, and produce valid ADF for agent-authored comments (prose). ``markdown_to_adf`` is intentionally minimal: agent comments are plain prose, so each blank-line-separated block becomes one paragraph. """ from __future__ import annotations from typing import Any _MARK_WRAP = { "strong": "**", "em": "_", "code": "`", "strikethrough": "~~", } def _apply_marks(text: str, marks: list[dict[str, Any]]) -> str: for mark in marks: mtype = mark.get("type") if mtype == "link": href = (mark.get("attrs") or {}).get("href", "") text = f"[{text}]({href})" elif mtype in _MARK_WRAP: wrap = _MARK_WRAP[mtype] text = f"{wrap}{text}{wrap}" return text def _render_nodes(nodes: list[dict[str, Any]]) -> str: return "".join(_render_node(n) for n in nodes) def _render_node(node: dict[str, Any]) -> str: # noqa: PLR0911, PLR0912 ntype = node.get("type") content = node.get("content", []) or [] attrs = node.get("attrs", {}) or {} if ntype == "text": return _apply_marks(node.get("text", ""), node.get("marks", []) or []) if ntype == "hardBreak": return "\n" if ntype == "paragraph": return _render_nodes(content) + "\n\n" if ntype == "heading": level = min(int(attrs.get("level", 1)), 6) return f"{'#' * level} {_render_nodes(content)}\n\n" if ntype == "blockquote": inner = _render_nodes(content).strip() return "".join(f"> {line}\n" for line in inner.splitlines()) + "\n" if ntype == "codeBlock": lang = attrs.get("language", "") return f"```{lang}\n{_render_nodes(content)}\n```\n\n" if ntype == "rule": return "---\n\n" if ntype == "bulletList": return ( "".join(f"- {_render_nodes(li.get('content', [])).strip()}\n" for li in content) + "\n" ) if ntype == "orderedList": out = [] for i, li in enumerate(content, start=1): out.append(f"{i}. {_render_nodes(li.get('content', [])).strip()}\n") return "".join(out) + "\n" if ntype == "listItem": return _render_nodes(content) if ntype in ("mediaSingle", "mediaGroup"): return _render_nodes(content) if ntype == "media": alt = attrs.get("alt") or attrs.get("id", "media") url = attrs.get("url", "") return f"![{alt}]({url})\n\n" if url else f"[media: {alt}]\n\n" if ntype == "inlineCard": return (attrs.get("url", "")) or "" if ntype == "mention": return attrs.get("text", "") or "" if ntype == "emoji": return attrs.get("text", "") or attrs.get("shortName", "") or "" # Unknown/container node: recurse into content. return _render_nodes(content) def adf_to_markdown(adf: Any) -> str: """Convert an ADF document (or None) to a markdown string.""" if not adf or not isinstance(adf, dict): return "" return _render_nodes(adf.get("content", []) or []).strip() def markdown_to_adf(text: str) -> dict[str, Any]: """Convert plain markdown/prose to a minimal ADF document. Blank-line-separated blocks become paragraphs; single newlines within a block become hardBreaks. Inline markdown is left as literal text. """ text = text or "" blocks = text.split("\n\n") paragraphs: list[dict[str, Any]] = [] for block in blocks: if not block.strip(): continue lines = block.split("\n") para_content: list[dict[str, Any]] = [] for idx, line in enumerate(lines): if idx > 0: para_content.append({"type": "hardBreak"}) if line: para_content.append({"type": "text", "text": line}) paragraphs.append({"type": "paragraph", "content": para_content}) if not paragraphs: paragraphs = [{"type": "paragraph", "content": []}] return {"type": "doc", "version": 1, "content": paragraphs}