Sync Salesforce with Jira Cloud


Get Started

Not the integration you're looking for? Browse more.

About the integration


How does the integration logic work?

When a new case is crated in Salesforce, a corresponding issue is created in Jira Cloud, and vice versa. Updates on both sides will be kept in sync as well. Get started to learn more.

Which fields are being synced?

  • Subject (Salesforce) ↔ Summary (Jira Cloud)
  • Description
  • Status
  • Priority
  • Comments (in creation only)
  • Attachments (in creation only)

Can I configure which fields are being synced?

Yes. You can change which fields are being synced and control quite many other things via the configuration.

About ScriptRunner Connect


What is ScriptRunner Connect?

ScriptRunner Connect is an AI assisted code-first (JavaScript/TypeScript) integration platform (iPaaS) for building complex integrations and automations.

Can I try it out for free?

Yes. ScriptRunner Connect comes with a forever free tier.

Can I customize the integration logic?

Absolutely. The main value proposition of ScriptRunner Connect is that you'll get full access to the code that is powering the integration, which means you can make any changes to the the integration logic yourself.

Can I change the integration to communicate with additional apps?

Yes. Since ScriptRunner Connect specializes in enabling complex integrations, you can easily change the integration logic to connect to as many additional apps as you need, no limitations.

What if I don't feel comfortable making changes to the code?

First you can try out our AI assistant which can help you understand what the code does, and also help you make changes to the code. Alternatively you can hire our professionals to make the changes you need or build new integrations from scratch.

Do I have to host it myself?

No. ScriptRunner Connect is a fully managed SaaS (Software-as-a-Service) product.

What about security?

ScriptRunner Connect is ISO 27001 and SOC 2 certified. Learn more about our security.

Template Content


README

Scripts

TypeScriptOnJiraCloudCommentCreated
Comment Created

README


📋 Overview

This template synchronizes Jira Cloud Issues and Salesforce Cases, ensuring the following data is exchanged between Jira Cloud and Salesforce:

  • Summary / Subject
  • Description / Description
  • Status / Status
  • Priority / Priority
  • Comments (creation)
  • Attachments (creation)

🖊️ Setup

  1. Configure API connections and event listeners:

    1. Create connectors for Jira Cloud and Salesforce instances or use existing ones.

    2. Add webhooks in Jira Cloud:

      • Go to the Issue related events field and add the project key you want to listen to (e.g., project = TEST).
  2. Create custom fields

    In Jira Cloud

    Create a new text custom field named ScriptRunner Connect Sync Issue Key in your Jira Cloud project to keep items in sync.

    In Salesforce

    1. Create a new text custom field named ScriptRunner Connect Sync Issue Key for the case object.
    2. Copy the API Name of this field (e.g., ScriptRunner_Connect_Sync_Issue_Key__c).
      • You will need it later to configure parameters.
  3. Configure parameters

    1. Go to Parameters and configure the following:
      • Fill out the STATUS_MAPPING and PRIORITY_MAPPING variables.
      • Ensure the JIRA_SYNC_CUSTOM_FIELD_NAME matches the custom field created in Jira Cloud.
      • Add the API Name of the Salesforce custom field to the SALESFORCE_SYNC_KEY_API_NAME variable.
      • Provide values for PROJECT_KEY and ISSUE_TYPE_NAME for Jira Cloud.
      • Leave SUPPORTED_JIRA_FIELDS unchanged, as it contains fields supported by the template.
  4. Create Salesforce event listeners

    OnSalesforceCaseCreated Event:

    1. Set up an outbound message for the Case object with these fields:

      • CreatedById
      • Description
      • Id
      • Priority
      • Status
      • Subject
    2. Set up a Flow for the event:

      • Select Case as the trigger object.
      • Select A record is updated for the trigger.

    OnSalesforceCaseUpdated Event

    1. Set up an outbound message for the Case object with these fields:

      • LastModifiedById
      • Description
      • Id
      • Priority
      • Status
      • Subject
    2. Set up a Flow for the event:

      • Select Case as the trigger object.
      • Select A record is updated for the trigger.
      • In the entry conditions, select:
        • Any Condition Is Met (or)
        • Fields: Status, Subject, Priority, Description
        • Operator: Is Changed
        • Value: True (select global constant)

    OnSalesforceCommentCreated Event

    1. Set up an outbound message for the Case Comment object with these fields:

      • CommentBody
      • CreatedById
      • Id
      • ParentId
    2. Set up a Flow for the event:

      • Select Case as the trigger object.
      • Select A record is updated for the trigger.

    OnSalesforceFeedItemAdded Event

    1. Set up an outbound message for the Feed Item object with these fields:

      • Body
      • CreatedById
      • Id
      • ParentId
      • RelatedRecordId
      • Type
    2. Set up a Flow for the event:

      • Select FeedItem as the trigger object.
      • Select A record is created for the trigger.

