From e158a3b07be4db35f835320e4a3d4939db0ecf31 Mon Sep 17 00:00:00 2001 From: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:52:21 +0000 Subject: [PATCH 1/6] fix(#6148): map
blocks to ADF expand nodes in Jira converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkdownToADF now converts HTML
/ blocks into Jira's ADF expand node type instead of falling back to raw-text paragraphs containing literal HTML markup. This handles both single-block (no blank lines inside) and multi-block (blank lines split across AST siblings, as produced by sticky.BuildUpdatedBody) layouts. Sticky history sentinels () are consumed as parser metadata during conversion and do not appear as visible text inside the rendered expansion. ADFToMarkdown now renders expand nodes back as
/ HTML, providing round-trip fidelity:
→ expand →
converges to a stable format after one cycle. Note: pre-commit could not fetch remote hook repos (network-restricted sandbox). Local hooks (gofmt, go vet) and secret scan passed. Remote hooks (pre-commit-hooks, gitleaks, ruff, etc.) were not run. Closes #6148 --- internal/forge/jira/adf.go | 210 +++++++++++++++++++++++- internal/forge/jira/adf_test.go | 273 +++++++++++++++++++++++++++++--- 2 files changed, 455 insertions(+), 28 deletions(-) diff --git a/internal/forge/jira/adf.go b/internal/forge/jira/adf.go index 0bfa5fea1..0c8bdab81 100644 --- a/internal/forge/jira/adf.go +++ b/internal/forge/jira/adf.go @@ -94,12 +94,183 @@ func adfBlockContent(parent ast.Node, source []byte, depth int, restricted bool) if depth > maxADFWriteDepth { return content } - for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + for c := parent.FirstChild(); c != nil; { + // In non-restricted context, attempt to convert
+ // HTML blocks into ADF expand nodes. tryDetailsExpand + // handles both single-block (no blank lines inside the + // markup) and multi-block (blank lines split the
+ // opening, body, and closing across several AST siblings) + // forms, since sticky.BuildUpdatedBody produces the latter + // while hand-written Markdown typically produces the former. + if !restricted { + if expand, next := tryDetailsExpand(c, source, depth); expand != nil { + content = append(content, expand) + c = next + continue + } + } content = append(content, convertBlockNode(c, source, depth, restricted)...) + c = c.NextSibling() } return content } +// summaryTagPattern matches ... in HTML block content +// for extracting the expand title from a
block. +var summaryTagPattern = regexp.MustCompile(`(?is)(.*?)`) + +// stickyHistorySentinelPattern matches sticky history sentinel comments +// on their own line, for stripping from
body content before +// re-parsing as Markdown. These sentinels are metadata used by +// sticky.BuildUpdatedBody for history reconstruction and must not +// become visible text inside the rendered ADF expansion. +var stickyHistorySentinelPattern = regexp.MustCompile(`(?m)^\s*\s*$`) + +// tryDetailsExpand checks whether c is an HTML block opening a +//
section and, if so, converts the entire section into an ADF +// expand node whose attrs.title carries the text and whose +// content holds the body re-parsed as Markdown. +// +// Two layouts are handled: +// +// - Single-block: the entire
is one goldmark +// HTMLBlock (no blank lines inside), common in hand-written Markdown. +// +// - Multi-block: blank lines inside the markup (as produced by +// sticky.BuildUpdatedBody) cause goldmark to split the opening tag, +// body, and closing tag across separate AST siblings. The function +// collects siblings until it finds the
closing block. +// +// Returns (expandNode, nextSibling) on success, or (nil, nil) if c is +// not a
opener — in which case adfBlockContent processes c +// normally through convertBlockNode. +func tryDetailsExpand(c ast.Node, source []byte, depth int) (map[string]any, ast.Node) { + html, ok := c.(*ast.HTMLBlock) + if !ok { + return nil, nil + } + raw := string(html.Lines().Value(source)) + if !isDetailsOpen(raw) { + return nil, nil + } + + title := extractSummary(raw) + + // Single-block case: the entire
...
is one + // HTMLBlock (no blank lines inside the markup). + if containsDetailsClose(raw) { + body := detailsInnerBody(raw) + body = stickyHistorySentinelPattern.ReplaceAllString(body, "") + body = strings.TrimSpace(body) + var adfContent []any + if body != "" { + src := []byte(body) + doc := goldmark.DefaultParser().Parse(text.NewReader(src)) + adfContent = adfBlockContent(doc, src, depth+1, false) + } + if len(adfContent) == 0 { + return nil, nil + } + return expandNode(title, adfContent), c.NextSibling() + } + + // Multi-block case: blank lines inside
caused goldmark to + // split the block across multiple AST siblings. Collect content + // nodes until we find the
closing block. + var bodyContent []any + next := c.NextSibling() + foundClose := false + for next != nil { + if htmlBlock, ok := next.(*ast.HTMLBlock); ok { + blockRaw := string(htmlBlock.Lines().Value(source)) + if isDetailsClose(blockRaw) { + foundClose = true + next = next.NextSibling() + break + } + if isStickyHistorySentinel(blockRaw) { + next = next.NextSibling() + continue + } + } + bodyContent = append(bodyContent, convertBlockNode(next, source, depth+1, false)...) + next = next.NextSibling() + } + if !foundClose || len(bodyContent) == 0 { + return nil, nil + } + return expandNode(title, bodyContent), next +} + +// expandNode builds an ADF expand node with the given title and block +// content. ADF's expand schema requires at least one content child +// (minItems: 1), so callers must ensure content is non-empty. +func expandNode(title string, content []any) map[string]any { + node := map[string]any{ + "type": "expand", + "content": content, + } + if title != "" { + node["attrs"] = map[string]any{"title": title} + } + return node +} + +// isDetailsOpen reports whether raw starts with an HTML
tag. +func isDetailsOpen(raw string) bool { + trimmed := strings.TrimSpace(raw) + lower := strings.ToLower(trimmed) + return strings.HasPrefix(lower, "
") || strings.HasPrefix(lower, "
. +func containsDetailsClose(raw string) bool { + return strings.Contains(strings.ToLower(raw), "
") +} + +// isDetailsClose reports whether raw is a
closing block. +func isDetailsClose(raw string) bool { + trimmed := strings.TrimSpace(raw) + return strings.HasPrefix(strings.ToLower(trimmed), " or +// ). +func isStickyHistorySentinel(raw string) bool { + trimmed := strings.TrimSpace(raw) + return trimmed == "" || + trimmed == "" +} + +// extractSummary extracts the text content of the first tag in +// raw, or "" if none is present. +func extractSummary(raw string) string { + match := summaryTagPattern.FindStringSubmatch(raw) + if match == nil { + return "" + } + return strings.TrimSpace(match[1]) +} + +// detailsInnerBody extracts the body content from a self-contained +//
...
HTML block: everything between
+// (or the
opening tag if there's no summary) and
. +func detailsInnerBody(raw string) string { + body := raw + lower := strings.ToLower(body) + if idx := strings.Index(lower, "
"); idx >= 0 { + body = body[idx+len("
"):] + } else if idx := strings.Index(body, ">"); idx >= 0 { + body = body[idx+1:] + } + lower = strings.ToLower(body) + if idx := strings.LastIndex(lower, "
"); idx >= 0 { + body = body[:idx] + } + return body +} + // convertBlockNode converts a single goldmark block node to zero or more // ADF nodes: normally one, but zero for a dropped or empty node, or // several when a nested blockquote is flattened into its parent's @@ -585,7 +756,8 @@ func walkADFNode(node map[string]any, sb *strings.Builder, depth int) { func isBlockType(nodeType string) bool { switch nodeType { case "doc", "paragraph", "heading", "blockquote", "codeBlock", - "bulletList", "orderedList", "listItem", "panel", "rule": + "bulletList", "orderedList", "listItem", "panel", "rule", + "expand": return true default: return false @@ -652,12 +824,12 @@ func adfMarkdownBlocks(node map[string]any, depth int) []string { } // adfMarkdownBlock renders a single ADF block-level node as Markdown. -// Unrecognized types (e.g. "panel", "table", "expand", "taskList") fall -// back to recursing into their block-level children (e.g. a panel's -// paragraphs, a table's rows) so content isn't silently dropped; if that -// yields nothing, e.g. a taskItem whose own children are inline text -// nodes rather than blocks, it falls back further to rendering any -// direct inline text content flat, mirroring MarkdownToADF's own +// Unrecognized types (e.g. "panel", "table", "taskList") fall back to +// recursing into their block-level children (e.g. a panel's paragraphs, +// a table's rows) so content isn't silently dropped; if that yields +// nothing, e.g. a taskItem whose own children are inline text nodes +// rather than blocks, it falls back further to rendering any direct +// inline text content flat, mirroring MarkdownToADF's own // fallback-to-plain-text convention. func adfMarkdownBlock(node map[string]any, depth int) string { nodeType, _ := node["type"].(string) @@ -716,6 +888,28 @@ func adfMarkdownBlock(node map[string]any, depth int) string { // produce a marker a Markdown parser would reject. start = clampInt(start, 0, 999999999) return adfMarkdownList(node, depth, func(i int) string { return fmt.Sprintf("%d. ", start+i) }) + case "expand": + title := "" + if attrs, ok := node["attrs"].(map[string]any); ok { + if t, ok := attrs["title"].(string); ok { + title = t + } + } + body := strings.Join(adfMarkdownBlocks(node, depth), "\n\n") + var sb strings.Builder + sb.WriteString("
") + if title != "" { + sb.WriteString("") + sb.WriteString(title) + sb.WriteString("") + } + sb.WriteString("\n") + if body != "" { + sb.WriteString(body) + sb.WriteString("\n") + } + sb.WriteString("
") + return sb.String() default: if blocks := adfMarkdownBlocks(node, depth); len(blocks) > 0 { return strings.Join(blocks, "\n\n") diff --git a/internal/forge/jira/adf_test.go b/internal/forge/jira/adf_test.go index 6454c105e..d4783f729 100644 --- a/internal/forge/jira/adf_test.go +++ b/internal/forge/jira/adf_test.go @@ -469,13 +469,12 @@ func TestMarkdownToADF_ImageRejectsDangerousScheme(t *testing.T) { func TestMarkdownToADF_UnknownBlockFallsBackToPlainText(t *testing.T) { // convertBlockNode's default case previously returned nil for any // block-level node outside its supported vocabulary (e.g. a raw HTML - // block), silently dropping the content with no trace. This repo's - // own convention (internal/sticky, postcomment.go, postreview.go) of - // wrapping output in
......
- // HTML blocks would vanish entirely if posted to Jira. Mirror + // block), silently dropping the content with no trace. Mirror // walkInline's own default case, which falls back to plain text - // rather than losing readable content. - doc := mustADF(t, "before\n\n
x\ny\n
\n\nafter") + // rather than losing readable content. (
blocks are no + // longer "unknown" — they convert to ADF expand nodes — so this + // test uses
to exercise the generic fallback.) + doc := mustADF(t, "before\n\n
some content
\n\nafter") content := asSlice(t, doc["content"]) var sawFallbackText bool @@ -487,7 +486,7 @@ func TestMarkdownToADF_UnknownBlockFallsBackToPlainText(t *testing.T) { for _, n := range asSlice(t, block["content"]) { node := asMap(t, n) text, _ := node["text"].(string) - if strings.Contains(text, "
") { + if strings.Contains(text, "
") { sawFallbackText = true } } @@ -1342,10 +1341,11 @@ func TestADFToMarkdown_HardBreak(t *testing.T) { } func TestADFToMarkdown_UnknownContainerNodeRecursesIntoBlockChildren(t *testing.T) { - // Unknown ADF container types (panel, table, expand, taskList) wrap + // Unknown ADF container types (panel, table, taskList) wrap // block-level content (paragraphs, tableRows, ...), not inline text. // adfMarkdownInline alone can't see any of it, since it only reads - // direct children's top-level "text" fields. + // direct children's top-level "text" fields. (expand is no longer + // unknown — it has dedicated
/ handling.) for _, tc := range []struct { name string node map[string]any @@ -1379,17 +1379,6 @@ func TestADFToMarkdown_UnknownContainerNodeRecursesIntoBlockChildren(t *testing. }, want: "a\n\nb", }, - { - name: "expand", - node: map[string]any{ - "type": "expand", - "attrs": map[string]any{"title": "Click to expand"}, - "content": []any{ - map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "hidden content"}}}, - }, - }, - want: "hidden content", - }, { name: "taskList", node: map[string]any{ @@ -1832,3 +1821,247 @@ func TestADFToMarkdown_RoundTripsThroughMarkdownToADF(t *testing.T) { } } } + +// --------------------------------------------------------------------------- +// MarkdownToADF —
→ expand +// --------------------------------------------------------------------------- + +func TestMarkdownToADF_DetailsToExpandNode(t *testing.T) { + // A
HTML block should + // convert to an ADF expand node, not fall back to raw text. + doc := mustADF(t, "text\n\n
Prior run\n- item one\n- item two\n
\n\nmore text") + + content := asSlice(t, doc["content"]) + if len(content) != 3 { + t.Fatalf("doc content len = %d, want 3 (paragraph, expand, paragraph)", len(content)) + } + + // First block: paragraph "text" + para := asMap(t, content[0]) + if para["type"] != "paragraph" { + t.Errorf("block 0 type = %v, want %q", para["type"], "paragraph") + } + + // Second block: expand + expand := asMap(t, content[1]) + if expand["type"] != "expand" { + t.Fatalf("block 1 type = %v, want %q", expand["type"], "expand") + } + attrs := asMap(t, expand["attrs"]) + if attrs["title"] != "Prior run" { + t.Errorf("expand attrs.title = %v, want %q", attrs["title"], "Prior run") + } + expandContent := asSlice(t, expand["content"]) + if len(expandContent) != 1 { + t.Fatalf("expand content len = %d, want 1 (bulletList)", len(expandContent)) + } + list := asMap(t, expandContent[0]) + if list["type"] != "bulletList" { + t.Errorf("expand content[0] type = %v, want %q", list["type"], "bulletList") + } + + // Third block: paragraph "more text" + para2 := asMap(t, content[2]) + if para2["type"] != "paragraph" { + t.Errorf("block 2 type = %v, want %q", para2["type"], "paragraph") + } +} + +func TestMarkdownToADF_DetailsWithStickySentinelsStripped(t *testing.T) { + // Sticky history sentinels () are + // parser metadata used by sticky.BuildUpdatedBody for history + // reconstruction. They must be consumed during the
→ + // expand conversion and must not appear as visible text inside the + // rendered expansion. + input := "text\n\n
Prior run\n" + + "\n- item one\n- item two\n" + + "\n
\n\nmore text" + doc := mustADF(t, input) + + content := asSlice(t, doc["content"]) + if len(content) != 3 { + t.Fatalf("doc content len = %d, want 3", len(content)) + } + + expand := asMap(t, content[1]) + if expand["type"] != "expand" { + t.Fatalf("block 1 type = %v, want %q", expand["type"], "expand") + } + + // Walk the entire expand subtree and verify no text node contains + // the sentinel marker strings. + var walk func(any) + walk = func(v any) { + m, ok := v.(map[string]any) + if !ok { + return + } + if text, ok := m["text"].(string); ok { + if strings.Contains(text, "sticky:history-start") || strings.Contains(text, "sticky:history-end") { + t.Errorf("expand subtree contains sentinel text %q; sentinels should be stripped", text) + } + } + if c, ok := m["content"].([]any); ok { + for _, child := range c { + walk(child) + } + } + } + walk(expand) +} + +func TestMarkdownToADF_DetailsMultiBlock(t *testing.T) { + // When sticky.BuildUpdatedBody produces
blocks with blank + // lines inside (between and the sentinel, and between the + // sentinel and
), goldmark splits the markup across + // multiple AST siblings. tryDetailsExpand must collect them all into + // a single expand node. + input := "text\n\n" + + "
\nPrevious run\n\n" + + "\n" + + "- item one\n- item two\n" + + "\n\n" + + "
\n\nmore text" + doc := mustADF(t, input) + + content := asSlice(t, doc["content"]) + if len(content) != 3 { + t.Fatalf("doc content len = %d, want 3 (paragraph, expand, paragraph)", len(content)) + } + + expand := asMap(t, content[1]) + if expand["type"] != "expand" { + t.Fatalf("block 1 type = %v, want %q", expand["type"], "expand") + } + attrs := asMap(t, expand["attrs"]) + if attrs["title"] != "Previous run" { + t.Errorf("expand attrs.title = %v, want %q", attrs["title"], "Previous run") + } + expandContent := asSlice(t, expand["content"]) + if len(expandContent) != 1 { + t.Fatalf("expand content len = %d, want 1 (bulletList)", len(expandContent)) + } + if asMap(t, expandContent[0])["type"] != "bulletList" { + t.Errorf("expand content[0] type = %v, want %q", asMap(t, expandContent[0])["type"], "bulletList") + } + + // Verify sentinels are stripped. + var walk func(any) + walk = func(v any) { + m, ok := v.(map[string]any) + if !ok { + return + } + if text, ok := m["text"].(string); ok { + if strings.Contains(text, "sticky:history") { + t.Errorf("expand subtree contains sentinel text %q", text) + } + } + if c, ok := m["content"].([]any); ok { + for _, child := range c { + walk(child) + } + } + } + walk(expand) +} + +func TestMarkdownToADF_DetailsWithoutSummary(t *testing.T) { + //
with no tag should still produce an expand + // node, just without a title. + doc := mustADF(t, "before\n\n
\nbody text\n
\n\nafter") + + content := asSlice(t, doc["content"]) + if len(content) != 3 { + t.Fatalf("doc content len = %d, want 3", len(content)) + } + expand := asMap(t, content[1]) + if expand["type"] != "expand" { + t.Fatalf("block 1 type = %v, want %q", expand["type"], "expand") + } + if _, hasAttrs := expand["attrs"]; hasAttrs { + t.Errorf("expand has attrs %v, want no attrs (no summary)", expand["attrs"]) + } +} + +// --------------------------------------------------------------------------- +// ADFToMarkdown — expand →
+// --------------------------------------------------------------------------- + +func TestADFToMarkdown_ExpandNode(t *testing.T) { + // An ADF expand node should render as
+ // with the body content inside, providing round-trip fidelity with + // MarkdownToADF's
→ expand conversion. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "expand", + "attrs": map[string]any{"title": "Click to expand"}, + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "hidden content"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "
Click to expand\nhidden content\n
" + if got != want { + t.Errorf("ADFToMarkdown(expand) = %q, want %q", got, want) + } +} + +func TestADFToMarkdown_ExpandNodeWithoutTitle(t *testing.T) { + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "expand", + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "content"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "
\ncontent\n
" + if got != want { + t.Errorf("ADFToMarkdown(expand without title) = %q, want %q", got, want) + } +} + +func TestMarkdownToADF_DetailsExpandRoundTrips(t *testing.T) { + // Verify that
→ expand →
round-trips stably. + src := "
Prior run\n- item one\n- item two\n
" + doc := mustADF(t, src) + + // Should be an expand node, not fallback text. + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("doc content len = %d, want 1 (expand)", len(content)) + } + expand := asMap(t, content[0]) + if expand["type"] != "expand" { + t.Fatalf("block type = %v, want %q", expand["type"], "expand") + } + + // Render back to Markdown. + md := ADFToMarkdown(doc) + + // Re-parse: should produce the same ADF structure. + doc2 := mustADF(t, md) + content2 := asSlice(t, doc2["content"]) + if len(content2) != 1 { + t.Fatalf("round-trip doc content len = %d, want 1", len(content2)) + } + expand2 := asMap(t, content2[0]) + if expand2["type"] != "expand" { + t.Fatalf("round-trip block type = %v, want %q", expand2["type"], "expand") + } + + // Render again: should be identical to the first render. + md2 := ADFToMarkdown(doc2) + if md != md2 { + t.Errorf("round-trip is not stable:\n first: %q\n second: %q", md, md2) + } +} From ded8a67fc60194be7a528832dd34ee05a1ddb161 Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:22:47 +0000 Subject: [PATCH 2/6] fix(jira): address review feedback on details-to-expand conversion - Emit minimal expand node with empty paragraph when multi-block
body contains only sticky sentinels, preventing raw HTML from appearing in Jira comments. - HTML-escape expand title in ADFToMarkdown to prevent injection via
in attrs.title. - Rename containsDetailsClose to hasDetailsClose for naming consistency with other boolean helpers in the file. - Clarify tryDetailsExpand godoc return description to avoid overlap with the expandNode helper function name. - Document detailsInnerBody limitation with attributed
tags. - Add tests: empty-body multi-block, nested details limitation, and HTML-escaped title round-trip. Addresses #6814 --- internal/forge/jira/adf.go | 29 ++++++++--- internal/forge/jira/adf_test.go | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/internal/forge/jira/adf.go b/internal/forge/jira/adf.go index 0c8bdab81..a04471190 100644 --- a/internal/forge/jira/adf.go +++ b/internal/forge/jira/adf.go @@ -2,6 +2,7 @@ package jira import ( "fmt" + "html" "net/url" "reflect" "regexp" @@ -141,7 +142,7 @@ var stickyHistorySentinelPattern = regexp.MustCompile(`(?m)^\s*\n\n" + + "\n\n" + + "
\n\nmore text" + doc := mustADF(t, input) + + content := asSlice(t, doc["content"]) + if len(content) != 3 { + t.Fatalf("doc content len = %d, want 3 (paragraph, expand, paragraph)", len(content)) + } + + expand := asMap(t, content[1]) + if expand["type"] != "expand" { + t.Fatalf("block 1 type = %v, want %q", expand["type"], "expand") + } + attrs := asMap(t, expand["attrs"]) + if attrs["title"] != "History" { + t.Errorf("expand attrs.title = %v, want %q", attrs["title"], "History") + } + expandContent := asSlice(t, expand["content"]) + if len(expandContent) != 1 { + t.Fatalf("expand content len = %d, want 1 (empty paragraph)", len(expandContent)) + } + para := asMap(t, expandContent[0]) + if para["type"] != "paragraph" { + t.Errorf("expand content[0] type = %v, want %q", para["type"], "paragraph") + } +} + +func TestMarkdownToADF_NestedDetailsLimitation(t *testing.T) { + // Known limitation: in the multi-block path, isDetailsClose matches + // the first
HTMLBlock without tracking nesting depth. If + // an inner
block's closing tag is split into its own + // HTMLBlock (requires blank lines), the outer expand closes + // prematurely. In practice, goldmark keeps inner
blocks + // as a single HTMLBlock, so this does not arise with real-world + // input. This test documents the limitation with the single-block + // layout where nesting works correctly. + input := "
Outer\n" + + "
Inner\ninner body\n
\n" + + "outer body\n
" + doc := mustADF(t, input) + + content := asSlice(t, doc["content"]) + if len(content) != 1 { + t.Fatalf("doc content len = %d, want 1 (expand)", len(content)) + } + expand := asMap(t, content[0]) + if expand["type"] != "expand" { + t.Fatalf("block type = %v, want %q", expand["type"], "expand") + } + attrs := asMap(t, expand["attrs"]) + if attrs["title"] != "Outer" { + t.Errorf("expand attrs.title = %v, want %q", attrs["title"], "Outer") + } +} + // --------------------------------------------------------------------------- // ADFToMarkdown — expand →
// --------------------------------------------------------------------------- +func TestADFToMarkdown_ExpandNodeEscapesTitle(t *testing.T) { + // A title containing HTML special characters (especially + //
) must be escaped to prevent breaking the output. + adf := map[string]any{ + "type": "doc", + "content": []any{ + map[string]any{ + "type": "expand", + "attrs": map[string]any{"title": "ab"}, + "content": []any{ + map[string]any{"type": "paragraph", "content": []any{map[string]any{"type": "text", "text": "body"}}}, + }, + }, + }, + } + got := ADFToMarkdown(adf) + want := "
a</summary>b\nbody\n
" + if got != want { + t.Errorf("ADFToMarkdown(expand with HTML title) = %q, want %q", got, want) + } +} + func TestADFToMarkdown_ExpandNode(t *testing.T) { // An ADF expand node should render as
// with the body content inside, providing round-trip fidelity with From 7b6be3307502c91671f505ea120a7840da2184db Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:54:05 +0000 Subject: [PATCH 3/6] fix(jira): address review feedback on details-expand conversion - Rename shadowed `html` variable to `htmlBlock` in tryDetailsExpand to match the multi-block path's naming and avoid shadowing the imported html package. - Decode HTML entities in extractSummary with html.UnescapeString so pre-existing entities (e.g. &) are not double-encoded when adfMarkdownBlock re-encodes the title with html.EscapeString. - Move TestMarkdownToADF_DetailsExpandRoundTrips into the MarkdownToADF section to match the file's direction-based grouping. - Add TestMarkdownToADF_DetailsSummaryWithHTMLEntities exercising the entity round-trip fix. Addresses #6814 --- internal/forge/jira/adf.go | 12 ++-- internal/forge/jira/adf_test.go | 108 +++++++++++++++++++++----------- 2 files changed, 80 insertions(+), 40 deletions(-) diff --git a/internal/forge/jira/adf.go b/internal/forge/jira/adf.go index a04471190..38dd090a3 100644 --- a/internal/forge/jira/adf.go +++ b/internal/forge/jira/adf.go @@ -146,11 +146,11 @@ var stickyHistorySentinelPattern = regexp.MustCompile(`(?m)^\s*