Sync Jira Data Center 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 Data Center, 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
  • Epic Name
  • Epic Link
  • Due Date
  • Labels
  • Issue Links
  • Attachments
  • Custom Fields

Does it work with older Jira Server instances too?

Yes. Functionally Jira Data Center and older server instances are the same.

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/OnJiraOnPremiseCommentCreated
Comment Created

README


📋 Overview

This template keeps issues in sync between 2 Jira Data Center (or server) instances by exchanging the following data:

  • Summary
  • Issue Type
  • Reporter
  • Assignee
  • Status
  • Priority
  • Comments
  • Attachments
  • Description
  • Epic Name
  • Epic Link
  • Custom Fields
  • Due Date
  • Labels
  • Issue Links
  • Sub-tasks
  • Moved issues

🖊️ Setup

  1. Configure API connections and event listeners:

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

    • 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 DC 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. Set environment variables:
    Configure the environment variables based on your project's needs.

    • In the FIELDS environment variable, 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 DC 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.
  • Some advanced system-controlled custom fields are not supported for syncing.
  • Converting existing issues to sub-tasks, or converting sub-tasks to standard issues, is not supported through the REST API. Additionally, changing the parent of a sub-task does not work.
  • This template provides a solid starting point for syncing issues between two Jira DC 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.
  • Built with Jira DC version 9.12 and 9.17; there may be differences in older or newer versions.

📅 Changelog

API Connections


TypeScriptCreateComment

import JiraOnPremise from './api/jira/on-premise/1';
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { IssueCommentCreatedEvent } from "@sr-connect/jira-on-premise/events";
import { RecordStorage } from '@sr-connect/record-storage';
import {
    extractIssueId,
    getComments,
    getEnvVars,
    getMatchingValue,
    getProjectIdentifier,
    getScriptRunnerConnectSyncIssueKey,
    searchIssue
} from './Utils';

/**
 * This function creates a new comment in matching issue when a comment is created.
 */
export default async function createComment(context: Context, event: IssueCommentCreatedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi): 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.emailAddress !== event.comment.author?.emailAddress) {
        const { JIRA_PROJECTS, CUSTOM_FIELD_NAME } = getEnvVars(context);
        const userDisplayName = event.comment.updateAuthor?.displayName;
        const email = event.comment.updateAuthor?.emailAddress;

        const metaData = {
            instance: sourceInstance === JiraOnPremise ? 1 : 2,
            user: `${userDisplayName} (${email})`,
            commentId: event.comment.id
        };

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

        // Get the issue ID for the created comment
        const createdCommentIssueId = extractIssueId(event.comment.self ?? '0');

        // Get the issue
        const issue = await sourceInstance.Issue.getIssue({
            issueIdOrKey: createdCommentIssueId ?? '0',
            fields: 'issuetype, project'
        })

        // Extract the source project key
        const sourceProjectKey = issue.fields?.project?.key ?? '0';

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

        // Get the ScriptRunner Connect Sync Issue Key from the issue
        const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(issue.key ?? '0', sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, issue.fields?.issuetype?.id ?? '0');

        // Check if ScriptRunner Connect Sync Issue Key exists on the issue
        if (scriptRunnerConnectSyncIssueKey === null) {
            throw new 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)

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

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

        // Construct the original user
        const originalUser = `Original comment by: ${event.comment.author?.displayName}, (${event.comment.author?.emailAddress})`

        // Clone the existing comment body and add original user
        const updatedCommentBody = `${event.comment.body}\n\n${originalUser}`

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

        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);

        // Save the comment ID's
        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 JiraOnPremise from './api/jira/on-premise/1';
import { JiraOnPremApi } from '@managed-api/jira-on-prem-v8-sr-connect';
import { IssueCreatedEvent } from '@sr-connect/jira-on-premise/events';
import { FieldsCreateIssue } from '@managed-api/jira-on-prem-v8-core/definitions/issue';
import {
    checkUserFieldOption,
    getCustomField,
    getEnvVars,
    getJiraIssueType,
    getJiraPriority,
    getMatchingValue,
    getScriptRunnerConnectSyncIssueKey,
    handleCustomFieldsForCreatingIssue,
    searchIssue,
    uploadAttachment,
} from './Utils';

/**
 * This function creates a new Jira DC issue when an issue is created.
 */
export default async function createIssue(context: Context, event: IssueCreatedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi, targetProjectPredifinedUserEmail?: 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.emailAddress !== event.user.emailAddress) {
        const sourceProjectKey = event.issue.fields?.project?.key ?? '';
        const userDisplayName = event.user.displayName;
        const accountEmail = event.user.emailAddress;

        // Extract the created issue issue key and issue type
        const eventIssueKey = event.issue.key ?? '';
        const eventIssueType = event.issue.fields?.issuetype;

        const metaData = {
            instance: sourceInstance === JiraOnPremise ? 1 : 2,
            project: sourceProjectKey,
            issueKey: eventIssueKey,
            issueType: eventIssueType?.name,
            user: `${userDisplayName} (${accountEmail})`,
            issueId: event.issue.id
        };

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

        // Get the environment variables
        const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, CUSTOM_FIELD_NAME } = getEnvVars(context);

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

        // Get the ScriptRunner Connect Sync Issue Key custom field
        const sourceCustomField = await getCustomField(sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, eventIssueType?.id ?? '0');

        // Check if ScriptRunner Connect Sync Issue Key custom field was found
        if (!sourceCustomField) {
            // If not, then throw an error
            throw Error('ScriptRunner Connect Sync Issue Key custom field not found')
        }

        // 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 the matching 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 name for the target project
        const issueTypeName = getMatchingValue(sourceInstance, eventIssueType?.name ?? '', ISSUE_TYPES);

        // Get the issue type from target instance
        const issueType = await getJiraIssueType(issueTypeName, targetInstance);

        // Get the ScriptRunner Connect Sync Issue Key custom field from target instance
        const targetCustomField = (await getCustomField(targetInstance, CUSTOM_FIELD_NAME, targetProjectKey, issueType.id ?? '0')) ?? '';

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

        // Check if issue type name is Epic
        if (issueTypeName === 'Epic') {
            // Get the Epic Name custom field ID from source Instance
            const sourceInstanceEpicNameCustomField = await getCustomField(sourceInstance, 'Epic Name', sourceProjectKey, eventIssueType?.id ?? '0');

            // Check if Epic Name custom field ID from source Instance was found
            if (!sourceInstanceEpicNameCustomField) {
                // If not, then throw an error
                throw Error('Epic Name custom field ID from source Instance was not found');
            }

            // Get the Epic Name custom field ID from target Instance
            const epicNameCustomField = await getCustomField(sourceInstance, 'Epic Name', sourceProjectKey, issueType.id ?? '0');

            // Check if Epic Name custom field ID from target Instance was found
            if (!epicNameCustomField) {
                // If not, then throw an error
                throw Error('Epic Name custom field ID from target Instance was not found');
            }

            // Add the Epic Name value to request body
            requestBody[epicNameCustomField] = event.issue.fields?.[sourceInstanceEpicNameCustomField];
        };

        // Get the current user for target Instance
        const targetInstanceIntegrationUser = await targetInstance.Myself.getCurrentUser();

        // 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 environment variables and handle the field appropriately
            const reporterUserFieldOption = await checkUserFieldOption(
                context,
                targetInstance,
                targetProjectKey,
                reporter?.emailAddress ?? '',
                targetInstanceIntegrationUser.name ?? '',
                'reporter',
                reporter?.displayName ?? '',
                targetProjectPredifinedUserEmail ?? '',
                issueType.id ?? '0'
            );

            // 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 environment variables and handle the field appropriately
            const assigneeUserFieldOption = await checkUserFieldOption(
                context,
                targetInstance,
                targetProjectKey,
                assignee?.emailAddress ?? '',
                targetInstanceIntegrationUser.name ?? '',
                'assignee', assignee?.displayName ?? '',
                targetProjectPredifinedUserEmail ?? '',
                issueType.id ?? ''
            );

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

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

            const priority = await getJiraPriority(priorityName, targetInstance);

            // Add priority ID to issue fields
            requestBody.priority = { id: priority.id ?? '' }
        }

        // Check if description field exists in FIELDS array and description has been added
        if (FIELDS.includes('description') && event.issue.fields?.description !== null) {
            // If it does, add description to issue fields
            requestBody.description = event.issue.fields?.description
        }

        // 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 due date to issue fields
            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 issue fields
            requestBody.labels = event.issue.fields?.labels;
        }

        // Check if Sub-task was created
        if (FIELDS.includes('issuetype') && issueType.name === 'Sub-task') {
            // Extract parent issue key
            const parentIssueKey = event.issue.fields?.parent?.key

            // Get the Sync Key for parent issue
            const parentSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(parentIssueKey ?? '', sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, event.issue.fields?.parent?.fields?.issuetype?.id ?? '0');

            // Check if Sync Key for parent issue was found
            if (!parentSyncIssueKey) {
                throw Error('ScriptRunner Connect Sync Issue Key is missing on parent issue');
            }
            // Search matching parent issue from target Instance
            const matchingIssue = await searchIssue(context, parentSyncIssueKey ?? '', targetInstance, false);

            // Check if a matching parent issue was found
            if (matchingIssue.total === 0) {
                // If not, throw an error
                throw Error(`Matching parent issue with sync key ${parentSyncIssueKey} missing`);
            }

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

        // Check if Epic Links is checked in FIELDS array
        if (FIELDS.includes('Epic Link')) {
            // Get the custom field for Epic Link in source instance
            const customField = await getCustomField(sourceInstance, 'Epic Link', sourceProjectKey, eventIssueType?.id ?? '')

            // Extract the Epic key
            const fieldValue = event.issue.fields?.[customField ?? ''];

            // If there is a value, find matching epic from target instance
            if (fieldValue) {
                // Get the Sync Key for Epic issue
                const epicSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(fieldValue ?? '', sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, event.issue.fields?.parent?.fields?.issuetype?.id ?? '0');

                // Check if Sync Key for Epic issue was found
                if (!epicSyncIssueKey) {
                    throw Error('ScriptRunner Connect Sync Issue Key is missing on Epic issue');
                }

                // Search matching Epic issue from target Instance
                const matchingIssue = await searchIssue(context, epicSyncIssueKey ?? '', targetInstance, false);

                // Check if a matching Epic issue was found
                if (matchingIssue.total === 0) {
                    // If not, throw an error
                    throw Error(`Matching Epic issue with sync key ${epicSyncIssueKey} missing`);
                }

                // Get the Epic Link custom field in target instance
                const epicLinkCustomField = await getCustomField(targetInstance, 'Epic Link', targetProjectKey, eventIssueType?.id ?? '')

                // Add Epic issue key to issue fields
                requestBody[epicLinkCustomField ?? ''] = matchingIssue.issues?.[0].key;
            }
        }

        // Check if custom fields have been added to CUSTOM_FIELDS in environment variables
        if (CUSTOM_FIELDS.length) {
            // Check for values and add the custom fields to issue fields
            const customFieldsToBeAdded = await handleCustomFieldsForCreatingIssue(sourceInstance, targetInstance, targetProjectKey, CUSTOM_FIELDS, event.issue, issueType.id ?? '');

            // Add custom fields
            requestBody = { ...requestBody, ...customFieldsToBeAdded }
        }

        console.log(`Issue fields`, {
            requestBody
        })

        // Create a new issue in target instance
        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
        })

        // Extract attachments from the issue that triggered the event
        const attachments = event.issue.fields?.attachment ?? [];

        // Check if attachments were added to the issue
        if (FIELDS.includes('Attachment') && attachments.length > 0) {
            // Loop through attachments and add them to the array
            for (const attachment of attachments) {
                // Upload attachment to target instance
                await uploadAttachment(sourceInstance, targetInstance, attachment, issueKey);
            }
        }

        // 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 the status is incorrect, then update it
        if (createdTargetIssue.fields?.status?.name !== status) {
            const transitions = (await targetInstance.Issue.Transition.getTransitions({
                issueIdOrKey: issueKey
            })).transitions ?? [];

            // Find the correct transition ID
            const transitionId = transitions.find(t => t.to?.name === status || t.name === status)?.id ?? ''

            // Check if transition ID was found
            if (!transitionId) {
                // If not, throw an Error
                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
                    }
                }
            });
        };
    }
}
TypeScriptDeleteComment