Optional: Sync existing Salesforce cases and Jira issues

  1. Go to the Salesforce case you want to sync in the Salesforce UI.
  2. Copy the ID of the case from the URL (e.g., 500J9000000hwmOIAQ).
  3. Paste the copied ID into the Sync Custom field in Salesforce.
  4. Go to the corresponding Jira issue and paste the same ID into the Sync Custom field.

🚀 Using the template

  • When a new Jira Cloud issue is created, a new Salesforce case will be created, and vice versa.
  • Updates to the Jira Cloud issue will sync to the Salesforce case and vice versa.

ℹ️ Note: To avoid infinite update loops, updates triggered by the user who authorized the connector(s) will be ignored.

❗Considerations

  • This template is a great starting point for syncing items between Jira Cloud and Salesforce. While it covers the basics, it might not include every field or check you need. Feel free to customize it to fit your specific requirements.
  • The template only picks up attachments added to the Feed section of a Salesforce case.

API Connections


TypeScriptOnJiraCloudCommentCreated

import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloud from "./api/jira/cloud";
import Salesforce from "./api/salesforce";
import { getEnvVars } from './Utils';

export default async function (event: IssueCommentCreatedEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }

    // Get the current user
    const myself = await JiraCloud.Myself.getCurrentUser();
    // Check if the user who updated the issue matches the user who set up the integration
    if (myself.accountId === event.comment.author.accountId) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }
    
    const { JIRA_SYNC_CUSTOM_FIELD_NAME } = getEnvVars(context);
    const issueSyncField = (await JiraCloud.Issue.Field.getFields()).find(f => f.name === JIRA_SYNC_CUSTOM_FIELD_NAME);
    const issue = await JiraCloud.Issue.getIssue({
        issueIdOrKey: event.issue.key,
        fields: [issueSyncField?.key ?? '']
    });

    const syncKey = issue.fields?.[issueSyncField?.id ?? ''];
    if (!syncKey) {
        throw Error(`Jira issue ${issue.key} is not synced.`);
    }

    // Create comment for Salesforce case
    await Salesforce.fetch(`/services/data/v57.0/sobjects/Case/${syncKey}`, {
        method: 'PATCH',
        body: JSON.stringify({
            Comments: event.comment.body,
        }),
    });
}
TypeScriptOnJiraCloudIssueCreated

import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import Salesforce from "./api/salesforce";
import JiraCloud from "./api/jira/cloud";
import { getEnvVars, createSalesforceCase, updateSalesforceCase, processAttachment, Mapper } from './Utils';

export default async function (event: IssueCreatedEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }

    // Get the current user
    const myself = await JiraCloud.Myself.getCurrentUser();
    // Check if the user who updated the issue matches the user who set up the integration
    if (myself.accountId === event.user.accountId) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }
    
    const { STATUS_MAPPING, PRIORITY_MAPPING, SALESFORCE_SYNC_KEY_API_NAME, JIRA_SYNC_CUSTOM_FIELD_NAME } = getEnvVars(context);
    // Collect values from the event
    const summary = event.issue.fields.summary;
    const status = event.issue.fields.status?.name;
    const description = event.issue.fields.description;
    const priority = event.issue.fields?.priority?.name;
    const statusMapping = new Mapper(STATUS_MAPPING);
    const priorityMapping = new Mapper(PRIORITY_MAPPING);

    const caseId = await createSalesforceCase(Salesforce, {
        Subject: 'Jira Cloud Issue: ' + summary,
        Status: statusMapping.getSalesforceValue(status ?? ''),
        Description: description ? description.toString() : '',
        Priority: priorityMapping.getSalesforceValue(priority ?? ''),
    });

    // Add sync key value to Saleforce Case
    await updateSalesforceCase(Salesforce, caseId, {
        [SALESFORCE_SYNC_KEY_API_NAME]: caseId,
    });
    if (event.issue.fields.attachment?.length) {
        const attachments = event.issue.fields.attachment ?? [];
        // Add attachments to Salesforce case
        await Promise.all(attachments.map((att) => processAttachment(JiraCloud, Salesforce, att, caseId)));
    }

    // Get sync key
    const syncField = (await JiraCloud.Issue.Field.getFields()).find(f => f.name === JIRA_SYNC_CUSTOM_FIELD_NAME);
    if (!syncField || !syncField.id) {
        throw Error('Sync custom field not found on Jira Cloud.');
    }

    // Add sync key to newly created Jira issue
    await JiraCloud.Issue.editIssue({
        issueIdOrKey: event.issue.key,
        body: {
            fields: {
                [syncField.id]: caseId,
            }
        }
    });

    console.log('Salesforce case created: ', caseId);
}
TypeScriptOnJiraCloudIssueUpdated

