Template Content
Scripts
About the integration
How does the integration logic work?
Which fields are being synced?
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?
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
Scripts
This template helps keep your Jira Cloud projects in sync by exchanging the following data:
Configure API connections and event listeners:
Add webhooks in Jira Cloud:
project = TEST
, which will only listen to the "TEST" project.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.Create custom text fields for syncing:
ScriptRunner Connect Sync Issue Key
. This will track the synced issue key from the other project.Configuring parameters:
Go to Parameters
and configure the parameters based on your project's needs.
FIELDS
parameter, uncheck any fields you don't want to sync.MOVE_ISSUES_BETWEEN_PROJECTS
to false
if you don't want issues to move between projects.💡 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.
5 November 2024
projectKey
from searchIssue
function.MoveIssue
.CreateComment
, CreateIssue
, CreateIssueLink
, DeleteComment
, DeleteIssue
,
DeleteIssueLink
, MoveIssue
, UpdateComment
, UpdateIssue
and UtilsJiraCloud
scripts
with these changes.27 September 2024
CreateIssue
, CreateIssueLink
, DeleteIssueLink
, MoveIssue
, and UtilsJiraCloud
scripts.CreateIssue
script to log warnings if errors occur after a successful issue creation.8 August 2024
CreateComment
, MoveIssue
, UpdateComment
, and DeleteComment
scripts to support inline attachments in comments.6 August 2024
getScriptRunnerConnectSyncIssueKeyForIssueLink
function.getCustomField
function.5 August 2024
CreateIssue
, MoveIssue
, UpdateIssue
, and UtilsJiraCloud
scripts to support inline attachments in descriptions.getCustomField
function to filter custom fields by project and issue type if multiple fields with the same name exist.getCustomField
function.1 August 2024
MoveIssue
script.30 July 2024
25 July 2024
USER_FIELD_FALLBACK_OPTION
was set to COPY_ORIGINAL_USER_TO_CUSTOM_FIELD
.UpdateIssue
script to prevent early transitions.Added Original comment by ${author}
to synced comments.import JiraCloud from './api/jira/cloud1';
import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import { RecordStorage } from '@sr-connect/record-storage';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { getComments, getEnvVars, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, searchIssue, getNewDescriptionWithMedia, hasMediaBlocks, AttachmentBody, AttachmentMediaMapping } from './UtilsJiraCloud';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
/**
* Function to create a new comment in target instance based on the comment created in source instance.
*/
export default async function createComment(context: Context, event: IssueCommentCreatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customField: string): Promise<void> {
console.log('Comment Created event:', event);
// Get the current user
const myself = await sourceInstance.Myself.getCurrentUser();
// Check that the comment was created by a different user than the one who set up the integration
if (myself.accountId !== event.comment.author.accountId) {
const userDisplayName = event.comment.updateAuthor.displayName;
const accountId = event.comment.updateAuthor.accountId;
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
issueKey: event.issue.key,
issueType: event.issue.fields.issuetype?.name,
user: `${userDisplayName} (${accountId})`,
commentId: event.comment.id
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
const { JIRA_PROJECTS } = getEnvVars(context);
// Get target project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
// Get the ScriptRunner Connect Sync Issue Key from the issue
const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(event.issue.key, sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);
if (scriptRunnerConnectSyncIssueKey === null) {
throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
}
// Find the matching issue from the other project
const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)
// Check if issue with a matching ScriptRunner Connect Sync Issue Key exists
if (issues.total === 0) {
// If not, throw an error
throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
};
// Extract the issue key
const issueKey = issues.issues?.[0].key ?? '';
const matchingIssueDetails = issues?.issues?.[0];
// Get the newly created comment
const comment = await sourceInstance.Issue.Comment.getComment({
id: event.comment.id,
issueIdOrKey: event.issue.key
});
let commentHasMediaBlocks = false;
let commentBody: doc_node;
commentHasMediaBlocks = hasMediaBlocks(comment.body);
// Check if comment has attachment
if (!commentHasMediaBlocks) {
// Add comment to request body
commentBody = comment.body
} else {
// Get issue attachments
const issueAttachments = (await sourceInstance.Issue.getIssue({
issueIdOrKey: event.issue.key,
})).fields?.attachment ?? [];
const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
const targetInstanceAttachments = matchingIssueDetails.fields.attachment ?? [];
const extractMediaIdHeader = commentHasMediaBlocks
? {
'x-stitch-extract-response-media-id': 'true',
'x-stitch-store-body': 'true'
}
: { 'x-stitch-store-body': 'true' };
// Loop through attachments and add them to the array
for (const attachment of issueAttachments) {
if (attachment.content && attachment.filename) {
const file = await sourceInstance.fetch(attachment.content, {
headers: {
...extractMediaIdHeader
}
});
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for created issue ${event.issue.key}}`)
}
// Get the attachment id from a response header
const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');
// // If no id could be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
sourceAttachmentIdAndMediaId.push({
id: attachment.id,
fileName: attachment.filename,
mediaId: mediaId,
storedAttachmentId
})
}
}
// Loop through target issue attachments and add them to array
for (const targetInstanceAttachment of targetInstanceAttachments) {
const file = await targetInstance.fetch(targetInstanceAttachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id')
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for updated issue ${issueKey}}`)
}
targetAttachmentIdAndMediaId.push({
id: targetInstanceAttachment.id,
fileName: targetInstanceAttachment.filename,
mediaId: targetMediaId,
})
}
// Filter attachments that are missing from issue
const attachmentsMissingInTargetInstance = sourceAttachmentIdAndMediaId.filter((el) => !targetAttachmentIdAndMediaId.find((file) => file.fileName === el.fileName));
// Loop through missing attachments and add them to missingAttachmentBody
for (const missingAttachment of attachmentsMissingInTargetInstance) {
const attachment = issueAttachments?.find(a => a.id === missingAttachment.id) ?? {}
// Check if the attachment was added
if (attachment.content && attachment.filename) {
// Upload the attachment to the target instance using the same feature
const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
method: 'POST',
headers: {
'X-Atlassian-Token': 'no-check',
'x-stitch-stored-body-id': missingAttachment.storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': attachment.filename
}
});
// Check if the attachment upload response is OK
if (!response.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${response.status}`);
}
const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();
console.log(`${attachment.filename} attachment added to issue: ${issueKey}`);
const file = await targetInstance.fetch(targetAttachment?.[0].content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id')
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for updated issue ${issueKey}}`)
}
targetAttachmentIdAndMediaId.push({
id: targetAttachment?.[0].id,
fileName: targetAttachment?.[0].filename,
mediaId: targetMediaId
})
}
}
commentBody = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, comment.body);
}
// Construct the original user
const originalUser = {
type: "paragraph",
content: [
{
type: 'text',
text: `Original comment by: ${event.comment.author.displayName}`
}
]
};
// Clone the existing comment body and add original user
const updatedCommentBody = {
...commentBody,
content: [...commentBody.content ?? '', originalUser]
};
// Create a new comment in target instance
const createdComment = await targetInstance.Issue.Comment.addComment({
issueIdOrKey: issueKey,
body: {
body: updatedCommentBody as doc_node
}
})
const storage = new RecordStorage();
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Get existing comments from Record Storage with the same ScriptRunner Connect Sync Issue Key
const comments = await getComments(storage, scriptRunnerConnectSyncIssueKey);
const commentIds = {
[sourceProjectNumberWithKey]: event.comment.id,
[targetProjectNumberWithKey]: createdComment.id
}
// Check if existing comments exist
if (!comments) {
// If they don't, create a new record
await storage.setValue(scriptRunnerConnectSyncIssueKey, commentIds);
} else {
// Id they do, update the existing record
const updatedComments = [...comments, commentIds];
await storage.setValue(scriptRunnerConnectSyncIssueKey, updatedComments);
}
console.log(`Comment created for Issue: ${issueKey}`)
}
}
import JiraCloud from './api/jira/cloud1';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import { IssueFieldsCreate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { AttachmentMediaMapping, checkAccountIds, checkUserFieldOption, getCustomField, getEnvVars, getFieldAndMatchingValue, getIssueLinks, getJiraPriority, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, searchIssue, setIssueLinks, hasMediaBlocks, getNewDescriptionWithMedia, getScriptRunnerConnectSyncIssueKeyForIssueLink } from './UtilsJiraCloud';
import { RecordStorage } from '@sr-connect/record-storage';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
/**
* Function to create a new issue in target instance based on the issue created in source instance.
*/
export default async function createIssue(context: Context, event: IssueCreatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string, targetProjectPredifinedUserAccountId?: string): Promise<void> {
console.log('Issue Created event:', event);
// Get the current user
let myself = await sourceInstance.Myself.getCurrentUser();
// Check if the current user does not match the person who committed the update
if (myself.accountId !== event.user.accountId) {
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const userDisplayName = event.user.displayName;
const accountId = event.user.accountId;
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
issueKey: event.issue.key,
issueType: event.issue.fields.issuetype?.name,
user: `${userDisplayName} (${accountId})`,
issueId: event.issue.id
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, IMPACT, CHANGE_REASON, CHANGE_RISK, CHANGE_TYPE } = getEnvVars(context);
// Find matching target project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
// Extract the issue issue key
const eventIssueKey = event.issue.key;
// Get the ScriptRunner Connect Sync Issue Key custom field
const sourceCustomField = await getCustomField(sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.issuetype?.name);
// Add issue key to the ScriptRunner Connect Sync Issue Key custom field
await sourceInstance.Issue.editIssue({
issueIdOrKey: eventIssueKey,
body: {
fields: {
[sourceCustomField]: eventIssueKey
}
}
})
// Find the project from target instance based on pre-defined project key
const project = await targetInstance.Project.getProject({
projectIdOrKey: targetProjectKey
});
// Check if the project was found
if (!project) {
// If not, then throw an error
throw Error(`Target project not found: ${targetProjectKey}`);
}
// Find the matching issue type for the other project
const issueTypeName = getMatchingValue(sourceInstance, event.issue.fields.issuetype?.name ?? '', ISSUE_TYPES);
// Find all the issue types for given project
const issueTypes = await targetInstance.Issue.Type.getTypesForProject({
projectId: +(project.id ?? 0) // + sign converts the string to number
});
// Find the issue type to use based on pre-defined issue type name
const issueType = issueTypes.find(it => it.name?.toLowerCase().replace(/\s/g, '') === issueTypeName.toLowerCase().replace(/\s/g, ''));
// Check if the issue type was found
if (!issueType) {
// If not, then throw an error
throw Error(`Issue Type not found in target instance: ${issueTypeName}`);
}
// Get the ScriptRunner Connect Sync Issue Key custom field from target instance
const targetCustomField = await getCustomField(targetInstance, customFieldName, targetProjectKey, issueType.name);
// Fields to be updated in target instance
let requestBody: IssueFieldsCreate = {
summary: event.issue.fields.summary ?? '',
project: {
key: project.key ?? ''
},
issuetype: {
name: issueType.name ?? ''
},
[targetCustomField]: eventIssueKey,
};
// Check if field exists in FIELDS array
if (FIELDS.includes('reporter')) {
// Extract the reporter
const reporter = event.issue.fields.reporter;
// Check the user field option value and user field fallback value from Values script and handle the field appropriately
const reporterUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, reporter?.accountId ?? '', myself.accountId ?? '', 'reporter', reporter?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name);
// If a value is returned add it to the request body
if (reporterUserFieldOption) {
requestBody = { ...requestBody, ...reporterUserFieldOption }
}
}
// Check if field exists in FIELDS array and assignee has been assigned
if (FIELDS.includes('assignee') && event.issue.fields.assignee !== null) {
// Extract the assignee
const assignee = event.issue.fields.assignee;
// Check the user field option value and user field fallback value from Values script and handle the field appropriately
const assigneeUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, assignee?.accountId ?? '', myself.accountId ?? '', 'assignee', assignee?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name) ?? '';
if (assigneeUserFieldOption) {
requestBody = { ...requestBody, ...assigneeUserFieldOption }
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('priority')) {
// Find the matching Jira Cloud priority name
const priorityName = getMatchingValue(sourceInstance, event.issue.fields.priority?.name ?? '', PRIORITY);
// Get the priority
const priority = await getJiraPriority(priorityName, targetInstance);
// Check if correct priority was found
if (!priority) {
// If not, throw an error
throw Error(`Priority not found in target instance: ${priority}`);
}
// Add priority Id to issue fields
requestBody.priority = { name: priority.name ?? '' }
}
let descriptionHasMediaBlocks = false;
let issueDescription: doc_node;
// Check if field exists in FIELDS array and description has been added
if (FIELDS.includes('description') && event.issue.fields.description !== null) {
// Get the description from the issue
issueDescription = (await sourceInstance.Issue.getIssue({
issueIdOrKey: eventIssueKey,
fields: ['description']
})).fields?.description;
descriptionHasMediaBlocks = hasMediaBlocks(issueDescription);
if (!descriptionHasMediaBlocks) {
// Add description to request body
requestBody.description = issueDescription
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Impact')) {
const impactField = await getCustomField(sourceInstance, 'Impact', sourceProjectKey, event.issue.fields.issuetype?.name);
const impact = event.issue.fields[impactField];
if (impact !== null) {
// Find the Impact field and matching value in target instance
const impactValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, impact.value, IMPACT, 'Impact', targetProjectKey, issueType.name);
// Add the Impact field to request body
requestBody[impactValues.field] = {
value: impactValues.matchingValue
};
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Change reason')) {
const changeReasonField = await getCustomField(sourceInstance, 'Change reason', sourceProjectKey, event.issue.fields.issuetype?.name);
const changeReason = event.issue.fields[changeReasonField];
if (changeReason !== null) {
// Find the Change reason and matching value in target instance
const changeReasonValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeReason.value, CHANGE_REASON, 'Change reason', targetProjectKey, issueType.name);
// Add the Change reason field to request body
requestBody[changeReasonValues.field] = {
value: changeReasonValues.matchingValue
};
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Change type')) {
const changeTypeField = await getCustomField(sourceInstance, 'Change type', sourceProjectKey, event.issue.fields.issuetype?.name);
const changeType = event.issue.fields[changeTypeField];
if (changeType !== null) {
// Find the Change type and matching value in target instance
const changeTypeValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeType.value, CHANGE_TYPE, 'Change type', targetProjectKey, issueType.name);
// Add the Change type field to request body
requestBody[changeTypeValues.field] = {
value: changeTypeValues.matchingValue
};
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Change risk')) {
const changeRiskField = await getCustomField(sourceInstance, 'Change risk', sourceProjectKey, event.issue.fields.issuetype?.name);
const changeRisk = event.issue.fields[changeRiskField];
if (changeRisk !== null) {
// Find the Change risk and matching value in target instance
const changeRiskValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeRisk.value, CHANGE_RISK, 'Change risk', targetProjectKey, issueType.name);
// Add the Change risk field to request body
requestBody[changeRiskValues.field] = {
value: changeRiskValues.matchingValue
};
}
}
// Check if duedate field exists in FIELDS array and if issue has due date added
if (FIELDS.includes('duedate') && event.issue.fields.duedate !== null) {
// If it does, add it to request body
requestBody.duedate = event.issue.fields.duedate;
}
// Check if labels field exist in FIELDS array and if issue has labels added
if (FIELDS.includes('labels') && event.issue.fields.labels?.length !== 0) {
// If it does, add it to request body
requestBody.labels = event.issue.fields.labels;
}
// Check if Sub-task was created
if (FIELDS.includes('issuetype') && event.changelog?.items.some(item => item.field === 'IssueParentAssociation')) {
const parentIssueKey = event.issue.fields.parent.key
const parentSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(parentIssueKey ?? '', sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.parent?.fields?.issuetype?.name);
const matchingIssue = await searchIssue(context, parentSyncIssueKey ?? '', targetInstance, false);
if (matchingIssue.total === 0) {
throw new Error(`Matching parent issue with sync key ${parentSyncIssueKey} missing`);
}
requestBody.parent = {
key: matchingIssue?.issues?.[0].key
}
}
// Get the created issue
const createdIssue = await sourceInstance.Issue.getIssue({
issueIdOrKey: eventIssueKey
});
// Check if custom fields have been added to CUSTOM_FIELDS array in Values script
if (CUSTOM_FIELDS.length) {
// Check the fields for creating an issue in the target instance
const createIssueMetadata = await targetInstance.Issue.Metadata.getCreateMetadata({
issuetypeNames: [issueTypeName],
projectKeys: [targetProjectKey],
expand: 'projects.issuetypes.fields'
})
// Extract custom fields
const createIssueFields = Object.entries(createIssueMetadata?.projects?.[0].issuetypes?.[0].fields ?? {})
.filter(([key]) => key.startsWith('customfield_'))
.map(([_, field]) => field.name);
// Filter out the custom fields that can be added to the issue
const customFieldsToCheck = CUSTOM_FIELDS.filter(f => createIssueFields.includes(f));
// If field names have been added there, we will add them to the request body
for (const customField of customFieldsToCheck) {
// Get custom field
const sourceInstanceCustomFieldId = await getCustomField(sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);
// Save its value
const value = event.issue.fields[sourceInstanceCustomFieldId];
// If the custom field has a value
if (value) {
// Find the custom field in target instance
const targetInstanceCustomFieldId = await getCustomField(targetInstance, customField, targetProjectKey, issueType.name);
// Check custom fields type
switch (true) {
// Check if custom field is a string or a number
case typeof value === 'string' || typeof value === 'number':
// Add the value to the request body
requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId];
break;
// Check if custom field is a array
case Array.isArray(value):
// Check if custom field is an object
if (typeof value[0] === 'object') {
// Check if the object in array has a value property
if (value[0].hasOwnProperty('value')) {
// If it does, map through the objects and save the values
requestBody[targetInstanceCustomFieldId] = (value as { value: string }[]).map(field => ({ value: field.value }));
}
// Check if the object in array has an accountId property
if (value[0].hasOwnProperty('accountId')) {
// If it does, save all the account IDs added in the custom field
const accountIds = (value as { accountId: string }[]).map(field => field.accountId);
// Check if the account IDs can be added to the issue
const validAccountIds = await checkAccountIds(targetInstance, targetProjectKey, accountIds);
// Add the valid account IDs to the request body
requestBody[targetInstanceCustomFieldId] = validAccountIds.map(value => ({ accountId: value }));
}
} else {
// Add the array to the request body
requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId]
}
break;
// Check if the custom field is an object
case typeof value === 'object':
// Add the value in the object to request body
requestBody[targetInstanceCustomFieldId] = {
value: createdIssue.fields?.[sourceInstanceCustomFieldId].value
}
break;
default:
break;
}
}
}
}
// Create a new Issue in Jira Cloud
const issue = await targetInstance.Issue.createIssue({
body: {
fields: requestBody,
}
});
// Extract the newly created Issue key
const issueKey = issue.key ?? '';
console.log(`Issue created: ${issueKey}`);
// Get the newly created issue
const createdTargetIssue = await targetInstance.Issue.getIssue({
issueIdOrKey: issueKey
})
const attachments = event.issue.fields.attachment ?? [];
// Check if attachments were added to the issue
if (FIELDS.includes('Attachment') && attachments.length > 0) {
try {
// Get the attachments from the issue
const issueAttachments = (await sourceInstance.Issue.getIssue({
issueIdOrKey: eventIssueKey
})).fields?.attachment ?? [];
const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
const targetInstanceAttachments: AddIssueAttachmentsResponseOK = []
const extractMediaIdHeader = descriptionHasMediaBlocks
? {
'x-stitch-extract-response-media-id': 'true',
'x-stitch-store-body': 'true'
}
: {
'x-stitch-store-body': 'true'
};
// Loop through attachments and add them to the array
for (const attachment of issueAttachments) {
if (attachment.content && attachment.filename) {
const file = await sourceInstance.fetch(attachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
// Get the attachment id from a response header
const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');
// If no id could be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
if (descriptionHasMediaBlocks) {
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for created issue ${eventIssueKey}}`)
}
sourceAttachmentIdAndMediaId.push({
id: attachment.id,
fileName: attachment.filename,
mediaId: mediaId,
storedAttachmentId
})
}
// Upload the attachment to the target instance using the same feature
const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
method: 'POST',
headers: {
'X-Atlassian-Token': 'no-check',
'x-stitch-stored-body-id': storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': attachment.filename
}
});
// Check if the upload response is OK
if (!response.ok) {
// If not, then throw an error
throw Error(`Unexpected response while uploading attachment: ${response.status}`);
}
const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();
targetInstanceAttachments.push(...targetAttachment);
}
}
if (descriptionHasMediaBlocks) {
for (const targetInstanceAttachment of targetInstanceAttachments) {
const file = await targetInstance.fetch(targetInstanceAttachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id')
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for newly created issue ${issueKey}}`)
}
targetAttachmentIdAndMediaId.push({
id: targetInstanceAttachment.id,
fileName: targetInstanceAttachment.filename,
mediaId: targetMediaId,
})
}
}
if (descriptionHasMediaBlocks) {
if (!targetInstanceAttachments.length) {
throw new Error(
`${issueKey} does not have any attachments but its description is expecting attachments. ` +
`Check if the attachments have been uploaded successfully.`
)
}
if (!targetAttachmentIdAndMediaId.length || !sourceAttachmentIdAndMediaId.length) {
throw new Error(
`${issueKey} does not have the necessary mapping to process the inline description attachments. `
)
}
const newDescription = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, issueDescription);
await targetInstance.Issue.editIssue({
issueIdOrKey: issueKey,
body: {
fields: {
description: newDescription
}
}
})
}
} catch (error) {
console.warn(`Adding attachments to issue ${issueKey} failed. In case of an ATTACHMENT_VALIDATION_ERROR, the description update with attachments failed.`, error)
}
}
// Check if newly created Issue has the correct status and find the matching status
const status = getMatchingValue(sourceInstance, event.issue.fields.status?.name ?? '', STATUS);
// Check if status is incorrect, and then change it
if (createdTargetIssue.fields?.status?.name !== status) {
try {
const transitions = (await targetInstance.Issue.Transition.getTransitions({
issueIdOrKey: issueKey
})).transitions ?? [];
const transitionId = transitions.find(t => t.to?.name === status || t.name === status)?.id ?? ''
if (!transitionId) {
throw Error(`Transition for status not found in target instance: ${status}`);
}
// Change the status of the issue (workflow transition)
await targetInstance.Issue.Transition.performTransition({
issueIdOrKey: issueKey,
body: {
transition: {
id: transitionId
}
}
});
} catch (error) {
console.warn(`Transitioning issue ${issueKey} failed`);
}
};
// Check if issue links exist in FIELDS array and if issue has issue links added
if (FIELDS.includes('issue links') && createdIssue.fields?.issuelinks?.length) {
try {
const issueLinks = event.issue.fields.issuelinks ?? [];
// Go over created issue links
for (const issueLink of issueLinks) {
// Extract issue keys
const outwardLinkedIssueKey = issueLink.outwardIssue?.key;
const inwardLinkedIssueKey = issueLink.inwardIssue?.key;
const storage = new RecordStorage();
// Check for existing issue links
const existingIssueLinks = await getIssueLinks(storage, event.issue.key);
// Check target instance has valid issue link type
const issueLinkTypes = await targetInstance.Issue.Link.Type.getTypes();
const issueLinkType = (issueLinkTypes.issueLinkTypes?.find((types) => types.name === issueLink.type.name));
if (!issueLinkType) {
throw Error(`Issue Link Type ${issueLink.type.name} doesn't exist in the target project`);
}
// Handle outward issue link
if (outwardLinkedIssueKey) {
// Find the Sync key for outward issue
const syncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, outwardLinkedIssueKey ?? '', sourceInstance, customFieldName);
if (syncIssueKey) {
// Find the matching issue from target instance
const targetIssue = (await searchIssue(context, syncIssueKey ?? '', targetInstance, false))
if (targetIssue.total === 0) {
throw new Error(`No matching issue with sync key ${syncIssueKey} was found for the issue link.`);
}
// Create issue link in target instance
await targetInstance.Issue.Link.createLink({
body: {
outwardIssue: {
key: targetIssue.issues?.[0].key,
},
type: {
name: issueLink.type.name
},
inwardIssue: {
key: issue.key
}
},
})
// Get the issue link id
const createdIssueLinkId = (await targetInstance.Issue.getIssue({
issueIdOrKey: issue.key ?? '0'
})).fields?.issuelinks?.find(x => x.outwardIssue?.key === targetIssue.issues?.[0].key && x.type.name === issueLink.type.name)?.id
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Save issue link mapping into Record Storage
const newIssueLink = {
[sourceProjectNumberWithKey]: issueLink.id,
[targetProjectNumberWithKey]: createdIssueLinkId ?? '0',
}
// Save issue links to Record Storage
const updatedIssueLinks = [...existingIssueLinks, newIssueLink];
await setIssueLinks(storage, eventIssueKey, updatedIssueLinks);
console.log(`Issue link created bewteen ${targetIssue.issues?.[0].key} and ${issue.key}`);
}
}
// Handle inward issue link
if (inwardLinkedIssueKey) {
// Find the Sync key for inward issue
const syncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, inwardLinkedIssueKey ?? '', sourceInstance, customFieldName);
if (syncIssueKey) {
// Find the matching issue from target instance
const targetIssue = (await searchIssue(context, syncIssueKey ?? '', targetInstance, false))
if (targetIssue.total === 0) {
throw new Error(`No matching issue with sync key ${syncIssueKey} was found for the issue link.`);
}
// Create issue link in target instance
await targetInstance.Issue.Link.createLink({
body: {
outwardIssue: {
key: issue.key
},
type: {
name: issueLink.type.name
},
inwardIssue: {
key: targetIssue.issues?.[0].key,
}
},
})
// Get the created issue link id
const createdIssueLinkId = (await targetInstance.Issue.getIssue({
issueIdOrKey: targetIssue.issues?.[0].key ?? '0'
})).fields?.issuelinks?.find(x => x.outwardIssue?.key === issue.key && x.type.name === issueLink.type.name)?.id
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Save issue link mapping into Record Storage
const newIssueLink = {
[sourceProjectNumberWithKey]: issueLink.id,
[targetProjectNumberWithKey]: createdIssueLinkId ?? '0'
}
// Save issue links to Record Storage
const updatedIssueLinks = [...existingIssueLinks, newIssueLink];
await setIssueLinks(storage, syncIssueKey, updatedIssueLinks);
console.log(`Issue link created bewteen ${targetIssue.issues?.[0].key} and ${issue.key}`);
}
}
};
} catch (error) {
console.warn('Issue link creation failed: ', error)
}
};
}
}
import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueLinkCreatedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { getEnvVars, getIssueLinks, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, getScriptRunnerConnectSyncIssueKeyForIssueLink, searchIssue, setIssueLinks } from "./UtilsJiraCloud";
export default async function createIssueLink(context: Context, event: IssueLinkCreatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi): Promise<void> {
console.log('Issue Link Created:', event);
const { JIRA_PROJECTS, CUSTOM_FIELD_NAME } = getEnvVars(context);
const issueId = event.issueLink.sourceIssueId;
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
inwardIssueId: event.issueLink.sourceIssueId,
destinationIssueId: event.issueLink.destinationIssueId,
issueLinkType: event.issueLink.issueLinkType.name,
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
// Get the Issue Sync Issue Keys for both inward and outward linked issue
const inwardIssueSyncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, event.issueLink.sourceIssueId.toString(), sourceInstance, CUSTOM_FIELD_NAME);
const outwardIssueSyncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, event.issueLink.destinationIssueId.toString(), sourceInstance, CUSTOM_FIELD_NAME);
// Check if sync issue keys for both issues exist
if (inwardIssueSyncIssueKey && outwardIssueSyncIssueKey) {
// Get source project key and find matching target project key
const sourceProjectKey = (await sourceInstance.Issue.getIssue({
issueIdOrKey: issueId.toString()
})).fields?.project?.key ?? '';
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
// Check if the matching issue has been created
const matchingInwardIssue = await searchIssue(context, inwardIssueSyncIssueKey, targetInstance, false);
const matchingOutwardIssue = await searchIssue(context, outwardIssueSyncIssueKey, targetInstance, false);
if (!matchingInwardIssue.issues?.length || !matchingOutwardIssue.issues?.length) {
console.log('Issue link cannot be created, matching issue is missing.')
return;
}
if (matchingInwardIssue.issues.length && matchingOutwardIssue.issues.length) {
const storage = new RecordStorage();
// Get issue links related to inward issue
const savedIssueLinks = await getIssueLinks(storage, inwardIssueSyncIssueKey);
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
const found = savedIssueLinks.some((issueLinks) => issueLinks[sourceProjectNumberWithKey] === event.issueLink.id.toString());
if (found) {
console.log('Issue Link already exists')
} else {
// Create new issue link
await targetInstance.Issue.Link.createLink({
body: {
inwardIssue: {
id: matchingInwardIssue.issues?.[0].id
},
outwardIssue: {
id: matchingOutwardIssue.issues?.[0].id
},
type: {
name: event.issueLink.issueLinkType.name
}
}
})
// Get the created issue link id from target inward issue
const createdIssueLinkId = (await targetInstance.Issue.getIssue({
issueIdOrKey: matchingInwardIssue.issues?.[0].id ?? '0'
})).fields?.issuelinks?.find(x => x.outwardIssue?.key === matchingOutwardIssue.issues?.[0].key && x.type.name === event.issueLink.issueLinkType.name)?.id
const issueLink = {
[sourceProjectNumberWithKey]: event.issueLink.id.toString(),
[targetProjectNumberWithKey]: createdIssueLinkId ?? '0'
}
// Add created issue link to Record Storage
const updatedIssueLinks = [...savedIssueLinks, issueLink];
await setIssueLinks(storage, inwardIssueSyncIssueKey, updatedIssueLinks);
console.log(`Issue link created bewteen ${matchingInwardIssue.issues?.[0].key} and ${matchingOutwardIssue.issues?.[0].key}`);
}
}
}
}
import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueCommentDeletedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { getComments, getEnvVars, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, hasMediaBlocks, searchIssue } from "./UtilsJiraCloud";
export default async function deleteComment(context: Context, event: IssueCommentDeletedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customField: string): Promise<void> {
console.log('Comment Deleted event: ', event);
const userDisplayName = event.comment.author.displayName;
const accountId = event.comment.author.accountId;
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
issueKey: event.issue.key,
issueType: event.issue.fields.issuetype?.name,
commentCreatedBy: `${userDisplayName} (${accountId})`,
commentId: event.comment.id
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
// Search for the issue that event was triggered from
const issueExists = await sourceInstance.Issue.getIssue<null>({
issueIdOrKey: event.issue.key,
errorStrategy: {
handleHttp404Error: () => null
}
})
// Check if the issue still exists
if (issueExists) {
const { JIRA_PROJECTS } = getEnvVars(context);
// Find matching target project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
// Get the ScriptRunner Connect Sync Issue Key from the issue
const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(event.issue.key, sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);
if (scriptRunnerConnectSyncIssueKey === null) {
throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
}
const storage = new RecordStorage();
// Get comments from Record Storage with the ScriptRunner Connect Sync Issue Key
const comments = await getComments(storage, scriptRunnerConnectSyncIssueKey);
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Find the comment ID for the target instance that matches the comment that got deleted
const commentId = comments.find(c => c[sourceProjectNumberWithKey] === event.comment.id)?.[targetProjectNumberWithKey];
// Check if the matching comment ID was found
if (!commentId) {
// If not, throw an error
throw Error(`Couldn't find comment ID from Record Storage: ${commentId}. Comment might already be deleted`);
};
// Find the matching issue from the target instance
const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, false);
// Check if issue with a matching ScriptRunner Connect Issue Key exists
if (issues.total === 0) {
// If not, throw an error
throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
};
// Extract the issue key
const issueKey = issues.issues?.[0].key ?? '';
// Get the matching comment
const commentBody = (await targetInstance.Issue.Comment.getComment({
id: commentId,
issueIdOrKey: issueKey
})).body;
let commentHasMediaBlocks = false;
// Check if the comment has an attachment
commentHasMediaBlocks = hasMediaBlocks(commentBody);
// Delete the comment
await targetInstance.Issue.Comment.deleteComment({
issueIdOrKey: issueKey,
id: commentId ?? '0'
})
// Check if issue has some comments left
if (comments.length > 1) {
// If it has then remove the deleted comment from Record Storage
const updatedComments = comments.filter(c => c[sourceProjectNumberWithKey] !== event.comment.id);
await storage.setValue(scriptRunnerConnectSyncIssueKey, updatedComments);
} else {
// If not, then delete the value from Record Storage since we don't need to keep it there anymore
await storage.deleteValue(scriptRunnerConnectSyncIssueKey);
}
console.log(`Comment deleted for Issue: ${issueKey}`);
// If matching comment had an attachment, delete deleted attachments
if (commentHasMediaBlocks) {
// Get issue attachments for the source issue
const sourceIssueAttachments = (await sourceInstance.Issue.getIssue({
issueIdOrKey: event.issue.key,
})).fields?.attachment ?? [];
// Get issue attachments for the target issue
const targetIssueAttachments = (await targetInstance.Issue.getIssue({
issueIdOrKey: issueKey,
})).fields?.attachment ?? [];
// Check if the issues have different amount of attachments
if (sourceIssueAttachments.length !== targetIssueAttachments.length) {
// Check if target issue has more attachments than the source issue
if (targetIssueAttachments.length > sourceIssueAttachments.length) {
// If so, filter out attachments that should get deleted
const filteredTargetAttachments = targetIssueAttachments.filter(
sourceItem => !sourceIssueAttachments.some(targetItem => targetItem.filename === sourceItem.filename)
);
for (const attachmentToDelete of filteredTargetAttachments) {
await targetInstance.Issue.Attachment.deleteAttachment({
id: attachmentToDelete.id
})
}
}
// Check if target issue has less attachments than the source issue
if (targetIssueAttachments.length < sourceIssueAttachments.length) {
// If so, filter out attachments that should get deleted
const filteredSourceAttachments = sourceIssueAttachments.filter(
sourceItem => !targetIssueAttachments.some(targetItem => targetItem.filename === sourceItem.filename)
);
for (const attachmentToDelete of filteredSourceAttachments) {
await sourceInstance.Issue.Attachment.deleteAttachment({
id: attachmentToDelete.id
})
}
}
}
}
}
}
import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueDeletedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { getCustomField, getEnvVars, getMatchingValue, searchIssue } from "./UtilsJiraCloud";
export default async function deleteIssue(context: Context, event: IssueDeletedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string): Promise<void> {
console.log('Issue Deleted event: ', event);
// Get the current user
let myself = await sourceInstance.Myself.getCurrentUser();
// Check if the current user does not match the person who committed the update
if (myself.accountId !== event.user.accountId) {
const userDisplayName = event.user.displayName;
const accountId = event.user.accountId;
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
issueKey: event.issue.key,
issueType: event.issue.fields.issuetype?.name,
user: `${userDisplayName} (${accountId})`,
issueId: event.issue.id
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
const { JIRA_PROJECTS, FIELDS } = getEnvVars(context);
// Find matching target project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
const customField = await getCustomField(sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.issuetype?.name);
// Get the ScriptRunner Connect Sync Issue Key from deleted issue
const scriptRunnerConnectSyncIssueKey = event.issue.fields[customField] as string | undefined;
if (!scriptRunnerConnectSyncIssueKey) {
throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
}
// Find the matching issue from the other project
const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, false);
if (issues.total === 0) {
throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
}
const issueKey = issues.issues?.[0].key ?? '';
// Delete the issue from the target instance
await targetInstance.Issue.deleteIssue({
issueIdOrKey: issueKey,
deleteSubtasks: "true",
});
const storage = new RecordStorage();
if (FIELDS.includes('comments')) {
// Check if there are any comments cached with this ScriptRunnerConnect Sync Issue Key
const comments = await storage.valueExists(scriptRunnerConnectSyncIssueKey);
// If there are then delete them
if (comments) {
await storage.deleteValue(scriptRunnerConnectSyncIssueKey)
}
}
if (FIELDS.includes('issue links')) {
// Check if there are any issue links cached with this ScriptRunnerConnect Sync Issue Key
const issueLinks = await storage.valueExists(`issue_link_${scriptRunnerConnectSyncIssueKey}`);
// If there are then delete them
if (issueLinks) {
await storage.deleteValue(`issue_link_${scriptRunnerConnectSyncIssueKey}`)
}
}
console.log(`Deleted Issue: ${issueKey}`)
}
}
import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueLinkDeletedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { getEnvVars, getIssueLinks, getMatchingValue, getProjectIdentifier, getScriptRunnerConnectSyncIssueKeyForIssueLink, setIssueLinks } from "./UtilsJiraCloud";
export default async function deleteIssueLink(context: Context, event: IssueLinkDeletedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi): Promise<void> {
console.log('Issue Link Deleted event: ', event);
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
inwardIssueId: event.issueLink.sourceIssueId,
destinationIssueId: event.issueLink.destinationIssueId,
issueLinkType: event.issueLink.issueLinkType.name,
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
const storage = new RecordStorage();
const { JIRA_PROJECTS, CUSTOM_FIELD_NAME } = getEnvVars(context);
// Extract deleted issue link id
const eventIssueLinkId = event.issueLink.id.toString();
// Retrieve the list of issue link IDs to ignore during deletion.
// This helps prevent accidental deletion of issue links that are currently being processed.
// This is useful in scenarios where moving issues results in multiple issue links being processed simultaneously.
const ignore = await storage.getValue('ISSUE_MOVED') as string[] | undefined;
// If the `ignore` array includes the current event's issue link ID, stop the script execution.
// This ensures that any issue link that still exists aren't deleted.
if (ignore && ignore.includes(eventIssueLinkId)) {
return;
}
const sourceIssueId = event.issueLink.sourceIssueId.toString();
// Search for the issue Sync Key from the inward issue
const issueSyncKeyForInwardIssue = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, sourceIssueId, sourceInstance, CUSTOM_FIELD_NAME) ?? '';
// 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 and find matching target project key
const sourceProjectKey = (await sourceInstance.Issue.getIssue({
issueIdOrKey: sourceIssueId
})).fields?.project?.key ?? '';
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Get all the saved issue links from Record Storage with that Sync Key
const savedIssueLinks = await getIssueLinks(storage, issueSyncKeyForInwardIssue);
const issueLinkId = savedIssueLinks.find(il => il[sourceProjectNumberWithKey] === eventIssueLinkId)?.[targetProjectNumberWithKey];
// Check if the matching Issue Link ID was found
if (!issueLinkId) {
// If not, throw an error
throw Error(`Couldn't find issue link ID from Record Storage. Issue Link might be deleted.`);
};
// Delete the link from target instance
await targetInstance.Issue.Link.deleteLink({
linkId: issueLinkId
})
// Check if there are any issue links left with that key
if (savedIssueLinks.length > 1) {
// If it has then remove the delete issue link from Record Storage
const updatedIssueLinks = savedIssueLinks.filter(il => il[sourceProjectNumberWithKey] !== eventIssueLinkId);
await setIssueLinks(storage, issueSyncKeyForInwardIssue, updatedIssueLinks);
} else {
// If not, then delete the value from Record Storage since we don't need to keep it there anymore
await storage.deleteValue(`issue_link_${issueSyncKeyForInwardIssue}`);
}
console.log(`Issue link deleted`);
}
}
import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueUpdatedEvent } from "@sr-connect/jira-cloud/events";
import { AttachmentBody, AttachmentMediaMapping, checkUserFieldOption, getCustomField, getEnvVars, getFieldAndMatchingValue, getIssueLinks, getJiraPriority, getMatchingValue, getNewDescriptionWithMedia, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, getScriptRunnerConnectSyncIssueKeyForIssueLink, handleCustomFieldsForCreatingIssue, hasMediaBlocks, retrySearchIssueInTargetInstance, retrySyncIssueKeyForIssueLinks, searchIssue, setIssueLinks } from "./UtilsJiraCloud";
import { IssueFieldsCreate, IssueFieldsUpdate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { GetCurrentUserResponseOK } from '@managed-api/jira-cloud-v3-core/types/myself';
import { RecordStorage } from '@sr-connect/record-storage';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { SearchIssuesByJqlResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/search';
import { AttachmentAsResponse } from '@managed-api/jira-cloud-v3-core/definitions/AttachmentAsResponse';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
export default async function moveIssue(context: Context, event: IssueUpdatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string, myself: GetCurrentUserResponseOK, targetProjectPredifinedUserAccountId?: string): Promise<void> {
console.log('Issue Moved event: ', event);
const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, IMPACT, CHANGE_REASON, CHANGE_RISK, CHANGE_TYPE, RetryConfigurations } = getEnvVars(context);
const changeLogItems = event.changelog?.items ?? [];
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const keyChange = changeLogItems.find(item => item.field === 'Key');
const projectChange = changeLogItems.find(item => item.field === 'project');
const issueTypeChange = changeLogItems.find(item => item.field === 'issuetype');
const oldIssueKey = keyChange?.fromString ?? '';
const newIssueKey = keyChange?.toString ?? '';
const oldProjectKey = projectChange?.fromString ?? '';
const newProjectKey = projectChange?.toString ?? '';
const oldIssueType = issueTypeChange?.fromString ?? '';
const newIssueType = issueTypeChange?.toString ?? '';
const userDisplayName = event.user.displayName;
const accountId = event.user.accountId;
const storage = new RecordStorage();
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
oldIssueKey: oldIssueKey,
newIssueKey: newIssueKey,
oldProject: oldProjectKey,
newProject: newProjectKey,
oldIssueType: oldIssueType,
newIssueType: newIssueType,
user: `${userDisplayName} (${accountId})`,
};
console.log('Going to perform', event.webhookEvent, event.issue_event_type_name, 'event:', metaData);
// Find target Project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
// Get the ScriptRunner Connect Sync Issue Key custom field
const sourceCustomField = await getCustomField(sourceInstance, customFieldName, newProjectKey, newIssueType);
// Extract the previous Script Runner Connect Sync Issue Key
const previousScriptRunnerConnectSyncIssueKey = event.issue?.fields?.[sourceCustomField];
// Update the ScriptRunner Connect Sync Issue Key custom field
await sourceInstance.Issue.editIssue({
issueIdOrKey: newIssueKey,
body: {
fields: {
[sourceCustomField]: newIssueKey,
}
}
})
// Find the project from target instance
const project = await targetInstance.Project.getProject({
projectIdOrKey: targetProjectKey
});
// Check if the project was found
if (!project) {
// If not, then throw an error
throw Error(`Target project not found: ${targetProjectKey}`);
}
// Find the matching issue type for the other project
const issueTypeName = getMatchingValue(sourceInstance, event.issue.fields.issuetype?.name ?? '', ISSUE_TYPES);
// Find all the issue types for given project
const issueTypes = await targetInstance.Issue.Type.getTypesForProject({
projectId: +(project.id ?? 0) // + sign converts the string to number
});
// Find the issue type to use based on pre-defined issue type name
const issueType = issueTypes.find(it => it.name?.toLowerCase().replace(/\s/g, '') === issueTypeName.toLowerCase().replace(/\s/g, ''));
// Check if the issue type was found
if (!issueType) {
// If not, then throw an error
throw Error(`Issue Type not found in target instance: ${issueTypeName}`);
}
// Get the ScriptRunner Connect Sync Issue Key custom field from target instance
const targetCustomField = await getCustomField(targetInstance, customFieldName, targetProjectKey, issueType.name);
// Fields to be updated in target instance
let requestBody: IssueFieldsCreate = {
summary: event.issue.fields.summary ?? '',
project: {
key: project.key ?? ''
},
issuetype: {
name: issueType.name ?? ''
},
[targetCustomField]: newIssueKey,
};
// Request body for fields we can't add when creating the issue
let requestBodyForTransition: IssueFieldsUpdate = {};
// Get editMetadata for the created Issue
const editMetadata = await sourceInstance.Issue.Metadata.getEditMetadata({
issueIdOrKey: newIssueKey,
})
// Filter fields that were required for this moved issue
const requiredFields = Object.entries(editMetadata.fields ?? {})
.filter(([, field]) => field.required)
.map(([key, field]) => key.startsWith('customfield_') ? field.name : key);
const fieldsThatIssueSupports = Object.entries(editMetadata.fields ?? {})
.map(([key, field]) => key.startsWith('customfield_') ? field.name : key);
// Check the fields for creating an issue in the target instance
const createIssueMetadata = await targetInstance.Issue.Metadata.getCreateMetadata({
issuetypeNames: [issueTypeName],
projectKeys: [targetProjectKey],
expand: 'projects.issuetypes.fields'
})
// Extract required fields
const createIssueFields = Object.entries(createIssueMetadata?.projects?.[0].issuetypes?.[0].fields ?? {})
.map(([key, field]) => key.startsWith('customfield_') ? field.name : key);
// Compare the two arrays and save the fields that we cannot add when we create the issue
const requiredFieldsForTransition = requiredFields.filter(field => !createIssueFields.includes(field));
// Check if field exists in FIELDS array
if (FIELDS.includes('reporter')) {
// Extract the reporter
const reporter = event.issue.fields.reporter;
// Check the user field option value and user field fallback value from Values script and handle the field appropriately
const reporterUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, reporter?.accountId ?? '', myself.accountId ?? '', 'reporter', reporter?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name);
// If a value is returned add it to the request body
if (reporterUserFieldOption) {
requestBody = { ...requestBody, ...reporterUserFieldOption }
}
}
// Check if field exists in FIELDS array and assignee has been assigned
if (FIELDS.includes('assignee') && event.issue.fields.assignee !== null) {
// Extract the assignee
const assignee = event.issue.fields.assignee;
// Check the user field option value and user field fallback value from Values script and handle the field appropriately
const assigneeUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, assignee?.accountId ?? '', myself.accountId ?? '', 'assignee', assignee?.displayName ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name) ?? '';
if (assigneeUserFieldOption) {
if (createIssueFields.includes('assignee')) {
requestBody = { ...requestBody, ...assigneeUserFieldOption }
} else if (fieldsThatIssueSupports.includes('assignee')) {
requestBodyForTransition = { ...requestBodyForTransition, ...assigneeUserFieldOption }
}
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('priority')) {
// Find the matching Jira Cloud priority name
const priorityName = getMatchingValue(sourceInstance, event.issue.fields.priority?.name ?? '', PRIORITY);
// Get the priority
const priority = await getJiraPriority(priorityName, targetInstance);
// Check if correct priority was found
if (!priority) {
// If not, throw an error
throw Error(`Priority not found in target instance: ${priority}`);
}
if (createIssueFields.includes('priority')) {
// Add priority name to issue fields
requestBody.priority = { name: priority.name ?? '' }
} else if (fieldsThatIssueSupports.includes('priority')) {
requestBodyForTransition.priority = { name: priority.name ?? '' }
}
}
let descriptionHasMediaBlocks = false;
let issueDescription: doc_node;
// Check if field exists in FIELDS array and description has been added
if (FIELDS.includes('description') && event.issue.fields.description !== null) {
// Get the description from the issue
issueDescription = (await sourceInstance.Issue.getIssue({
issueIdOrKey: newIssueKey,
fields: ['description']
})).fields?.description;
descriptionHasMediaBlocks = hasMediaBlocks(issueDescription);
if (!descriptionHasMediaBlocks) {
if (createIssueFields.includes('description')) {
// Add description to issue fields
requestBody.description = issueDescription
} else if (fieldsThatIssueSupports.includes('description')) {
requestBodyForTransition.description = issueDescription
}
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Impact')) {
const impactField = await getCustomField(sourceInstance, 'Impact', sourceProjectKey, event.issue.fields.issuetype?.name);
const impact = event.issue.fields[impactField];
if (impact !== null) {
// Find the Impact field and matching value in target instance
const impactValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, impact.value, IMPACT, 'Impact', targetProjectKey, issueType.name);
if (createIssueFields.includes('Impact')) {
// Add the Impact field to request body
requestBody[impactValues.field] = {
value: impactValues.matchingValue
};
} else if (fieldsThatIssueSupports.includes('Impact')) {
requestBodyForTransition[impactValues.field] = {
value: impactValues.matchingValue
};
}
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Change reason')) {
const changeReasonField = await getCustomField(sourceInstance, 'Change reason', sourceProjectKey, event.issue.fields.issuetype?.name);
const changeReason = event.issue.fields[changeReasonField];
if (changeReason !== null) {
// Find the Change reason and matching value in target instance
const changeReasonValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeReason.value, CHANGE_REASON, 'Change reason', targetProjectKey, issueType.name);
if (createIssueFields.includes('Change reason')) {
// Add the Change reason field to request body
requestBody[changeReasonValues.field] = {
value: changeReasonValues.matchingValue
};
} else if (fieldsThatIssueSupports.includes('Change reason')) {
requestBodyForTransition[changeReasonValues.field] = {
value: changeReasonValues.matchingValue
};
}
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Change type')) {
const changeTypeField = await getCustomField(sourceInstance, 'Change type', sourceProjectKey, event.issue.fields.issuetype?.name);
const changeType = event.issue.fields[changeTypeField];
if (changeType !== null) {
// Find the Change type and matching value in target instance
const changeTypeValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeType.value, CHANGE_TYPE, 'Change type', targetProjectKey, issueType.name);
if (createIssueFields.includes('Change type')) {
// Add the Change type field to request body
requestBody[changeTypeValues.field] = {
value: changeTypeValues.matchingValue
};
} else if (fieldsThatIssueSupports.includes('Change type')) {
requestBodyForTransition[changeTypeValues.field] = {
value: changeTypeValues.matchingValue
};
}
}
}
// Check if field exists in FIELDS array
if (FIELDS.includes('Change risk')) {
const changeRiskField = await getCustomField(sourceInstance, 'Change risk', sourceProjectKey, event.issue.fields.issuetype?.name);
const changeRisk = event.issue.fields[changeRiskField];
if (changeRisk !== null) {
// Find the Change risk and matching value in target instance
const changeRiskValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, changeRisk.value, CHANGE_RISK, 'Change risk', targetProjectKey, issueType.name);
if (createIssueFields.includes('Change risk')) {
// Add the Change type field to request body
requestBody[changeRiskValues.field] = {
value: changeRiskValues.matchingValue
};
} else if (fieldsThatIssueSupports.includes('Change risk')) {
requestBodyForTransition[changeRiskValues.field] = {
value: changeRiskValues.matchingValue
};
}
}
}
// Check if duedate field exists in FIELDS array and if issue has due date added
if (FIELDS.includes('duedate') && event.issue.fields.duedate !== null) {
if (createIssueFields.includes('duedate')) {
// Add the duedate to request body
requestBody.duedate = event.issue.fields.duedate;
} else if (fieldsThatIssueSupports.includes('duedate')) {
requestBodyForTransition.duedate = event.issue.fields.duedate;
}
}
// Check if labels field exist in FIELDS array and if issue has labels added
if (FIELDS.includes('labels') && event.issue.fields.labels?.length !== 0) {
if (createIssueFields.includes('labels')) {
// If it does, add it to request body
requestBody.labels = event.issue.fields.labels;
} else if (fieldsThatIssueSupports.includes('labels')) {
requestBodyForTransition.labels = event.issue.fields.labels;
}
}
// Check if Sub-task was created
if (FIELDS.includes('issuetype') && (newIssueType === 'Sub-task' || newIssueType === 'Subtask')) {
const parentIssueKey = event.issue.fields.parent?.key
let parentSyncIssueKey: string | null = null;
let matchingIssue: SearchIssuesByJqlResponseOK | undefined;
let attempts = 0;
do {
// Get ScriptRunner Connect Sync Issue Key for parent
parentSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(
parentIssueKey ?? '',
sourceInstance,
customFieldName,
sourceProjectKey,
event.issue.fields?.parent?.fields?.issuetype?.id ?? '0'
);
// Search for parent issue in target instance
matchingIssue = await searchIssue(context, parentSyncIssueKey ?? '0', targetInstance, false);
// Check if we found a matching issue and it's in the correct project
if (matchingIssue.total && matchingIssue.total > 0 && parentSyncIssueKey && matchingIssue.issues?.[0].fields?.project?.key === targetProjectKey) {
break; // Exit loop if matching issue is found
}
console.log('No matching issue found. Retrying...');
attempts++;
await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));
} while (attempts < RetryConfigurations.MAX_RETRY_ATTEMPTS); // Continue until max attempts reached
// Check if a matching issue was found
if (matchingIssue?.total === 0) {
// If not, throw an error
throw Error(`Issue with the matching Issue Parent ScriptRunner Connect Sync Issue Key does not exist in target instance: ${parentSyncIssueKey}`);
};
// Add matching parent issue key to issue fields
requestBody.parent = {
key: matchingIssue?.issues?.[0].key
}
}
// Get the moved issue
const createdIssue = await sourceInstance.Issue.getIssue({
issueIdOrKey: newIssueKey
});
// Check if custom fields have been added to CUSTOM_FIELDS array in Values script
if (CUSTOM_FIELDS.length) {
// Filter custom fields that we can add to create issue
const customFields = CUSTOM_FIELDS.filter(field => createIssueFields.includes(field));
// Filter custom fields that we can't add while creating an issue but should be present in update issue
const requiredCustomFieldsForTranstition = CUSTOM_FIELDS.filter(field =>
!createIssueFields.includes(field) && fieldsThatIssueSupports.includes(field)
);
// If field names have been added there, we will add them to the request body
if (customFields.length) {
const customFieldsBody = await handleCustomFieldsForCreatingIssue(sourceInstance, targetInstance, targetProjectKey, customFields, event.issue, createdIssue, issueType.name);
requestBody = { ...requestBody, ...customFieldsBody }
}
if (requiredCustomFieldsForTranstition.length) {
const transitionCustomFieldsBody = await handleCustomFieldsForCreatingIssue(sourceInstance, targetInstance, targetProjectKey, requiredCustomFieldsForTranstition, event.issue, createdIssue, issueType.name);
requestBodyForTransition = { ...requestBodyForTransition, ...transitionCustomFieldsBody }
}
}
// Create a new Issue in Jira Cloud
const issue = await targetInstance.Issue.createIssue({
body: {
fields: requestBody,
}
})
// Extract the newly created Issue key
const issueKey = issue.key ?? '';
const attachments = event.issue.fields.attachment ?? [];
const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
let issueAttachments: AttachmentAsResponse[];
let targetInstanceAttachments: AddIssueAttachmentsResponseOK = [];
const extractMediaIdHeader = descriptionHasMediaBlocks
? {
'x-stitch-extract-response-media-id': 'true',
'x-stitch-store-body': 'true'
}
: {
'x-stitch-store-body': 'true'
};
// Check if attachments were added to the issue
if ((FIELDS.includes('Attachment') || descriptionHasMediaBlocks) && attachments.length > 0) {
// Get the attachments from the issue
issueAttachments = (await sourceInstance.Issue.getIssue({
issueIdOrKey: newIssueKey
})).fields?.attachment ?? [];
// Loop through attachments and add them to the array
for (const attachment of issueAttachments) {
if (attachment.content && attachment.filename) {
const file = await sourceInstance.fetch(attachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
// Get the attachment id from a response header
const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');
// If no id could be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
if (descriptionHasMediaBlocks) {
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for issue ${newIssueKey}}`)
}
sourceAttachmentIdAndMediaId.push({
id: attachment.id,
fileName: attachment.filename,
mediaId: mediaId,
storedAttachmentId
})
}
// Upload the attachment to the target instance using the same feature
const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
method: 'POST',
headers: {
'X-Atlassian-Token': 'no-check',
'x-stitch-stored-body-id': storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': attachment.filename
}
});
// Check if the attachment upload response is OK
if (!response.ok) {
// If not, then throw an error
throw Error(`Unexpected response while uploading attachment: ${response.status}`);
}
const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();
targetInstanceAttachments.push(...targetAttachment);
}
}
if (descriptionHasMediaBlocks) {
for (const targetInstanceAttachment of targetInstanceAttachments) {
const file = await targetInstance.fetch(targetInstanceAttachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id',)
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for issue ${issueKey}}`)
}
targetAttachmentIdAndMediaId.push({
id: targetInstanceAttachment.id,
fileName: targetInstanceAttachment.filename,
mediaId: targetMediaId
})
}
}
if (descriptionHasMediaBlocks) {
if (!targetInstanceAttachments.length) {
throw new Error(
`${issueKey} does not have any attachments but its description is expecting attachments. ` +
`Check if the attachments have been uploaded successfully.`
)
}
if (!targetAttachmentIdAndMediaId.length || !sourceAttachmentIdAndMediaId.length) {
throw new Error(
`${issueKey} does not have the necessary mapping to process the inline description attachments. `
)
}
const newDescription = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, issueDescription);
await targetInstance.Issue.editIssue({
issueIdOrKey: issueKey,
body: {
fields: {
description: newDescription
}
}
})
}
}
// Check if newly created Issue has the correct status and find the matching status
const createdTargetIssue = await targetInstance.Issue.getIssue({
issueIdOrKey: issueKey
})
// CHeck if moved issue has comments and if does, add them to the new issue
if (FIELDS.includes('comments')) {
const issueComments = await sourceInstance.Issue.Comment.getComments({
issueIdOrKey: newIssueKey,
});
if (issueComments.total && issueComments.total > 0) {
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
const matchingComments = [];
for (const comment of issueComments.comments ?? []) {
let commentHasMediaBlocks = false;
let issueCommentBody: doc_node;
commentHasMediaBlocks = hasMediaBlocks(comment.body);
if (!commentHasMediaBlocks) {
issueCommentBody = {
type: 'doc',
version: 1,
content: [...comment.body?.content ?? []]
};
} else {
issueCommentBody = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, comment.body);
}
// Check if we have to add original commentator
if (comment.author?.accountId !== myself.accountId) {
// Construct the original user
const originalUser = {
type: "paragraph",
content: [
{
type: 'text',
text: `Original comment by: ${comment.author?.displayName}`
}
]
};
issueCommentBody.content.push(originalUser as any);
}
const createdComment = await targetInstance.Issue.Comment.addComment({
issueIdOrKey: issueKey,
body: {
body: issueCommentBody as doc_node
}
})
const commentIds = {
[sourceProjectNumberWithKey]: comment.id,
[targetProjectNumberWithKey]: createdComment.id
}
matchingComments.push(commentIds)
}
// Save the new comment Id's to record storage
await storage.setValue(newIssueKey, matchingComments)
// Delete comment Id's from record storage with old Sync Key
await storage.deleteValue(previousScriptRunnerConnectSyncIssueKey);
}
}
const status = getMatchingValue(sourceInstance, event.issue.fields.status?.name ?? '', STATUS);
// Check if status is incorrect, and then change it
if (createdTargetIssue.fields?.status?.name !== status) {
const transitions = (await targetInstance.Issue.Transition.getTransitions({
issueIdOrKey: issueKey
})).transitions ?? [];
const transitionId = transitions.find(t => t.to?.name === status || t.name === status)?.id ?? ''
if (!transitionId) {
throw Error(`Transition for status not found in target instance: ${status}`);
}
// Change the status of the issue (workflow transition)
await targetInstance.Issue.Transition.performTransition({
issueIdOrKey: issueKey,
body: {
transition: {
id: transitionId
},
fields: requestBodyForTransition
}
});
};
// Check if issue links exist in FIELDS array and if issue has issue links added
if (FIELDS.includes('issue links') && createdIssue.fields?.issuelinks?.length) {
const oldIssueLinks = await storage.valueExists(`issue_link_${previousScriptRunnerConnectSyncIssueKey}`);
// If there are then delete them
if (oldIssueLinks) {
await storage.deleteValue(`issue_link_${previousScriptRunnerConnectSyncIssueKey}`)
}
const issueLinks = event.issue.fields.issuelinks ?? [];
// Go over created issue links
for (const issueLink of issueLinks) {
// Extract issue keys
const outwardLinkedIssueKey = issueLink.outwardIssue?.key;
const inwardLinkedIssueKey = issueLink.inwardIssue?.key;
// Check for existing issue links
const existingIssueLinks = await getIssueLinks(storage, event.issue.key);
// Check target instance has valid issue link type
const issueLinkTypes = await targetInstance.Issue.Link.Type.getTypes();
const issueLinkType = (issueLinkTypes.issueLinkTypes?.find((types) => types.name === issueLink.type.name));
if (!issueLinkType) {
throw Error(`Issue Link Type ${issueLink.type.name} doesn't exist in the target project`);
}
// Handle outward issue link
if (outwardLinkedIssueKey) {
// Find the Sync key for outward issue
const syncIssueKey = await retrySyncIssueKeyForIssueLinks(context, outwardLinkedIssueKey ?? '', sourceInstance, customFieldName);
if (syncIssueKey) {
// Find the matching issue from target instance
const targetIssue = await retrySearchIssueInTargetInstance(context, syncIssueKey ?? '', targetInstance, false)
// Create issue link in target instance
await targetInstance.Issue.Link.createLink({
body: {
outwardIssue: {
key: targetIssue.issues?.[0].key,
},
type: {
name: issueLink.type.name
},
inwardIssue: {
key: issue.key
}
},
})
// Get the issue link id
const createdIssueLinkId = (await targetInstance.Issue.getIssue({
issueIdOrKey: issue.key ?? '0'
})).fields?.issuelinks?.find(x => x.outwardIssue?.key === targetIssue.issues?.[0].key && x.type.name === issueLink.type.name)?.id
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Save issue link mapping into Record Storage
const newIssueLink = {
[sourceProjectNumberWithKey]: issueLink.id,
[targetProjectNumberWithKey]: createdIssueLinkId ?? '0',
}
// Save issue links to Record Storage
const updatedIssueLinks = [...existingIssueLinks, newIssueLink];
await setIssueLinks(storage, newIssueKey, updatedIssueLinks);
console.log(`Issue link created between ${targetIssue.issues?.[0].key} and ${issue.key}`);
}
}
// Handle inward issue link
if (inwardLinkedIssueKey) {
// Find the Sync key for inward issue
const syncIssueKey = await retrySyncIssueKeyForIssueLinks(context, inwardLinkedIssueKey ?? '', sourceInstance, customFieldName);
if (syncIssueKey) {
// Find the matching issue from target instance
const targetIssue = await retrySearchIssueInTargetInstance(context, syncIssueKey ?? '', targetInstance, false)
// Create issue link in target instance
await targetInstance.Issue.Link.createLink({
body: {
outwardIssue: {
key: issue.key
},
type: {
name: issueLink.type.name
},
inwardIssue: {
key: targetIssue.issues?.[0].key,
}
},
})
// Get inward issue for source instance
const inwardIssue = await sourceInstance.Issue.getIssue({
issueIdOrKey: inwardLinkedIssueKey
})
// Get the updated target inward issue
const targetIssueWithInward = await targetInstance.Issue.getIssue({
issueIdOrKey: targetIssue.issues?.[0].key ?? '0'
})
// Find the create issue link ID
const createdIssueLinkId = targetIssueWithInward?.fields?.issuelinks?.find(link => link.outwardIssue?.key === issue.key && link.type?.name === issueLink.type?.name)?.id;
const inwardIssueProjectNumberWithKey = getProjectIdentifier(sourceInstance, inwardIssue.fields?.project?.key ?? '');
const targetInwardIssueProjectNumberWithKey = getProjectIdentifier(targetInstance, targetIssueWithInward.fields?.project?.key ?? '');
// Get existing inward issue links from storage
const existingInwardIssueLinks = await getIssueLinks(storage, syncIssueKey)
// Save issue link mapping into Record Storage
const newIssueLink = {
[inwardIssueProjectNumberWithKey]: issueLink.id,
[targetInwardIssueProjectNumberWithKey]: createdIssueLinkId ?? '0'
}
// Save issue links to Record Storage
const updatedIssueLinks = [...existingInwardIssueLinks.filter(il => il[inwardIssueProjectNumberWithKey] !== issueLink.id), newIssueLink];
await setIssueLinks(storage, syncIssueKey, updatedIssueLinks);
console.log(`Issue link created between ${targetIssue.issues?.[0].key} and ${issue.key}`);
}
}
};
};
const issueToBeDeleted = await searchIssue(context, previousScriptRunnerConnectSyncIssueKey, targetInstance, false)
if (issueToBeDeleted.total && issueToBeDeleted.total > 0) {
// If found, check for any sub-tasks associated with the issue
if ((issueToBeDeleted.issues?.[0].fields?.subtasks ?? []).length > 0) {
const subTasks = issueToBeDeleted.issues?.[0].fields?.subtasks ?? [];
for (const subTask of subTasks) {
// Get the sub-task
const issue = await targetInstance.Issue.getIssue({
issueIdOrKey: subTask.key ?? '0',
errorStrategy: {
handleHttp404Error: () => null // Returns null if the sub-task does not exist
}
})
// If the sub-task does not exist, skip to the next iteration
if (!issue) {
continue;
}
const issueLinks = issue.fields?.issuelinks ?? [];
const inwardIssueLinks = issueLinks.filter(link => link.inwardIssue);
const ignoreIssueLinks = []
// Add inward issue link IDs to ignore if they exist
if (inwardIssueLinks.length > 0) {
for (const issueLink of inwardIssueLinks) {
const linkId = issueLink.id
ignoreIssueLinks.push(linkId)
}
}
// If there are issue links to ignore, store them in Record Storage with a TTL
// This is used so we don't end up accidentallly deleting still valid issue links when multiple issues are being moved
if (ignoreIssueLinks.length > 0) {
await storage.setValue('ISSUE_MOVED', ignoreIssueLinks, {
ttl: 5
});
}
}
}
// Proceed to delete the old issue, along with its sub-tasks if any
await targetInstance.Issue.deleteIssue({
issueIdOrKey: issueToBeDeleted.issues?.[0].key ?? '0',
deleteSubtasks: 'true',
})
console.log(`Deleted issue ${issueToBeDeleted.issues?.[0].key}`);
}
}
import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject1 from '../api/jira/cloud1';
import JiraCloudProject2 from '../api/jira/cloud2';
import createComment from '../CreateComment';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Comment Created event
*
* @param event Object that holds Comment Created event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCommentCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await createComment(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME);
}
import { IssueCommentDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteComment from '../DeleteComment';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Comment Deleted event
*
* @param event Object that holds Comment Deleted event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCommentDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await deleteComment(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME);
}
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-cloud/events';
import updateComment from '../UpdateComment';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Comment Updated event
*
* @param event Object that holds Comment Updated event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCommentUpdatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await updateComment(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME);
}
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import createIssue from '../CreateIssue';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Issue Created event
*
* @param event Object that holds Issue Created event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);
await createIssue(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_2_ACCOUNT_ID);
}
import { IssueDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteIssue from '../DeleteIssue';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Issue Deleted event
*
* @param event Object that holds Issue Deleted event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await deleteIssue(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME)
}
import { IssueLinkCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import createIssueLink from '../CreateIssueLink';
/**
* Entry point to Issue Link Created event
*
* @param event Object that holds Issue Link Created event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueLinkCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
await createIssueLink(context, event, JiraCloudProject1, JiraCloudProject2);
}
import { IssueLinkDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteIssueLink from '../DeleteIssueLink';
/**
* Entry point to Issue Link Deleted event
*
* @param event Object that holds Issue Link Deleted event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueLinkDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
await deleteIssueLink(context, event, JiraCloudProject1, JiraCloudProject2)
}
import { IssueUpdatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import updateIssue from '../UpdateIssue';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Issue Updated event
*
* @param event Object that holds Issue Updated event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueUpdatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);
await updateIssue(context, event, JiraCloudProject1, JiraCloudProject2, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_2_ACCOUNT_ID);
}
import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject1 from '../api/jira/cloud1';
import JiraCloudProject2 from '../api/jira/cloud2';
import createComment from '../CreateComment';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Comment Created event
*
* @param event Object that holds Comment Created event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCommentCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await createComment(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME);
}
import { IssueCommentDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import deleteComment from '../DeleteComment';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Comment Deleted event
*
* @param event Object that holds Comment Deleted event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCommentDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await deleteComment(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME);
}
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-cloud/events';
import updateComment from '../UpdateComment';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Comment Updated event
*
* @param event Object that holds Comment Updated event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCommentUpdatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await updateComment(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME);
}
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import createIssue from '../CreateIssue';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Issue Created event
*
* @param event Object that holds Issue Created event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);
await createIssue(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_1_ACCOUNT_ID);
}
import { IssueDeletedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject1 from '../api/jira/cloud1';
import JiraCloudProject2 from '../api/jira/cloud2';
import deleteIssue from '../DeleteIssue';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Issue Deleted event
*
* @param event Object that holds Issue Deleted event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
await deleteIssue(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME)
}
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueLinkCreatedEvent } from '@sr-connect/jira-cloud/events';
import createIssueLink from '../CreateIssueLink';
/**
* Entry point to Issue Link Created event
*
* @param event Object that holds Issue Link Created event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueLinkCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
await createIssueLink(context, event, JiraCloudProject2, JiraCloudProject1);
}
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import { IssueLinkDeletedEvent } from '@sr-connect/jira-cloud/events';
import deleteIssueLink from '../DeleteIssueLink';
/**
* Entry point to Issue Link Deleted event
*
* @param event Object that holds Issue Link Deleted event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueLinkDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
await deleteIssueLink(context, event, JiraCloudProject2, JiraCloudProject1)
}
import { IssueUpdatedEvent } from '@sr-connect/jira-cloud/events';
import JiraCloudProject2 from '../api/jira/cloud2';
import JiraCloudProject1 from '../api/jira/cloud1';
import updateIssue from '../UpdateIssue';
import { getEnvVars } from '../UtilsJiraCloud';
/**
* Entry point to Issue Updated event
*
* @param event Object that holds Issue Updated event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IssueUpdatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally or manually from the Event Listener. Please consider using Event Listener Test Event Payload if you need to trigger this script manually.');
return;
}
const { CUSTOM_FIELD_NAME, PredefinedUser } = getEnvVars(context);
await updateIssue(context, event, JiraCloudProject2, JiraCloudProject1, CUSTOM_FIELD_NAME, PredefinedUser.JIRA_PROJECT_1_ACCOUNT_ID);
}
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);
}
import JiraCloud from './api/jira/cloud1';
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueCommentUpdatedEvent } from "@sr-connect/jira-cloud/events";
import { RecordStorage } from "@sr-connect/record-storage";
import { AttachmentMediaMapping, getComments, getEnvVars, getMatchingValue, getNewDescriptionWithMedia, getProjectIdentifier, getScriptRunnerConnectSyncIssueKey, hasMediaBlocks, searchIssue } from "./UtilsJiraCloud";
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
export default async function updateComment(context: Context, event: IssueCommentUpdatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customField: string): Promise<void> {
console.log('Comment Updated event: ', event);
// Get the current user
const myself = await sourceInstance.Myself.getCurrentUser();
// Check that the comment was updated by a different user than the one who set up the integration
if (myself.accountId !== event.comment.updateAuthor.accountId) {
const userDisplayName = event.comment.updateAuthor.displayName;
const accountId = event.comment.updateAuthor.accountId;
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
issueKey: event.issue.key,
issueType: event.issue.fields.issuetype?.name,
user: `${userDisplayName} (${accountId})`,
commentId: event.comment.id
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
const { JIRA_PROJECTS } = getEnvVars(context);
// Find matching target project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
// Get the ScriptRunner Connect Sync Issue Key from the issue
const scriptRunnerConnectSyncIssueKey = await getScriptRunnerConnectSyncIssueKey(event.issue.key, sourceInstance, customField, sourceProjectKey, event.issue.fields.issuetype?.name);
if (scriptRunnerConnectSyncIssueKey === null) {
throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
}
const storage = new RecordStorage();
const sourceProjectNumberWithKey = getProjectIdentifier(sourceInstance, sourceProjectKey);
const targetProjectNumberWithKey = getProjectIdentifier(targetInstance, targetProjectKey);
// Get matching comment IDs from Record Storage
const commentIds = await getComments(storage, scriptRunnerConnectSyncIssueKey);
const commentId = commentIds.find(ci => ci[sourceProjectNumberWithKey] === event.comment.id)?.[targetProjectNumberWithKey];
if (!commentId) {
throw Error(`Couldn't find correct comment ID from Record Storage`);
};
// Get the updated comments body
const commentBody = (await sourceInstance.Issue.Comment.getComment({
id: event.comment.id,
issueIdOrKey: event.issue.key
})).body;
// Find the matching issue from the target instance
const issues = await searchIssue(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)
// Check if issue with a matching ScriptRunner Connect Sync Issue Key exists
if (issues.total === 0) {
// If not, throw an error
throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${scriptRunnerConnectSyncIssueKey}`);
};
// Save the issue key
const issueKey = issues.issues?.[0].key ?? '';
const matchingIssueDetails = issues?.issues?.[0];
// Add orignal user text
const originalUserText = `Original comment by: ${event.comment.author.displayName}`;
const originalUser = {
type: "paragraph",
content: [
{
type: 'text',
text: originalUserText
}
]
};
let issueCommentBody: doc_node;
let commentHasMediaBlocks = false;
let targetCommentHasMediaBlocks = false;
let updatedCommentBody: doc_node;
const sourceAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
const targetAttachmentIdAndMediaId: AttachmentMediaMapping[] = [];
// Check if the comment has an attachment
commentHasMediaBlocks = hasMediaBlocks(commentBody);
// Get matching comment
const matchingComment = await targetInstance.Issue.Comment.getComment({
id: commentId,
issueIdOrKey: issueKey
})
if (!commentHasMediaBlocks) {
// Check if matching comment has an attachment
targetCommentHasMediaBlocks = hasMediaBlocks(matchingComment.body);
// Add comment body to request body
updatedCommentBody = commentBody
}
const extractMediaIdHeader = {
'x-stitch-extract-response-media-id': 'true',
'x-stitch-store-body': 'true'
}
// Check if updated comment or matching comment has an attachment
if (commentHasMediaBlocks || targetCommentHasMediaBlocks) {
// Get issue attachments for the source issue
const issueAttachments = (await sourceInstance.Issue.getIssue({
issueIdOrKey: event.issue.key,
})).fields?.attachment ?? [];
// Extract matching issue attachments
const targetInstanceAttachments = matchingIssueDetails.fields.attachment ?? [];
// Loop through attachments and add them to the array
for (const attachment of issueAttachments) {
if (attachment.content && attachment.filename) {
const file = await sourceInstance.fetch(attachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for created issue ${event.issue.key}}`)
}
// Get the attachment id from a response header
const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');
// If no id could be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
sourceAttachmentIdAndMediaId.push({
id: attachment.id,
fileName: attachment.filename,
mediaId: mediaId,
storedAttachmentId
})
}
}
for (const targetInstanceAttachment of targetInstanceAttachments) {
const file = await targetInstance.fetch(targetInstanceAttachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id')
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for updated issue ${issueKey}}`)
}
targetAttachmentIdAndMediaId.push({
id: targetInstanceAttachment.id,
fileName: targetInstanceAttachment.filename,
mediaId: targetMediaId
})
}
// Attachments that are missing
const attachmentsMissingInTargetInstance = sourceAttachmentIdAndMediaId.filter((el) => !targetAttachmentIdAndMediaId.find((file) => file.fileName === el.fileName));
// Loop throught missing attachments and add them into the array
for (const missingAttachment of attachmentsMissingInTargetInstance) {
const attachment = issueAttachments?.find(a => a.id === missingAttachment.id) ?? {}
// Check if the attachment was added
if (attachment.content && attachment.filename) {
// Upload the attachment to the target instance using the same feature
const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
method: 'POST',
headers: {
'X-Atlassian-Token': 'no-check',
'x-stitch-stored-body-id': missingAttachment.storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': attachment.filename
}
});
// // Check if the attachment content response is OK
if (!response.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${response.status}`);
}
const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();
targetInstanceAttachments.push(...targetAttachment);
const file = await targetInstance.fetch(targetAttachment?.[0].content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id')
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for updated issue ${issueKey}}`)
}
targetAttachmentIdAndMediaId.push({
id: targetAttachment?.[0].id,
fileName: targetAttachment?.[0].filename,
mediaId: targetMediaId
})
}
}
// Check if the attachment got removed from the comment but the matching comment has one
if (!commentHasMediaBlocks && targetCommentHasMediaBlocks) {
const filteredTargetAttachments = targetAttachmentIdAndMediaId.filter(
sourceItem => !sourceAttachmentIdAndMediaId.some(targetItem => targetItem.fileName === sourceItem.fileName)
);
for (const attachmentToDelete of filteredTargetAttachments) {
await targetInstance.Issue.Attachment.deleteAttachment({
id: attachmentToDelete.id
})
}
}
// Update comment body with the attachment
updatedCommentBody = getNewDescriptionWithMedia(sourceAttachmentIdAndMediaId, targetAttachmentIdAndMediaId, commentBody);
}
// Function to remove original user text
const removeOriginalUserParagraph = (content: any[], searchText: string): any[] => {
return content.filter(node => {
if (node.type === 'paragraph' && node.content) {
return !node.content.some((subNode: any) =>
subNode.type === 'text' && subNode.text?.includes(searchText)
);
}
return true;
});
};
// Remove the original user text from the comment if necessary
if (event.comment.author.accountId === myself.accountId && updatedCommentBody && matchingComment.author.accountId !== myself.accountId) {
issueCommentBody = {
...updatedCommentBody,
content: removeOriginalUserParagraph(updatedCommentBody.content, 'Original comment by')
};
} else if (matchingComment.author.accountId === event.comment.author.accountId) {
issueCommentBody = {
...updatedCommentBody,
content: [...updatedCommentBody?.content as any]
}
} else {
issueCommentBody = {
...updatedCommentBody,
content: [...updatedCommentBody?.content as any, originalUser]
}
}
// Update the comment
await targetInstance.Issue.Comment.updateComment({
id: commentId,
issueIdOrKey: issueKey,
body: {
body: issueCommentBody as doc_node
}
});
console.log(`Comment updated for Issue: ${issueKey}`)
// Additional check if after comment update attachments should be moved from source or target instance
if (commentHasMediaBlocks || targetCommentHasMediaBlocks) {
const updatedIssueAttachments = (await targetInstance.Issue.getIssue({
issueIdOrKey: issueKey
})).fields.attachment ?? []
// Check if updated issue and the source issue have the same amount of attachments
if (updatedIssueAttachments.length !== sourceAttachmentIdAndMediaId.length) {
// Check if target issue has more attachments than the source issue
if (updatedIssueAttachments.length > sourceAttachmentIdAndMediaId.length) {
// If so, filter out attachments that should get deleted
const filteredTargetAttachments = updatedIssueAttachments.filter(
sourceItem => !sourceAttachmentIdAndMediaId.some(targetItem => targetItem.fileName === sourceItem.filename)
);
for (const attachmentToDelete of filteredTargetAttachments) {
await targetInstance.Issue.Attachment.deleteAttachment({
id: attachmentToDelete.id
})
}
}
// Check if target issue has less attachments than the source issue
if (updatedIssueAttachments.length < sourceAttachmentIdAndMediaId.length) {
// If so, filter out attachments that should get deleted
const filteredSourceAttachments = sourceAttachmentIdAndMediaId.filter(
sourceItem => !updatedIssueAttachments.some(targetItem => targetItem.filename === sourceItem.fileName)
);
for (const attachmentToDelete of filteredSourceAttachments) {
await sourceInstance.Issue.Attachment.deleteAttachment({
id: attachmentToDelete.id
})
}
}
}
}
}
}
import JiraCloud from './api/jira/cloud1';
import { IssueFieldsUpdate } from "@managed-api/jira-cloud-v3-core/definitions/IssueFields";
import { UserDetailsAsResponse } from "@managed-api/jira-cloud-v3-core/definitions/UserDetailsAsResponse";
import { GetIssueResponseOK } from "@managed-api/jira-cloud-v3-core/types/issue";
import { SearchIssuesByJqlResponseOK } from "@managed-api/jira-cloud-v3-core/types/issue/search";
import { JiraCloudApi } from "@managed-api/jira-cloud-v3-sr-connect";
import { IssueUpdatedEvent } from "@sr-connect/jira-cloud/events";
import { AttachmentMediaMapping, checkAccountIds, checkUserFieldOption, getCustomField, getEnvVars, getFieldAndMatchingValue, getJiraPriority, getMatchingValue, getOriginalUserToCustomFieldId, searchIssue, stringToArray, hasMediaBlocks, getNewDescriptionWithMedia, retrySearchIssueInTargetInstance } from "./UtilsJiraCloud";
import moveIssue from './MoveIssue';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
export default async function updateIssue(context: Context, event: IssueUpdatedEvent, sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, customFieldName: string, targetProjectPredifinedUserAccountId?: string): Promise<void> {
console.log('Issue Updated event: ', event);
// Get the current user
let myself = await sourceInstance.Myself.getCurrentUser();
const { JIRA_PROJECTS, STATUS, PRIORITY, ISSUE_TYPES, FIELDS, CUSTOM_FIELDS, IMPACT, CHANGE_REASON, CHANGE_RISK, CHANGE_TYPE, RetryConfigurations, MOVE_ISSUES_BETWEEN_PROJECTS, UserFieldOptions } = getEnvVars(context);
// Check if the current user does not match the person who committed the update and the changelog includes one of the fields that we're interested in
if ((myself.accountId !== event.user.accountId) && event.changelog?.items.some(cl => (FIELDS.includes(cl.field) || CUSTOM_FIELDS.includes(cl.field)))) {
// Check if issue got moved
if (event.issue_event_type_name as any === 'issue_moved') {
// Check if we MOVE_ISSUES_BETWEEN_PROJECTS is set to true
if (MOVE_ISSUES_BETWEEN_PROJECTS) {
// If issue got moved and MOVE_ISSUES_BETWEEN_PROJECTS is true, run moveIssue script
return await moveIssue(context, event, sourceInstance, targetInstance, customFieldName, myself, targetProjectPredifinedUserAccountId);
}
return;
}
const userDisplayName = event.user.displayName;
const accountId = event.user.accountId;
const sourceProjectKey = event.issue.fields.project?.key ?? '';
const metaData = {
instance: sourceInstance === JiraCloud ? 1 : 2,
project: sourceProjectKey,
issueKey: event.issue.key,
issueId: event.issue.id,
issueType: event.issue.fields.issuetype?.name,
user: `${userDisplayName} (${accountId})`,
};
console.log('Going to perform', event.webhookEvent, 'event:', metaData);
// Find matching target project key
const targetProjectKey = getMatchingValue(sourceInstance, sourceProjectKey, JIRA_PROJECTS);
const customField = await getCustomField(sourceInstance, customFieldName, sourceProjectKey, event.issue.fields.issuetype?.name);
let issue: GetIssueResponseOK | undefined;
let scriptRunnerConnectSyncIssueKey = null;
// Retry logic for getting sync key from updated issue
for (let i = 0; i < RetryConfigurations.MAX_RETRY_ATTEMPTS + 1; i++) {
// Get the updated issue
issue = await sourceInstance.Issue.getIssue({
issueIdOrKey: event.issue.key,
});
if (issue?.fields?.[customField] !== null) {
// Extract the ScriptRunner Connect Sync Issue Key
scriptRunnerConnectSyncIssueKey = issue?.fields?.[customField];
break;
} else {
console.log('No ScriptRunner Connect Sync Issue Key found on the updated issue. Retrying...');
await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));
}
}
if (scriptRunnerConnectSyncIssueKey === null) {
throw Error('Issue is missing ScriptRunner Connect Sync Issue Key');
}
let issues: SearchIssuesByJqlResponseOK | undefined;
// Find the matching issue from the target instance
issues = await retrySearchIssueInTargetInstance(context, scriptRunnerConnectSyncIssueKey, targetInstance, true)
// Extract the issue that needs updating
const matchingIssueDetails = issues?.issues?.[0];
const issueKey = matchingIssueDetails.key ?? '';
// Extract the fields that was updated
const eventItems = event.changelog?.items.filter(item => FIELDS.includes(item.field)) ?? [];
// Object that contains changes that need to be updated on the target instance
let requestBody: IssueFieldsUpdate = {};
// Find the project to use based on pre-defined project key
const project = await targetInstance.Project.getProject({
projectIdOrKey: targetProjectKey
});
// Check if the project was found
if (!project) {
// If not, then throw an error
throw Error(`Target project not found: ${targetProjectKey}`);
}
let statusChanged = false;
// Get the matching issue
const matchingIssue = await targetInstance.Issue.getIssue({
issueIdOrKey: issueKey
});
const updatedIssueType = event.issue.fields.issuetype?.name ?? ''
const mappedIssueType = getMatchingValue(sourceInstance, updatedIssueType, ISSUE_TYPES);
// Find all the issue types in target instance
const issueTypes = await targetInstance.Issue.Type.getTypesForProject({
projectId: +(project.id ?? 0) // + sign converts the string to number
});
// Find the issue type
const issueType = issueTypes.find(it => it.name?.toLowerCase().replace(/\s/g, '') === mappedIssueType.toLowerCase().replace(/\s/g, ''));
// Add fields and their values to the request body
for (const eventItem of eventItems) {
switch (eventItem.field) {
case 'summary':
// Add summary to request body
requestBody.summary = eventItem.toString ?? '';
break;
case 'issuetype':
// Check if the issue type was found
if (!issueType) {
// If not, throw an error
throw Error(`Issue type not found in target instance: ${mappedIssueType}`);
}
// Add issue type to request body
requestBody.issuetype = {
id: issueType.id ?? ''
};
break;
case 'reporter':
const reporterAccountId = eventItem.to;
// Function that check USER_FIELD_OPTION value and handles the field appropriately
const reporterUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, reporterAccountId ?? '', myself.accountId ?? '', eventItem.field, eventItem.toString ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name, matchingIssue);
if (reporterUserFieldOption) {
requestBody = { ...requestBody, ...reporterUserFieldOption }
}
break;
case 'assignee':
const assigneeAccountId = eventItem.to;
if (assigneeAccountId) {
// Function that check USER_FIELD_OPTION value and handles the field appropriately
const assigneeUserFieldOption = await checkUserFieldOption(context, targetInstance, targetProjectKey, assigneeAccountId ?? '', myself.accountId ?? '', eventItem.field, eventItem.toString ?? '', targetProjectPredifinedUserAccountId ?? '', issueType.name, matchingIssue);
if (assigneeUserFieldOption) {
requestBody = { ...requestBody, ...assigneeUserFieldOption }
}
} else {
if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD') {
const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, 'assignee', targetProjectKey, issueType.name);
requestBody[customFieldId] = null;
}
requestBody.assignee = null;
}
break;
case 'priority':
const updateIssuePriority = eventItem.toString ?? '';
// Find the matching priority
const matchingPiority = getMatchingValue(sourceInstance, updateIssuePriority, PRIORITY);
const priority = await getJiraPriority(matchingPiority, targetInstance,)
// Check if priority was found
if (!priority) {
// If not, throw an error
throw Error(`Priority not found in target instance: ${priority}`)
}
// Add the priority to request body
requestBody.priority = {
id: priority.id ?? '0'
}
break;
case 'Impact':
// Find the field and matching value in target instance
const impactValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, IMPACT, eventItem.field, targetProjectKey, issueType.name);
if (eventItem.to) {
// Add the Impact field to request body
requestBody[impactValues.field] = {
value: impactValues.matchingValue
};
} else {
requestBody[impactValues.field] = null;
}
break;
case 'Change reason':
// Find the field and matching value in target instance
const changeReasonValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, CHANGE_REASON, eventItem.field, targetProjectKey, issueType.name);
if (eventItem.to) {
// Add the Change reason field to request body
requestBody[changeReasonValues.field] = {
value: changeReasonValues.matchingValue
};
} else {
requestBody[changeReasonValues.field] = null;
}
break;
case 'Change type':
// Find the field and matching value in target instance
const changeTypeValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, CHANGE_TYPE, eventItem.field, targetProjectKey, issueType.name);
if (eventItem.to) {
// Add the Change type field to request body
requestBody[changeTypeValues.field] = {
value: changeTypeValues.matchingValue
};
} else {
requestBody[changeTypeValues.field] = null;
}
break;
case 'Change risk':
// Find the field and matching value in target instance
const changeRiskValues = await getFieldAndMatchingValue(targetInstance, sourceInstance, eventItem.toString, CHANGE_RISK, eventItem.field, targetProjectKey, issueType.name);
if (eventItem.to) {
// Add the Change risk field to request body
requestBody[changeRiskValues.field] = {
value: changeRiskValues.matchingValue
};
} else {
requestBody[changeRiskValues.field] = null;
}
break;
case 'labels':
requestBody.labels = event.issue.fields.labels;
break;
case 'duedate':
requestBody.duedate = event.issue.fields.duedate;
break;
case 'IssueParentAssociation':
if (eventItem.toString) {
const eventIssueType = event.issue.fields.issuetype?.name;
// Check if issueType is Sub-task and if it got removed from parent
if ((eventIssueType === 'Sub-task' || eventIssueType === 'Subtask') && eventItem.toString === null) {
break;
}
// Get the parent or epic issue
const parentOrEpicIssue = await sourceInstance.Issue.getIssue({
issueIdOrKey: eventItem.toString ?? '',
fields: [customField]
})
// Extract the epic issue sync key
const syncKey = parentOrEpicIssue.fields?.[customField];
// Find the matching epic or parent issue from target instance
const matchingParentOrEpicIssue = await searchIssue(context, syncKey, targetInstance, false)
if (matchingParentOrEpicIssue.issues?.length === 0) {
throw Error('Matching Epic Issue not found')
}
// Check if subtask has been added to the issue already
if (matchingParentOrEpicIssue.issues?.[0].fields?.subtasks?.some(s => s.key === issues?.issues?.[0].key)) {
console.log('Sub-task is already present');
break;
}
requestBody.parent = {
key: matchingParentOrEpicIssue.issues?.[0].key
}
} else {
requestBody.parent = null
}
break;
default:
break;
}
}
// Filter custom fields that were updated and exist in the CUSTOM_FIELDS array in Values script
const updatedCustomFields = event.changelog?.items.filter(cl => CUSTOM_FIELDS.includes(cl.field));
// Check if any custom field got updated
if (updatedCustomFields.length) {
// Map through updated custom fields
for (const customField of updatedCustomFields) {
const sourceInstanceCustomField = customField.field
const sourceInstanceCustomFieldId = customField.fieldId
// Find the custom field value in the issue
const value = issue?.fields?.[sourceInstanceCustomFieldId];
// Find the custom field from target instance
const targetInstanceCustomFieldId = await getCustomField(targetInstance, sourceInstanceCustomField, targetProjectKey, issueType.name);
// Check what type of value custom field has
switch (true) {
// Check if the value is null, string or a number
case value === null || typeof value === 'string' || typeof value === 'number':
requestBody[targetInstanceCustomFieldId] = value;
break;
// Check if the value is an array
case Array.isArray(value):
// Check if there is an object in the array
if (typeof value[0] === 'object') {
// Check if the object has a value propert
if (value[0].hasOwnProperty('value')) {
const values = (value as CustomFieldBody[]).map(field => ({ value: field.value }));
requestBody[targetInstanceCustomFieldId] = values;
// Check if the object has a accountId property
} else if (value[0].hasOwnProperty('accountId')) {
const targetIssue = await targetInstance.Issue.getIssue({
issueIdOrKey: issueKey,
fields: [targetInstanceCustomFieldId]
})
const currentlyAddedUsersOnTargetInstance: UserDetailsAsResponse[] | null = targetIssue.fields?.[targetInstanceCustomFieldId];
// Checks if the field is null in the other instance
if (currentlyAddedUsersOnTargetInstance === null) {
const listOfAccountIds = await stringToArray(customField.to ?? '');
const accountsToAdd = await checkAccountIds(targetInstance, targetProjectKey, listOfAccountIds);
// Adds valid account IDs to the request body
requestBody[targetInstanceCustomFieldId] = accountsToAdd.map(value => ({ accountId: value }));
} else {
// Extract the original Account IDs from the issue
const originalListOfAccountIds = await stringToArray(customField.from ?? '')
// Extract the updated Account IDs from the issue
const listOfAccountIds = await stringToArray(customField.to ?? '');
// Filter which accounts got removed and which added
const removedAccountIds = originalListOfAccountIds.filter(id => !listOfAccountIds.includes(id));
const addedAccountIds = listOfAccountIds.filter(id => !originalListOfAccountIds.includes(id));
// Check which accounts are currently added in the target instance
const currentlyAddedAccountIdsOnTargetInstance = currentlyAddedUsersOnTargetInstance.map(field => field.accountId ?? '');
let accountsToRemove: string[] = [];
let accountsToAdd: string[] = []
// If any account IDs got removed, add them to the accountsToRemove array
if (removedAccountIds.length) {
accountsToRemove = await checkAccountIds(targetInstance, targetProjectKey, removedAccountIds);
}
// If any account IDs got added, add them to the accountsToAdd array
if (addedAccountIds.length) {
accountsToAdd = await checkAccountIds(targetInstance, targetProjectKey, addedAccountIds);
}
// New list of account IDs, filtering out accounts that need to be removed and adding new ones
const newList = currentlyAddedAccountIdsOnTargetInstance?.filter(item => !accountsToRemove.includes(item));
newList.push(...accountsToAdd);
const accountIds = newList.map(value => ({ accountId: value }));
// Add necessary account IDs to the request body
requestBody[targetInstanceCustomFieldId] = accountIds;
}
}
} else {
requestBody[targetInstanceCustomFieldId] = value;
}
break;
// Check if value is an object
case typeof value === 'object':
// Check if value has property value
if (value.hasOwnProperty('value')) {
requestBody[targetInstanceCustomFieldId] = {
value: value.value
}
} else {
requestBody[targetInstanceCustomFieldId] = value
}
break;
default:
break;
}
}
}
//Process description and attachments
let descriptionHasMediaBlocks = false;
let issueDescription: doc_node;
const eventDescription = eventItems.find((el) => el.field === 'description');
if (eventDescription) {
if (!eventDescription.toString) {
requestBody.description = null;
} else {
issueDescription = (await sourceInstance.Issue.getIssue({
issueIdOrKey: event.issue.key,
fields: ['description']
})).fields?.description;
descriptionHasMediaBlocks = hasMediaBlocks(issueDescription);
if (descriptionHasMediaBlocks) {
// Will be processing them after adding the attachments
requestBody.description = null;
} else {
// Add description to issue fields
requestBody.description = issueDescription;
}
}
}
const extractMediaIdHeader = descriptionHasMediaBlocks
? {
'x-stitch-extract-response-media-id': 'true',
'x-stitch-store-body': 'true'
}
: {
'x-stitch-store-body': 'true'
};
//Mapping of attachments to media id for use in the in-line attachments
const sourceAttachmentsWithMediaId: AttachmentMediaMapping[] = [];
const targetAttachmentsWithMediaId: AttachmentMediaMapping[] = [];
const eventAttachments = eventItems.filter((el) => el.field === 'Attachment');
const sourceAttachments = issue.fields.attachment ?? [];
const deletedSourceAttachmentIds: string[] = [];
const targetInstanceAttachments = matchingIssueDetails.fields.attachment ?? [];
if (sourceAttachments.length === 0 && descriptionHasMediaBlocks) {
throw new Error(
`${event.issue.key} does not have any attachments but the description has inline attachments. ` +
`Verify the issue to ensure it is correctly setup.`
);
}
for (const eventItem of eventAttachments) {
if (eventItem.to) {
const attachmentId = eventItem.to ?? '';
const attachment = sourceAttachments?.find(a => a.id === attachmentId) ?? {}
// Check if the attachment was added
if (attachment.content && attachment.filename) {
// Add the attachment
const file = await sourceInstance.fetch(attachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
// Get the attachment id from a response header
const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');
// If no id could be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
if (descriptionHasMediaBlocks) {
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for the updated issue ${issueKey}}`)
}
sourceAttachmentsWithMediaId.push({
id: attachment.id,
fileName: attachment.filename,
mediaId: mediaId,
storedAttachmentId
})
}
// Upload the attachment to the target instance using the same feature
const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
method: 'POST',
headers: {
'X-Atlassian-Token': 'no-check',
'x-stitch-stored-body-id': storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': attachment.filename
}
});
// Check if the attachment upload response is OK
if (!response.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${response.status}`);
}
const targetAttachment: AddIssueAttachmentsResponseOK = await response.json();
targetInstanceAttachments.push(...targetAttachment);
console.log(`Attachment ${attachment.filename} has been added to target issue ${issueKey}`);
}
} else {
deletedSourceAttachmentIds.push(eventItem.fromString);
// Find matching attachment ID
const attachmentId = (matchingIssueDetails.fields.attachment?.find(a => a.filename === eventItem.fromString))?.id ?? '';
if (!attachmentId) {
throw Error('Matching attachment ID was not found from target instance');
}
// Delete the attachment
await targetInstance.Issue.Attachment.deleteAttachment({
id: attachmentId
})
}
}
if (!sourceAttachments.length && targetInstanceAttachments.length > 0) {
console.log(
`The target instance has ${targetInstanceAttachments.length} attachment(s) which are not in the source instance. ` +
`These will be deleted.`,
{ attachments: targetInstanceAttachments.map((el) => el.filename) }
)
for (const attachmentToDelete of targetInstanceAttachments) {
await targetInstance.Issue.Attachment.deleteAttachment({
id: attachmentToDelete.id
})
}
}
if (descriptionHasMediaBlocks) {
// Attachments that are not in the change log and have not been deleted in the source instance
const sourceAttachmentsWithoutMediaId = sourceAttachments.filter((el) => !sourceAttachmentsWithMediaId.find((file) => file.id === el.id) && !deletedSourceAttachmentIds.includes(el.id));
for (const sourceAttachmentWithoutMediaId of sourceAttachmentsWithoutMediaId) {
if (sourceAttachmentWithoutMediaId.filename && sourceAttachmentWithoutMediaId.content) {
const file = await sourceInstance.fetch(sourceAttachmentWithoutMediaId.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${sourceAttachmentWithoutMediaId.filename} on the updated issue`)
}
// Get the attachment id from a response header
const storedAttachmentId = file.headers.get('x-stitch-stored-body-id');
// If no id could be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
sourceAttachmentsWithMediaId.push({
id: `${sourceAttachmentWithoutMediaId.id}`,
fileName: sourceAttachmentWithoutMediaId.filename,
mediaId: mediaId,
storedAttachmentId
})
}
}
for (const targetInstanceAttachment of targetInstanceAttachments) {
const file = await targetInstance.fetch(targetInstanceAttachment.content, {
headers: {
...extractMediaIdHeader
}
});
// Check if the attachment content response is OK
if (!file.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${file.status}`);
}
const targetMediaId = file.headers.get('x-stitch-response-media-id')
if (!targetMediaId) {
throw new Error(`Could not retrieve the media id for attachment ${targetInstanceAttachment.filename} for updated issue ${issueKey}}`)
}
targetAttachmentsWithMediaId.push({
id: targetInstanceAttachment.id,
fileName: targetInstanceAttachment.filename,
mediaId: targetMediaId
})
}
// Attachments that are part of the description which didn't come through via the changelog
const attachmentsMissingInTargetInstance = sourceAttachmentsWithMediaId.filter((el) => !targetAttachmentsWithMediaId.find((file) => file.fileName === el.fileName));
for (const missingAttachment of attachmentsMissingInTargetInstance) {
const attachment = sourceAttachments?.find(a => a.id === missingAttachment.id) ?? {}
// Check if the attachment was added
if (attachment.content && attachment.filename) {
// Upload the attachment to the target instance using the same feature
const response = await targetInstance.fetch(`/rest/api/3/issue/${issueKey}/attachments`, {
method: 'POST',
headers: {
'X-Atlassian-Token': 'no-check',
'x-stitch-stored-body-id': missingAttachment.storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': attachment.filename
}
});
// Check if the attachment content response is OK
if (!response.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${response.status}`);
}
const targetAttachment: AddIssueAttachmentsResponseOK = await response.json()
// Adding it to targetInstanceAttachemnt to update the attachments that are currently in the issue
targetInstanceAttachments.push(...targetAttachment);
// Fetch the attachment so we can get its mediaId
const file = await targetInstance.fetch(targetAttachment[0].content, {
headers: {
...extractMediaIdHeader
}
});
const mediaId = file.headers.get('x-stitch-response-media-id')
if (!mediaId) {
throw new Error(`Could not retrieve the media id for attachment ${attachment.filename} for updated issue ${issueKey}}`)
}
targetAttachmentsWithMediaId.push({
id: targetAttachment[0].id,
fileName: targetAttachment[0].filename,
mediaId: mediaId
})
console.log(`Missing attachment with file name ${attachment.filename} added to issue ${issueKey}`)
}
}
const attachmentsStillMissingInTarget = sourceAttachmentsWithMediaId.filter((el) => !targetAttachmentsWithMediaId.find((file) => file.fileName === el.fileName));
if (attachmentsStillMissingInTarget.length > 0) {
console.error('Some attachments are still missing in target instance: ', attachmentsStillMissingInTarget)
throw new Error('Some attachments are still missing in target instance')
}
if (!targetInstanceAttachments.length) {
throw new Error(
`${issueKey} does not have any attachments but its description is expecting attachments. ` +
`Check if the attachments have been uploaded successfully.`
)
}
if (!targetAttachmentsWithMediaId.length || !sourceAttachmentsWithMediaId.length) {
throw new Error(
`${issueKey} does not have the necessary mapping to process the inline description attachments. `
)
}
requestBody.description = getNewDescriptionWithMedia(sourceAttachmentsWithMediaId, targetAttachmentsWithMediaId, issueDescription);
}
const updatedStatus = eventItems.find(change => change.field === 'status');
if (updatedStatus) {
const updateIssueStatus = event.issue.fields.status?.name ?? ''
// Find the matching state
const status = getMatchingValue(sourceInstance, updateIssueStatus, STATUS);
const transitions = (await targetInstance.Issue.Transition.getTransitions({
issueIdOrKey: issueKey,
expand: 'transitions.fields'
})).transitions ?? [];
// Check for correct transition Id
const correctTransition = transitions.find(t => t.to?.name === status || t.name === status);
const transitionId = correctTransition?.id;
if (!transitionId) {
throw Error(`Transition ID not found in target instance for status: ${status}. Check the STATUS mapping or the transition might not
be available for the current status`);
}
// Finally change the issue status (workflow transition)
await targetInstance.Issue.Transition.performTransition({
issueIdOrKey: issueKey,
body: {
transition: {
id: transitionId
},
fields: requestBody
}
});
}
console.log('Issue fields', requestBody);
// If there is any fields in requestBody, update the issue in target instance
if (Object.keys(requestBody).length > 0 && !statusChanged) {
await targetInstance.Issue.editIssue({
issueIdOrKey: issueKey,
body: {
fields: requestBody,
}
});
console.log(`Updated issue: ${issueKey}`);
}
}
}
interface CustomFieldBody {
value: string;
}
import JiraCloud1 from './api/jira/cloud1';
import { SearchIssuePrioritiesResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/priority';
import { RecordStorage } from '@sr-connect/record-storage';
import { JiraCloudApi } from '@managed-api/jira-cloud-v3-sr-connect';
import { Issue } from '@sr-connect/jira-cloud/types/issue';
import { IssueFieldsUpdate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { GetIssueResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue';
import { doc_node } from '@sr-connect/jira-cloud/types/adf/doc_node';
import { mediaInline_node, media_node } from '@sr-connect/jira-cloud/types/adf';
import { SearchIssuesByJqlResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/search';
/**
* Function that finds the priority from Jira Cloud (traverses paginated results)
*/
export async function getJiraPriority(value: string, instance: JiraCloudApi) {
const maxResults = '50';
let startAt = '0';
let priorities: SearchIssuePrioritiesResponseOK;
do {
priorities = await instance.Issue.Priority.searchPriorities({
startAt,
maxResults
});
const priority = priorities.values?.find(p => p.name === value);
if (priority) {
return priority;
}
if (priorities.values?.length === +maxResults) {
startAt = (+startAt + +maxResults).toString();
} else {
startAt = '0'
}
} while (+startAt > 0);
}
/**
* Function that tries to retrieve issue comments from the cache (using RecordStorage)
*/
export async function getComments(storage: RecordStorage, scriptRunnerConnectIssueKey: string) {
return await storage.getValue<Record<string, string>[]>(scriptRunnerConnectIssueKey) ?? [];
}
/**
* Function that tries to retrieve issue links from the cache (using RecordStorage)
*/
export async function getIssueLinks(storage: RecordStorage, scriptRunnerConnectIssueKey: string) {
return await storage.getValue<Record<string, string>[]>(`issue_link_${scriptRunnerConnectIssueKey}`) ?? [];
}
/**
* Function that tries to set issue links to the cache (using RecordStorage)
*/
export async function setIssueLinks(storage: RecordStorage, scriptRunnerConnectIssueKey: string, values: { [key: string]: string }[]) {
return await storage.setValue(`issue_link_${scriptRunnerConnectIssueKey}`, values);
}
/**
* Function that finds the ScriptRunner Connect Sync Issue Key custom field value from the issue
*/
export async function getScriptRunnerConnectSyncIssueKey(issueKey: string, instance: JiraCloudApi, customFieldName: string, projectKey: string, issueType: string): Promise<string | null> {
const customField = await getCustomField(instance, customFieldName, projectKey, issueType);
const scriptRunnerConnectSyncIssueKey = (await instance.Issue.getIssue({
issueIdOrKey: issueKey,
fields: [customField],
})).fields?.[customField]
return scriptRunnerConnectSyncIssueKey
}
/**
* Function that finds the issue using ScriptRunner Connect Sync Issue Key custom field
*/
export async function searchIssue(context: Context, issueKey: string, instance: JiraCloudApi, includeAttachments: boolean) {
const { CUSTOM_FIELD_NAME } = getEnvVars(context);
const includedFields = includeAttachments ? ['attachment'] : [];
const issues = await instance.Issue.Search.searchByJql({
body: {
jql: `"${CUSTOM_FIELD_NAME}" ~ "${issueKey}"`,
fields: includedFields
}
})
return issues;
}
/**
* Function that finds matching value for STATUS, PRIORITY, ISSUE TYPE, IMPACT, CHANGE_REASON, CHANGE_TYPE, CHANGE_RISK
*/
export function getMatchingValue(instance: JiraCloudApi, value: string, attribute: Record<string, string>) {
const matchingValue = instance === JiraCloud1 ? attribute[value] : Object.keys(attribute).find(key => attribute[key] === value) ?? '';
return matchingValue
}
/**
* Function that finds the ID for sync custom field
*/
export async function getCustomField(instance: JiraCloudApi, customFieldName: string, projectKey: string, issueType: string): Promise<string | undefined> {
const customField = (await instance.Issue.Field.getFields()).filter(f => f.name === customFieldName);
if (customField.length === 0) {
throw Error(`Custom field for instance not found: ${customFieldName}`)
}
if (customField.length > 1) {
const createMetaData = (await instance.Issue.Metadata.getCreateMetadata({
projectKeys: [projectKey],
issuetypeNames: [issueType],
expand: 'projects.issuetypes.fields',
}))
const matchingFields = []
if (createMetaData.projects.length > 0) {
for (const [fieldKey, field] of Object.entries(createMetaData.projects?.[0].issuetypes?.[0].fields)) {
if (field.name === customFieldName) {
matchingFields.push({
id: fieldKey,
fieldName: field.name
});
}
}
}
if (matchingFields.length > 1) {
throw Error(`More than one custom field was found with this name: ${customFieldName}`);
} else if (matchingFields.length === 0) {
console.log(`Custom field ${customFieldName} is not assignable to project ${projectKey}, issue type: ${issueType}.`)
return;
} else {
return matchingFields?.[0].id ?? '';
}
}
return customField?.[0].id ?? '';
}
/**
* Function that finds the ID for custom fields matching value in target instance
*/
export async function getFieldAndMatchingValue(targetInstance: JiraCloudApi, sourceInstance: JiraCloudApi, eventValue: string | null, valuesType: ValuesType, fieldName: string, projectKey: string, issueType: string): Promise<{ field: string; matchingValue: string; }> {
const matchingValue = getMatchingValue(sourceInstance, eventValue ?? '', valuesType);
// Find field name from target instance
const field = await getCustomField(targetInstance, fieldName, projectKey, issueType);
if (!field) {
throw Error(`${fieldName} field does not exist`)
}
return {
field,
matchingValue
}
}
/**
* Function that changes specific string to array
*/
export async function stringToArray(originalString: string): Promise<string[]> {
const newArray = originalString.match(/[^[\],\s]+/g) ?? [];
const updatedArray = newArray.map(value => `${value}`);
return updatedArray
}
/**
* Function that checks if account IDs can be added to issue
*/
export async function checkAccountIds(targetInstance: JiraCloudApi, targetProjectKey: string, accountIds: string[]): Promise<string[]> {
let validAccountIds: string[] = [];
await Promise.all(accountIds.map(async (field) => {
const user = await targetInstance.User.Search.findUsersAssignableToIssues({
project: targetProjectKey,
accountId: field
})
if (user.length) {
validAccountIds.push(user?.[0].accountId ?? '')
}
}))
return validAccountIds
}
/**
* Function that checks userFieldOption value and changes the field accordingly
*/
export async function checkUserFieldOption(context: Context, targetInstance: JiraCloudApi, targetProjectKey: string, accountId: string, integrationUserAccountId: string, fieldName: string, displayName: string, targetProjectPredifinedUserAccountId: string, issueType: string, matchingIssue?: GetIssueResponseOK): Promise<UserField | undefined> {
const { UserFieldOptions } = getEnvVars(context);
switch (UserFieldOptions.USER_FIELD_OPTION) {
case 'ORIGINAL_USER':
const isUserAssignable = await findAssignableUser(targetInstance, targetProjectKey, accountId);
// Check if user is assignable or not
if (!isUserAssignable) {
// Handle fallback for non-assignable user
return await checkUserFieldFallbackValue(
context,
targetInstance,
targetProjectKey,
integrationUserAccountId,
fieldName,
displayName,
targetProjectPredifinedUserAccountId,
issueType
);
}
// Check if USER_FIELD_FALLBACK_OPTION is COPY_ORIGINAL_USER_TO_CUSTOM_FIELD and if matching issue is passed
if (UserFieldOptions.USER_FIELD_FALLBACK_OPTION === 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' && matchingIssue) {
const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);
const customFieldValue = matchingIssue?.fields?.[customFieldId];
return customFieldValue
? {
[fieldName]: { accountId: accountId },
[customFieldId]: ''
}
: { [fieldName]: { accountId: accountId } };
}
return { [fieldName]: { accountId: accountId } };
case 'REMAIN_UNASSIGNED':
return fieldName === 'reporter' ? { [fieldName]: { accountId: integrationUserAccountId } } : { [fieldName]: { accountId: null } }
case 'INTEGRATION_USER':
return {
[fieldName]: { accountId: integrationUserAccountId }
}
case 'PREDEFINED_USER':
const isPredefinedUserAssignable = await findAssignableUser(targetInstance, targetProjectKey, targetProjectPredifinedUserAccountId);
return isPredefinedUserAssignable ? await handlePredefinedUser(targetInstance, targetProjectKey, fieldName, targetProjectPredifinedUserAccountId) : checkUserFieldFallbackValue(context, targetInstance, targetProjectKey, integrationUserAccountId, fieldName, displayName, targetProjectPredifinedUserAccountId, issueType);
case 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD':
const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);
return {
[customFieldId]: displayName
};
default:
return;
}
}
/**
* Function that checks userFieldFallbackOption value and changes the field accordingly
*/
export async function checkUserFieldFallbackValue(context: Context, targetInstance: JiraCloudApi, targetProjectKey: string, integrationUserAccountId: string, fieldName: string, displayName: string, targetProjectPredifinedUserAccountId: string, issueType: string): Promise<UserField | undefined> {
const { UserFieldOptions } = getEnvVars(context);
switch (UserFieldOptions.USER_FIELD_FALLBACK_OPTION) {
case 'REMAIN_UNASSIGNED':
// If fallback value is REMAIN_UNASSIGNED, reporter field will be integration user, this can be changed to PREDEFINED_USER_ID instead
return fieldName === 'reporter' ? { [fieldName]: { accountId: integrationUserAccountId } } : { [fieldName]: { accountId: null } }
case 'INTEGRATION_USER':
return { [fieldName]: { accountId: integrationUserAccountId } };
case 'PREDEFINED_USER':
return await handlePredefinedUser(targetInstance, targetProjectKey, fieldName, targetProjectPredifinedUserAccountId);
case 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD':
const customFieldId = await getOriginalUserToCustomFieldId(context, targetInstance, fieldName, targetProjectKey, issueType);
const account = fieldName === 'reporter' ? { [fieldName]: { accountId: integrationUserAccountId } } : { [fieldName]: { accountId: null } }
return {
[customFieldId]: displayName,
...account
};
case 'HALT_SYNC':
throw Error(`Script halted because user for field ${fieldName} was not found.`)
default:
return;
}
}
/**
* Function that checks if account can be added to issue
*/
export async function findAssignableUser(targetInstance: JiraCloudApi, targetProjectKey: string, accountId: string): Promise<boolean> {
const user = await targetInstance.User.Search.findUsersAssignableToIssues({
project: targetProjectKey,
accountId: accountId
})
return user.length > 0
}
/**
* Function that copies original user to custom field
*/
export async function getOriginalUserToCustomFieldId(context: Context, targetInstance: JiraCloudApi, fieldName: string, projectKey: string, issueType: string): Promise<string> {
const { OriginalUserFields } = getEnvVars(context);
const customFieldName = fieldName === 'assignee' ? OriginalUserFields.CUSTOM_FIELD_FOR_ASSIGNEE : OriginalUserFields.CUSTOM_FIELD_FOR_REPORTER;
const customFieldId = await getCustomField(targetInstance, customFieldName ?? '', projectKey, issueType);
return customFieldId
}
/**
* Function that handles predefined user
*/
export async function handlePredefinedUser(targetInstance: JiraCloudApi, targetProjectKey: string, fieldName: string, targetProjectPredifinedUserAccountId: string): Promise<UserField> {
if (!targetProjectPredifinedUserAccountId) {
throw Error('Missing predifined user ID')
};
const isPredefineUserdAssignable = await findAssignableUser(targetInstance, targetProjectKey, targetProjectPredifinedUserAccountId);
if (!isPredefineUserdAssignable) {
throw Error('Predifined user ID cannot be set')
}
return {
[fieldName]: { accountId: targetProjectPredifinedUserAccountId }
};
}
// Determine project number or identifier based on instance type
export const getProjectIdentifier = (instance: JiraCloudApi, projectKey: string): string => {
const instanceId = instance === JiraCloud1 ? 1 : 2;
return `${instanceId}-${projectKey}`;
};
/**
* Function that handles custom fields for issue creation
*/
export async function handleCustomFieldsForCreatingIssue(sourceInstance: JiraCloudApi, targetInstance: JiraCloudApi, targetProjectKey: string, customFields: string[], eventIssue: Issue, createdIssue: GetIssueResponseOK, issueType: string): Promise<IssueFieldsUpdate> {
const requestBody: IssueFieldsUpdate = {};
for (const customField of customFields) {
// Get custom field
const sourceInstanceCustomFieldId = await getCustomField(sourceInstance, customField, targetProjectKey, issueType);
// Save its value
const value = eventIssue.fields[sourceInstanceCustomFieldId];
// If the custom field has a value
if (value) {
// Find the custom field in target instance
const targetInstanceCustomFieldId = await getCustomField(targetInstance, customField, targetProjectKey, issueType);
// Check custom fields type
switch (true) {
// Check if custom field is a string or a number
case typeof value === 'string' || typeof value === 'number':
// Add the value to the request body
requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId];
break;
// Check if custom field is a array
case Array.isArray(value):
// Check if custom field is an object
if (typeof value[0] === 'object') {
// Check if the object in array has a value property
if (value[0].hasOwnProperty('value')) {
// If it does, map through the objects and save the values
requestBody[targetInstanceCustomFieldId] = (value as { value: string }[]).map(field => ({ value: field.value }));
}
// Check if the object in array has an accountId property
if (value[0].hasOwnProperty('accountId')) {
// If it does, save all the account IDs added in the custom field
const accountIds = (value as { accountId: string }[]).map(field => field.accountId);
// Check if the account IDs can be added to the issue
const validAccountIds = await checkAccountIds(targetInstance, targetProjectKey, accountIds);
// Add the valid account IDs to the request body
requestBody[targetInstanceCustomFieldId] = validAccountIds.map(value => ({ accountId: value }));
}
} else {
// Add the array to the request body
requestBody[targetInstanceCustomFieldId] = createdIssue.fields?.[sourceInstanceCustomFieldId]
}
break;
// Check if the custom field is an object
case typeof value === 'object':
// Add the value in the object to request body
requestBody[targetInstanceCustomFieldId] = {
value: createdIssue.fields?.[sourceInstanceCustomFieldId].value
}
break;
default:
break;
}
}
}
// Return the updated requestBody
return requestBody;
}
/**
* Function that handles finding the ScriptRunner Connect Sync Issue Key custom field value from the issue for Issue Links
*/
export async function getScriptRunnerConnectSyncIssueKeyForIssueLink(context: Context, issueKey: string, instance: JiraCloudApi, customFieldName: string): Promise<string | null> {
const { JIRA_PROJECTS } = getEnvVars(context);
const issue = (await instance.Issue.getIssue({
issueIdOrKey: issueKey,
errorStrategy: {
handleHttp404Error: () => null
}
}))
if (issue) {
// Check if the linked issue belongs to a project we wan't to sync
const isProjectInScope = instance === JiraCloud1
? JIRA_PROJECTS.hasOwnProperty(issue.fields.project.key)
: Object.values(JIRA_PROJECTS).includes(issue.fields.project.key);
// Stop issue link creation if issue doesn't belong to synced projects.
if (!isProjectInScope) {
console.warn(`Issue ${issueKey} does not belong in to a synced project.`)
return;
}
}
let customField: string;
if (issue) {
customField = await getCustomField(instance, customFieldName, issue.fields.project.key, issue.fields.issuetype.name);
}
if (!issue) {
console.warn(`Issue for the following issue key is not found ${issueKey}.`)
return null;
} else if (issue?.fields?.[customField] === null) {
throw Error(`${issueKey} is missing ScriptRunner Connect Sync Issue Key`)
} else {
return issue?.fields?.[customField]
}
}
function getNewBlockForMedia(
source: AttachmentMediaMapping[],
target: AttachmentMediaMapping[],
tleNode: media_node | mediaInline_node
): media_node | mediaInline_node {
const { type: tleContentType, attrs } = tleNode;
// Handle external media separately (for media_node)
if (tleContentType === 'media' && attrs.type === 'external') {
return {
type: tleContentType,
attrs
}
}
const srcMediaId = (attrs.type === 'link' || attrs.type === 'file') ? attrs.id : '0';
const srcMapping = source.find(el => el.mediaId === srcMediaId);
const targetMapping = target.find(el => el.fileName === srcMapping?.fileName);
if (!targetMapping) {
throw new Error(`Could not find the corresponding media id for ${srcMapping?.fileName} - id ${srcMapping?.id}`);
}
const newAttrs = {
...attrs,
id: targetMapping.mediaId,
};
return {
type: tleContentType,
attrs: newAttrs,
} as media_node | mediaInline_node;
}
export function getNewDescriptionWithMedia(
source: AttachmentMediaMapping[],
target: AttachmentMediaMapping[],
description: doc_node
): doc_node {
const newDescription: doc_node = {
version: description.version,
type: description.type,
content: description.content.map(tleNode => {
// Handle nested content
if ('content' in tleNode && Array.isArray(tleNode.content)) {
const updatedContent = processContent(tleNode.content, source, target);
return { ...tleNode, content: updatedContent };
}
// Handle mediaSingle
if (tleNode.type === 'mediaSingle') {
return {
...tleNode,
content: [getNewBlockForMedia(source, target, tleNode.content[0] as media_node)]
};
}
// Handle mediaGroup
if (tleNode.type === 'mediaGroup') {
return {
...tleNode,
content: tleNode.content.map(el => getNewBlockForMedia(source, target, el as media_node))
};
}
// Return unchanged node if not media-related
return tleNode;
})
};
return newDescription;
}
export function hasMediaBlocks(description: doc_node): boolean {
function hasNestedMedia(content: any[]): boolean {
return content.some(el => {
// Check for mediaInline, mediaSingle, or mediaGroup
if (['mediaInline', 'mediaSingle', 'mediaGroup'].includes(el.type)) {
return true;
}
// Check nested content
return 'content' in el && Array.isArray(el.content) && hasNestedMedia(el.content);
});
}
return hasNestedMedia(description.content);
}
export function processContent(
content: any[],
source: AttachmentMediaMapping[],
target: AttachmentMediaMapping[]
): any[] {
return content.map(node => {
// Process nested content
if ('content' in node && Array.isArray(node.content)) {
return { ...node, content: processContent(node.content, source, target) };
}
// Handle mediaInline and media nodes separately
if (node.type === 'mediaInline') {
return getNewBlockForMedia(source, target, node as mediaInline_node);
}
if (node.type === 'media') {
return getNewBlockForMedia(source, target, node as media_node);
}
// Return unchanged node if not media-related
return node;
});
}
/**
* Retrieves the ScriptRunner Connect Sync Issue Key from an issue link, with multiple retry attempts
*/
export async function retrySyncIssueKeyForIssueLinks(
context: Context,
issueIdOrKey: string,
sourceInstance: JiraCloudApi,
customFieldName: string
): Promise<string | null> {
const { RetryConfigurations } = getEnvVars(context);
let syncIssueKey: string | null = null;
let attempts = 0;
do {
syncIssueKey = await getScriptRunnerConnectSyncIssueKeyForIssueLink(context, issueIdOrKey, sourceInstance, customFieldName);
if (syncIssueKey) {
break;
}
attempts++;
console.log(`No Sync Issue Key found for issue ${issueIdOrKey}. Retrying attempt ${attempts}/${RetryConfigurations.MAX_RETRY_ATTEMPTS}...`);
await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));
} while (attempts < RetryConfigurations.MAX_RETRY_ATTEMPTS);
if (!syncIssueKey) {
console.log(`Failed to find Sync Issue Key after all retry attempts.`);
}
return syncIssueKey;
}
/**
* Searches for an issue using the provided Sync Issue Key in the target instance.
*/
export async function retrySearchIssueInTargetInstance(
context: Context,
syncIssueKey: string,
targetInstance: JiraCloudApi,
includeAttachments: boolean,
): Promise<SearchIssuesByJqlResponseOK> {
const { RetryConfigurations } = getEnvVars(context);
let targetIssue: SearchIssuesByJqlResponseOK;
let attempts = 0;
do {
targetIssue = await searchIssue(context, syncIssueKey, targetInstance, includeAttachments);
if (targetIssue.total !== 0) {
return targetIssue;
}
attempts++;
console.log(`No matching issue found for key ${syncIssueKey}. Retrying attempt ${attempts}/${RetryConfigurations.MAX_RETRY_ATTEMPTS}...`);
await new Promise(resolve => setTimeout(resolve, RetryConfigurations.RETRY_DELAY_SECONDS * 1000));
} while (attempts < RetryConfigurations.MAX_RETRY_ATTEMPTS);
// Throw error if no matching issue was found after max attempts
throw Error(`Issue with the matching ScriptRunner Connect Sync Issue Key does not exist in target instance: ${syncIssueKey}`);
}
export type ValuesType = {
[key: string]: string;
};
interface UserField {
[key: string]: {
accountId: string | null;
} | string;
};
export interface AttachmentBody {
fileName: string;
content: ArrayBuffer;
};
export interface AttachmentMediaMapping {
id: string;
fileName: string;
mediaId: string;
storedAttachmentId?: string;
}
export type UserFieldOptionType = 'ORIGINAL_USER' | 'REMAIN_UNASSIGNED' | 'INTEGRATION_USER' | 'PREDEFINED_USER' | 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD';
export type UserFieldFallbackOptionType = 'REMAIN_UNASSIGNED' | 'INTEGRATION_USER' | 'PREDEFINED_USER' | 'COPY_ORIGINAL_USER_TO_CUSTOM_FIELD' | 'HALT_SYNC';
type JiraProject1Value = string;
type JiraProject2Value = string;
interface EnvVars {
JIRA_PROJECTS: Record<JiraProject1Value, JiraProject2Value>;
CUSTOM_FIELD_NAME: string;
RetryConfigurations: {
MAX_RETRY_ATTEMPTS: number;
RETRY_DELAY_SECONDS: number;
};
FIELDS: string[];
CUSTOM_FIELDS: string[];
UserFieldOptions: {
USER_FIELD_OPTION: UserFieldOptionType;
USER_FIELD_FALLBACK_OPTION: UserFieldFallbackOptionType;
};
PredefinedUser: {
JIRA_PROJECT_1_ACCOUNT_ID?: string;
JIRA_PROJECT_2_ACCOUNT_ID?: string;
};
OriginalUserFields: {
CUSTOM_FIELD_FOR_ASSIGNEE?: string;
CUSTOM_FIELD_FOR_REPORTER?: string;
};
PRIORITY: Record<JiraProject1Value, JiraProject2Value>;
STATUS: Record<JiraProject1Value, JiraProject2Value>;
ISSUE_TYPES: Record<JiraProject1Value, JiraProject2Value>;
IMPACT: Record<JiraProject1Value, JiraProject2Value>;
CHANGE_REASON: Record<JiraProject1Value, JiraProject2Value>;
CHANGE_TYPE: Record<JiraProject1Value, JiraProject2Value>;
CHANGE_RISK: Record<JiraProject1Value, JiraProject2Value>;
MOVE_ISSUES_BETWEEN_PROJECTS: boolean;
}
export function getEnvVars(context: Context) {
return context.environment.vars as EnvVars;
}