import JiraOnPremise from './api/jira/on-premise/1';
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { IssueCommentDeletedEvent } from "@sr-connect/jira-on-premise/events";
import { RecordStorage } from '@sr-connect/record-storage';
import {
    extractIssueId,
    getComments,
    getEnvVars,
    getMatchingValue,
    getProjectIdentifier,
    getScriptRunnerConnectSyncIssueKey,
    searchIssue
} from './Utils';

/**
 * This function deletes a corresponding comment when a comment has been deleted.
 */
export default async function deleteComment(context: Context, event: IssueCommentDeletedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi): Promise<void> {
    console.log('Comment Deleted event: ', event);

    // Extract the comments author
    const userDisplayName = event.comment.author?.displayName;
    const userEmail = event.comment.author?.emailAddress;

    const metaData = {
        instance: sourceInstance === JiraOnPremise ? 1 : 2,
        commentCreatedBy: `${userDisplayName} (${userEmail})`,
        commentId: event.comment.id
    };

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

    // Extract the deleted comment issue ID
    const deletedCommentIssueId = extractIssueId(event.comment.self ?? '0');

    // Check if Issue ID extraction was successful
    if (!deletedCommentIssueId) {
        // If not, throw an error
        throw Error("Issue Id extraction failed");
    }

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

    // Check if the issue still exists
    if (issue) {
        const storage = new RecordStorage();
        const { JIRA_PROJECTS, CUSTOM_FIELD_NAME } = getEnvVars(context);

        // Extract the source issue project key
        const sourceProjectKey = issue.fields?.project?.key ?? '0';

        // 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(issue.key ?? '0', sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, issue.fields?.issuetype?.id ?? '0');

        // Check is Sync Issue Key is present
        if (scriptRunnerConnectSyncIssueKey === null) {
            // If not, throw an error
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        // 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 matching comment ID from Record Storage: ${event.comment.id}. 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 ?? '';

        // 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}`);
    }
}
TypeScriptDeleteIssue

import JiraOnPremise from './api/jira/on-premise/1';
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { IssueDeletedEvent } from "@sr-connect/jira-on-premise/events";
import { getCustomField, getEnvVars, searchIssue } from './Utils';
import { RecordStorage } from '@sr-connect/record-storage';

/**
 * This function deletes a corresponding issue when an issue in Jira DC is deleted.
 */
export default async function deleteIssue(context: Context, event: IssueDeletedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi): 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.emailAddress !== event.user.emailAddress) {
        const userDisplayName = event.user.displayName;
        const email = event.user.emailAddress;
        const sourceProjectKey = event.issue.fields?.project?.key ?? '';

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

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

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

        // Get the ScriptRunner Connect Sync Issue Key custom field in source instance
        const customField = await getCustomField(sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, event.issue.fields?.issuetype?.id ?? '0');

        // Get the ScriptRunner Connect Sync Issue Key from deleted issue
        const scriptRunnerConnectSyncIssueKey = event.issue.fields?.[customField ?? ''];

        // Check if the deleted issue had ScriptRunner Connect Sync Issue Key
        if (!scriptRunnerConnectSyncIssueKey) {
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

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

        // Check if any matching issues were found
        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 matching issue key
        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();

        // Check FIELDS array includes comments
        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)
            }
        }

        // Check FIELDS array includes issue links
        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 JiraOnPremise from './api/jira/on-premise/1';
import { GetCurrentUserResponseOK } from "@managed-api/jira-on-prem-v8-core/types/myself";
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { IssueUpdatedEvent } from "@sr-connect/jira-on-premise/events";
import { RecordStorage } from '@sr-connect/record-storage';
import { FieldsCreateIssue, FieldsEditIssue } from '@managed-api/jira-on-prem-v8-core/definitions/issue';
import { SearchIssuesByJqlResponseOK } from '@managed-api/jira-on-prem-v8-core/types/issue/search';
import {
    checkUserFieldOption,
    getCreateMetaData,
    getCustomField,
    getEnvVars,
    getIssueLinks,
    getJiraIssueType,
    getJiraPriority,
    getMatchingValue,
    getProjectIdentifier,
    getScriptRunnerConnectSyncIssueKey,
    getScriptRunnerConnectSyncIssueKeyForIssueLink,
    handleCustomFieldsForCreatingIssue,
    retrySearchIssueInTargetInstance,
    retrySyncIssueKeyForIssueLinks,
    searchIssue,
    setIssueLinks,
    uploadAttachment,
} from './Utils';

/**
 * This function creates a new matching issue and deletes the old one when issue gets moved.
 */