import { IssueUpdatedEvent } from '@sr-connect/jira-cloud/events';
import Salesforce from "./api/salesforce";
import JiraCloud from "./api/jira/cloud";
import { getEnvVars, updateSalesforceCase, processAttachment, addSalesforceAttachment, Mapper } from './Utils';

export default async function (event: IssueUpdatedEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }

    // Get the current user
    const myself = await JiraCloud.Myself.getCurrentUser();
    // Check if the user who updated the issue matches the user who set up the integration
    if (myself.accountId === event.user.accountId) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }

    const { STATUS_MAPPING, PRIORITY_MAPPING, JIRA_SYNC_CUSTOM_FIELD_NAME, SUPPORTED_JIRA_FIELDS } = getEnvVars(context);
    // Check if update concerns supported fields
    if (!event.changelog?.items.some(cl => SUPPORTED_JIRA_FIELDS.includes(cl.field))) {
        console.warn('No supported fields in update event.');
        return;
    }

    const issueSyncField = (await JiraCloud.Issue.Field.getFields()).find(f => f.name === JIRA_SYNC_CUSTOM_FIELD_NAME);
    const issue = await JiraCloud.Issue.getIssue({
        issueIdOrKey: event.issue.key,
        fields: [...SUPPORTED_JIRA_FIELDS, issueSyncField?.key ?? '']
    });

    const syncKey = issue.fields?.[issueSyncField?.id ?? ''];
    if (!syncKey) {
        throw Error(`Jira issue ${issue.key} is not synced.`);
    }

    // Process status update
    if (event.changelog?.items.some(cl => cl.field === 'status')) {
        const status = issue.fields?.status?.name ?? '';
        const statusMapping = new Mapper(STATUS_MAPPING);
        await updateSalesforceCase(Salesforce, syncKey, {
            Status: statusMapping.getSalesforceValue(status),
        });

        console.log(`Status updated for Salesforce case ${syncKey}.`);
    }

    // Process summary update
    if (event.changelog?.items.some(cl => cl.field === 'summary')) {
        const summary = issue.fields?.summary;
        await updateSalesforceCase(Salesforce, syncKey, {
            Subject: summary,
        });

        console.log(`Subject updated for Salesforce case ${syncKey}.`);
    }

    // Process description update
    const updatedDesc = event.changelog?.items.find(cl => cl.field === 'description')?.toString;
    if (updatedDesc) {
        await updateSalesforceCase(Salesforce, syncKey, {
            Description: updatedDesc,
        });

        console.log(`Description updated for Salesforce case ${syncKey}.`);
    }

    // Process priority update
    if (event.changelog?.items.some(cl => cl.field === 'priority')) {
        const priority = issue.fields?.priority?.name ?? '';
        const priorityMapping = new Mapper(PRIORITY_MAPPING);
        await updateSalesforceCase(Salesforce, syncKey, {
            Priority: priorityMapping.getSalesforceValue(priority),
        });

        console.log(`Process updated for Salesforce case ${syncKey}.`);
    }
    if (event.changelog?.items.some(cl => cl.field === 'Attachment')) {
        const item = event.changelog?.items.find(cl => cl.field === 'Attachment');
        if (!item?.fromString) {
            const attachmentName = item?.toString ?? '';
            const content = event.issue.fields?.attachment?.find(a => a.filename === attachmentName);

            // Retrieving content of an attachment from Jira
            const response = await JiraCloud.fetch(`/rest/api/2/attachment/content/${content?.id}`);
            const body = await response.arrayBuffer();

            // Processing attachment
            await addSalesforceAttachment(Salesforce, syncKey, {
                body,
                attachmentName,
                mimeType: content?.mimeType ?? ''
            });

            console.log(`Attachment added to Salesforce case: ${attachmentName}`);
        }
    }
}
TypeScriptOnSalesforceCaseCreated

