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
50 changes: 30 additions & 20 deletions internal/api/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,31 @@ func countLeadingSpaces(line string) int {
return count
}

// hasCodeMark returns true if the content node has an inline code mark.
// In ADF, the code mark is exclusive and cannot be combined with other marks
// like strong, em, or strike. Jira will reject the document with INVALID_INPUT.
func hasCodeMark(c ADFContent) bool {
for _, m := range c.Marks {
if m.Type == "code" {
return true
}
}
return false
}

// addMarkToContent prepends a mark to inner content nodes, skipping nodes that
// already have a code mark (since code is exclusive in ADF).
func addMarkToContent(innerContent []ADFContent, mark ADFMark) []ADFContent {
result := make([]ADFContent, 0, len(innerContent))
for _, c := range innerContent {
if !hasCodeMark(c) {
c.Marks = append([]ADFMark{mark}, c.Marks...)
}
result = append(result, c)
}
return result
}

// parseInline parses inline markdown elements (bold, italic, code, links).
func parseInline(text string) []ADFContent {
if text == "" {
Expand Down Expand Up @@ -519,20 +544,14 @@ func parseInline(text string) []ADFContent {
if boldMatch := regexp.MustCompile(`^\*\*([^*]+)\*\*`).FindStringSubmatch(remaining); len(boldMatch) > 0 {
// Parse inner content for nested formatting
innerContent := parseInline(boldMatch[1])
for _, c := range innerContent {
c.Marks = append([]ADFMark{{Type: "strong"}}, c.Marks...)
content = append(content, c)
}
content = append(content, addMarkToContent(innerContent, ADFMark{Type: "strong"})...)
remaining = remaining[len(boldMatch[0]):]
matched = true
continue
}
if boldMatch := regexp.MustCompile(`^__([^_]+)__`).FindStringSubmatch(remaining); len(boldMatch) > 0 {
innerContent := parseInline(boldMatch[1])
for _, c := range innerContent {
c.Marks = append([]ADFMark{{Type: "strong"}}, c.Marks...)
content = append(content, c)
}
content = append(content, addMarkToContent(innerContent, ADFMark{Type: "strong"})...)
remaining = remaining[len(boldMatch[0]):]
matched = true
continue
Expand All @@ -541,10 +560,7 @@ func parseInline(text string) []ADFContent {
// Strikethrough: ~~text~~
if strikeMatch := regexp.MustCompile(`^~~([^~]+)~~`).FindStringSubmatch(remaining); len(strikeMatch) > 0 {
innerContent := parseInline(strikeMatch[1])
for _, c := range innerContent {
c.Marks = append([]ADFMark{{Type: "strike"}}, c.Marks...)
content = append(content, c)
}
content = append(content, addMarkToContent(innerContent, ADFMark{Type: "strike"})...)
remaining = remaining[len(strikeMatch[0]):]
matched = true
continue
Expand All @@ -553,20 +569,14 @@ func parseInline(text string) []ADFContent {
// Italic: *text* or _text_ (must not be followed by another * or _)
if italicMatch := regexp.MustCompile(`^\*([^*]+)\*`).FindStringSubmatch(remaining); len(italicMatch) > 0 {
innerContent := parseInline(italicMatch[1])
for _, c := range innerContent {
c.Marks = append([]ADFMark{{Type: "em"}}, c.Marks...)
content = append(content, c)
}
content = append(content, addMarkToContent(innerContent, ADFMark{Type: "em"})...)
remaining = remaining[len(italicMatch[0]):]
matched = true
continue
}
if italicMatch := regexp.MustCompile(`^_([^_]+)_`).FindStringSubmatch(remaining); len(italicMatch) > 0 {
innerContent := parseInline(italicMatch[1])
for _, c := range innerContent {
c.Marks = append([]ADFMark{{Type: "em"}}, c.Marks...)
content = append(content, c)
}
content = append(content, addMarkToContent(innerContent, ADFMark{Type: "em"})...)
remaining = remaining[len(italicMatch[0]):]
matched = true
continue
Expand Down
98 changes: 98 additions & 0 deletions internal/api/markdown_code_mark_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package api

import (
"encoding/json"
"testing"
)

func TestMarkdownToADF_CodeInsideBold(t *testing.T) {
adf := MarkdownToADF("**Bold with `code` inside**")

if len(adf.Content) != 1 {
t.Fatalf("expected 1 block, got %d", len(adf.Content))
}

para := adf.Content[0]
if para.Type != "paragraph" {
t.Fatalf("expected paragraph, got %s", para.Type)
}

if len(para.Content) != 3 {
b, _ := json.MarshalIndent(adf, "", " ")
t.Fatalf("expected 3 inline nodes, got %d:\n%s", len(para.Content), b)
}

// "Bold with " should have strong mark
if para.Content[0].Text != "Bold with " {
t.Errorf("expected 'Bold with ', got %q", para.Content[0].Text)
}
if len(para.Content[0].Marks) != 1 || para.Content[0].Marks[0].Type != "strong" {
t.Errorf("expected [strong] marks, got %v", para.Content[0].Marks)
}

// "code" should have ONLY code mark (not strong+code)
if para.Content[1].Text != "code" {
t.Errorf("expected 'code', got %q", para.Content[1].Text)
}
if len(para.Content[1].Marks) != 1 || para.Content[1].Marks[0].Type != "code" {
t.Errorf("expected [code] marks only, got %v", para.Content[1].Marks)
}

// " inside" should have strong mark
if para.Content[2].Text != " inside" {
t.Errorf("expected ' inside', got %q", para.Content[2].Text)
}
if len(para.Content[2].Marks) != 1 || para.Content[2].Marks[0].Type != "strong" {
t.Errorf("expected [strong] marks, got %v", para.Content[2].Marks)
}
}

func TestMarkdownToADF_CodeInsideItalic(t *testing.T) {
adf := MarkdownToADF("*italic `code` here*")

para := adf.Content[0]
for _, c := range para.Content {
if hasCodeMark(c) {
if len(c.Marks) != 1 {
t.Errorf("code node should have only code mark, got %v", c.Marks)
}
}
}
}
Comment on lines +50 to +61

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test correctly verifies that code marks are exclusive. To make it more robust, I suggest expanding it to also assert that the non-code parts of the string correctly receive the em mark. This would provide a more complete validation, similar to TestMarkdownToADF_CodeInsideBold.

func TestMarkdownToADF_CodeInsideItalic(t *testing.T) {
	adf := MarkdownToADF("*italic `code` here*")

	if len(adf.Content) != 1 {
		t.Fatalf("expected 1 block, got %d", len(adf.Content))
	}

	para := adf.Content[0]
	if para.Type != "paragraph" {
		t.Fatalf("expected paragraph, got %s", para.Type)
	}

	if len(para.Content) != 3 {
		b, _ := json.MarshalIndent(adf, "", "  ")
		t.Fatalf("expected 3 inline nodes, got %d:\n%s", len(para.Content), b)
	}

	// "italic " should have em mark
	if para.Content[0].Text != "italic " {
		t.Errorf("expected 'italic ', got %q", para.Content[0].Text)
	}
	if len(para.Content[0].Marks) != 1 || para.Content[0].Marks[0].Type != "em" {
		t.Errorf("expected [em] marks, got %v", para.Content[0].Marks)
	}

	// "code" should have ONLY code mark
	if para.Content[1].Text != "code" {
		t.Errorf("expected 'code', got %q", para.Content[1].Text)
	}
	if len(para.Content[1].Marks) != 1 || para.Content[1].Marks[0].Type != "code" {
		t.Errorf("expected [code] marks only, got %v", para.Content[1].Marks)
	}

	// " here" should have em mark
	if para.Content[2].Text != " here" {
		t.Errorf("expected ' here', got %q", para.Content[2].Text)
	}
	if len(para.Content[2].Marks) != 1 || para.Content[2].Marks[0].Type != "em" {
		t.Errorf("expected [em] marks, got %v", para.Content[2].Marks)
	}
}


func TestMarkdownToADF_CodeInsideStrikethrough(t *testing.T) {
adf := MarkdownToADF("~~deleted `code` here~~")

para := adf.Content[0]
for _, c := range para.Content {
if hasCodeMark(c) {
if len(c.Marks) != 1 {
t.Errorf("code node should have only code mark, got %v", c.Marks)
}
}
}
}
Comment on lines +63 to +74

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the italic test, this test for strikethrough can be strengthened. Explicitly checking that the text segments around the code block have the strike mark would make the test more comprehensive and help prevent future regressions.

func TestMarkdownToADF_CodeInsideStrikethrough(t *testing.T) {
	adf := MarkdownToADF("~~deleted `code` here~~")

	if len(adf.Content) != 1 {
		t.Fatalf("expected 1 block, got %d", len(adf.Content))
	}

	para := adf.Content[0]
	if para.Type != "paragraph" {
		t.Fatalf("expected paragraph, got %s", para.Type)
	}

	if len(para.Content) != 3 {
		b, _ := json.MarshalIndent(adf, "", "  ")
		t.Fatalf("expected 3 inline nodes, got %d:\n%s", len(para.Content), b)
	}

	// "deleted " should have strike mark
	if para.Content[0].Text != "deleted " {
		t.Errorf("expected 'deleted ', got %q", para.Content[0].Text)
	}
	if len(para.Content[0].Marks) != 1 || para.Content[0].Marks[0].Type != "strike" {
		t.Errorf("expected [strike] marks, got %v", para.Content[0].Marks)
	}

	// "code" should have ONLY code mark
	if para.Content[1].Text != "code" {
		t.Errorf("expected 'code', got %q", para.Content[1].Text)
	}
	if len(para.Content[1].Marks) != 1 || para.Content[1].Marks[0].Type != "code" {
		t.Errorf("expected [code] marks only, got %v", para.Content[1].Marks)
	}

	// " here" should have strike mark
	if para.Content[2].Text != " here" {
		t.Errorf("expected ' here', got %q", para.Content[2].Text)
	}
	if len(para.Content[2].Marks) != 1 || para.Content[2].Marks[0].Type != "strike" {
		t.Errorf("expected [strike] marks, got %v", para.Content[2].Marks)
	}
}


func TestMarkdownToADF_BoldWithCodeAndCodeBlock(t *testing.T) {
// This was the exact combination that caused INVALID_INPUT from Jira
md := "**Migration: Falsche `s_action` korrigieren**\n\n```sql\nSELECT 1\n```"
adf := MarkdownToADF(md)

if len(adf.Content) != 2 {
b, _ := json.MarshalIndent(adf, "", " ")
t.Fatalf("expected 2 blocks (paragraph + codeBlock), got %d:\n%s", len(adf.Content), b)
}

// Verify no code node has additional marks
para := adf.Content[0]
for _, c := range para.Content {
if hasCodeMark(c) && len(c.Marks) > 1 {
t.Errorf("code node should have only code mark, got %v", c.Marks)
}
}

// Verify code block is present
if adf.Content[1].Type != "codeBlock" {
t.Errorf("expected codeBlock, got %s", adf.Content[1].Type)
}
}
Comment on lines +76 to +98

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This test covers the specific case that triggered the bug, which is excellent. To make it even more solid, I suggest adding assertions to verify that the non-code parts of the bolded string also have the strong mark. This provides a more complete verification of the paragraph's content and protects against regressions.

func TestMarkdownToADF_BoldWithCodeAndCodeBlock(t *testing.T) {
	// This was the exact combination that caused INVALID_INPUT from Jira
	md := "**Migration: Falsche `s_action` korrigieren**\n\n```sql\nSELECT 1\n```"
	adf := MarkdownToADF(md)

	if len(adf.Content) != 2 {
		b, _ := json.MarshalIndent(adf, "", "  ")
		t.Fatalf("expected 2 blocks (paragraph + codeBlock), got %d:\n%s", len(adf.Content), b)
	}

	// Verify paragraph content
	para := adf.Content[0]
	if para.Type != "paragraph" {
		t.Fatalf("expected paragraph, got %s", para.Type)
	}
	if len(para.Content) != 3 {
		b, _ := json.MarshalIndent(para, "", "  ")
		t.Fatalf("expected 3 inline nodes in paragraph, got %d:\n%s", len(para.Content), b)
	}

	// "Migration: Falsche " should have strong mark
	if para.Content[0].Text != "Migration: Falsche " {
		t.Errorf("expected 'Migration: Falsche ', got %q", para.Content[0].Text)
	}
	if len(para.Content[0].Marks) != 1 || para.Content[0].Marks[0].Type != "strong" {
		t.Errorf("expected [strong] marks, got %v", para.Content[0].Marks)
	}

	// "s_action" should have ONLY code mark
	if para.Content[1].Text != "s_action" {
		t.Errorf("expected 's_action', got %q", para.Content[1].Text)
	}
	if len(para.Content[1].Marks) != 1 || para.Content[1].Marks[0].Type != "code" {
		t.Errorf("expected [code] marks only, got %v", para.Content[1].Marks)
	}

	// " korrigieren" should have strong mark
	if para.Content[2].Text != " korrigieren" {
		t.Errorf("expected ' korrigieren', got %q", para.Content[2].Text)
	}
	if len(para.Content[2].Marks) != 1 || para.Content[2].Marks[0].Type != "strong" {
		t.Errorf("expected [strong] marks, got %v", para.Content[2].Marks)
	}

	// Verify code block is present
	if adf.Content[1].Type != "codeBlock" {
		t.Errorf("expected codeBlock, got %s", adf.Content[1].Type)
	}
}