export default async function moveIssue(context: Context, event: IssueUpdatedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi, myself: GetCurrentUserResponseOK, targetProjectPredifinedUserEmail?: string): Promise<void> {
    console.log('Issue Moved event: ', event);

    const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, CUSTOM_FIELD_NAME, RetryConfigurations } = getEnvVars(context);

    const eventIssueType = event.issue.fields?.issuetype;
    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 ? issueTypeChange.fromString ?? '' : eventIssueType?.name;
    const newIssueType = issueTypeChange ? issueTypeChange?.toString ?? '' : eventIssueType?.name;
    const userDisplayName = event.user.displayName;
    const userEmail = event.user.emailAddress;

    const storage = new RecordStorage();

    const metaData = {
        instance: sourceInstance === JiraOnPremise ? 1 : 2,
        project: sourceProjectKey,
        summary: event.issue.fields?.summary,
        oldIssueKey: oldIssueKey,
        newIssueKey: newIssueKey,
        oldProject: oldProjectKey,
        newProject: newProjectKey,
        oldIssueType: oldIssueType,
        newIssueType: newIssueType,
        user: `${userDisplayName} (${userEmail})`,
    };

    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, CUSTOM_FIELD_NAME, newProjectKey, eventIssueType?.id ?? '0')) ?? '';

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

    if (!previousScriptRunnerConnectSyncIssueKey) {
        throw Error('Missing ScriptRunner Connect Sync Issue Key');
    }

    // Update the ScriptRunner Connect Sync Issue Key custom field with the new issue key
    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 target project
    const issueTypeName = getMatchingValue(sourceInstance, eventIssueType?.name ?? '', ISSUE_TYPES);

    // Get the issue type from target instance
    const issueType = await getJiraIssueType(issueTypeName, targetInstance);

    // Get the ScriptRunner Connect Sync Issue Key custom field from target instance
    const targetCustomField = (await getCustomField(targetInstance, CUSTOM_FIELD_NAME, targetProjectKey, eventIssueType?.id ?? '')) ?? '';

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

    // Issue fields we can't add when creating the issue
    let requestBodyForTransition: FieldsEditIssue = {};

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

    // Map through fields the moved issue supports
    const fieldsThatIssueSupports = Object.entries(editMetadata.fields ?? {})
        .map(([key, field]) => key.startsWith('customfield_') ? field.name : key);

    // Get fields that issue creations supports in target project
    const createIssueMetadata = await getCreateMetaData(targetInstance, targetProjectKey, issueType.id ?? '0')

    // Add fields that issue creation supports into an array
    const createIssueFields = (createIssueMetadata?.values ?? []).map(fieldMetadata =>
        fieldMetadata.fieldId.startsWith('customfield_') ? fieldMetadata.name : fieldMetadata.fieldId
    );

    // Get target instance integration user
    const targetInstanceIntegrationUser = await targetInstance.Myself.getCurrentUser();
    // Epic Issue Type, this will be used 
    const epicIssueType = await getJiraIssueType('Epic', targetInstance);

    // 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 environment variables and handle the field appropriately
        const reporterUserFieldOption = await checkUserFieldOption(
            context,
            targetInstance,
            targetProjectKey,
            reporter?.emailAddress ?? '',
            targetInstanceIntegrationUser.name ?? '',
            'reporter',
            reporter?.displayName ?? '',
            targetProjectPredifinedUserEmail ?? '',
            issueType.name ?? ''
        );

        // If a value is returned, add it to the issue fields
        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 environtment variables and handle the field appropriately
        const assigneeUserFieldOption = await checkUserFieldOption(
            context,
            targetInstance,
            targetProjectKey,
            assignee?.emailAddress ?? '',
            targetInstanceIntegrationUser.name ?? '',
            'assignee',
            assignee?.displayName ?? '',
            targetProjectPredifinedUserEmail ?? '',
            issueType.name ?? ''
        );

        // If a value is returned, add it to the issue fields
        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 priority name
        const priorityName = getMatchingValue(sourceInstance, event.issue.fields?.priority?.name ?? '', PRIORITY);

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

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

    // Check if field exists in FIELDS array and description has been added
    if (FIELDS.includes('description') && event.issue.fields?.description !== null) {
        // Add description to issue fields
        if (createIssueFields.includes('description')) {
            requestBody.description = event.issue.fields?.description
        } else if (fieldsThatIssueSupports.includes('description')) {
            requestBodyForTransition.description = event.issue.fields?.description
        }
    }

    // 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 issue fields
            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 it does, add it to issue fields
        if (createIssueFields.includes('labels')) {
            requestBody.labels = event.issue.fields?.labels;
        } else if (fieldsThatIssueSupports.includes('labels')) {
            requestBodyForTransition.labels = event.issue.fields?.labels;
        }
    }

    // Check if issue is a sub-task
    if (FIELDS.includes('issuetype') && issueType.name === 'Sub-task') {
        // Extract parent issue key
        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,
                CUSTOM_FIELD_NAME,
                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
        }

        // Check if the matching issue has any inward issues linked
        const linkedInwardIssues = matchingIssue.issues?.[0].fields?.issuelinks?.filter(link => link.inwardIssue) ?? [];

        // Iterate through each inward issue link found
        for (const issueLink of linkedInwardIssues) {
            // Search for the issue Sync Key from the inward issue
            const issueSyncKeyForInwardIssue = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, issueLink?.inwardIssue?.id ?? '0', targetInstance, CUSTOM_FIELD_NAME);

            // If the sync key is missing, skip to the next inward issue link
            if (!issueSyncKeyForInwardIssue) {
                continue;
            }

            // Check if a Record Storage has issue link key value with that Sync Key
            const valueExists = await storage.valueExists(`issue_link_${issueSyncKeyForInwardIssue}`);

            if (valueExists) {
                // Get source project key
                const targetIssueProjectKey = (await targetInstance.Issue.getIssue({
                    issueIdOrKey: issueLink?.inwardIssue?.id ?? '0'
                })).fields?.project?.key ?? '';

                // Get all the saved issue links from Record Storage with that Sync Key
                const savedIssueLinks = await getIssueLinks(storage, issueSyncKeyForInwardIssue);

                // Find the matching issue link ID 
                const issueLinkId = savedIssueLinks.filter(il => il[targetIssueProjectKey] !== issueLink.id);

                // Update the Record Storage
                await setIssueLinks(storage, issueSyncKeyForInwardIssue, issueLinkId);
            }
        }
    }

    // Check if issue type name is Epic
    if (FIELDS.includes('issuetype') && issueTypeName === 'Epic') {
        // Get the Epic Name custom field ID from source Instance
        const sourceInstanceEpicNameCustomField = await getCustomField(sourceInstance, 'Epic Name', sourceProjectKey, eventIssueType?.id ?? '0');

        // Check if Epic Name custom field ID from source Instance was found
        if (!sourceInstanceEpicNameCustomField) {
            // If not, then throw an error
            throw Error('Epic Name custom field ID from source Instance was not found');
        }

        // Get the Epic Name custom field ID from target Instance
        const epicNameCustomField = await getCustomField(sourceInstance, 'Epic Name', sourceProjectKey, issueType.id ?? '0');

        // Check if Epic Name custom field ID from target Instance was found
        if (!epicNameCustomField) {
            // If not, then throw an error
            throw Error('Epic Name custom field ID from target Instance was not found');
        }

        // Add the Epic Name value to issue fields
        requestBody[epicNameCustomField] = event.issue.fields?.[sourceInstanceEpicNameCustomField];
    };


    // Check if Epic Links is checked in FIELDS array
    if (FIELDS.includes('Epic Link') && issueTypeName !== 'Epic') {
        // Get the custom field for Epic Link in source instance
        const customField = await getCustomField(sourceInstance, 'Epic Link', sourceProjectKey, eventIssueType?.id ?? '0')

        // Extract the Epic key
        const fieldValue = event.issue.fields?.[customField ?? ''];

        // If there is a value, find matching epic from target instance
        if (fieldValue) {
            // Get the ScriptRunner Connect Sync Key for Epic issue
            const epicSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(fieldValue ?? '', sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, event.issue.fields?.parent?.fields?.issuetype?.id ?? '0');

            // Check if Sync Key for Epic issue was found
            if (!epicSyncIssueKey) {
                throw Error('ScriptRunner Connect Sync Issue Key is missing on Epic issue');
            }

            // Search matching Epic issue from target Instance
            const matchingIssue = await searchIssue(context, epicSyncIssueKey ?? '', targetInstance, false);

            // Check if a matching Epic issue was found
            if (matchingIssue.total === 0) {
                // If not, throw an error
                throw Error(`Matching Epic issue with sync key ${epicSyncIssueKey} missing`);
            }

            // Get the Epic Link custom field in target instance
            const epicLinkCustomField = await getCustomField(targetInstance, 'Epic Link', targetProjectKey, epicIssueType.id ?? '0')

            // Check if the field was found
            if (!epicLinkCustomField) {
                // If not, throw an error
                throw Error("Coudn't find Epic Link custom field in target instance")
            }

            // If it does, add it to issue fields
            if (createIssueFields.includes('Epic Link')) {
                requestBody[epicLinkCustomField ?? ''] = matchingIssue.issues?.[0].key;
            } else if (fieldsThatIssueSupports.includes('Epic Link')) {
                requestBodyForTransition[epicLinkCustomField ?? ''] = matchingIssue.issues?.[0].key;
            }
        }
    }


    // Check if custom fields have been added to CUSTOM_FIELDS in Environment variables
    if (CUSTOM_FIELDS.length) {
        // Filter custom fields that can be added when creating an issue
        const customFields = CUSTOM_FIELDS.filter(field => createIssueFields.includes(field));

        // Filter custom fields that should be present but cannot be added during issue creation
        const requiredCustomFieldsForTranstition = CUSTOM_FIELDS.filter(field =>
            !createIssueFields.includes(field) && fieldsThatIssueSupports.includes(field)
        );

        // If there are custom fields available for issue creation, process and add them to the request body
        if (customFields.length > 0) {
            const customFieldsBody = await handleCustomFieldsForCreatingIssue(
                sourceInstance,
                targetInstance,
                targetProjectKey,
                customFields,
                event.issue,
                issueType.id ?? '0'
            );

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

        // If there are required custom fields for transition, process and add them to the transition request body
        if (requiredCustomFieldsForTranstition.length > 0) {
            const transitionCustomFieldsBody = await handleCustomFieldsForCreatingIssue(
                sourceInstance,
                targetInstance,
                targetProjectKey,
                requiredCustomFieldsForTranstition,
                event.issue,
                issueType?.id ?? '0'
            );

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

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

    // Create the new matching issue
    const issue = await targetInstance.Issue.createIssue({
        body: {
            fields: requestBody,
        }
    })

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

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

    // Check if there are any attachments
    if (FIELDS.includes('Attachment') && attachments.length > 0) {
        // Loop through attachments and upload them to target issue
        for (const attachment of attachments) {
            await uploadAttachment(sourceInstance, targetInstance, attachment, issueKey);
        }
    }

    // CHeck if moved issue has comments and if does, add them to the new issue
    if (FIELDS.includes('comments')) {
        const issueComments = event.issue.fields?.comment;

        // If comments exist, proceed to iterate through each comment
        if (issueComments?.total && issueComments.total > 0) {
            // Get the project identifiers for the source and target instances
            const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
            const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

            // Array to hold mappings of original comment IDs to created comment IDs
            const matchingComments = [];

            // Iterate through each comment from the original issue
            for (const comment of issueComments.comments ?? []) {
                let issueCommentBody = comment.body;

                // Check if "Original comment by" is already in the comment body
                const hasOriginalUserText = issueCommentBody?.includes('Original comment by');

                // Check if the original commentator is different from the current user
                if (comment.author?.emailAddress !== myself.emailAddress && !hasOriginalUserText) {
                    // Add the original commentator
                    const originalUser = `Original comment by: ${comment.author?.displayName}, (${comment.author?.emailAddress})`;

                    // Append the original commentator's information to the comment body
                    issueCommentBody = `${comment.body}\n\n${originalUser}`
                }

                // Create the comment in target issue
                const createdComment = await targetInstance.Issue.Comment.addComment({
                    issueIdOrKey: issueKey,
                    body: {
                        body: issueCommentBody
                    }
                })

                // Create a mapping of the original comment ID to the new comment ID
                const commentIds = {
                    [sourceProjectNumberWithKey]: comment.id,
                    [targetProjectNumberWithKey]: createdComment.id
                }

                // Store the mapping in the array
                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 ScriptRunner Connect Issue Sync Key
            await storage.deleteValue(previousScriptRunnerConnectSyncIssueKey);
        }
    }

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

    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 ?? ''

        // Check if transition ID was found for target instance
        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
            }
        });

        console.log(`Issue ${issueKey} was transitioned`)
    };

    // Check if the issue is Epic
    if (FIELDS.includes('issuetype') && issueTypeName === 'Epic') {
        // Retrieve issues linked to the current Epic in the source instance
        const epicLinkedIssues = await sourceInstance.Issue.Search.searchByJql({
            body: {
                jql: `"Epic Link" = ${newIssueKey}`
            }
        })

        // Check if any issues were found linked to this Epic
        if (epicLinkedIssues.total && epicLinkedIssues.total > 0) {
            // Iterate over each linked issue in the source instance
            for (const issue of epicLinkedIssues.issues ?? []) {
                // Extract ScriptRunner Connect Sync Issue Key
                const issueSyncKey = issue.fields?.[sourceCustomField];

                // Find the matching target issue
                const targetIssue = await searchIssue(context, issueSyncKey, targetInstance, false);

                // Check if a matching target issue was found
                if (targetIssue.total === 0) {
                    // If not, throw an error
                    throw Error('Matching issue for epic link not found');
                }

                // Get the Epic Link custom field in target instance
                const epicLinkCustomField = (await getCustomField(targetInstance, 'Epic Link', targetProjectKey, epicIssueType.id ?? '0')) ?? '';

                // Check if the field was found
                if (!epicLinkCustomField) {
                    // If not, throw an error
                    throw Error('Epic Link custom field in target instance not found');
                }

                // Add the Epic Link to the linked issue in the target instance
                await targetInstance.Issue.editIssue({
                    issueIdOrKey: targetIssue.issues?.[0].key ?? '',
                    body: {
                        fields: {
                            [epicLinkCustomField]: issueKey
                        }
                    }
                })

                console.log(`Epic Link added to issue ${targetIssue.issues?.[0].key}`)
            }
        }
    };

    // Check if issue links exist in FIELDS array and if the event issue has issue links added
    if (FIELDS.includes('issue links') && event.issue.fields?.issuelinks?.length) {
        // Check if there are any issue links stored in Record Storage with the old Sync Key
        const oldIssueLinks = await storage.valueExists(`issue_link_${previousScriptRunnerConnectSyncIssueKey}`);

        // If there issue links with old Sync Key, delete them
        if (oldIssueLinks) {
            await storage.deleteValue(`issue_link_${previousScriptRunnerConnectSyncIssueKey}`)
        }

        // Extract the issue links
        const issueLinks = event.issue.fields.issuelinks ?? [];
        const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
        const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);

        // Iterate over the issue links
        for (const issueLink of issueLinks) {
            const outwardLinkedIssueKey = issueLink.outwardIssue?.key;
            const inwardLinkedIssueKey = issueLink.inwardIssue?.key;
            const inwardLinkedIssueId = (issueLink.inwardIssue?.id ?? 0).toString();

            // Check for existing issue links from Record Storage
            const existingIssueLinks = await getIssueLinks(storage, newIssueKey);

            // 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, +(issueLink.outwardIssue?.id ?? '0'), sourceInstance, CUSTOM_FIELD_NAME);

                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: issueLinkType.name
                            },
                            inwardIssue: {
                                key: issue.key
                            }
                        },
                    })

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

                    // Save issue link mapping into Record Storage
                    const newIssueLink = {
                        [sourceProjectNumberWithKey]: issueLink.id ?? '0',
                        [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 inwardIssueSyncIssueKey = await retrySyncIssueKeyForIssueLinks(context, inwardLinkedIssueId, sourceInstance, CUSTOM_FIELD_NAME);

                if (inwardIssueSyncIssueKey) {
                    // Search for the inward issue in target instance
                    const targetIssue = await retrySearchIssueInTargetInstance(context, inwardIssueSyncIssueKey, 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 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;

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

                    // Retrieve the project identifier for the inward issue in the source instance
                    // and the target inward issue in the target instance using their project keys
                    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, inwardIssueSyncIssueKey);

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

                    // Create updated issue links by filtering out the existing link mapping and adding the new link
                    const updatedIssueLinks = [
                        newIssueLink,
                        ...existingInwardIssueLinks.filter(il => il[inwardIssueProjectNumberWithKey] !== issueLink.id)
                    ];

                    // Save issue links to Record Storage
                    await setIssueLinks(storage, inwardIssueSyncIssueKey, updatedIssueLinks);

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

    // Search for the matching issue again, in case it has been deleted already
    const issueToBeDeleted = await searchIssue(context, previousScriptRunnerConnectSyncIssueKey, targetInstance, false)

    // Check if the old issue was found
    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/OnJiraOnPremiseCommentCreated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCommentCreatedEvent } from '@sr-connect/jira-on-premise/events';
import createComment from '../CreateComment';

/**
 * 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;
    }

    await createComment(context, event, JiraOnPremiseProject1, JiraOnPremiseProject2);
}
TypeScriptProject1/OnJiraOnPremiseCommentDeleted

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCommentDeletedEvent } from '@sr-connect/jira-on-premise/events';
import deleteComment from '../DeleteComment';

/**
 * 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;
    }

    await deleteComment(context, event, JiraOnPremiseProject1, JiraOnPremiseProject2);
}
TypeScriptProject1/OnJiraOnPremiseCommentUpdated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-on-premise/events';
import updateComment from '../UpdateComment';

/**
 * 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;
    }

    await updateComment(context, event, JiraOnPremiseProject1, JiraOnPremiseProject2);
}
TypeScriptProject1/OnJiraOnPremiseIssueCreated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCreatedEvent } from '@sr-connect/jira-on-premise/events';
import { getEnvVars } from '../Utils';
import createIssue from '../CreateIssue';

/**
 * 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 { PredefinedUser } = getEnvVars(context);

    await createIssue(context, event, JiraOnPremiseProject1, JiraOnPremiseProject2, PredefinedUser.JIRA_PROJECT_2_EMAIL);
}
TypeScriptProject1/OnJiraOnPremiseIssueDeleted

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueDeletedEvent } from '@sr-connect/jira-on-premise/events';
import deleteIssue from '../DeleteIssue';

/**
 * 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;
    }

    await deleteIssue(context, event, JiraOnPremiseProject1, JiraOnPremiseProject2)
}
TypeScriptProject1/OnJiraOnPremiseIssueLinkCreated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueLinkCreatedEvent } from '@sr-connect/jira-on-premise/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, JiraOnPremiseProject1, JiraOnPremiseProject2);
}
TypeScriptProject1/OnJiraOnPremiseIssueLinkDeleted

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueLinkDeletedEvent } from '@sr-connect/jira-on-premise/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, JiraOnPremiseProject1, JiraOnPremiseProject2)
}
TypeScriptProject1/OnJiraOnPremiseIssueUpdated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueUpdatedEvent } from '@sr-connect/jira-on-premise/events';
import { getEnvVars } from '../Utils';
import updateIssue from '../UpdateIssue';

/**
 * 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 { PredefinedUser } = getEnvVars(context);

    await updateIssue(context, event, JiraOnPremiseProject1, JiraOnPremiseProject2, PredefinedUser.JIRA_PROJECT_1_EMAIL);
}
TypeScriptProject2/OnJiraOnPremiseCommentCreated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCommentCreatedEvent } from '@sr-connect/jira-on-premise/events';
import createComment from '../CreateComment';

/**
 * 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;
    }

    await createComment(context, event, JiraOnPremiseProject2, JiraOnPremiseProject1);
}
TypeScriptProject2/OnJiraOnPremiseCommentDeleted

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCommentDeletedEvent } from '@sr-connect/jira-on-premise/events';
import deleteComment from '../DeleteComment';

/**
 * 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;
    }

    await deleteComment(context, event, JiraOnPremiseProject2, JiraOnPremiseProject1);
}
TypeScriptProject2/OnJiraOnPremiseCommentUpdated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-on-premise/events';
import updateComment from '../UpdateComment';

/**
 * 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;
    }

    await updateComment(context, event, JiraOnPremiseProject2, JiraOnPremiseProject1);
}
TypeScriptProject2/OnJiraOnPremiseIssueCreated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueCreatedEvent } from '@sr-connect/jira-on-premise/events';
import { getEnvVars } from '../Utils';
import createIssue from '../CreateIssue';

/**
 * 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 { PredefinedUser } = getEnvVars(context);

    await createIssue(context, event, JiraOnPremiseProject2, JiraOnPremiseProject1, PredefinedUser.JIRA_PROJECT_1_EMAIL);
}
TypeScriptProject2/OnJiraOnPremiseIssueDeleted

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueDeletedEvent } from '@sr-connect/jira-on-premise/events';
import deleteIssue from '../DeleteIssue';

/**
 * 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;
    }

    await deleteIssue(context, event, JiraOnPremiseProject2, JiraOnPremiseProject1)
}
TypeScriptProject2/OnJiraOnPremiseIssueLinkCreated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueLinkCreatedEvent } from '@sr-connect/jira-on-premise/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, JiraOnPremiseProject2, JiraOnPremiseProject1);
}
TypeScriptProject2/OnJiraOnPremiseIssueLinkDeleted

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueLinkDeletedEvent } from '@sr-connect/jira-on-premise/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, JiraOnPremiseProject2, JiraOnPremiseProject1)
}
TypeScriptProject2/OnJiraOnPremiseIssueUpdated

import JiraOnPremiseProject2 from '../api/jira/on-premise/2';
import JiraOnPremiseProject1 from '../api/jira/on-premise/1';
import { IssueUpdatedEvent } from '@sr-connect/jira-on-premise/events';
import { getEnvVars } from '../Utils';
import updateIssue from '../UpdateIssue';

/**
 * 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 { PredefinedUser } = getEnvVars(context);

    await updateIssue(context, event, JiraOnPremiseProject2, JiraOnPremiseProject1, PredefinedUser.JIRA_PROJECT_2_EMAIL);
}
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 JiraOnPremise from './api/jira/on-premise/1';
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { IssueCommentUpdatedEvent } from "@sr-connect/jira-on-premise/events";
import { RecordStorage } from '@sr-connect/record-storage';
import {
    extractIssueId,
    getComments,
    getEnvVars,
    getMatchingValue,
    getProjectIdentifier,
    getScriptRunnerConnectSyncIssueKey,
    removeOriginalUserParagraph,
    searchIssue
} from './Utils';

/**
 * This function updates a corresponding comment when a comment has been updated.
 */
export default async function updateComment(context: Context, event: IssueCommentUpdatedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi): Promise<void> {
    // Get the current user
    const myself = await sourceInstance.Myself.getCurrentUser();
    const myselfInTargetInstance = await targetInstance.Myself.getCurrentUser();

    // Check that the comment was updated by a different user than the one who set up the integration
    if (myself.emailAddress !== event.comment.updateAuthor?.emailAddress) {
        const userEmail = event.comment.updateAuthor?.emailAddress;
        const userDisplayName = event.comment.updateAuthor?.displayName;

        const metaData = {
            instance: sourceInstance === JiraOnPremise ? 1 : 2,
            user: `${userDisplayName} (${userEmail})`,
            commentId: event.comment.id
        };

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

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

        // Get the issue ID for the updated comment
        const updatedCommentIssueId = extractIssueId(event.comment.self ?? '0');

        // Check if Issue ID extraction was successful
        if (!updatedCommentIssueId) {
            throw new Error("Issue Id extraction failed");
        }

        // Get the source issue
        const issue = await sourceInstance.Issue.getIssue({
            issueIdOrKey: updatedCommentIssueId ?? '0',
            fields: 'issuetype, project, attachment'
        })

        // Extract source project key
        const sourceProjectKey = issue.fields?.project?.key ?? '0';

        // 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(issue.key ?? '0', sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, issue.fields?.issuetype?.id ?? '0');

        // Check if ScriptRunner Connect Sync Issue Key is missing
        if (scriptRunnerConnectSyncIssueKey === null) {
            throw new 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);

        // Find the matching comment ID
        const commentId = commentIds.find(ci => ci[sourceProjectNumberWithKey] === event.comment.id)?.[targetProjectNumberWithKey];

        // Check if matching comment ID was found
        if (!commentId) {
            throw new Error(`Couldn't find correct comment ID from Record Storage`);
        };

        // 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 ?? '';

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

        let issueCommentBody: string = event.comment.body || '';

        // Check if "Original comment by" is already in the comment body
        const hasOriginalUserText = issueCommentBody.includes('Original comment by');

        const matchingCommentAuthor = (await targetInstance.Issue.Comment.getComment({
            issueIdOrKey: issueKey,
            id: commentId
        })).author?.emailAddress;

        // Determine if current user is the author of the comment
        const isCurrentUserComment = myself.emailAddress === event.comment.author?.emailAddress;
        const isMatchingCommentAuthor = myselfInTargetInstance.emailAddress === matchingCommentAuthor;

        if (isCurrentUserComment) {
            // If the current user is the author
            if (isMatchingCommentAuthor) {
                // Append original user info if missing
                if (!hasOriginalUserText) {
                    issueCommentBody = `${issueCommentBody}\n\n${originalUserText}`;
                }
            } else {
                // Remove original user info if the author is different
                if (hasOriginalUserText) {
                    issueCommentBody = removeOriginalUserParagraph(issueCommentBody, 'Original comment by');
                }
            }
        } else {
            // If the comment author is somebody else
            if (hasOriginalUserText && !isMatchingCommentAuthor) {
                // Remove original user info if the current user is not the matching author
                issueCommentBody = removeOriginalUserParagraph(issueCommentBody, 'Original comment by');
            }

            // Append original user info if the current user matches the matching comment author
            if (isMatchingCommentAuthor && !hasOriginalUserText) {
                issueCommentBody = `${issueCommentBody}\n\n${originalUserText}`;
            }
        }

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

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

import JiraOnPremise from './api/jira/on-premise/1';
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { IssueUpdatedEvent } from "@sr-connect/jira-on-premise/events";
import { GetIssueResponseOK } from '@managed-api/jira-on-prem-v8-core/types/issue';
import { FieldsEditIssue } from '@managed-api/jira-on-prem-v8-core/definitions/issue';
import { UserFull } from '@managed-api/jira-on-prem-v8-core/definitions/user';
import { Attachment } from '@managed-api/jira-on-prem-v8-core/definitions/attachment';
import moveIssue from './MoveIssue';
import {
    checkAccountsByKey,
    checkAccountsOnJira,
    checkUserFieldOption,
    findAssignableUserOnJiraOnPrem,
    getCustomField,
    getEnvVars,
    getJiraCustomFieldIdAndType,
    getJiraIssueType,
    getJiraPriority,
    getMatchingValue,
    getOriginalUserToCustomFieldId,
    retrySearchIssueInTargetInstance,
    searchIssue,
    stringToArray,
    uploadAttachment
} from "./Utils";

/**
 * This function updates a corresponding issue when an issue has been updated.
 */
export default async function updateIssue(context: Context, event: IssueUpdatedEvent, sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi, targetProjectPredifinedUserEmail?: 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, RetryConfigurations, MOVE_ISSUES_BETWEEN_PROJECTS, UserFieldOptions, CUSTOM_FIELD_NAME } = 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.emailAddress !== event.user.emailAddress) && event.changelog?.items?.some(cl => (FIELDS.includes(cl.field ?? '') || CUSTOM_FIELDS.includes(cl.field ?? '') || event.issue_event_type_name === 'issue_moved' && MOVE_ISSUES_BETWEEN_PROJECTS))) {
        // Check the update is triggered by issue being moved
        if (event.issue_event_type_name === '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 environment variable is true, run moveIssue script
                return await moveIssue(context, event, sourceInstance, targetInstance, myself, targetProjectPredifinedUserEmail);
            }
            // If not, stop the update
            return;
        }

        const userDisplayName = event.user.displayName;
        const userEmail = event.user.emailAddress;
        const sourceProjectKey = event.issue.fields?.project?.key ?? '';
        const updatedIssue = event.issue.key ?? '';

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

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

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

        // Get the ScriptRunner Connect Sync Issue Key custom field
        const customField = await getCustomField(sourceInstance, CUSTOM_FIELD_NAME, sourceProjectKey, event.issue.fields?.issuetype?.id ?? '0');

        // Check if the custom field was found
        if (!customField) {
            // If not, throw an error
            throw Error('No ScriptRunner Connect Sync Issue Key custom field found on the updated issue.')
        }

        let issue: GetIssueResponseOK | undefined;
        let scriptRunnerConnectSyncIssueKey = null;
        let statusChanged = false;
        let scriptRunnerConnectSyncIssueKeyRetry = 0;

        do {
            // Get the updated issue
            issue = await sourceInstance.Issue.getIssue({
                issueIdOrKey: updatedIssue,
            });

            // Check if the custom field has a value
            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));
            }

            scriptRunnerConnectSyncIssueKeyRetry++;
        } while (scriptRunnerConnectSyncIssueKeyRetry < RetryConfigurations.MAX_RETRY_ATTEMPTS);

        // Check if ScriptRunner Connect Sync Issue Key custom field is still missing a value after retry logic
        if (scriptRunnerConnectSyncIssueKey === null) {
            // Throw and error
            throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
        }

        // Search for the matching issue in the target instance
        const issues = await retrySearchIssueInTargetInstance(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)

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

        // Extract the fields that were 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: FieldsEditIssue = {};

        // Object that contains changes that need to be updated on the source intance
        let sourceInstanceRequestBody: FieldsEditIssue = {};

        // 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}`);
        }

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

        // Get the matching issue type for target instance
        const updatedIssueTypeName = event.issue.fields?.issuetype?.name ?? ''
        const mappedIssueType = getMatchingValue(sourceInstance, updatedIssueTypeName, ISSUE_TYPES);

        // Get the issue type from target instance
        const issueType = await getJiraIssueType(mappedIssueType, targetInstance);

        // Get the current user in target instance
        const targetInstanceIntegrationUser = await targetInstance.Myself.getCurrentUser();

        // 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':
                    // Add issue type to request body
                    requestBody.issuetype = {
                        id: issueType.id ?? ''
                    };
                    break;
                case 'reporter':
                    // Extract the reporter email
                    const reporterEmail = event.issue.fields?.reporter?.emailAddress ?? '';

                    // Function that check USER_FIELD_OPTION value and handles the field appropriately
                    const reporterUserFieldOption = await checkUserFieldOption(
                        context,
                        targetInstance,
                        targetProjectKey,
                        reporterEmail ?? '',
                        targetInstanceIntegrationUser.name ?? '',
                        eventItem.field,
                        event.issue.fields?.reporter?.displayName ?? '',
                        targetProjectPredifinedUserEmail ?? '',
                        issueType.id ?? '0',
                        matchingIssue
                    );

                    // If value is returned, add it to the issue fields
                    if (reporterUserFieldOption) {
                        requestBody = { ...requestBody, ...reporterUserFieldOption }
                    }

                    // Check if COPY_ORIGINAL_USER_TO_CUSTOM_FIELD option is being used
                    if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' ||
                        UserFieldOptions.USER_FIELD_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD') {

                        // Find the custom field for storing reporter
                        const customFieldId = await getOriginalUserToCustomFieldId(
                            context,
                            sourceInstance,
                            eventItem.field,
                            sourceProjectKey,
                            event.issue.fields?.issuetype?.id ?? '0'
                        );

                        // Check if the field has a value in the source issue, and if so, remove it
                        const originFieldValue = event.issue.fields?.[customFieldId];

                        if (originFieldValue) {
                            sourceInstanceRequestBody[customFieldId] = null;
                        }
                    }
                    break;
                case 'assignee':
                    // Extract the assignee email
                    const assigneeEmail = event.issue.fields?.assignee?.emailAddress ?? '';

                    // Check if the field has a value
                    if (assigneeEmail) {
                        // Function that check USER_FIELD_OPTION value and handles the field appropriately
                        const assigneeUserFieldOption = await checkUserFieldOption(
                            context,
                            targetInstance,
                            targetProjectKey,
                            assigneeEmail ?? '',
                            targetInstanceIntegrationUser.name ?? '',
                            eventItem.field,
                            event.issue.fields?.assignee?.displayName ?? '',
                            targetProjectPredifinedUserEmail ?? '',
                            issueType.id ?? '0',
                            matchingIssue
                        )

                        // Add the result from checkUserFieldOption to issue fields
                        if (assigneeUserFieldOption) {
                            requestBody = { ...requestBody, ...assigneeUserFieldOption }
                        }

                        // Check if COPY_ORIGINAL_USER_TO_CUSTOM_FIELD option is being used
                        if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' ||
                            UserFieldOptions.USER_FIELD_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD') {

                            // Find the custom field for storing assignee
                            const sourceCustomFieldId = await getOriginalUserToCustomFieldId(
                                context,
                                sourceInstance,
                                eventItem.field,
                                sourceProjectKey,
                                event.issue.fields?.issuetype?.id ?? '0'
                            );

                            // Check if the field has a value in the source issue, and if so, remove it
                            const originFieldValue = event.issue.fields?.[sourceCustomFieldId];

                            if (originFieldValue) {
                                sourceInstanceRequestBody[sourceCustomFieldId] = null;
                            }
                        }
                    } else {
                        // Check if COPY_ORIGINAL_USER_TO_CUSTOM_FIELD option is being used
                        if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' ||
                            UserFieldOptions.USER_FIELD_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD') {

                            // Find the custom field for storing assignee
                            const customFieldId = await getOriginalUserToCustomFieldId(
                                context,
                                targetInstance,
                                eventItem.field,
                                targetProjectKey,
                                issueType?.id ?? '0'
                            );

                            requestBody[customFieldId] = null;
                        }

                        // Remove user from field
                        requestBody.assignee = {
                            name: ''
                        }
                    }
                    break;
                case 'priority':
                    // Extract the new priority
                    const updateIssuePriority = eventItem.toString ?? '';

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

                    // Find the correct priority
                    const priority = await getJiraPriority(matchingPiority, targetInstance);

                    // Add the priority to request body
                    requestBody.priority = {
                        id: priority.id
                    }
                    break;
                case 'labels':
                    // Add labels new value
                    requestBody.labels = event.issue.fields?.labels;
                    break;
                case 'duedate':
                    // Add due date new value
                    requestBody.duedate = event.issue.fields?.duedate;
                    break;
                case 'description':
                    // Add new description
                    requestBody.description = eventItem.toString ?? '';
                    break;
                case 'Attachment':
                    // Check if attachment was added or deleted
                    if (eventItem.to) {
                        // Extract attachment ID
                        const attachmentId = eventItem.to ?? '';

                        // Find the added attachment from issue
                        const attachment = issue?.fields?.attachment?.find(a => a.id === attachmentId);

                        if (attachment) {
                            // Upload attachment to target instance
                            await uploadAttachment(sourceInstance, targetInstance, attachment, issueKey);
                        }
                    } else {
                        // Extract issue attachments
                        const attachments = issues?.issues?.[0].fields?.attachment as Attachment[] ?? [];

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

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

                        // Delete the attachment
                        await targetInstance.Issue.Attachment.removeAttachment({
                            id: attachmentId
                        })
                    }
                    break;
                case 'Epic Name':
                    // Find the custom field from target instance and save the updated Epic Name
                    const epicNameField = await getCustomField(targetInstance, eventItem.field, targetProjectKey, issueType.id ?? '');

                    // Check if custom field was found
                    if (!epicNameField) {
                        // If not, throw an error
                        throw Error('Epic Name custom field was not found on issue');
                    }

                    // Add the updated Epic Name to request body
                    requestBody[epicNameField ?? ''] = eventItem.toString
                    break;
                case 'Epic Link':
                    let value = null;

                    // Find the Epic Link custom field
                    const epicLinkCustomField = await getCustomField(targetInstance, eventItem.field, targetProjectKey, issueType.id ?? '');

                    if (eventItem.toString) {
                        // Get the linked Epic issue
                        const epicLinkIssue = await sourceInstance.Issue.getIssue({
                            issueIdOrKey: eventItem.toString ?? '',
                            fields: customField
                        })

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

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

                        // Check if matching Epic Issue was found
                        if (matchingEpicLinkIssue.total === 0) {
                            // If not, throw an error
                            throw new Error('Could not find matching Epic Link Issue')
                        }

                        // Extract the issue key for matching Epic
                        value = matchingEpicLinkIssue.issues?.[0].key
                    }

                    // Add the updated Epic Name to request body
                    requestBody[epicLinkCustomField ?? ''] = value;
                    break;
                default:
                    break;
            }
        }

        // Filter custom fields that were updated and are added in the CUSTOM_FIELDS environment variable
        const updatedCustomFields = event.changelog?.items.filter(cl => CUSTOM_FIELDS.includes(cl.field ?? ''));

        // Check if any custom fields got updated
        if (updatedCustomFields.length) {
            // Iterate through updated custom fields
            for (const customField of updatedCustomFields) {

                // Get custom field ID and type
                const jiraCustomField = await getJiraCustomFieldIdAndType(sourceInstance, customField.field ?? '');

                // Check if custom field was found
                if (!jiraCustomField) {
                    throw Error(`Field ${customField} not found on Jsource instance`);
                }

                // Extract the custom field value
                const fieldValue = event.issue.fields?.[jiraCustomField.id];

                // Find the custom field in target instance
                const customFieldId = await getCustomField(targetInstance, customField.field ?? '', project.key ?? '', issueType.id ?? '0');

                // Check if custom field was found
                if (!customFieldId) {
                    throw new Error(`Could not find ${customField.field} from target instance`);
                }

                // Check the type and value of each custom field to appropriately update the issue fields
                switch (jiraCustomField.type) {
                    case 'Text Field (single line)':
                    case 'Number Field':
                    case 'URL Field':
                    case 'Date Picker':
                    case 'Date Time Picker':
                    case 'Labels':
                    case 'Text Field (multi-line)':
                    case 'Job Checkbox':
                    case 'Group Picker (single group)':
                        requestBody[customFieldId] = fieldValue;
                        break;
                    case 'Select List (multiple choices)':
                    case 'Checkboxes':
                        if (!fieldValue) {
                            requestBody[customFieldId] = null;
                        } else {
                            requestBody[customFieldId] = (fieldValue as { value: string }[]).map(field => ({ value: field.value }));
                        }
                        break;
                    case 'Select List (cascading)':
                        if (!fieldValue) {
                            requestBody[customFieldId] = null;
                        } else {
                            requestBody[customFieldId] = fieldValue?.child
                                ? {
                                    value: fieldValue.value,
                                    child: { value: fieldValue.child.value }
                                }
                                : { value: fieldValue.value };
                        }
                        break;
                    case 'Select List (single choice)':
                    case 'Radio Buttons':
                        if (!fieldValue) {
                            requestBody[customFieldId] = null;
                        } else {
                            requestBody[customFieldId] = {
                                value: fieldValue.value
                            }
                        }
                        break;
                    case 'User Picker (multiple users)':
                        // Get the target Issue
                        const targetIssue = await targetInstance.Issue.getIssue({
                            issueIdOrKey: issueKey,
                        })

                        // Extract users that are added on the issue
                        const currentlyAddedUsersOnJira: UserFull[] | null = targetIssue.fields?.[customFieldId];

                        // Verify whether any users have been added and if the fieldValue is not null
                        if (currentlyAddedUsersOnJira === null && fieldValue !== null) {
                            // Iterate through added users and extract their email
                            const users = (fieldValue as { emailAddress: string }[])?.map(field => ({
                                emailAddress: field.emailAddress ?? '',
                            }));

                            // Check if accounts can be added to the matching issue on Jira
                            const accountsToAdd = await checkAccountsOnJira(targetInstance, targetProjectKey, users)

                            if (accountsToAdd) {
                                // Adds valid accounts to the custom field
                                requestBody[customFieldId] = accountsToAdd.map(user => ({ name: user.name }));
                            }
                        } else {
                            // Extract the original Accounts from the issue
                            const originalListOfAccounts = stringToArray(customField.from ?? '')

                            // Extract the updated Accounts from the issue
                            const listOfAccounts = stringToArray(customField.to ?? '');

                            // Filter which accounts got removed and which added
                            const removedAccounts = originalListOfAccounts.filter(id => !listOfAccounts.includes(id));
                            const addedAccounts = listOfAccounts.filter(id => !originalListOfAccounts.includes(id));

                            // Map through currently added accounts on Jira and save their email and account name
                            const currentlyAddedUsers = (currentlyAddedUsersOnJira?.map(field => ({
                                emailAddress: field.emailAddress,
                                name: field.name
                            }))) ?? [];

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

                            // If any accounts got removed, add them to the accountsToRemove array
                            if (removedAccounts.length > 0) {
                                // Get the account email of removed users
                                const usersThatGotRemoved = await checkAccountsByKey(sourceInstance, sourceProjectKey, removedAccounts);

                                // Check if the removed accounts are valid Jira accounts and add them to the accountsToRemove array
                                const validJiraAccounts = await checkAccountsOnJira(targetInstance, targetProjectKey, usersThatGotRemoved);

                                if (validJiraAccounts.length > 0) {
                                    accountsToRemove = validJiraAccounts.map(user => user.name ?? '');
                                }
                            }

                            // If any account got added, add them to the accountsToAdd array
                            if (addedAccounts.length > 0) {
                                const addedUsers = (fieldValue as UserFull[]).filter(u => addedAccounts.includes(u.key ?? '')).map(user => ({
                                    emailAddress: user.emailAddress ?? '',
                                }));

                                // Check if added accounts are valid Jira accounts and add them to the accountsToAdd array
                                const validJiraAccounts = await checkAccountsOnJira(targetInstance, targetProjectKey, addedUsers);

                                if (validJiraAccounts.length > 0) {
                                    accountsToAdd = validJiraAccounts.map(user => user.name ?? '');
                                }
                            }

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

                            const accounts = newList?.map(value => ({ name: value }));

                            // Add necessary accounts to the request body
                            requestBody[customFieldId] = accounts;
                        }
                        break;
                    case 'User Picker (single user)':
                        // Check if user got added or removed
                        if (!fieldValue) {
                            requestBody[customFieldId] = null;
                        } else {
                            // Find account for user using email
                            const account = await findAssignableUserOnJiraOnPrem(targetInstance, targetProjectKey, fieldValue.emailAddress);

                            // If account is found, add it to the request body
                            if (account) {
                                requestBody[customFieldId] = {
                                    name: account
                                }
                            } else {
                                // If not, change the field value to null
                                requestBody[customFieldId] = null

                                // Log out a warning
                                console.warn(`User ${fieldValue.displayName}, (${fieldValue.emailAddress}) is not assignable to the field ${customField.field}.`)
                            }
                        }
                        break;
                    case 'Group Picker (multiple groups)':
                        if (!fieldValue) {
                            requestBody[customFieldId] = null;
                        } else {
                            requestBody[customFieldId] = (fieldValue as {
                                name: string, self: string
                            }[]).map(group => ({ name: group.name }));
                        }
                        break;
                    default:
                        break;
                }
            }
        }

        console.log('Updated issue fields', requestBody);

        // Check if issue status was changed
        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;

            // Check if transition ID was found
            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(`Updated issue: ${issueKey}`);
        }

        // If there are 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}`);
        }

        // If there are any fields in source instance requestBody, update the issue in source instance
        if (Object.keys(sourceInstanceRequestBody).length > 0) {
            await sourceInstance.Issue.editIssue({
                issueIdOrKey: updatedIssue,
                body: {
                    fields: sourceInstanceRequestBody,
                }
            });

            console.log(`Updated source issue: ${updatedIssue}`);
        }
    }
}
TypeScriptUtils