import JiraCloud from "./api/jira/cloud";
import Salesforce from "./api/salesforce";
import { SalesforceGenericEvent } from '@sr-connect/salesforce/events';
import { getCurrentUser, getEnvVars, updateSalesforceCase, Mapper } from './Utils';

export default async function (event: SalesforceGenericEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }
    const { STATUS_MAPPING, PRIORITY_MAPPING, PROJECT_KEY, ISSUE_TYPE_NAME, SALESFORCE_SYNC_KEY_API_NAME, JIRA_SYNC_CUSTOM_FIELD_NAME } = getEnvVars(context);
    // Case data from the event
    const caseId = event.notifications.Notification.sObject['sf:Id'];
    const caseSubject = event.notifications.Notification.sObject['sf:Subject'];
    const caseStatus = event.notifications.Notification.sObject['sf:Status'];
    const casePriority = event.notifications.Notification.sObject['sf:Priority'];
    const caseDescription = event.notifications.Notification.sObject['sf:Description'];
    const userId = event.notifications.Notification.sObject['sf:CreatedById'];

    // Check if the user who triggered the event matches the user who set up the integration
    if (userId === await getCurrentUser(Salesforce)) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }

    if (caseSubject) {
        const priorityMapping = new Mapper(PRIORITY_MAPPING);
        // Get sync key
        const syncField = (await JiraCloud.Issue.Field.getFields()).find(f => f.name === JIRA_SYNC_CUSTOM_FIELD_NAME);
        if (!syncField || !syncField.id) {
            throw Error('Sync custom field not found on Jira Cloud.');
        }

        // Create Jira Cloud issue with data from Salesforce event. 
        const issueCreated = await JiraCloud.Issue.createIssue({
            body: {
                fields: {
                    project: {
                        key: PROJECT_KEY,
                    },
                    issuetype: {
                        name: ISSUE_TYPE_NAME,
                    },
                    summary: `SF Case: ${caseSubject}`,
                    priority: {
                        name: casePriority && priorityMapping.getJiraCloudValue(casePriority),
                    },
                    [syncField.id]: caseId,
                    description: caseDescription && {
                        version: 1,
                        type: "doc",
                        content: [
                            {
                                type: "paragraph",
                                content: [
                                    {
                                        type: "text",
                                        text: caseDescription
                                    }
                                ]
                            },
                        ]
                    }
                }
            }
        });

        if (caseStatus) {
            const statusMapping = new Mapper(STATUS_MAPPING);
            // Find Jira status if Trello list value is passed
            const jiraStatus = statusMapping.getJiraCloudValue(caseStatus);
            if (!jiraStatus) {
                throw Error(`Failed to get correct Jira status for Salesforce status: ${caseStatus}.`);
            }

            // Find Jira transitions
            const transition = await JiraCloud.Issue.Transition.getTransitions({
                issueIdOrKey: issueCreated.key ?? ''
            });

            // Find Jira transition ID
            const id = transition?.transitions?.find(t => t.name?.localeCompare(jiraStatus, 'en', { sensitivity: 'base' }) === 0)?.id;
            if (!id) {
                throw Error('Failed to retrieve transition id for Jira Status.');
            }

            // Perform Jira issue transition according to status/list mapping
            await JiraCloud.Issue.Transition.performTransition({
                issueIdOrKey: issueCreated.key ?? '',
                body: {
                    transition: {
                        id
                    }
                }
            });
        }

        // Add sync key value to Saleforce Case
        await updateSalesforceCase(Salesforce, caseId, {
            [SALESFORCE_SYNC_KEY_API_NAME]: caseId,
        });

        // Print out issue key of the newly created issue.
        console.log(`Issue created: ${issueCreated.key}.`);
    } else {
        console.error('Case data is not presented in the event object.');
    }
}
TypeScriptOnSalesforceCaseUpdated

import { SalesforceGenericEvent } from '@sr-connect/salesforce/events';
import JiraCloud from "./api/jira/cloud";
import Salesforce from "./api/salesforce";
import { getCurrentUser, getEnvVars, getSyncedJiraIssueKey, Mapper } from './Utils';

