The ask here is to sync Description from Azure DevOps containing Inline Images and Rich Text to a Custom Field in Jira which ofcourse supports Rich Text.
To sync the Description field from Azure DevOps (which includes rich text and inline images) to a custom rich text field in Jira, you’ll need to map the Azure DevOps Description to your Jira custom field in the outgoing and incoming sync scripts.
Here’s how you can approach this:
- In your Azure DevOps outgoing sync script, assign the Description field to an entity property, for example:
replica.customFields."customDescription" = workItem.description
- In your Jira incoming sync script, map this property to your custom field. If your Jira custom field is a rich text field (like a Wiki-style field), use its custom field ID or name:
issue.customFields."Your Custom Field Name or ID" = replica.customFields."customDescription"
This setup will transfer the rich text content, including inline images, as long as both fields support rich text formatting. Note that inline images in Azure DevOps are typically stored as attachments and referenced in the description. Exalate will handle the transfer of attachments if you have attachment sync enabled, but you may need to ensure that image references are correctly mapped and accessible in Jira.
For more details on syncing rich text fields and handling attachments, check out:
If your Jira custom field is not rendering the rich text as expected, make sure it is configured to support wiki-style or HTML content.
Let me know if you need a sample script or further details on handling inline images!
To achieve this we need to send HTML Description out from Azure
replica.description = workItem.description
On Jira Cloud incoming
issue.customFields."CF Name".value = nodeHelper.toMarkDownFromHtml(replica.description)
Please note that I have taken Paragraph field (which supports Rich Text) as an example. This may or may not work for other field types.
Thanks, Dhiren
This does not work on newly created fields/instances as Atlassian has largely removed wiki markup support from Jira Cloud. The Paragraph custom field type uses the ADF (rich text) editor and there is no setting to revert it to wiki markup mode.
Checking in on this issue. Are you still facing the issue, or has it resolved on your end?
Please let us know either way and I’ll take it from there.
Thanks,
Sonal
Hi @Sonal_Otwani . I am still facing this issue. At the moment, we are having to make do with a combination of manual mapping and accepting incorrect formatting.
Did you try this script, or your script contains different logic?
ADO Outgoing sync script:
replica.description = workItem.description
JC incoming sync script:
issue.customFields."field name".value = nodeHelper.toMarkDownFromHtml(replica.description)
Thanks,
Sonal
Hi @Sonal_Otwani yes I tried this. However, this does not work on newly created fields/instances as Atlassian has largely removed wiki markup support from Jira Cloud. The Paragraph custom field type uses the ADF (rich text) editor and there is no setting to revert it to wiki markup mode.
Here are the updated Exalate sync scripts for syncing the Azure DevOps work item description (HTML) into Jira Cloud’s rich text Paragraph custom field (ADF) while handling formatting, clean line breaks, and inline image resolution.
1. Azure DevOps — Outgoing Sync
replica.description = workItem.description
replica.attachments = workItem.attachments
2. Jira Cloud — Incoming Sync
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.Elements
import org.jsoup.safety.Safelist
import java.net.URLDecoder
class HtmlToADF {
static Map<String, List<String>> tagMap = [
"h1" : ["h1. ", "\n"],
"h2" : ["h2. ", "\n"],
"h3" : ["h3. ", "\n"],
"h4" : ["h4. ", "\n"],
"h5" : ["h5. ", "\n"],
"h6" : ["h6. ", "\n"],
"i" : ["_", "_"],
"u" : ["", ""],
"strike": ["", ""],
"em" : ["_", "_"],
"b" : ["*", "*"],
"strong": ["*", "*"],
"p" : ["\n", "\n"],
"br" : ["\n", ""],
"hr" : ["\n", "-----\n"],
"table" : ["", ""],
"tbody" : ["", ""],
"div" : ["\n", "\n"],
"tr" : ["", ""],
"td" : ["", ""],
"th" : ["", ""],
"pre" : ["{noformat}", "{noformat}"],
"code" : ["{code}", "{code}"]
]
private static final String DUMMY = "http://dummy/"
private int listLevel = 0
private boolean ignoreCodeTag = false
private Map<String, String> imageNames = [:]
private List replicaAttachments = []
private Safelist safeList
private Integer imgWidth
HtmlToADF() {
makeSafeList()
}
HtmlToADF(List imageAttachments) {
makeSafeList()
this.replicaAttachments = imageAttachments ?: []
imageAttachments?.each { attachment ->
if (attachment.remoteIdStr) {
imageNames.put(attachment.remoteIdStr as String, attachment.filename as String)
}
if (attachment.id) {
imageNames.put(attachment.id as String, attachment.filename as String)
}
}
}
void setImgWidth(Integer imageWidth) {
this.imgWidth = imageWidth
}
String formatString(String htmlText) {
if (!htmlText) {
return ""
}
Document doc = Jsoup.parse(cleanup(htmlText))
String formatted = process(doc.body().children())
// 1. Strip asterisks or underscores directly wrapping image tokens (*!image.png!* -> !image.png!)
formatted = formatted.replaceAll(/[*_]+(![^!\n]+!)[*_]+/, '$1')
// 2. Strip orphan delimiter lines containing only formatting artifacts
formatted = formatted.replaceAll(/(?m)^\s*[*_]\s*$/, "")
// 3. Ensure image tokens sit on separate lines for Jira Cloud ADF conversion
formatted = formatted.replaceAll(/(?<!\n)(![^!\n]+!)/, '\n$1')
formatted = formatted.replaceAll(/(![^!\n]+!)(?!\n)/, '$1\n')
// 4. Collapse 3+ newlines to standard double newlines
return formatted.replaceAll(/\n{3,}/, "\n\n").trim()
}
private void makeSafeList() {
safeList = Safelist.basicWithImages()
String[] supportedTags =
tagMap.keySet().toArray(new String[tagMap.size()])
safeList
.addTags(supportedTags)
.preserveRelativeLinks(true)
.addAttributes("span", "style")
}
private String cleanup(String htmlText) {
Document.OutputSettings outputSettings =
new Document.OutputSettings()
outputSettings.prettyPrint(false)
return Jsoup.clean(
htmlText,
DUMMY,
safeList,
outputSettings
)
}
private String process(Elements elements) {
String result = ""
elements.each { element ->
result += process((Node) element)
}
return result
}
private String process(Node node) {
if (node instanceof TextNode) {
return ((TextNode) node).getWholeText()
}
if (!(node instanceof Element)) {
return ""
}
Element element = (Element) node
String tagName = element.tagName()
switch (tagName) {
case "img":
return processImage(element)
case "a":
return processHref(element)
case "ol":
return processList(element, "#")
case "ul":
return processList(element, "*")
case "span":
return processSpan(element)
case "pre":
return processPre(element)
case "table":
return processTable(element)
case "code":
if (ignoreCodeTag) {
return processChildren(element)
}
break
}
String childrenText = processChildren(element)
// Prevent empty or whitespace tags from rendering orphan tokens
if (["b", "strong", "i", "em", "u", "strike"].contains(tagName)) {
if (!childrenText || childrenText.trim().isEmpty()) {
return childrenText
}
if (childrenText.contains("!")) {
return childrenText
}
}
return startTag(tagName) +
childrenText +
stopTag(tagName)
}
private String processChildren(Element element) {
String result = ""
element.childNodes().each { child ->
result += process(child)
}
return result
}
private String processList(
Element element,
String listItemMarkup
) {
listLevel++
String result = ""
List<Node> children = element.childNodes()
children.eachWithIndex { node, index ->
if (node instanceof Element &&
((Element) node).tagName() == "li") {
result += (listItemMarkup * listLevel) +
" " +
processChildren((Element) node)
if (index < children.size() - 1) {
result += "\n"
}
} else if (node instanceof TextNode) {
result += ((TextNode) node).wholeText
.replace("\n", "")
.replace("\r", "")
}
}
listLevel--
return result
}
private String processHref(Element element) {
String href = element.attr("href")
String label = processChildren(element)
return label ? "[${label}|${href}]" : "[${href}]"
}
private String processImage(Element element) {
if (element.attr("title") == "database image") {
return ""
}
String sourceName = element.attr("src")
if (!sourceName) {
return ""
}
String resolvedName = null
// 1. ServiceNow sys_attachment mapping
if (sourceName.contains("sys_attachment.do") && !imageNames.isEmpty()) {
def matcher = sourceName =~ /sys_attachment\.do\?sys_id=([^&]+)/
if (matcher.find()) {
resolvedName = imageNames[matcher.group(1)]
}
}
// 2. Azure DevOps attachment GUID / ID matching against replica.attachments
if (!resolvedName && sourceName.contains("/_apis/wit/attachments/")) {
def idMatcher = sourceName =~ /\/_apis\/wit\/attachments\/([a-zA-Z0-9\-]+)/
if (idMatcher.find()) {
String attId = idMatcher.group(1).toLowerCase()
def matchedAtt = replicaAttachments.find { att ->
String rId = (att.remoteIdStr ?: att.id)?.toString()?.toLowerCase()
return rId && (rId.contains(attId) || attId.contains(rId))
}
if (matchedAtt) {
resolvedName = matchedAtt.filename
}
}
}
// 3. Fallback: URL fileName parameter check
if (!resolvedName && sourceName.contains("fileName=")) {
def nameMatcher = sourceName =~ /fileName=([^&]+)/
if (nameMatcher.find()) {
String rawName = URLDecoder.decode(nameMatcher.group(1), "UTF-8")
def matchedAtt = replicaAttachments.find { it.filename == rawName }
if (matchedAtt) {
resolvedName = matchedAtt.filename
}
}
}
// 4. Fallback: If only 1 attachment exists, map directly
if (!resolvedName && replicaAttachments && replicaAttachments.size() == 1) {
resolvedName = replicaAttachments[0].filename
}
// 5. Fallback: Pick first matching image attachment
if (!resolvedName && replicaAttachments) {
def imageAtt = replicaAttachments.find {
it.filename?.toLowerCase()?.matches(/.*\.(png|jpe?g|gif|webp|bmp)$/)
}
if (imageAtt) {
resolvedName = imageAtt.filename
}
}
if (!resolvedName) {
return ""
}
return imgWidth
? "\n!${resolvedName}|width=${imgWidth}!\n"
: "\n!${resolvedName}!\n"
}
private String processSpan(Element element) {
String result = processChildren(element)
String style = element.attr("style")
result = processColorHash(style, result)
result = processColorRGB(style, result)
return result
}
private String processPre(Element element) {
ignoreCodeTag = true
String result =
startTag("pre") +
processChildren(element) +
stopTag("pre")
ignoreCodeTag = false
return result
}
private String processTable(Element table) {
List<Element> rows = []
collectElements(table, "tr", rows)
return rows.collect { Element row ->
List<Element> cells = []
row.children().each { child ->
if (child.tagName() == "th" ||
child.tagName() == "td") {
cells.add(child)
}
}
String rowText = cells.collect { Element cell ->
String prefix =
cell.tagName() == "th" ? "||" : "|"
String cellText = processChildren(cell)
if (cellText.contains("\n")) {
cellText = "{panel}${cellText}{panel}"
}
return prefix + cellText
}.join("")
return rowText + "|"
}.join("\n")
}
private void collectElements(
Element parent,
String tagName,
List<Element> result
) {
parent.children().each { child ->
if (child.tagName() == tagName) {
result.add(child)
} else {
collectElements(child, tagName, result)
}
}
}
private String processColorHash(
String style,
String result
) {
def matcher =
style =~ /(?i)color\s*:\s*#([0-9a-f]{3,8})/
if (!matcher.find()) {
return result
}
return "{color:#${matcher.group(1)}}${result}{color}"
}
private String processColorRGB(
String style,
String result
) {
def matcher = style =~
/(?i)color\s*:\s*rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/
if (!matcher.find()) {
return result
}
try {
int red = matcher.group(1).toInteger()
int green = matcher.group(2).toInteger()
int blue = matcher.group(3).toInteger()
String hexColor = String.format(
"#%02x%02x%02x",
red,
green,
blue
)
return "{color:${hexColor}}${result}{color}"
} catch (Exception ignored) {
return result
}
}
private String startTag(String tagName) {
return tagMap[tagName]
? tagMap[tagName][0]
: ""
}
private String stopTag(String tagName) {
return tagMap[tagName]
? tagMap[tagName][1]
: ""
}
}
// --- Sync Logic ---
if (firstSync) {
issue.projectKey = "DP"
issue.typeName = "Task"
}
issue.summary = replica.summary
issue.comments = commentHelper.mergeComments(issue, replica)
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
issue.labels = replica.labels
// Convert HTML to Wiki/ADF and resolve image references via replica.attachments
HtmlToADF htw = new HtmlToADF(replica.attachments)
def demoScriptCF = replica.description
issue.customFields."Paragraph_Custom".value = htw.formatString(demoScriptCF)
Key Updates Made:
-
Prevented Orphan Delimiters: Empty or whitespace-only tags (such as
<b><br></b>or<strong>\n</strong>) generated by Azure DevOps no longer output stray asterisks (*) or underscores (_). -
Resolved “Preview Unavailable”: Passed
replica.attachmentsinto the converter constructor and added regex matching on Azure’s attachment IDs and parameters so the inline image markup accurately references the exact filename generated in Jira (e.g.!image-YYYYMMDD-HHMMSS.png!). -
Normalized Spacing: Cleaned up excessive newlines and ensured image tokens sit cleanly on their own lines so Jira Cloud’s internal Wiki-to-ADF converter can render them as native image blocks.
Let me know if you have any questions or need any tweaks on this!
Thanks, Dhiren