import JiraOnPremise1 from './api/jira/on-premise/1';
import { JiraOnPremApi } from "@managed-api/jira-on-prem-v8-sr-connect";
import { GetIssueResponseOK } from '@managed-api/jira-on-prem-v8-core/types/issue';
import { RecordStorage } from '@sr-connect/record-storage';
import { GetIssueCustomFieldsResponseOK } from '@managed-api/jira-on-prem-v8-core/types/issue/field/custom';
import { UserFull } from '@managed-api/jira-on-prem-v8-core/definitions/user';
import { FindAssignableUsersResponseOK } from '@managed-api/jira-on-prem-v8-core/types/user/search';
import { Issue } from '@sr-connect/jira-on-premise/types';
import { FieldsEditIssue } from '@managed-api/jira-on-prem-v8-core/definitions/issue';
import { Attachment } from '@managed-api/jira-on-prem-v8-core/definitions/attachment';
import { SearchIssuesByJqlResponseOK } from '@managed-api/jira-on-prem-v8-core/types/issue/search';

/**
 * Function that finds matching value for PROJECT, STATUS, PRIORITY, ISSUE TYPE
 */
export function getMatchingValue(instance: JiraOnPremApi, value: string, attribute: Record<string, string>) {
    return instance === JiraOnPremise1 ? attribute[value] : Object.keys(attribute).find(key => attribute[key] === value) ?? '';
}

