Skip to content

Commit 1eb7362

Browse files
git-hyagiclaude
andauthored
fix: convert Jira wiki markup to ADF instead of dumping as plain text (#1162)
The adfTextBlock function was wrapping all text as a single plain-text ADF paragraph, causing wiki markup (h2., ||table||, *bold*, {{code}}) to render as literal characters in Jira comments. This replaces it with a parser that converts wiki markup to proper ADF nodes: headings, tables, bullet lists, bold marks, inline code, and horizontal rules. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 15bcc8e commit 1eb7362

1 file changed

Lines changed: 229 additions & 10 deletions

File tree

  • tools/agents/agent-splunk/jira

tools/agents/agent-splunk/jira/jira.go

Lines changed: 229 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -690,22 +690,241 @@ func (c *Client) transitionIssueTool(mgr *mcpClient.MCPManager) llms.Tool {
690690
return tool
691691
}
692692

693-
// adfTextBlock wraps plain text in Atlassian Document Format (ADF),
693+
// adfTextBlock converts Jira wiki markup to Atlassian Document Format (ADF),
694694
// which is required by Jira Cloud REST API v3 for description and comment bodies.
695+
// Supported: headings (h1.-h6.), bold (*text*), inline code ({{text}}),
696+
// bullet lists (* item), tables (||header|| and |cell|), horizontal rules (----).
697+
// Only ---- is recognized as a rule, matching Jira's documented syntax.
695698
func adfTextBlock(text string) map[string]any {
699+
lines := strings.Split(text, "\n")
700+
var content []map[string]any
701+
702+
i := 0
703+
for i < len(lines) {
704+
trimmed := strings.TrimSpace(lines[i])
705+
706+
if trimmed == "" {
707+
i++
708+
continue
709+
}
710+
711+
if level, title, ok := parseWikiHeading(trimmed); ok {
712+
content = append(content, map[string]any{
713+
"type": "heading",
714+
"attrs": map[string]any{"level": level},
715+
"content": parseWikiInline(title),
716+
})
717+
i++
718+
continue
719+
}
720+
721+
if isWikiTableRow(trimmed) {
722+
var rows []map[string]any
723+
for i < len(lines) {
724+
t := strings.TrimSpace(lines[i])
725+
if !isWikiTableRow(t) {
726+
break
727+
}
728+
rows = append(rows, parseWikiTableRow(t))
729+
i++
730+
}
731+
content = append(content, map[string]any{
732+
"type": "table",
733+
"content": rows,
734+
})
735+
continue
736+
}
737+
738+
if strings.HasPrefix(trimmed, "* ") {
739+
var items []map[string]any
740+
for i < len(lines) {
741+
t := strings.TrimSpace(lines[i])
742+
if !strings.HasPrefix(t, "* ") {
743+
break
744+
}
745+
items = append(items, map[string]any{
746+
"type": "listItem",
747+
"content": []map[string]any{
748+
{
749+
"type": "paragraph",
750+
"content": parseWikiInline(t[2:]),
751+
},
752+
},
753+
})
754+
i++
755+
}
756+
content = append(content, map[string]any{
757+
"type": "bulletList",
758+
"content": items,
759+
})
760+
continue
761+
}
762+
763+
if trimmed == "----" {
764+
content = append(content, map[string]any{"type": "rule"})
765+
i++
766+
continue
767+
}
768+
769+
content = append(content, map[string]any{
770+
"type": "paragraph",
771+
"content": parseWikiInline(trimmed),
772+
})
773+
i++
774+
}
775+
776+
if len(content) == 0 {
777+
content = []map[string]any{
778+
{
779+
"type": "paragraph",
780+
"content": []map[string]any{{"type": "text", "text": text}},
781+
},
782+
}
783+
}
784+
696785
return map[string]any{
697786
"type": "doc",
698787
"version": 1,
699-
"content": []map[string]any{
700-
{
701-
"type": "paragraph",
702-
"content": []map[string]any{
703-
{
704-
"type": "text",
705-
"text": text,
706-
},
788+
"content": content,
789+
}
790+
}
791+
792+
func parseWikiHeading(line string) (int, string, bool) {
793+
for _, p := range [6]string{"h1. ", "h2. ", "h3. ", "h4. ", "h5. ", "h6. "} {
794+
if strings.HasPrefix(line, p) {
795+
return int(p[1] - '0'), line[len(p):], true
796+
}
797+
}
798+
return 0, "", false
799+
}
800+
801+
func isWikiTableRow(line string) bool {
802+
return (strings.HasPrefix(line, "||") && strings.HasSuffix(line, "||")) ||
803+
(strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|"))
804+
}
805+
806+
func parseWikiTableRow(line string) map[string]any {
807+
isHeader := strings.HasPrefix(line, "||")
808+
var cells []string
809+
var cellType string
810+
811+
if isHeader {
812+
cellType = "tableHeader"
813+
inner := line[2 : len(line)-2]
814+
cells = strings.Split(inner, "||")
815+
} else {
816+
cellType = "tableCell"
817+
inner := line[1 : len(line)-1]
818+
cells = strings.Split(inner, "|")
819+
}
820+
821+
var rowContent []map[string]any
822+
for _, cell := range cells {
823+
rowContent = append(rowContent, map[string]any{
824+
"type": cellType,
825+
"content": []map[string]any{
826+
{
827+
"type": "paragraph",
828+
"content": parseWikiInline(strings.TrimSpace(cell)),
707829
},
708830
},
709-
},
831+
})
710832
}
833+
834+
return map[string]any{
835+
"type": "tableRow",
836+
"content": rowContent,
837+
}
838+
}
839+
840+
func parseWikiInline(text string) []map[string]any {
841+
var nodes []map[string]any
842+
var buf strings.Builder
843+
844+
flushBuf := func() {
845+
if buf.Len() > 0 {
846+
nodes = append(nodes, map[string]any{
847+
"type": "text",
848+
"text": unescapeWiki(buf.String()),
849+
})
850+
buf.Reset()
851+
}
852+
}
853+
854+
i := 0
855+
for i < len(text) {
856+
// Escape sequence — accumulate literally so unescapeWiki handles it later.
857+
if text[i] == '\\' && i+1 < len(text) && (text[i+1] == '{' || text[i+1] == '}' || text[i+1] == '\\') {
858+
buf.WriteByte(text[i])
859+
buf.WriteByte(text[i+1])
860+
i += 2
861+
continue
862+
}
863+
864+
// Inline code: {{...}}
865+
if i+1 < len(text) && text[i] == '{' && text[i+1] == '{' {
866+
if end := strings.Index(text[i+2:], "}}"); end >= 0 {
867+
flushBuf()
868+
nodes = append(nodes, map[string]any{
869+
"type": "text",
870+
"text": unescapeWiki(text[i+2 : i+2+end]),
871+
"marks": []map[string]any{{"type": "code"}},
872+
})
873+
i = i + 2 + end + 2
874+
continue
875+
}
876+
}
877+
878+
// Bold: *...* (word-boundary rules matching Jira's renderer)
879+
if text[i] == '*' {
880+
if end := strings.Index(text[i+1:], "*"); end > 0 {
881+
bold := text[i+1 : i+1+end]
882+
closeIdx := i + 1 + end
883+
beforeOK := i == 0 || !isAlnum(text[i-1])
884+
afterOK := closeIdx+1 >= len(text) || !isAlnum(text[closeIdx+1])
885+
if bold[0] != ' ' && bold[len(bold)-1] != ' ' && beforeOK && afterOK {
886+
flushBuf()
887+
nodes = append(nodes, map[string]any{
888+
"type": "text",
889+
"text": unescapeWiki(bold),
890+
"marks": []map[string]any{{"type": "strong"}},
891+
})
892+
i = i + 1 + end + 1
893+
continue
894+
}
895+
}
896+
}
897+
898+
buf.WriteByte(text[i])
899+
i++
900+
}
901+
902+
flushBuf()
903+
904+
if len(nodes) == 0 {
905+
return []map[string]any{{"type": "text", "text": ""}}
906+
}
907+
return nodes
908+
}
909+
910+
// unescapeWiki resolves wiki escape sequences: \{ → {, \} → }, \\ → \.
911+
// This means \\{ in the input produces a literal \{ in the output.
912+
func unescapeWiki(s string) string {
913+
var b strings.Builder
914+
b.Grow(len(s))
915+
for i := 0; i < len(s); i++ {
916+
if s[i] == '\\' && i+1 < len(s) {
917+
if next := s[i+1]; next == '{' || next == '}' || next == '\\' {
918+
b.WriteByte(next)
919+
i++
920+
continue
921+
}
922+
}
923+
b.WriteByte(s[i])
924+
}
925+
return b.String()
926+
}
927+
928+
func isAlnum(c byte) bool {
929+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
711930
}

0 commit comments

Comments
 (0)