diff --git a/src/forge/integrations/jira/models.py b/src/forge/integrations/jira/models.py index 836fc3fd..b32c406c 100644 --- a/src/forge/integrations/jira/models.py +++ b/src/forge/integrations/jira/models.py @@ -119,20 +119,71 @@ def _extract_text_from_adf(adf: dict[str, Any]) -> str: if not isinstance(adf, dict): return str(adf) if adf else "" - content = adf.get("content", []) - texts = [] - - for node in content: - if node.get("type") == "paragraph": - para_texts = [] - for child in node.get("content", []): - if child.get("type") == "text": - para_texts.append(child.get("text", "")) - texts.append("".join(para_texts)) - elif node.get("type") == "text": - texts.append(node.get("text", "")) - - return "\n\n".join(texts) + # Jira does not provide a supported ADF-to-Markdown endpoint. Keep this + # extractor local and conservative so workflow prompts receive readable + # issue text without taking a dependency on an under-supported converter. + # Add unsupported ADF nodes here with focused JiraIssue regression tests. + # If a node cannot be parsed, preserve its raw representation instead of + # dropping issue content from workflow prompts. + def inline_text(nodes: list[dict[str, Any]]) -> str: + parts = [] + for child in nodes: + child_type = child.get("type") + if child_type == "text": + parts.append(child.get("text", "")) + elif child_type == "hardBreak": + parts.append("\n") + else: + parts.append("\n".join(extract_blocks(child))) + return "".join(parts) + + def extract_blocks(node: dict[str, Any]) -> list[str]: + node_type = node.get("type") + content = node.get("content", []) + + if node_type == "doc": + return extract_children(content) + if node_type == "paragraph": + text = inline_text(content).strip() + return [text] if text else [] + if node_type == "heading": + text = inline_text(content).strip() + if not text: + return [] + level = node.get("attrs", {}).get("level", 1) + return [f"{'#' * int(level)} {text}"] + if node_type == "text": + text = node.get("text", "") + return [text] if text else [] + if node_type == "codeBlock": + text = inline_text(content) + language = node.get("attrs", {}).get("language", "") + fence = f"```{language}".rstrip() + return [f"{fence}\n{text}\n```"] + if node_type in ("bulletList", "orderedList"): + items = [] + for index, item in enumerate(content, start=1): + item_text = "\n".join(extract_blocks(item)).strip() + if item_text: + prefix = f"{index}. " if node_type == "orderedList" else "- " + items.append(prefix + item_text.replace("\n", "\n ")) + return items + if node_type == "listItem": + return extract_children(content) + if node_type == "rule": + return ["---"] + + blocks = extract_children(content) + return blocks or [str(node)] + + def extract_children(nodes: list[dict[str, Any]]) -> list[str]: + blocks = [] + for child in nodes: + blocks.extend(extract_blocks(child)) + return [block for block in blocks if block] + + blocks = extract_blocks(adf) + return "\n\n".join(blocks) if blocks else str(adf) @dataclass diff --git a/tests/unit/integrations/jira/test_client.py b/tests/unit/integrations/jira/test_client.py index ad2977c2..0b011f9b 100644 --- a/tests/unit/integrations/jira/test_client.py +++ b/tests/unit/integrations/jira/test_client.py @@ -6,6 +6,7 @@ import pytest from forge.integrations.jira.client import JiraClient, MissingProjectConfig +from forge.integrations.jira.models import JiraIssue from forge.models.workflow import ForgeLabel @@ -365,6 +366,65 @@ def test_text_to_adf_inline_formatting(self): # Should have paragraph with inline marks assert adf["content"][0]["type"] == "paragraph" + def test_issue_description_extracts_epic_plan_blocks(self): + """Epic plan descriptions survive the markdown to ADF to JiraIssue round trip.""" + plan = """# Task Takeover Routing + +- Keep workflow identity in TaskTakeoverWorkflow.matches(). +- Preserve forge:managed and add a separate trigger label. + +```python +def matches(issue): + return True +```""" + adf = JiraClient._text_to_adf(plan) + + issue = JiraIssue.from_api_response( + { + "key": "AISOS-1981", + "id": "10001", + "fields": { + "summary": "Task Takeover Routing", + "description": adf, + "status": {"name": "To Do"}, + "issuetype": {"name": "Epic"}, + }, + } + ) + + assert "# Task Takeover Routing" in issue.description + assert "- Keep workflow identity" in issue.description + assert "- Preserve forge:managed" in issue.description + assert "```python" in issue.description + assert "def matches(issue):" in issue.description + + def test_issue_description_preserves_unparsed_adf_node(self): + """Unknown ADF nodes should not disappear from issue descriptions.""" + issue = JiraIssue.from_api_response( + { + "key": "AISOS-1981", + "id": "10001", + "fields": { + "summary": "Unknown node", + "description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "unsupportedWidget", + "attrs": {"payload": "important raw context"}, + } + ], + }, + "status": {"name": "To Do"}, + "issuetype": {"name": "Epic"}, + }, + } + ) + + assert "unsupportedWidget" in issue.description + assert "important raw context" in issue.description + @pytest.fixture def jira_client():