@@ -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.
695698func 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