Template Content
Scripts
About the integration
How does the integration logic work?
Which fields are being synced?
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 creates a two-way sync between Jira Cloud issues and ServiceNow incidents. Setting it up will take a little bit of patience, so buckle up!
Out of the box, this templates supports exchanging the following data between ServiceNow and Jira Cloud:
Create a custom field in Jira Cloud in order to store the number of the corresponding ServiceNow incident.
Create a custom column in ServiceNow for your Incident table in order to store the corresponding Jira Cloud issue key.
Please do not edit either of these custom fields or columns throught the UI, or you will risk breaking the integration.
Create and authorize connectors for JiraCloud and ServiceNow or use existing ones.
Make sure that the accounts used to setup the connectors have all necessary permissions to create, edit and delete all the fields and resources covered by this integration.
Configure and save all API connections (only needed in advanced view) and event listeners. Keep in mind though that while setting up an event listener for ServiceNow you will need to tweak a few steps:
Event Listener that uses OnServiceNowIncidentCreated:
Event Listener that uses OnServiceNowIncidentUpdated:
KEY: VALUE:
short_description short_description
description description
urgency urgency
state state
caller_id caller_id
comments comments
" var gru = GlideScriptRecordUtil.get(current);\n" +
" var short_description = '';\n" +
" var description = '';\n" +
" var state = '';\n" +
" var urgency = '';\n" +
" var caller_id = '';\n" +
" var comments = '';\n" +
" // Returns an arrayList of changed field elements with database names\n" +
" var changedFieldNames = gru.getChangedFieldNames();\n" +
" //Returns an arrayList of all change values from changed fields\n" +
" var changes = gru.getChanges();\n" +
" // Convert to JavaScript Arrays\n" +
" gs.include('j2js');\n" +
" changedFieldNames = j2js(changedFieldNames);\n" +
" changes = j2js(changes);\n" +
" for (var i = 0; i < changedFieldNames.length; i++) {\n" +
" switch (changedFieldNames[i]) {\n" +
" case 'short_description':\n" +
" short_description = changes[i];\n" +
" break;\n" +
" case 'description':\n" +
" description = changes[i];\n" +
" break;\n" +
" case 'urgency':\n" +
" urgency = changes[i];\n" +
" break;\n" +
" case 'state':\n" +
" state = changes[i];\n" +
" break;\n" +
" case 'caller_id':\n" +
" caller_id = changes[i];\n" +
" break;\n" +
" case 'comments':\n" +
" var journalGR = new GlideRecord('sys_journal_field');\n" +
" journalGR.addQuery('element', 'comments');\n" +
" journalGR.addQuery('element_id', current.sys_id);\n" +
" journalGR.orderByDesc('sys_created_on');\n" +
" journalGR.setLimit(1);\n" +
" journalGR.query();\n" +
" comments = journalGR.next() ? journalGR.getValue('value') : '';\n" +
" break;\n" +
" default:\n" +
" break;\n" +
" }\n" +
" }\n" +
Event Listener that uses OnServiceNowIncidentDeleted:
KEY: VALUE:
jira_cloud_issue_key current.u_jira_cloud_issue_key
current.<YOUR COLUMN NAME>
Event Listener that uses OnServiceNowIncidentAttachmentCreated:
KEY: VALUE:
attachment_id current.sys_id
brGr.condition = 'current.table_name == "incident"';
Event Listener that uses OnServiceNowIncidentAttachmentDeleted:
KEY: VALUE:
attachment_id current.sys_id
brGr.condition = 'current.table_name == "incident"';
When Adding Webhooks in Jira Cloud, make sure to add in the Issue related events field the project key you want to listen. Example: project = TEST, so you would only listen to projects you're interested in.
In ScriptRunner Connect, navigate to Parameters and fill them in according to your configuration and preference.
8 March 2025
README
to fix OnServiceNowIncidentUpdated
event listener instructions.OnServiceNowIncidentUpdated
script to check if comment
has a value.import JiraCloud from "./api/jira/cloud";
import ServiceNow from './api/servicenow';
import { IssueCommentCreatedEvent } from '@sr-connect/jira-cloud/events';
import { Incident } from "./Types";
import { getEnvVars, getRecordFromArrayOfOne } from "./Utils";
/**
* This function creates a comment in ServiceNow when a comment is created on an issue in Jira Cloud.
*
* @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. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant to this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
},
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME
}
} = getEnvVars(context);
// If 'comments' is not among the synced fields, log out a message and stop the operation
if (!SYNCED_FIELDS.includes('comments')) {
console.log('Not syncing comments');
return;
}
try {
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// If the comment was created by the current user, log out a message, and stop the operation to avoid infinite loops
if (myself.accountId === event.comment.author.accountId) {
console.log('Same user, skipping sync');
return;
}
// Get the issue in Jira Cloud which triggered the event
const issue = await JiraCloud.Issue.getIssue({
issueIdOrKey: event.issue.key,
});
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields({});
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Get the number of the corresponding ServiceNow incident from the Jira Cloud issue that triggered the event
const incidentNumber = issue.fields?.[customField.key];
// If the incident number is missing, log out a warning and stop the operation
if (!incidentNumber) {
console.warn(`No corresponding ServiceNow incident found for Jira Cloud issue ${event.issue.key}`);
return;
}
// Use this number to query for the correct ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident',
sysparm_query: `number=${incidentNumber}`,
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Copy the the comment to the correct ServiceNow incident
await ServiceNow.Table.updateRecord({
tableName: 'incident',
sys_id: incident.sys_id,
body: {
comments: `${event.comment.author.displayName}: ${event.comment.body}`,
},
});
// If successful, log out the incident number
console.log(`Comment created in ServiceNow for incident ${incidentNumber}`)
} catch (error) {
// If something goes wrong, log out the error
console.error('Error creating a comment in ServiceNow', error);
}
}
import JiraCloud from "./api/jira/cloud";
import ServiceNow from './api/servicenow';
import { IssueCommentUpdatedEvent } from '@sr-connect/jira-cloud/events';
import { Incident } from "./Types";
import { getEnvVars, getRecordFromArrayOfOne } from "./Utils";
/**
* This function creates a new comment for ServiceNow Incident when a comment is updated in Jira Cloud.
*
* @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. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant to this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
},
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME
}
} = getEnvVars(context);
// If 'comments' is not among the synced fields, log out a message and stop the operation
if (!SYNCED_FIELDS.includes('comments')) {
console.log('Not syncing comments');
return;
}
try {
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// If the comment was updated by the current user, log out a message, and stop the operation to avoid infinite loops
if (myself.accountId === event.comment.updateAuthor.accountId) {
console.log('Same user, skipping sync');
return;
}
// Get the issue in Jira Cloud which triggered the event
const issue = await JiraCloud.Issue.getIssue({
issueIdOrKey: event.issue.key,
});
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields({});
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Get the number of the corresponding ServiceNow incident from the Jira Cloud issue that triggered the event
const incidentNumber = issue.fields?.[customField?.key];
// If the incident number is missing, log out a warning and stop the operation
if (!incidentNumber) {
console.warn(`No corresponding ServiceNow incident found for Jira Cloud issue ${event.issue.key}`);
return;
}
// Use this number to query for the correct ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident',
sysparm_query: `number=${incidentNumber}`,
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Copy the the comment to the ServiceNow incident
await ServiceNow.Table.updateRecord({
tableName: 'incident',
sys_id: incident.sys_id,
body: {
comments: `${event.comment.author.displayName} (update): ${event.comment.body}`,
},
});
// If successful, log out the incident number
console.log(`Comment created in ServiceNow for incident ${incidentNumber}`)
} catch (error) {
// If something goes wrong, log out the error
console.error('Error creating a comment in ServiceNow', error)
}
}
import JiraCloud from './api/jira/cloud';
import ServiceNow from './api/servicenow';
import { IssueCreatedEvent } from '@sr-connect/jira-cloud/events';
import { RecordStorage } from '@sr-connect/record-storage';
import { CreateIncidentRequestBody, IncidentColumn, ServiceNowUser } from './Types';
import { getEnvVars, getRecordFromArrayOfOne } from './Utils';
/**
* This function creates a new ServiceNow incident when an issue is created in Jira Cloud.
*
* @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. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant for this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
INCIDENT_DEFAULT_SHORT_DESCRIPTION,
SERVICENOW_ADMIN_EMAIL,
},
FieldMappings: {
STATUS_TO_STATE,
PRIORITY_TO_URGENCY,
},
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME,
SERVICENOW_CUSTOM_COLUMN_NAME,
},
} = getEnvVars(context)
// Start keep track of when a corresponding incident is created in ServiceNow
let incidentCreated = false;
try {
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// If the issue was created by the current user, log out a message, and stop the operation to avoid infinite loops
if (myself.accountId === event.user.accountId) {
console.log('Same user, skipping sync');
return;
}
// Get the custom column where the corresponding Jira Cloud issue key is stored
const columnRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_dictionary',
sysparm_query: `name=incident^sys_name=${SERVICENOW_CUSTOM_COLUMN_NAME}`,
});
const column = getRecordFromArrayOfOne<IncidentColumn>(columnRecords.result);
// Define the request body for the new ServiceNow incident, also adding the key of the current Jira Cloud issue to a custom column in ServiceNow
const requestBody: CreateIncidentRequestBody = {
short_description: INCIDENT_DEFAULT_SHORT_DESCRIPTION, // Overwrite later if 'summary' is amonged the synced fields
[column.element]: event.issue.key,
};
// Extract the issue fields from the Jira Cloud issue that triggered the event
const issueFields = event.issue.fields;
// If 'summary' is among the synced fields, assign it as the short description for the new ServiceNow incident
if (SYNCED_FIELDS.includes('summary')) {
// If no summary is found for the Jira Cloud issue that triggered the event, throw an error
if (!issueFields.summary) {
throw Error(`No summary found for the Jira Cloud issue ${event.issue.key}`);
}
// Assign it as the summary on the new ServiceNow incident
requestBody.short_description = issueFields.summary;
}
// If 'reporter' is among the synced fields, try to assign a corresponding caller to the new ServiceNow incident
if (SYNCED_FIELDS.includes('reporter')) {
try {
// Check if the Jira Cloud issue reporter's email matches with that of a ServiceNow user
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${issueFields.reporter?.emailAddress}`,
headers: {
'Content-type': 'application/json',
},
});
const user = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result)
// Assign it as the caller on the new ServiceNow incident
requestBody.caller_id = user.sys_id;
} catch (error) {
// If no such user exists in ServiceNow, log out a warning
console.warn(`Corresponding user with email ${issueFields.reporter?.emailAddress} was not found from ServiceNow, using the specified admin user instead as the caller`)
// Find the specified admin user in ServiceNow
const usersRecordWithAdminEmail = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const adminUser = getRecordFromArrayOfOne<ServiceNowUser>(usersRecordWithAdminEmail.result);
// Assign it to the new ServiceNow incident as the reporter
requestBody.caller_id = adminUser.sys_id;
}
} else {
// If 'reporter' is not among the synced fields, always assign the specified admin user as the caller
const usersRecordWithAdminEmail = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const adminUser = getRecordFromArrayOfOne<ServiceNowUser>(usersRecordWithAdminEmail.result);
// Assign the admin user to the new ServiceNow incident as the reporter
requestBody.caller_id = adminUser.sys_id;
}
// If 'priority' is among the synced fields, add a corresponding urgency to the new ServiceNow incident
if (SYNCED_FIELDS.includes('priority')) {
// If no priority can be found for the Jira Cloud issue, throw an error
if (!issueFields.priority) {
throw Error(`No priority found for the Jira Cloud issue ${event.issue.key}`)
}
// For the Jira Cloud issue priority, find a corresponding incident urgency name in ServiceNow
const urgencyLabel = PRIORITY_TO_URGENCY[issueFields.priority.name];
// If no such urgency can be found, throw an error
if (!urgencyLabel) {
throw Error(`Corresponding urgency not found in ServiceNow for the Jira Cloud priority ${issueFields.priority.name}`)
}
// Obtain more data about the urgency record from ServiceNow
const urgencyRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=urgency^label=${urgencyLabel}`
});
const urgency = getRecordFromArrayOfOne<IncidentColumn>(urgencyRecords.result);
// Add the urgency value to the new ServiceNow incident
requestBody.urgency = urgency.value;
}
// If 'status' is among the synced field, add a corresponding state to the new ServiceNow incident
if (SYNCED_FIELDS.includes('status')) {
// Get the issue that triggered the event, because if status was updated, it is not reflected in the initial creation event
const issue = await JiraCloud.Issue.getIssue({
issueIdOrKey: event.issue.key
});
// Get the name of the new status
const statusName = issue.fields?.status?.name
// If no status can be found for the Jira Cloud issue, throw an error
if (!statusName) {
throw Error(`No status found for Jira Cloud issue ${event.issue.key}`)
}
// For the Jira Cloud issue status, find a corresponding state record for the ServiceNow incident
const stateLabel = STATUS_TO_STATE[statusName];
// If no such state is found in ServiceNow, throw an error
if (!stateLabel) {
throw Error(`Corresponding incident state not found in ServiceNow for the Jira Cloud workflow status ${statusName}`)
}
// Obtain more data about the state record from ServiceNow
const stateRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=state^name=incident^label=${stateLabel}`
});
const state = getRecordFromArrayOfOne<IncidentColumn>(stateRecords.result);
// Add the state value to the new ServiceNow incident
requestBody.state = state.value;
}
// If 'description' is among the synced fields and one exists on the Jira Cloud issue, add it to the new ServiceNow incident as well
if (SYNCED_FIELDS.includes('description')) {
// Add the description to the new ServiceNow incident
requestBody.description = issueFields.description?.toString() ?? undefined;
}
// Create a new Incident in ServiceNow and store the corresponding Jira issue key in its custom field
const incident = await ServiceNow.Table.addRecord({
tableName: 'incident',
body: requestBody,
});
// Keep track that a corresponding incident has been created
incidentCreated = true;
// Try to extract the number of the newly created ServiceNow incident
const incidentNumber = incident.result?.number;
// If no such key can be obtained, throw an error
if (!incidentNumber) {
throw Error('Failed to retrieve incident number');
}
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields();
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Add the number of the new ServiceNow incident to the Jira Cloud issue
await JiraCloud.Issue.editIssue(
{
issueIdOrKey: event.issue.key,
body: {
fields: {
[customField?.key ?? '']: incidentNumber
},
}
},
);
// If 'attachment' is among the synced fields and there are some on the Jira Cloud issue, add them to the new ServiceNow incident as well
if (SYNCED_FIELDS.includes('attachments') && issueFields.attachment?.length) {
// Define a variable for storing attachment ids
const attachmentIdPairs: Record<string, number> = {};
// Loop through all attachments on the issue
for (const attachment of issueFields.attachment) {
// Get the metadata for each attachment
const metadata = await JiraCloud.Issue.Attachment.Metadata.getMetadata({ id: String(attachment.id) });
// Find the content link from attachment metadata and store its content in SRC using the large attachment feature (In case the attachment exceeds 100MB)
const storedAttachment = await JiraCloud.fetch(metadata.content ?? '', {
headers: {
'x-stitch-store-body': 'true'
}
});
// Check if the attachment content was fetched successfully
if (!storedAttachment.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${storedAttachment.status}`);
}
// Obtain the id of the stored attachment from a response header
const storedAttachmentId = storedAttachment.headers.get('x-stitch-stored-body-id');
// If no id can be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
// Upload the attachment to ServiceNow, using the same feature
const attachmentResponse = await ServiceNow.fetch('/api/now/v1/attachment/upload', {
method: 'POST',
headers: {
'x-stitch-stored-body-id': storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': `${metadata.filename}`,
'x-stitch-stored-body-form-data-file-identifier': 'uploadFile',
'x-stitch-stored-body-form-data-additional-fields': `table_name:incident;table_sys_id:${incident.result?.sys_id};`,
}
});
// Obtain the attachment from the response
const uploadedAttachment = await attachmentResponse.json();
// If an error is returned, log it out and throw an error
if (uploadedAttachment.error) {
console.error(uploadedAttachment.error);
throw Error('Error uploading attachment to ServiceNow');
}
// Extract the id of the newly created attachment in ServiceNow
const serviceNowAttachmentId = uploadedAttachment.result?.sys_id;
// Should this id not be found, throw an error
if (!serviceNowAttachmentId) {
throw Error(`Failed to retrieve attachment data from ServiceNow`);
}
// Push the id-pair into the variable
attachmentIdPairs[serviceNowAttachmentId] = attachment.id
}
// Define a storage object for storing attachment ids
const storage = new RecordStorage();
// Store the attachment ids of in record storage as key-value pairs
await storage.setValue(incidentNumber, attachmentIdPairs)
}
// If the operation is successful, log out the number of the created ServiceNow incident
console.log(`Incident ${incidentNumber} created in ServiceNow`);
} catch (e) {
// If something goes wrong, create an appropriate error message
const errorMessage = incidentCreated
? 'Incident created in ServiceNow, but syncing all fields has failed'
: 'Error creating incident in ServiceNow';
// Log out the error
console.error(errorMessage, e);
}
}
import JiraCloud from './api/jira/cloud';
import ServiceNow from './api/servicenow';
import { IssueDeletedEvent } from '@sr-connect/jira-cloud/events';
import { RecordStorage } from '@sr-connect/record-storage';
import { Incident } from './Types';
import { getEnvVars, getRecordFromArrayOfOne } from './Utils';
/**
* This function deletes a corresponding ServiceNow incident when an issue in Jira Cloud is deleted.
*
* @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. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the name of the custom field in Jira Cloud where the number of the corresponding ServiceNow incident is stored
const { CustomFieldConfiguration: { JIRA_CLOUD_CUSTOM_FIELD_NAME } } = getEnvVars(context);
try {
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// If the issue was created by the current user, log out a message, and stop the operation to avoid infinite loops
if (myself.accountId === event.user.accountId) {
console.log('Same user, skipping sync');
return;
}
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields();
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Obtain the number of the corresponding ServiceNow incident from the event
const incidentNumber = event.issue.fields[customField.key];
// If no such key exists in the event, log out a message and stop the operation
if (!incidentNumber) {
console.warn(`Number of the corresponding ServiceNow incident not stored on Jira Cloud issue ${event.issue.key}`);
return;
}
// If it exists, use it to query for more data about the ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident',
sysparm_query: `number=${incidentNumber}`,
headers: {
'Content-type': 'application/json',
}
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Delete the corresponding ServiceNow incident
await ServiceNow.All.deleteRecord({
sys_id: incident.sys_id, tableName: 'incident'
});
// Delete all attachment ids from record storage if any exist for this issue-incident pair
const storage = new RecordStorage();
await storage.deleteValue(incidentNumber);
// If the operation was successful, log out the incident number
console.log(`Successfully deleted ServiceNow incident ${incidentNumber}`);
} catch (error) {
// If something goes wrong, log out the error
console.error('Error deleting ServiceNow incident', error);
}
}
import JiraCloud from "./api/jira/cloud";
import ServiceNow from './api/servicenow';
import { IssueUpdatedEvent } from '@sr-connect/jira-cloud/events';
import { RecordStorage } from "@sr-connect/record-storage";
import { Incident, IncidentColumn, ServiceNowUser, UpdateIncidentRequestBody } from "./Types";
import { getEnvVars, getRecordFromArrayOfOne } from "./Utils";
/**
* This function updates a corresponding ServiceNow incident when an issue in Jira Cloud is updated.
*
* @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. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant for this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
SERVICENOW_ADMIN_EMAIL,
},
FieldMappings: {
STATUS_TO_STATE,
PRIORITY_TO_URGENCY,
},
ServiceNowCloseNotesConfiguration: {
ADD_INCIDENT_CLOSE_NOTES,
INCIDENT_RESOLVED_STATE,
INCIDENT_CLOSE_NOTES,
INCIDENT_CLOSE_CODE,
},
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME,
}
} = getEnvVars(context);
try {
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// If the issue was created by the current user, log out a message, and stop the operation to avoid infinite loops
if (myself.accountId === event.user.accountId) {
console.log('Same user, skipping sync');
return;
}
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields();
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Extract the number of the corresponding Service Now incident from the event
const incidentNumber = event.issue.fields[customField.key];
// Should the incident number be missing, log out a warning and stop the operation
if (!incidentNumber) {
console.warn(`Number of a corresponding ServiceNow incident not found on Jira Cloud issue ${event.issue.key}`);
return;
}
// Use this number to query for the correct ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident',
sysparm_query: `number=${incidentNumber}`,
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Get the updated issue fields in Jira Cloud
const changedItems = event.changelog?.items ?? [];
// Filter for those items that are included in the sync
const changedSyncedItems = changedItems.filter(i => SYNCED_FIELDS.includes(i.field.toLocaleLowerCase()));
// Define the request body for updating the ServiceNow incident
const requestBody: UpdateIncidentRequestBody = {}; // Overwrite later
// Loop through all the fields that have changed
for (const item of changedSyncedItems) {
switch (item.field) {
case 'summary':
//Assign the issue summary as the short description on the incident
requestBody.short_description = item.toString ?? ''
break;
case 'description':
// Assign the description to the incident
requestBody.description = item.toString ?? ''
break;
case 'priority':
// Get the priority for the Jira Cloud issue
const priority = item.toString;
// Find the corresponding urgency record in ServiceNow
const urgencyLabel = priority && PRIORITY_TO_URGENCY[priority];
// If no such urgency is found, throw an error
if (!urgencyLabel) {
throw Error(`Corresponding urgency not found in ServiceNow for Jira Cloud priority: ${priority}`)
}
// Obtain more data about the urgency record from ServiceNow
const urgencyRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=urgency^label=${urgencyLabel}`
});
const urgency = getRecordFromArrayOfOne<IncidentColumn>(urgencyRecords.result);
// Finally assign the urgency value to the request body
requestBody.urgency = urgency.value;
break;
case 'status':
// Get the status for the Jira Cloud issue
const status = item.toString;
// Find the corresponding state value in ServiceNow
const stateLabel = status && STATUS_TO_STATE[status];
// If no such state is found, throw an error
if (!stateLabel) {
throw Error(`Corresponding state not found in ServiceNow for Jira Cloud status: ${status}`)
}
// Obtain more data about the state record from ServiceNow
const stateRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=state^name=incident^label=${stateLabel}`
});
const state = getRecordFromArrayOfOne<IncidentColumn>(stateRecords.result);
// Finally assign the state value to the request body
requestBody.state = state.value
// The 'Resolved' state in ServiceNow by default requires close notes and a resolution code unless specified otherwise
if (ADD_INCIDENT_CLOSE_NOTES) {
// Check if the new state is the 'Resolved' state and if yes, add close notes and a resolution code to the request body
if (stateLabel === INCIDENT_RESOLVED_STATE) {
requestBody.close_notes = INCIDENT_CLOSE_NOTES;
requestBody.close_code = INCIDENT_CLOSE_CODE;
}
}
break;
case 'reporter':
// Get the reporter id for the Jira Cloud issue
const newReporterId = item.to;
// Find the new reporter's user in Jira Cloud
const jiraUser = await JiraCloud.User.getUser({
accountId: newReporterId ?? ''
});
// Define the caller for the new ServiceNow incident
let caller: ServiceNowUser;
try {
// Check if the reporter's email matches with that of a ServiceNow user
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${jiraUser.emailAddress}`,
});
caller = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result);
} catch (error) {
// If such a user does not exist log out a warning
console.warn(`Jira Cloud issue reporter does not have a corresponding user in ServiceNow, using the specified admin user instead as the incident caller`);
// Find the specified admin user in ServiceNow,
const usersRecordWithMyEmail = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
caller = getRecordFromArrayOfOne<ServiceNowUser>(usersRecordWithMyEmail.result)
}
// Assign the appropriate user to the incident as the caller
requestBody.caller_id = caller.sys_id
break;
default:
break;
}
}
// Update the incident
if (requestBody) {
await ServiceNow.Table.updateRecord({
tableName: 'incident',
sys_id: incident.sys_id,
body: requestBody,
});
}
// Check if 'attachment' is among the synced fields
if (SYNCED_FIELDS.includes('attachments')) {
// Check if attachments have been modified
const change = changedItems.find(i => i.field === 'Attachment');
// If yes, sync the attachments
if (change) {
// Define a storage object to obtain and store attachment ids
const storage = new RecordStorage();
// Check if the change on the Jira Cloud side has been the addition of an attachment
if (change.to) {
// If yes, get the metadata for the new attachment
const metadata = await JiraCloud.Issue.Attachment.Metadata.getMetadata({ id: change.to });
// Find the content link from attachment metadata and store its content in SRC using the large attachment feature (In case the attachment exceeds 100MB)
const storedAttachment = await JiraCloud.fetch(metadata.content ?? '', {
headers: {
'x-stitch-store-body': 'true'
}
});
// Check if the attachment content was fetched successfully
if (!storedAttachment.ok) {
// If not, then throw an error
throw Error(`Unexpected response while downloading attachment: ${storedAttachment.status}`);
}
// Obtain the id of the stored attachment from a response header
const storedAttachmentId = storedAttachment.headers.get('x-stitch-stored-body-id');
// If no id can be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
// Upload the attachment to ServiceNow, using the same feature
const attachmentResponse = await ServiceNow.fetch('/api/now/v1/attachment/upload', {
method: 'POST',
headers: {
'x-stitch-stored-body-id': storedAttachmentId,
'x-stitch-stored-body-form-data-file-name': `${metadata.filename}`,
'x-stitch-stored-body-form-data-file-identifier': 'uploadFile',
'x-stitch-stored-body-form-data-additional-fields': `table_name:incident;table_sys_id:${incident.sys_id};`,
}
});
// Obtain the attachment from the response
const attachment = await attachmentResponse.json();
// If an error is returned, log it out and throw an error
if (attachment.error) {
console.error(attachment.error);
throw Error('Error uploading attachment to ServiceNow');
}
// Extract the attachment id from the response
const attachmentId = (attachment as any).result?.sys_id;
// If no id cannot be found, throw an error
if (!attachmentId) {
throw Error(`Attachment created in ServiceNow, but failed to retrieve attachment data`);
}
// Get all stored attachment ids for the current issue - incident pair
const syncedAttachments = await storage.getValue<Record<string, string>>(incidentNumber);
// Store both attachment ids in record storage as a key-value pair
await storage.setValue(incidentNumber, { ...syncedAttachments, [attachmentId]: change.to });
} else {
// Get all stored attachment ids for the current issue - incident pair
const syncedAttachments = await storage.getValue<Record<string, string>>(incidentNumber);
// If the change in Jira Cloud side has been the deletion of an attachment, find the corresponding file on the ServiceNow side, using record storage
const attachmentId = syncedAttachments && Object.keys(syncedAttachments).find(i => syncedAttachments[i] === change.from);
// If no corresponding attachment was found in ServiceNow, log out a warning and stop the operation
if (!attachmentId) {
console.warn(`No attachment found in ServiceNow for Jira Cloud issue attachment ${change.from}`);
return;
}
// Delete the ServiceNow attachment
await ServiceNow.Attachment.deleteAttachment({ sys_id: attachmentId });
// If other attachments are stored on this issue-incident pair, only remove the ids of the deleted attachments from the storage object
if (Object.keys(syncedAttachments).length > 1) {
await storage.setValue(incidentNumber, { ...syncedAttachments, [attachmentId]: undefined });
} else {
// If it was the last remaining attachment, delete the whole entry for this issue-incident pair
await storage.deleteValue(incidentNumber)
}
}
}
}
// If the operation is successful, log out a message
console.log(`ServiceNow incident ${incidentNumber} synced with Jira Cloud issue ${event.issue.key}`);
} catch (error) {
// If something goes wrong, log out the error
console.error('Error updating ServiceNow incident', error);
}
}
import JiraCloud from './api/jira/cloud';
import ServiceNow from './api/servicenow';
import { RecordStorage } from '@sr-connect/record-storage';
import { Incident, IncidentAttachment, IncidentAttachmentCreatedEvent, IncidentColumn, ServiceNowUser } from './Types';
import { getEnvVars, getRecordFromArrayOfOne } from './Utils';
import { AddIssueAttachmentsResponseOK } from '@managed-api/jira-cloud-v3-core/types/issue/attachment';
import { AttachmentAsResponse } from '@managed-api/jira-cloud-v3-core/definitions/AttachmentAsResponse';
/**
* This function uploads an attachment to the corresponding Jira Cloud issue when an attachment is created in ServiceNow
*
* @param event Object that holds Generic Event event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IncidentAttachmentCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant for this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
SERVICENOW_ADMIN_EMAIL,
},
CustomFieldConfiguration: {
SERVICENOW_CUSTOM_COLUMN_NAME
}
} = getEnvVars(context);
// If 'attachments' is not among the synced fields, log out a message and stop the operation
if (!SYNCED_FIELDS.includes('attachments')) {
console.log('Not syncing attachments');
return;
}
// Keep track of when the corresponding attachment is created in Jira Cloud
let attachmentCreated = false;
try {
// Get the user who authorised the ServiceNow connector.
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const user = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result);
// If the attachment was uploaded by this user, log out a message and stop the operation to avoid infinite loops
if (user.sys_id === event.user) {
console.log('Same user, skipping sync');
return;
}
// Use the incident number from the event to obtain more data for the ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident', sysparm_query: `sys_id=${event.incident}`,
headers: {
'Content-type': 'application/json',
},
// For new incidents, ServiceNow fires this event before the incident has even been created,
// which will result in an error, so to avoid that we will need to explicitly return an empty result here
errorStrategy: {
handleHttp404Error: () => ({}),
}
});
// If the query did not fetch any incidents, log out a message and stop the operation
if (!incidentRecords?.result?.length) {
console.log(`Incident not found or hasn't been created yet`);
return;
}
// Extract the incident from query results
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Get the custom column where the corresponding Jira Cloud issue key is stored
const columnRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_dictionary',
sysparm_query: `name=incident^sys_name=${SERVICENOW_CUSTOM_COLUMN_NAME}`,
});
const column = getRecordFromArrayOfOne<IncidentColumn>(columnRecords.result);
// Try to extract the corresponding Jira Cloud issue key from the incident
const issueKey = incident[column.element];
// If no such key exists, throw an erro
if (!issueKey) {
throw Error('No corresponding Jira Cloud issue key found on the ServiceNow incident');
}
// Use the attachment id from the event to fetch more data about the attachment that triggered the event
const attachmentRecords = await ServiceNow.Attachment.getAttachments({
sysparm_query: `sys_id=${event.attachment_id}`
});
const attachment = getRecordFromArrayOfOne<IncidentAttachment>(attachmentRecords.result);
// Use a download link to fetch the content of the ServiceNow attachment and store its content in SRC using the large attachment feature (In case the attachment exceeds 100MB)
const response = await ServiceNow.fetch(`api/now/v1/attachment/${event.attachment_id}/file`, {
headers: {
'x-stitch-store-body': 'true'
}
});
// Get the stored attachment id from a response header
const storedAttachmentId = response.headers.get('x-stitch-stored-body-id');
// If no attachment id can be found, throw an error
if (!storedAttachmentId) {
throw new Error('The attachment stored body was not returned');
}
// Upload the attachment to Jira Cloud using the same feature
const uploadedAttachmentResponse = await JiraCloud.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.file_name,
}
});
attachmentCreated = true;
// Obtain the uploaded attachment from the response
const jiraAttachmentRecords: AddIssueAttachmentsResponseOK = await uploadedAttachmentResponse.json();
const jiraAttachment = getRecordFromArrayOfOne<AttachmentAsResponse>(jiraAttachmentRecords);
// Define a storage object for storing attachment ids
const storage = new RecordStorage();
// Fetch all attachment ids for the current issue-incident pai
const syncedAttachments = await storage.getValue<Record<string, string>>(event.incident);
// Add the new attachment ids to record storage as a key-value pair
await storage.setValue(event.incident, { ...syncedAttachments ?? {}, [event.attachment_id]: jiraAttachment.id });
// If the operation is successful, log out the name of the uploaded file
console.log(`Attachment ${attachment.file_name} uploaded to Jira Cloud issue ${issueKey}`);
} catch (error) {
// If something goes wrong, create an appropriate error message
const errorMessage = attachmentCreated
? 'Attachment has been uploaded to Jira Cloud issue, but storing attachment ids has failed'
: 'Error deleting attachment in Jira Cloud';
//Log out the error
console.error(errorMessage, error);
}
}
import JiraCloud from './api/jira/cloud';
import ServiceNow from './api/servicenow';
import { RecordStorage } from '@sr-connect/record-storage';
import { Incident, IncidentAttachmentDeletedEvent, IncidentColumn, ServiceNowUser } from './Types';
import { getEnvVars, getRecordFromArrayOfOne } from './Utils';
/**
* This function deletes the corresponding attachment in Jira Cloud when an attachment is deleted in ServiceNow.
*
* @param event Object that holds Generic Event event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IncidentAttachmentDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant for this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
SERVICENOW_ADMIN_EMAIL,
},
CustomFieldConfiguration: {
SERVICENOW_CUSTOM_COLUMN_NAME,
}
} = getEnvVars(context);
// If 'attachments' is not among the synced fields, log out a message and stop the operation
if (!SYNCED_FIELDS.includes('attachments')) {
console.log('Not syncing attachments');
return;
}
try {
// Get the user who has authorised the ServiceNow connector
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const user = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result);
// If the attachment was deleted by this user, log out a message and stop the operation to avoid infinite loops
if (user.sys_id === event.user) {
console.log('Same user, skipping sync');
return;
}
// Use the incident number from the event to obtain more data for the ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident', sysparm_query: `sys_id=${event.incident}`,
headers: {
'Content-type': 'application/json',
}
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Get the custom column where the corresponding Jira Cloud issue key is stored
const columnRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_dictionary',
sysparm_query: `name=incident^sys_name=${SERVICENOW_CUSTOM_COLUMN_NAME}`,
});
const column = getRecordFromArrayOfOne<IncidentColumn>(columnRecords.result);
// Try to extract the corresponding Jira Cloud issue key from the incident
const issueKey = incident[column.element];
// If no such key exists, throw an error
if (!issueKey) {
throw Error('No corresponding Jira Cloud issue key found on the ServiceNow incident');
}
// Define a storage object to obtain the id of a corresponding attachment in Jira Cloud
const storage = new RecordStorage();
// Fetch all attachment ids for the current issue-incident pair
const syncedAttachments = await storage.getValue<Record<string, string>>(event.incident) ?? {};
// Find the id for the corresponding attachment in Jira Cloud, using record storage
const jiraAttachmentId = syncedAttachments[event.attachment_id] ?? '';
// Obtain more data about the corresponding attachment in Jira Cloud
const attachmentMetadata = await JiraCloud.Issue.Attachment.Metadata.getMetadata({
id: jiraAttachmentId,
// ServiceNow sometimes fires this event multiple times in a row, resulting in an error,
// so in order to avoid that, we need to explicitly return null here
errorStrategy: {
handleHttp404Error: () => null
}
});
// If the attachment cannot be found in Jira Cloud, log out a message and stop the operation
if (!attachmentMetadata) {
console.log('Attachment not found or has already been deleted');
return;
}
// Delete the attachment
await JiraCloud.Issue.Attachment.deleteAttachment({ id: jiraAttachmentId, });
// If other attachments are stored on this issue-incident pair, only remove the ids of the deleted attachments from the storage object
if (Object.keys(syncedAttachments).length > 1) {
await storage.setValue(event.incident, { ...syncedAttachments, attachmentId: undefined });
} else {
// If it was the last remaining attachment, delete the whole entry for this issue-incident pair
await storage.deleteValue(event.incident)
}
// If the operation is successful, log out the name of the deleted file
console.log(`Attachment ${attachmentMetadata.filename} deleted from Jira Cloud issue ${issueKey}`);
} catch (error) {
// If something goes wrong, log out the error
console.error('Error while deleting attachment', error);
}
}
import JiraCloud from "./api/jira/cloud";
import ServiceNow from './api/servicenow';
import { IssueFieldsCreate } from "@managed-api/jira-cloud-v3-core/definitions/IssueFields";
import { UserAsResponse } from "@managed-api/jira-cloud-v3-core/definitions/UserAsResponse";
import { Incident, IncidentColumn, IncidentCreatedEvent, ServiceNowUser } from "./Types";
import { createParagraphInADF, getEnvVars, getRecordFromArrayOfOne } from "./Utils";
/**
* This function creates a new issue in Jira Cloud when an incident is created in ServiceNow
*
* @param event Object that holds Generic Event event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IncidentCreatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant for this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
SERVICENOW_ADMIN_EMAIL,
JIRA_CLOUD_PROJECT_KEY,
JIRA_CLOUD_ISSUE_TYPE,
},
FieldMappings: {
STATE_TO_STATUS,
URGENCY_TO_PRIORITY,
},
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME,
SERVICENOW_CUSTOM_COLUMN_NAME,
}
} = getEnvVars(context);
// Keep track of when the corresponding issue is created in Jira Cloud
let issueCreated = false;
try {
// Get the user who authorised the ServiceNow connector
const adminUsersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const adminUser = getRecordFromArrayOfOne<ServiceNowUser>(adminUsersRecord.result);
// If the incident was created by this user, log out a message and stop the operation to avoid infinite loops
if (adminUser.sys_id === event.user) {
console.log('Same user, skipping sync');
return;
}
// Use the incident number from the event to query for the correct ServiceNow incident
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident',
sysparm_query: `number=${event.trigger}`,
headers: {
'Content-type': 'application/json',
}
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields();
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Define the fields for creating the new Jira issue, also passing the incident number into a custom field on Jira Cloud side
const issueFields: IssueFieldsCreate = {
issuetype: {
name: JIRA_CLOUD_ISSUE_TYPE
},
project: {
key: JIRA_CLOUD_PROJECT_KEY,
},
summary: '', // Overwrite later
[customField.key]: event.trigger,
}
// If 'summary' is among the synced fields, assign it to the short description property on the corresponding ServiceNow incident
if (SYNCED_FIELDS.includes('summary')) {
issueFields.summary = incident.short_description;
}
// If 'reporter' is among the synced fields, find a user in Jira Cloud who will be added to that role on the new issue
if (SYNCED_FIELDS.includes('reporter')) {
// Get the user in ServiceNow who created the incident
const serviceNowUser = await ServiceNow.Table.getRecord({
tableName: 'sys_user',
sys_id: incident.caller_id.value,
});
// Search for a user in Jira cloud with a matching email
const jiraUserRecords = await JiraCloud.User.Search.findUsers({ query: serviceNowUser.result?.email });
const jiraUser = jiraUserRecords.length === 1 ? getRecordFromArrayOfOne<UserAsResponse>(jiraUserRecords) : undefined;
// If such a user exists in Jira Cloud, add them to the request body as the reporter
if (jiraUser?.accountId) {
issueFields.reporter = { accountId: jiraUser.accountId };
} else {
// If no such user is found, log out a warning and use the current user instead
console.warn(`User with email ${serviceNowUser.result?.email} does not exist in Jira Cloud, adding the current user as the reporter instead`);
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// Should there be a problem with fetching the current user's account id, throw an error
if (!myself.accountId) {
throw Error('Account id not found for the current user in Jira Cloud');
}
// Add the user to the Jira Cloud issue as the reporter
issueFields.reporter = { accountId: myself.accountId };
}
}
// If 'priority' is among the synced fields, find the name of a priority in Jira Cloud corresponding to the urgency of the ServiceNow incident that triggered the event
if (SYNCED_FIELDS.includes('priority')) {
// Obtain more data about the urgency record from ServiceNow
const urgencyRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=urgency^value=${incident.urgency}`
});
const urgency = getRecordFromArrayOfOne<IncidentColumn>(urgencyRecords.result);
// Using the urgency label, find a corresponding priority for Jira Cloud
const priorityName = URGENCY_TO_PRIORITY[urgency.label];
// If no corresponding priority is found, throw an error
if (!priorityName) {
throw Error(`No corresponding priority found in Jira Cloud for the ServiceNow incident urgency ${urgency.label}`)
}
// Add the priority to the new Jira Cloud issue
issueFields.priority = { name: priorityName }
}
// If 'description' is among the synced fields, check if there is one added to the corresponding ServiceNow incident
if (SYNCED_FIELDS.includes('description')) {
if (incident.description) {
// Add the description to the new Jira Cloud issue
issueFields.description = createParagraphInADF(incident.description);
}
}
// Create the new Issue in Jira Cloud
const newIssue = await JiraCloud.Issue.createIssue({
body: {
fields: issueFields,
}
});
issueCreated = true;
// Should the newly created issue not have a key, throw an error
if (!newIssue.key) {
throw Error('No key found for the newly created Jira Cloud issue')
}
// Get the custom column where the corresponding Jira Cloud issue key will be stored
const columnRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_dictionary',
sysparm_query: `name=incident^sys_name=${SERVICENOW_CUSTOM_COLUMN_NAME}`,
});
const column = getRecordFromArrayOfOne<IncidentColumn>(columnRecords.result);
// Add the Jira Cloud issue key to a custom field of the ServiceNow incident that triggered the event
await ServiceNow.Table.updateRecord({
tableName: 'incident',
sys_id: incident.sys_id,
body: {
[column.element]: newIssue.key,
}
});
// Only continue the operation if the synced fields include 'comment', 'status' or 'attachment'
if (SYNCED_FIELDS.some((f) => f === 'comments' || f === 'status' || f === 'attachments')) {
// Obtain more data for the new issue in Jira Cloud
const issue = await JiraCloud.Issue.getIssue({
issueIdOrKey: newIssue.key,
});
// If 'status' is among the synced fields, transition the Jira Cloud issue to the corresponding workflow status
if (SYNCED_FIELDS.includes('status')) {
// Obtain more data about the label record from ServiceNow
const stateRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=state^name=incident^value=${incident.state}`
});
const state = getRecordFromArrayOfOne<IncidentColumn>(stateRecords.result);
// Using the state label, find a corresponding status name for the Jira Cloud issue
const statusName = STATE_TO_STATUS[state.label];
// If no corresponding status is found, throw an error
if (!statusName) {
throw Error(`No status id found in Jira Cloud for the corresponding ServiceNow incident state ${state.label}`)
}
// Check if the newly created Jira Cloud issue already has the correct status
if (issue.fields?.status?.name !== statusName) {
// If not, find an appropriate workflow transition in order to change the status
const transitions = await JiraCloud.Issue.Transition.getTransitions({
issueIdOrKey: newIssue.key,
expand: 'transition.fields'
});
const transition = transitions.transitions?.find(t => t.to?.name === statusName);
if (!transition?.id) {
throw Error(`No transition found for assigning the workflow status ${statusName} to the Jira Cloud issue`)
}
// Perform the workflow transition in order to match the status of the Jira Cloud issue to the state of the ServiceNow incident
await JiraCloud.Issue.Transition.performTransition({
issueIdOrKey: newIssue.key,
body: {
transition: {
id: transition.id
},
}
});
}
}
// If 'attachments' is among the synced fields and there are some on the ServiceNow incident, add them to the Jira Cloud issue as well
if (SYNCED_FIELDS.includes('attachments')) {
// Get all attachments on the newly created ServiceNow incident
const attachments = await ServiceNow.Attachment.getAttachments({
sysparm_query: `table_name=incident^table_sys_id=${incident.sys_id}`
});
if (attachments.result.length) {
// If there are any, then define the request body for adding files to the Jira Cloud issue
const requestBody = [];
// Loop through all the attachments on the ServiceNow incident
for (const attachment of attachments.result) {
// Fetch the attachment content
const content = await ServiceNow.fetch(attachment.download_link ?? '');
// Turn the file content into an array buffer
const arrayBuffer = await content.arrayBuffer();
// Add the array buffer to the request body along with the file name
requestBody.push({ content: arrayBuffer, fileName: attachment.file_name ?? '' });
}
// Upload all found attachments to the Jira Cloud issue
await JiraCloud.Issue.Attachment.addAttachments({
issueIdOrKey: newIssue.key,
body: requestBody
});
}
}
// If the synced fields include 'comments', check if the ServiceNow incident has any
if (SYNCED_FIELDS.includes('comments')) {
// Get comments from the ServiceNow incident that triggered the event
const comments = await ServiceNow.Table.getRecords({
tableName: 'sys_journal_field',
sysparm_query: `element=comments^element_id=${incident.sys_id}`,
headers: {
'Content-type': 'application/json',
},
// ServiceNow throws a 404 error if no comments are found,
// so we explicitly need to return an empty result ourselves in this scenario
errorStrategy: {
handleHttp404Error: () => ({ result: [] }),
}
});
// If any comments were found on the incident, add them to the Jira Cloud issue
if (comments && comments.result.length) {
// Get the user who triggered the event
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `sys_id=${event.user}`,
headers: {
'Content-type': 'application/json',
}
});
const user = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result);
// Loop through all the found comments
for (const comment of comments.result) {
// Add the comment to the Jira Cloud issue
await JiraCloud.Issue.Comment.addComment({
issueIdOrKey: newIssue.key,
body: {
body: createParagraphInADF(`${user.name}: ${comment.value}`)
}
});
}
}
}
// If the operation was successful, log out the key for the new Jira Cloud issue
console.log(`Issue ${newIssue.key} created in Jira Cloud`);
}
} catch (error) {
// If something goes wrong, create an appropriate error message
const errorMessage = issueCreated
? 'Issue created in Jira Cloud, but syncing all fields has failed'
: 'Error creating Jira Cloud issue';
// Log out the error
console.error(errorMessage, error);
}
}
import JiraCloud from './api/jira/cloud';
import ServiceNow from './api/servicenow';
import { IncidentDeletedEvent, ServiceNowUser } from './Types';
import { getEnvVars, getRecordFromArrayOfOne } from './Utils';
/**
* This function deletes a corresponding issue in Jira Cloud when an incident is deleted in ServiceNow.
*
* @param event Object that holds Generic Event event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IncidentDeletedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant for this script
const {
BasicConfiguration: {
SERVICENOW_ADMIN_EMAIL,
},
} = getEnvVars(context);
try {
// Get the user who authorised the ServiceNow connector
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const user = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result);
// If the incident was deleted by this user, log out a message and stop the operation to avoid infinite loops
if (user.sys_id === event.user) {
console.log('Same user, skipping sync');
return;
}
// Try to obtain the corresponding Jira Cloud issue key from the event
const issueKey = event.jira_cloud_issue_key;
// If such a key was not found, log out a message and stop the operation
if (!issueKey) {
throw Error('Corresponding issue key not stored on the ServiceNow incident');
}
// If the key exists, use it to delete the corresponding Jira issue
await JiraCloud.Issue.deleteIssue({ issueIdOrKey: issueKey });
// If the operation was successful, log out the key of the deleted Jira Cloud issue
console.log(`Issue with key ${issueKey} deleted in Jira Cloud`);
} catch (error) {
// If something goes wrong, log out the error
console.error('Error deleting issue', error);
}
}
import JiraCloud from "./api/jira/cloud";
import ServiceNow from './api/servicenow';
import { IssueFieldsUpdate } from '@managed-api/jira-cloud-v3-core/definitions/IssueFields';
import { UserAsResponse } from "@managed-api/jira-cloud-v3-core/definitions/UserAsResponse";
import { Incident, IncidentColumn, IncidentUpdatedEvent, ServiceNowUser } from "./Types";
import { createParagraphInADF, getEnvVars, getRecordFromArrayOfOne } from "./Utils";
/**
* This function updates an issue in Jira Cloud when an incident is updated in ServiceNow.
*
* @param event Object that holds Generic Event event data
* @param context Object that holds function invocation context data
*/
export default async function (event: IncidentUpdatedEvent, context: Context): Promise<void> {
if (context.triggerType === 'MANUAL') {
console.error('This script is designed to be triggered externally. If you need to trigger the script manually, consider emulating the event with a test payload instead.');
return;
}
// Get the environment variables relevant to this script
const {
BasicConfiguration: {
SYNCED_FIELDS,
SERVICENOW_ADMIN_EMAIL,
},
FieldMappings: {
STATE_TO_STATUS,
URGENCY_TO_PRIORITY
},
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME,
SERVICENOW_CUSTOM_COLUMN_NAME,
}
} = getEnvVars(context);
try {
// Get the user who authorised the ServiceNow connector
const adminUsersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `email=${SERVICENOW_ADMIN_EMAIL}`,
headers: {
'Content-type': 'application/json',
}
});
const adminUser = getRecordFromArrayOfOne<ServiceNowUser>(adminUsersRecord.result);
// If the incident was deleted by this user, log out a message and stop the operation to avoid infinite loops
if (adminUser.sys_id === event.user) {
console.log('Same user, skipping sync');
return;
}
// Use the incident number from the event to query for the ServiceNow incident that triggered the event
const incidentRecords = await ServiceNow.Table.getRecords({
tableName: 'incident',
sysparm_query: `number=${event.trigger}`,
headers: {
'Content-type': 'application/json',
}
});
const incident = getRecordFromArrayOfOne<Incident>(incidentRecords.result);
// Get the custom column where the corresponding Jira Cloud issue key is stored
const columnRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_dictionary',
sysparm_query: `name=incident^sys_name=${SERVICENOW_CUSTOM_COLUMN_NAME}`,
});
const column = getRecordFromArrayOfOne<IncidentColumn>(columnRecords.result);
// Extract the corresponding Jira Cloud issue key from the incident
const issueKey = incident[column.element];
// If no such key exists, throw an error
if (!issueKey) {
throw Error('No corresponding Jira Cloud issue key stored on the ServiceNow incident');
}
// Get all custom issue fields in Jira Cloud
const customFields = await JiraCloud.Issue.Field.getFields();
// Find the custom field where the ServiceNow incident number is stored
const customField = customFields.find(f => f.name === JIRA_CLOUD_CUSTOM_FIELD_NAME);
// If no such field is found, throw an error
if (!customField?.key) {
throw Error(`Custom field ${JIRA_CLOUD_CUSTOM_FIELD_NAME} not found in Jira Cloud`);
}
// Store the request body or updating the corresponding issue, while also passing the Jira Cloud issue key into a custom field on the ServiceNow side
let issueFields: IssueFieldsUpdate = { [customField.key]: event.trigger };
// If 'summary' is among the synced fields, and if short_description has changed on the incident, add it to the Jira Cloud issue as the summary
if (SYNCED_FIELDS.includes('summary') && event.short_description) {
issueFields.summary = event.short_description;
}
// If 'description' is among the synced fields and it has changed on the incident, convert it to ADF format and add it to the Jira Cloud issue
if (SYNCED_FIELDS.includes('description') && event.description) {
issueFields.description = createParagraphInADF(event.description);
}
// If 'priority' is among the synced fields and if the incident urgency has changed, assign a corresponding priority to the Jira Cloud issue
if (SYNCED_FIELDS.includes('priority') && event.urgency) {
// Obtain more data about the urgency record from ServiceNow
const urgencyRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=urgency^value=${event.urgency}`
});
const urgency = getRecordFromArrayOfOne<IncidentColumn>(urgencyRecords.result);
// Using the urgency label, find a corresponding priority for Jira Cloud
const priorityName = URGENCY_TO_PRIORITY[urgency.label];
// If no such priority is found, throw an error
if (!priorityName) {
throw Error(`Corresponding priority not found in Jira Cloud for ServiceNow urgency ${urgency.label}`)
}
// Assign the priority to the issue in Jira Cloud
issueFields.priority = { name: priorityName };
}
// If 'reporter' is among the synced fields and if the incident caller has changed, assign a corresponding user to the Jira Cloud issue as the reporter
if (SYNCED_FIELDS.includes('reporter') && event.caller_id) {
// Get the user in ServiceNow who updated the incident
const caller = await ServiceNow.Table.getRecord({
tableName: 'sys_user',
sys_id: event.caller_id,
});
// Search for a user in Jira cloud with a matching email
const jiraUserRecords = await JiraCloud.User.Search.findUsers({ query: caller.result?.email });
const jiraUser = getRecordFromArrayOfOne<UserAsResponse>(jiraUserRecords);
// If such a user is found, obtain the user id
if (jiraUser?.accountId) {
// Assign the user to the Jira Cloud issue as a reporter
issueFields.reporter = { accountId: jiraUser.accountId };
} else {
// If no such user is found, log out a warning
console.warn(`User with email ${caller.result?.email} does not exist in Jira Cloud, adding the current user as the reporter instead`);
// Get the current user
const myself = await JiraCloud.Myself.getCurrentUser();
// Should the account id not be available for the current user, throw an error
if (!myself.accountId) {
throw Error(`Account id missing for the current user in Jira Cloud`);
}
// Assign the current user to the Jira Cloud issue as a reporter
issueFields.reporter = { accountId: myself.accountId };
}
}
// Update the issue Jira Cloud with all the mapped fields
await JiraCloud.Issue.editIssue({
issueIdOrKey: issueKey,
body: {
fields: issueFields,
}
});
// If 'status' is among the synced fields and the incident state has changed, assign a corresponding status to the issue in Jira Cloud
if (SYNCED_FIELDS.includes('status') && event.state) {
// Obtain more data about the label record from ServiceNow
const stateRecords = await ServiceNow.Table.getRecords({
tableName: 'sys_choice',
sysparm_query: `element=state^name=incident^value=${event.state}`
});
const state = getRecordFromArrayOfOne<IncidentColumn>(stateRecords.result);
// Using the state label, find a corresponding status name for the Jira Cloud issue
const statusName = STATE_TO_STATUS[state.label];
// If no such status is found, throw an error
if (!statusName) {
throw Error(`Corresponding status not found in Jira Cloud for ServiceNow state: ${state.label}`);
}
// In order to update the status of the Jira Issue, find the necessary workflow transition
const transitions = await JiraCloud.Issue.Transition.getTransitions({
issueIdOrKey: issueKey,
});
const transition = transitions.transitions?.find(t => t.to?.name === statusName);
// If no such transition is found throw an error
if (!transition?.id) {
throw Error(`No transition found for assigning the workflow status ${statusName} to the Jira Cloud issue`);
}
// Finally perform the workflow transition to match the status of the Jira Cloud issue to that of the ServiceNow incident
await JiraCloud.Issue.Transition.performTransition({
issueIdOrKey: issueKey,
body: {
transition: {
id: transition.id
}
}
});
}
// If 'comment' is among the synced fields and one has been added to the incident, copy it to the issue in Jira Cloud
if (SYNCED_FIELDS.includes('comments') && event.comments) {
// Get the user who created the comment
const usersRecord = await ServiceNow.Table.getRecords({
tableName: 'sys_user',
sysparm_query: `sys_id=${event.user}`,
headers: {
'Content-type': 'application/json',
}
});
const user = getRecordFromArrayOfOne<ServiceNowUser>(usersRecord.result);
// Add the comment to the Jira Cloud issue
await JiraCloud.Issue.Comment.addComment({
issueIdOrKey: issueKey,
body: {
body: createParagraphInADF(`${user.name}: ${event.comments}`)
}
});
}
// If the operation is successful, log out a message
console.log(`ServiceNow incident ${event.trigger} synced with Jira Cloud issue ${issueKey}`);
} catch (error) {
// If something goes wrong, log out the error
console.error('Error updating Jira Cloud issue', error);
}
}
export interface IncidentCreatedEvent {
eventType: string;
title: string;
user: string;
trigger: string;
}
export interface IncidentUpdatedEvent {
eventType: string;
title: string;
user: string;
trigger: string;
short_description: string;
description: string;
urgency: string;
state: string;
caller_id: string;
comments: string;
}
export interface IncidentDeletedEvent {
eventType: string;
title: string;
user: string;
trigger: string;
jira_cloud_issue_key: string;
}
export interface IncidentAttachmentCreatedEvent {
eventType: string;
title: string;
user: string;
attachment_id: string;
incident: string;
}
export interface IncidentAttachmentDeletedEvent {
eventType: string;
title: string;
user: string;
attachment_id: string;
incident: string;
}
export interface CreateIncidentRequestBody {
short_description: string;
description?: string;
state?: string;
urgency?: string;
caller_id?: string;
[x: string]: any;
}
export interface UpdateIncidentRequestBody {
short_description?: string;
description?: string;
state?: number | string;
urgency?: string;
caller_id?: string;
close_notes?: string;
close_code?: string;
}
export interface Incident {
caller_id: {
value: string;
};
description?: string;
priority: string;
short_description: string;
state: string;
sys_id: string;
urgency: string;
[x: string]: any;
}
export interface IncidentAttachment {
file_name: string;
}
export interface IncidentColumn {
element: string;
label: string;
value: string;
}
export interface ServiceNowUser {
email: string;
name: string;
sys_id: string;
}
export interface EnvVars {
BasicConfiguration: {
SYNCED_FIELDS: string[];
JIRA_CLOUD_PROJECT_KEY: string;
JIRA_CLOUD_ISSUE_TYPE: string;
SERVICENOW_ADMIN_EMAIL: string;
INCIDENT_DEFAULT_SHORT_DESCRIPTION: string;
};
FieldMappings: {
STATUS_TO_STATE: Record<string, string>;
STATE_TO_STATUS: Record<string, string>;
PRIORITY_TO_URGENCY: Record<string, string>;
URGENCY_TO_PRIORITY: Record<string, string>;
};
CustomFieldConfiguration: {
JIRA_CLOUD_CUSTOM_FIELD_NAME: string;
SERVICENOW_CUSTOM_COLUMN_NAME: string;
};
ServiceNowCloseNotesConfiguration: {
ADD_INCIDENT_CLOSE_NOTES: boolean;
INCIDENT_RESOLVED_STATE: string;
INCIDENT_CLOSE_NOTES: string;
INCIDENT_CLOSE_CODE: string;
};
}
import { EnvVars } from "./Types";
// Function that constructs a paragraph in ADF (Atlassian Document Format)
export const createParagraphInADF = (text: string) => {
return {
type: 'doc' as const,
version: 1 as const,
content: [
{
type: 'paragraph' as const,
content: [
{
type: 'text' as const,
text
}
]
}
]
};
};
// Function that checks if an array of query results only consists of one record and if yes, returns it, if not, throws an error
export function getRecordFromArrayOfOne<T>(records: Record<string, any>[]): T {
if (!records.length) {
throw Error('No records found.');
}
if (records.length !== 1) {
throw Error('Unique record not found.')
}
return records[0] as T;
}
// Function to retrieve environment variables from the context object
export function getEnvVars(context: Context) {
return context.environment.vars as EnvVars;
}