feat(Confluence Node): Add CQL search operation (no-changelog) (#36589)

This commit is contained in:
Stephen Wright
2026-08-25 09:50:31 +00:00
committed by GitHub
parent aaf0a7e7b7
commit 2147b15230
12 changed files with 472 additions and 37 deletions
@@ -315,7 +315,7 @@ export function parsePositiveInt(
): number {
const value = Number(raw);
if (!Number.isFinite(value) || value < 1) {
throw new NodeOperationError(this.getNode(), `${label} must be a number of at least 1`, {
throw new NodeOperationError(this.getNode(), `${label} must be a finite number of at least 1`, {
itemIndex,
});
}
@@ -4,6 +4,7 @@ import { NodeConnectionTypes } from 'n8n-workflow';
import * as attachment from './attachment';
import * as page from './page';
import * as search from './search';
import { CONFLUENCE_CREDENTIAL_NAME } from '../transport';
export const confluenceNodeDescription: INodeTypeDescription = {
@@ -42,10 +43,15 @@ export const confluenceNodeDescription: INodeTypeDescription = {
name: 'Page',
value: 'page',
},
{
name: 'Search',
value: 'search',
},
],
default: 'page',
},
...attachment.description,
...page.description,
...search.description,
],
};
@@ -1,5 +1,4 @@
import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { confluenceApiRequest } from '../../transport';
import type { ConfluenceBodyFormat } from '../common';
@@ -9,6 +8,7 @@ import {
extractNextCursor,
optionalSpaceRLC,
pageRLC,
parsePositiveInt,
resolvePageId,
shapeBody,
} from '../common';
@@ -187,13 +187,12 @@ export const execute: ConfluenceOperation = async function (
return shapeBody(page, bodyFormat);
}
const rawMaxPages = this.getNodeParameter('maxPages', itemIndex, 100) as number;
if (!Number.isFinite(rawMaxPages) || rawMaxPages < 1) {
throw new NodeOperationError(this.getNode(), 'Max Pages must be a number of at least 1', {
itemIndex,
});
}
const maxPages = Math.floor(rawMaxPages);
const maxPages = parsePositiveInt.call(
this,
this.getNodeParameter('maxPages', itemIndex, 100),
'Max Pages',
itemIndex,
);
const descendantIds = await collectDescendantPageIds.call(
this,
pageId,
@@ -3,6 +3,7 @@ import { NodeOperationError } from 'n8n-workflow';
import * as attachment from './attachment';
import * as page from './page';
import * as search from './search';
/**
* Compile-checked contract for operation modules. The router calls
@@ -53,6 +54,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
case 'page:update':
responseData = await page.update.execute.call(this, i);
break;
case 'search:query':
responseData = await search.query.execute.call(this, i);
break;
default:
throw new NodeOperationError(
this.getNode(),
@@ -0,0 +1,29 @@
import type { INodeProperties } from 'n8n-workflow';
import * as query from './query.operation';
export { query };
export const description: INodeProperties[] = [
{
displayName: 'Operation',
name: 'operation',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
resource: ['search'],
},
},
options: [
{
name: 'Query',
value: 'query',
description: 'Search content with a CQL query',
action: 'Perform a query',
},
],
default: 'query',
},
...query.description,
];
@@ -0,0 +1,159 @@
import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { returnAllOrLimit } from '@utils/descriptions';
import { updateDisplayOptions } from '@utils/utilities';
import { confluenceApiRequest } from '../../transport';
import type { NextPageParam } from '../common';
import { extractNextPageParam, parsePositiveInt } from '../common';
import type { ConfluenceOperation } from '../router';
const SEARCH_PAGE_SIZE = 50;
// Search post-filters results by permission, so empty pages mid-stream are legitimate
const MAX_CONSECUTIVE_EMPTY_PAGES = 5;
const properties: INodeProperties[] = [
{
displayName: 'Query (CQL)',
name: 'cql',
type: 'string',
typeOptions: {
rows: 4,
},
required: true,
default: '',
placeholder: 'e.g. type = page AND space = "DOCS" AND text ~ "roadmap"',
description:
'The CQL query to run. See <a href="https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/" target="_blank">Atlassian\'s CQL reference</a> for the syntax.',
},
...returnAllOrLimit,
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add option',
default: {},
options: [
{
displayName: 'Additional Expand Fields',
name: 'additionalExpandFields',
type: 'string',
default: '',
placeholder: 'e.g. content.version,content.metadata.labels',
description: 'Comma-separated list of extra fields to expand on each search result',
},
{
displayName: 'Content Status',
name: 'contentStatuses',
type: 'multiOptions',
default: [],
description: 'Only match content in these statuses',
options: [
{ name: 'Archived', value: 'archived' },
{ name: 'Current', value: 'current' },
{ name: 'Draft', value: 'draft' },
],
},
{
displayName: 'Fetch Full Page Content',
name: 'fetchFullPageContent',
type: 'boolean',
default: false,
description:
'Whether each result carries its full storage-format body (content.body.storage), fetched on the same request. Only applies to content results such as pages; space and user results have no body.',
},
],
},
];
const displayOptions = {
show: {
resource: ['search'],
operation: ['query'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
interface SearchOptions {
additionalExpandFields?: string;
contentStatuses?: string[];
fetchFullPageContent?: boolean;
}
function buildExpand(options: SearchOptions): string {
const fields = (options.additionalExpandFields ?? '')
.split(',')
.map((field) => field.trim())
.filter((field) => field !== '');
if (options.fetchFullPageContent === true) fields.unshift('content.body.storage');
return [...new Set(fields)].join(',');
}
export const execute: ConfluenceOperation = async function (
this: IExecuteFunctions,
itemIndex: number,
) {
// Deliberately passed through untouched: in a raw CQL string the node cannot tell
// values from syntax, so escaping here would corrupt valid queries. Where this node
// composes CQL itself it backslash-escapes quotes and backslashes as Confluence's
// CQL reference prescribes (see methods/listSearch.ts).
const cql = String(this.getNodeParameter('cql', itemIndex, '')).trim();
if (cql === '') {
throw new NodeOperationError(this.getNode(), 'The CQL query must not be empty', { itemIndex });
}
const returnAll = this.getNodeParameter('returnAll', itemIndex, false);
const limit = returnAll
? Infinity
: parsePositiveInt.call(
this,
this.getNodeParameter('limit', itemIndex, 100),
'Limit',
itemIndex,
);
const options = this.getNodeParameter('options', itemIndex, {}) as SearchOptions;
const qs: IDataObject = { cql };
const expand = buildExpand(options);
if (expand !== '') qs.expand = expand;
if (options.contentStatuses !== undefined && options.contentStatuses.length > 0) {
qs.cqlcontext = JSON.stringify({ contentStatuses: options.contentStatuses });
}
const results: IDataObject[] = [];
let pageParam: NextPageParam | undefined;
const seenPageParams = new Set<string>();
let emptyPages = 0;
for (;;) {
const pageQs: IDataObject = {
...qs,
limit: Math.min(limit - results.length, SEARCH_PAGE_SIZE),
};
if (pageParam !== undefined) pageQs[pageParam.key] = pageParam.value;
const response = await confluenceApiRequest.call(
this,
'GET',
'/wiki/rest/api/search',
{},
pageQs,
);
const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
results.push.apply(results, entries);
if (results.length >= limit) break;
emptyPages = entries.length === 0 ? emptyPages + 1 : 0;
if (emptyPages >= MAX_CONSECUTIVE_EMPTY_PAGES) break;
const next = extractNextPageParam(response);
// A next link revisiting any earlier page would loop forever under Return All
if (next === undefined || seenPageParams.has(`${next.key}:${next.value}`)) break;
seenPageParams.add(`${next.key}:${next.value}`);
pageParam = next;
}
return returnAll ? results : results.slice(0, limit);
};
@@ -1,32 +1,32 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { mockDeep } from 'vitest-mock-extended';
import { Confluence } from '../Confluence.node';
import { mockExecuteCtx } from './shared';
describe('Confluence Node', () => {
const node = new Confluence();
const operationOptions = (resource: string) =>
node.description.properties.find(
(p) => p.name === 'operation' && p.displayOptions?.show?.resource?.includes(resource),
)?.options;
it('should stay hidden and off the AI-tool surface while operations land', () => {
expect(node.description.hidden).toBe(true);
expect(node.description.properties.length).toBeGreaterThan(0);
expect(node.description.usableAsTool).toBeUndefined();
});
it('should expose the attachment and page resources with their operations', () => {
it('should expose the attachment, page and search resources with their operations', () => {
const resource = node.description.properties.find((p) => p.name === 'resource');
expect(resource?.options).toEqual([
expect.objectContaining({ value: 'attachment' }),
expect.objectContaining({ value: 'page' }),
expect.objectContaining({ value: 'search' }),
]);
const operations = node.description.properties.filter((p) => p.name === 'operation');
const operationsFor = (resourceName: string) =>
operations.find((p) => (p.displayOptions?.show?.resource ?? []).includes(resourceName));
expect(operationsFor('attachment')?.options).toEqual([
expect.objectContaining({ value: 'getMany' }),
]);
expect(operationsFor('page')?.options).toEqual([
expect(operationOptions('attachment')).toEqual([expect.objectContaining({ value: 'getMany' })]);
expect(operationOptions('page')).toEqual([
expect.objectContaining({ value: 'append' }),
expect.objectContaining({ value: 'create' }),
expect.objectContaining({ value: 'delete' }),
@@ -34,6 +34,7 @@ describe('Confluence Node', () => {
expect.objectContaining({ value: 'getManyByLabel' }),
expect.objectContaining({ value: 'update' }),
]);
expect(operationOptions('search')).toEqual([expect.objectContaining({ value: 'query' })]);
});
it('should reference the confluenceCloudOAuth2Api credential by name', () => {
@@ -49,21 +50,7 @@ describe('Confluence Node', () => {
});
it('should throw a NodeOperationError when executed without an operation', async () => {
const ctx = mockDeep<IExecuteFunctions>();
ctx.getInputData.mockReturnValue([{ json: {} }]);
ctx.getNodeParameter.mockImplementation(
(_name: string, _itemIndex?: number, fallback?: unknown) => fallback as never,
);
ctx.getNode.mockReturnValue({
id: 'test-node',
name: 'Test Confluence Node',
type: 'n8n-nodes-base.confluence',
typeVersion: 1,
position: [0, 0],
parameters: {},
});
const promise = node.execute.call(ctx);
const promise = node.execute.call(mockExecuteCtx({}));
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('The operation ":" is not supported');
@@ -127,7 +127,7 @@ describe('attachment:getMany', () => {
it('rejects a non-positive limit from an expression', async () => {
const promise = runGetMany({ limit: 0 });
await expect(promise).rejects.toThrow('Limit must be a number of at least 1');
await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1');
expect(apiRequest).not.toHaveBeenCalled();
});
@@ -547,7 +547,7 @@ describe('Confluence page:get operation', () => {
});
await expect(execute.call(ctx, 0)).rejects.toThrow(
'Max Pages must be a number of at least 1',
'Max Pages must be a finite number of at least 1',
);
});
@@ -237,7 +237,9 @@ describe('Confluence page:getManyByLabel operation', () => {
limit: 0,
});
await expect(execute.call(ctx, 0)).rejects.toThrow('Limit must be a number of at least 1');
await expect(execute.call(ctx, 0)).rejects.toThrow(
'Limit must be a finite number of at least 1',
);
expect(apiRequest).not.toHaveBeenCalled();
});
});
@@ -31,6 +31,15 @@ const getParams: Record<string, unknown> = {
includeDescendants: false,
};
const searchParams: Record<string, unknown> = {
resource: 'search',
operation: 'query',
cql: 'type = page',
returnAll: false,
limit: 100,
options: {},
};
describe('Confluence router', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -166,6 +175,25 @@ describe('Confluence router', () => {
expect(result).toEqual([[{ json: { id: '222' }, pairedItem: { item: 0 } }]]);
});
it('dispatches search:query and fans the results out into items', async () => {
apiRequest.mockResolvedValue({ results: [{ title: 'A' }, { title: 'B' }] });
const result = await router.call(mockExecuteCtx(searchParams));
expect(apiRequest).toHaveBeenCalledWith(
'GET',
'/wiki/rest/api/search',
{},
{ cql: 'type = page', limit: 50 },
);
expect(result).toEqual([
[
{ json: { title: 'A' }, pairedItem: { item: 0 } },
{ json: { title: 'B' }, pairedItem: { item: 0 } },
],
]);
});
it('emits an error item and continues with later items when continue-on-fail is on', async () => {
const ctx = mockExecuteCtx(createParams, 2);
ctx.continueOnFail.mockReturnValue(true);
@@ -0,0 +1,221 @@
import { NodeOperationError } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { execute } from '../../../actions/search/query.operation';
import { confluenceApiRequest } from '../../../transport';
import { mockExecuteCtx } from '../../shared';
vi.mock('../../../transport', async (importOriginal) => ({
...(await importOriginal<object>()),
confluenceApiRequest: vi.fn(),
}));
const apiRequest = confluenceApiRequest as unknown as Mock;
const baseParams: Record<string, unknown> = {
cql: 'type = page',
returnAll: false,
limit: 100,
options: {},
};
const SEARCH_ENDPOINT = '/wiki/rest/api/search';
const runSearch = async (overrides: Record<string, unknown> = {}) =>
await execute.call(mockExecuteCtx({ ...baseParams, ...overrides }), 0);
const expectSearchRequest = (qs: unknown) =>
expect(apiRequest).toHaveBeenCalledWith('GET', SEARCH_ENDPOINT, {}, qs);
const expectNthSearchRequest = (nth: number, qs: unknown) =>
expect(apiRequest).toHaveBeenNthCalledWith(nth, 'GET', SEARCH_ENDPOINT, {}, qs);
function searchPage(ids: string[], next?: string) {
return {
results: ids.map((id) => ({ id })),
...(next === undefined ? {} : { _links: { next } }),
};
}
describe('search:query', () => {
beforeEach(() => {
vi.clearAllMocks();
apiRequest.mockResolvedValue(searchPage([]));
});
it('queries the v1 search endpoint with the CQL and page size', async () => {
apiRequest.mockResolvedValue({ results: [{ title: 'Hit' }] });
const result = await runSearch();
expect(apiRequest).toHaveBeenCalledTimes(1);
expectSearchRequest({ cql: 'type = page', limit: 50 });
expect(result).toEqual([{ title: 'Hit' }]);
});
it('returns an empty array when nothing matches', async () => {
expect(await runSearch()).toEqual([]);
});
it('coerces a non-string query from an expression', async () => {
await runSearch({ cql: 32 });
expectSearchRequest(expect.objectContaining({ cql: '32' }));
});
it('rejects an empty query after trimming', async () => {
const promise = runSearch({ cql: ' ' });
await expect(promise).rejects.toThrow(NodeOperationError);
await expect(promise).rejects.toThrow('The CQL query must not be empty');
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a non-positive limit from an expression', async () => {
const promise = runSearch({ limit: 0 });
await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1');
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a non-finite limit from an expression', async () => {
const promise = runSearch({ limit: Infinity });
await expect(promise).rejects.toThrow('Limit must be a finite number of at least 1');
expect(apiRequest).not.toHaveBeenCalled();
});
it('returns all pages by following the next cursor with the same query', async () => {
apiRequest
.mockResolvedValueOnce(
searchPage(['1', '2'], '/rest/api/search?cql=type%3Dpage&cursor=abc&limit=50'),
)
.mockResolvedValueOnce(searchPage(['3']));
const result = await runSearch({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(2);
expectNthSearchRequest(2, { cql: 'type = page', limit: 50, cursor: 'abc' });
expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]);
});
it('falls back to the start offset when the next link carries no cursor', async () => {
apiRequest
.mockResolvedValueOnce(
searchPage(['1'], '/rest/api/search?cql=type%3Dpage&start=25&limit=25'),
)
.mockResolvedValueOnce(searchPage([]));
await runSearch({ returnAll: true });
expectNthSearchRequest(2, expect.objectContaining({ start: '25' }));
});
it('requests only the limit when it is below the page size', async () => {
apiRequest.mockResolvedValue(searchPage(['1', '2'], '/rest/api/search?cursor=abc'));
const result = await runSearch({ limit: 1 });
expect(apiRequest).toHaveBeenCalledTimes(1);
expectSearchRequest({ cql: 'type = page', limit: 1 });
expect(result).toEqual([{ id: '1' }]);
});
it('stops fetching and truncates once the limit is met', async () => {
apiRequest
.mockResolvedValueOnce(searchPage(['1', '2'], '/rest/api/search?cursor=abc'))
.mockResolvedValueOnce(searchPage(['3', '4'], '/rest/api/search?cursor=def'));
const result = await runSearch({ limit: 3 });
expect(apiRequest).toHaveBeenCalledTimes(2);
expectNthSearchRequest(2, { cql: 'type = page', limit: 1, cursor: 'abc' });
expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]);
});
it('coerces a numeric-string limit from an expression', async () => {
apiRequest.mockResolvedValue(searchPage(['1', '2', '3']));
const result = await runSearch({ limit: '2' });
expectSearchRequest(expect.objectContaining({ limit: 2 }));
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
});
it('stops when the next link repeats the same cursor', async () => {
apiRequest.mockResolvedValue(searchPage([], '/rest/api/search?cursor=same'));
const result = await runSearch({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(2);
expect(result).toEqual([]);
});
it('stops when the next links cycle between earlier cursors', async () => {
apiRequest
.mockResolvedValueOnce(searchPage(['1'], '/rest/api/search?cursor=a'))
.mockResolvedValueOnce(searchPage(['2'], '/rest/api/search?cursor=b'))
.mockResolvedValue(searchPage(['3'], '/rest/api/search?cursor=a'));
const result = await runSearch({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(3);
expect(result).toEqual([{ id: '1' }, { id: '2' }, { id: '3' }]);
});
it('stops after a run of consecutive empty pages even when cursors keep changing', async () => {
let page = 0;
apiRequest.mockImplementation(async () => searchPage([], `/rest/api/search?cursor=c${++page}`));
const result = await runSearch({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(5);
expect(result).toEqual([]);
});
it('stops when the next link has no usable parameter', async () => {
apiRequest.mockResolvedValueOnce(searchPage(['1'], '/rest/api/search?next=true'));
const result = await runSearch({ returnAll: true });
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(result).toEqual([{ id: '1' }]);
});
it.each([
['the full-content toggle', { fetchFullPageContent: true }, 'content.body.storage'],
[
'additional expand fields, trimmed',
{ additionalExpandFields: ' content.version , space ,' },
'content.version,space',
],
[
'both merged without duplicates',
{
fetchFullPageContent: true,
additionalExpandFields: 'content.body.storage,content.version',
},
'content.body.storage,content.version',
],
])('sends expand for %s', async (_name, options, expand) => {
await runSearch({ options });
expectSearchRequest(expect.objectContaining({ expand }));
});
it('sends cqlcontext when content statuses are selected', async () => {
await runSearch({ options: { contentStatuses: ['draft', 'archived'] } });
expectSearchRequest(
expect.objectContaining({ cqlcontext: '{"contentStatuses":["draft","archived"]}' }),
);
});
it('omits expand and cqlcontext when no options are set', async () => {
await runSearch();
const qs = apiRequest.mock.calls[0][3] as Record<string, unknown>;
expect(qs).not.toHaveProperty('expand');
expect(qs).not.toHaveProperty('cqlcontext');
});
});