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.
- 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.
- 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.
- 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. - 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
WikiToHtmlclass) at the top of the ADO incoming script, and script 2 below it. - No extra libraries are needed, the class is plain Groovy.
- Replace
FOOwith 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) : " "
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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
}
}
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
})