Sync Jira Cloud issues


Get Started

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

About the integration


How does the integration logic work?

When an issue is created in Jira Cloud, a corresponding issue is created in a separate instance, or across the project within the same instance. Updates on both sides will be kept in sync as well. Get started to learn more.

Which fields are being synced?

  • Summary
  • Issue Type
  • Reporter
  • Assignee
  • Status
  • Priority
  • Comments
  • Description
  • Impact
  • Change Reason
  • Change Risk
  • Change Type
  • Epic Name
  • Due Date
  • Labels
  • Issue Links
  • Attachments
  • Custom Fields

Are sub-tasks synced too?

Yes.

Are the issue move action changes synced as well?

Yes.

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

TypeScriptCreateComment
TypeScriptProject1/OnJiraCloudCommentCreated
Comment Created

README


📋 Overview

This template helps keep your Jira Cloud projects in sync by exchanging the following data:

  • Summary
  • Issue Type
  • Reporter
  • Assignee
  • Status
  • Priority
  • Comments
  • Attachments
  • Description
  • Impact
  • Change reason
  • Change risk
  • Change type
  • Epic Name
  • Custom fields
  • Due date
  • Labels
  • Issue Links
  • Sub-tasks
  • Move issues

🖊️ Setup

  1. Configure API connections and event listeners:

    • Set up connectors for both Jira Cloud instances (or use existing ones).
      ℹ️ Note: Only configure event listeners for events you want to listen to.
  2. Add webhooks in Jira Cloud:

    • Set the Issue related events field using a JQL expression. For example, project = TEST, which will only listen to the "TEST" project.
      💡 Tip: To listen to multiple projects, use project in (TEST, SP), which listens to both TEST and SP projects. You can also add filters like labels, issueType, status, priority, and more. For example, project = TEST AND labels = test AND issueType = Task filters issues with a test label and Task type in the TEST project.
  3. Create custom text fields for syncing:

    • In both Jira Cloud projects, create a custom text field called ScriptRunner Connect Sync Issue Key. This will track the synced issue key from the other project.
    • Add this custom field to all issue types used in the projects.
  4. Configuring parameters:
    Go to Parameters and configure the parameters based on your project's needs.

    • In the FIELDS parameter, uncheck any fields you don't want to sync.
    • Set MOVE_ISSUES_BETWEEN_PROJECTS to false if you don't want issues to move between projects.

🚀 Using the template

  • When a new issue is created and the event is successfully processed, a corresponding issue will be created in the other Jira Cloud instance.
  • Updates made to the issue will automatically sync to the other project.

💡 Tip: You can run the PurgeCache script to clear cached data (after testing, etc.).

ℹ️ Note: Updates made by the user who set up the integration will be ignored to prevent an endless update loop.

❗️ Considerations

  • Some field mappings between different field types might produce unexpected results.
  • This template provides a solid starting point for syncing issues between two Jira Cloud instances. While it handles the basics, it doesn't sync every field or perform all necessary checks. You can easily customize it to meet your specific needs.

📅 Changelog

5 November 2024

  • Added support for uploading large attachments.
  • Removed unused parameter projectKey from searchIssue function.
  • Updated issue link logic in MoveIssue.
  • Updated CreateComment, CreateIssue, CreateIssueLink, DeleteComment, DeleteIssue, DeleteIssueLink, MoveIssue, UpdateComment, UpdateIssue and UtilsJiraCloud scripts with these changes.

27 September 2024

  • Updated CreateIssue, CreateIssueLink, DeleteIssueLink, MoveIssue, and UtilsJiraCloud scripts.
  • Added a fix for nested attachments.
  • Added an issue links filter.
  • Modified CreateIssue script to log warnings if errors occur after a successful issue creation.

8 August 2024

  • Updated CreateComment, MoveIssue, UpdateComment, and DeleteComment scripts to support inline attachments in comments.

6 August 2024

  • Updated the getScriptRunnerConnectSyncIssueKeyForIssueLink function.
  • Added an additional check for the getCustomField function.

5 August 2024

  • Updated CreateIssue, MoveIssue, UpdateIssue, and UtilsJiraCloud scripts to support inline attachments in descriptions.
  • Enhanced getCustomField function to filter custom fields by project and issue type if multiple fields with the same name exist.
  • Updated root scripts to work with the improved getCustomField function.

1 August 2024

  • Fixed issues with the MoveIssue script.

30 July 2024

  • Added support for moving issues between projects.

25 July 2024

  • Fixed comments not syncing correctly when project names were the same.
  • Resolved issue with assignee not being unassigned when USER_FIELD_FALLBACK_OPTION was set to COPY_ORIGINAL_USER_TO_CUSTOM_FIELD.
  • Moved issue transitions to the end of the UpdateIssue script to prevent early transitions.
  • Improved syncing of issue links when project names matched.
  • Added Original comment by ${author} to synced comments.

API Connections


TypeScriptCreateComment

import JiraCloud from './api/jira/cloud1';
import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import { RecordStorage } from '@sr-connect/record-storage';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { getComments, getEnvVars, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, searchIssue, getNewDescriptionWithMedia, hasMediaBlocks, AttachmentBody, AttachmentMediaMapping } from './UtilsJiraCloud';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';

/**
 * Function to create a new comment in target instance based on the comment created in source instance.
 */
export default async function createComment(context: Context, event: IssueCommentCreatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customField: string): Promise<void> {
    console.log('Comment Created event:', event);

    // Get the current user
    const myself = await sourceInstance.Myself.getCurrentUser();

    // Check that the comment was created by a different user than the one who set up the integration
    if (myself.accountId !== event.comment.author.accountId) {
        const userDisplayName = event.comment.updateAuthor.displayName;
        const accountId = event.comment.updateAuthor.accountId;
        const sourceProjectKey = event.issue.fields.project?.key ?? '';

        const metaData = {
            instance: sourceInstance === JiraCloud ? 1 : 2,
            project: sourceProjectKey,
            issueKey: event.issue.key,
            issueType: event.issue.fields.issuetype?.name,
            user: `${userDisplayName} (${accountId})`,
            commentId: event.comment.id
        };

        console.log('Going to perform', event.webhookEvent, 'event:', metaData);

        const { JIRA_PROJECTS } = getEnvVars(context);

        // Get target project key
        const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);

        // Get the ScriptRunner Connect Sync Issue Key from the issue
        const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(event.issue.key, sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);

        if (scriptRunnerConnectSyncIssueKey === null) {
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        // Find the matching issue from the other project
        const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)

        // Check if issue with a matching ScriptRunner Connect Sync Issue Key exists
        if (issues.total === 0) {
            // If not, throw an error
            throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
        };

        // Extract the issue key
        const issueKey = issues.issues?.[0].key ?? '';
        const matchingIssueDetails = issues?.issues?.[0];

        // Get the newly created comment
        const comment = await sourceInstance.Issue.Comment.getComment({
            id: event.comment.id,
            issueIdOrKey: event.issue.key
        });

        let commentHasMediaBlocks = false;
        let commentBody: doc_node;

        commentHasMediaBlocks = hasMediaBlocks(comment.body);

        // Check if comment has attachment
        if (!commentHasMediaBlocks) {
            // Add comment to request body
            commentBody = comment.body
        } else {
            // Get issue attachments
            const issueAttachments = (await sourceInstance.Issue.getIssue({
                issueIdOrKey: event.issue.key,
            })).fields?.attachment ?? [];

            const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
            const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
            const targetInstanceAttachments = matchingIssueDetails.fields.attachment ?? [];
            const extractMediaIdHeader = commentHasMediaBlocks
                ? {
                    'x-stitch-extract-response-media-id': 'true',
                    'x-stitch-store-body': 'true'
                }
                : { 'x-stitch-store-body': 'true' };

            // Loop through attachments and add them to the array
            for (const attachment of issueAttachments) {
                if (attachment.content && attachment.filename) {
                    const file = await sourceInstance.fetch(attachment.content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    const mediaId = file.headers.get('x-stitch-response-media-id')
                    if (!mediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for created issue ${event.issue.key}}`)
                    }

                    // Get the attachment id from a response header
                    const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');

                    // // If no id could be found, throw an error
                    if (!storedAttachmentId) {
                        throw new Error('The attachment stored body was not returned');
                    }

                    sourceAttachmentIdAndMediaId.push({
                        id: attachment.id,
                        fileName: attachment.filename,
                        mediaId: mediaId,
                        storedAttachmentId
                    })
                }
            }

            // Loop through target issue attachments and add them to array
            for (const targetInstanceAttachment of targetInstanceAttachments) {
                const file = await targetInstance.fetch(targetInstanceAttachment.content, {
                    headers: {
                        ...extractMediaIdHeader
                    }
                });

                // Check if the attachment content response is OK
                if (!file.ok) {
                    // If not, then throw an error
                    throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                }

                const targetMediaId = file.headers.get('x-stitch-response-media-id')
                if (!targetMediaId) {
                    throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for updated issue ${issueKey}}`)
                }

                targetAttachmentIdAndMediaId.push({
                    id: targetInstanceAttachment.id,
                    fileName: targetInstanceAttachment.filename,
                    mediaId: targetMediaId,
                })
            }

            // Filter attachments that are missing from issue
            const attachmentsMissingInTargetInstance = sourceAttachmentIdAndMediaId.filter((el) => !targetAttachmentIdAndMediaId.find((file) => file.fileName === el.fileName));

            // Loop through missing attachments and add them to missingAttachmentBody
            for (const missingAttachment of attachmentsMissingInTargetInstance) {
                const attachment = issueAttachments?.find(a => a.id === missingAttachment.id) ?? {}
                // Check if the attachment was added
                if (attachment.content && attachment.filename) {

                    // Upload the attachment to the target instance using the same feature
                    const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
                        method: 'POST',
                        headers: {
                            'X-Atlassian-Token': 'no-check',
                            'x-stitch-stored-body-id': missingAttachment.storedAttachmentId,
                            'x-stitch-stored-body-form-data-file-name': attachment.filename
                        }
                    });

                    // Check if the attachment upload response is OK
                    if (!response.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${response.status}`);
                    }

                    const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();

                    console.log(`${attachment.filename} attachment added to issue: ${issueKey}`);

                    const file = await targetInstance.fetch(targetAttachment?.[0].content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    // Check if the attachment content response is OK
                    if (!file.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                    }

                    const targetMediaId = file.headers.get('x-stitch-response-media-id')

                    if (!targetMediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for updated issue ${issueKey}}`)
                    }

                    targetAttachmentIdAndMediaId.push({
                        id: targetAttachment?.[0].id,
                        fileName: targetAttachment?.[0].filename,
                        mediaId: targetMediaId
                    })
                }
            }

            commentBody = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, comment.body);
        }

        // Construct the original user
        const originalUser = {
            type: "paragraph",
            content: [
                {
                    type: 'text',
                    text: `Original comment by: ${event.comment.author.displayName}`
                }
            ]
        };

        // Clone the existing comment body and add original user
        const updatedCommentBody = {
            ...commentBody,
            content: [...commentBody.content ?? '', originalUser]
        };

        // Create a new comment in target instance
        const createdComment = await targetInstance.Issue.Comment.addComment({
            issueIdOrKey: issueKey,
            body: {
                body: updatedCommentBody as doc_node
            }
        })

        const storage = new RecordStorage();

        const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
        const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

        // Get existing comments from Record Storage with the same ScriptRunner Connect Sync Issue Key
        const comments = await getComments(storage, scriptRunnerConnectSyncIssueKey);

        const commentIds = {
            [sourceProjectNumberWithKey]: event.comment.id,
            [targetProjectNumberWithKey]: createdComment.id
        }

        // Check if existing comments exist
        if (!comments) {
            // If they don't, create a new record
            await storage.setValue(scriptRunnerConnectSyncIssueKey, commentIds);
        } else {
            // Id they do, update the existing record
            const updatedComments = [...comments, commentIds];
            await storage.setValue(scriptRunnerConnectSyncIssueKey, updatedComments);
        }

        console.log(`Comment created for Issue: ${issueKey}`)
    }
}
TypeScriptCreateIssue

