first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,333 @@
import type {
IDataObject,
IExecuteFunctions,
IHookFunctions,
IHttpRequestMethods,
ILoadOptionsFunctions,
INodeListSearchItems,
INodePropertyOptions,
IRequestOptions,
JsonObject,
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import type { JiraServerInfo, JiraWebhook } from './types';
export async function jiraSoftwareCloudApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
endpoint: string,
method: IHttpRequestMethods,
body: any = {},
query?: IDataObject,
uri?: string,
option: IDataObject = {},
): Promise<any> {
const jiraVersion = this.getNodeParameter('jiraVersion', 0) as string;
let domain = '';
let credentialType: string;
if (jiraVersion === 'server') {
domain = (await this.getCredentials('jiraSoftwareServerApi')).domain as string;
credentialType = 'jiraSoftwareServerApi';
} else if (jiraVersion === 'serverPat') {
domain = (await this.getCredentials('jiraSoftwareServerPatApi')).domain as string;
credentialType = 'jiraSoftwareServerPatApi';
} else {
domain = (await this.getCredentials('jiraSoftwareCloudApi')).domain as string;
credentialType = 'jiraSoftwareCloudApi';
}
const options: IRequestOptions = {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Atlassian-Token': 'no-check',
},
method,
qs: query,
uri: uri || `${domain}/rest${endpoint}`,
body,
json: true,
};
if (Object.keys(option).length !== 0) {
Object.assign(options, option);
}
if (Object.keys(body as IDataObject).length === 0) {
delete options.body;
}
if (Object.keys(query || {}).length === 0) {
delete options.qs;
}
try {
return await this.helpers.requestWithAuthentication.call(this, credentialType, options);
} catch (error) {
if (error.description?.includes?.("Field 'priority' cannot be set")) {
throw new NodeApiError(this.getNode(), error as JsonObject, {
message:
"Field 'priority' cannot be set. You need to add the Priority field to your Jira Project's Issue Types.",
});
}
throw error;
}
}
export function handlePagination(
method: IHttpRequestMethods,
body: any,
query: IDataObject,
paginationType: 'offset' | 'token',
responseData?: any,
): boolean {
if (!responseData) {
if (paginationType === 'offset') {
if (method === 'GET') {
// Example: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-get
query.startAt = 0;
query.maxResults = 100;
} else {
// Example: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-post
body.startAt = 0;
body.maxResults = 100;
}
} else {
if (method === 'GET') {
// Example: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-jql-get
query.maxResults = 100;
} else {
// Example: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-jql-post
body.maxResults = 100;
}
}
return true;
}
if (paginationType === 'offset') {
const nextStartAt = (responseData.startAt as number) + (responseData.maxResults as number);
if (method === 'GET') {
query.startAt = nextStartAt;
} else {
body.startAt = nextStartAt;
}
return nextStartAt < responseData.total;
} else {
if (method === 'GET') {
query.nextPageToken = responseData.nextPageToken as string;
} else {
body.nextPageToken = responseData.nextPageToken as string;
}
return !!responseData.nextPageToken;
}
}
export async function jiraSoftwareCloudApiRequestAllItems(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
endpoint: string,
method: IHttpRequestMethods,
body: any = {},
query: IDataObject = {},
paginationType: 'offset' | 'token' = 'offset',
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
let hasNextPage = handlePagination(method, body, query, paginationType);
do {
responseData = await jiraSoftwareCloudApiRequest.call(this, endpoint, method, body, query);
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
hasNextPage = handlePagination(method, body, query, paginationType, responseData);
} while (hasNextPage);
return returnData;
}
export function validateJSON(json: string | undefined): any {
let result;
try {
result = JSON.parse(json!);
} catch (exception) {
result = '';
}
return result;
}
export function eventExists(currentEvents: string[], webhookEvents: string[]) {
for (const currentEvent of currentEvents) {
if (!webhookEvents.includes(currentEvent)) {
return false;
}
}
return true;
}
export function getWebhookId(webhook: JiraWebhook) {
if (webhook.id) return webhook.id.toString();
return webhook.self?.split('/').pop();
}
export function simplifyIssueOutput(responseData: {
names: { [key: string]: string };
fields: IDataObject;
id: string;
key: string;
self: string;
}) {
const mappedFields: IDataObject = {
id: responseData.id,
key: responseData.key,
self: responseData.self,
};
// Sort custom fields last so we map them last
const customField = /^customfield_\d+$/;
const sortedFields: string[] = Object.keys(responseData.fields).sort((a, b) => {
if (customField.test(a) && customField.test(b)) {
return a > b ? 1 : -1;
}
if (customField.test(a)) {
return 1;
}
if (customField.test(b)) {
return -1;
}
return a > b ? 1 : -1;
});
for (const field of sortedFields) {
if (responseData.names[field] in mappedFields) {
let newField: string = responseData.names[field];
let counter = 0;
while (newField in mappedFields) {
counter++;
newField = `${responseData.names[field]}_${counter}`;
}
mappedFields[newField] = responseData.fields[field];
} else {
mappedFields[responseData.names[field] || field] = responseData.fields[field];
}
}
return mappedFields;
}
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',
];
export function filterSortSearchListItems(items: INodeListSearchItems[], filter?: string) {
return items
.filter(
(item) =>
!filter ||
item.name.toLowerCase().includes(filter.toLowerCase()) ||
item.value.toString().toLowerCase().includes(filter.toLowerCase()),
)
.sort((a, b) => {
if (a.name.toLocaleLowerCase() < b.name.toLocaleLowerCase()) {
return -1;
}
if (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) {
return 1;
}
return 0;
});
}
export async function getUsers(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
const jiraVersion = this.getCurrentNodeParameter('jiraVersion') as string;
const maxResults = 1000;
const query: IDataObject = { maxResults };
let endpoint = '/api/2/users/search';
if (jiraVersion === 'server' || jiraVersion === 'serverPat') {
endpoint = '/api/2/user/search';
query.username = "'";
}
const users = [];
let hasNextPage: boolean;
do {
const usersPage = (await jiraSoftwareCloudApiRequest.call(
this,
endpoint,
'GET',
{},
{ ...query, startAt: users.length },
)) as IDataObject[];
users.push(...usersPage);
hasNextPage = usersPage.length === maxResults;
} while (hasNextPage);
return users
.filter((user) => user.active)
.map((user) => ({
name: user.displayName as string,
value: (user.accountId ?? user.name) as string,
}))
.sort((a: INodePropertyOptions, b: INodePropertyOptions) => {
return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1;
});
}
export async function getServerInfo(this: IHookFunctions) {
return await (jiraSoftwareCloudApiRequest.call(
this,
'/api/2/serverInfo',
'GET',
) as Promise<JiraServerInfo>);
}
export async function getWebhookEndpoint(this: IHookFunctions) {
const serverInfo = await getServerInfo.call(this).catch(() => null);
if (!serverInfo || serverInfo.deploymentType === 'Cloud') return '/webhooks/1.0/webhook';
// Assume old version when versionNumbers is not set
const majorVersion = serverInfo.versionNumbers?.[0] ?? 1;
return majorVersion >= 10 ? '/jira-webhook/1.0/webhooks' : '/webhooks/1.0/webhook';
}
@@ -0,0 +1,213 @@
import type { INodeProperties } from 'n8n-workflow';
export const issueAttachmentOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
},
},
options: [
{
name: 'Add',
value: 'add',
description: 'Add attachment to issue',
action: 'Add an attachment to an issue',
},
{
name: 'Get',
value: 'get',
description: 'Get an attachment',
action: 'Get an attachment from an issue',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many attachments',
action: 'Get many issue attachments',
},
{
name: 'Remove',
value: 'remove',
description: 'Remove an attachment',
action: 'Remove an attachment from an issue',
},
],
default: 'add',
},
];
export const issueAttachmentFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* issueAttachment:add */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['add'],
},
},
default: '',
},
{
displayName: 'Input Binary Field',
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['add'],
},
},
name: 'binaryPropertyName',
type: 'string',
default: 'data',
hint: 'The name of the input binary field containing the file to be written',
required: true,
},
/* -------------------------------------------------------------------------- */
/* issueAttachment:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Attachment ID',
name: 'attachmentId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['get'],
},
},
default: '',
description: 'The ID of the attachment',
},
{
displayName: 'Download',
name: 'download',
type: 'boolean',
default: false,
required: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['get'],
},
},
},
{
displayName: 'Put Output File in Field',
name: 'binaryProperty',
type: 'string',
default: 'data',
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['get'],
download: [true],
},
},
hint: 'The name of the output binary field to put the file in',
required: true,
},
/* -------------------------------------------------------------------------- */
/* issueAttachment:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['getAll'],
},
},
default: '',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['getAll'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['getAll'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'Max number of results to return',
},
{
displayName: 'Download',
name: 'download',
type: 'boolean',
default: false,
required: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['getAll'],
},
},
},
{
displayName: 'Put Output File in Field',
name: 'binaryProperty',
type: 'string',
default: 'data',
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['getAll'],
download: [true],
},
},
hint: 'The name of the output binary field to put the file in',
required: true,
},
/* -------------------------------------------------------------------------- */
/* issueAttachment:remove */
/* -------------------------------------------------------------------------- */
{
displayName: 'Attachment ID',
name: 'attachmentId',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueAttachment'],
operation: ['remove'],
},
},
default: '',
description: 'The ID of the attachment',
},
];
@@ -0,0 +1,455 @@
import type { INodeProperties } from 'n8n-workflow';
export const issueCommentOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['issueComment'],
},
},
options: [
{
name: 'Add',
value: 'add',
description: 'Add comment to issue',
action: 'Add a comment',
},
{
name: 'Get',
value: 'get',
description: 'Get a comment',
action: 'Get a comment',
},
{
name: 'Get Many',
value: 'getAll',
description: 'Get many comments',
action: 'Get many comments',
},
{
name: 'Remove',
value: 'remove',
description: 'Remove a comment',
action: 'Remove a comment',
},
{
name: 'Update',
value: 'update',
description: 'Update a comment',
action: 'Update a comment',
},
],
default: 'add',
},
];
export const issueCommentFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* issueComment:add */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['add'],
},
},
default: '',
// eslint-disable-next-line n8n-nodes-base/node-param-description-lowercase-first-char
description: 'issueComment Key',
},
{
displayName: 'JSON Parameters',
name: 'jsonParameters',
type: 'boolean',
default: false,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['add'],
},
},
},
{
displayName: 'Comment',
name: 'comment',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['add'],
jsonParameters: [false],
},
},
description: "Comment's text",
},
{
displayName: 'Document Format (JSON)',
name: 'commentJson',
type: 'json',
default: '',
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['add'],
jsonParameters: [true],
},
},
description:
'The Atlassian Document Format (ADF). Online builder can be found <a href="https://developer.atlassian.com/cloud/jira/platform/apis/document/playground/">here</a>.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['add'],
},
},
options: [
{
displayName: 'Expand',
name: 'expand',
type: 'options',
options: [
{
name: 'Rendered Body',
value: 'renderedBody',
},
],
default: [],
description:
'Use expand to include additional information about comments in the response. This parameter accepts Rendered Body, which returns the comment body rendered in HTML.',
},
{
displayName: 'Use Wiki Markup',
name: 'wikiMarkup',
type: 'boolean',
default: false,
displayOptions: {
show: {
'/jiraVersion': ['cloud'],
},
},
description:
'Whether to enable parsing of wikiformatting for this comment. Default is false.',
},
],
},
/* -------------------------------------------------------------------------- */
/* issueComment:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['get'],
},
},
default: '',
description: 'The ID or key of the issue',
},
{
displayName: 'Comment ID',
name: 'commentId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['get'],
},
},
description: 'The ID of the comment',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['get'],
},
},
options: [
{
displayName: 'Expand',
name: 'expand',
type: 'options',
options: [
{
name: 'Rendered Body',
value: 'renderedBody',
},
],
default: '',
description:
'Use expand to include additional information about comments in the response. This parameter accepts Rendered Body, which returns the comment body rendered in HTML.',
},
],
},
/* -------------------------------------------------------------------------- */
/* issueComment:getAll */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['getAll'],
},
},
default: '',
description: 'The ID or key of the issue',
},
{
displayName: 'Return All',
name: 'returnAll',
type: 'boolean',
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['getAll'],
},
},
default: false,
description: 'Whether to return all results or only up to a given limit',
},
{
displayName: 'Limit',
name: 'limit',
type: 'number',
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['getAll'],
returnAll: [false],
},
},
typeOptions: {
minValue: 1,
maxValue: 100,
},
default: 50,
description: 'Max number of results to return',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['getAll'],
},
},
options: [
{
displayName: 'Expand',
name: 'expand',
type: 'options',
options: [
{
name: 'Rendered Body',
value: 'renderedBody',
},
],
default: 'renderedBody',
description:
'Use expand to include additional information about comments in the response. This parameter accepts Rendered Body, which returns the comment body rendered in HTML.',
},
{
displayName: 'Order By',
name: 'orderBy',
type: 'options',
options: [
{
name: 'Created Ascending',
value: '+created',
},
{
name: 'Created Descending',
value: '-created',
},
],
default: '+created',
description: 'Order comments by the created date',
},
],
},
/* -------------------------------------------------------------------------- */
/* issueComment:remove */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['remove'],
},
},
default: '',
description: 'The ID or key of the issue',
},
{
displayName: 'Comment ID',
name: 'commentId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['remove'],
},
},
description: 'The ID of the comment',
},
/* -------------------------------------------------------------------------- */
/* issueComment:update */
/* -------------------------------------------------------------------------- */
{
displayName: 'Issue Key',
name: 'issueKey',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['update'],
},
},
default: '',
description: 'The Issue Comment key',
},
{
displayName: 'Comment ID',
name: 'commentId',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['update'],
},
},
description: 'The ID of the comment',
},
{
displayName: 'JSON Parameters',
name: 'jsonParameters',
type: 'boolean',
default: false,
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['update'],
},
},
},
{
displayName: 'Comment',
name: 'comment',
type: 'string',
default: '',
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['update'],
jsonParameters: [false],
},
},
description: "Comment's text",
},
{
displayName: 'Document Format (JSON)',
name: 'commentJson',
type: 'json',
default: '',
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['update'],
jsonParameters: [true],
},
},
description:
'The Atlassian Document Format (ADF). Online builder can be found <a href="https://developer.atlassian.com/cloud/jira/platform/apis/document/playground/">here</a>.',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
displayOptions: {
show: {
resource: ['issueComment'],
operation: ['update'],
},
},
options: [
{
displayName: 'Expand',
name: 'expand',
type: 'options',
options: [
{
name: 'Rendered Body',
value: 'renderedBody',
},
],
default: 'renderedBody',
description:
'Use expand to include additional information about comments in the response. This parameter accepts Rendered Body, which returns the comment body rendered in HTML.',
},
{
displayName: 'Use Wiki Markup',
name: 'wikiMarkup',
type: 'boolean',
default: false,
displayOptions: {
show: {
'/jiraVersion': ['cloud'],
},
},
description:
'Whether to enable parsing of wikiformatting for this comment. Default is false.',
},
],
},
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
import type { IDataObject } from 'n8n-workflow';
export interface IFields {
assignee?: IDataObject;
description?: string;
issuetype?: IDataObject;
labels?: string[];
parent?: IDataObject;
priority?: IDataObject;
project?: IDataObject;
summary?: string;
reporter?: IDataObject;
components?: IDataObject[];
}
export interface IIssue {
fields?: IFields;
transition?: IDataObject;
}
export interface INotify {
subject?: string;
textBody?: string;
htmlBody?: string;
to?: INotificationRecipients;
restrict?: NotificationRecipientsRestrictions;
}
export interface INotificationRecipients {
reporter?: boolean;
assignee?: boolean;
watchers?: boolean;
voters?: boolean;
users?: IDataObject[];
groups?: IDataObject[];
}
export interface NotificationRecipientsRestrictions {
groups?: IDataObject[];
}
@@ -0,0 +1,29 @@
{
"node": "n8n-nodes-base.jira",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/jira/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.jira/"
}
],
"generic": [
{
"label": "5 workflow automations for Mattermost that we love at n8n",
"icon": "🤖",
"url": "https://n8n.io/blog/5-workflow-automations-for-mattermost-that-we-love-at-n8n/"
},
{
"label": "How to automate every step of an incident response workflow",
"url": "https://n8n.io/blog/creating-custom-incident-response-workflows-with-n8n/"
}
]
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
{
"node": "n8n-nodes-base.jiraTrigger",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Productivity"],
"resources": {
"credentialDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/credentials/jira/"
}
],
"primaryDocumentation": [
{
"url": "https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.jiratrigger/"
}
]
}
}
@@ -0,0 +1,606 @@
import type {
ICredentialDataDecryptedObject,
IDataObject,
IHookFunctions,
IWebhookFunctions,
INodeType,
INodeTypeDescription,
IWebhookResponseData,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import {
allEvents,
eventExists,
getWebhookId,
getWebhookEndpoint,
jiraSoftwareCloudApiRequest,
} from './GenericFunctions';
import type { JiraWebhook } from './types';
export class JiraTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Jira Trigger',
name: 'jiraTrigger',
icon: 'file:jira.svg',
group: ['trigger'],
version: [1, 1.1],
description: 'Starts the workflow when Jira events occur',
defaults: {
name: 'Jira Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
displayName: 'Credentials to Connect to Jira',
name: 'jiraSoftwareCloudApi',
required: true,
displayOptions: {
show: {
jiraVersion: ['cloud'],
},
},
},
{
displayName: 'Credentials to Connect to Jira',
name: 'jiraSoftwareServerApi',
required: true,
displayOptions: {
show: {
jiraVersion: ['server'],
},
},
},
{
displayName: 'Credentials to Connect to Jira',
name: 'jiraSoftwareServerPatApi',
required: true,
displayOptions: {
show: {
jiraVersion: ['serverPat'],
},
},
},
{
// eslint-disable-next-line n8n-nodes-base/node-class-description-credentials-name-unsuffixed
name: 'httpQueryAuth',
displayName: 'Credentials to Authenticate Webhook',
displayOptions: {
show: {
authenticateWebhook: [true],
},
},
},
{
name: 'httpQueryAuth',
displayName: 'Credentials to Authenticate Webhook',
displayOptions: {
show: {
incomingAuthentication: ['queryAuth'],
},
},
},
],
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',
},
{
name: 'Server (Pat) (Self Hosted)',
value: 'serverPat',
},
],
default: 'cloud',
},
{
displayName: 'Authenticate Incoming Webhook',
name: 'authenticateWebhook',
type: 'boolean',
default: false,
description:
'Whether authentication should be activated for the incoming webhooks (makes it more secure)',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
},
{
displayName: 'Authenticate Webhook With',
name: 'incomingAuthentication',
type: 'options',
options: [
{
name: 'Query Auth',
value: 'queryAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
description: 'If authentication should be activated for the webhook (makes it more secure)',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Events',
name: 'events',
type: 'multiOptions',
options: [
{
name: '*',
value: '*',
},
{
name: 'Board Configuration Changed',
value: 'board_configuration_changed',
},
{
name: 'Board Created',
value: 'board_created',
},
{
name: 'Board Deleted',
value: 'board_deleted',
},
{
name: 'Board Updated',
value: 'board_updated',
},
{
name: 'Comment Created',
value: 'comment_created',
},
{
name: 'Comment Deleted',
value: 'comment_deleted',
},
{
name: 'Comment Updated',
value: 'comment_updated',
},
{
name: 'Issue Created',
value: 'jira:issue_created',
},
{
name: 'Issue Deleted',
value: 'jira:issue_deleted',
},
{
name: 'Issue Link Created',
value: 'issuelink_created',
},
{
name: 'Issue Link Deleted',
value: 'issuelink_deleted',
},
{
name: 'Issue Updated',
value: 'jira:issue_updated',
},
{
name: 'Option Attachments Changed',
value: 'option_attachments_changed',
},
{
name: 'Option Issue Links Changed',
value: 'option_issuelinks_changed',
},
{
name: 'Option Subtasks Changed',
value: 'option_subtasks_changed',
},
{
name: 'Option Timetracking Changed',
value: 'option_timetracking_changed',
},
{
name: 'Option Unassigned Issues Changed',
value: 'option_unassigned_issues_changed',
},
{
name: 'Option Voting Changed',
value: 'option_voting_changed',
},
{
name: 'Option Watching Changed',
value: 'option_watching_changed',
},
{
name: 'Project Created',
value: 'project_created',
},
{
name: 'Project Deleted',
value: 'project_deleted',
},
{
name: 'Project Updated',
value: 'project_updated',
},
{
name: 'Sprint Closed',
value: 'sprint_closed',
},
{
name: 'Sprint Created',
value: 'sprint_created',
},
{
name: 'Sprint Deleted',
value: 'sprint_deleted',
},
{
name: 'Sprint Started',
value: 'sprint_started',
},
{
name: 'Sprint Updated',
value: 'sprint_updated',
},
{
name: 'User Created',
value: 'user_created',
},
{
name: 'User Deleted',
value: 'user_deleted',
},
{
name: 'User Updated',
value: 'user_updated',
},
{
name: 'Version Created',
value: 'jira:version_created',
},
{
name: 'Version Deleted',
value: 'jira:version_deleted',
},
{
name: 'Version Moved',
value: 'jira:version_moved',
},
{
name: 'Version Released',
value: 'jira:version_released',
},
{
name: 'Version Unreleased',
value: 'jira:version_unreleased',
},
{
name: 'Version Updated',
value: 'jira:version_updated',
},
{
name: 'Worklog Created',
value: 'worklog_created',
},
{
name: 'Worklog Deleted',
value: 'worklog_deleted',
},
{
name: 'Worklog Updated',
value: 'worklog_updated',
},
],
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:
'Whether a request with empty body will be sent to the URL. Leave unchecked if you want to receive JSON.',
},
{
displayName: 'Filter',
name: 'filter',
type: 'string',
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: [],
},
],
},
],
};
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
const webhookUrl = this.getNodeWebhookUrl('default') as string;
const webhookData = this.getWorkflowStaticData('node');
const events = this.getNodeParameter('events') as string[];
const endpoint = await getWebhookEndpoint.call(this);
webhookData.endpoint = endpoint;
const webhooks: JiraWebhook[] = await jiraSoftwareCloudApiRequest.call(
this,
endpoint,
'GET',
{},
);
for (const webhook of webhooks) {
if (webhook.url === webhookUrl && eventExists(events, webhook.events)) {
webhookData.webhookId = getWebhookId(webhook);
return true;
}
}
return false;
},
async create(this: IHookFunctions): Promise<boolean> {
const nodeVersion = this.getNode().typeVersion;
const webhookUrl = this.getNodeWebhookUrl('default') as string;
let events = this.getNodeParameter('events', []) as string[];
const additionalFields = this.getNodeParameter('additionalFields') as IDataObject;
const webhookData = this.getWorkflowStaticData('node');
const endpoint = webhookData.endpoint as string;
let authenticateWebhook = false;
if (nodeVersion === 1) {
const incomingAuthentication = this.getNodeParameter('incomingAuthentication') as string;
if (incomingAuthentication === 'queryAuth') {
authenticateWebhook = true;
}
} else {
authenticateWebhook = this.getNodeParameter('authenticateWebhook') as boolean;
}
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;
}
const parameters: Record<string, string> = {};
if (authenticateWebhook) {
let httpQueryAuth;
try {
httpQueryAuth = await this.getCredentials('httpQueryAuth');
} catch (e) {
throw new NodeOperationError(
this.getNode(),
new Error('Could not retrieve HTTP Query Auth credentials', { cause: e }),
);
}
if (!httpQueryAuth.name && !httpQueryAuth.value) {
throw new NodeOperationError(this.getNode(), 'HTTP Query Auth credentials are empty');
}
parameters[encodeURIComponent(httpQueryAuth.name as string)] = Buffer.from(
httpQueryAuth.value as string,
).toString('base64');
}
if (additionalFields.includeFields) {
for (const field of additionalFields.includeFields as string[]) {
// eslint-disable-next-line n8n-local-rules/no-interpolation-in-regular-string
parameters[field] = '${' + field + '}';
}
}
if (Object.keys(parameters as IDataObject).length) {
const params = new URLSearchParams(parameters).toString();
body.url = `${body.url}?${decodeURIComponent(params)}`;
}
const responseData: JiraWebhook = await jiraSoftwareCloudApiRequest.call(
this,
endpoint,
'POST',
body,
);
webhookData.webhookId = getWebhookId(responseData);
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
const webhookData = this.getWorkflowStaticData('node');
if (webhookData.webhookId !== undefined) {
const baseUrl = webhookData.endpoint as string;
const webhookId = webhookData.webhookId as string;
const endpoint = `${baseUrl}/${webhookId}`;
const body = {};
try {
await jiraSoftwareCloudApiRequest.call(this, endpoint, 'DELETE', body);
} catch (error) {
return false;
}
// Remove from the static workflow data so that it is clear
// that no webhooks are registered anymore
delete webhookData.webhookId;
}
return true;
},
},
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const nodeVersion = this.getNode().typeVersion;
const bodyData = this.getBodyData();
const queryData = this.getQueryData() as IDataObject;
const response = this.getResponseObject();
let authenticateWebhook = false;
if (nodeVersion === 1) {
const incomingAuthentication = this.getNodeParameter('incomingAuthentication') as string;
if (incomingAuthentication === 'queryAuth') {
authenticateWebhook = true;
}
} else {
authenticateWebhook = this.getNodeParameter('authenticateWebhook') as boolean;
}
if (authenticateWebhook) {
let httpQueryAuth: ICredentialDataDecryptedObject | undefined;
try {
httpQueryAuth = await this.getCredentials<ICredentialDataDecryptedObject>('httpQueryAuth');
} catch (error) {}
if (httpQueryAuth === undefined || !httpQueryAuth.name || !httpQueryAuth.value) {
response
.status(403)
.json({ message: 'Auth settings are not valid, some data are missing' });
return {
noWebhookResponse: true,
};
}
const paramName = httpQueryAuth.name as string;
const paramValue = Buffer.from(httpQueryAuth.value as string).toString('base64');
if (!queryData.hasOwnProperty(paramName) || queryData[paramName] !== paramValue) {
response.status(403).json({ message: 'Provided authentication data is not valid' });
return {
noWebhookResponse: true,
};
}
delete queryData[paramName];
Object.assign(bodyData, queryData);
} else {
Object.assign(bodyData, queryData);
}
return {
workflowData: [this.helpers.returnJsonArray(bodyData)],
};
}
}
@@ -0,0 +1,181 @@
import type { INodeProperties } from 'n8n-workflow';
export const userOperations: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['user'],
},
},
options: [
{
name: 'Create',
value: 'create',
description: 'Create a new user',
action: 'Create a user',
},
{
name: 'Delete',
value: 'delete',
description: 'Delete a user',
action: 'Delete a user',
},
{
name: 'Get',
value: 'get',
description: 'Retrieve a user',
action: 'Get a user',
},
],
default: 'create',
},
];
export const userFields: INodeProperties[] = [
/* -------------------------------------------------------------------------- */
/* user:create */
/* -------------------------------------------------------------------------- */
{
displayName: 'Username',
name: 'username',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['user'],
operation: ['create'],
},
},
},
{
displayName: 'Email Address',
name: 'emailAddress',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
resource: ['user'],
operation: ['create'],
},
},
},
{
displayName: 'Display Name',
name: 'displayName',
type: 'string',
required: true,
displayOptions: {
show: {
resource: ['user'],
operation: ['create'],
},
},
default: '',
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['user'],
operation: ['create'],
},
},
options: [
{
displayName: 'Password',
name: 'password',
type: 'string',
default: '',
description:
'Password for the user. If a password is not set, a random password is generated.',
typeOptions: {
password: true,
},
},
{
displayName: 'Notification',
name: 'notification',
type: 'boolean',
default: false,
description:
'Whether to send the user an email confirmation that they have been added to Jira',
},
],
},
/* -------------------------------------------------------------------------- */
/* user:delete */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account ID',
name: 'accountId',
type: 'string',
default: '',
description: 'Account ID of the user to delete',
displayOptions: {
show: {
resource: ['user'],
operation: ['delete'],
},
},
},
/* -------------------------------------------------------------------------- */
/* user:get */
/* -------------------------------------------------------------------------- */
{
displayName: 'Account ID',
name: 'accountId',
type: 'string',
default: '',
description: 'Account ID of the user to retrieve',
displayOptions: {
show: {
resource: ['user'],
operation: ['get'],
},
},
},
{
displayName: 'Additional Fields',
name: 'additionalFields',
type: 'collection',
placeholder: 'Add Field',
default: {},
displayOptions: {
show: {
resource: ['user'],
operation: ['get'],
},
},
options: [
{
displayName: 'Expand',
name: 'expand',
type: 'multiOptions',
default: [],
description: 'Include more information about the user',
options: [
{
name: 'Groups',
value: 'groups',
description: 'Include all groups to which the user belongs',
},
{
name: 'Application Roles',
value: 'applicationRoles',
description: 'Include details of all the applications the user can access',
},
],
},
],
},
];
@@ -0,0 +1,72 @@
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"created": {
"type": "string"
},
"id": {
"type": "string"
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {
"type": "string"
},
"fieldId": {
"type": "string"
},
"fieldtype": {
"type": "string"
}
}
}
}
},
"version": 1
}
@@ -0,0 +1,15 @@
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"self": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
},
"version": 1
}
@@ -0,0 +1,514 @@
{
"type": "object",
"properties": {
"expand": {
"type": "string"
},
"fields": {
"type": "object",
"properties": {
"attachment": {
"type": "array",
"items": {
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"content": {
"type": "string"
},
"created": {
"type": "string"
},
"filename": {
"type": "string"
},
"id": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"self": {
"type": "string"
},
"size": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
}
}
},
"comment": {
"type": "object",
"properties": {
"comments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"body": {
"type": "string"
},
"created": {
"type": "string"
},
"id": {
"type": "string"
},
"jsdPublic": {
"type": "boolean"
},
"self": {
"type": "string"
},
"updateAuthor": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"updated": {
"type": "string"
}
}
}
},
"maxResults": {
"type": "integer"
},
"self": {
"type": "string"
},
"startAt": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"created": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"issuetype": {
"type": "object",
"properties": {
"avatarId": {
"type": "integer"
},
"description": {
"type": "string"
},
"entityId": {
"type": "string"
},
"hierarchyLevel": {
"type": "integer"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"subtask": {
"type": "boolean"
}
}
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"reporter": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"status": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
},
"subtasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"issuetype": {
"type": "object",
"properties": {
"avatarId": {
"type": "integer"
},
"description": {
"type": "string"
},
"entityId": {
"type": "string"
},
"hierarchyLevel": {
"type": "integer"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"subtask": {
"type": "boolean"
}
}
},
"priority": {
"type": "object",
"properties": {
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"status": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
},
"summary": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
},
"summary": {
"type": "string"
},
"updated": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"self": {
"type": "string"
}
},
"version": 9
}
@@ -0,0 +1,649 @@
{
"type": "object",
"properties": {
"expand": {
"type": "string"
},
"fields": {
"type": "object",
"properties": {
"aggregateprogress": {
"type": "object",
"properties": {
"progress": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"components": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
},
"created": {
"type": "string"
},
"creator": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"customfield_10001": {
"type": "null"
},
"customfield_10019": {
"type": "string"
},
"customfield_10021": {
"type": "null"
},
"fixVersions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"archived": {
"type": "boolean"
},
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"released": {
"type": "boolean"
},
"releaseDate": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
},
"issuelinks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"inwardIssue": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"issuetype": {
"type": "object",
"properties": {
"avatarId": {
"type": "integer"
},
"description": {
"type": "string"
},
"entityId": {
"type": "string"
},
"hierarchyLevel": {
"type": "integer"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"subtask": {
"type": "boolean"
}
}
},
"priority": {
"type": "object",
"properties": {
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"status": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
},
"summary": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"self": {
"type": "string"
},
"type": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"inward": {
"type": "string"
},
"name": {
"type": "string"
},
"outward": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
}
},
"issuetype": {
"type": "object",
"properties": {
"avatarId": {
"type": "integer"
},
"description": {
"type": "string"
},
"entityId": {
"type": "string"
},
"hierarchyLevel": {
"type": "integer"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"subtask": {
"type": "boolean"
}
}
},
"labels": {
"type": "array",
"items": {
"type": "string"
}
},
"priority": {
"type": "object",
"properties": {
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"progress": {
"type": "object",
"properties": {
"progress": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"project": {
"type": "object",
"properties": {
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"projectTypeKey": {
"type": "string"
},
"self": {
"type": "string"
},
"simplified": {
"type": "boolean"
}
}
},
"reporter": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"security": {
"type": "null"
},
"status": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"statuscategorychangedate": {
"type": "string"
},
"subtasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"issuetype": {
"type": "object",
"properties": {
"avatarId": {
"type": "integer"
},
"description": {
"type": "string"
},
"entityId": {
"type": "string"
},
"hierarchyLevel": {
"type": "integer"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"subtask": {
"type": "boolean"
}
}
},
"priority": {
"type": "object",
"properties": {
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"status": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
},
"summary": {
"type": "string"
}
}
},
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
},
"summary": {
"type": "string"
},
"updated": {
"type": "string"
},
"votes": {
"type": "object",
"properties": {
"hasVoted": {
"type": "boolean"
},
"self": {
"type": "string"
},
"votes": {
"type": "integer"
}
}
},
"watches": {
"type": "object",
"properties": {
"isWatching": {
"type": "boolean"
},
"self": {
"type": "string"
},
"watchCount": {
"type": "integer"
}
}
},
"workratio": {
"type": "integer"
}
}
},
"id": {
"type": "string"
},
"key": {
"type": "string"
},
"self": {
"type": "string"
}
},
"version": 9
}
@@ -0,0 +1,70 @@
{
"type": "object",
"properties": {
"hasScreen": {
"type": "boolean"
},
"id": {
"type": "string"
},
"isAvailable": {
"type": "boolean"
},
"isConditional": {
"type": "boolean"
},
"isGlobal": {
"type": "boolean"
},
"isInitial": {
"type": "boolean"
},
"isLooped": {
"type": "boolean"
},
"name": {
"type": "string"
},
"to": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"iconUrl": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
},
"statusCategory": {
"type": "object",
"properties": {
"colorName": {
"type": "string"
},
"id": {
"type": "integer"
},
"key": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
}
}
},
"version": 1
}
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
},
"version": 1
}
@@ -0,0 +1,70 @@
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"content": {
"type": "string"
},
"created": {
"type": "string"
},
"filename": {
"type": "string"
},
"id": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"self": {
"type": "string"
},
"size": {
"type": "integer"
}
},
"version": 1
}
@@ -0,0 +1,61 @@
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"self": {
"type": "string"
}
}
},
"content": {
"type": "string"
},
"created": {
"type": "string"
},
"filename": {
"type": "string"
},
"id": {
"type": "integer"
},
"mimeType": {
"type": "string"
},
"self": {
"type": "string"
},
"size": {
"type": "integer"
}
},
"version": 1
}
@@ -0,0 +1,73 @@
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"content": {
"type": "string"
},
"created": {
"type": "string"
},
"filename": {
"type": "string"
},
"id": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"self": {
"type": "string"
},
"size": {
"type": "integer"
},
"thumbnail": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,143 @@
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"body": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"type": "object",
"properties": {
"text": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
},
"type": {
"type": "string"
}
}
}
},
"type": {
"type": "string"
},
"version": {
"type": "integer"
}
}
},
"created": {
"type": "string"
},
"id": {
"type": "string"
},
"jsdPublic": {
"type": "boolean"
},
"self": {
"type": "string"
},
"updateAuthor": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"updated": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,160 @@
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"body": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {
"type": "array",
"items": {
"type": "object",
"properties": {
"attrs": {
"type": "object",
"properties": {
"accessLevel": {
"type": "string"
},
"id": {
"type": "string"
},
"localId": {
"type": "string"
},
"text": {
"type": "string"
}
}
},
"text": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
},
"type": {
"type": "string"
}
}
}
},
"type": {
"type": "string"
},
"version": {
"type": "integer"
}
}
},
"created": {
"type": "string"
},
"id": {
"type": "string"
},
"jsdPublic": {
"type": "boolean"
},
"self": {
"type": "string"
},
"updateAuthor": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
}
},
"updated": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"success": {
"type": "boolean"
}
},
"version": 1
}
@@ -0,0 +1,97 @@
{
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"accountType": {
"type": "string"
},
"active": {
"type": "boolean"
},
"applicationRoles": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"name": {
"type": "string"
}
}
}
},
"size": {
"type": "integer"
}
}
},
"avatarUrls": {
"type": "object",
"properties": {
"16x16": {
"type": "string"
},
"24x24": {
"type": "string"
},
"32x32": {
"type": "string"
},
"48x48": {
"type": "string"
}
}
},
"displayName": {
"type": "string"
},
"emailAddress": {
"type": "string"
},
"expand": {
"type": "string"
},
"groups": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"groupId": {
"type": "string"
},
"name": {
"type": "string"
},
"self": {
"type": "string"
}
}
}
},
"size": {
"type": "integer"
}
}
},
"locale": {
"type": "string"
},
"self": {
"type": "string"
},
"timeZone": {
"type": "string"
}
},
"version": 1
}
@@ -0,0 +1,225 @@
import { type DeepMockProxy, mockDeep } from 'jest-mock-extended';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { handlePagination, jiraSoftwareCloudApiRequestAllItems } from '../GenericFunctions';
describe('Jira -> GenericFunctions', () => {
describe('jiraSoftwareCloudApiRequestAllItems', () => {
let mockExecuteFunctions: DeepMockProxy<IExecuteFunctions>;
beforeEach(() => {
mockExecuteFunctions = mockDeep<IExecuteFunctions>();
mockExecuteFunctions.getNodeParameter.mockReturnValue('server');
mockExecuteFunctions.getCredentials.mockResolvedValue({ domain: 'jira.domain.com' });
mockExecuteFunctions.helpers.requestWithAuthentication.mockImplementation(
async function (_, options) {
if (!options.qs?.startAt) {
return {
issues: [{ id: 1000 }, { id: 1001 }],
startAt: 0,
maxResults: 2,
total: 3,
};
}
return {
issues: [{ id: 1002 }],
startAt: 2,
maxResults: 2,
total: 3,
};
},
);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should get all items and not pass the body when the method is GET', async () => {
const result = await jiraSoftwareCloudApiRequestAllItems.call(
mockExecuteFunctions,
'issues',
'/api/2/search',
'GET',
);
expect(result).toEqual([{ id: 1000 }, { id: 1001 }, { id: 1002 }]);
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toBeCalledTimes(2);
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith(
'jiraSoftwareServerApi',
expect.not.objectContaining({
body: expect.anything(),
}),
);
});
});
describe('handlePagination', () => {
it('should initialize offset pagination parameters with GET when responseData is not provided', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const result = handlePagination('GET', body, query, 'offset');
expect(result).toBe(true);
expect(query.startAt).toBe(0);
expect(query.maxResults).toBe(100);
expect(body).toEqual({});
});
it('should initialize offset pagination parameters with POST when responseData is not provided', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const result = handlePagination('POST', body, query, 'offset');
expect(result).toBe(true);
expect(body.startAt).toBe(0);
expect(body.maxResults).toBe(100);
expect(query).toEqual({});
});
it('should initialize token pagination parameters with GET when responseData is not provided', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const result = handlePagination('GET', body, query, 'token');
expect(result).toBe(true);
expect(query.maxResults).toBe(100);
expect(body).toEqual({});
});
it('should initialize token pagination parameters with POST when responseData is not provided', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const result = handlePagination('POST', body, query, 'token');
expect(result).toBe(true);
expect(query).toEqual({});
expect(body.maxResults).toBe(100);
});
it('should handle offset pagination with GET and more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
startAt: 0,
maxResults: 100,
total: 250,
};
const result = handlePagination('GET', body, query, 'offset', responseData);
expect(result).toBe(true);
expect(query.startAt).toBe(100);
expect(body).toEqual({});
});
it('should handle offset pagination with POST and more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
startAt: 0,
maxResults: 100,
total: 250,
};
const result = handlePagination('POST', body, query, 'offset', responseData);
expect(result).toBe(true);
expect(body.startAt).toBe(100);
expect(query).toEqual({});
});
it('should handle offset pagination with GET and no more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
startAt: 200,
maxResults: 100,
total: 250,
};
const result = handlePagination('GET', body, query, 'offset', responseData);
expect(result).toBe(false);
expect(query.startAt).toBe(300);
expect(body).toEqual({});
});
it('should handle offset pagination with POST and no more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
startAt: 200,
maxResults: 100,
total: 250,
};
const result = handlePagination('POST', body, query, 'offset', responseData);
expect(result).toBe(false);
expect(body.startAt).toBe(300);
expect(query).toEqual({});
});
it('should handle token pagination with GET and more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
nextPageToken: 'someToken123',
};
const result = handlePagination('GET', body, query, 'token', responseData);
expect(result).toBe(true);
expect(query.nextPageToken).toBe('someToken123');
expect(body).toEqual({});
});
it('should handle token pagination with POST and more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
nextPageToken: 'someToken123',
};
const result = handlePagination('POST', body, query, 'token', responseData);
expect(result).toBe(true);
expect(body.nextPageToken).toBe('someToken123');
expect(query).toEqual({});
});
it('should handle token pagination with GET and no more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
nextPageToken: '',
};
const result = handlePagination('GET', body, query, 'token', responseData);
expect(result).toBe(false);
expect(query.nextPageToken).toBe('');
expect(body).toEqual({});
});
it('should handle token pagination with POST and no more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
nextPageToken: '',
};
const result = handlePagination('POST', body, query, 'token', responseData);
expect(result).toBe(false);
expect(body.nextPageToken).toBe('');
expect(query).toEqual({});
});
});
});
@@ -0,0 +1,248 @@
import type { DeepMockProxy } from 'jest-mock-extended';
import { mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import * as GenericFunctions from '../GenericFunctions';
import { Jira } from '../Jira.node';
jest.mock('../GenericFunctions', () => ({
jiraSoftwareCloudApiRequest: jest.fn().mockResolvedValue({ issues: [] }),
jiraSoftwareCloudApiRequestAllItems: jest.fn().mockResolvedValue([]),
}));
const jiraSoftwareCloudApiRequestMock = GenericFunctions.jiraSoftwareCloudApiRequest as jest.Mock;
const jiraSoftwareCloudApiRequestAllItems =
GenericFunctions.jiraSoftwareCloudApiRequestAllItems as jest.Mock;
describe('Jira Node', () => {
let jiraNode: Jira;
let executeFunctionsMock: DeepMockProxy<IExecuteFunctions>;
beforeEach(() => {
jest.clearAllMocks();
jiraNode = new Jira();
executeFunctionsMock = mockDeep<IExecuteFunctions>();
executeFunctionsMock.getInputData.mockReturnValue([{ json: {} }]);
executeFunctionsMock.helpers.returnJsonArray.mockReturnValue([]);
executeFunctionsMock.helpers.constructExecutionMetaData.mockReturnValue([]);
});
describe('issue getAll', () => {
it('should set default fields to "*navigable" when not provided', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return 'cloud';
case 'returnAll':
return false;
case 'limit':
return 10;
case 'options':
return { fields: undefined };
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestMock).toHaveBeenCalledWith(
'/api/2/search/jql',
'POST',
expect.objectContaining({
fields: ['*navigable'],
}),
);
});
it('should set default JQL filter to "created >= 1970-01-01" when not provided', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return 'cloud';
case 'returnAll':
return false;
case 'limit':
return 10;
case 'options':
return { jql: undefined };
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestMock).toHaveBeenCalledWith(
'/api/2/search/jql',
'POST',
expect.objectContaining({
jql: 'created >= "1970-01-01"',
}),
);
});
it('should use custom fields when provided', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return 'cloud';
case 'returnAll':
return false;
case 'limit':
return 10;
case 'options':
return { fields: 'summary,description' };
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestMock).toHaveBeenCalledWith(
'/api/2/search/jql',
'POST',
expect.objectContaining({
fields: ['summary', 'description'],
}),
);
});
it('should use custom JQL filter when provided', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return 'cloud';
case 'returnAll':
return false;
case 'limit':
return 10;
case 'options':
return { jql: 'project = TEST' };
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestMock).toHaveBeenCalledWith(
'/api/2/search/jql',
'POST',
expect.objectContaining({
jql: 'project = TEST',
}),
);
});
it('should call new endpoint for the cloud version with return all = true', async () => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return 'cloud';
case 'returnAll':
return true;
case 'options':
return {};
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestAllItems).toHaveBeenCalledWith(
'issues',
'/api/2/search/jql',
'POST',
expect.anything(),
{},
'token',
);
});
it.each([['server'], ['serverPat']])(
'should call old endpoint for the self-hosted version with return all = false',
async (jiraVersion: string) => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return jiraVersion;
case 'returnAll':
return false;
case 'limit':
return 10;
case 'options':
return {};
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestMock).toHaveBeenCalledWith(
'/api/2/search',
'POST',
expect.anything(),
);
},
);
it.each([['server'], ['serverPat']])(
'should call old endpoint for the self-hosted version with return all = true',
async (jiraVersion: string) => {
executeFunctionsMock.getNodeParameter.mockImplementation((parameterName: string) => {
switch (parameterName) {
case 'resource':
return 'issue';
case 'operation':
return 'getAll';
case 'jiraVersion':
return jiraVersion;
case 'returnAll':
return true;
case 'options':
return {};
default:
return null;
}
});
await jiraNode.execute.call(executeFunctionsMock);
expect(jiraSoftwareCloudApiRequestAllItems).toHaveBeenCalledWith(
'issues',
'/api/2/search',
'POST',
expect.anything(),
);
},
);
});
});
@@ -0,0 +1,263 @@
import { mock, mockDeep } from 'jest-mock-extended';
import type {
ICredentialDataDecryptedObject,
IDataObject,
IHookFunctions,
INode,
} from 'n8n-workflow';
import { testWebhookTriggerNode } from '@test/nodes/TriggerHelpers';
import { JiraTrigger } from '../JiraTrigger.node';
describe('JiraTrigger', () => {
describe('Webhook lifecycle', () => {
let staticData: IDataObject;
beforeEach(() => {
staticData = {};
});
function mockHookFunctions(
mockRequest: IHookFunctions['helpers']['requestWithAuthentication'],
) {
const baseUrl = 'https://jira.local';
const credential = {
email: 'test@n8n.io',
password: 'secret',
domain: baseUrl,
};
return mockDeep<IHookFunctions>({
getWorkflowStaticData: () => staticData,
getNode: jest.fn(() => mock<INode>({ typeVersion: 1 })),
getNodeWebhookUrl: jest.fn(() => 'https://n8n.local/webhook/id'),
getNodeParameter: jest.fn((param: string) => {
if (param === 'events') return ['jira:issue_created'];
return {};
}),
getCredentials: async <T extends object = ICredentialDataDecryptedObject>() =>
credential as T,
helpers: {
requestWithAuthentication: mockRequest,
},
});
}
test('should register a webhook subscription on Jira 10', async () => {
const trigger = new JiraTrigger();
const mockExistsRequest = jest
.fn()
.mockResolvedValueOnce({ versionNumbers: [10, 0, 1] })
.mockResolvedValueOnce([]);
const exists = await trigger.webhookMethods.default?.checkExists.call(
mockHookFunctions(mockExistsRequest),
);
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }),
);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks' }),
);
expect(staticData.endpoint).toBe('/jira-webhook/1.0/webhooks');
expect(exists).toBe(false);
const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 });
const created = await trigger.webhookMethods.default?.create.call(
mockHookFunctions(mockCreateRequest),
);
expect(mockCreateRequest).toHaveBeenCalledTimes(1);
expect(mockCreateRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks',
body: expect.objectContaining({
events: ['jira:issue_created'],
excludeBody: false,
filters: {},
name: 'n8n-webhook:https://n8n.local/webhook/id',
url: 'https://n8n.local/webhook/id',
}),
}),
);
expect(created).toBe(true);
const mockDeleteRequest = jest.fn().mockResolvedValueOnce({});
const deleted = await trigger.webhookMethods.default?.delete.call(
mockHookFunctions(mockDeleteRequest),
);
expect(deleted).toBe(true);
expect(mockDeleteRequest).toHaveBeenCalledTimes(1);
expect(mockDeleteRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'DELETE',
uri: 'https://jira.local/rest/jira-webhook/1.0/webhooks/1',
}),
);
});
test('should register a webhook subscription on Jira 9', async () => {
const trigger = new JiraTrigger();
const mockExistsRequest = jest
.fn()
.mockResolvedValueOnce({ versionNumbers: [9, 0, 1] })
.mockResolvedValueOnce([]);
const exists = await trigger.webhookMethods.default?.checkExists.call(
mockHookFunctions(mockExistsRequest),
);
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }),
);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ uri: 'https://jira.local/rest/webhooks/1.0/webhook' }),
);
expect(staticData.endpoint).toBe('/webhooks/1.0/webhook');
expect(exists).toBe(false);
const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 });
const created = await trigger.webhookMethods.default?.create.call(
mockHookFunctions(mockCreateRequest),
);
expect(mockCreateRequest).toHaveBeenCalledTimes(1);
expect(mockCreateRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
uri: 'https://jira.local/rest/webhooks/1.0/webhook',
body: expect.objectContaining({
events: ['jira:issue_created'],
excludeBody: false,
filters: {},
name: 'n8n-webhook:https://n8n.local/webhook/id',
url: 'https://n8n.local/webhook/id',
}),
}),
);
expect(created).toBe(true);
const mockDeleteRequest = jest.fn().mockResolvedValueOnce({});
const deleted = await trigger.webhookMethods.default?.delete.call(
mockHookFunctions(mockDeleteRequest),
);
expect(deleted).toBe(true);
expect(mockDeleteRequest).toHaveBeenCalledTimes(1);
expect(mockDeleteRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'DELETE',
uri: 'https://jira.local/rest/webhooks/1.0/webhook/1',
}),
);
});
test('should register a webhook subscription on Jira Cloud', async () => {
const trigger = new JiraTrigger();
const mockExistsRequest = jest
.fn()
.mockResolvedValueOnce({ deploymentType: 'Cloud', versionNumbers: [1000, 0, 1] })
.mockResolvedValueOnce([]);
const exists = await trigger.webhookMethods.default?.checkExists.call(
mockHookFunctions(mockExistsRequest),
);
expect(mockExistsRequest).toHaveBeenCalledTimes(2);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ uri: 'https://jira.local/rest/api/2/serverInfo' }),
);
expect(mockExistsRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ uri: 'https://jira.local/rest/webhooks/1.0/webhook' }),
);
expect(staticData.endpoint).toBe('/webhooks/1.0/webhook');
expect(exists).toBe(false);
const mockCreateRequest = jest.fn().mockResolvedValueOnce({ id: 1 });
const created = await trigger.webhookMethods.default?.create.call(
mockHookFunctions(mockCreateRequest),
);
expect(mockCreateRequest).toHaveBeenCalledTimes(1);
expect(mockCreateRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'POST',
uri: 'https://jira.local/rest/webhooks/1.0/webhook',
body: expect.objectContaining({
events: ['jira:issue_created'],
excludeBody: false,
filters: {},
name: 'n8n-webhook:https://n8n.local/webhook/id',
url: 'https://n8n.local/webhook/id',
}),
}),
);
expect(created).toBe(true);
const mockDeleteRequest = jest.fn().mockResolvedValueOnce({});
const deleted = await trigger.webhookMethods.default?.delete.call(
mockHookFunctions(mockDeleteRequest),
);
expect(deleted).toBe(true);
expect(mockDeleteRequest).toHaveBeenCalledTimes(1);
expect(mockDeleteRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'DELETE',
uri: 'https://jira.local/rest/webhooks/1.0/webhook/1',
}),
);
});
});
describe('Webhook', () => {
test('should receive a webhook event', async () => {
const event = {
timestamp: 1743524005044,
webhookEvent: 'jira:issue_created',
issue_event_type_name: 'issue_created',
user: {
self: 'http://localhost:8080/rest/api/2/user?key=JIRAUSER10000',
name: 'elias',
key: 'JIRAUSER10000',
emailAddress: 'elias@meire.dev',
displayName: 'Test',
},
issue: {
id: '10018',
self: 'http://localhost:8080/rest/api/2/issue/10018',
key: 'TEST-19',
},
};
const { responseData } = await testWebhookTriggerNode(JiraTrigger, {
bodyData: event,
});
expect(responseData).toEqual({ workflowData: [[{ json: event }]] });
});
});
});
@@ -0,0 +1,126 @@
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IHttpRequestMethods, ILoadOptionsFunctions } from 'n8n-workflow';
import { Jira } from '../Jira.node';
const ISSUE_KEY = 'KEY-1';
jest.mock('../GenericFunctions', () => {
const originalModule = jest.requireActual('../GenericFunctions');
return {
...originalModule,
jiraSoftwareCloudApiRequest: jest.fn(async function (
endpoint: string,
method: IHttpRequestMethods,
) {
if (method === 'GET' && endpoint === `/api/2/issue/${ISSUE_KEY}`) {
return {
id: 10000,
fields: {
project: {
id: 10001,
},
issuetype: {
id: 10002,
},
},
};
} else if (method === 'GET' && endpoint === '/api/2/issue/10000/editmeta') {
return {
fields: {
customfield_123: {
name: 'Field 123',
},
customfield_456: {
name: 'Field 456',
},
},
};
} else if (
method === 'GET' &&
endpoint ===
'/api/2/issue/createmeta?projectIds=10001&issueTypeIds=10002&expand=projects.issuetypes.fields'
) {
return {
projects: [
{
id: 10001,
issuetypes: [
{
id: 10002,
fields: {
customfield_abc: {
name: 'Field ABC',
schema: { customId: 'customfield_abc' },
fieldId: 'customfield_abc',
},
customfield_def: {
name: 'Field DEF',
schema: { customId: 'customfield_def' },
fieldId: 'customfield_def',
},
},
},
],
},
],
};
}
}),
};
});
describe('Jira Node, methods', () => {
let jira: Jira;
let loadOptionsFunctions: MockProxy<ILoadOptionsFunctions>;
beforeEach(() => {
jira = new Jira();
loadOptionsFunctions = mock<ILoadOptionsFunctions>();
});
describe('listSearch.getCustomFields', () => {
it('should call correct endpoint and return custom fields for server version', async () => {
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce('update');
loadOptionsFunctions.getNodeParameter.mockReturnValue('server');
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce(ISSUE_KEY);
const { results } = await jira.methods.listSearch.getCustomFields.call(
loadOptionsFunctions as ILoadOptionsFunctions,
);
expect(results).toEqual([
{
name: 'Field 123',
value: 'customfield_123',
},
{
name: 'Field 456',
value: 'customfield_456',
},
]);
});
it('should call correct endpoint and return custom fields for cloud version', async () => {
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce('update');
loadOptionsFunctions.getNodeParameter.mockReturnValue('cloud');
loadOptionsFunctions.getCurrentNodeParameter.mockReturnValueOnce(ISSUE_KEY);
const { results } = await jira.methods.listSearch.getCustomFields.call(
loadOptionsFunctions as ILoadOptionsFunctions,
);
expect(results).toEqual([
{
name: 'Field ABC',
value: 'customfield_abc',
},
{
name: 'Field DEF',
value: 'customfield_def',
},
]);
});
});
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#fff" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 68.25 71.25"><use xlink:href="#a" x="3.125" y="3.125"/><defs><linearGradient id="b" x1="91.9%" x2="28.49%" y1="40.22%" y2="81.63%"><stop offset="18%" stop-color="#0052cc"/><stop offset="100%" stop-color="#2684ff"/></linearGradient><linearGradient id="c" x1="8.7%" x2="72.26%" y1="59.17%" y2="17.99%"><stop offset="18%" stop-color="#0052cc"/><stop offset="100%" stop-color="#2684ff"/></linearGradient></defs><symbol id="a" overflow="visible"><g fill-rule="nonzero" stroke="none"><path fill="#2684ff" d="M61.161 30.211 30.95 0 .74 30.211a2.54 2.54 0 0 0 0 3.581l30.211 30.21 30.211-30.21a2.54 2.54 0 0 0 0-3.581zM30.95 41.46l-9.462-9.462 9.462-9.462 9.462 9.462z"/><path fill="url(#b)" d="M30.95 22.599C24.755 16.405 24.724 6.37 30.881.138L10.114 20.774l11.268 11.268z"/><path fill="url(#c)" d="M40.437 31.973 30.95 41.46a15.93 15.93 0 0 1 0 22.536l20.749-20.749z"/></g></symbol></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+25
View File
@@ -0,0 +1,25 @@
export type JiraWebhook = {
id: number;
name: string;
createdDate: number;
updatedDate: number;
events: string[];
configuration: {};
url: string;
active: boolean;
scopeType: string;
sslVerificationRequired: boolean;
self?: string; // Only available for version < 10
};
export type JiraServerInfo = {
baseUrl: string;
version: string;
versionNumbers: number[];
deploymentType?: 'Cloud' | 'Server';
buildNumber: number;
buildDate: string;
databaseBuildNumber: number;
serverTime: string;
scmInfo: string;
serverTitle: string;
};