fix(Jira Trigger Node): Fix Jira webhook subscriptions on Jira v10+ (#14333)

This commit is contained in:
Elias Meire
2025-04-02 12:49:25 +02:00
committed by GitHub
parent 8abbc304f0
commit cd212e4f78
4 changed files with 343 additions and 13 deletions

View File

@@ -11,6 +11,8 @@ import type {
} from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import type { JiraServerInfo, JiraWebhook } from './types';
export async function jiraSoftwareCloudApiRequest(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
endpoint: string,
@@ -122,8 +124,9 @@ export function eventExists(currentEvents: string[], webhookEvents: string[]) {
return true;
}
export function getId(url: string) {
return url.split('/').pop();
export function getWebhookId(webhook: JiraWebhook) {
if (webhook.id) return webhook.id.toString();
return webhook.self?.split('/').pop();
}
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;
});
}
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';
}

View 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 }]] });
});
});
});

View File

@@ -9,7 +9,14 @@ import type {
} 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 {
description: INodeTypeDescription = {
@@ -411,13 +418,19 @@ export class JiraTrigger implements INodeType {
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) {
if (webhook.url === webhookUrl && eventExists(events, webhook.events as string[])) {
webhookData.webhookId = getId(webhook.self as string);
if (webhook.url === webhookUrl && eventExists(events, webhook.events)) {
webhookData.webhookId = getWebhookId(webhook);
return true;
}
}
@@ -429,8 +442,8 @@ export class JiraTrigger implements INodeType {
const webhookUrl = this.getNodeWebhookUrl('default') as string;
let events = this.getNodeParameter('events', []) as string[];
const additionalFields = this.getNodeParameter('additionalFields') as IDataObject;
const endpoint = '/webhooks/1.0/webhook';
const webhookData = this.getWorkflowStaticData('node');
const endpoint = webhookData.endpoint as string;
let authenticateWebhook = false;
@@ -466,7 +479,7 @@ export class JiraTrigger implements INodeType {
body.excludeBody = additionalFields.excludeBody as boolean;
}
const parameters: any = {};
const parameters: Record<string, string> = {};
if (authenticateWebhook) {
let httpQueryAuth;
@@ -494,13 +507,18 @@ export class JiraTrigger implements INodeType {
}
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)}`;
}
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;
},
@@ -508,7 +526,9 @@ export class JiraTrigger implements INodeType {
const webhookData = this.getWorkflowStaticData('node');
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 = {};
try {

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;
};