import JiraCloud from './api/jira/cloud1';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import { IssueFieldsCreate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { AttachmentMediaMapping, checkAccountIds, checkUserFieldOption, getCustomField, getEnvVars, getFieldAndMatchingValue, getIssueLinks, getJiraPriority, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, searchIssue, setIssueLinks, hasMediaBlocks, getNewDescriptionWithMedia, getScriptRunnerConnectSyncIssueKeyForIssueLink } from './UtilsJiraCloud';
import { RecordStorage } from '@sr-connect/record-storage';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';

/**
 * Function to create a new issue in target instance based on the issue created in source instance.
 */
export default async function createIssue(context: Context, event: IssueCreatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string, targetProjectPredifinedUserAccountId?: string): Promise<void> {
    console.log('Issue Created event:', event);

    // Get the current user
    let myself = await sourceInstance.Myself.getCurrentUser();

    // Check if the current user does not match the person who committed the update
    if (myself.accountId !== event.user.accountId) {

        const sourceProjectKey = event.issue.fields.project?.key ?? '';
        const userDisplayName = event.user.displayName;
        const accountId = event.user.accountId;

        const metaData = {
            instance: sourceInstance === JiraCloud ? 1 : 2,
            project: sourceProjectKey,
            issueKey: event.issue.key,
            issueType: event.issue.fields.issuetype?.name,
            user: `${userDisplayName} (${accountId})`,
            issueId: event.issue.id
        };

        console.log('Going to perform', event.webhookEvent, 'event:', metaData);

        const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, IMPACT, CHANGE_REASON, CHANGE_RISK, CHANGE_TYPE } = getEnvVars(context);

        // Find matching target project key
        const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);

        // Extract the issue issue key
        const eventIssueKey = event.issue.key;

        // Get the ScriptRunner Connect Sync Issue Key custom field
        const sourceCustomField = await getCustomField(sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.issuetype?.name);

        // Add issue key to the ScriptRunner Connect Sync Issue Key custom field
        await sourceInstance.Issue.editIssue({
            issueIdOrKey: eventIssueKey,
            body: {
                fields: {
                    [sourceCustomField]: eventIssueKey
                }
            }
        })

        // Find the project from target instance based on pre-defined project key
        const project = await targetInstance.Project.getProject({
            projectIdOrKey: targetProjectKey
        });

        // Check if the project was found
        if (!project) {
            // If not, then throw an error
            throw Error(`Target project not found: ${targetProjectKey}`);
        }

        // Find the matching issue type for the other project
        const issueTypeName = getMatchingValue(sourceInstance, event.issue.fields.issuetype?.name ?? '', ISSUE_TYPES);

        // Find all the issue types for given project
        const issueTypes = await targetInstance.Issue.Type.getTypesForProject({
            projectId: +(project.id ?? 0) // + sign converts the string to number
        });

        // Find the issue type to use based on pre-defined issue type name
        const issueType = issueTypes.find(it => it.name?.toLowerCase().replace(/\s/g, '') === issueTypeName.toLowerCase().replace(/\s/g, ''));

        // Check if the issue type was found
        if (!issueType) {
            // If not, then throw an error
            throw Error(`Issue Type not found in target instance: ${issueTypeName}`);
        }

        // Get the ScriptRunner Connect Sync Issue Key custom field from target instance
        const targetCustomField = await getCustomField(targetInstance, customFieldName, targetProjectKey, issueType.name);

        // Fields to be updated in target instance
        let requestBody: IssueFieldsCreate = {
            summary: event.issue.fields.summary ?? '',
            project: {
                key: project.key ?? ''
            },
            issuetype: {
                name: issueType.name ?? ''
            },
            [targetCustomField]: eventIssueKey,
        };

        // Check if field exists in FIELDS array
        if (FIELDS.includes('reporter')) {
            // Extract the reporter
            const reporter = event.issue.fields.reporter;

            // Check the user field option value and user field fallback value from Values script and handle the field appropriately
            const reporterUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, reporter?.accountId ?? '', myself.accountId ?? '', 'reporter', reporter?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name);

            // If a value is returned add it to the request body
            if (reporterUserFieldOption) {
                requestBody = { ...requestBody, ...reporterUserFieldOption }
            }
        }

        // Check if field exists in FIELDS array and assignee has been assigned
        if (FIELDS.includes('assignee') && event.issue.fields.assignee !== null) {
            // Extract the assignee
            const assignee = event.issue.fields.assignee;

            // Check the user field option value and user field fallback value from Values script and handle the field appropriately
            const assigneeUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, assignee?.accountId ?? '', myself.accountId ?? '', 'assignee', assignee?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name) ?? '';

            if (assigneeUserFieldOption) {
                requestBody = { ...requestBody, ...assigneeUserFieldOption }
            }
        }

        // Check if field exists in FIELDS array
        if (FIELDS.includes('priority')) {
            // Find the matching Jira Cloud priority name
            const priorityName = getMatchingValue(sourceInstance, event.issue.fields.priority?.name ?? '', PRIORITY);

            // Get the priority
            const priority = await getJiraPriority(priorityName, targetInstance);

            // Check if correct priority was found
            if (!priority) {
                // If not, throw an error
                throw Error(`Priority not found in target instance: ${priority}`);
            }

            // Add priority Id to issue fields
            requestBody.priority = { name: priority.name ?? '' }
        }

        let descriptionHasMediaBlocks = false;
        let issueDescription: doc_node;
        // Check if field exists in FIELDS array and description has been added
        if (FIELDS.includes('description') && event.issue.fields.description !== null) {
            // Get the description from the issue
            issueDescription = (await sourceInstance.Issue.getIssue({
                issueIdOrKey: eventIssueKey,
                fields: ['description']
            })).fields?.description;

            descriptionHasMediaBlocks = hasMediaBlocks(issueDescription);
            if (!descriptionHasMediaBlocks) {
                // Add description to request body
                requestBody.description = issueDescription
            }
        }

        // Check if field exists in FIELDS array
        if (FIELDS.includes('Impact')) {
            const impactField = await getCustomField(sourceInstance, 'Impact', sourceProjectKey, event.issue.fields.issuetype?.name);

            const impact = event.issue.fields[impactField];

            if (impact !== null) {
                // Find the Impact field and matching value in target instance
                const impactValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, impact.value, IMPACT, 'Impact', targetProjectKey, issueType.name);

                // Add the Impact field to request body
                requestBody[impactValues.field] = {
                    value: impactValues.matchingValue
                };
            }
        }

        // Check if field exists in FIELDS array
        if (FIELDS.includes('Change reason')) {
            const changeReasonField = await getCustomField(sourceInstance, 'Change reason', sourceProjectKey, event.issue.fields.issuetype?.name);

            const changeReason = event.issue.fields[changeReasonField];

            if (changeReason !== null) {
                // Find the Change reason and matching value in target instance
                const changeReasonValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeReason.value, CHANGE_REASON, 'Change reason', targetProjectKey, issueType.name);

                // Add the Change reason field to request body
                requestBody[changeReasonValues.field] = {
                    value: changeReasonValues.matchingValue
                };
            }
        }

        // Check if field exists in FIELDS array
        if (FIELDS.includes('Change type')) {
            const changeTypeField = await getCustomField(sourceInstance, 'Change type', sourceProjectKey, event.issue.fields.issuetype?.name);

            const changeType = event.issue.fields[changeTypeField];

            if (changeType !== null) {
                // Find the Change type and matching value in target instance
                const changeTypeValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeType.value, CHANGE_TYPE, 'Change type', targetProjectKey, issueType.name);

                // Add the Change type field to request body
                requestBody[changeTypeValues.field] = {
                    value: changeTypeValues.matchingValue
                };
            }
        }

        // Check if field exists in FIELDS array
        if (FIELDS.includes('Change risk')) {
            const changeRiskField = await getCustomField(sourceInstance, 'Change risk', sourceProjectKey, event.issue.fields.issuetype?.name);

            const changeRisk = event.issue.fields[changeRiskField];

            if (changeRisk !== null) {
                // Find the Change risk and matching value in target instance
                const changeRiskValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeRisk.value, CHANGE_RISK, 'Change risk', targetProjectKey, issueType.name);

                // Add the Change risk field to request body
                requestBody[changeRiskValues.field] = {
                    value: changeRiskValues.matchingValue
                };
            }
        }

        // Check if duedate field exists in FIELDS array and if issue has due date added
        if (FIELDS.includes('duedate') && event.issue.fields.duedate !== null) {
            // If it does, add it to request body
            requestBody.duedate = event.issue.fields.duedate;
        }

        // Check if labels field exist in FIELDS array and if issue has labels added
        if (FIELDS.includes('labels') && event.issue.fields.labels?.length !== 0) {
            // If it does, add it to request body
            requestBody.labels = event.issue.fields.labels;
        }

        // Check if Sub-task was created
        if (FIELDS.includes('issuetype') && event.changelog?.items.some(item => item.field === 'IssueParentAssociation')) {
            const parentIssueKey = event.issue.fields.parent.key
            const parentSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(parentIssueKey ?? '', sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.parent?.fields?.issuetype?.name);
            const matchingIssue = await searchIssue(context, parentSyncIssueKey ?? '', targetInstance, false);

            if (matchingIssue.total === 0) {
                throw new Error(`Matching parent issue with sync key ${parentSyncIssueKey} missing`);
            }

            requestBody.parent = {
                key: matchingIssue?.issues?.[0].key
            }
        }

        // Get the created issue
        const createdIssue = await sourceInstance.Issue.getIssue({
            issueIdOrKey: eventIssueKey
        });

        // Check if custom fields have been added to CUSTOM_FIELDS array in Values script
        if (CUSTOM_FIELDS.length) {
            // Check the fields for creating an issue in the target instance
            const createIssueMetadata = await targetInstance.Issue.Metadata.getCreateMetadata({
                issuetypeNames: [issueTypeName],
                projectKeys: [targetProjectKey],
                expand: 'projects.issuetypes.fields'
            })

            // Extract custom fields
            const createIssueFields = Object.entries(createIssueMetadata?.projects?.[0].issuetypes?.[0].fields ?? {})
                .filter(([key]) => key.startsWith('customfield_'))
                .map(([_, field]) => field.name);

            // Filter out the custom fields that can be added to the issue
            const customFieldsToCheck = CUSTOM_FIELDS.filter(f => createIssueFields.includes(f));

            // If field names have been added there, we will add them to the request body
            for (const customField of customFieldsToCheck) {
                // Get custom field
                const sourceInstanceCustomFieldId = await getCustomField(sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);

                // Save its value
                const value = event.issue.fields[sourceInstanceCustomFieldId];

                // If the custom field has a value
                if (value) {
                    // Find the custom field in target instance
                    const targetInstanceCustomFieldId = await getCustomField(targetInstance, customField, targetProjectKey, issueType.name);

                    // Check custom fields type
                    switch (true) {
                        // Check if custom field is a string or a number
                        case typeof value === 'string' || typeof value === 'number':
                            // Add the value to the request body
                            requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId];
                            break;
                        // Check if custom field is a array
                        case Array.isArray(value):
                            // Check if custom field is an object
                            if (typeof value[0] === 'object') {
                                // Check if the object in array has a value property
                                if (value[0].hasOwnProperty('value')) {
                                    // If it does, map through the objects and save the values
                                    requestBody[targetInstanceCustomFieldId] = (value as { value: string }[]).map(field => ({ value: field.value }));
                                }

                                // Check if the object in array has an accountId property
                                if (value[0].hasOwnProperty('accountId')) {
                                    // If it does, save all the account IDs added in the custom field
                                    const accountIds = (value as { accountId: string }[]).map(field => field.accountId);

                                    // Check if the account IDs can be added to the issue
                                    const validAccountIds = await checkAccountIds(targetInstance, targetProjectKey, accountIds);

                                    // Add the valid account IDs to the request body
                                    requestBody[targetInstanceCustomFieldId] = validAccountIds.map(value => ({ accountId: value }));
                                }
                            } else {
                                // Add the array to the request body
                                requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId]
                            }
                            break;
                        // Check if the custom field is an object
                        case typeof value === 'object':
                            // Add the value in the object to request body
                            requestBody[targetInstanceCustomFieldId] = {
                                value: createdIssue.fields?.[sourceInstanceCustomFieldId].value
                            }
                            break;
                        default:
                            break;
                    }
                }
            }
        }

        // Create a new Issue in Jira Cloud
        const issue = await targetInstance.Issue.createIssue({
            body: {
                fields: requestBody,
            }
        });

        // Extract the newly created Issue key
        const issueKey = issue.key ?? '';

        console.log(`Issue created: ${issueKey}`);

        // Get the newly created issue
        const createdTargetIssue = await targetInstance.Issue.getIssue({
            issueIdOrKey: issueKey
        })

        const attachments = event.issue.fields.attachment ?? [];

        // Check if attachments were added to the issue
        if (FIELDS.includes('Attachment') && attachments.length > 0) {
            try {
                // Get the attachments from the issue
                const issueAttachments = (await sourceInstance.Issue.getIssue({
                    issueIdOrKey: eventIssueKey
                })).fields?.attachment ?? [];

                const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
                const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
                const targetInstanceAttachments: AddIssueAttachmentsResponseOK = []
                const extractMediaIdHeader = descriptionHasMediaBlocks
                    ? {
                        'x-stitch-extract-response-media-id': 'true',
                        'x-stitch-store-body': 'true'
                    }
                    : {
                        'x-stitch-store-body': 'true'
                    };

                // Loop through attachments and add them to the array
                for (const attachment of issueAttachments) {
                    if (attachment.content && attachment.filename) {
                        const file = await sourceInstance.fetch(attachment.content, {
                            headers: {
                                ...extractMediaIdHeader
                            }
                        });

                        // Check if the attachment content response is OK
                        if (!file.ok) {
                            // If not, then throw an error
                            throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                        }

                        // Get the attachment id from a response header
                        const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');

                        // If no id could be found, throw an error
                        if (!storedAttachmentId) {
                            throw new Error('The attachment stored body was not returned');
                        }

                        if (descriptionHasMediaBlocks) {
                            const mediaId = file.headers.get('x-stitch-response-media-id')
                            if (!mediaId) {
                                throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for created issue ${eventIssueKey}}`)
                            }
                            sourceAttachmentIdAndMediaId.push({
                                id: attachment.id,
                                fileName: attachment.filename,
                                mediaId: mediaId,
                                storedAttachmentId
                            })
                        }

                        // Upload the attachment to the target instance using the same feature
                        const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
                            method: 'POST',
                            headers: {
                                'X-Atlassian-Token': 'no-check',
                                'x-stitch-stored-body-id': storedAttachmentId,
                                'x-stitch-stored-body-form-data-file-name': attachment.filename
                            }
                        });

                        // Check if the upload response is OK
                        if (!response.ok) {
                            // If not, then throw an error
                            throw Error(`Unexpected response while uploading attachment: ${response.status}`);
                        }

                        const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();

                        targetInstanceAttachments.push(...targetAttachment);
                    }
                }

                if (descriptionHasMediaBlocks) {
                    for (const targetInstanceAttachment of targetInstanceAttachments) {
                        const file = await targetInstance.fetch(targetInstanceAttachment.content, {
                            headers: {
                                ...extractMediaIdHeader
                            }
                        });

                        // Check if the attachment content response is OK
                        if (!file.ok) {
                            // If not, then throw an error
                            throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                        }

                        const targetMediaId = file.headers.get('x-stitch-response-media-id')
                        if (!targetMediaId) {
                            throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for newly created issue ${issueKey}}`)
                        }

                        targetAttachmentIdAndMediaId.push({
                            id: targetInstanceAttachment.id,
                            fileName: targetInstanceAttachment.filename,
                            mediaId: targetMediaId,
                        })
                    }
                }

                if (descriptionHasMediaBlocks) {
                    if (!targetInstanceAttachments.length) {
                        throw new Error(
                            `${issueKey} does not have any attachments but its description is expecting attachments. ` +
                            `Check if the attachments have been uploaded successfully.`
                        )
                    }
                    if (!targetAttachmentIdAndMediaId.length || !sourceAttachmentIdAndMediaId.length) {
                        throw new Error(
                            `${issueKey} does not have the necessary mapping to process the inline description attachments. `
                        )
                    }

                    const newDescription = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, issueDescription);

                    await targetInstance.Issue.editIssue({
                        issueIdOrKey: issueKey,
                        body: {
                            fields: {
                                description: newDescription
                            }
                        }
                    })
                }
            } catch (error) {
                console.warn(`Adding attachments to issue ${issueKey} failed. In case of an ATTACHMENT_VALIDATION_ERROR, the description update with attachments failed.`, error)
            }
        }

        // Check if newly created Issue has the correct status and find the matching status
        const status = getMatchingValue(sourceInstance, event.issue.fields.status?.name ?? '', STATUS);

        // Check if status is incorrect, and then change it
        if (createdTargetIssue.fields?.status?.name !== status) {
            try {
                const transitions = (await targetInstance.Issue.Transition.getTransitions({
                    issueIdOrKey: issueKey
                })).transitions ?? [];

                const transitionId = transitions.find(t => t.to?.name === status || t.name === status)?.id ?? ''

                if (!transitionId) {
                    throw Error(`Transition for status not found in target instance: ${status}`);
                }

                // Change the status of the issue (workflow transition)
                await targetInstance.Issue.Transition.performTransition({
                    issueIdOrKey: issueKey,
                    body: {
                        transition: {
                            id: transitionId
                        }
                    }
                });
            } catch (error) {
                console.warn(`Transitioning issue ${issueKey} failed`);
            }
        };

        // Check if issue links exist in FIELDS array and if issue has issue links added
        if (FIELDS.includes('issue links') && createdIssue.fields?.issuelinks?.length) {
            try {
                const issueLinks = event.issue.fields.issuelinks ?? [];

                // Go over created issue links
                for (const issueLink of issueLinks) {
                    // Extract issue keys
                    const outwardLinkedIssueKey = issueLink.outwardIssue?.key;
                    const inwardLinkedIssueKey = issueLink.inwardIssue?.key;
                    const storage = new RecordStorage();

                    // Check for existing issue links
                    const existingIssueLinks = await getIssueLinks(storage, event.issue.key);

                    // Check target instance has valid issue link type
                    const issueLinkTypes = await targetInstance.Issue.Link.Type.getTypes();
                    const issueLinkType = (issueLinkTypes.issueLinkTypes?.find((types) => types.name === issueLink.type.name));

                    if (!issueLinkType) {
                        throw Error(`Issue Link Type ${issueLink.type.name} doesn't exist in the target project`);
                    }

                    // Handle outward issue link
                    if (outwardLinkedIssueKey) {
                        // Find the Sync key for outward issue
                        const syncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, outwardLinkedIssueKey ?? '', sourceInstance, customFieldName);
                        if (syncIssueKey) {
                            // Find the matching issue from target instance
                            const targetIssue = (await searchIssue(context, syncIssueKey ?? '', targetInstance, false))

                            if (targetIssue.total === 0) {
                                throw new Error(`No matching issue with sync key ${syncIssueKey} was found for the issue link.`);
                            }

                            // Create issue link in target instance
                            await targetInstance.Issue.Link.createLink({
                                body: {
                                    outwardIssue: {
                                        key: targetIssue.issues?.[0].key,
                                    },
                                    type: {
                                        name: issueLink.type.name
                                    },
                                    inwardIssue: {
                                        key: issue.key
                                    }
                                },
                            })

                            // Get the issue link id
                            const createdIssueLinkId = (await targetInstance.Issue.getIssue({
                                issueIdOrKey: issue.key ?? '0'
                            })).fields?.issuelinks?.find(x => x.outwardIssue?.key === targetIssue.issues?.[0].key && x.type.name === issueLink.type.name)?.id

                            const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
                            const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
                            // Save issue link mapping into Record Storage
                            const newIssueLink = {
                                [sourceProjectNumberWithKey]: issueLink.id,
                                [targetProjectNumberWithKey]: createdIssueLinkId ?? '0',
                            }

                            // Save issue links to Record Storage
                            const updatedIssueLinks = [...existingIssueLinks, newIssueLink];
                            await setIssueLinks(storage, eventIssueKey, updatedIssueLinks);

                            console.log(`Issue link created bewteen ${targetIssue.issues?.[0].key} and ${issue.key}`);
                        }
                    }

                    // Handle inward issue link
                    if (inwardLinkedIssueKey) {
                        // Find the Sync key for inward issue
                        const syncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, inwardLinkedIssueKey ?? '', sourceInstance, customFieldName);

                        if (syncIssueKey) {
                            // Find the matching issue from target instance
                            const targetIssue = (await searchIssue(context, syncIssueKey ?? '', targetInstance, false))

                            if (targetIssue.total === 0) {
                                throw new Error(`No matching issue with sync key ${syncIssueKey} was found for the issue link.`);
                            }

                            // Create issue link in target instance
                            await targetInstance.Issue.Link.createLink({
                                body: {
                                    outwardIssue: {
                                        key: issue.key
                                    },
                                    type: {
                                        name: issueLink.type.name
                                    },
                                    inwardIssue: {
                                        key: targetIssue.issues?.[0].key,
                                    }
                                },
                            })

                            // Get the created issue link id
                            const createdIssueLinkId = (await targetInstance.Issue.getIssue({
                                issueIdOrKey: targetIssue.issues?.[0].key ?? '0'
                            })).fields?.issuelinks?.find(x => x.outwardIssue?.key === issue.key && x.type.name === issueLink.type.name)?.id

                            const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
                            const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

                            // Save issue link mapping into Record Storage
                            const newIssueLink = {
                                [sourceProjectNumberWithKey]: issueLink.id,
                                [targetProjectNumberWithKey]: createdIssueLinkId ?? '0'
                            }

                            // Save issue links to Record Storage
                            const updatedIssueLinks = [...existingIssueLinks, newIssueLink];
                            await setIssueLinks(storage, syncIssueKey, updatedIssueLinks);

                            console.log(`Issue link created bewteen ${targetIssue.issues?.[0].key} and ${issue.key}`);
                        }
                    }
                };
            } catch (error) {
                console.warn('Issue link creation failed: ', error)
            }
        };
    }
}
TypeScriptDeleteComment

import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueCommentDeletedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { getComments, getEnvVars, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, hasMediaBlocks, searchIssue } from "./UtilsJiraCloud";