/**
 * Function that finds the priority from Jira
 */
export async function getJiraPriority(value: string, instance: JiraOnPremApi) {
    const priorities = await instance.Issue.Priority.getPriorities();

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

    if (!priority) {
        throw new Error(`Could not find priority ${value} on target instance`)
    }

    return priority
}

/**
 * Function that finds issue type from Jira
 */
export async function getJiraIssueType(value: string, instance: JiraOnPremApi) {
    const issueTypes = await instance.Issue.Type.getTypes();

    const issueType = issueTypes.find(p => p.name === value);

    if (!issueType) {
        throw new Error(`Could not find issue type ${value} on target instance`)
    }

    return issueType
}

/**
 * Function to fetch create metadata for a specific project and issue type from Jira instance
 */
export async function getCreateMetaData(instance: JiraOnPremApi, projectKey: string, issueTypeId: string) {

    // Fetch create metadata for the given project and issue type
    const response = await instance.fetch(`/rest/api/2/issue/createmeta/${projectKey}/issuetypes/${issueTypeId}`);

    if (!response.ok) {
        throw new Error(`Failed to fetch create metadata for project: ${projectKey}, issue type ID: ${issueTypeId}`);
    }

    const createMetaData: CreateMetaData = await response.json();

    if (!createMetaData) {
        throw new Error(`Create metadata not found for project: ${projectKey}, issue type: ${issueTypeId}`);
    }

    return createMetaData;
}

