feat(Jira Software Node): Migrate from soon deprecated endpoints to get issues (#14821)

This commit is contained in:
RomanDavydchuk
2025-04-28 17:17:43 +03:00
committed by GitHub
parent 70b93f2f53
commit 216bdd15fd
4 changed files with 282 additions and 24 deletions

View File

@@ -75,6 +75,33 @@ export async function jiraSoftwareCloudApiRequest(
}
}
export function handlePagination(
body: any,
query: IDataObject,
paginationType: 'offset' | 'token',
responseData?: any,
): boolean {
if (!responseData) {
if (paginationType === 'offset') {
query.startAt = 0;
query.maxResults = 100;
} else {
body.maxResults = 100;
}
return true;
}
if (paginationType === 'offset') {
const nextStartAt = (responseData.startAt as number) + (responseData.maxResults as number);
query.startAt = nextStartAt;
return nextStartAt < responseData.total;
} else {
body.nextPageToken = responseData.nextPageToken as string;
return !!responseData.nextPageToken;
}
}
export async function jiraSoftwareCloudApiRequestAllItems(
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
propertyName: string,
@@ -82,29 +109,17 @@ export async function jiraSoftwareCloudApiRequestAllItems(
method: IHttpRequestMethods,
body: any = {},
query: IDataObject = {},
paginationType: 'offset' | 'token' = 'offset',
): Promise<any> {
const returnData: IDataObject[] = [];
let responseData;
query.startAt = 0;
query.maxResults = 100;
if (method !== 'GET') {
body.startAt = 0;
body.maxResults = 100;
}
let hasNextPage = handlePagination(body, query, paginationType);
do {
responseData = await jiraSoftwareCloudApiRequest.call(this, endpoint, method, body, query);
returnData.push.apply(returnData, responseData[propertyName] as IDataObject[]);
query.startAt = (responseData.startAt as number) + (responseData.maxResults as number);
if (method !== 'GET') {
body.startAt = (responseData.startAt as number) + (responseData.maxResults as number);
}
} while (
(responseData.startAt as number) + (responseData.maxResults as number) <
responseData.total
);
hasNextPage = handlePagination(body, query, paginationType, responseData);
} while (hasNextPage);
return returnData;
}

View File

@@ -804,12 +804,16 @@ export class Jira implements INodeType {
const returnAll = this.getNodeParameter('returnAll', i);
const options = this.getNodeParameter('options', i);
const body: IDataObject = {};
if (options.fields) {
body.fields = (options.fields as string).split(',');
if (!options.fields) {
// By default, the new endpoint returns only the ids, before it used to return `*navigable` fields
options.fields = '*navigable';
}
if (options.jql) {
body.jql = options.jql as string;
body.fields = (options.fields as string).split(',');
if (!options.jql) {
// Jira API returns an error if the JQL query is unbounded (i.e. does not include any filters)
options.jql = 'created >= "1970-01-01"';
}
body.jql = options.jql as string;
if (options.expand) {
if (typeof options.expand === 'string') {
body.expand = options.expand.split(',');
@@ -821,16 +825,18 @@ export class Jira implements INodeType {
responseData = await jiraSoftwareCloudApiRequestAllItems.call(
this,
'issues',
'/api/2/search',
'/api/2/search/jql',
'POST',
body,
{},
'token',
);
} else {
const limit = this.getNodeParameter('limit', i);
body.maxResults = limit;
responseData = await jiraSoftwareCloudApiRequest.call(
this,
'/api/2/search',
'/api/2/search/jql',
'POST',
body,
);

View File

@@ -1,7 +1,7 @@
import { type DeepMockProxy, mockDeep } from 'jest-mock-extended';
import type { IExecuteFunctions } from 'n8n-workflow';
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
import { jiraSoftwareCloudApiRequestAllItems } from '../GenericFunctions';
import { handlePagination, jiraSoftwareCloudApiRequestAllItems } from '../GenericFunctions';
describe('Jira -> GenericFunctions', () => {
describe('jiraSoftwareCloudApiRequestAllItems', () => {
@@ -54,4 +54,89 @@ describe('Jira -> GenericFunctions', () => {
);
});
});
describe('handlePagination', () => {
it('should initialize offset pagination parameters when responseData is not provided', () => {
const body = {};
const query: IDataObject = {};
const result = handlePagination(body, query, 'offset');
expect(result).toBe(true);
expect(query.startAt).toBe(0);
expect(query.maxResults).toBe(100);
expect(body).toEqual({});
});
it('should initialize token pagination parameters when responseData is not provided', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const result = handlePagination(body, query, 'token');
expect(result).toBe(true);
expect(query).toEqual({});
expect(body.maxResults).toBe(100);
});
it('should handle offset pagination with more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
startAt: 0,
maxResults: 100,
total: 250,
};
const result = handlePagination(body, query, 'offset', responseData);
expect(result).toBe(true);
expect(query.startAt).toBe(100);
expect(body).toEqual({});
});
it('should handle offset pagination with no more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
startAt: 200,
maxResults: 100,
total: 250,
};
const result = handlePagination(body, query, 'offset', responseData);
expect(result).toBe(false);
expect(query.startAt).toBe(300);
expect(body).toEqual({});
});
it('should handle token pagination with more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
nextPageToken: 'someToken123',
};
const result = handlePagination(body, query, 'token', responseData);
expect(result).toBe(true);
expect(body.nextPageToken).toBe('someToken123');
expect(query).toEqual({});
});
it('should handle token pagination with no more pages available', () => {
const body: IDataObject = {};
const query: IDataObject = {};
const responseData = {
nextPageToken: '',
};
const result = handlePagination(body, query, 'token', responseData);
expect(result).toBe(false);
expect(body.nextPageToken).toBe('');
expect(query).toEqual({});
});
});
});

View File

@@ -0,0 +1,152 @@
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: [] }),
}));
const jiraSoftwareCloudApiRequestMock = GenericFunctions.jiraSoftwareCloudApiRequest 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',
}),
);
});
});
});