Salesforce ↔ Azure DevOps: Comments Synchronization

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 :white_check_mark:
<em>, <i> Italic :white_check_mark:
<u> Underline :white_check_mark:
<a href="..."> Hyperlink :white_check_mark:
<p> Paragraph :white_check_mark:
<ul>, <ol>, <li> Lists :white_check_mark:
<img> Image :cross_mark:
<table> Table :cross_mark:

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

2 Likes