export default async function (event: SalesforceGenericEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }
    const { STATUS_MAPPING, PRIORITY_MAPPING, PROJECT_KEY, ISSUE_TYPE_NAME, JIRA_SYNC_CUSTOM_FIELD_NAME } = getEnvVars(context);
    // Case data from the event
    const caseId = event.notifications.Notification.sObject['sf:Id'];
    const caseSubject = event.notifications.Notification.sObject['sf:Subject'];
    const caseStatus = event.notifications.Notification.sObject['sf:Status'];
    const casePriority = event.notifications.Notification.sObject['sf:Priority'];
    const caseDescription = event.notifications.Notification.sObject['sf:Description'];
    const userId = event.notifications.Notification.sObject['sf:LastModifiedById'];

    // Check if the user who triggered the event matches the user who set up the integration
    if (userId === await getCurrentUser(Salesforce)) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }

    const jiraIssueKey = await getSyncedJiraIssueKey(JiraCloud, caseId, JIRA_SYNC_CUSTOM_FIELD_NAME);

    if (!jiraIssueKey) {
        throw Error(`Issue key not found for sync key ${caseId}`);
    }
    const priorityMapping = new Mapper(PRIORITY_MAPPING);

    // Update Jira Cloud issue with data from Salesforce event. 
    await JiraCloud.Issue.editIssue({
        issueIdOrKey: jiraIssueKey,
        body: {
            fields: {
                project: {
                    key: PROJECT_KEY,
                },
                issuetype: {
                    name: ISSUE_TYPE_NAME,
                },
                summary: `SF Case: ${caseSubject}`,
                priority: {
                    name: casePriority && priorityMapping.getJiraCloudValue(casePriority),
                },
                description: caseDescription && {
                    version: 1,
                    type: "doc",
                    content: [
                        {
                            type: "paragraph",
                            content: [
                                {
                                    type: "text",
                                    text: caseDescription
                                }
                            ]
                        },
                    ]
                }
            }
        }
    });

    if (caseStatus) {
        const statusMapping = new Mapper(STATUS_MAPPING);
        // Find Jira status if Salesforce list value is passed
        const jiraStatus = statusMapping.getJiraCloudValue(caseStatus);
        if (!jiraStatus) {
            throw Error(`Failed to get correct Jira status for Salesforce status: ${caseStatus}.`);
        }

        // Find Jira transitions
        const transition = await JiraCloud.Issue.Transition.getTransitions({
            issueIdOrKey: jiraIssueKey,
        });

        // Find Jira transition ID
        const id = transition?.transitions?.find(t => t.name?.localeCompare(jiraStatus, 'en', { sensitivity: 'base' }) === 0)?.id;
        if (!id) {
            throw Error('Failed to retrieve transition id for Jira Status.');
        }

        // Perform Jira issue transition according to status/list mapping
        await JiraCloud.Issue.Transition.performTransition({
            issueIdOrKey: jiraIssueKey,
            body: {
                transition: {
                    id
                }
            }
        });
    }
}
TypeScriptOnSalesforceCommentCreated

import { SalesforceGenericEvent } from '@sr-connect/salesforce/events';
import Salesforce from "./api/salesforce";
import JiraCloud from "./api/jira/cloud";
import { htmlToPlainText, getSyncedJiraIssueKey, getEnvVars, getCurrentUser } from './Utils';

export default async function (event: SalesforceGenericEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }

    const { JIRA_SYNC_CUSTOM_FIELD_NAME } = getEnvVars(context);
    // Collect values from the event
    const isFeedItem = event.notifications.Notification.sObject['$']['xsi:type'] === 'sf:FeedItem';
    const caseId = event.notifications.Notification.sObject['sf:ParentId'];
    const userId = event.notifications.Notification.sObject['sf:CreatedById'];
    const feedItemBody = event.notifications.Notification.sObject['sf:Body'];

    // Check if the user who triggered the event matches the user who set up the integration
    if (userId === await getCurrentUser(Salesforce)) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }

    const jiraIssueKey = await getSyncedJiraIssueKey(JiraCloud, caseId, JIRA_SYNC_CUSTOM_FIELD_NAME);
    if (!jiraIssueKey) {
        throw Error(`Issue key not found for sync key ${caseId}`);
    }

    // Check the type of comment
    if (isFeedItem) {
        const stringifiedFeedItemBody = htmlToPlainText(feedItemBody);
        // Add coment to Jira Cloud issue
        await JiraCloud.Issue.Comment.addComment({
            issueIdOrKey: jiraIssueKey,
            body: {
                body: {
                    version: 1,
                    type: "doc",
                    content: [
                        {
                            type: "paragraph",
                            content: [
                                {
                                    type: "text",
                                    text: stringifiedFeedItemBody,
                                }
                            ]
                        },
                    ]
                }
            }
        });
    } else {
        const commentBody = event.notifications.Notification.sObject['sf:CommentBody'];
        // Add coment to Jira Cloud issue
        await JiraCloud.Issue.Comment.addComment({
            issueIdOrKey: jiraIssueKey,
            body: {
                body: {
                    version: 1,
                    type: "doc",
                    content: [
                        {
                            type: "paragraph",
                            content: [
                                {
                                    type: "text",
                                    text: commentBody,
                                }
                            ]
                        },
                    ]
                }
            }
        });
    }
}
TypeScriptOnSalesforceFeedItemAdded

