diff --git a/packages/nodes-base/nodes/Jira/GenericFunctions.ts b/packages/nodes-base/nodes/Jira/GenericFunctions.ts index 3ee49ac166..88e247f1cc 100644 --- a/packages/nodes-base/nodes/Jira/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Jira/GenericFunctions.ts @@ -46,7 +46,7 @@ export async function jiraSoftwareCloudApiRequest(this: IHookFunctions | IExecut }, method, qs: query, - uri: uri || `${domain}/rest/api/2${endpoint}`, + uri: uri || `${domain}/rest${endpoint}`, body, json: true }; @@ -54,6 +54,7 @@ export async function jiraSoftwareCloudApiRequest(this: IHookFunctions | IExecut try { return await this.helpers.request!(options); } catch (error) { + let errorMessage = error.message; if (error.response.body) { @@ -104,3 +105,58 @@ export function validateJSON(json: string | undefined): any { // tslint:disable- } return result; } + +export function eventExists (currentEvents : string[], webhookEvents: string[]) { + for (const currentEvent of currentEvents) { + if (!webhookEvents.includes(currentEvent)) { + return false; + } + } + return true; +} + +export function getId (url: string) { + return url.split('/').pop(); +} + +export const allEvents = [ + 'board_created', + 'board_updated', + 'board_deleted', + 'board_configuration_changed', + 'comment_created', + 'comment_updated', + 'comment_deleted', + 'jira:issue_created', + 'jira:issue_updated', + 'jira:issue_deleted', + 'option_voting_changed', + 'option_watching_changed', + 'option_unassigned_issues_changed', + 'option_subtasks_changed', + 'option_attachments_changed', + 'option_issuelinks_changed', + 'option_timetracking_changed', + 'project_created', + 'project_updated', + 'project_deleted', + 'sprint_created', + 'sprint_deleted', + 'sprint_updated', + 'sprint_started', + 'sprint_closed', + 'user_created', + 'user_updated', + 'user_deleted', + 'jira:version_released', + 'jira:version_unreleased', + 'jira:version_created', + 'jira:version_moved', + 'jira:version_updated', + 'jira:version_deleted', + 'issuelink_created', + 'issuelink_deleted', + 'worklog_created', + 'worklog_updated', + 'worklog_deleted', +]; diff --git a/packages/nodes-base/nodes/Jira/Jira.node.ts b/packages/nodes-base/nodes/Jira/Jira.node.ts index f761aaa878..07c29bca94 100644 --- a/packages/nodes-base/nodes/Jira/Jira.node.ts +++ b/packages/nodes-base/nodes/Jira/Jira.node.ts @@ -113,9 +113,9 @@ export class Jira implements INodeType { const returnData: INodePropertyOptions[] = []; const jiraVersion = this.getCurrentNodeParameter('jiraVersion') as string; - let endpoint = '/project/search'; + let endpoint = '/api/2/project/search'; if (jiraVersion === 'server') { - endpoint = '/project'; + endpoint = '/api/2/project'; } let projects = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET'); @@ -139,7 +139,7 @@ export class Jira implements INodeType { const projectId = this.getCurrentNodeParameter('project'); const returnData: INodePropertyOptions[] = []; - const issueTypes = await jiraSoftwareCloudApiRequest.call(this, '/issuetype', 'GET'); + const issueTypes = await jiraSoftwareCloudApiRequest.call(this, '/api/2/issuetype', 'GET'); const jiraVersion = this.getCurrentNodeParameter('jiraVersion') as string; if (jiraVersion === 'server') { for (const issueType of issueTypes) { @@ -173,7 +173,7 @@ export class Jira implements INodeType { async getLabels(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const labels = await jiraSoftwareCloudApiRequest.call(this, '/label', 'GET'); + const labels = await jiraSoftwareCloudApiRequest.call(this, '/api/2/label', 'GET'); for (const label of labels.values) { const labelName = label; @@ -192,7 +192,7 @@ export class Jira implements INodeType { async getPriorities(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const priorities = await jiraSoftwareCloudApiRequest.call(this, '/priority', 'GET'); + const priorities = await jiraSoftwareCloudApiRequest.call(this, '/api/2/priority', 'GET'); for (const priority of priorities) { const priorityName = priority.name; @@ -213,7 +213,7 @@ export class Jira implements INodeType { const jiraVersion = this.getCurrentNodeParameter('jiraVersion') as string; if (jiraVersion === 'server') { // the interface call must bring username - const users = await jiraSoftwareCloudApiRequest.call(this, '/user/search', 'GET', {}, + const users = await jiraSoftwareCloudApiRequest.call(this, '/api/2/user/search', 'GET', {}, { username: "'", } @@ -228,7 +228,7 @@ export class Jira implements INodeType { }); } } else { - const users = await jiraSoftwareCloudApiRequest.call(this, '/users/search', 'GET'); + const users = await jiraSoftwareCloudApiRequest.call(this, '/api/2/users/search', 'GET'); for (const user of users) { const userName = user.displayName; @@ -249,7 +249,7 @@ export class Jira implements INodeType { async getGroups(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; - const groups = await jiraSoftwareCloudApiRequest.call(this, '/groups/picker', 'GET'); + const groups = await jiraSoftwareCloudApiRequest.call(this, '/api/2/groups/picker', 'GET'); for (const group of groups.groups) { const groupName = group.name; @@ -269,7 +269,7 @@ export class Jira implements INodeType { const returnData: INodePropertyOptions[] = []; const issueKey = this.getCurrentNodeParameter('issueKey'); - const transitions = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}/transitions`, 'GET'); + const transitions = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}/transitions`, 'GET'); for (const transition of transitions.transitions) { returnData.push({ @@ -340,7 +340,7 @@ export class Jira implements INodeType { if (additionalFields.updateHistory) { qs.updateHistory = additionalFields.updateHistory as boolean; } - const issueTypes = await jiraSoftwareCloudApiRequest.call(this, '/issuetype', 'GET', body, qs); + const issueTypes = await jiraSoftwareCloudApiRequest.call(this, '/api/2/issuetype', 'GET', body, qs); const subtaskIssues = []; for (const issueType of issueTypes) { if (issueType.subtask) { @@ -358,7 +358,7 @@ export class Jira implements INodeType { }; } body.fields = fields; - responseData = await jiraSoftwareCloudApiRequest.call(this, '/issue', 'POST', body); + responseData = await jiraSoftwareCloudApiRequest.call(this, '/api/2/issue', 'POST', body); } //https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issue-issueIdOrKey-put if (operation === 'update') { @@ -399,7 +399,7 @@ export class Jira implements INodeType { if (updateFields.description) { fields.description = updateFields.description as string; } - const issueTypes = await jiraSoftwareCloudApiRequest.call(this, '/issuetype', 'GET', body); + const issueTypes = await jiraSoftwareCloudApiRequest.call(this, '/api/2/issuetype', 'GET', body); const subtaskIssues = []; for (const issueType of issueTypes) { if (issueType.subtask) { @@ -419,10 +419,10 @@ export class Jira implements INodeType { body.fields = fields; if (updateFields.statusId) { - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}/transitions`, 'POST', { transition: { id: updateFields.statusId } }); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}/transitions`, 'POST', { transition: { id: updateFields.statusId } }); } - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}`, 'PUT', body); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}`, 'PUT', body); responseData = { success: true }; } //https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issue-issueIdOrKey-get @@ -445,7 +445,7 @@ export class Jira implements INodeType { qs.updateHistory = additionalFields.updateHistory as string; } - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}`, 'GET', {}, qs); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}`, 'GET', {}, qs); } //https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-search-post @@ -463,11 +463,11 @@ export class Jira implements INodeType { body.expand = options.expand as string; } if (returnAll) { - responseData = await jiraSoftwareCloudApiRequestAllItems.call(this, 'issues', `/search`, 'POST', body); + responseData = await jiraSoftwareCloudApiRequestAllItems.call(this, 'issues', `/api/2/search`, 'POST', body); } else { const limit = this.getNodeParameter('limit', i) as number; body.maxResults = limit; - responseData = await jiraSoftwareCloudApiRequest.call(this, `/search`, 'POST', body); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/search`, 'POST', body); responseData = responseData.issues; } } @@ -476,10 +476,10 @@ export class Jira implements INodeType { const issueKey = this.getNodeParameter('issueKey', i) as string; const returnAll = this.getNodeParameter('returnAll', i) as boolean; if (returnAll) { - responseData = await jiraSoftwareCloudApiRequestAllItems.call(this, 'values',`/issue/${issueKey}/changelog`, 'GET'); + responseData = await jiraSoftwareCloudApiRequestAllItems.call(this, 'values',`/api/2/issue/${issueKey}/changelog`, 'GET'); } else { qs.maxResults = this.getNodeParameter('limit', i) as number; - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}/changelog`, 'GET', {}, qs); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}/changelog`, 'GET', {}, qs); responseData = responseData.values; } } @@ -563,7 +563,7 @@ export class Jira implements INodeType { body.restrict = notificationRecipientsRestrictionsJson; } } - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}/notify`, 'POST', body, qs); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}/notify`, 'POST', body, qs); } //https://developer.atlassian.com/cloud/jira/platform/rest/v2/#api-rest-api-2-issue-issueIdOrKey-transitions-get @@ -579,7 +579,7 @@ export class Jira implements INodeType { if (additionalFields.skipRemoteOnlyCondition) { qs.skipRemoteOnlyCondition = additionalFields.skipRemoteOnlyCondition as boolean; } - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}/transitions`, 'GET', {}, qs); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}/transitions`, 'GET', {}, qs); responseData = responseData.transitions; } @@ -588,7 +588,7 @@ export class Jira implements INodeType { const issueKey = this.getNodeParameter('issueKey', i) as string; const deleteSubtasks = this.getNodeParameter('deleteSubtasks', i) as boolean; qs.deleteSubtasks = deleteSubtasks; - responseData = await jiraSoftwareCloudApiRequest.call(this, `/issue/${issueKey}`, 'DELETE', {}, qs); + responseData = await jiraSoftwareCloudApiRequest.call(this, `/api/2/issue/${issueKey}`, 'DELETE', {}, qs); } } if (Array.isArray(responseData)) { diff --git a/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts b/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts new file mode 100644 index 0000000000..ed63d4cb5e --- /dev/null +++ b/packages/nodes-base/nodes/Jira/JiraTrigger.node.ts @@ -0,0 +1,454 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + INodeTypeDescription, + INodeType, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + jiraSoftwareCloudApiRequest, + eventExists, + getId, + allEvents, +} from './GenericFunctions'; + +import * as queryString from 'querystring'; + +export class JiraTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Jira Trigger', + name: 'jiraTrigger', + icon: 'file:jira.png', + group: ['trigger'], + version: 1, + description: 'Starts the workflow when Jira events occurs.', + defaults: { + name: 'Jira Trigger', + color: '#4185f7', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'jiraSoftwareCloudApi', + required: true, + displayOptions: { + show: { + jiraVersion: [ + 'cloud', + ], + }, + }, + }, + { + name: 'jiraSoftwareServerApi', + required: true, + displayOptions: { + show: { + jiraVersion: [ + 'server', + ], + }, + }, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Jira Version', + name: 'jiraVersion', + type: 'options', + options: [ + { + name: 'Cloud', + value: 'cloud', + }, + { + name: 'Server (Self Hosted)', + value: 'server', + }, + ], + default: 'cloud', + }, + { + displayName: 'Events', + name: 'events', + type: 'multiOptions', + options: [ + { + name: '*', + value: '*', + }, + { + name: 'Board Created', + value: 'board_created', + }, + { + name: 'Board Updated', + value: 'board_updated', + }, + { + name: 'Board Deleted', + value: 'board_deleted', + }, + { + name: 'Board Configuration Changed', + value: 'board_configuration_changed', + }, + { + name: 'Comment Created', + value: 'comment_created', + }, + { + name: 'Comment Updated', + value: 'comment_updated', + }, + { + name: 'Comment Deleted', + value: 'comment_deleted', + }, + { + name: 'Issue Created', + value: 'jira:issue_created', + }, + { + name: 'Issue Updated', + value: 'jira:issue_updated', + }, + { + name: 'Issue Deleted', + value: 'jira:issue_deleted', + }, + { + name: 'Option Voting Changed', + value: 'option_voting_changed', + }, + { + name: 'Option Watching Changed', + value: 'option_watching_changed', + }, + { + name: 'Option Unassigned Issues Changed', + value: 'option_unassigned_issues_changed', + }, + { + name: 'Option Subtasks Changed', + value: 'option_subtasks_changed', + }, + { + name: 'Option Attachments Changed', + value: 'option_attachments_changed', + }, + { + name: 'Option Issue Links Changed', + value: 'option_issuelinks_changed', + }, + { + name: 'Option Timetracking Changed', + value: 'option_timetracking_changed', + }, + { + name: 'Project Created', + value: 'project_created', + }, + { + name: 'Project Updated', + value: 'project_updated', + }, + { + name: 'Project Deleted', + value: 'project_deleted', + }, + { + name: 'Sprint Created', + value: 'sprint_created', + }, + { + name: 'Sprint Deleted', + value: 'sprint_deleted', + }, + { + name: 'Sprint Updated', + value: 'sprint_updated', + }, + { + name: 'Sprint Started', + value: 'sprint_started', + }, + { + name: 'Sprint Closed', + value: 'sprint_closed', + }, + { + name: 'User Created', + value: 'user_created', + }, + { + name: 'User Updated', + value: 'user_updated', + }, + { + name: 'User Deleted', + value: 'user_deleted', + }, + { + name: 'Version Released', + value: 'jira:version_released', + }, + { + name: 'Version Unreleased', + value: 'jira:version_unreleased', + }, + { + name: 'Version Created', + value: 'jira:version_created', + }, + { + name: 'Version Moved', + value: 'jira:version_moved', + }, + { + name: 'Version Updated', + value: 'jira:version_updated', + }, + { + name: 'Version Deleted', + value: 'jira:version_deleted', + }, + { + name: 'Issue Link Created', + value: 'issuelink_created', + }, + { + name: 'Issue Link Deleted', + value: 'issuelink_deleted', + }, + { + name: 'Worklog Created', + value: 'worklog_created', + }, + { + name: 'Worklog Updated', + value: 'worklog_updated', + }, + { + name: 'Worklog Deleted', + value: 'worklog_deleted', + }, + ], + required: true, + default: [], + description: 'The events to listen to.', + }, + { + displayName: 'Additional Fields', + name: 'additionalFields', + type: 'collection', + placeholder: 'Add Field', + default: {}, + options: [ + { + displayName: 'Exclude Body', + name: 'excludeBody', + type: 'boolean', + default: false, + description: 'Request with empty body will be sent to the URL. Leave unchecked if you want to receive JSON.', + }, + { + displayName: 'Filter', + name: 'filter', + type: 'string', + typeOptions: { + alwaysOpenEditWindow: true, + }, + default: '', + placeholder: 'Project = JRA AND resolution = Fixed', + description: 'You can specify a JQL query to send only events triggered by matching issues. The JQL filter only applies to events under the Issue and Comment columns.', + }, + { + displayName: 'Include Fields', + name: 'includeFields', + type: 'multiOptions', + options: [ + { + name: 'Attachment ID', + value: 'attachment.id' + }, + { + name: 'Board ID', + value: 'board.id' + }, + { + name: 'Comment ID', + value: 'comment.id' + }, + { + name: 'Issue ID', + value: 'issue.id' + }, + { + name: 'Merge Version ID', + value: 'mergeVersion.id' + }, + { + name: 'Modified User Account ID', + value: 'modifiedUser.accountId' + }, + { + name: 'Modified User Key', + value: 'modifiedUser.key' + }, + { + name: 'Modified User Name', + value: 'modifiedUser.name' + }, + { + name: 'Project ID', + value: 'project.id' + }, + { + name: 'Project Key', + value: 'project.key' + }, + { + name: 'Propery Key', + value: 'property.key' + }, + { + name: 'Sprint ID', + value: 'sprint.id' + }, + { + name: 'Version ID', + value: 'version.id' + }, + { + name: 'Worklog ID', + value: 'worklog.id' + }, + ], + default: [], + }, + ], + }, + ], + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + + const webhookData = this.getWorkflowStaticData('node'); + + const events = this.getNodeParameter('events') as string[]; + + const endpoint = `/webhooks/1.0/webhook`; + + const webhooks = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET', {}); + + for (const webhook of webhooks) { + if (webhook.url === webhookUrl && eventExists(events, webhook.events)) { + webhookData.webhookId = getId(webhook.self); + return true; + } + } + + return false; + }, + async create(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default') as string; + + let events = this.getNodeParameter('events', []) as string[]; + + const additionalFields = this.getNodeParameter('additionalFields') as IDataObject; + + const endpoint = `/webhooks/1.0/webhook`; + + const webhookData = this.getWorkflowStaticData('node'); + + if (events.includes('*')) { + events = allEvents; + } + + const body = { + name: `n8n-webhook:${webhookUrl}`, + url: webhookUrl, + events, + filters: { + }, + excludeBody: false, + }; + + if (additionalFields.filter) { + body.filters = { + 'issue-related-events-section': additionalFields.filter, + }; + } + + if (additionalFields.excludeBody) { + body.excludeBody = additionalFields.excludeBody as boolean; + } + + if (additionalFields.includeFields) { + const parameters: IDataObject = {}; + for (const field of additionalFields.includeFields as string[]) { + parameters[field] = '${' + field + '}'; + } + body.url = `${body.url}?${queryString.unescape(queryString.stringify(parameters))}`; + } + + const responseData = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'POST', body); + + webhookData.webhookId = getId(responseData.self); + + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + + if (webhookData.webhookId !== undefined) { + const endpoint = `/webhooks/1.0/webhook/${webhookData.webhookId}`; + const body = {}; + + try { + await jiraSoftwareCloudApiRequest.call(this, endpoint, 'DELETE', body); + } catch (e) { + return false; + } + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData(); + const queryData = this.getQueryData(); + + Object.assign(bodyData, queryData); + + return { + workflowData: [ + this.helpers.returnJsonArray(bodyData) + ], + }; + } +} diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 6b262c7605..6cae4ae7eb 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -235,6 +235,7 @@ "dist/nodes/InvoiceNinja/InvoiceNinja.node.js", "dist/nodes/InvoiceNinja/InvoiceNinjaTrigger.node.js", "dist/nodes/Jira/Jira.node.js", + "dist/nodes/Jira/JiraTrigger.node.js", "dist/nodes/JotForm/JotFormTrigger.node.js", "dist/nodes/Keap/Keap.node.js", "dist/nodes/Keap/KeapTrigger.node.js",