export default async function deleteComment(context: Context, event: IssueCommentDeletedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customField: string): Promise<void> {
    console.log('Comment Deleted event: ', event);

    const userDisplayName = event.comment.author.displayName;
    const accountId = event.comment.author.accountId;
    const sourceProjectKey = event.issue.fields.project?.key ?? '';

    const metaData = {
        instance: sourceInstance === JiraCloud ? 1 : 2,
        project: sourceProjectKey,
        issueKey: event.issue.key,
        issueType: event.issue.fields.issuetype?.name,
        commentCreatedBy: `${userDisplayName} (${accountId})`,
        commentId: event.comment.id
    };

    console.log('Going to perform', event.webhookEvent, 'event:', metaData);

    // Search for the issue that event was triggered from
    const issueExists = await sourceInstance.Issue.getIssue<null>({
        issueIdOrKey: event.issue.key,
        errorStrategy: {
            handleHttp404Error: () => null
        }
    })

    // Check if the issue still exists
    if (issueExists) {
        const { JIRA_PROJECTS } = getEnvVars(context);

        // Find matching target project key
        const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);

        // Get the ScriptRunner Connect Sync Issue Key from the issue
        const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(event.issue.key, sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);

        if (scriptRunnerConnectSyncIssueKey === null) {
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        const storage = new RecordStorage();

        // Get comments from Record Storage with the ScriptRunner Connect Sync Issue Key
        const comments = await getComments(storage, scriptRunnerConnectSyncIssueKey);

        const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
        const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

        // Find the comment ID for the target instance that matches the comment that got deleted
        const commentId = comments.find(c => c[sourceProjectNumberWithKey] === event.comment.id)?.[targetProjectNumberWithKey];

        // Check if the matching comment ID was found
        if (!commentId) {
            // If not, throw an error
            throw Error(`Couldn't find comment ID from Record Storage: ${commentId}. Comment might already be deleted`);
        };

        // Find the matching issue from the target instance
        const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, false);

        // Check if issue with a matching ScriptRunner Connect Issue Key exists
        if (issues.total === 0) {
            // If not, throw an error
            throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
        };

        // Extract the issue key
        const issueKey = issues.issues?.[0].key ?? '';

        // Get the matching comment
        const commentBody = (await targetInstance.Issue.Comment.getComment({
            id: commentId,
            issueIdOrKey: issueKey
        })).body;

        let commentHasMediaBlocks = false;

        // Check if the comment has an attachment
        commentHasMediaBlocks = hasMediaBlocks(commentBody);

        // Delete the comment
        await targetInstance.Issue.Comment.deleteComment({
            issueIdOrKey: issueKey,
            id: commentId ?? '0'
        })

        // Check if issue has some comments left
        if (comments.length > 1) {
            // If it has then remove the deleted comment from Record Storage
            const updatedComments = comments.filter(c => c[sourceProjectNumberWithKey] !== event.comment.id);
            await storage.setValue(scriptRunnerConnectSyncIssueKey, updatedComments);
        } else {
            // If not, then delete the value from Record Storage since we don't need to keep it there anymore
            await storage.deleteValue(scriptRunnerConnectSyncIssueKey);
        }

        console.log(`Comment deleted for Issue: ${issueKey}`);

        // If matching comment had an attachment, delete deleted attachments
        if (commentHasMediaBlocks) {
            // Get issue attachments for the source issue
            const sourceIssueAttachments = (await sourceInstance.Issue.getIssue({
                issueIdOrKey: event.issue.key,
            })).fields?.attachment ?? [];

            // Get issue attachments for the target issue
            const targetIssueAttachments = (await targetInstance.Issue.getIssue({
                issueIdOrKey: issueKey,
            })).fields?.attachment ?? [];

            // Check if the issues have different amount of attachments
            if (sourceIssueAttachments.length !== targetIssueAttachments.length) {
                // Check if target issue has more attachments than the source issue
                if (targetIssueAttachments.length > sourceIssueAttachments.length) {
                    // If so, filter out attachments that should get deleted
                    const filteredTargetAttachments = targetIssueAttachments.filter(
                        sourceItem => !sourceIssueAttachments.some(targetItem => targetItem.filename === sourceItem.filename)
                    );

                    for (const attachmentToDelete of filteredTargetAttachments) {
                        await targetInstance.Issue.Attachment.deleteAttachment({
                            id: attachmentToDelete.id
                        })
                    }
                }

                // Check if target issue has less attachments than the source issue
                if (targetIssueAttachments.length < sourceIssueAttachments.length) {
                    // If so, filter out attachments that should get deleted
                    const filteredSourceAttachments = sourceIssueAttachments.filter(
                        sourceItem => !targetIssueAttachments.some(targetItem => targetItem.filename === sourceItem.filename)
                    );

                    for (const attachmentToDelete of filteredSourceAttachments) {
                        await sourceInstance.Issue.Attachment.deleteAttachment({
                            id: attachmentToDelete.id
                        })
                    }
                }
            }
        }
    }
}
TypeScriptDeleteIssue

import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueDeletedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { getCustomField, getEnvVars, getMatchingValue, searchIssue } from "./UtilsJiraCloud";

export default async function deleteIssue(context: Context, event: IssueDeletedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string): Promise<void> {
    console.log('Issue Deleted event: ', event);

    // Get the current user
    let myself = await sourceInstance.Myself.getCurrentUser();

    // Check if the current user does not match the person who committed the update
    if (myself.accountId !== event.user.accountId) {
        const userDisplayName = event.user.displayName;
        const accountId = event.user.accountId;
        const sourceProjectKey = event.issue.fields.project?.key ?? '';

        const metaData = {
            instance: sourceInstance === JiraCloud ? 1 : 2,
            project: sourceProjectKey,
            issueKey: event.issue.key,
            issueType: event.issue.fields.issuetype?.name,
            user: `${userDisplayName} (${accountId})`,
            issueId: event.issue.id
        };

        console.log('Going to perform', event.webhookEvent, 'event:', metaData);

        const { JIRA_PROJECTS, FIELDS } = getEnvVars(context);

        // Find matching target project key
        const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);

        const customField = await getCustomField(sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.issuetype?.name);

        // Get the ScriptRunner Connect Sync Issue Key from deleted issue
        const scriptRunnerConnectSyncIssueKey = event.issue.fields[customField] as string | undefined;

        if (!scriptRunnerConnectSyncIssueKey) {
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        // Find the matching issue from the other project
        const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, false);

        if (issues.total === 0) {
            throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
        }

        const issueKey = issues.issues?.[0].key ?? '';

        // Delete the issue from the target instance
        await targetInstance.Issue.deleteIssue({
            issueIdOrKey: issueKey,
            deleteSubtasks: "true",
        });

        const storage = new RecordStorage();

        if (FIELDS.includes('comments')) {
            // Check if there are any comments cached with this ScriptRunnerConnect Sync Issue Key
            const comments = await storage.valueExists(scriptRunnerConnectSyncIssueKey);


            // If there are then delete them
            if (comments) {
                await storage.deleteValue(scriptRunnerConnectSyncIssueKey)
            }
        }

        if (FIELDS.includes('issue links')) {
            // Check if there are any issue links cached with this ScriptRunnerConnect Sync Issue Key
            const issueLinks = await storage.valueExists(`issue_link_${scriptRunnerConnectSyncIssueKey}`);

            // If there are then delete them
            if (issueLinks) {
                await storage.deleteValue(`issue_link_${scriptRunnerConnectSyncIssueKey}`)
            }
        }

        console.log(`Deleted Issue: ${issueKey}`)
    }
}
TypeScriptMoveIssue

