Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 65 additions & 14 deletions src/forge/integrations/jira/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/unit/integrations/jira/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand Down
Loading