import { SalesforceGenericEvent } from '@sr-connect/salesforce/events';
import { SalesforceApi } from "@managed-api/salesforce-v57-sr-connect";
import Salesforce from "./api/salesforce";
import JiraCloud from "./api/jira/cloud";
import { AddIssueAttachmentsRequest } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
import { htmlToPlainText, getSyncedJiraIssueKey, getEnvVars, getCurrentUser } from './Utils';

export default async function (event: SalesforceGenericEvent, context: Context): Promise<void> {
    if (context.triggerType === 'MANUAL') {
        console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
        return;
    }

    const { JIRA_SYNC_CUSTOM_FIELD_NAME } = getEnvVars(context);
    const notification = event.notifications.Notification;

    if (Array.isArray(notification)) {
        await Promise.all(notification.map(n => processFeedItemEvent(n, JIRA_SYNC_CUSTOM_FIELD_NAME)));
    } else {
        await processFeedItemEvent(notification, JIRA_SYNC_CUSTOM_FIELD_NAME);
    }
}

const processFeedItemEvent = async (notification: any, syncFieldName: string) => {
    // Collect values from the event
    const caseId = notification.sObject['sf:ParentId'];
    const userId = notification.sObject['sf:CreatedById'];
    const feedItemBody = notification.sObject['sf:Body'];
    const feedItemType = notification.sObject['sf:Type'];
    const feedItemId = notification.sObject['sf:Id'];
    const relatedRecordId = notification.sObject['sf:RelatedRecordId'];

    // Check if the user who triggered the event matches the user who set up the integration
    if (userId === await getCurrentUser(Salesforce)) {
        console.warn('Integration user triggered the event, skipping.');
        return;
    }

    const jiraIssueKey = await getSyncedJiraIssueKey(JiraCloud, caseId, syncFieldName);
    if (!jiraIssueKey) {
        throw Error(`Issue key not found for sync key ${caseId}`);
    }

    // Check if item type is comment
    if (feedItemType === 'TextPost') {
        // Convert comment body HTML into a string
        const stringifiedFeedItemBody = htmlToPlainText(feedItemBody);
        // Add coment to Jira Cloud issue
        await JiraCloud.Issue.Comment.addComment({
            issueIdOrKey: jiraIssueKey,
            body: {
                body: {
                    version: 1,
                    type: "doc",
                    content: [
                        {
                            type: "paragraph",
                            content: [
                                {
                                    type: "text",
                                    text: stringifiedFeedItemBody,
                                }
                            ]
                        },
                    ]
                }
            }
        });
        // Check if item type is attachment
    } else if (feedItemType === 'ContentPost') {
        // Fetch Feed item details 
        const details = await getSalesforceFeedItemDetails(Salesforce, feedItemId);
        // Iterate through Feed item records and collect attachments content
        const attachmentBody = await Promise.all(details.records.map((rec) => getSalesforceAttachmentContent(Salesforce, rec.RecordId)));
        // Add all attachments from Feed item
        await JiraCloud.Issue.Attachment.addAttachments({
            issueIdOrKey: jiraIssueKey,
            body: attachmentBody,
        });

        // Check if attachment has a comment
        if (feedItemBody) {
            // Convert comment body HTML into a string
            const stringifiedFeedItemBody = htmlToPlainText(feedItemBody);
            await JiraCloud.Issue.Comment.addComment({
                issueIdOrKey: jiraIssueKey,
                body: {
                    body: {
                        version: 1,
                        type: "doc",
                        content: [
                            {
                                type: "paragraph",
                                content: [
                                    {
                                        type: "text",
                                        text: `Comment to the attachment ${relatedRecordId}: ${stringifiedFeedItemBody}`,
                                    }
                                ]
                            },
                        ]
                    }
                }
            });
        }
    }
}