import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueUpdatedEvent } from "@sr-connect/jira-cloud/events";
import { AttachmentBody, AttachmentMediaMapping, checkUserFieldOption, getCustomField, getEnvVars, getFieldAndMatchingValue, getIssueLinks, getJiraPriority, getMatchingValue, getNewDescriptionWithMedia, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, getScriptRunnerConnectSyncIssueKeyForIssueLink, handleCustomFieldsForCreatingIssue, hasMediaBlocks, retrySearchIssueInTargetInstance, retrySyncIssueKeyForIssueLinks, searchIssue, setIssueLinks } from "./UtilsJiraCloud";
import { IssueFieldsCreate, IssueFieldsUpdate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { GetCurrentUserResponseOK } from '@managed-api/jira-cloud-v3-core/types/myself';
import { RecordStorage } from '@sr-connect/record-storage';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { SearchIssuesByJqlResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/search';
import { AttachmentAsResponse } from '@managed-api/jira-cloud-v3-core/definitions/AttachmentAsResponse';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';

export default async function moveIssue(context: Context, event: IssueUpdatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string, myself: GetCurrentUserResponseOK, targetProjectPredifinedUserAccountId?: string): Promise<void> {
    console.log('Issue Moved event: ', event);
    const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, IMPACT, CHANGE_REASON, CHANGE_RISK, CHANGE_TYPE, RetryConfigurations } = getEnvVars(context);

    const changeLogItems = event.changelog?.items ?? [];
    const sourceProjectKey = event.issue.fields.project?.key ?? '';
    const keyChange = changeLogItems.find(item => item.field === 'Key');
    const projectChange = changeLogItems.find(item => item.field === 'project');
    const issueTypeChange = changeLogItems.find(item => item.field === 'issuetype');
    const oldIssueKey = keyChange?.fromString ?? '';
    const newIssueKey = keyChange?.toString ?? '';
    const oldProjectKey = projectChange?.fromString ?? '';
    const newProjectKey = projectChange?.toString ?? '';
    const oldIssueType = issueTypeChange?.fromString ?? '';
    const newIssueType = issueTypeChange?.toString ?? '';

    const userDisplayName = event.user.displayName;
    const accountId = event.user.accountId;

    const storage = new RecordStorage();

    const metaData = {
        instance: sourceInstance === JiraCloud ? 1 : 2,
        project: sourceProjectKey,
        oldIssueKey: oldIssueKey,
        newIssueKey: newIssueKey,
        oldProject: oldProjectKey,
        newProject: newProjectKey,
        oldIssueType: oldIssueType,
        newIssueType: newIssueType,
        user: `${userDisplayName} (${accountId})`,
    };

    console.log('Going to perform', event.webhookEvent, event.issue_event_type_name, 'event:', metaData);

    // Find target Project key
    const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
    // Get the ScriptRunner Connect Sync Issue Key custom field
    const sourceCustomField = await getCustomField(sourceInstance, customFieldName, newProjectKey, newIssueType);

    // Extract the previous Script Runner Connect Sync Issue Key
    const previousScriptRunnerConnectSyncIssueKey = event.issue?.fields?.[sourceCustomField];

    // Update the ScriptRunner Connect Sync Issue Key custom field
    await sourceInstance.Issue.editIssue({
        issueIdOrKey: newIssueKey,
        body: {
            fields: {
                [sourceCustomField]: newIssueKey,
            }
        }
    })

    // Find the project from target instance
    const project = await targetInstance.Project.getProject({
        projectIdOrKey: targetProjectKey
    });

    // Check if the project was found
    if (!project) {
        // If not, then throw an error
        throw Error(`Target project not found: ${targetProjectKey}`);
    }

    // Find the matching issue type for the other project
    const issueTypeName = getMatchingValue(sourceInstance, event.issue.fields.issuetype?.name ?? '', ISSUE_TYPES);

    // Find all the issue types for given project
    const issueTypes = await targetInstance.Issue.Type.getTypesForProject({
        projectId: +(project.id ?? 0) // + sign converts the string to number
    });

    // Find the issue type to use based on pre-defined issue type name
    const issueType = issueTypes.find(it => it.name?.toLowerCase().replace(/\s/g, '') === issueTypeName.toLowerCase().replace(/\s/g, ''));

    // Check if the issue type was found
    if (!issueType) {
        // If not, then throw an error
        throw Error(`Issue Type not found in target instance: ${issueTypeName}`);
    }

    // Get the ScriptRunner Connect Sync Issue Key custom field from target instance
    const targetCustomField = await getCustomField(targetInstance, customFieldName, targetProjectKey, issueType.name);

    // Fields to be updated in target instance
    let requestBody: IssueFieldsCreate = {
        summary: event.issue.fields.summary ?? '',
        project: {
            key: project.key ?? ''
        },
        issuetype: {
            name: issueType.name ?? ''
        },
        [targetCustomField]: newIssueKey,
    };

    // Request body for fields we can't add when creating the issue
    let requestBodyForTransition: IssueFieldsUpdate = {};

    // Get editMetadata for the created Issue
    const editMetadata = await sourceInstance.Issue.Metadata.getEditMetadata({
        issueIdOrKey: newIssueKey,
    })


    // Filter fields that were required for this moved issue
    const requiredFields = Object.entries(editMetadata.fields ?? {})
        .filter(([, field]) => field.required)
        .map(([key, field]) => key.startsWith('customfield_') ? field.name : key);

    const fieldsThatIssueSupports = Object.entries(editMetadata.fields ?? {})
        .map(([key, field]) => key.startsWith('customfield_') ? field.name : key);

    // Check the fields for creating an issue in the target instance
    const createIssueMetadata = await targetInstance.Issue.Metadata.getCreateMetadata({
        issuetypeNames: [issueTypeName],
        projectKeys: [targetProjectKey],
        expand: 'projects.issuetypes.fields'
    })

    // Extract required fields
    const createIssueFields = Object.entries(createIssueMetadata?.projects?.[0].issuetypes?.[0].fields ?? {})
        .map(([key, field]) => key.startsWith('customfield_') ? field.name : key);

    // Compare the two arrays and save the fields that we cannot add when we create the issue
    const requiredFieldsForTransition = requiredFields.filter(field => !createIssueFields.includes(field));

    // Check if field exists in FIELDS array
    if (FIELDS.includes('reporter')) {
        // Extract the reporter
        const reporter = event.issue.fields.reporter;

        // Check the user field option value and user field fallback value from Values script and handle the field appropriately
        const reporterUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, reporter?.accountId ?? '', myself.accountId ?? '', 'reporter', reporter?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name);

        // If a value is returned add it to the request body
        if (reporterUserFieldOption) {
            requestBody = { ...requestBody, ...reporterUserFieldOption }
        }
    }

    // Check if field exists in FIELDS array and assignee has been assigned
    if (FIELDS.includes('assignee') && event.issue.fields.assignee !== null) {
        // Extract the assignee
        const assignee = event.issue.fields.assignee;

        // Check the user field option value and user field fallback value from Values script and handle the field appropriately
        const assigneeUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, assignee?.accountId ?? '', myself.accountId ?? '', 'assignee', assignee?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name) ?? '';

        if (assigneeUserFieldOption) {
            if (createIssueFields.includes('assignee')) {
                requestBody = { ...requestBody, ...assigneeUserFieldOption }
            } else if (fieldsThatIssueSupports.includes('assignee')) {
                requestBodyForTransition = { ...requestBodyForTransition, ...assigneeUserFieldOption }
            }
        }
    }

    // Check if field exists in FIELDS array
    if (FIELDS.includes('priority')) {
        // Find the matching Jira Cloud priority name
        const priorityName = getMatchingValue(sourceInstance, event.issue.fields.priority?.name ?? '', PRIORITY);

        // Get the priority
        const priority = await getJiraPriority(priorityName, targetInstance);

        // Check if correct priority was found
        if (!priority) {
            // If not, throw an error
            throw Error(`Priority not found in target instance: ${priority}`);
        }

        if (createIssueFields.includes('priority')) {
            // Add priority name to issue fields
            requestBody.priority = { name: priority.name ?? '' }
        } else if (fieldsThatIssueSupports.includes('priority')) {
            requestBodyForTransition.priority = { name: priority.name ?? '' }
        }
    }

    let descriptionHasMediaBlocks = false;
    let issueDescription: doc_node;
    // Check if field exists in FIELDS array and description has been added
    if (FIELDS.includes('description') && event.issue.fields.description !== null) {
        // Get the description from the issue
        issueDescription = (await sourceInstance.Issue.getIssue({
            issueIdOrKey: newIssueKey,
            fields: ['description']
        })).fields?.description;

        descriptionHasMediaBlocks = hasMediaBlocks(issueDescription);

        if (!descriptionHasMediaBlocks) {
            if (createIssueFields.includes('description')) {
                // Add description to issue fields
                requestBody.description = issueDescription
            } else if (fieldsThatIssueSupports.includes('description')) {
                requestBodyForTransition.description = issueDescription
            }
        }
    }

    // Check if field exists in FIELDS array
    if (FIELDS.includes('Impact')) {
        const impactField = await getCustomField(sourceInstance, 'Impact', sourceProjectKey, event.issue.fields.issuetype?.name);

        const impact = event.issue.fields[impactField];

        if (impact !== null) {
            // Find the Impact field and matching value in target instance
            const impactValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, impact.value, IMPACT, 'Impact', targetProjectKey, issueType.name);

            if (createIssueFields.includes('Impact')) {
                // Add the Impact field to request body
                requestBody[impactValues.field] = {
                    value: impactValues.matchingValue
                };
            } else if (fieldsThatIssueSupports.includes('Impact')) {
                requestBodyForTransition[impactValues.field] = {
                    value: impactValues.matchingValue
                };
            }
        }
    }

    // Check if field exists in FIELDS array
    if (FIELDS.includes('Change reason')) {
        const changeReasonField = await getCustomField(sourceInstance, 'Change reason', sourceProjectKey, event.issue.fields.issuetype?.name);

        const changeReason = event.issue.fields[changeReasonField];

        if (changeReason !== null) {
            // Find the Change reason and matching value in target instance
            const changeReasonValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeReason.value, CHANGE_REASON, 'Change reason', targetProjectKey, issueType.name);

            if (createIssueFields.includes('Change reason')) {
                // Add the Change reason field to request body
                requestBody[changeReasonValues.field] = {
                    value: changeReasonValues.matchingValue
                };
            } else if (fieldsThatIssueSupports.includes('Change reason')) {
                requestBodyForTransition[changeReasonValues.field] = {
                    value: changeReasonValues.matchingValue
                };
            }
        }
    }

    // Check if field exists in FIELDS array
    if (FIELDS.includes('Change type')) {
        const changeTypeField = await getCustomField(sourceInstance, 'Change type', sourceProjectKey, event.issue.fields.issuetype?.name);

        const changeType = event.issue.fields[changeTypeField];

        if (changeType !== null) {
            // Find the Change type and matching value in target instance
            const changeTypeValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeType.value, CHANGE_TYPE, 'Change type', targetProjectKey, issueType.name);

            if (createIssueFields.includes('Change type')) {
                // Add the Change type field to request body
                requestBody[changeTypeValues.field] = {
                    value: changeTypeValues.matchingValue
                };
            } else if (fieldsThatIssueSupports.includes('Change type')) {
                requestBodyForTransition[changeTypeValues.field] = {
                    value: changeTypeValues.matchingValue
                };
            }
        }
    }

    // Check if field exists in FIELDS array
    if (FIELDS.includes('Change risk')) {
        const changeRiskField = await getCustomField(sourceInstance, 'Change risk', sourceProjectKey, event.issue.fields.issuetype?.name);

        const changeRisk = event.issue.fields[changeRiskField];

        if (changeRisk !== null) {
            // Find the Change risk and matching value in target instance
            const changeRiskValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeRisk.value, CHANGE_RISK, 'Change risk', targetProjectKey, issueType.name);

            if (createIssueFields.includes('Change risk')) {
                // Add the Change type field to request body
                requestBody[changeRiskValues.field] = {
                    value: changeRiskValues.matchingValue
                };
            } else if (fieldsThatIssueSupports.includes('Change risk')) {
                requestBodyForTransition[changeRiskValues.field] = {
                    value: changeRiskValues.matchingValue
                };
            }
        }
    }

    // Check if duedate field exists in FIELDS array and if issue has due date added
    if (FIELDS.includes('duedate') && event.issue.fields.duedate !== null) {

        if (createIssueFields.includes('duedate')) {
            // Add the duedate to request body
            requestBody.duedate = event.issue.fields.duedate;
        } else if (fieldsThatIssueSupports.includes('duedate')) {
            requestBodyForTransition.duedate = event.issue.fields.duedate;
        }
    }

    // Check if labels field exist in FIELDS array and if issue has labels added
    if (FIELDS.includes('labels') && event.issue.fields.labels?.length !== 0) {
        if (createIssueFields.includes('labels')) {
            // If it does, add it to request body
            requestBody.labels = event.issue.fields.labels;
        } else if (fieldsThatIssueSupports.includes('labels')) {
            requestBodyForTransition.labels = event.issue.fields.labels;
        }
    }

    // Check if Sub-task was created
    if (FIELDS.includes('issuetype') && (newIssueType === 'Sub-task' || newIssueType === 'Subtask')) {
        const parentIssueKey = event.issue.fields.parent?.key
        let parentSyncIssueKey: string | null = null;
        let matchingIssue: SearchIssuesByJqlResponseOK | undefined;
        let attempts = 0;

        do {
            // Get ScriptRunner Connect Sync Issue Key for parent
            parentSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(
                parentIssueKey ?? '',
                sourceInstance,
                customFieldName,
                sourceProjectKey,
                event.issue.fields?.parent?.fields?.issuetype?.id ?? '0'
            );

            // Search for parent issue in target instance
            matchingIssue = await searchIssue(context, parentSyncIssueKey ?? '0', targetInstance, false);

            // Check if we found a matching issue and it's in the correct project
            if (matchingIssue.total && matchingIssue.total > 0 && parentSyncIssueKey && matchingIssue.issues?.[0].fields?.project?.key === targetProjectKey) {
                break; // Exit loop if matching issue is found
            }

            console.log('No matching issue found. Retrying...');
            attempts++;
            await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));
        } while (attempts < RetryConfigurations.MAX_RETRY_ATTEMPTS); // Continue until max attempts reached

        // Check if a matching issue was found
        if (matchingIssue?.total === 0) {
            // If not, throw an error
            throw Error(`Issue with the matching Issue Parent ScriptRunner Connect Sync Issue Key does not exist in target instance: ${parentSyncIssueKey}`);
        };

        // Add matching parent issue key to issue fields
        requestBody.parent = {
            key: matchingIssue?.issues?.[0].key
        }
    }

    // Get the moved issue
    const createdIssue = await sourceInstance.Issue.getIssue({
        issueIdOrKey: newIssueKey
    });

    // Check if custom fields have been added to CUSTOM_FIELDS array in Values script
    if (CUSTOM_FIELDS.length) {
        // Filter custom fields that we can add to create issue
        const customFields = CUSTOM_FIELDS.filter(field => createIssueFields.includes(field));

        // Filter custom fields that we can't add while creating an issue but should be present in update issue
        const requiredCustomFieldsForTranstition = CUSTOM_FIELDS.filter(field =>
            !createIssueFields.includes(field) && fieldsThatIssueSupports.includes(field)
        );
        // If field names have been added there, we will add them to the request body
        if (customFields.length) {
            const customFieldsBody = await handleCustomFieldsForCreatingIssue(sourceInstance, targetInstance, targetProjectKey, customFields, event.issue, createdIssue, issueType.name);

            requestBody = { ...requestBody, ...customFieldsBody }
        }

        if (requiredCustomFieldsForTranstition.length) {
            const transitionCustomFieldsBody = await handleCustomFieldsForCreatingIssue(sourceInstance, targetInstance, targetProjectKey, requiredCustomFieldsForTranstition, event.issue, createdIssue, issueType.name);

            requestBodyForTransition = { ...requestBodyForTransition, ...transitionCustomFieldsBody }
        }
    }

    // Create a new Issue in Jira Cloud
    const issue = await targetInstance.Issue.createIssue({
        body: {
            fields: requestBody,
        }
    })

    // Extract the newly created Issue key
    const issueKey = issue.key ?? '';

    const attachments = event.issue.fields.attachment ?? [];
    const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
    const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
    let issueAttachments: AttachmentAsResponse[];
    let targetInstanceAttachments: AddIssueAttachmentsResponseOK = [];

    const extractMediaIdHeader = descriptionHasMediaBlocks
        ? {
            'x-stitch-extract-response-media-id': 'true',
            'x-stitch-store-body': 'true'
        }
        : {
            'x-stitch-store-body': 'true'
        };

    // Check if attachments were added to the issue
    if ((FIELDS.includes('Attachment') || descriptionHasMediaBlocks) && attachments.length > 0) {
        // Get the attachments from the issue
        issueAttachments = (await sourceInstance.Issue.getIssue({
            issueIdOrKey: newIssueKey
        })).fields?.attachment ?? [];

        // Loop through attachments and add them to the array
        for (const attachment of issueAttachments) {
            if (attachment.content && attachment.filename) {
                const file = await sourceInstance.fetch(attachment.content, {
                    headers: {
                        ...extractMediaIdHeader
                    }
                });

                // Check if the attachment content response is OK
                if (!file.ok) {
                    // If not, then throw an error
                    throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                }

                // Get the attachment id from a response header
                const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');

                // If no id could be found, throw an error
                if (!storedAttachmentId) {
                    throw new Error('The attachment stored body was not returned');
                }

                if (descriptionHasMediaBlocks) {
                    const mediaId = file.headers.get('x-stitch-response-media-id')
                    if (!mediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for issue ${newIssueKey}}`)
                    }
                    sourceAttachmentIdAndMediaId.push({
                        id: attachment.id,
                        fileName: attachment.filename,
                        mediaId: mediaId,
                        storedAttachmentId
                    })
                }

                // Upload the attachment to the target instance using the same feature
                const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
                    method: 'POST',
                    headers: {
                        'X-Atlassian-Token': 'no-check',
                        'x-stitch-stored-body-id': storedAttachmentId,
                        'x-stitch-stored-body-form-data-file-name': attachment.filename
                    }
                });

                // Check if the attachment upload response is OK
                if (!response.ok) {
                    // If not, then throw an error
                    throw Error(`Unexpected response while uploading attachment: ${response.status}`);
                }

                const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();

                targetInstanceAttachments.push(...targetAttachment);
            }
        }

        if (descriptionHasMediaBlocks) {
            for (const targetInstanceAttachment of targetInstanceAttachments) {
                const file = await targetInstance.fetch(targetInstanceAttachment.content, {
                    headers: {
                        ...extractMediaIdHeader
                    }
                });
                // Check if the attachment content response is OK
                if (!file.ok) {
                    // If not, then throw an error
                    throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                }

                const targetMediaId = file.headers.get('x-stitch-response-media-id',)
                if (!targetMediaId) {
                    throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for issue ${issueKey}}`)
                }

                targetAttachmentIdAndMediaId.push({
                    id: targetInstanceAttachment.id,
                    fileName: targetInstanceAttachment.filename,
                    mediaId: targetMediaId
                })
            }
        }

        if (descriptionHasMediaBlocks) {
            if (!targetInstanceAttachments.length) {
                throw new Error(
                    `${issueKey} does not have any attachments but its description is expecting attachments. ` +
                    `Check if the attachments have been uploaded successfully.`
                )
            }
            if (!targetAttachmentIdAndMediaId.length || !sourceAttachmentIdAndMediaId.length) {
                throw new Error(
                    `${issueKey} does not have the necessary mapping to process the inline description attachments. `
                )
            }

            const newDescription = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, issueDescription);

            await targetInstance.Issue.editIssue({
                issueIdOrKey: issueKey,
                body: {
                    fields: {
                        description: newDescription
                    }
                }
            })
        }
    }

    // Check if newly created Issue has the correct status and find the matching status
    const createdTargetIssue = await targetInstance.Issue.getIssue({
        issueIdOrKey: issueKey
    })

    // CHeck if moved issue has comments and if does, add them to the new issue
    if (FIELDS.includes('comments')) {
        const issueComments = await sourceInstance.Issue.Comment.getComments({
            issueIdOrKey: newIssueKey,
        });

        if (issueComments.total && issueComments.total > 0) {
            const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
            const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

            const matchingComments = [];

            for (const comment of issueComments.comments ?? []) {
                let commentHasMediaBlocks = false;
                let issueCommentBody: doc_node;

                commentHasMediaBlocks = hasMediaBlocks(comment.body);

                if (!commentHasMediaBlocks) {
                    issueCommentBody = {
                        type: 'doc',
                        version: 1,
                        content: [...comment.body?.content ?? []]
                    };
                } else {
                    issueCommentBody = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, comment.body);
                }

                // Check if we have to add original commentator
                if (comment.author?.accountId !== myself.accountId) {
                    // Construct the original user
                    const originalUser = {
                        type: "paragraph",
                        content: [
                            {
                                type: 'text',
                                text: `Original comment by: ${comment.author?.displayName}`
                            }
                        ]
                    };

                    issueCommentBody.content.push(originalUser as any);
                }

                const createdComment = await targetInstance.Issue.Comment.addComment({
                    issueIdOrKey: issueKey,
                    body: {
                        body: issueCommentBody as doc_node
                    }
                })

                const commentIds = {
                    [sourceProjectNumberWithKey]: comment.id,
                    [targetProjectNumberWithKey]: createdComment.id
                }

                matchingComments.push(commentIds)
            }

            // Save the new comment Id's to record storage
            await storage.setValue(newIssueKey, matchingComments)

            // Delete comment Id's from record storage with old Sync Key
            await storage.deleteValue(previousScriptRunnerConnectSyncIssueKey);
        }
    }

    const status = getMatchingValue(sourceInstance, event.issue.fields.status?.name ?? '', STATUS);

    // Check if status is incorrect, and then change it
    if (createdTargetIssue.fields?.status?.name !== status) {
        const transitions = (await targetInstance.Issue.Transition.getTransitions({
            issueIdOrKey: issueKey
        })).transitions ?? [];

        const transitionId = transitions.find(t => t.to?.name === status || t.name === status)?.id ?? ''

        if (!transitionId) {
            throw Error(`Transition for status not found in target instance: ${status}`);
        }

        // Change the status of the issue (workflow transition)
        await targetInstance.Issue.Transition.performTransition({
            issueIdOrKey: issueKey,
            body: {
                transition: {
                    id: transitionId
                },
                fields: requestBodyForTransition
            }
        });
    };

    // Check if issue links exist in FIELDS array and if issue has issue links added
    if (FIELDS.includes('issue links') && createdIssue.fields?.issuelinks?.length) {
        const oldIssueLinks = await storage.valueExists(`issue_link_${previousScriptRunnerConnectSyncIssueKey}`);

        // If there are then delete them
        if (oldIssueLinks) {
            await storage.deleteValue(`issue_link_${previousScriptRunnerConnectSyncIssueKey}`)
        }

        const issueLinks = event.issue.fields.issuelinks ?? [];

        // Go over created issue links
        for (const issueLink of issueLinks) {
            // Extract issue keys
            const outwardLinkedIssueKey = issueLink.outwardIssue?.key;
            const inwardLinkedIssueKey = issueLink.inwardIssue?.key;

            // Check for existing issue links
            const existingIssueLinks = await getIssueLinks(storage, event.issue.key);

            // Check target instance has valid issue link type
            const issueLinkTypes = await targetInstance.Issue.Link.Type.getTypes();
            const issueLinkType = (issueLinkTypes.issueLinkTypes?.find((types) => types.name === issueLink.type.name));

            if (!issueLinkType) {
                throw Error(`Issue Link Type ${issueLink.type.name} doesn't exist in the target project`);
            }

            // Handle outward issue link
            if (outwardLinkedIssueKey) {
                // Find the Sync key for outward issue
                const syncIssueKey = await retrySyncIssueKeyForIssueLinks(context, outwardLinkedIssueKey ?? '', sourceInstance, customFieldName);
                if (syncIssueKey) {
                    // Find the matching issue from target instance
                    const targetIssue = await retrySearchIssueInTargetInstance(context, syncIssueKey ?? '', targetInstance, false)

                    // Create issue link in target instance
                    await targetInstance.Issue.Link.createLink({
                        body: {
                            outwardIssue: {
                                key: targetIssue.issues?.[0].key,
                            },
                            type: {
                                name: issueLink.type.name
                            },
                            inwardIssue: {
                                key: issue.key
                            }
                        },
                    })

                    // Get the issue link id
                    const createdIssueLinkId = (await targetInstance.Issue.getIssue({
                        issueIdOrKey: issue.key ?? '0'
                    })).fields?.issuelinks?.find(x => x.outwardIssue?.key === targetIssue.issues?.[0].key && x.type.name === issueLink.type.name)?.id

                    const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
                    const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
                    // Save issue link mapping into Record Storage
                    const newIssueLink = {
                        [sourceProjectNumberWithKey]: issueLink.id,
                        [targetProjectNumberWithKey]: createdIssueLinkId ?? '0',
                    }

                    // Save issue links to Record Storage
                    const updatedIssueLinks = [...existingIssueLinks, newIssueLink];

                    await setIssueLinks(storage, newIssueKey, updatedIssueLinks);

                    console.log(`Issue link created between ${targetIssue.issues?.[0].key} and ${issue.key}`);
                }
            }

            // Handle inward issue link
            if (inwardLinkedIssueKey) {
                // Find the Sync key for inward issue
                const syncIssueKey = await retrySyncIssueKeyForIssueLinks(context, inwardLinkedIssueKey ?? '', sourceInstance, customFieldName);

                if (syncIssueKey) {
                    // Find the matching issue from target instance
                    const targetIssue = await retrySearchIssueInTargetInstance(context, syncIssueKey ?? '', targetInstance, false)

                    // Create issue link in target instance
                    await targetInstance.Issue.Link.createLink({
                        body: {
                            outwardIssue: {
                                key: issue.key
                            },
                            type: {
                                name: issueLink.type.name
                            },
                            inwardIssue: {
                                key: targetIssue.issues?.[0].key,
                            }
                        },
                    })

                    // Get inward issue for source instance
                    const inwardIssue = await sourceInstance.Issue.getIssue({
                        issueIdOrKey: inwardLinkedIssueKey
                    })

                    // Get the updated target inward issue
                    const targetIssueWithInward = await targetInstance.Issue.getIssue({
                        issueIdOrKey: targetIssue.issues?.[0].key ?? '0'
                    })

                    // Find the create issue link ID 
                    const createdIssueLinkId = targetIssueWithInward?.fields?.issuelinks?.find(link => link.outwardIssue?.key === issue.key && link.type?.name === issueLink.type?.name)?.id;
                    const inwardIssueProjectNumberWithKey = getProjectIdentifier(sourceInstance, inwardIssue.fields?.project?.key ?? '');
                    const targetInwardIssueProjectNumberWithKey = getProjectIdentifier(targetInstance, targetIssueWithInward.fields?.project?.key ?? '');
                    // Get existing inward issue links from storage
                    const existingInwardIssueLinks = await getIssueLinks(storage, syncIssueKey)
                    // Save issue link mapping into Record Storage
                    const newIssueLink = {
                        [inwardIssueProjectNumberWithKey]: issueLink.id,
                        [targetInwardIssueProjectNumberWithKey]: createdIssueLinkId ?? '0'
                    }

                    // Save issue links to Record Storage
                    const updatedIssueLinks = [...existingInwardIssueLinks.filter(il => il[inwardIssueProjectNumberWithKey] !== issueLink.id), newIssueLink];
                    await setIssueLinks(storage, syncIssueKey, updatedIssueLinks);

                    console.log(`Issue link created between ${targetIssue.issues?.[0].key} and ${issue.key}`);
                }
            }
        };
    };

    const issueToBeDeleted = await searchIssue(context, previousScriptRunnerConnectSyncIssueKey, targetInstance, false)

    if (issueToBeDeleted.total && issueToBeDeleted.total > 0) {
        // If found, check for any sub-tasks associated with the issue
        if ((issueToBeDeleted.issues?.[0].fields?.subtasks ?? []).length > 0) {
            const subTasks = issueToBeDeleted.issues?.[0].fields?.subtasks ?? [];

            for (const subTask of subTasks) {
                // Get the sub-task
                const issue = await targetInstance.Issue.getIssue({
                    issueIdOrKey: subTask.key ?? '0',
                    errorStrategy: {
                        handleHttp404Error: () => null // Returns null if the sub-task does not exist
                    }
                })

                // If the sub-task does not exist, skip to the next iteration
                if (!issue) {
                    continue;
                }

                const issueLinks = issue.fields?.issuelinks ?? [];
                const inwardIssueLinks = issueLinks.filter(link => link.inwardIssue);
                const ignoreIssueLinks = []

                // Add inward issue link IDs to ignore if they exist
                if (inwardIssueLinks.length > 0) {
                    for (const issueLink of inwardIssueLinks) {
                        const linkId = issueLink.id

                        ignoreIssueLinks.push(linkId)
                    }
                }

                // If there are issue links to ignore, store them in Record Storage with a TTL
                // This is used so we don't end up accidentallly deleting still valid issue links when multiple issues are being moved
                if (ignoreIssueLinks.length > 0) {
                    await storage.setValue('ISSUE_MOVED', ignoreIssueLinks, {
                        ttl: 5
                    });
                }
            }
        }

        // Proceed to delete the old issue, along with its sub-tasks if any
        await targetInstance.Issue.deleteIssue({
            issueIdOrKey: issueToBeDeleted.issues?.[0].key ?? '0',
            deleteSubtasks: 'true',
        })

        console.log(`Deleted issue ${issueToBeDeleted.issues?.[0].key}`);
    }
}
TypeScriptProject1/OnJiraCloudCommentCreated