/**
 * Function that finds the ID for custom field
 */
export async function getCustomField(instance: JiraOnPremApi, customFieldName: string, projectKey: string, issueTypeId: string) {
    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 getCreateMetaData(instance, projectKey, issueTypeId);

        let matchingFields: any[] = [];

        if (createMetaData.total > 0) {
            matchingFields = createMetaData.values.filter(field => field.name === customFieldName);
        }

        if (matchingFields.length > 1) {
            throw Error(`More than one custom field was found with this name: ${customFieldName}`);
        }

        if (matchingFields.length === 0) {
            console.log(`Custom field ${customFieldName} is not assignable to project ${projectKey}, issue type ID: ${issueTypeId}.`);
            return
        }

        return matchingFields?.[0].fieldId as string
    }

    return customField?.[0].id as string;
}


/**
 * Retrieves user information based on the provided account key
 */
export async function checkAccountsByKey(instance: JiraOnPremApi, projectKey: string, accountKeys: string[]): Promise<UserFull[]> {
    const maxResults = 50;
    let startAt = 0;
    let validUsers: UserFull[] = [];

    do {
        const response = await instance.User.Search.findAssignableUsers({
            startAt,
            maxResults,
            project: projectKey,
        });

        for (const key of accountKeys) {
            const validUser = response.find(u => u.key === key)

            if (validUser) {
                validUsers.push(validUser);
            }
        }

        startAt = response.length === maxResults ? startAt + maxResults : 0;
    } while (startAt > 0);

    return validUsers;
}