const getSalesforceFeedItemDetails = async (instance: SalesforceApi, feedItemId: string): Promise<{
    records: {
        RecordId: string;
    }[];
}> => {
    const soql_query = `SELECT Id, RecordId, Type FROM FeedAttachment WHERE FeedEntityId = '${feedItemId}'`; // feed item ID
    const attachmentsResponse = await instance.fetch(`/services/data/v57.0/query?q=${soql_query}`);

    if (attachmentsResponse.ok) {
        return await attachmentsResponse.json();
    } else {
        throw new Error('Failed to get Salesforce FedItem details.');
    }
}

const getSalesforceAttachmentContent = async (instance: SalesforceApi, recordId: string): Promise<AddIssueAttachmentsRequest['body'][0]> => {
    const res = await instance.fetch(`/services/data/v57.0/sobjects/ContentVersion/${recordId}/VersionData`);
    if (res.ok) {
        return {
            fileName: recordId,
            content: await res.arrayBuffer()
        };
    } else {
        throw new Error('Failed to get Salesforce attachment content.');
    }
}
TypeScriptUtils

import { SalesforceApi } from "@managed-api/salesforce-v57-sr-connect";
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { UpdateSObjectRowRecordRequest } from "@managed-api/salesforce-v57-core/types/sObject/row";
import { PartialAttachment } from '@sr-connect/jira-cloud/types/shared-types';

export async function getCurrentUser(instance: SalesforceApi): Promise<string> {
    const resp = await instance.fetch('/services/oauth2/userinfo');
    if (resp.ok) {
        return (await resp.json()).user_id;
    } else {
        throw new Error('Failed to get current user details from Salesforce');
    }
}

type JiraCloudValue = string;
type SalesforceValue = string;

export class Mapper {
    constructor(private mapping: Record<string, string>) { }

    // Method to get value by key
    getSalesforceValue(key: JiraCloudValue): SalesforceValue | undefined {
        return this.mapping[key];
    }
    // Method to get key by value
    getJiraCloudValue(value: SalesforceValue): JiraCloudValue | undefined {
        return Object.keys(this.mapping).find(key => this.mapping[key] === value);
    }
}

interface EnvVars {
    STATUS_MAPPING: Record<JiraCloudValue, SalesforceValue>;
    PRIORITY_MAPPING: Record<JiraCloudValue, SalesforceValue>;
    PROJECT_KEY: string;
    ISSUE_TYPE_NAME: string;
    SALESFORCE_SYNC_KEY_API_NAME: string;
    JIRA_SYNC_CUSTOM_FIELD_NAME: string;
    SUPPORTED_JIRA_FIELDS: string[];
}

export function getEnvVars(context: Context) {
    return context.environment.vars as EnvVars;
}

export interface SalesforceCaseRequestBody {
    Subject?: string,
    Status?: string,
    Description?: string,
    Priority?: string,
}

export async function createSalesforceCase(instance: SalesforceApi, body: SalesforceCaseRequestBody): Promise<string> {
    const resp = await instance.fetch('/services/data/v57.0/sobjects/Case', {
        method: 'POST',
        body: JSON.stringify(body),
    });

    if (resp.ok) {
        const payload = await resp.json();
        return payload.id;
    } else {
        throw new Error('Failed to create Salesforce case.');
    }
}

export async function updateSalesforceCase(instance: SalesforceApi, caseId: string, body: UpdateSObjectRowRecordRequest['body']): Promise<void> {
    // Add sync key value to Saleforce Case
    await instance.sObject.Row.updateRecord({
        id: caseId,
        sObject: 'Case',
        body,
    });
}

/**
 * Find an issue that belongs to Salesforce case
 */
export async function getSyncedJiraIssueKey(instance: JiraCloudApi, syncKey: string, syncKeyName: string,): Promise<string> {
    // You may want to use another means of storing Incident number in Jira Cloud side, for example use a custom field
    const issues = (await instance.Issue.Search.searchByJql({
        body: {
            jql: `"${syncKeyName}" ~ "${syncKey}"`
        }
    })).issues ?? [];

    // If no issues with that sync key is found throw an error
    if (issues.length === 0) {
        throw Error('No issue is found');
    }

    if (issues.length > 1) {
        throw Error(`Found more than 1 matching issue with sync key (${syncKey}): ${issues.map(i => i.key).join(', ')}`)
    }

    if (!issues[0].key) {
        throw Error('No issue key presented.');
    }

    return issues[0].key;
}