import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject1 from '../api/jira/cloud1';
import JiraCloudProject2 from '../api/jira/cloud2';
import createComment from '../CreateComment';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Comment Created event
 *
 * @param event Object that holds Comment Created event data
 * @param context Object that holds function invocation context data
 */
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;
    }
    const { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await createComment(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME);
}
TypeScriptProject1/OnJiraCloudCommentDeleted

import { IssueCommentDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteComment from '../DeleteComment';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Comment Deleted event
 *
 * @param event Object that holds Comment Deleted event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueCommentDeletedEvent, 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 { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await deleteComment(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME);
}
TypeScriptProject1/OnJiraCloudCommentUpdated

import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-cloud/events';
import updateComment from '../UpdateComment';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Comment Updated event
 *
 * @param event Object that holds Comment Updated event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueCommentUpdatedEvent, 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 { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await updateComment(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME);
}
TypeScriptProject1/OnJiraCloudIssueCreated

import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import createIssue from '../CreateIssue';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Issue Created event
 *
 * @param event Object that holds Issue Created event data
 * @param context Object that holds function invocation context data
 */
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;
    }

    const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);

    await createIssue(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_2_ACCOUNT_ID);
}
TypeScriptProject1/OnJiraCloudIssueDeleted

import { IssueDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteIssue from '../DeleteIssue';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Issue Deleted event
 *
 * @param event Object that holds Issue Deleted event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueDeletedEvent, 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 { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await deleteIssue(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME)
}
TypeScriptProject1/OnJiraCloudIssueLinkCreated

import { IssueLinkCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import createIssueLink from '../CreateIssueLink';

/**
 * Entry point to Issue Link Created event
 *
 * @param event Object that holds Issue Link Created event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueLinkCreatedEvent, 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;
    }

    await createIssueLink(context, event, JiraCloudProject1, JiraCloudProject2);
}
TypeScriptProject1/OnJiraCloudIssueLinkDeleted

import { IssueLinkDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteIssueLink from '../DeleteIssueLink';

/**
 * Entry point to Issue Link Deleted event
 *
 * @param event Object that holds Issue Link Deleted event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueLinkDeletedEvent, 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;
    }

    await deleteIssueLink(context, event, JiraCloudProject1, JiraCloudProject2)
}
TypeScriptProject1/OnJiraCloudIssueUpdated

import { IssueUpdatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import updateIssue from '../UpdateIssue';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Issue Updated event
 *
 * @param event Object that holds Issue Updated event data
 * @param context Object that holds function invocation context data
 */
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;
    }

    const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);

    await updateIssue(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_2_ACCOUNT_ID);
}
TypeScriptProject2/OnJiraCloudCommentCreated

import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject1 from '../api/jira/cloud1';
import JiraCloudProject2 from '../api/jira/cloud2';
import createComment from '../CreateComment';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Comment Created event
 *
 * @param event Object that holds Comment Created event data
 * @param context Object that holds function invocation context data
 */
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;
    }

    const { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await createComment(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME);
}
TypeScriptProject2/OnJiraCloudCommentDeleted

import { IssueCommentDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteComment from '../DeleteComment';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Comment Deleted event
 *
 * @param event Object that holds Comment Deleted event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueCommentDeletedEvent, 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 { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await deleteComment(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME);
}
TypeScriptProject2/OnJiraCloudCommentUpdated

import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-cloud/events';
import updateComment from '../UpdateComment';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Comment Updated event
 *
 * @param event Object that holds Comment Updated event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueCommentUpdatedEvent, 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 { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await updateComment(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME);
}
TypeScriptProject2/OnJiraCloudIssueCreated

import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import createIssue from '../CreateIssue';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Issue Created event
 *
 * @param event Object that holds Issue Created event data
 * @param context Object that holds function invocation context data
 */
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;
    }

    const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);

    await createIssue(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_1_ACCOUNT_ID);
}

TypeScriptProject2/OnJiraCloudIssueDeleted

import { IssueDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject1 from '../api/jira/cloud1';
import JiraCloudProject2 from '../api/jira/cloud2';
import deleteIssue from '../DeleteIssue';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Issue Deleted event
 *
 * @param event Object that holds Issue Deleted event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueDeletedEvent, 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 { CUSTOM_FIELD_NAME } = getEnvVars(context);

    await deleteIssue(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME)
}
TypeScriptProject2/OnJiraCloudIssueLinkCreated

import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueLinkCreatedEvent } from '@sr-connect/jira-cloud/events';
import createIssueLink from '../CreateIssueLink';

/**
 * Entry point to Issue Link Created event
 *
 * @param event Object that holds Issue Link Created event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueLinkCreatedEvent, 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;
    }

    await createIssueLink(context, event, JiraCloudProject2, JiraCloudProject1);
}
TypeScriptProject2/OnJiraCloudIssueLinkDeleted

import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueLinkDeletedEvent } from '@sr-connect/jira-cloud/events';
import deleteIssueLink from '../DeleteIssueLink';

/**
 * Entry point to Issue Link Deleted event
 *
 * @param event Object that holds Issue Link Deleted event data
 * @param context Object that holds function invocation context data
 */
export default async function (event: IssueLinkDeletedEvent, 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;
    }

    await deleteIssueLink(context, event, JiraCloudProject2, JiraCloudProject1)
}
TypeScriptProject2/OnJiraCloudIssueUpdated

import { IssueUpdatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import updateIssue from '../UpdateIssue';
import { getEnvVars } from '../UtilsJiraCloud';

/**
 * Entry point to Issue Updated event
 *
 * @param event Object that holds Issue Updated event data
 * @param context Object that holds function invocation context data
 */
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;
    }

    const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);

    await updateIssue(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_1_ACCOUNT_ID);
}
TypeScriptPurgeCache

import { RecordStorage } from '@sr-connect/record-storage';
import { GetKeysOfAllRecordsResponse } from '@sr-connect/record-storage/types';
import { throttleAll } from 'promise-throttle-all';

// How many concurrent deletion jobs to maintain
const CONCURRENCY = 3;

/**
 * This script purges all cached data.
 */
export default async function (event: any, context: Context): Promise<void> {
    const storage = new RecordStorage();

    let lastEvaluatedKey: string | undefined;

    do {
        const keys: GetKeysOfAllRecordsResponse = await storage.getAllKeys();
        lastEvaluatedKey = keys.lastEvaluatedKey;

        await throttleAll(CONCURRENCY, keys.keys.map((key) => async () => await storage.deleteValue(key)));

    } while (lastEvaluatedKey !== undefined);
}
TypeScriptUpdateComment

import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueCommentUpdatedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { AttachmentMediaMapping, getComments, getEnvVars, getMatchingValue, getNewDescriptionWithMedia, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, hasMediaBlocks, searchIssue } from "./UtilsJiraCloud";
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';

export default async function updateComment(context: Context, event: IssueCommentUpdatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customField: string): Promise<void> {
    console.log('Comment Updated event: ', event);

    // Get the current user
    const myself = await sourceInstance.Myself.getCurrentUser();

    // Check that the comment was updated by a different user than the one who set up the integration
    if (myself.accountId !== event.comment.updateAuthor.accountId) {
        const userDisplayName = event.comment.updateAuthor.displayName;
        const accountId = event.comment.updateAuthor.accountId;
        const sourceProjectKey = event.issue.fields.project?.key ?? '';

        const metaData = {
            instance: sourceInstance === JiraCloud ? 1 : 2,
            project: sourceProjectKey,
            issueKey: event.issue.key,
            issueType: event.issue.fields.issuetype?.name,
            user: `${userDisplayName} (${accountId})`,
            commentId: event.comment.id
        };

        console.log('Going to perform', event.webhookEvent, 'event:', metaData);

        const { JIRA_PROJECTS } = getEnvVars(context);

        // Find matching target project key
        const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);

        // Get the ScriptRunner Connect Sync Issue Key from the issue
        const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(event.issue.key, sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);

        if (scriptRunnerConnectSyncIssueKey === null) {
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        const storage = new RecordStorage();

        const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
        const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

        // Get matching comment IDs from Record Storage
        const commentIds = await getComments(storage, scriptRunnerConnectSyncIssueKey);
        const commentId = commentIds.find(ci => ci[sourceProjectNumberWithKey] === event.comment.id)?.[targetProjectNumberWithKey];

        if (!commentId) {
            throw Error(`Couldn't find correct comment ID from Record Storage`);
        };

        // Get the updated comments body
        const commentBody = (await sourceInstance.Issue.Comment.getComment({
            id: event.comment.id,
            issueIdOrKey: event.issue.key
        })).body;

        // Find the matching issue from the target instance
        const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)

        // Check if issue with a matching ScriptRunner Connect Sync Issue Key exists
        if (issues.total === 0) {
            // If not, throw an error
            throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
        };

        // Save the issue key
        const issueKey = issues.issues?.[0].key ?? '';
        const matchingIssueDetails = issues?.issues?.[0];

        // Add orignal user text
        const originalUserText = `Original comment by: ${event.comment.author.displayName}`;

        const originalUser = {
            type: "paragraph",
            content: [
                {
                    type: 'text',
                    text: originalUserText
                }
            ]
        };

        let issueCommentBody: doc_node;
        let commentHasMediaBlocks = false;
        let targetCommentHasMediaBlocks = false;
        let updatedCommentBody: doc_node;
        const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
        const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];

        // Check if the comment has an attachment
        commentHasMediaBlocks = hasMediaBlocks(commentBody);

        // Get matching comment
        const matchingComment = await targetInstance.Issue.Comment.getComment({
            id: commentId,
            issueIdOrKey: issueKey
        })

        if (!commentHasMediaBlocks) {
            // Check if matching comment has an attachment
            targetCommentHasMediaBlocks = hasMediaBlocks(matchingComment.body);

            // Add comment body to request body
            updatedCommentBody = commentBody
        }

        const extractMediaIdHeader = {
            'x-stitch-extract-response-media-id': 'true',
            'x-stitch-store-body': 'true'
        }

        // Check if updated comment or matching comment has an attachment
        if (commentHasMediaBlocks || targetCommentHasMediaBlocks) {
            // Get issue attachments for the source issue
            const issueAttachments = (await sourceInstance.Issue.getIssue({
                issueIdOrKey: event.issue.key,
            })).fields?.attachment ?? [];

            // Extract matching issue attachments
            const targetInstanceAttachments = matchingIssueDetails.fields.attachment ?? [];

            // Loop through attachments and add them to the array
            for (const attachment of issueAttachments) {
                if (attachment.content && attachment.filename) {
                    const file = await sourceInstance.fetch(attachment.content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    // Check if the attachment content response is OK
                    if (!file.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                    }

                    const mediaId = file.headers.get('x-stitch-response-media-id')
                    if (!mediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for created issue ${event.issue.key}}`)
                    }

                    // Get the attachment id from a response header
                    const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');

                    // If no id could be found, throw an error
                    if (!storedAttachmentId) {
                        throw new Error('The attachment stored body was not returned');
                    }
                    sourceAttachmentIdAndMediaId.push({
                        id: attachment.id,
                        fileName: attachment.filename,
                        mediaId: mediaId,
                        storedAttachmentId
                    })
                }
            }

            for (const targetInstanceAttachment of targetInstanceAttachments) {
                const file = await targetInstance.fetch(targetInstanceAttachment.content, {
                    headers: {
                        ...extractMediaIdHeader
                    }
                });

                // Check if the attachment content response is OK
                if (!file.ok) {
                    // If not, then throw an error
                    throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                }

                const targetMediaId = file.headers.get('x-stitch-response-media-id')
                if (!targetMediaId) {
                    throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for updated issue ${issueKey}}`)
                }

                targetAttachmentIdAndMediaId.push({
                    id: targetInstanceAttachment.id,
                    fileName: targetInstanceAttachment.filename,
                    mediaId: targetMediaId
                })
            }

            // Attachments that are missing
            const attachmentsMissingInTargetInstance = sourceAttachmentIdAndMediaId.filter((el) => !targetAttachmentIdAndMediaId.find((file) => file.fileName === el.fileName));

            // Loop throught missing attachments and add them into the array
            for (const missingAttachment of attachmentsMissingInTargetInstance) {
                const attachment = issueAttachments?.find(a => a.id === missingAttachment.id) ?? {}
                // Check if the attachment was added
                if (attachment.content && attachment.filename) {
                    // Upload the attachment to the target instance using the same feature
                    const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
                        method: 'POST',
                        headers: {
                            'X-Atlassian-Token': 'no-check',
                            'x-stitch-stored-body-id': missingAttachment.storedAttachmentId,
                            'x-stitch-stored-body-form-data-file-name': attachment.filename
                        }
                    });

                    // // Check if the attachment content response is OK
                    if (!response.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${response.status}`);
                    }

                    const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();

                    targetInstanceAttachments.push(...targetAttachment);

                    const file = await targetInstance.fetch(targetAttachment?.[0].content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    // Check if the attachment content response is OK
                    if (!file.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                    }

                    const targetMediaId = file.headers.get('x-stitch-response-media-id')
                    if (!targetMediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for updated issue ${issueKey}}`)
                    }

                    targetAttachmentIdAndMediaId.push({
                        id: targetAttachment?.[0].id,
                        fileName: targetAttachment?.[0].filename,
                        mediaId: targetMediaId
                    })
                }
            }

            // Check if the attachment got removed from the comment but the matching comment has one
            if (!commentHasMediaBlocks && targetCommentHasMediaBlocks) {
                const filteredTargetAttachments = targetAttachmentIdAndMediaId.filter(
                    sourceItem => !sourceAttachmentIdAndMediaId.some(targetItem => targetItem.fileName === sourceItem.fileName)
                );

                for (const attachmentToDelete of filteredTargetAttachments) {
                    await targetInstance.Issue.Attachment.deleteAttachment({
                        id: attachmentToDelete.id
                    })
                }
            }

            // Update comment body with the attachment
            updatedCommentBody = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, commentBody);
        }

        // Function to remove original user text
        const removeOriginalUserParagraph = (content: any[], searchText: string): any[] => {
            return content.filter(node => {
                if (node.type === 'paragraph' && node.content) {
                    return !node.content.some((subNode: any) =>
                        subNode.type === 'text' && subNode.text?.includes(searchText)
                    );
                }
                return true;
            });
        };

        // Remove the original user text from the comment if necessary
        if (event.comment.author.accountId === myself.accountId && updatedCommentBody && matchingComment.author.accountId !== myself.accountId) {
            issueCommentBody = {
                ...updatedCommentBody,
                content: removeOriginalUserParagraph(updatedCommentBody.content, 'Original comment by')
            };
        } else if (matchingComment.author.accountId === event.comment.author.accountId) {
            issueCommentBody = {
                ...updatedCommentBody,
                content: [...updatedCommentBody?.content as any]
            }
        } else {
            issueCommentBody = {
                ...updatedCommentBody,
                content: [...updatedCommentBody?.content as any, originalUser]
            }
        }

        // Update the comment
        await targetInstance.Issue.Comment.updateComment({
            id: commentId,
            issueIdOrKey: issueKey,
            body: {
                body: issueCommentBody as doc_node
            }
        });

        console.log(`Comment updated for Issue: ${issueKey}`)

        // Additional check if after comment update attachments should be moved from source or target instance
        if (commentHasMediaBlocks || targetCommentHasMediaBlocks) {
            const updatedIssueAttachments = (await targetInstance.Issue.getIssue({
                issueIdOrKey: issueKey
            })).fields.attachment ?? []

            // Check if updated issue and the source issue have the same amount of attachments
            if (updatedIssueAttachments.length !== sourceAttachmentIdAndMediaId.length) {
                // Check if target issue has more attachments than the source issue
                if (updatedIssueAttachments.length > sourceAttachmentIdAndMediaId.length) {
                    // If so, filter out attachments that should get deleted
                    const filteredTargetAttachments = updatedIssueAttachments.filter(
                        sourceItem => !sourceAttachmentIdAndMediaId.some(targetItem => targetItem.fileName === sourceItem.filename)
                    );

                    for (const attachmentToDelete of filteredTargetAttachments) {
                        await targetInstance.Issue.Attachment.deleteAttachment({
                            id: attachmentToDelete.id
                        })
                    }
                }

                // Check if target issue has less attachments than the source issue
                if (updatedIssueAttachments.length < sourceAttachmentIdAndMediaId.length) {
                    // If so, filter out attachments that should get deleted
                    const filteredSourceAttachments = sourceAttachmentIdAndMediaId.filter(
                        sourceItem => !updatedIssueAttachments.some(targetItem => targetItem.filename === sourceItem.fileName)
                    );

                    for (const attachmentToDelete of filteredSourceAttachments) {
                        await sourceInstance.Issue.Attachment.deleteAttachment({
                            id: attachmentToDelete.id
                        })
                    }
                }
            }
        }
    }
}
TypeScriptUpdateIssue

