Salesforce ↔ Azure DevOps: Comments Synchronization
Description
Platforms: Salesforce to Azure DevOps, and vice-versa
Summary: This guide explains how to synchronise comments between Salesforce and Azure DevOps using Exalate. By default, Exalate syncs basic comments, but with custom scripts, you can sync
richer content including Feed Items, formatting, and author information.
When and why this use case is relevant:
This use case is useful when:
-
Your support team uses Salesforce Cases while your development team uses Azure DevOps Work Items
-
You need to keep comments synchronised between both systems
-
You want to preserve author information and timestamps across systems
-
You need to sync file attachments from Salesforce ContentPost to Azure DevOps
-
You want to preserve text formatting (Bold, Italic, Hyperlinks, Lists) when syncing from Azure DevOps to Salesforce
Background: Understanding Salesforce Comments
Salesforce has multiple comment types:
| Type | Description |
|---|---|
| Case Comments | Traditional comments attached to a Case |
| Feed Items | Chatter posts (TextPost, LinkPost, ContentPost) |
| Feed Comments | Replies to Feed Items |
By default, Exalate syncs only TextPost and LinkPost. This script extends support to include ContentPost, Case Comments, Feed Comments, and preserves author information.
Script Overview
Salesforce → Azure DevOps
1. Parse Salesforce DateTime
Salesforce returns dates in various formats. This helper function handles all formats:
def parseSalesforceDateTime(dateInput) {
if (!dateInput) return null
if (dateInput instanceof Date) return dateInput
String[] possibleFormats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS"
]
for (fmt in possibleFormats) {
try {
return new SimpleDateFormat(fmt).parse(dateInput)
} catch(Exception ignored) {}
}
return null
}
2. Fetch Feed Items (TextPost, LinkPost, ContentPost)
The script queries all Feed Items attached to the Case:
def feedItemQuery = """
SELECT Id, Body, LastModifiedDate, CreatedById, CreatedDate, Type, CreatedBy.Name
FROM FeedItem
WHERE ParentId = '${caseId}'
AND CreatedById != '${excludeUserId}'
AND Type IN ('ContentPost', 'TextPost', 'LinkPost')
"""
3. Fetch Feed Comments
The script fetches replies to Feed Items:
def feedCommentQuery = """
SELECT Id, CommentBody, CreatedById, CreatedBy.Name, LastEditDate, CreatedDate, IsDeleted, FeedItemId
FROM FeedComment
WHERE FeedItemId IN (SELECT Id FROM FeedItem WHERE ParentId='${caseId}')
AND CreatedById != '${excludeUserId}'
"""
4. Fetch Case Comments
The script fetches traditional Case Comments:
def caseCommentQuery = """
SELECT CommentBody, CreatedById, CreatedBy.Name, LastModifiedDate, CreatedDate, Id, IsDeleted, IsPublished
FROM CaseComment
WHERE ParentId='${caseId}'
AND CreatedById != '${excludeUserId}'
"""
5. Map Author Information
For each comment, the script extracts and maps author details:
def authorUser = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
authorUser.setKey(feed.CreatedById) // Salesforce User ID
authorUser.setDisplayName(feed.CreatedBy.Name) // Full Name
authorUser.setUsername(feed.CreatedBy.Name) // Username
comment.author = authorUser
6. Sync Attachments
ContentPost file attachments are synced via:
replica.attachments = entity.attachments
Azure DevOps → Salesforce
1. ADO Comment Limitations
Azure DevOps only provides:
-
Comment Body (HTML format)
-
Author Display Name
Azure DevOps does NOT provide:
- Original comment created date
2. HTML to Message Segments Conversion
Salesforce Feed Items use “Message Segments” instead of HTML. The script converts HTML tags:
// Bold
segments << ["type": "MarkupBegin", "markupType": "Bold"]
// Italic
segments << ["type": "MarkupBegin", "markupType": "Italic"]
// Hyperlink
segments << ["type": "MarkupBegin", "markupType": "Hyperlink", "url": element.attr("href")]
Final Solution
Salesforce
Outgoing Sync
// ============================================================
// Salesforce - Outgoing Sync
// Syncs: Feed Items, Feed Comments, Case Comments, Attachments
// ============================================================
def parseSalesforceDateTime(dateInput) {
if (!dateInput) return null
if (dateInput instanceof Date) return dateInput
String[] possibleFormats = [
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS"
]
for (fmt in possibleFormats) {
try {
return new SimpleDateFormat(fmt).parse(dateInput)
} catch(Exception ignored) {}
}
return null
}
def caseId = entity.Id
// IMPORTANT: Replace with your Exalate integration user ID to prevent sync loops
def excludeUserId = '005O900000lgGy9IAE'
// ------------------------------------------
// 1. Fetch Feed Items (TextPost, LinkPost, ContentPost)
// ------------------------------------------
def feedItemQuery = """
SELECT Id, Body, LastModifiedDate, CreatedById, CreatedDate, Type, CreatedBy.Name
FROM FeedItem
WHERE ParentId = '${caseId}'
AND CreatedById != '${excludeUserId}'
AND Type IN ('ContentPost', 'TextPost', 'LinkPost')
"""
def feedItemResponse = httpClient.get("/services/data/v54.0/query?q=${java.net.URLEncoder.encode(feedItemQuery, 'UTF-8')}")
def feedItems = (feedItemResponse.records ?: []).collect { feed ->
def comment = commentHelper.addComment(feed.Body ?: "[File uploaded]", null).find()
comment.id = feed.Id
comment.created = parseSalesforceDateTime(feed.LastModifiedDate)
def authorUser = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
authorUser.setKey(feed.CreatedById)
authorUser.setDisplayName(feed.CreatedBy.Name)
authorUser.setUsername(feed.CreatedBy.Name)
comment.author = authorUser
comment.internal = false
comment
}
// ------------------------------------------
// 2. Fetch Feed Comments (replies to Feed Items)
// ------------------------------------------
def feedCommentQuery = """
SELECT Id, CommentBody, CreatedById, CreatedBy.Name, LastEditDate, CreatedDate, IsDeleted, FeedItemId
FROM FeedComment
WHERE FeedItemId IN (SELECT Id FROM FeedItem WHERE ParentId='${caseId}')
AND CreatedById != '${excludeUserId}'
"""
def feedCommentResponse = httpClient.get("/services/data/v54.0/query?q=${java.net.URLEncoder.encode(feedCommentQuery, 'UTF-8')}")
def feedComments = (feedCommentResponse.records ?: []).findAll { !it.IsDeleted }.collect { fc ->
def comment = commentHelper.addComment(fc.CommentBody, null).find()
comment.id = fc.Id
comment.created = parseSalesforceDateTime(fc.LastEditDate ?: fc.CreatedDate)
def authorUser = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
authorUser.setKey(fc.CreatedById)
authorUser.setDisplayName(fc.CreatedBy.Name)
authorUser.setUsername(fc.CreatedBy.Name)
comment.author = authorUser
comment.internal = false
comment
}
// ------------------------------------------
// 3. Fetch Case Comments
// ------------------------------------------
def caseCommentQuery = """
SELECT CommentBody, CreatedById, CreatedBy.Name, LastModifiedDate, CreatedDate, Id, IsDeleted, IsPublished
FROM CaseComment
WHERE ParentId='${caseId}'
AND CreatedById != '${excludeUserId}'
"""
def caseCommentResponse = httpClient.get("/services/data/v54.0/query?q=${java.net.URLEncoder.encode(caseCommentQuery, 'UTF-8')}")
def caseComments = (caseCommentResponse.records ?: []).findAll { !it.IsDeleted }.collect { fc ->
def comment = commentHelper.addComment(fc.CommentBody, null).find()
comment.id = fc.Id
comment.created = parseSalesforceDateTime(fc.LastModifiedDate)
def authorUser = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
authorUser.setKey(fc.CreatedById)
authorUser.setDisplayName(fc.CreatedBy.Name)
authorUser.setUsername(fc.CreatedBy.Name)
comment.author = authorUser
comment.internal = !fc.IsPublished
comment
}
// ------------------------------------------
// 4. Combine and send all comments
// ------------------------------------------
replica.comments = feedItems + feedComments + caseComments
// ------------------------------------------
// 5. Sync Attachments
// ------------------------------------------
replica.attachments = entity.attachments
Incoming Sync (Plain Text - Default)
// ============================================================
// Salesforce - Incoming Sync (Plain Text)
// Comments are written as plain text without formatting
// ============================================================
entity.comments = commentHelper.mergeComments(entity, replica)
Incoming Sync (With Text Formatting)
Use this script if you want to preserve text formatting from Azure DevOps:
// ============================================================
// Salesforce - Incoming Sync (With Text Formatting)
// Converts HTML to Salesforce Message Segments
// Supports: Bold, Italic, Underline, Hyperlinks, Lists
// ============================================================
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode
import groovy.json.JsonOutput
def formatDateToString(dateInput) {
return new SimpleDateFormat("dd/MM/yyyy hh:mm a").format(dateInput)
}
List convertHtmlElementToSegments(Element element) {
List segments = []
String tag = element.tagName()
switch(tag) {
case "p":
segments << ["type": "MarkupBegin", "markupType": "Paragraph"]
element.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "Paragraph"]
break
case "strong":
case "b":
segments << ["type": "MarkupBegin", "markupType": "Bold"]
element.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "Bold"]
break
case "em":
case "i":
segments << ["type": "MarkupBegin", "markupType": "Italic"]
element.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "Italic"]
break
case "u":
segments << ["type": "MarkupBegin", "markupType": "Underline"]
element.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "Underline"]
break
case "a":
segments << ["type": "MarkupBegin", "markupType": "Hyperlink", "url": element.attr("href")]
element.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "Hyperlink"]
break
case "ul":
segments << ["type": "MarkupBegin", "markupType": "UnorderedList"]
element.children().each { li ->
if (li.tagName() == "li") {
segments << ["type": "MarkupBegin", "markupType": "ListItem"]
li.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "ListItem"]
}
}
segments << ["type": "MarkupEnd", "markupType": "UnorderedList"]
break
case "ol":
segments << ["type": "MarkupBegin", "markupType": "OrderedList"]
element.children().each { li ->
if (li.tagName() == "li") {
segments << ["type": "MarkupBegin", "markupType": "ListItem"]
li.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
segments << ["type": "MarkupEnd", "markupType": "ListItem"]
}
}
segments << ["type": "MarkupEnd", "markupType": "OrderedList"]
break
default:
element.childNodes().each {
if (it instanceof Element) segments += convertHtmlElementToSegments(it)
else if (it instanceof TextNode) segments << ["type": "Text", "text": it.text()]
}
}
return segments
}
// ------------------------------------------
// 1. Handle ADDED Comments (Create new Feed Items)
// ------------------------------------------
replica.addedComments.collect {
String sfCommentId = ""
// Remove <ins> tags (ADO uses these for edit tracking)
String tmpBody = it.body.replaceAll("<ins>", "").replaceAll("</ins>", "")
// Append author and timestamp
def dateStr = formatDateToString(it.created)
def commentDisplayName = it?.author?.displayName
if (commentDisplayName) {
tmpBody = tmpBody + " <br> Posted by [" + commentDisplayName + "]"
}
if (dateStr) {
tmpBody = tmpBody + " on [" + dateStr + "]"
}
// Convert HTML to Message Segments
def document = Jsoup.parse(tmpBody)
List segments = convertHtmlElementToSegments(document.body())
// Create Feed Item via Chatter API
Map body = [
"feedElementType": "FeedItem",
"subjectId" : "${entity.key}",
"body" : [
"messageSegments": segments
],
"visibility" : "InternalUsers"
]
def res = httpClient.http(
"POST",
"/services/data/v54.0/chatter/feed-elements",
JsonOutput.toJson(body),
null,
['Content-Type': ['application/json']]
) { req, res ->
if (res.code == 201) {
sfCommentId = res?.body?.id
} else {
throw new Exception("Error while creating comment: " + res.code + " message: " + res.body)
}
}
// Create trace for sync tracking
if (sfCommentId) {
def trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
.setType(com.exalate.api.domain.twintrace.TraceType.COMMENT)
.setToSynchronize(true)
.setLocalId(sfCommentId as String)
.setRemoteId(it.remoteId as String)
.setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
traces.add(trace)
}
}
// ------------------------------------------
// 2. Handle CHANGED Comments (Update existing Feed Items)
// ------------------------------------------
replica.changedComments.collect {
// Remove <ins> tags
String tmpBody = it.body.replaceAll("<ins>", "").replaceAll("</ins>", "")
// Append author and timestamp
def dateStr = formatDateToString(it.created)
def commentDisplayName = it?.author?.displayName
if (commentDisplayName) {
tmpBody = tmpBody + " <br> Posted by [" + commentDisplayName + "]"
}
if (dateStr) {
tmpBody = tmpBody + " on [" + dateStr + "]"
}
// Convert HTML to Message Segments
def document = Jsoup.parse(tmpBody)
List segments = convertHtmlElementToSegments(document.body())
// Get Salesforce comment ID
String sfCommentId = it.idStr
// Update Feed Item via Chatter API
Map body = [
"body": [
"messageSegments": segments
]
]
def res = httpClient.http(
"PATCH",
"/services/data/v54.0/chatter/feed-elements/${sfCommentId}",
JsonOutput.toJson(body),
null,
['Content-Type': ['application/json']]
) { req, res ->
if (res.code == 200) {
log.info("Comment ${sfCommentId} updated successfully")
} else {
log.error("Error while updating comment: " + res.code + " message: " + res.body)
}
}
}
// ------------------------------------------
// 3. Handle REMOVED Comments (Delete Feed Items)
// ------------------------------------------
replica.removedComments.collect {
String sfCommentId = it.idStr
def res = httpClient.http(
"DELETE",
"/services/data/v54.0/chatter/feed-elements/${sfCommentId}",
null,
null,
['Content-Type': ['application/json']]
) { req, res ->
if (res.code == 204) {
log.info("Comment ${sfCommentId} deleted successfully")
} else {
log.error("Error while deleting comment: " + res.code + " message: " + res.body)
}
}
}
Azure DevOps
Outgoing Sync
// ============================================================
// Azure DevOps - Outgoing Sync
// Sends Work Item comments to Salesforce
// ============================================================
replica.comments = workItem.comments
Incoming Sync
// ============================================================
// Azure DevOps - Incoming Sync
// Merges comments and appends author attribution
// Note: ADO does not preserve original comment dates,
// so author and timestamp are appended to the body
// ============================================================
def formatDateToString(dateInput) {
return new SimpleDateFormat("dd/MM/yyyy hh:mm a").format(dateInput)
}
workItem.comments = commentHelper.mergeComments(workItem, replica, { comment ->
def dateStr = formatDateToString(comment.created)
comment.body = comment.body + " <br> Posted by [" + comment.author.displayName + "] on [" + dateStr + "] (GMT)"
})
Notes
Dependencies
| Requirement | Description |
|---|---|
| Exalate Integration User | Replace excludeUserId with your Exalate integration user ID to prevent sync loops |
| Salesforce API Access | API version v54.0 or higher |
| Jsoup Library | Required for HTML parsing (included in Exalate) |
Supported Formatting (ADO → SF)
| HTML Tag | Salesforce Result | Supported |
|---|---|---|
<strong>, <b> |
Bold | |
<em>, <i> |
Italic | |
<u> |
Underline | |
<a href="..."> |
Hyperlink | |
<p> |
Paragraph | |
<ul>, <ol>, <li> |
Lists | |
<img> |
Image | |
<table> |
Table |
Limitations
| Limitation | Description |
|---|---|
| Original Timestamps | Azure DevOps does not provide original comment created dates |
| Images in Comments | Images embedded in HTML are not converted to Salesforce format |
| Tables | HTML tables are not supported in Salesforce Message Segments |
| Nested Formatting | Deeply nested HTML may not convert correctly |
Recommendations
-
Test with a small number of comments first before enabling on production
-
Use the plain text incoming sync if formatting is not required
Version
| Component | Version |
|---|---|
| Exalate | 5.29.0 |
| Salesforce API | v54.0 |