// Convert HTML into a text string
export function htmlToPlainText(html: string): string {
    // Handle specific block-level elements by replacing them with newlines
    const blockElements = ['p', 'div', 'section', 'header', 'footer', 'article'];
    blockElements.forEach(tag => {
        const regex = new RegExp(`<${tag}[^>]*>`, 'gi');
        html = html.replace(regex, '\n');
        html = html.replace(new RegExp(`</${tag}>`, 'gi'), '\n');
    });

    // Handle list items by adding newlines before and after each item
    html = html.replace(/<li[^>]*>/gi, '\n- ');
    html = html.replace(/<\/li>/gi, '');

    // Replace other HTML tags with spaces
    html = html.replace(/<\/?[^>]+(>|$)/g, "");

    // Optionally, you can handle common HTML entities like &nbsp; &lt; &gt; etc.
    const entities: { [key: string]: string } = {
        '&nbsp;': ' ',
        '&lt;': '<',
        '&gt;': '>',
        '&amp;': '&',
        '&quot;': '"',
        '&#39;': "'"
    };

    html = html.replace(/&[^;]+;/g, (entity) => entities[entity] || entity);

    // Trim and collapse multiple newlines into a single one
    return html.trim().replace(/\n\s*\n/g, '\n');
}

// Process Jira Cloud attachment
export const processAttachment = async (
    jiraInstance: JiraCloudApi,
    salesforceInstance: SalesforceApi,
    att: PartialAttachment & { id: number },
    syncKey: string
) => {
    // Retrieving content of an attachment from Jira
    const response = await jiraInstance.fetch(`/rest/api/2/attachment/content/${att?.id}`);
    const body = await response.arrayBuffer();

    // Processing attachment
    await addSalesforceAttachment(salesforceInstance, syncKey, {
        body,
        attachmentName: att.filename,
        mimeType: att.mimeType ?? ''
    });
    console.log(`Attachment added to Salesforce case: ${att.filename}`);
}

// Convert and upload content to Salesforce Case
export const addSalesforceAttachment = async (instance: SalesforceApi, caseId: string, content: {
    body: ArrayBuffer | string;
    attachmentName: string;
    mimeType: string;
}) => {
    const { body, attachmentName, mimeType } = content;
    const { uint8Array, boundary } = await getUint8ArrayOfFormDataFromBlob(new Blob([body]), attachmentName, mimeType);
    const contentUploadResponse = await instance.fetch(`/services/data/v57.0/sobjects/ContentVersion`, {
        method: 'POST',
        body: uint8Array,
        headers: {
            'Content-Type': `multipart/form-data; boundary=${boundary}`
        }
    });

    if (contentUploadResponse.ok) {
        const uploadedContent = await contentUploadResponse.json<{ id: string }>();

        const feedItemCreatedResponse = await instance.fetch('/services/data/v57.0/sobjects/FeedItem', {
            method: 'POST',
            body: JSON.stringify({
                ParentId: caseId,
                Type: 'ContentPost',
                RelatedRecordId: uploadedContent.id,
            }),
        });

        if (!feedItemCreatedResponse.ok) {
            throw new Error('Failed to add attachment to Salesforce.');
        }
    }
}

// Create FormData in Uint8Array representation from Blob
export async function getUint8ArrayOfFormDataFromBlob(content: Blob, fileName: string, mimeType: string): Promise<{
    uint8Array: Uint8Array,
    boundary: string;
}> {
    const boundary = '----formdata-' + Math.random();
    const chunks: any[] = [];
    chunks.push(`--${boundary}\r\n`);
    chunks.push(
        `Content-Disposition: form-data; name="entity_content";\r\n`,
        `Content-Type: application/json\r\n\r\n`,
        JSON.stringify({
            "ContentLocation": "S",
            "Title": fileName,
            "PathOnClient": fileName
        }),
        '\r\n'
    );
    chunks.push(`--${boundary}\r\n`);
    chunks.push(
        `Content-Type: ${mimeType}\r\n`,
        `Content-Disposition: form-data; name="VersionData"; filename="${fileName}"\r\n\r\n`,
        await content.arrayBuffer(),
        '\r\n'
    );
    chunks.push(`--${boundary}--`);
    const encoder = new TextEncoder();
    const encodedChunks: ArrayBuffer[] = chunks.map(chunk => typeof chunk === 'string' ? encoder?.encode(chunk) : chunk);
    const totalByteLength = encodedChunks.reduce((total, chunk) => total + chunk.byteLength, 0);
    const uint8Array = new Uint8Array(totalByteLength);
    let offset = 0;

    for (const chunk of encodedChunks) {
        uint8Array.set(new Uint8Array(chunk), offset);
        offset += chunk.byteLength;
    }

    return {
        uint8Array,
        boundary
    };
}
Documentation · Support · Suggestions & feature requests