mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
fix(Jira Trigger Node): Fix Jira webhook subscriptions on Jira v10+ (#14333)
This commit is contained in:
@@ -11,6 +11,8 @@ import type {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeApiError } from 'n8n-workflow';
|
import { NodeApiError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { JiraServerInfo, JiraWebhook } from './types';
|
||||||
|
|
||||||
export async function jiraSoftwareCloudApiRequest(
|
export async function jiraSoftwareCloudApiRequest(
|
||||||
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
|
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
@@ -122,8 +124,9 @@ export function eventExists(currentEvents: string[], webhookEvents: string[]) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getId(url: string) {
|
export function getWebhookId(webhook: JiraWebhook) {
|
||||||
return url.split('/').pop();
|
if (webhook.id) return webhook.id.toString();
|
||||||
|
return webhook.self?.split('/').pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function simplifyIssueOutput(responseData: {
|
export function simplifyIssueOutput(responseData: {
|
||||||
@@ -266,3 +269,22 @@ export async function getUsers(this: ILoadOptionsFunctions): Promise<INodeProper
|
|||||||
return a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1;
|
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';
|
||||||
|
}
|
||||||
|
|||||||
263
packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts
Normal file
263
packages/nodes-base/nodes/Jira/JiraTrigger.node.test.ts
Normal file
@@ -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 }]] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,14 @@ import type {
|
|||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
import { allEvents, eventExists, getId, jiraSoftwareCloudApiRequest } from './GenericFunctions';
|
import {
|
||||||
|
allEvents,
|
||||||
|
eventExists,
|
||||||
|
getWebhookId,
|
||||||
|
getWebhookEndpoint,
|
||||||
|
jiraSoftwareCloudApiRequest,
|
||||||
|
} from './GenericFunctions';
|
||||||
|
import type { JiraWebhook } from './types';
|
||||||
|
|
||||||
export class JiraTrigger implements INodeType {
|
export class JiraTrigger implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
@@ -411,13 +418,19 @@ export class JiraTrigger implements INodeType {
|
|||||||
|
|
||||||
const events = this.getNodeParameter('events') as string[];
|
const events = this.getNodeParameter('events') as string[];
|
||||||
|
|
||||||
const endpoint = '/webhooks/1.0/webhook';
|
const endpoint = await getWebhookEndpoint.call(this);
|
||||||
|
webhookData.endpoint = endpoint;
|
||||||
|
|
||||||
const webhooks = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'GET', {});
|
const webhooks: JiraWebhook[] = await jiraSoftwareCloudApiRequest.call(
|
||||||
|
this,
|
||||||
|
endpoint,
|
||||||
|
'GET',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
for (const webhook of webhooks) {
|
for (const webhook of webhooks) {
|
||||||
if (webhook.url === webhookUrl && eventExists(events, webhook.events as string[])) {
|
if (webhook.url === webhookUrl && eventExists(events, webhook.events)) {
|
||||||
webhookData.webhookId = getId(webhook.self as string);
|
webhookData.webhookId = getWebhookId(webhook);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,8 +442,8 @@ export class JiraTrigger implements INodeType {
|
|||||||
const webhookUrl = this.getNodeWebhookUrl('default') as string;
|
const webhookUrl = this.getNodeWebhookUrl('default') as string;
|
||||||
let events = this.getNodeParameter('events', []) as string[];
|
let events = this.getNodeParameter('events', []) as string[];
|
||||||
const additionalFields = this.getNodeParameter('additionalFields') as IDataObject;
|
const additionalFields = this.getNodeParameter('additionalFields') as IDataObject;
|
||||||
const endpoint = '/webhooks/1.0/webhook';
|
|
||||||
const webhookData = this.getWorkflowStaticData('node');
|
const webhookData = this.getWorkflowStaticData('node');
|
||||||
|
const endpoint = webhookData.endpoint as string;
|
||||||
|
|
||||||
let authenticateWebhook = false;
|
let authenticateWebhook = false;
|
||||||
|
|
||||||
@@ -466,7 +479,7 @@ export class JiraTrigger implements INodeType {
|
|||||||
body.excludeBody = additionalFields.excludeBody as boolean;
|
body.excludeBody = additionalFields.excludeBody as boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters: any = {};
|
const parameters: Record<string, string> = {};
|
||||||
|
|
||||||
if (authenticateWebhook) {
|
if (authenticateWebhook) {
|
||||||
let httpQueryAuth;
|
let httpQueryAuth;
|
||||||
@@ -494,13 +507,18 @@ export class JiraTrigger implements INodeType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(parameters as IDataObject).length) {
|
if (Object.keys(parameters as IDataObject).length) {
|
||||||
const params = new URLSearchParams(parameters as string).toString();
|
const params = new URLSearchParams(parameters).toString();
|
||||||
body.url = `${body.url}?${decodeURIComponent(params)}`;
|
body.url = `${body.url}?${decodeURIComponent(params)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = await jiraSoftwareCloudApiRequest.call(this, endpoint, 'POST', body);
|
const responseData: JiraWebhook = await jiraSoftwareCloudApiRequest.call(
|
||||||
|
this,
|
||||||
|
endpoint,
|
||||||
|
'POST',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
webhookData.webhookId = getId(responseData.self as string);
|
webhookData.webhookId = getWebhookId(responseData);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -508,7 +526,9 @@ export class JiraTrigger implements INodeType {
|
|||||||
const webhookData = this.getWorkflowStaticData('node');
|
const webhookData = this.getWorkflowStaticData('node');
|
||||||
|
|
||||||
if (webhookData.webhookId !== undefined) {
|
if (webhookData.webhookId !== undefined) {
|
||||||
const endpoint = `/webhooks/1.0/webhook/${webhookData.webhookId}`;
|
const baseUrl = webhookData.endpoint as string;
|
||||||
|
const webhookId = webhookData.webhookId as string;
|
||||||
|
const endpoint = `${baseUrl}/${webhookId}`;
|
||||||
const body = {};
|
const body = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
25
packages/nodes-base/nodes/Jira/types.ts
Normal file
25
packages/nodes-base/nodes/Jira/types.ts
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user