Convert Jira wiki format into HTML for Azure DevOps

Platforms: Jira Data Center → Azure DevOps (or any platform that needs HTML output from Jira Wiki)

The problem

Jira Data Center stores descriptions and comments as wiki markup. Azure DevOps uses HTML. When you sync an issue from Jira into ADO without converting it, ADO shows the raw markup (*bold*, h2., ||header||, {code}) as plain text, and every line runs together because ADO ignores plain line breaks.

Mapping the markup by hand in the incoming script gets messy fast. Lists can be nested and mixed (*#), tables have header rows and header columns, and code blocks must stay exactly as written. On top of that, Jira’s own editor saves text in more than one way: bold can be *bold* or {*}bold{*}, and colors can come through as {color:var(--ds-text-danger, #de350b)}. Text like 2026-10-22, my_var_name or 5*3*2 must not be turned into strikethrough, italic or bold by accident.

Not in scope: converting the other way (HTML to Jira wiki). That is covered in a separate post [link to HtmlToWiki post].

The approach

The incoming script on the ADO side converts the wiki markup into HTML before it is saved.

  1. Read the blocks, line by line. Code and noformat blocks, quotes, panels, headings, horizontal rules, lists and tables are recognized first. Code blocks are kept exactly as written, so markup inside code is never converted.
  2. Convert the inline markup per block. Links, mentions, images, monospace, text effects and colors are converted next. Finished HTML is parked while the rest of the line is processed, so it is never converted twice.
  3. Follow Jira’s own rules for effects. A marker only counts when it is not glued to a letter or number on the outside, which keeps dates, file names and variable names as they are. The braced form that Jira’s editor writes ({*}bold{*}) is supported too.
  4. Escape everything else. Characters like <, > and & in the text are escaped, so HTML typed in a Jira description shows as text in ADO instead of being run as HTML.

What gets converted:

Jira wiki HTML
h1. to h6. <h1> to <h6>
*bold*, _italic_, +underline+, -strike-, ^sup^, ~sub~, ??cite?? (plain and braced form) <strong>, <em>, <u>, <del>, <sup>, <sub>, <cite>
{color:red}, {color:#0052CC}, {color:var(..., #de350b)} <span style="color:...">
*, #, -, nested and mixed (**, *#) <ul> / <ol>, nested
||header|| and |cell|, including header columns <table> with <th> / <td>
{{monospace}} <code>
{code} / {noformat} <pre><code> with a grey code block style
{quote} and bq. <blockquote>
{panel} a styled box with title and background color
---- <hr>
[text|url], [url], bare URLs, [mailto:...] <a href>
!https://...png! <img>
!file.png! (attachment) [image: file.png] placeholder
[~user] mention ADO mention when found, otherwise plain @Name
\\ and line breaks <br> and paragraphs

The result

Descriptions and comments from Jira arrive in ADO formatted the way they were written: headings, lists, tables, colors, panels and code blocks stay intact, line breaks are kept, and nothing in the text is formatted by accident. The same converter is used for the description and the comments, so there is one place to maintain.

Notes

  • Paste script 1 (the WikiToHtml class) at the top of the ADO incoming script, and script 2 below it.
  • No extra libraries are needed, the class is plain Groovy.
  • Replace FOO with your ADO project name.
  • First sync: the work item is created first with only project, type and summary, so mentions can be resolved before the description is written.
  • Mentions: the optional lookup uses nodeHelper.getUserByEmail. On Jira Data Center a mention is [~username], so the lookup only finds the user when the username is the email address. [confirm?] If no user is found, or you remove the lookup, the mention becomes plain @Name.
  • Attachment images (!file.png!) become a [image: file.png] placeholder, because the image only exists in ADO once the attachment is synced.
  • If Jira’s visual editor changed the text (for example removed escapes or rewrote code blocks), that is what arrives in the replica. To check what Jira actually sends, temporarily add debug.error(replica.description) to the incoming script.

The Scripts

1. ADO incoming script: the converter (paste at the top of the script)

/**
 * Jira wiki markup -> HTML (e.g. Jira description/comments into ADO).
 *
 * Two passes:
 *   1) Blocks, line by line: code/noformat, quote, panel, headings, bq., hr, lists (nested), tables, paragraphs
 *   2) Inline, per block: escapes, links, mentions, images, {{mono}}, text effects, colors, line breaks
 *
 * Pure Groovy, no extra libraries needed.
 * Inside Exalate: paste this class at the TOP of your rule, then call it below.
 *
 * Usage (ADO incoming):
 *
 *   // 1) Basic: mentions become plain "@Name"
 *   def converter = new WikiToHtml()
 *   issue.description = converter.convert(replica.description)
 *
 *   // 2) Comments
 *   issue.comments = commentHelper.mergeComments(issue, replica, { comment ->
 *       comment.body = converter.convert(comment.body)
 *       comment
 *   })
 *
 *   // 3) With user mentions resolved to ADO users ("FOO" = your ADO project)
 *   def converter = new WikiToHtml({ String email, String name ->
 *       def user = nodeHelper.getUserByEmail(email, "FOO")
 *       user ? "<a href=\"#\" data-vss-mention=\"version:2.0,${user.key}\"></a>" : null
 *   })
 *
 *   // 4) Any other string
 *   String html = new WikiToHtml().convert("h2. Title\n* *bold* item")
 *
 *   // Debug: show the result in the sync error instead of saving it
 *   debug.error(converter.convert(replica.description))
 */
class WikiToHtml {

    /** Optional: { String idOrEmail, String displayName -> html or null }. Null result = plain "@name". */
    Closure mentionResolver

    WikiToHtml(Closure mentionResolver = null) {
        this.mentionResolver = mentionResolver
    }

    // ---------------------------------------------------------------- blocks

    String convert(String wiki) {
        if (!wiki) return ""
        List<String> lines = new ArrayList<String>(wiki.replace("\r\n", "\n").replace("\r", "\n").split("\n", -1) as List)
        StringBuilder out = new StringBuilder()
        List<String> para = []
        int i = 0

        while (i < lines.size()) {
            String line = lines[i]
            String t = line.trim()
            def m

            if (!t) {
                flushParagraph(out, para)
                i++
                continue
            }

            // {code}, {noformat}, {quote}, {panel}
            if ((m = (t =~ /^\{(code|noformat|quote|panel)(?::([^}]*))?\}/))) {
                flushParagraph(out, para)
                String name = m[0][1]
                Map block = readMacro(lines, i, name)
                out << renderMacro(name, parseParams(m[0][2]), block.body as String)
                i = block.next as int
                continue
            }

            // h1. - h6.
            if ((m = (t =~ /^h([1-6])\.\s+(.*)$/))) {
                flushParagraph(out, para)
                out << "<h${m[0][1]}>${inline(m[0][2])}</h${m[0][1]}>"
                i++
                continue
            }

            // bq. single line quote
            if ((m = (t =~ /^bq\.\s+(.*)$/))) {
                flushParagraph(out, para)
                out << "<blockquote>${inline(m[0][1])}</blockquote>"
                i++
                continue
            }

            // ---- horizontal rule
            if (t ==~ /^-{4,}$/) {
                flushParagraph(out, para)
                out << "<hr>"
                i++
                continue
            }

            // lists: *, #, mixed (*#), single "-"
            if (isListLine(t)) {
                flushParagraph(out, para)
                i = renderList(lines, i, out)
                continue
            }

            // tables: lines starting with |
            if (t.startsWith("|")) {
                flushParagraph(out, para)
                i = renderTable(lines, i, out)
                continue
            }

            para << line
            i++
        }
        flushParagraph(out, para)
        return out.toString()
    }

    private void flushParagraph(StringBuilder out, List<String> para) {
        if (!para) return
        out << "<p>${inline(para.join("\n"))}</p>"
        para.clear()
    }

    /** Reads a {name}...{name} block. Supports open and close on the same line and text after the close. */
    private Map readMacro(List<String> lines, int i, String name) {
        String first = lines[i].trim()
        String closeTag = "{${name}}"
        String rest = first.substring(first.indexOf('}') + 1)
        List<String> body = []
        int j = i
        String current = rest

        while (true) {
            int c = current.indexOf(closeTag)
            if (c >= 0) {
                body << current.substring(0, c)
                String trailing = current.substring(c + closeTag.length())
                if (trailing.trim()) {
                    lines.set(j, trailing)   // process leftover text as a normal line
                    return [next: j, body: joinBody(body)]
                }
                return [next: j + 1, body: joinBody(body)]
            }
            body << current
            j++
            if (j >= lines.size()) return [next: j, body: joinBody(body)]   // unclosed: take the rest
            current = lines[j]
        }
    }

    private static String joinBody(List<String> body) {
        // drop the empty first/last line produced by "{code}\n...\n{code}"
        List<String> b = new ArrayList<String>(body)
        if (b && !b[0].trim()) b.remove(0)
        if (b && !b[-1].trim()) b.remove(b.size() - 1)
        return b.join("\n")
    }

    // ADO keeps inline styles, so code blocks get a visible box
    private static final String PRE_STYLE = "background-color:#f4f5f7;border:1px solid #dfe1e6;border-radius:3px;padding:8px;font-family:Consolas,Menlo,monospace;font-size:12px;white-space:pre-wrap;"

    private String renderMacro(String name, Map params, String body) {
        switch (name) {
            case "code":
            case "noformat":
                return "<pre style=\"${PRE_STYLE}\"><code>${esc(body)}</code></pre>"
            case "quote":
                return "<blockquote>${convert(body)}</blockquote>"
            case "panel":
                String bg = safeColor(params.bgColor) ?: "#f4f5f7"
                String border = safeColor(params.borderColor) ?: "#c1c7d0"
                String title = params.title ? "<div style=\"font-weight:bold;margin-bottom:4px;\">${inline(params.title as String)}</div>" : ""
                return "<div style=\"border:1px solid ${border};background-color:${bg};padding:8px;margin:4px 0;\">${title}${convert(body)}</div>"
        }
        return esc(body)
    }

    /** "title=My title|bgColor=#fff" -> [title: "My title", bgColor: "#fff"]. A bare "java" becomes [default: "java"]. */
    private static Map parseParams(String raw) {
        Map p = [:]
        if (!raw) return p
        raw.split(/\|/).each { String part ->
            int eq = part.indexOf('=')
            if (eq > 0) p[part.substring(0, eq).trim()] = part.substring(eq + 1).trim()
            else if (part.trim()) p["default"] = part.trim()
        }
        return p
    }

    // ---------------------------------------------------------------- lists

    private static final String LIST_REGEX = /^(?:([*#]+)|-)\s+(.*)$/

    private static boolean isListLine(String t) {
        return t ==~ LIST_REGEX
    }

    /** Nested lists, including mixed types (e.g. "*#" = numbered list inside a bullet). Returns next line index. */
    private int renderList(List<String> lines, int i, StringBuilder out) {
        List<String> stack = []
        while (i < lines.size() && isListLine(lines[i].trim())) {
            def m = lines[i].trim() =~ LIST_REGEX
            m.find()
            String markers = m.group(1) ?: "*"
            String text = m.group(2)
            List<String> want = markers.toList().collect { it == "#" ? "ol" : "ul" }

            int common = 0
            while (common < stack.size() && common < want.size() && stack[common] == want[common]) common++

            while (stack.size() > common) {
                out << "</li></${stack.remove(stack.size() - 1)}>"
            }
            if (stack && stack.size() == want.size()) out << "</li>"
            boolean opened = false
            while (stack.size() < want.size()) {
                // a level skipped (e.g. "##" with no parent item) needs an empty item to stay valid HTML
                if (opened) out << "<li style=\"list-style-type:none\">"
                String type = want[stack.size()]
                out << "<${type}>"
                stack << type
                opened = true
            }
            out << "<li>${inline(text)}"
            i++
        }
        while (stack) {
            out << "</li></${stack.remove(stack.size() - 1)}>"
        }
        return i
    }

    // ---------------------------------------------------------------- tables

    private static final String CELL_STYLE = "border:1px solid #c1c7d0;padding:4px 8px;vertical-align:top;"
    private static final String HEAD_STYLE = CELL_STYLE + "background-color:#f4f5f7;font-weight:bold;text-align:left;"

    /** Consecutive "|" lines = one table. Cells can be headers (||) or data (|), also mixed in one row. */
    private int renderTable(List<String> lines, int i, StringBuilder out) {
        out << "<table style=\"border-collapse:collapse;\"><tbody>"
        while (i < lines.size() && lines[i].trim().startsWith("|")) {
            String row = lines[i].trim()
            i++
            // a cell can continue on the next line until the row ends with |
            while (!row.endsWith("|") && i < lines.size() && lines[i].trim() && !lines[i].trim().startsWith("|")) {
                row += "\n" + lines[i].trim()
                i++
            }
            out << "<tr>"
            splitRow(row).each { Map cell ->
                String content = cell.text.toString().trim()
                String html = content ? inline(content) : "&nbsp;"
                out << (cell.header ? "<th style=\"${HEAD_STYLE}\">${html}</th>" : "<td style=\"${CELL_STYLE}\">${html}</td>")
            }
            out << "</tr>"
        }
        out << "</tbody></table>"
        return i
    }

    /** Splits a row on | and ||, ignoring pipes inside [links] and {macros}. */
    private static List<Map> splitRow(String row) {
        List<Map> cells = []
        StringBuilder cur = null
        boolean header = false
        int brackets = 0, braces = 0
        int i = 0
        while (i < row.length()) {
            String c = row.substring(i, i + 1)
            if (c == "[") brackets++
            else if (c == "]" && brackets > 0) brackets--
            else if (c == "{") braces++
            else if (c == "}" && braces > 0) braces--

            if (c == "|" && brackets == 0 && braces == 0) {
                if (cur != null) cells << [header: header, text: cur.toString()]
                header = (i + 1 < row.length() && row.substring(i + 1, i + 2) == "|")
                if (header) i++
                cur = new StringBuilder()
            } else if (cur != null) {
                cur.append(c)
            }
            i++
        }
        if (cur != null && cur.toString().trim()) cells << [header: header, text: cur.toString()]
        return cells
    }

    // ---------------------------------------------------------------- inline

    /** Converts inline markup. Finished HTML pieces are parked as tokens so later steps can't break them. */
    String inline(String s) {
        if (!s) return ""
        List<String> tokens = []
        Closure keep = { String html ->
            tokens << html
            return "\u0000${tokens.size() - 1}\u0000"
        }

        // \\ = line break, \x = literal x
        s = s.replaceAll("\\\\\\\\") { keep("<br>") }
        s = s.replaceAll("\\\\([*_?\\-+^~{}\\[\\]!|#])") { all, ch -> keep(esc(ch as String)) }

        // {{monospace}} and inline {noformat}/{code}
        s = s.replace("{{}}", "")   // empty monospace left behind by Jira's editor
        s = s.replaceAll(/\{\{(.+?)\}\}/) { all, txt -> keep("<code>${esc(txt as String)}</code>") }
        s = s.replaceAll(/\{(?:noformat|code)(?::[^}]*)?\}(.+?)\{(?:noformat|code)\}/) { all, txt ->
            keep("<code>${esc(txt as String)}</code>")
        }

        // mentions: [~username], [~accountid:xyz], [~accountid:email|Name]
        s = s.replaceAll(/\[~(?:accountid:)?([^\]|]+)(?:\|([^\]]+))?\]/) { all, id, name ->
            keep(mention((id as String).trim(), (name as String)?.trim()))
        }

        // links: [text|url], [url]
        s = s.replaceAll(/\[(?:([^\]|]*)\|)?\s*((?:https?|ftp|mailto):[^\]\s|]+)\s*\]/) { all, text, url ->
            String u = url as String
            String label = text ? inline(text as String) : esc(u.replaceFirst(/^mailto:/, ""))
            keep("<a href=\"${esc(u)}\">${label}</a>")
        }

        // images: !https://...png! and !file.png|thumbnail!
        s = s.replaceAll(/!([^!\s|]+\.(?:png|jpe?g|gif|bmp|svg|webp))(?:\|[^!]*)?!/) { all, src ->
            String u = src as String
            keep(u ==~ /(?i)https?:\/\/.*/ ? "<img src=\"${esc(u)}\" alt=\"\">" : "[image: ${esc(u)}]")
        }

        // bare URLs
        s = s.replaceAll(/(?<![\w"'=\/])(https?:\/\/[^\s\u0000<]*[^\s\u0000<.,;:!?)\]])/) { all, url ->
            keep("<a href=\"${esc(url as String)}\">${esc(url as String)}</a>")
        }

        s = esc(s)

        // text effects (Jira rule: marker not glued to a letter/number on the outside, not followed/preceded by space inside)
        s = effect(s, /\*/, "strong")
        s = effect(s, /_/, "em")
        s = effect(s, /\?\?/, "cite")
        s = effect(s, /-/, "del")
        s = effect(s, /\+/, "u")
        s = effect(s, /\^/, "sup")
        s = effect(s, /~/, "sub")

        // {color:red}text{color} and {color:#ff0000}text{color}
        // also accepts values like {color:var(--ds-text-danger, #de350b)} by taking the hex fallback
        s = s.replaceAll(/(?s)\{color:([^}]*)\}(.*?)\{color\}/) { all, color, txt ->
            String c = safeColor(color as String)
            if (!c) {
                def hex = (color as String) =~ /#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}(?![0-9a-fA-F])/
                c = hex.find() ? hex.group() : null
            }
            c ? "<span style=\"color:${c}\">${txt}</span>" : txt
        }

        s = s.replace("\n", "<br>")

        // restore parked HTML (loop handles tokens inside tokens)
        while (s.contains("\u0000")) {
            String before = s
            s = s.replaceAll(/\u0000(\d+)\u0000/) { all, n -> tokens[(n as String) as int] }
            if (s == before) break
        }
        return s
    }

    private static String effect(String s, String marker, String tag) {
        // braced form, written by Jira's editor when the marker touches other text: {*}bold{*}
        s = s.replaceAll("\\{" + marker + "\\}(.+?)\\{" + marker + "\\}") { all, txt -> "<${tag}>${txt}</${tag}>" }
        String regex = "(?<![\\p{L}\\p{N}])" + marker + "(?=\\S)(.+?)(?<=\\S)" + marker + "(?![\\p{L}\\p{N}])"
        return s.replaceAll(regex) { all, txt -> "<${tag}>${txt}</${tag}>" }
    }

    private String mention(String id, String name) {
        if (mentionResolver) {
            def html = mentionResolver.call(id, name)
            if (html) return html as String
        }
        return "@" + esc(name ?: id)
    }

    /** Allows #rgb/#rrggbb or a plain color name; anything else is dropped. */
    private static String safeColor(def c) {
        if (!c) return null
        String v = c.toString().trim()
        return (v ==~ /#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?/ || v ==~ /[a-zA-Z]+/) ? v : null
    }

    static String esc(String s) {
        if (s == null) return ""
        return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
    }
}

2. ADO incoming script: description and comments

// On first sync, create the work item first so mentions resolve correctly.
if (firstSync) {
    issue.projectKey = "FOO"
    issue.typeName   = "Task"
    issue.summary    = replica.summary
    store(issue)   // creates the work item with only project, type and summary
}

// Optional: resolve Jira mentions to ADO users. Remove the closure to output plain "@Name".
def converter = new WikiToHtml({ String email, String name ->
    def user = nodeHelper.getUserByEmail(email, "FOO")
    user ? "<a href=\"#\" data-vss-mention=\"version:2.0,${user.key}\"></a>" : null
})

issue.description = converter.convert(replica.description)

issue.comments = commentHelper.mergeComments(issue, replica, { comment ->
    comment.body = converter.convert(comment.body)
    comment
})

You’ve provided a fantastic overview and a robust solution for converting Jira wiki markup to HTML for Azure DevOps using a Groovy class in the ADO incoming sync script. This approach ensures that Jira descriptions and comments retain their formatting when transferred to Azure DevOps, handling everything from headings and lists to tables, code blocks, mentions, and even color macros.

To summarize the key steps for anyone looking to implement this:

  1. Add the WikiToHtml class at the top of your Azure DevOps incoming sync script. This class parses Jira wiki markup and outputs clean HTML, handling both block-level and inline formatting.
  2. Use the converter in your script to process descriptions and comments. For example:
    def converter = new WikiToHtml()
    issue.description = converter.convert(replica.description)
    issue.comments = commentHelper.mergeComments(issue, replica, { comment ->
        comment.body = converter.convert(comment.body)
        comment
    })
    
    If you want to resolve Jira mentions to ADO users, pass a closure to the WikiToHtml constructor that uses nodeHelper.getUserByEmail.
  3. Handle first syncs carefully: Create the work item with minimal fields first if you need to resolve mentions, then update the description and comments.
  4. No extra libraries are needed—the solution is pure Groovy and works directly in Exalate’s scripting environment.

For more details and the full script, check the official documentation:

This method ensures your Jira content looks great in Azure DevOps, with all formatting preserved and no accidental conversions. If you need to go the other way (HTML to Jira wiki), that’s a separate process.

Let me know if you need any clarifications on integrating this into your sync rules!