/**
 * Function that checks the value of the USER_FIELD_OPTION and updates the field accordingly
 */
export async function checkUserFieldOption(
    context: Context,
    targetInstance: JiraOnPremApi,
    targetProjectKey: string,
    email: string,
    integrationUser: string,
    fieldName: string,
    displayName: string,
    targetProjectPredifinedUser: string,
    issueType: string,
    matchingIssue?: GetIssueResponseOK
) {
    const { UserFieldOptions } = getEnvVars(context);

    switch (UserFieldOptions.USER_FIELD_OPTION) {
        case 'ORIGINAL_USER':
            const assignableUser = await findAssignableUserOnJiraOnPrem(targetInstance, targetProjectKey, email);

            // Check if user is assignable or not
            if (!assignableUser) {
                // Handle fallback for non-assignable user
                return await checkUserFieldFallbackValue(
                    context,
                    targetInstance,
                    targetProjectKey,
                    integrationUser,
                    fieldName,
                    displayName,
                    targetProjectPredifinedUser,
                    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]: { name: assignableUser },
                        [customFieldId]: ''
                    }
                    : { [fieldName]: { name: assignableUser } };
            }

            return { [fieldName]: { name: assignableUser } };
        case 'REMAIN_UNASSIGNED':
            return fieldName === 'reporter' ? { [fieldName]: { name: integrationUser } } : { [fieldName]: { name: null } }
        case 'INTEGRATION_USER':
            return {
                [fieldName]: { name: integrationUser }
            }
        case 'PREDEFINED_USER':
            const isPredefinedUserAssignable = await findAssignableUserOnJiraOnPrem(
                targetInstance,
                targetProjectKey,
                targetProjectPredifinedUser
            );

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

            return {
                [fieldName]: {
                    name: integrationUser
                },
                [customFieldId]: displayName
            };
        default:
            return;
    }
}

/**
 * Function that checks the value of USER_FIELD_FALLBACK_OPTION and updates the field accordingly
 */
export async function checkUserFieldFallbackValue(
    context: Context,
    targetInstance: JiraOnPremApi,
    targetProjectKey: string,
    integrationUser: string,
    fieldName: string,
    displayName: string,
    targetProjectPredifinedUser: string,
    issueType: string
) {
    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 predifined user instead
            return fieldName === 'reporter' ? { [fieldName]: { name: integrationUser } } : { [fieldName]: { name: null } }
        case 'INTEGRATION_USER':
            return { [fieldName]: { name: integrationUser } };
        case 'PREDEFINED_USER':
            return await handlePredefinedUser(targetInstance, targetProjectKey, fieldName, targetProjectPredifinedUser);
        case 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD':
            const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);
            const account = fieldName === 'reporter' ? { [fieldName]: { name: integrationUser } } : { [fieldName]: { name: null } }
            return {
                [customFieldId]: displayName,
                ...account
            };
        case 'HALT_SYNC':
            throw Error(`Script halted because user for field ${fieldName} was not found.`)
        default:
            return;
    }
}

/**
 * Function that copies original user to custom field
 */
export async function getOriginalUserToCustomFieldId(context: Context, targetInstance: JiraOnPremApi, 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);

    if (!customFieldId) {
        throw Error("Couldn't find Original user custom field for issue.")
    }

    return customFieldId;
}

/**
 * Function that handles predefined user
 */
