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>, ) 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.
- Parse the HTML with jsoup. jsoup turns the HTML into a tree of nodes. It already handles the hard parts: unclosed tags, entities like
and&, and tags without a closing tag such as<br>and<img>. - 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. - 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. - 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 showsorg.jsoup.Jsoup, you are good to go. - Paste script 1 (imports +
HtmlToWikiJsoupclass) 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.getUserByFullNameto 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("&", "&").replace("<", "<").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
})