Convert HTML into Jira Data Center wiki format

Convert HTML into Jira Data Center wiki format

Platforms: Azure DevOps (or any platform that sends HTML) → Jira Data Center

The problem

Azure DevOps stores descriptions and comments as HTML. Jira Data Center uses wiki markup. When you sync a work item from ADO into Jira DC without converting it, Jira shows the raw HTML tags (<div>, <b>, <ul>, &nbsp;) instead of formatted text, or loses the line breaks, so the whole description ends up on a single line.

Mapping every tag by hand in the incoming script gets messy fast. ADO wraps every line in a <div>, leaves tags unclosed, nests lists inside lists, and adds inline styles for colors. On top of that, text that happens to contain *, _, [ or { is read by Jira as markup and gets formatted by accident.

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

The approach

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

  1. Parse the HTML with jsoup. jsoup turns the HTML into a tree of nodes. It already handles the hard parts: unclosed tags, entities like &nbsp; and &amp;, and tags without a closing tag such as <br> and <img>.
  2. Walk the tree node by node. Every tag that has a Jira equivalent is mapped to its wiki markup. Every other tag is handled by jsoup’s isBlock() check: block tags (like <div>) become their own line, inline tags keep their text. Unknown tags never break the sync.
  3. Protect the text. Characters Jira would read as markup are escaped, so *not bold* stays text. Dates, file names and variable names (2026-10-22, my_var_name) are left alone.
  4. Pick the right markers. When bold or italic text touches a letter (for example abc<b>def</b>ghi), the braced form {*}def{*} is used, which is what Jira’s own editor does. Otherwise the plain form *def* is used.

What gets converted:

HTML Jira wiki
<h1> to <h6> h1. to h6.
<b>, <i>, <u>, <s>, <sup>, <sub>, <cite> *bold*, _italic_, +underline+, -strike-, ^sup^, ~sub~, ??cite??
<span style="color:..."> (hex, rgb, named) {color:#ff0000}text{color}
<ul> / <ol>, nested and mixed *, #, **, #* and so on
<table> with <th> / <td> ||header|| and |cell|
<code> {{code}}
<pre> {code:java} when a language is set, otherwise {noformat}
<blockquote> {quote}
<hr> ----
<a href> [text|url]
<img> !url!, ADO attachments become !fileName!
ADO mention [~user] when found, otherwise plain @Name
<div>, <br>, <p> line breaks and paragraphs

The result

Descriptions and comments from ADO arrive in Jira DC formatted the way they were written: headings, lists, tables, colors 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

  • The script uses jsoup. jsoup is not part of Java or Groovy, so it must be on the classpath of the Exalate app on your Jira DC node. To check, add debug.error(org.jsoup.Jsoup.class.name) to the incoming script: if it shows org.jsoup.Jsoup, you are good to go.
  • Paste script 1 (imports + HtmlToWikiJsoup class) at the top of the Jira DC incoming script, and script 2 below it.
  • Mentions: an ADO mention only contains the ADO user id and the display name, no email. The optional lookup uses nodeHelper.getUserByFullName to find the Jira user with the same full name, so the names must match on both sides. If no user is found, or you remove the lookup, the mention becomes plain @Name.
  • Some things have no Jira wiki equivalent and are simplified: merged table cells (colspan / rowspan), background colors, font sizes and text alignment. Lists inside table cells become bullet lines.
  • Attachment images only show in Jira if the attachment is synced as well.
  • Pasted screenshots that are stored inside the HTML itself (data: images) are dropped.
  • To check what ADO actually sends, temporarily add debug.error(converter.convert(replica.description)) to the incoming script. The converted result shows in the sync error instead of being saved.

The Scripts

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

import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode

/**
 * HTML -> Jira wiki markup, jsoup version (e.g. ADO description/comments into Jira DC).
 * jsoup does the parsing (tags, entities, unclosed tags), this class maps the nodes to wiki markup.
 *
 * Requires jsoup on the node's classpath (Jira DC). Inside Exalate: paste the imports above and
 * this class at the TOP of your rule, then call it below.
 *
 * Usage (Jira incoming):
 *
 *   // 1) Basic: mentions become plain "@Name"
 *   def converter = new HtmlToWikiJsoup()
 *   issue.description = converter.convert(replica.description)
 *
 *   // 2) Comments
 *   issue.comments = commentHelper.mergeComments(issue, replica, { comment ->
 *       comment.body = converter.convert(comment.body)
 *       comment
 *   })
 *
 *   // 3) With ADO mentions resolved to Jira users by display name. The closure gets the ADO
 *   //    user id and the display name, and returns wiki markup or null (null = plain "@Name").
 *   def converter = new HtmlToWikiJsoup({ String adoId, String name ->
 *       def user = nodeHelper.getUserByFullName(name)
 *       user ? "[~${user.username}]" : null   // null = plain "@Name"
 *   })
 *
 *   // Debug: show the result in the sync error instead of saving it
 *   debug.error(converter.convert(replica.description))
 *
 * Notes:
 *   - ADO attachment images become !fileName!, which only shows in Jira if the attachment is synced too.
 *   - Inline data: images (pasted screenshots stored inside the HTML) are dropped.
 */
class HtmlToWikiJsoup {

    /** Optional: { String adoUserId, String displayName -> wiki markup or null }. */
    Closure mentionResolver

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

    // Private-use characters as internal markers (never in real text, not removed by trim())
    private static final String OPEN  = "\uE000"   // effect open token:  OPEN id OPEN
    private static final String CLOSE = "\uE001"   // effect close token: CLOSE id CLOSE
    private static final String LINE  = "\uE002"   // line starts with generated markup (list, heading, table row)
    private static final String BLOCK = "\uE003"   // soft block boundary (<div>), merged into line breaks later
    private static final String KEEP  = "\uE004"   // parked code block: KEEP id KEEP

    // {code:x} languages Jira knows; anything else becomes {noformat}
    private static final Map<String, String> CODE_LANGS = [
        java: "java", javascript: "javascript", js: "javascript", sql: "sql", xml: "xml", html: "html", css: "css",
        groovy: "groovy", python: "python", py: "python", ruby: "ruby", php: "php", scala: "scala", perl: "perl"
    ]

    // per conversion
    private List<String> effects
    private List<String> kept

    String convert(String html) {
        if (!html || !html.trim()) return ""
        effects = []
        kept = []
        // plain text (no tags): keep the line breaks
        if (!(html =~ /<\/?[a-zA-Z][^>]*>/)) {
            html = html.replace("&", "&amp;").replace("<", "&lt;").replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
        }
        Document doc = Jsoup.parseBodyFragment(html)
        doc.select("script, style, template, head, title").remove()
        String s = renderChildren(doc.body(), false)
        s = normalizeBreaks(s)
        s = escapeLines(s)
        s = applyEffects(s)
        s = s.replaceAll(/\n{3,}/, "\n\n").trim()
        for (int i = 0; i < kept.size(); i++) s = s.replace(KEEP + i + KEEP, kept[i])
        return s
    }

    // ---------------------------------------------------------------- rendering

    private static boolean blockish(Node n) {
        return n instanceof Element && (((Element) n).isBlock() || ((Element) n).tagName().equalsIgnoreCase("br"))
    }

    private static String clean(String s) {
        return (s ?: "").replace(" ", " ").replace("​", "")
    }

    private String renderChildren(Element e, boolean cell) {
        List<Node> kids = e.childNodes().findAll { it instanceof TextNode || it instanceof Element }
        boolean parentBlock = e.isBlock()
        StringBuilder sb = new StringBuilder()
        for (int i = 0; i < kids.size(); i++) {
            Node c = kids[i]
            // whitespace between block tags (HTML source indentation) is not content
            if (c instanceof TextNode && !clean(((TextNode) c).getWholeText()).trim()) {
                boolean prevBlock = i == 0 ? parentBlock : blockish(kids[i - 1])
                boolean nextBlock = i == kids.size() - 1 ? parentBlock : blockish(kids[i + 1])
                if (prevBlock || nextBlock) continue
            }
            sb << render(c, cell)
        }
        return sb.toString()
    }

    private String render(Node n, boolean cell) {
        if (n instanceof TextNode) return escText(clean(((TextNode) n).getWholeText()).replaceAll(/\s+/, " "))
        if (!(n instanceof Element)) return ""
        Element e = (Element) n
        String tag = e.tagName().toLowerCase()

        switch (tag) {
            case "br":
                return "\n"
            case "p":
                return "\n\n" + renderChildren(e, cell) + "\n\n"
            case "h1": case "h2": case "h3": case "h4": case "h5": case "h6":
                String h = oneLine(renderChildren(e, cell))
                if (!h) return ""
                return cell ? "\n" + h + "\n" : "\n\n" + LINE + tag + ". " + h + "\n\n"
            case "hr":
                return cell ? "\n" : "\n" + LINE + "----\n"
            case "strong": case "b":
                return effect(renderChildren(e, cell), "*")
            case "em": case "i":
                return effect(renderChildren(e, cell), "_")
            case "u": case "ins":
                return effect(renderChildren(e, cell), "+")
            case "s": case "strike": case "del":
                return effect(renderChildren(e, cell), "-")
            case "sup":
                return effect(renderChildren(e, cell), "^")
            case "sub":
                return effect(renderChildren(e, cell), "~")
            case "cite":
                return effect(renderChildren(e, cell), "??")
            case "code": case "tt": case "kbd": case "samp":
                return mono(textOf(e, false))
            case "pre":
                return pre(e, cell)
            case "a":
                return link(e, cell)
            case "img":
                return image(e)
            case "ul": case "ol":
                return list(e, cell)
            case "table":
                return table(e, cell)
            case "blockquote":
                String body = normalizeBreaks(renderChildren(e, cell)).trim()
                if (!body) return ""
                return cell ? "\n" + body + "\n" : "\n\n{quote}\n" + body + "\n{quote}\n\n"
            case "span": case "font":
                return styled(e, cell)
        }
        if (e.isBlock()) {
            // <div> and other block wrappers: one line each. An empty <div> (ADO uses <div><br></div>) is an empty line
            String inner = renderChildren(e, cell)
            if (!normalizeBreaks(inner).trim()) return BLOCK + "\n" + BLOCK
            return BLOCK + inner + BLOCK
        }
        // unknown inline tag: keep its text
        return renderChildren(e, cell)
    }

    /** Wraps each line in effect tokens; the real marker (plain or braced) is chosen in applyEffects. */
    private String effect(String inner, String marker) {
        return wrapLines(inner) { String core ->
            int id = effects.size()
            effects << marker
            return OPEN + id + OPEN + core + CLOSE + id + CLOSE
        }
    }

    /** Jira markup can't span lines, so wrap per line, keeping outer whitespace outside the markers. */
    private static String wrapLines(String inner, Closure wrap) {
        if (!inner.trim()) return inner
        return normalizeBreaks(inner).split("\n", -1).collect { String line ->
            def m = line =~ /^(\s*)(.*?)(\s*)$/
            m.find()
            String core = m.group(2)
            if (!core || core.startsWith(LINE) || core.startsWith(KEEP)) return line
            return m.group(1) + wrap(core) + m.group(3)
        }.join("\n")
    }

    private String mono(String text) {
        String t = text.replaceAll(/\s+/, " ")
        if (!t.trim()) return t
        return (t.startsWith(" ") ? " " : "") + "{{" + escText(t.trim()) + "}}" + (t.endsWith(" ") ? " " : "")
    }

    private String pre(Element e, boolean cell) {
        String body = textOf(e, true).replaceFirst(/^\n/, "").replaceAll(/\s+$/, "")
        if (!body.trim()) return ""
        if (cell) return "\n" + escText(body) + "\n"
        String cls = e.className() + " " + e.children().findAll { it.tagName() == "code" }.collect { it.className() }.join(" ")
        def lm = cls =~ /(?:language|lang)-([\w+#-]+)/
        String lang = lm.find() ? CODE_LANGS[lm.group(1).toLowerCase()] : null
        String macro = lang ? "{code:" + lang + "}\n" + body + "\n{code}" : "{noformat}\n" + body + "\n{noformat}"
        kept << macro
        return "\n\n" + KEEP + (kept.size() - 1) + KEEP + "\n\n"
    }

    private String link(Element e, boolean cell) {
        if (e.hasAttr("data-vss-mention")) return mention(e.attr("data-vss-mention"), textOf(e, false).trim())
        String text = oneLine(renderChildren(e, cell))
        String href = e.attr("href").trim()
        if (!(href ==~ /(?i)(https?|ftp|mailto):.+/)) return text
        href = href.replace(" ", "%20").replace("|", "%7C").replace("[", "%5B").replace("]", "%5D")
        String plain = textOf(e, false).trim()
        if (!text || plain == href || "mailto:" + plain == href) return "[" + href + "]"
        return "[" + text.replace("]", "\\]") + "|" + href + "]"
    }

    private String mention(String attr, String display) {
        String id = attr?.contains(",") ? attr.substring(attr.lastIndexOf(",") + 1).trim() : (attr ?: "")
        String name = display.startsWith("@") ? display.substring(1) : display
        if (mentionResolver) {
            def r = mentionResolver.call(id, name)
            if (r) return r as String
        }
        return escText("@" + name)
    }

    private static String image(Element e) {
        String src = e.attr("src").trim()
        if (!src || src.startsWith("data:")) return ""
        def f = src =~ /(?i)[?&]fileName=([^&#]+)/
        if (src.contains("/_apis/wit/attachments/") && f.find()) {
            return "!" + URLDecoder.decode(f.group(1), "UTF-8").replace("!", "").replace("|", "") + "!"
        }
        if (src ==~ /(?i)https?:\/\/.+/) return "!" + src.replace(" ", "%20").replace("|", "%7C").replace("!", "%21") + "!"
        return ""
    }

    private String styled(Element e, boolean cell) {
        String style = e.attr("style")
        String inner = renderChildren(e, cell)
        if (style =~ /(?i)line-through/) inner = effect(inner, "-")
        if (style =~ /(?i)text-decoration[^;]*underline/) inner = effect(inner, "+")
        if (style =~ /(?i)font-style\s*:\s*italic/) inner = effect(inner, "_")
        if (style =~ /(?i)font-weight\s*:\s*(bold|[6-9]00)/) inner = effect(inner, "*")
        String color = cssColor(style, e.attr("color"))
        if (color) inner = wrapLines(inner) { String core -> "{color:" + color + "}" + core + "{color}" }
        return inner
    }

    /** Text color from style="color:..." or <font color>. Background colors and black are ignored. */
    private static String cssColor(String style, String attr) {
        String v = attr
        if (!v && style) {
            def m = style =~ /(?i)(?:^|;)\s*color\s*:\s*([^;]+)/
            if (m.find()) v = m.group(1)
        }
        if (!v) return null
        v = v.replace("!important", "").trim().toLowerCase()
        def rgb = v =~ /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
        if (rgb.find()) {
            v = String.format("#%02x%02x%02x", Math.min(255, Integer.parseInt(rgb.group(1))),
                    Math.min(255, Integer.parseInt(rgb.group(2))), Math.min(255, Integer.parseInt(rgb.group(3))))
        }
        if (!(v ==~ /#[0-9a-f]{3}|#[0-9a-f]{6}|[a-z]+/)) return null
        if (v in ["#000", "#000000", "black", "inherit", "initial", "unset", "currentcolor", "windowtext", "transparent"]) return null
        return v
    }

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

    private String list(Element e, boolean cell) {
        if (cell) {
            // no wiki lists inside table cells: use bullet lines instead
            return "\n" + e.children().findAll { it.tagName() == "li" }.collect { "• " + oneLine(renderChildren(it, cell)) }.join("\n") + "\n"
        }
        return "\n\n" + listLines(e, cell, e.tagName() == "ol" ? "#" : "*") + "\n\n"
    }

    private String listLines(Element e, boolean cell, String prefix) {
        List<String> lines = []
        for (Element c : e.children()) {
            String t = c.tagName()
            if (t == "li") lines << item(c, cell, prefix)
            else if (t == "ul" || t == "ol") lines << listLines(c, cell, prefix + (t == "ol" ? "#" : "*"))
        }
        return lines.findAll { it }.join("\n")
    }

    private String item(Element li, boolean cell, String prefix) {
        StringBuilder text = new StringBuilder()
        List<String> nested = []
        for (Node c : li.childNodes()) {
            String t = c instanceof Element ? ((Element) c).tagName() : ""
            if (t == "ul" || t == "ol") nested << listLines((Element) c, cell, prefix + (t == "ol" ? "#" : "*"))
            else text << render(c, cell)
        }
        String txt = lineBreaks(text.toString())
        List<String> parts = []
        if (txt || !nested) parts << LINE + prefix + " " + txt
        parts.addAll(nested.findAll { it })
        return parts.join("\n")
    }

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

    private String table(Element e, boolean cell) {
        List<Element> rows = []
        collectRows(e, rows)
        if (cell) {
            // nested table: flatten to text lines
            return "\n" + rows.collect { Element tr ->
                cells(tr).collect { lineBreaks(renderChildren(it, true)) }.findAll { it }.join(" / ")
            }.join("\n") + "\n"
        }
        List<String> lines = []
        rows.each { Element tr ->
            StringBuilder sb = new StringBuilder(LINE)
            String sep = "|"
            cells(tr).each { Element c ->
                sep = c.tagName() == "th" ? "||" : "|"
                String content = lineBreaks(renderChildren(c, true))
                sb << sep << (content ?: " ")
            }
            if (sb.length() > LINE.length()) lines << sb.append(sep).toString()
        }
        return lines ? "\n\n" + lines.join("\n") + "\n\n" : ""
    }

    private static List<Element> cells(Element tr) {
        return tr.children().findAll { it.tagName() == "td" || it.tagName() == "th" }
    }

    private static void collectRows(Element e, List<Element> rows) {
        for (Element c : e.children()) {
            if (c.tagName() == "tr") rows << c
            else if (c.tagName() in ["thead", "tbody", "tfoot"]) collectRows(c, rows)
        }
    }

    // ---------------------------------------------------------------- text helpers

    /** Raw text of a node. keepLines: <br>, <div> and <p> become line breaks (for <pre>). */
    private static String textOf(Node n, boolean keepLines) {
        if (n instanceof TextNode) return clean(((TextNode) n).getWholeText())
        if (!(n instanceof Element)) return ""
        Element e = (Element) n
        if (e.tagName() == "br") return keepLines ? "\n" : " "
        StringBuilder sb = new StringBuilder()
        for (Node c : e.childNodes()) sb << textOf(c, keepLines)
        return (keepLines && (e.tagName() == "div" || e.tagName() == "p")) ? sb.toString() + "\n" : sb.toString()
    }

    /** Escapes text that Jira would read as markup. Bare URLs stay untouched so Jira still links them. */
    private static String escText(String s) {
        StringBuilder sb = new StringBuilder()
        def m = s =~ /https?:\/\/[^\s<>"]+/
        int last = 0
        while (m.find()) {
            sb << escPlain(s.substring(last, m.start())) << m.group()
            last = m.end()
        }
        sb << escPlain(s.substring(last))
        return sb.toString()
    }

    private static String escPlain(String s) {
        StringBuilder sb = new StringBuilder()
        for (int i = 0; i < s.length(); i++) {
            String c = s.substring(i, i + 1)
            String prev = i > 0 ? s.substring(i - 1, i) : " "
            String next = i + 1 < s.length() ? s.substring(i + 1, i + 2) : " "
            boolean esc = false
            if ("{[|".contains(c)) esc = true
            else if ("*_+^~-".contains(c)) {
                // only where Jira could see the start or end of an effect (keeps 2026-10-22, my_var_name, 5*3*2 as is)
                boolean opening = !isWord(prev) && !isSpace(next)
                boolean closing = !isSpace(prev) && !isWord(next)
                esc = opening || closing
            }
            else if (c == "!") esc = !isSpace(next)
            else if (c == "?") esc = (prev == "?" || next == "?")
            if (esc) sb << "\\"
            sb << c
        }
        return sb.toString()
    }

    private static boolean isWord(String c) { return Character.isLetterOrDigit(c.charAt(0)) }

    private static boolean isSpace(String c) { return Character.isWhitespace(c.charAt(0)) }

    /** Merges <div> boundaries with real line breaks: div after div = one line break, empty div = empty line. */
    private static String normalizeBreaks(String s) {
        if (!s.contains(BLOCK)) return s
        return s.replaceAll("[\n" + BLOCK + "]+") { String run ->
            int n = run.count("\n")
            if (!run.contains(BLOCK)) return "\n" * n
            int lines = n == 0 ? 1 : (run.startsWith(BLOCK) && run.endsWith(BLOCK) ? n + 1 : n)
            return "\n" * lines
        }
    }

    /** For headings: everything on one line. */
    private static String oneLine(String s) {
        return normalizeBreaks(s).replace(LINE, "").replaceAll(/\s*\n\s*/, " ").trim()
    }

    /** For list items and table cells: line breaks become Jira's \\ */
    private static String lineBreaks(String s) {
        return normalizeBreaks(s).replace(LINE, "").trim().replaceAll(/\s*\n\s*/) { " \\\\ " }
    }

    /** Trims lines, removes LINE markers, escapes text lines that would start a list or rule. */
    private static String escapeLines(String s) {
        return s.split("\n", -1).collect { String raw ->
            String line = raw.replaceAll(/[ \t]+/, " ").trim()
            if (line.startsWith(LINE)) return line.substring(LINE.length()).replace(LINE, "")
            line = line.replace(LINE, "")
            if (line.startsWith("#") || line.startsWith("*") || line ==~ /-+(\s.*)?/) line = "\\" + line
            return line
        }.join("\n")
    }

    /** Plain *x* when the marker sits next to spaces/punctuation, braced {*}x{*} when it touches a letter or digit. */
    private String applyEffects(String s) {
        for (int id = effects.size() - 1; id >= 0; id--) {
            String open = OPEN + id + OPEN
            String close = CLOSE + id + CLOSE
            int o = s.indexOf(open)
            int c = s.indexOf(close)
            if (o < 0 || c < 0) continue
            int after = c + close.length()
            String before = o > 0 ? s.substring(o - 1, o) : " "
            String next = after < s.length() ? s.substring(after, after + 1) : " "
            String mk = effects[id]
            if (isWord(before) || isWord(next)) mk = "{" + mk + "}"
            s = s.substring(0, o) + mk + s.substring(o + open.length(), c) + mk + s.substring(after)
        }
        return s
    }
}

2. Jira DC incoming script: description and comments

// Optional: resolve ADO mentions to Jira users by display name. Remove the closure to get plain "@Name".
def converter = new HtmlToWikiJsoup({ String adoId, String name ->
    def user = nodeHelper.getUserByFullName(name)
    user ? "[~${user.username}]" : null   // null = plain "@Name"
})

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 and thorough breakdown of converting HTML from Azure DevOps (or any HTML source) into Jira Data Center’s wiki markup using Exalate’s scripting capabilities. This approach is spot-on for handling the messy realities of HTML input and Jira’s markup quirks.

To summarize and clarify the key steps for anyone following along:

  1. Place the Converter Class: Paste the full HtmlToWikiJsoup class (with imports) at the very top of your Jira Data Center incoming sync script. This class does all the heavy lifting—parsing, mapping, and escaping.

  2. Use the Converter in Your Script:

    • For descriptions:
      def converter = new HtmlToWikiJsoup()
      issue.description = converter.convert(replica.description)
      
    • For comments:
      issue.comments = commentHelper.mergeComments(issue, replica, { comment ->
          comment.body = converter.convert(comment.body)
          comment
      })
      
    • To resolve ADO mentions to Jira users by display name:
      def converter = new HtmlToWikiJsoup({ String adoId, String name ->
          def user = nodeHelper.getUserByFullName(name)
          user ? "[~${user.username}]" : null
      })
      
  3. Ensure jsoup is Available: jsoup must be present on your Jira DC node’s classpath. You can check this by adding:

    debug.error(org.jsoup.Jsoup.class.name)
    

    If you see org.jsoup.Jsoup in the error output, you’re good.

  4. Script Placement: The converter class and imports go at the very top. The usage code (step 2) goes below, in the main body of your incoming script.

  5. Debugging: To preview the conversion result without saving, use:

    debug.error(converter.convert(replica.description))
    
  6. Limitations & Notes:

    • Some HTML features (like merged table cells, background colors, or inline images as data URIs) are simplified or dropped.
    • Attachment images require the actual file to be synced.
    • Mentions rely on display name matching between ADO and Jira.

For more details and the full script, check the official documentation:
https://docs.exalate.com/docs/html-to-jira-wiki-markup-converter

This solution keeps your Jira issues readable and formatted, no matter how wild the HTML from ADO gets. If you run into edge cases or want to tweak the mapping, you can extend the HtmlToWikiJsoup class as needed.

Great job documenting the process and sharing the scripts!