import JiraCloud from './api/jira/cloud1';
import { IssueFieldsUpdate } from "@managed-api/jira-cloud-v3-core/definitions/IssueFields";
import { UserDetailsAsResponse } from "@managed-api/jira-cloud-v3-core/definitions/UserDetailsAsResponse";
import { GetIssueResponseOK } from "@managed-api/jira-cloud-v3-core/types/issue";
import { SearchIssuesByJqlResponseOK } from "@managed-api/jira-cloud-v3-core/types/issue/search";
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueUpdatedEvent } from "@sr-connect/jira-cloud/events";
import { AttachmentMediaMapping, checkAccountIds, checkUserFieldOption, getCustomField, getEnvVars, getFieldAndMatchingValue, getJiraPriority, getMatchingValue, getOriginalUserToCustomFieldId, searchIssue, stringToArray, hasMediaBlocks, getNewDescriptionWithMedia, retrySearchIssueInTargetInstance } from "./UtilsJiraCloud";
import moveIssue from './MoveIssue';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';

export default async function updateIssue(context: Context, event: IssueUpdatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string, targetProjectPredifinedUserAccountId?: string): Promise<void> {
    console.log('Issue Updated event: ', event);

    // Get the current user
    let myself = await sourceInstance.Myself.getCurrentUser();

    const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, IMPACT, CHANGE_REASON, CHANGE_RISK, CHANGE_TYPE, RetryConfigurations, MOVE_ISSUES_BETWEEN_PROJECTS, UserFieldOptions } = getEnvVars(context);

    // Check if the current user does not match the person who committed the update and the changelog includes one of the fields that we're interested in
    if ((myself.accountId !== event.user.accountId) && event.changelog?.items.some(cl => (FIELDS.includes(cl.field) || CUSTOM_FIELDS.includes(cl.field)))) {


        // Check if issue got moved
        if (event.issue_event_type_name as any === 'issue_moved') {
            // Check if we MOVE_ISSUES_BETWEEN_PROJECTS is set to true
            if (MOVE_ISSUES_BETWEEN_PROJECTS) {
                // If issue got moved and MOVE_ISSUES_BETWEEN_PROJECTS is true, run moveIssue script
                return await moveIssue(context, event, sourceInstance, targetInstance, customFieldName, myself, targetProjectPredifinedUserAccountId);
            }

            return;
        }

        const userDisplayName = event.user.displayName;
        const accountId = event.user.accountId;
        const sourceProjectKey = event.issue.fields.project?.key ?? '';

        const metaData = {
            instance: sourceInstance === JiraCloud ? 1 : 2,
            project: sourceProjectKey,
            issueKey: event.issue.key,
            issueId: event.issue.id,
            issueType: event.issue.fields.issuetype?.name,
            user: `${userDisplayName} (${accountId})`,
        };

        console.log('Going to perform', event.webhookEvent, 'event:', metaData);

        // Find matching target project key
        const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);

        const customField = await getCustomField(sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.issuetype?.name);

        let issue: GetIssueResponseOK | undefined;
        let scriptRunnerConnectSyncIssueKey = null;

        // Retry logic for getting sync key from updated issue
        for (let i = 0; i < RetryConfigurations.MAX_RETRY_ATTEMPTS + 1; i++) {
            // Get the updated issue
            issue = await sourceInstance.Issue.getIssue({
                issueIdOrKey: event.issue.key,
            });

            if (issue?.fields?.[customField] !== null) {
                // Extract the ScriptRunner Connect Sync Issue Key
                scriptRunnerConnectSyncIssueKey = issue?.fields?.[customField];
                break;
            } else {
                console.log('No ScriptRunner Connect Sync Issue Key found on the updated issue. Retrying...');
                await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));
            }
        }

        if (scriptRunnerConnectSyncIssueKey === null) {
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        let issues: SearchIssuesByJqlResponseOK | undefined;

        // Find the matching issue from the target instance
        issues = await retrySearchIssueInTargetInstance(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)

        // Extract the issue that needs updating
        const matchingIssueDetails = issues?.issues?.[0];
        const issueKey = matchingIssueDetails.key ?? '';


        // Extract the fields that was updated
        const eventItems = event.changelog?.items.filter(item => FIELDS.includes(item.field)) ?? [];

        // Object that contains changes that need to be updated on the target instance
        let requestBody: IssueFieldsUpdate = {};

        // Find the project to use based on pre-defined project key
        const project = await targetInstance.Project.getProject({
            projectIdOrKey: targetProjectKey
        });

        // Check if the project was found
        if (!project) {
            // If not, then throw an error
            throw Error(`Target project not found: ${targetProjectKey}`);
        }

        let statusChanged = false;

        // Get the matching issue
        const matchingIssue = await targetInstance.Issue.getIssue({
            issueIdOrKey: issueKey
        });

        const updatedIssueType = event.issue.fields.issuetype?.name ?? ''
        const mappedIssueType = getMatchingValue(sourceInstance, updatedIssueType, ISSUE_TYPES);

        // Find all the issue types in target instance
        const issueTypes = await targetInstance.Issue.Type.getTypesForProject({
            projectId: +(project.id ?? 0) // + sign converts the string to number
        });

        // Find the issue type
        const issueType = issueTypes.find(it => it.name?.toLowerCase().replace(/\s/g, '') === mappedIssueType.toLowerCase().replace(/\s/g, ''));

        // Add fields and their values to the request body
        for (const eventItem of eventItems) {
            switch (eventItem.field) {
                case 'summary':
                    // Add summary to request body
                    requestBody.summary = eventItem.toString ?? '';
                    break;
                case 'issuetype':
                    // Check if the issue type was found
                    if (!issueType) {
                        // If not, throw an error
                        throw Error(`Issue type not found in target instance: ${mappedIssueType}`);
                    }

                    // Add issue type to request body
                    requestBody.issuetype = {
                        id: issueType.id ?? ''
                    };
                    break;
                case 'reporter':
                    const reporterAccountId = eventItem.to;

                    // Function that check USER_FIELD_OPTION value and handles the field appropriately
                    const reporterUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, reporterAccountId ?? '', myself.accountId ?? '', eventItem.field, eventItem.toString ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name, matchingIssue);

                    if (reporterUserFieldOption) {
                        requestBody = { ...requestBody, ...reporterUserFieldOption }
                    }
                    break;
                case 'assignee':
                    const assigneeAccountId = eventItem.to;
                    if (assigneeAccountId) {
                        // Function that check USER_FIELD_OPTION value and handles the field appropriately
                        const assigneeUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, assigneeAccountId ?? '', myself.accountId ?? '', eventItem.field, eventItem.toString ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name, matchingIssue);

                        if (assigneeUserFieldOption) {
                            requestBody = { ...requestBody, ...assigneeUserFieldOption }
                        }
                    } else {
                        if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD') {
                            const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, 'assignee', targetProjectKey, issueType.name);
                            requestBody[customFieldId] = null;
                        }
                        requestBody.assignee = null;
                    }
                    break;
                case 'priority':
                    const updateIssuePriority = eventItem.toString ?? '';

                    // Find the matching priority
                    const matchingPiority = getMatchingValue(sourceInstance, updateIssuePriority, PRIORITY);

                    const priority = await getJiraPriority(matchingPiority, targetInstance,)

                    // Check if priority was found
                    if (!priority) {
                        // If not, throw an error
                        throw Error(`Priority not found in target instance: ${priority}`)
                    }

                    // Add the priority to request body
                    requestBody.priority = {
                        id: priority.id ?? '0'
                    }
                    break;
                case 'Impact':
                    // Find the field and matching value in target instance
                    const impactValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, IMPACT, eventItem.field, targetProjectKey, issueType.name);

                    if (eventItem.to) {
                        // Add the Impact field to request body
                        requestBody[impactValues.field] = {
                            value: impactValues.matchingValue
                        };
                    } else {
                        requestBody[impactValues.field] = null;
                    }
                    break;
                case 'Change reason':
                    // Find the field and matching value in target instance
                    const changeReasonValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, CHANGE_REASON, eventItem.field, targetProjectKey, issueType.name);

                    if (eventItem.to) {
                        // Add the Change reason field to request body
                        requestBody[changeReasonValues.field] = {
                            value: changeReasonValues.matchingValue
                        };
                    } else {
                        requestBody[changeReasonValues.field] = null;
                    }
                    break;
                case 'Change type':
                    // Find the field and matching value in target instance
                    const changeTypeValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, CHANGE_TYPE, eventItem.field, targetProjectKey, issueType.name);

                    if (eventItem.to) {
                        // Add the Change type field to request body
                        requestBody[changeTypeValues.field] = {
                            value: changeTypeValues.matchingValue
                        };
                    } else {
                        requestBody[changeTypeValues.field] = null;
                    }
                    break;
                case 'Change risk':
                    // Find the field and matching value in target instance
                    const changeRiskValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, CHANGE_RISK, eventItem.field, targetProjectKey, issueType.name);

                    if (eventItem.to) {
                        // Add the Change risk field to request body
                        requestBody[changeRiskValues.field] = {
                            value: changeRiskValues.matchingValue
                        };
                    } else {
                        requestBody[changeRiskValues.field] = null;
                    }
                    break;
                case 'labels':
                    requestBody.labels = event.issue.fields.labels;
                    break;
                case 'duedate':
                    requestBody.duedate = event.issue.fields.duedate;
                    break;
                case 'IssueParentAssociation':
                    if (eventItem.toString) {
                        const eventIssueType = event.issue.fields.issuetype?.name;
                        // Check if issueType is Sub-task and if it got removed from parent
                        if ((eventIssueType === 'Sub-task' || eventIssueType === 'Subtask') && eventItem.toString === null) {
                            break;
                        }

                        // Get the parent or epic issue
                        const parentOrEpicIssue = await sourceInstance.Issue.getIssue({
                            issueIdOrKey: eventItem.toString ?? '',
                            fields: [customField]
                        })

                        // Extract the epic issue sync key
                        const syncKey = parentOrEpicIssue.fields?.[customField];

                        // Find the matching epic or parent issue from target instance
                        const matchingParentOrEpicIssue = await searchIssue(context, syncKey, targetInstance, false)

                        if (matchingParentOrEpicIssue.issues?.length === 0) {
                            throw Error('Matching Epic Issue not found')
                        }

                        // Check if subtask has been added to the issue already
                        if (matchingParentOrEpicIssue.issues?.[0].fields?.subtasks?.some(s => s.key === issues?.issues?.[0].key)) {
                            console.log('Sub-task is already present');
                            break;
                        }

                        requestBody.parent = {
                            key: matchingParentOrEpicIssue.issues?.[0].key
                        }
                    } else {
                        requestBody.parent = null
                    }
                    break;
                default:
                    break;
            }
        }

        // Filter custom fields that were updated and exist in the CUSTOM_FIELDS array in Values script
        const updatedCustomFields = event.changelog?.items.filter(cl => CUSTOM_FIELDS.includes(cl.field));

        // Check if any custom field got updated
        if (updatedCustomFields.length) {
            // Map through updated custom fields
            for (const customField of updatedCustomFields) {
                const sourceInstanceCustomField = customField.field
                const sourceInstanceCustomFieldId = customField.fieldId

                // Find the custom field value in the issue
                const value = issue?.fields?.[sourceInstanceCustomFieldId];

                // Find the custom field from target instance
                const targetInstanceCustomFieldId = await getCustomField(targetInstance, sourceInstanceCustomField, targetProjectKey, issueType.name);

                // Check what type of value custom field has
                switch (true) {
                    // Check if the value is null, string or a number
                    case value === null || typeof value === 'string' || typeof value === 'number':
                        requestBody[targetInstanceCustomFieldId] = value;
                        break;
                    // Check if the value is an array
                    case Array.isArray(value):
                        // Check if there is an object in the array
                        if (typeof value[0] === 'object') {
                            // Check if the object has a value propert
                            if (value[0].hasOwnProperty('value')) {
                                const values = (value as CustomFieldBody[]).map(field => ({ value: field.value }));
                                requestBody[targetInstanceCustomFieldId] = values;
                                // Check if the object has a accountId property
                            } else if (value[0].hasOwnProperty('accountId')) {
                                const targetIssue = await targetInstance.Issue.getIssue({
                                    issueIdOrKey: issueKey,
                                    fields: [targetInstanceCustomFieldId]
                                })
                                const currentlyAddedUsersOnTargetInstance: UserDetailsAsResponse[] | null = targetIssue.fields?.[targetInstanceCustomFieldId];

                                // Checks if the field is null in the other instance
                                if (currentlyAddedUsersOnTargetInstance === null) {
                                    const listOfAccountIds = await stringToArray(customField.to ?? '');
                                    const accountsToAdd = await checkAccountIds(targetInstance, targetProjectKey, listOfAccountIds);

                                    // Adds valid account IDs to the request body
                                    requestBody[targetInstanceCustomFieldId] = accountsToAdd.map(value => ({ accountId: value }));
                                } else {
                                    // Extract the original Account IDs from the issue
                                    const originalListOfAccountIds = await stringToArray(customField.from ?? '')

                                    // Extract the updated Account IDs from the issue
                                    const listOfAccountIds = await stringToArray(customField.to ?? '');

                                    // Filter which accounts got removed and which added
                                    const removedAccountIds = originalListOfAccountIds.filter(id => !listOfAccountIds.includes(id));
                                    const addedAccountIds = listOfAccountIds.filter(id => !originalListOfAccountIds.includes(id));

                                    // Check which accounts are currently added in the target instance
                                    const currentlyAddedAccountIdsOnTargetInstance = currentlyAddedUsersOnTargetInstance.map(field => field.accountId ?? '');

                                    let accountsToRemove: string[] = [];
                                    let accountsToAdd: string[] = []

                                    // If any account IDs got removed, add them to the accountsToRemove array
                                    if (removedAccountIds.length) {
                                        accountsToRemove = await checkAccountIds(targetInstance, targetProjectKey, removedAccountIds);
                                    }

                                    // If any account IDs got added, add them to the accountsToAdd array
                                    if (addedAccountIds.length) {
                                        accountsToAdd = await checkAccountIds(targetInstance, targetProjectKey, addedAccountIds);
                                    }

                                    // New list of account IDs, filtering out accounts that need to be removed and adding new ones
                                    const newList = currentlyAddedAccountIdsOnTargetInstance?.filter(item => !accountsToRemove.includes(item));
                                    newList.push(...accountsToAdd);

                                    const accountIds = newList.map(value => ({ accountId: value }));

                                    // Add necessary account IDs to the request body
                                    requestBody[targetInstanceCustomFieldId] = accountIds;
                                }
                            }
                        } else {
                            requestBody[targetInstanceCustomFieldId] = value;
                        }
                        break;
                    // Check if value is an object
                    case typeof value === 'object':
                        // Check if value has property value
                        if (value.hasOwnProperty('value')) {
                            requestBody[targetInstanceCustomFieldId] = {
                                value: value.value
                            }
                        } else {
                            requestBody[targetInstanceCustomFieldId] = value
                        }
                        break;
                    default:
                        break;
                }
            }
        }

        //Process description and attachments
        let descriptionHasMediaBlocks = false;
        let issueDescription: doc_node;
        const eventDescription = eventItems.find((el) => el.field === 'description');
        if (eventDescription) {
            if (!eventDescription.toString) {
                requestBody.description = null;
            } else {
                issueDescription = (await sourceInstance.Issue.getIssue({
                    issueIdOrKey: event.issue.key,
                    fields: ['description']
                })).fields?.description;

                descriptionHasMediaBlocks = hasMediaBlocks(issueDescription);
                if (descriptionHasMediaBlocks) {
                    // Will be processing them after adding the attachments
                    requestBody.description = null;
                } else {
                    // Add description to issue fields
                    requestBody.description = issueDescription;
                }
            }
        }

        const extractMediaIdHeader = descriptionHasMediaBlocks
            ? {
                'x-stitch-extract-response-media-id': 'true',
                'x-stitch-store-body': 'true'
            }
            : {
                'x-stitch-store-body': 'true'
            };

        //Mapping of attachments to media id for use in the in-line attachments
        const sourceAttachmentsWithMediaId: AttachmentMediaMapping[] = [];
        const targetAttachmentsWithMediaId: AttachmentMediaMapping[] = [];

        const eventAttachments = eventItems.filter((el) => el.field === 'Attachment');
        const sourceAttachments = issue.fields.attachment ?? [];
        const deletedSourceAttachmentIds: string[] = [];
        const targetInstanceAttachments = matchingIssueDetails.fields.attachment ?? [];

        if (sourceAttachments.length === 0 && descriptionHasMediaBlocks) {
            throw new Error(
                `${event.issue.key} does not have any attachments but the description has inline attachments. ` +
                `Verify the issue to ensure it is correctly setup.`
            );
        }

        for (const eventItem of eventAttachments) {
            if (eventItem.to) {
                const attachmentId = eventItem.to ?? '';
                const attachment = sourceAttachments?.find(a => a.id === attachmentId) ?? {}

                // Check if the attachment was added
                if (attachment.content && attachment.filename) {
                    // Add the attachment
                    const file = await sourceInstance.fetch(attachment.content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    // Check if the attachment content response is OK
                    if (!file.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                    }

                    // Get the attachment id from a response header
                    const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');

                    // If no id could be found, throw an error
                    if (!storedAttachmentId) {
                        throw new Error('The attachment stored body was not returned');
                    }

                    if (descriptionHasMediaBlocks) {
                        const mediaId = file.headers.get('x-stitch-response-media-id')

                        if (!mediaId) {
                            throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for the updated issue ${issueKey}}`)
                        }

                        sourceAttachmentsWithMediaId.push({
                            id: attachment.id,
                            fileName: attachment.filename,
                            mediaId: mediaId,
                            storedAttachmentId
                        })
                    }

                    // Upload the attachment to the target instance using the same feature
                    const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
                        method: 'POST',
                        headers: {
                            'X-Atlassian-Token': 'no-check',
                            'x-stitch-stored-body-id': storedAttachmentId,
                            'x-stitch-stored-body-form-data-file-name': attachment.filename
                        }
                    });

                    // Check if the attachment upload response is OK
                    if (!response.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${response.status}`);
                    }

                    const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();

                    targetInstanceAttachments.push(...targetAttachment);

                    console.log(`Attachment ${attachment.filename} has been added to target issue ${issueKey}`);
                }
            } else {
                deletedSourceAttachmentIds.push(eventItem.fromString);

                // Find matching attachment ID
                const attachmentId = (matchingIssueDetails.fields.attachment?.find(a => a.filename === eventItem.fromString))?.id ?? '';

                if (!attachmentId) {
                    throw Error('Matching attachment ID was not found from target instance');
                }

                // Delete the attachment
                await targetInstance.Issue.Attachment.deleteAttachment({
                    id: attachmentId
                })
            }
        }

        if (!sourceAttachments.length && targetInstanceAttachments.length > 0) {
            console.log(
                `The target instance has ${targetInstanceAttachments.length} attachment(s) which are not in the source instance. ` +
                `These will be deleted.`,
                { attachments: targetInstanceAttachments.map((el) => el.filename) }
            )
            for (const attachmentToDelete of targetInstanceAttachments) {
                await targetInstance.Issue.Attachment.deleteAttachment({
                    id: attachmentToDelete.id
                })
            }
        }

        if (descriptionHasMediaBlocks) {
            // Attachments that are not in the change log and have not been deleted in the source instance
            const sourceAttachmentsWithoutMediaId = sourceAttachments.filter((el) => !sourceAttachmentsWithMediaId.find((file) => file.id === el.id) && !deletedSourceAttachmentIds.includes(el.id));
            for (const sourceAttachmentWithoutMediaId of sourceAttachmentsWithoutMediaId) {
                if (sourceAttachmentWithoutMediaId.filename && sourceAttachmentWithoutMediaId.content) {
                    const file = await sourceInstance.fetch(sourceAttachmentWithoutMediaId.content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    // Check if the attachment content response is OK
                    if (!file.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                    }

                    const mediaId = file.headers.get('x-stitch-response-media-id')

                    if (!mediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${sourceAttachmentWithoutMediaId.filename} on the updated issue`)
                    }

                    // Get the attachment id from a response header
                    const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');

                    // If no id could be found, throw an error
                    if (!storedAttachmentId) {
                        throw new Error('The attachment stored body was not returned');
                    }

                    sourceAttachmentsWithMediaId.push({
                        id: `${sourceAttachmentWithoutMediaId.id}`,
                        fileName: sourceAttachmentWithoutMediaId.filename,
                        mediaId: mediaId,
                        storedAttachmentId
                    })
                }
            }

            for (const targetInstanceAttachment of targetInstanceAttachments) {
                const file = await targetInstance.fetch(targetInstanceAttachment.content, {
                    headers: {
                        ...extractMediaIdHeader
                    }
                });

                // Check if the attachment content response is OK
                if (!file.ok) {
                    // If not, then throw an error
                    throw Error(`Unexpected response while downloading attachment: ${file.status}`);
                }

                const targetMediaId = file.headers.get('x-stitch-response-media-id')

                if (!targetMediaId) {
                    throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for updated issue ${issueKey}}`)
                }

                targetAttachmentsWithMediaId.push({
                    id: targetInstanceAttachment.id,
                    fileName: targetInstanceAttachment.filename,
                    mediaId: targetMediaId
                })
            }

            // Attachments that are part of the description which didn't come through via the changelog
            const attachmentsMissingInTargetInstance = sourceAttachmentsWithMediaId.filter((el) => !targetAttachmentsWithMediaId.find((file) => file.fileName === el.fileName));
            for (const missingAttachment of attachmentsMissingInTargetInstance) {
                const attachment = sourceAttachments?.find(a => a.id === missingAttachment.id) ?? {}
                // Check if the attachment was added
                if (attachment.content && attachment.filename) {

                    // Upload the attachment to the target instance using the same feature
                    const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
                        method: 'POST',
                        headers: {
                            'X-Atlassian-Token': 'no-check',
                            'x-stitch-stored-body-id': missingAttachment.storedAttachmentId,
                            'x-stitch-stored-body-form-data-file-name': attachment.filename
                        }
                    });

                    // Check if the attachment content response is OK
                    if (!response.ok) {
                        // If not, then throw an error
                        throw Error(`Unexpected response while downloading attachment: ${response.status}`);
                    }

                    const targetAttachment: AddIssueAttachmentsResponseOK = await response.json()

                    // Adding it to targetInstanceAttachemnt to update the attachments that are currently in the issue
                    targetInstanceAttachments.push(...targetAttachment);

                    // Fetch the attachment so we can get its mediaId
                    const file = await targetInstance.fetch(targetAttachment[0].content, {
                        headers: {
                            ...extractMediaIdHeader
                        }
                    });

                    const mediaId = file.headers.get('x-stitch-response-media-id')

                    if (!mediaId) {
                        throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for updated issue ${issueKey}}`)
                    }

                    targetAttachmentsWithMediaId.push({
                        id: targetAttachment[0].id,
                        fileName: targetAttachment[0].filename,
                        mediaId: mediaId
                    })

                    console.log(`Missing attachment with file name ${attachment.filename} added to issue ${issueKey}`)
                }
            }

            const attachmentsStillMissingInTarget = sourceAttachmentsWithMediaId.filter((el) => !targetAttachmentsWithMediaId.find((file) => file.fileName === el.fileName));
            if (attachmentsStillMissingInTarget.length > 0) {
                console.error('Some attachments are still missing in target instance: ', attachmentsStillMissingInTarget)
                throw new Error('Some attachments are still missing in target instance')
            }

            if (!targetInstanceAttachments.length) {
                throw new Error(
                    `${issueKey} does not have any attachments but its description is expecting attachments. ` +
                    `Check if the attachments have been uploaded successfully.`
                )
            }
            if (!targetAttachmentsWithMediaId.length || !sourceAttachmentsWithMediaId.length) {
                throw new Error(
                    `${issueKey} does not have the necessary mapping to process the inline description attachments. `
                )
            }
            requestBody.description = getNewDescriptionWithMedia(sourceAttachmentsWithMediaId, targetAttachmentsWithMediaId, issueDescription);
        }

        const updatedStatus = eventItems.find(change => change.field === 'status');

        if (updatedStatus) {
            const updateIssueStatus = event.issue.fields.status?.name ?? ''

            // Find the matching state
            const status = getMatchingValue(sourceInstance, updateIssueStatus, STATUS);

            const transitions = (await targetInstance.Issue.Transition.getTransitions({
                issueIdOrKey: issueKey,
                expand: 'transitions.fields'
            })).transitions ?? [];

            // Check for correct transition Id
            const correctTransition = transitions.find(t => t.to?.name === status || t.name === status);
            const transitionId = correctTransition?.id;

            if (!transitionId) {
                throw Error(`Transition ID not found in target instance for status: ${status}. Check the STATUS mapping or the transition might not
                be available for the current status`);
            }

            // Finally change the issue status (workflow transition)
            await targetInstance.Issue.Transition.performTransition({
                issueIdOrKey: issueKey,
                body: {
                    transition: {
                        id: transitionId
                    },
                    fields: requestBody
                }
            });
        }

        console.log('Issue fields', requestBody);

        // If there is any fields in requestBody, update the issue in target instance
        if (Object.keys(requestBody).length > 0 && !statusChanged) {
            await targetInstance.Issue.editIssue({
                issueIdOrKey: issueKey,
                body: {
                    fields: requestBody,
                }
            });
            console.log(`Updated issue: ${issueKey}`);
        }
    }
}


interface CustomFieldBody {
    value: string;
}
TypeScriptUtilsJiraCloud

import JiraCloud1 from './api/jira/cloud1';
import { SearchIssuePrioritiesResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/priority';
import { RecordStorage } from '@sr-connect/record-storage';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { Issue } from '@sr-connect/jira-cloud/types/issue';
import { IssueFieldsUpdate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { GetIssueResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { mediaInline_node, media_node } from '@sr-connect/jira-cloud/types/adf';
import { SearchIssuesByJqlResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/search';

/**
 * Function that finds the priority from Jira Cloud (traverses paginated results) 
 */
export async function getJiraPriority(value: string, instance: JiraCloudApi) {
    const maxResults = '50';
    let startAt = '0';
    let priorities: SearchIssuePrioritiesResponseOK;

    do {
        priorities = await instance.Issue.Priority.searchPriorities({
            startAt,
            maxResults
        });

        const priority = priorities.values?.find(p => p.name === value);

        if (priority) {
            return priority;
        }

        if (priorities.values?.length === +maxResults) {
            startAt = (+startAt + +maxResults).toString();
        } else {
            startAt = '0'
        }
    } while (+startAt > 0);
}

/**
 * Function that tries to retrieve issue comments from the cache (using RecordStorage)
 */
export async function getComments(storage: RecordStorage, scriptRunnerConnectIssueKey: string) {
    return await storage.getValue<Record<string, string>[]>(scriptRunnerConnectIssueKey) ?? [];
}

/**
 * Function that tries to retrieve issue links from the cache (using RecordStorage)
 */
export async function getIssueLinks(storage: RecordStorage, scriptRunnerConnectIssueKey: string) {
    return await storage.getValue<Record<string, string>[]>(`issue_link_${scriptRunnerConnectIssueKey}`) ?? [];
}
/**
 * Function that tries to set issue links to the cache (using RecordStorage)
 */
export async function setIssueLinks(storage: RecordStorage, scriptRunnerConnectIssueKey: string, values: { [key: string]: string }[]) {
    return await storage.setValue(`issue_link_${scriptRunnerConnectIssueKey}`, values);
}

/**
 * Function that finds the ScriptRunner Connect Sync Issue Key custom field value from the issue
 */
export async function getScriptRunnerConnectSyncIssueKey(issueKey: string, instance: JiraCloudApi, customFieldName: string, projectKey: string, issueType: string): Promise<string | null> {
    const customField = await getCustomField(instance, customFieldName, projectKey, issueType);

    const scriptRunnerConnectSyncIssueKey = (await instance.Issue.getIssue({
        issueIdOrKey: issueKey,
        fields: [customField],
    })).fields?.[customField]

    return scriptRunnerConnectSyncIssueKey
}

/**
 * Function that finds the issue using ScriptRunner Connect Sync Issue Key custom field
 */
export async function searchIssue(context: Context, issueKey: string, instance: JiraCloudApi, includeAttachments: boolean) {
    const { CUSTOM_FIELD_NAME } = getEnvVars(context);

    const includedFields = includeAttachments ? ['attachment'] : [];
    const issues = await instance.Issue.Search.searchByJql({
        body: {
            jql: `"${CUSTOM_FIELD_NAME}" ~ "${issueKey}"`,
            fields: includedFields
        }
    })

    return issues;
}

/**
 * Function that finds matching value for STATUS, PRIORITY, ISSUE TYPE, IMPACT, CHANGE_REASON, CHANGE_TYPE, CHANGE_RISK
 */
export function getMatchingValue(instance: JiraCloudApi, value: string, attribute: Record<string, string>) {
    const matchingValue = instance === JiraCloud1 ? attribute[value] : Object.keys(attribute).find(key => attribute[key] === value) ?? '';

    return matchingValue
}

/**
 * Function that finds the ID for sync custom field
 */
export async function getCustomField(instance: JiraCloudApi, customFieldName: string, projectKey: string, issueType: string): Promise<string | undefined> {
    const customField = (await instance.Issue.Field.getFields()).filter(f => f.name === customFieldName);

    if (customField.length === 0) {
        throw Error(`Custom field for instance not found: ${customFieldName}`)
    }

    if (customField.length > 1) {
        const createMetaData = (await instance.Issue.Metadata.getCreateMetadata({
            projectKeys: [projectKey],
            issuetypeNames: [issueType],
            expand: 'projects.issuetypes.fields',
        }))

        const matchingFields = []

        if (createMetaData.projects.length > 0) {
            for (const [fieldKey, field] of Object.entries(createMetaData.projects?.[0].issuetypes?.[0].fields)) {
                if (field.name === customFieldName) {
                    matchingFields.push({
                        id: fieldKey,
                        fieldName: field.name
                    });
                }
            }
        }

        if (matchingFields.length > 1) {
            throw Error(`More than one custom field was found with this name: ${customFieldName}`);
        } else if (matchingFields.length === 0) {
            console.log(`Custom field ${customFieldName} is not assignable to project ${projectKey}, issue type: ${issueType}.`)
            return;
        } else {
            return matchingFields?.[0].id ?? '';
        }
    }

    return customField?.[0].id ?? '';
}


/**
 * Function that finds the ID for custom fields matching value in target instance
 */
export async function getFieldAndMatchingValue(targetInstance: JiraCloudApi, sourceInstance: JiraCloudApi, eventValue: string | null, valuesType: ValuesType, fieldName: string, projectKey: string, issueType: string): Promise<{ field: string; matchingValue: string; }> {
    const matchingValue = getMatchingValue(sourceInstance, eventValue ?? '', valuesType);

    // Find field name from target instance
    const field = await getCustomField(targetInstance, fieldName, projectKey, issueType);

    if (!field) {
        throw Error(`${fieldName} field does not exist`)
    }

    return {
        field,
        matchingValue
    }
}

/**
 * Function that changes specific string to array
 */
export async function stringToArray(originalString: string): Promise<string[]> {
    const newArray = originalString.match(/[^[\],\s]+/g) ?? [];

    const updatedArray = newArray.map(value => `${value}`);

    return updatedArray
}

/**
 * Function that checks if account IDs can be added to issue
 */
export async function checkAccountIds(targetInstance: JiraCloudApi, targetProjectKey: string, accountIds: string[]): Promise<string[]> {
    let validAccountIds: string[] = [];

    await Promise.all(accountIds.map(async (field) => {
        const user = await targetInstance.User.Search.findUsersAssignableToIssues({
            project: targetProjectKey,
            accountId: field
        })

        if (user.length) {
            validAccountIds.push(user?.[0].accountId ?? '')
        }
    }))

    return validAccountIds
}

/**
 * Function that checks userFieldOption value and changes the field accordingly
 */
export async function checkUserFieldOption(context: Context, targetInstance: JiraCloudApi, targetProjectKey: string, accountId: string, integrationUserAccountId: string, fieldName: string, displayName: string, targetProjectPredifinedUserAccountId: string, issueType: string, matchingIssue?: GetIssueResponseOK): Promise<UserField | undefined> {
    const { UserFieldOptions } = getEnvVars(context);

    switch (UserFieldOptions.USER_FIELD_OPTION) {
        case 'ORIGINAL_USER':
            const isUserAssignable = await findAssignableUser(targetInstance, targetProjectKey, accountId);

            // Check if user is assignable or not
            if (!isUserAssignable) {
                // Handle fallback for non-assignable user
                return await checkUserFieldFallbackValue(
                    context,
                    targetInstance,
                    targetProjectKey,
                    integrationUserAccountId,
                    fieldName,
                    displayName,
                    targetProjectPredifinedUserAccountId,
                    issueType
                );
            }

            // Check if USER_FIELD_FALLBACK_OPTION is COPY_ORIGINAL_USER_TO_CUSTOM_FIELD and if matching issue is passed
            if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' && matchingIssue) {
                const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);
                const customFieldValue = matchingIssue?.fields?.[customFieldId];

                return customFieldValue
                    ? {
                        [fieldName]: { accountId: accountId },
                        [customFieldId]: ''
                    }
                    : { [fieldName]: { accountId: accountId } };
            }

            return { [fieldName]: { accountId: accountId } };
        case 'REMAIN_UNASSIGNED':
            return fieldName === 'reporter' ? { [fieldName]: { accountId: integrationUserAccountId } } : { [fieldName]: { accountId: null } }
        case 'INTEGRATION_USER':
            return {
                [fieldName]: { accountId: integrationUserAccountId }
            }
        case 'PREDEFINED_USER':
            const isPredefinedUserAssignable = await findAssignableUser(targetInstance, targetProjectKey, targetProjectPredifinedUserAccountId);

            return isPredefinedUserAssignable ? await handlePredefinedUser(targetInstance, targetProjectKey, fieldName, targetProjectPredifinedUserAccountId) : checkUserFieldFallbackValue(context, targetInstance, targetProjectKey, integrationUserAccountId, fieldName, displayName, targetProjectPredifinedUserAccountId, issueType);
        case 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD':
            const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);

            return {
                [customFieldId]: displayName
            };
        default:
            return;
    }
}

/**
 * Function that checks userFieldFallbackOption value and changes the field accordingly
 */
export async function checkUserFieldFallbackValue(context: Context, targetInstance: JiraCloudApi, targetProjectKey: string, integrationUserAccountId: string, fieldName: string, displayName: string, targetProjectPredifinedUserAccountId: string, issueType: string): Promise<UserField | undefined> {
    const { UserFieldOptions } = getEnvVars(context);

    switch (UserFieldOptions.USER_FIELD_FALLBACK_OPTION) {
        case 'REMAIN_UNASSIGNED':
            // If fallback value is REMAIN_UNASSIGNED, reporter field will be integration user, this can be changed to PREDEFINED_USER_ID instead
            return fieldName === 'reporter' ? { [fieldName]: { accountId: integrationUserAccountId } } : { [fieldName]: { accountId: null } }
        case 'INTEGRATION_USER':
            return { [fieldName]: { accountId: integrationUserAccountId } };
        case 'PREDEFINED_USER':
            return await handlePredefinedUser(targetInstance, targetProjectKey, fieldName, targetProjectPredifinedUserAccountId);
        case 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD':
            const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);
            const account = fieldName === 'reporter' ? { [fieldName]: { accountId: integrationUserAccountId } } : { [fieldName]: { accountId: null } }
            return {
                [customFieldId]: displayName,
                ...account
            };
        case 'HALT_SYNC':
            throw Error(`Script halted because user for field ${fieldName} was not found.`)
        default:
            return;
    }
}

/**
 * Function that checks if account can be added to issue
 */
export async function findAssignableUser(targetInstance: JiraCloudApi, targetProjectKey: string, accountId: string): Promise<boolean> {
    const user = await targetInstance.User.Search.findUsersAssignableToIssues({
        project: targetProjectKey,
        accountId: accountId
    })

    return user.length > 0
}

/**
 * Function that copies original user to custom field
 */
export async function getOriginalUserToCustomFieldId(context: Context, targetInstance: JiraCloudApi, fieldName: string, projectKey: string, issueType: string): Promise<string> {
    const { OriginalUserFields } = getEnvVars(context);

    const customFieldName = fieldName === 'assignee' ? OriginalUserFields.CUSTOM_FIELD_FOR_ASSIGNEE : OriginalUserFields.CUSTOM_FIELD_FOR_REPORTER;

    const customFieldId = await getCustomField(targetInstance, customFieldName ?? '', projectKey, issueType);

    return customFieldId
}

/**
 * Function that handles predefined user
 */
export async function handlePredefinedUser(targetInstance: JiraCloudApi, targetProjectKey: string, fieldName: string, targetProjectPredifinedUserAccountId: string): Promise<UserField> {
    if (!targetProjectPredifinedUserAccountId) {
        throw Error('Missing predifined user ID')
    };

    const isPredefineUserdAssignable = await findAssignableUser(targetInstance, targetProjectKey, targetProjectPredifinedUserAccountId);

    if (!isPredefineUserdAssignable) {
        throw Error('Predifined user ID cannot be set')
    }

    return {
        [fieldName]: { accountId: targetProjectPredifinedUserAccountId }
    };
}

// Determine project number or identifier based on instance type
export const getProjectIdentifier = (instance: JiraCloudApi, projectKey: string): string => {

    const instanceId = instance === JiraCloud1 ? 1 : 2;
    return `${instanceId}-${projectKey}`;
};

/**
 * Function that handles custom fields for issue creation
 */
export async function handleCustomFieldsForCreatingIssue(sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, targetProjectKey: string, customFields: string[], eventIssue: Issue, createdIssue: GetIssueResponseOK, issueType: string): Promise<IssueFieldsUpdate> {
    const requestBody: IssueFieldsUpdate = {};

    for (const customField of customFields) {
        // Get custom field
        const sourceInstanceCustomFieldId = await getCustomField(sourceInstance, customField, targetProjectKey, issueType);

        // Save its value
        const value = eventIssue.fields[sourceInstanceCustomFieldId];

        // If the custom field has a value
        if (value) {
            // Find the custom field in target instance
            const targetInstanceCustomFieldId = await getCustomField(targetInstance, customField, targetProjectKey, issueType);

            // Check custom fields type
            switch (true) {
                // Check if custom field is a string or a number
                case typeof value === 'string' || typeof value === 'number':
                    // Add the value to the request body
                    requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId];
                    break;
                // Check if custom field is a array
                case Array.isArray(value):
                    // Check if custom field is an object
                    if (typeof value[0] === 'object') {
                        // Check if the object in array has a value property
                        if (value[0].hasOwnProperty('value')) {
                            // If it does, map through the objects and save the values
                            requestBody[targetInstanceCustomFieldId] = (value as { value: string }[]).map(field => ({ value: field.value }));
                        }

                        // Check if the object in array has an accountId property
                        if (value[0].hasOwnProperty('accountId')) {
                            // If it does, save all the account IDs added in the custom field
                            const accountIds = (value as { accountId: string }[]).map(field => field.accountId);

                            // Check if the account IDs can be added to the issue
                            const validAccountIds = await checkAccountIds(targetInstance, targetProjectKey, accountIds);

                            // Add the valid account IDs to the request body
                            requestBody[targetInstanceCustomFieldId] = validAccountIds.map(value => ({ accountId: value }));
                        }
                    } else {
                        // Add the array to the request body
                        requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId]
                    }
                    break;
                // Check if the custom field is an object
                case typeof value === 'object':
                    // Add the value in the object to request body
                    requestBody[targetInstanceCustomFieldId] = {
                        value: createdIssue.fields?.[sourceInstanceCustomFieldId].value
                    }
                    break;
                default:
                    break;
            }
        }
    }

    // Return the updated requestBody
    return requestBody;
}

/**
 * Function that handles finding the ScriptRunner Connect Sync Issue Key custom field value from the issue for Issue Links
 */
export async function getScriptRunnerConnectSyncIssueKeyForIssueLink(context: Context, issueKey: string, instance: JiraCloudApi, customFieldName: string): Promise<string | null> {
    const { JIRA_PROJECTS } = getEnvVars(context);

    const issue = (await instance.Issue.getIssue({
        issueIdOrKey: issueKey,
        errorStrategy: {
            handleHttp404Error: () => null
        }
    }))

    if (issue) {
        // Check if the linked issue belongs to a project we wan't to sync
        const isProjectInScope = instance === JiraCloud1
            ? JIRA_PROJECTS.hasOwnProperty(issue.fields.project.key)
            : Object.values(JIRA_PROJECTS).includes(issue.fields.project.key);

        // Stop issue link creation if issue doesn't belong to synced projects.
        if (!isProjectInScope) {
            console.warn(`Issue ${issueKey} does not belong in to a synced project.`)
            return;
        }
    }

    let customField: string;

    if (issue) {
        customField = await getCustomField(instance, customFieldName, issue.fields.project.key, issue.fields.issuetype.name);
    }

    if (!issue) {
        console.warn(`Issue for the following issue key is not found ${issueKey}.`)
        return null;
    } else if (issue?.fields?.[customField] === null) {
        throw Error(`${issueKey} is missing ScriptRunner Connect Sync Issue Key`)
    } else {
        return issue?.fields?.[customField]
    }
}

function getNewBlockForMedia(
    source: AttachmentMediaMapping[],
    target: AttachmentMediaMapping[],
    tleNode: media_node | mediaInline_node
): media_node | mediaInline_node {
    const { type: tleContentType, attrs } = tleNode;

    // Handle external media separately (for media_node)
    if (tleContentType === 'media' && attrs.type === 'external') {
        return {
            type: tleContentType,
            attrs
        }
    }

    const srcMediaId = (attrs.type === 'link' || attrs.type === 'file') ? attrs.id : '0';
    const srcMapping = source.find(el => el.mediaId === srcMediaId);
    const targetMapping = target.find(el => el.fileName === srcMapping?.fileName);

    if (!targetMapping) {
        throw new Error(`Could not find the corresponding media id for ${srcMapping?.fileName} - id ${srcMapping?.id}`);
    }

    const newAttrs = {
        ...attrs,
        id: targetMapping.mediaId,
    };

    return {
        type: tleContentType,
        attrs: newAttrs,
    } as media_node | mediaInline_node;
}

export function getNewDescriptionWithMedia(
    source: AttachmentMediaMapping[],
    target: AttachmentMediaMapping[],
    description: doc_node
): doc_node {
    const newDescription: doc_node = {
        version: description.version,
        type: description.type,
        content: description.content.map(tleNode => {
            // Handle nested content
            if ('content' in tleNode && Array.isArray(tleNode.content)) {
                const updatedContent = processContent(tleNode.content, source, target);
                return { ...tleNode, content: updatedContent };
            }

            // Handle mediaSingle
            if (tleNode.type === 'mediaSingle') {
                return {
                    ...tleNode,
                    content: [getNewBlockForMedia(source, target, tleNode.content[0] as media_node)]
                };
            }

            // Handle mediaGroup
            if (tleNode.type === 'mediaGroup') {
                return {
                    ...tleNode,
                    content: tleNode.content.map(el => getNewBlockForMedia(source, target, el as media_node))
                };
            }

            // Return unchanged node if not media-related
            return tleNode;
        })
    };

    return newDescription;
}

export function hasMediaBlocks(description: doc_node): boolean {
    function hasNestedMedia(content: any[]): boolean {
        return content.some(el => {
            // Check for mediaInline, mediaSingle, or mediaGroup
            if (['mediaInline', 'mediaSingle', 'mediaGroup'].includes(el.type)) {
                return true;
            }
            // Check nested content
            return 'content' in el && Array.isArray(el.content) && hasNestedMedia(el.content);
        });
    }

    return hasNestedMedia(description.content);
}

export function processContent(
    content: any[],
    source: AttachmentMediaMapping[],
    target: AttachmentMediaMapping[]
): any[] {
    return content.map(node => {
        // Process nested content
        if ('content' in node && Array.isArray(node.content)) {
            return { ...node, content: processContent(node.content, source, target) };
        }

        // Handle mediaInline and media nodes separately
        if (node.type === 'mediaInline') {
            return getNewBlockForMedia(source, target, node as mediaInline_node);
        }

        if (node.type === 'media') {
            return getNewBlockForMedia(source, target, node as media_node);
        }

        // Return unchanged node if not media-related
        return node;
    });
}


/**
 * Retrieves the ScriptRunner Connect Sync Issue Key from an issue link, with multiple retry attempts
 */
export async function retrySyncIssueKeyForIssueLinks(
    context: Context,
    issueIdOrKey: string,
    sourceInstance: JiraCloudApi,
    customFieldName: string
): Promise<string | null> {
    const { RetryConfigurations } = getEnvVars(context);
    let syncIssueKey: string | null = null;
    let attempts = 0;

    do {
        syncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, issueIdOrKey, sourceInstance, customFieldName);

        if (syncIssueKey) {
            break;
        }

        attempts++;
        console.log(`No Sync Issue Key found for issue ${issueIdOrKey}. Retrying attempt ${attempts}/${RetryConfigurations.MAX_RETRY_ATTEMPTS}...`);
        await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));

    } while (attempts < RetryConfigurations.MAX_RETRY_ATTEMPTS);

    if (!syncIssueKey) {
        console.log(`Failed to find Sync Issue Key after all retry attempts.`);
    }

    return syncIssueKey;
}

/**
 * Searches for an issue using the provided Sync Issue Key in the target instance.
 */
export async function retrySearchIssueInTargetInstance(
    context: Context,
    syncIssueKey: string,
    targetInstance: JiraCloudApi,
    includeAttachments: boolean,
): Promise<SearchIssuesByJqlResponseOK> {
    const { RetryConfigurations } = getEnvVars(context);
    let targetIssue: SearchIssuesByJqlResponseOK;
    let attempts = 0;

    do {
        targetIssue = await searchIssue(context, syncIssueKey, targetInstance, includeAttachments);

        if (targetIssue.total !== 0) {
            return targetIssue;
        }

        attempts++;
        console.log(`No matching issue found for key ${syncIssueKey}. Retrying attempt ${attempts}/${RetryConfigurations.MAX_RETRY_ATTEMPTS}...`);
        await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));

    } while (attempts < RetryConfigurations.MAX_RETRY_ATTEMPTS);

    // Throw error if no matching issue was found after max attempts
    throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${syncIssueKey}`);
}