export async function handlePredefinedUser(targetInstance: JiraOnPremApi, targetProjectKey: string, fieldName: string, targetProjectPredifinedUser: string) {
    if (!targetProjectPredifinedUser) {
        throw Error('Missing predifined username')
    };

    const predifinedUser = await findAssignableUserOnJiraOnPrem(targetInstance, targetProjectKey, targetProjectPredifinedUser);

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

    return {
        [fieldName]: { name: predifinedUser }
    };
}

/**
 * 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 finds the ID and type for Jira custom field
 */
export async function getJiraCustomFieldIdAndType(
    instance: JiraOnPremApi,
    customFieldName: string
): Promise<{ id: string, type: string } | undefined> {
    const maxResults = 50;
    let startAt = 0;
    let customFields: GetIssueCustomFieldsResponseOK;

    do {
        customFields = await instance.Issue.Field.Custom.getFields({
            startAt,
            maxResults
        });

        const customField = customFields.values?.find(f => f.name === customFieldName);

        if (customField) {
            return {
                id: customField.id ?? '',
                type: customField.type ?? ''
            }
        }

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

/**
 * 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,
    issueKeyOrId: string | number,
    instance: JiraOnPremApi,
    customFieldName: string
): Promise<string | null> {
    const { JIRA_PROJECTS } = getEnvVars(context);

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

    if (!issue) {
        console.warn(`Issue not found for key/ID: ${issueKeyOrId}.`);
        return null;
    }

    // Check if the linked issue belongs to a project added in the JIRA_PROJECTS array
    const isProjectInScope = instance === JiraOnPremise1
        ? 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) {
        throw new Error(`Issue ${issueKeyOrId} does not belong in to a synced project.`)
    }

    // Get the custom field
    const customField = (await getCustomField(instance, customFieldName, issue.fields?.project?.key ?? '', issue.fields?.issuetype?.id ?? '0')) ?? '';

    if (!customField) {
        console.warn(`Custom field ${customFieldName} not found for issue ${issueKeyOrId}.`);
        return null;
    }

    // Check if the field value exists
    const syncIssueKey = issue.fields?.[customField];

    if (!syncIssueKey) {
        console.warn(`Issue ${issueKeyOrId} is missing the ScriptRunner Connect Sync Issue Key.`);
        return null;
    }

    return syncIssueKey;
}

/**
 * Retrieves the ScriptRunner Connect Sync Issue Key from an issue link, with multiple retry attempts
 */
export async function retrySyncIssueKeyForIssueLinks(
    context: Context,
    issueIdOrKey: number | string,
    sourceInstance: JiraOnPremApi,
    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: JiraOnPremApi,
    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}`);
}


/**
 * Function that finds the issue using ScriptRunner Connect Sync Issue Key custom field
 */
export async function searchIssue(context: Context, issueKey: string, instance: JiraOnPremApi, 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;
}

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

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

/**
 * 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 checks accounts on Jira using email
 */
export async function checkAccountsOnJira(instance: JiraOnPremApi, projectKey: string, users: {
    emailAddress?: string,
}[]): Promise<UserFull[]> {
    const maxResults = 50;
    let startAt = 0;
    let validUsers: UserFull[] = [];

    do {
        const response = await instance.User.Search.findAssignableUsers({
            startAt,
            maxResults,
            project: projectKey
        });

        for (const user of users) {
            const validUser = response.find(u => u.emailAddress === user.emailAddress)

            if (validUser) {
                validUsers.push(validUser);
            }
        }

        startAt = response.length === maxResults ? startAt + maxResults : 0;
    } while (startAt > 0);

    return validUsers;
}

/**
 * Function that checks if user can be added to the issue on Jira
 */
export async function findAssignableUserOnJiraOnPrem(instance: JiraOnPremApi, projectKey: string, email: string): Promise<string | null> {
    const maxResults = 50;
    let startAt = 0;
    let users: FindAssignableUsersResponseOK;

    do {
        users = await instance.User.Search.findAssignableUsers({
            startAt,
            maxResults,
            project: projectKey
        });

        let user: UserFull | undefined;

        user = users.find(u => u.emailAddress === email);

        if (user) {
            return user.name ?? '';
        }

        if (users.length === maxResults) {
            startAt = startAt + maxResults;
        } else {
            startAt = 0;
        }
    } while (startAt > 0);

    return null;
}

/**
 * Function that extracts issue ID from URL
 */
export function extractIssueId(url: string) {
    const match = url.match(/\/issue\/(\d+)/);
    return match ? match[1] : null;
}

/**
 * Function that handles string value and changes it to string array
 */
export function stringToArray(originalString: string) {
    if (originalString.trim() === "" || originalString.trim() === "[]") {
        return [];
    }

    const trimmedString = originalString.trim().replace(/^\[|\]$/g, '');

    return trimmedString.split(',').map(item => item.trim());
}

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

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

    return scriptRunnerConnectSyncIssueKey
}

/**
 * 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 handles custom fields for issue creation
 */
export async function handleCustomFieldsForCreatingIssue(
    sourceInstance: JiraOnPremApi,
    targetInstance: JiraOnPremApi,
    targetProjectKey: string,
    customFields: string[],
    eventIssue: Issue,
    issueTypeId: string
): Promise<FieldsEditIssue> {
    const requestBody: FieldsEditIssue = {};

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

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

        // If the custom field has a value
        if (fieldValue) {
            // Find the custom field from target instance
            const jiraCustomField = await getJiraCustomFieldIdAndType(targetInstance, customField);

            switch (jiraCustomField?.type) {
                // Check if custom field is a string or a number
                case 'Text Field (single line)':
                case 'Text Field (multi-line)':
                case 'Number Field':
                case 'URL Field':
                case 'Date Picker':
                case 'Date Time Picker':
                case 'Labels':
                case 'Job Checkbox':
                case 'Group Picker (single group)':
                    // Add the value to the request body
                    requestBody[jiraCustomField.id] = fieldValue;
                    break;
                case 'Select List (multiple choices)':
                case 'Checkboxes':
                    requestBody[jiraCustomField.id] = (fieldValue as { value: string }[]).map(field => ({ value: field.value }));
                    break;
                case 'Select List (cascading)':
                    requestBody[jiraCustomField.id] = fieldValue?.child
                        ? {
                            value: fieldValue.value,
                            child: { value: fieldValue.child.value }
                        }
                        : { value: fieldValue.value };
                    break;
                case 'Select List (single choice)':
                case 'Radio Buttons':
                    requestBody[jiraCustomField.id] = {
                        value: fieldValue.value
                    }
                    break;
                case 'User Picker (multiple users)':
                    const users = (fieldValue as { emailAddress?: string, displayName: string }[]).map(field => ({
                        emailAddress: field.emailAddress ?? '',
                        displayName: field.displayName
                    }));

                    // Check if the account can be added to the issue
                    const validAccounts = await checkAccountsOnJira(targetInstance, targetProjectKey, users);

                    if (validAccounts) {
                        // Adds valid account IDs to the request body
                        requestBody[jiraCustomField.id] = validAccounts.map(user => ({ name: user.name }));
                    }
                    break;
                case 'User Picker (single user)':
                    // Check if user is assignable to the target project
                    const user = await findAssignableUserOnJiraOnPrem(targetInstance, targetProjectKey, fieldValue.emailAddress);

                    if (user) {
                        requestBody[jiraCustomField.id] = {
                            name: user
                        }
                    }
                    break;
                case 'Group Picker (multiple groups)':
                    requestBody[jiraCustomField.id] = (fieldValue as {
                        name: string, self: string
                    }[]).map(group => ({ name: group.name }));
                    break;
                default:
                    break;
            }
        }
    }

    // Return the updated requestBody
    return requestBody;
}

/**
 * Function that handles uploading attachment to target instance
 */
export async function uploadAttachment(sourceInstance: JiraOnPremApi, targetInstance: JiraOnPremApi, attachment: Attachment, issueKey: string) {
    if (attachment.content && attachment.filename) {
        // Fetch the source issue attachment from the source instance
        const storedAttachment = await sourceInstance.fetch(`/secure/attachment/${attachment.id}/${attachment.filename}`, {
            headers: {
                'x-stitch-store-body': 'true'
            }
        });

        // Check if the fetch request was successful
        if (!storedAttachment.ok) {
            // Throw an error if the response status indicates a failure
            throw Error(`Unexpected response while downloading attachment: ${storedAttachment.status}`);
        }

        // Retrieve the stored body ID from the response headers
        const storedAttachmentId = storedAttachment.headers.get('x-stitch-stored-body-id');

        // Check if the stored body ID was returned
        if (!storedAttachmentId) {
            // Throw an error if the stored body ID is not present
            throw new Error('The attachment stored body is was not returned');
        }

        // Upload the attachment to the target issue using the stored body ID
        await targetInstance.fetch(`/rest/api/2/issue/${issueKey}/attachments`, {
            method: 'POST',
            headers: {
                // Include necessary headers for the upload
                'X-Atlassian-Token': 'no-check',
                'x-stitch-stored-body-id': storedAttachmentId,
                'x-stitch-stored-body-form-data-file-name': attachment.filename
            }
        });

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

/*
* Function to remove the "Original comment by" paragraph from the comment body
*/
export const removeOriginalUserParagraph = (content: string, searchText: string) => {
    // Escape any special characters in the searchText to ensure an exact match
    const escapedSearchText = searchText.replace(/([.*+?^${}()|[\]\\])/g, '\\$1');
    // Create a regular expression to match the escaped search text and everything following it until the end of the line
    const regex = new RegExp(`${escapedSearchText}.*`, 'g');

    // Replace the matched text with an empty string and trim any extra spaces
    return content.replace(regex, '').trim();
};

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

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_EMAIL?: string;
        JIRA_PROJECT_2_EMAIL?: 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>;
    MOVE_ISSUES_BETWEEN_PROJECTS: boolean;
}



export interface CreateMetaData {
    maxResults: number,
    startAt: number,
    total: number,
    isLast: boolean,
    values: {
        fieldId: string;
        name: string;
        [x: string]: any;
    }[]
}
Documentation · Support · Suggestions & feature requests