mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
118 lines
4.1 KiB
Python
118 lines
4.1 KiB
Python
|
|
"""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"\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}
|