export type ValuesType = {
    [key: string]: string;
};

interface UserField {
    [key: string]: {
        accountId: string | null;
    } | string;
};

export interface AttachmentBody {
    fileName: string;
    content: ArrayBuffer;
};

export interface AttachmentMediaMapping {
    id: string;
    fileName: string;
    mediaId: string;
    storedAttachmentId?: string;
}

export type UserFieldOptionType = 'ORIGINAL_USER' | 'REMAIN_UNASSIGNED' | 'INTEGRATION_USER' | 'PREDEFINED_USER' | 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD';
export type UserFieldFallbackOptionType = 'REMAIN_UNASSIGNED' | 'INTEGRATION_USER' | 'PREDEFINED_USER' | 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' | 'HALT_SYNC';

type JiraProject1Value = string;
type JiraProject2Value = string;

interface EnvVars {
    JIRA_PROJECTS: Record<JiraProject1Value, JiraProject2Value>;
    CUSTOM_FIELD_NAME: string;
    RetryConfigurations: {
        MAX_RETRY_ATTEMPTS: number;
        RETRY_DELAY_SECONDS: number;
    };
    FIELDS: string[];
    CUSTOM_FIELDS: string[];
    UserFieldOptions: {
        USER_FIELD_OPTION: UserFieldOptionType;
        USER_FIELD_FALLBACK_OPTION: UserFieldFallbackOptionType;
    };
    PredefinedUser: {
        JIRA_PROJECT_1_ACCOUNT_ID?: string;
        JIRA_PROJECT_2_ACCOUNT_ID?: string;
    };
    OriginalUserFields: {
        CUSTOM_FIELD_FOR_ASSIGNEE?: string;
        CUSTOM_FIELD_FOR_REPORTER?: string;
    };
    PRIORITY: Record<JiraProject1Value, JiraProject2Value>;
    STATUS: Record<JiraProject1Value, JiraProject2Value>;
    ISSUE_TYPES: Record<JiraProject1Value, JiraProject2Value>;
    IMPACT: Record<JiraProject1Value, JiraProject2Value>;
    CHANGE_REASON: Record<JiraProject1Value, JiraProject2Value>;
    CHANGE_TYPE: Record<JiraProject1Value, JiraProject2Value>;
    CHANGE_RISK: Record<JiraProject1Value, JiraProject2Value>;
    MOVE_ISSUES_BETWEEN_PROJECTS: boolean;
}

export function getEnvVars(context: Context) {
    return context.environment.vars as EnvVars;
}
Documentation · Support · Suggestions & feature requests