feat(Confluence Node): Add page add and remove label operations (#37002)

This commit is contained in:
Yen Su
2026-08-27 09:21:28 +00:00
committed by GitHub
parent bcf693e698
commit ea24aa94ae
10 changed files with 462 additions and 1 deletions
@@ -0,0 +1,71 @@
import type { IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { confluenceApiRequest } from '../../transport';
import { optionalSpaceRLC, pageRLC, resolvePageId } from '../common';
import type { ConfluenceOperation } from '../router';
const properties: INodeProperties[] = [
optionalSpaceRLC,
{
...pageRLC,
description: 'The page to add labels to',
},
{
displayName: 'Labels',
name: 'labels',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. runbook, q3-release',
description:
'The label names to add, comma-separated for several. Label names cannot contain spaces — use an underscore or hyphen instead. Existing labels are kept.',
},
];
const displayOptions = {
show: {
resource: ['page'],
operation: ['addLabels'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export const execute: ConfluenceOperation = async function (
this: IExecuteFunctions,
itemIndex: number,
) {
// `?? ''` guards an expression resolving to undefined, which would otherwise
// stringify into a label literally named "undefined"
const names = String(this.getNodeParameter('labels', itemIndex, '') ?? '')
.split(',')
.map((name) => name.trim())
.filter((name) => name !== '');
if (names.length === 0) {
throw new NodeOperationError(this.getNode(), "The 'Labels' parameter is empty", { itemIndex });
}
// Confluence splits a name on whitespace into several labels rather than rejecting it,
// so "release notes" would silently create "release" and "notes"
const spaced = names.find((name) => /\s/.test(name));
if (spaced !== undefined) {
throw new NodeOperationError(
this.getNode(),
`The label "${spaced}" contains a space; use an underscore or hyphen instead`,
{ itemIndex },
);
}
const pageId = await resolvePageId.call(this, itemIndex);
// v1 endpoint: v2's label surface is read-only. LabelCreate requires a prefix,
// and 'global' is what the Confluence UI writes.
return await confluenceApiRequest.call(
this,
'POST',
`/wiki/rest/api/content/${encodeURIComponent(pageId)}/label`,
names.map((name) => ({ prefix: 'global', name })),
);
};
@@ -1,6 +1,7 @@
import type { INodeProperties } from 'n8n-workflow';
import * as addComment from './addComment.operation';
import * as addLabels from './addLabels.operation';
import * as append from './append.operation';
import * as create from './create.operation';
import * as del from './delete.operation';
@@ -9,10 +10,12 @@ import * as get from './get.operation';
import * as getComments from './getComments.operation';
import * as getLabels from './getLabels.operation';
import * as getManyByLabel from './getManyByLabel.operation';
import * as removeLabel from './removeLabel.operation';
import * as update from './update.operation';
export {
addComment,
addLabels,
append,
create,
del as delete,
@@ -21,6 +24,7 @@ export {
getComments,
getLabels,
getManyByLabel,
removeLabel,
update,
};
@@ -42,6 +46,12 @@ export const description: INodeProperties[] = [
description: 'Add a footer comment to a page, or reply to an existing comment',
action: 'Add a comment to a page',
},
{
name: 'Add Labels',
value: 'addLabels',
description: 'Add one or more labels to a page',
action: 'Add labels to a page',
},
{
name: 'Append',
value: 'append',
@@ -90,6 +100,12 @@ export const description: INodeProperties[] = [
description: 'Retrieve all pages carrying a label',
action: 'Get many pages by label',
},
{
name: 'Remove Label',
value: 'removeLabel',
description: 'Remove a label from a page by name',
action: 'Remove a label from a page',
},
{
name: 'Update',
value: 'update',
@@ -100,6 +116,7 @@ export const description: INodeProperties[] = [
default: 'create',
},
...addComment.description,
...addLabels.description,
...append.description,
...create.description,
...del.description,
@@ -108,5 +125,6 @@ export const description: INodeProperties[] = [
...getComments.description,
...getLabels.description,
...getManyByLabel.description,
...removeLabel.description,
...update.description,
];
@@ -0,0 +1,76 @@
import type { IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { confluenceApiRequest } from '../../transport';
import { optionalSpaceRLC, pageRLC, resolvePageId } from '../common';
import type { ConfluenceOperation } from '../router';
const properties: INodeProperties[] = [
optionalSpaceRLC,
{
...pageRLC,
description: 'The page to remove the label from',
},
{
displayName: 'Label',
name: 'labelName',
type: 'string',
default: '',
required: true,
placeholder: 'e.g. runbook',
description: 'The name of a single label to remove',
},
];
const displayOptions = {
show: {
resource: ['page'],
operation: ['removeLabel'],
},
};
export const description = updateDisplayOptions(displayOptions, properties);
export const execute: ConfluenceOperation = async function (
this: IExecuteFunctions,
itemIndex: number,
) {
// `?? ''` guards an expression resolving to undefined, which would otherwise
// stringify into a request for a label literally named "undefined"
const label = String(this.getNodeParameter('labelName', itemIndex, '') ?? '').trim();
if (label === '') {
throw new NodeOperationError(this.getNode(), "The 'Label' parameter is empty", { itemIndex });
}
// A comma-separated list would 204 as a no-op and report success — reject it here instead
if (label.includes(',')) {
throw new NodeOperationError(
this.getNode(),
"The 'Label' parameter accepts one label name; commas are not allowed",
{ itemIndex },
);
}
// A label can never hold a space, so this would 204 as a no-op and report success too
if (/\s/.test(label)) {
throw new NodeOperationError(
this.getNode(),
`The label "${label}" contains a space; Confluence labels use an underscore or hyphen instead`,
{ itemIndex },
);
}
const pageId = await resolvePageId.call(this, itemIndex);
// The name goes in the query string, not the path: the path variant rejects "/" in the name.
// No prefix: neither content-label DELETE variant declares one — the name alone identifies it.
await confluenceApiRequest.call(
this,
'DELETE',
`/wiki/rest/api/content/${encodeURIComponent(pageId)}/label`,
{},
{ name: label },
);
return { removed: true, pageId, label };
};
@@ -46,6 +46,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
case 'page:addComment':
responseData = await page.addComment.execute.call(this, i);
break;
case 'page:addLabels':
responseData = await page.addLabels.execute.call(this, i);
break;
case 'page:append':
responseData = await page.append.execute.call(this, i);
break;
@@ -70,6 +73,9 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
case 'page:getManyByLabel':
responseData = await page.getManyByLabel.execute.call(this, i);
break;
case 'page:removeLabel':
responseData = await page.removeLabel.execute.call(this, i);
break;
case 'page:update':
responseData = await page.update.execute.call(this, i);
break;
@@ -36,6 +36,7 @@ describe('Confluence Node', () => {
expect(operationProperty('attachment')?.default).toBe('getMany');
expect(operationOptions('page')).toEqual([
expect.objectContaining({ value: 'addComment' }),
expect.objectContaining({ value: 'addLabels' }),
expect.objectContaining({ value: 'append' }),
expect.objectContaining({ value: 'create' }),
expect.objectContaining({ value: 'delete' }),
@@ -44,6 +45,7 @@ describe('Confluence Node', () => {
expect.objectContaining({ value: 'getComments' }),
expect.objectContaining({ value: 'getLabels' }),
expect.objectContaining({ value: 'getManyByLabel' }),
expect.objectContaining({ value: 'removeLabel' }),
expect.objectContaining({ value: 'update' }),
]);
expect(operationOptions('search')).toEqual([expect.objectContaining({ value: 'query' })]);
@@ -66,6 +68,16 @@ describe('Confluence Node', () => {
expect(limit?.displayOptions?.show?.returnAll).toEqual([false]);
});
it('should render the label operations own fields', () => {
const fieldsFor = (operation: string) =>
node.description.properties
.filter((p) => (p.displayOptions?.show?.operation ?? []).includes(operation))
.map((p) => p.name);
expect(fieldsFor('addLabels')).toEqual(['space', 'page', 'labels']);
expect(fieldsFor('removeLabel')).toEqual(['space', 'page', 'labelName']);
});
it('should reference the confluenceCloudOAuth2Api credential by name', () => {
expect(node.description.credentials).toEqual([
{ name: 'confluenceCloudOAuth2Api', required: true },
@@ -0,0 +1,103 @@
import { NodeOperationError } from 'n8n-workflow';
import { execute } from '../../../actions/page/addLabels.operation';
import { confluenceApiRequest } from '../../../transport';
import { mockExecuteCtx } from '../../shared';
vi.mock('../../../transport', () => ({
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
confluenceApiRequest: vi.fn(),
}));
const apiRequest = vi.mocked(confluenceApiRequest);
describe('Confluence page:addLabels operation', () => {
beforeEach(() => {
vi.clearAllMocks();
apiRequest.mockResolvedValue({});
});
it('posts a single label with the global prefix', async () => {
const ctx = mockExecuteCtx({ page: { mode: 'id', value: '123' }, labels: 'runbook' });
await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/rest/api/content/123/label', [
{ prefix: 'global', name: 'runbook' },
]);
});
it('trims a comma-separated list, drops empties and posts it in one request', async () => {
const ctx = mockExecuteCtx({
page: { mode: 'id', value: '123' },
labels: ' alpha , ,beta, ',
});
await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/rest/api/content/123/label', [
{ prefix: 'global', name: 'alpha' },
{ prefix: 'global', name: 'beta' },
]);
});
it('returns the label list response unchanged', async () => {
const response = {
results: [{ id: '9', name: 'runbook', prefix: 'global', label: 'runbook' }],
start: 0,
limit: 200,
size: 1,
_links: { base: 'https://example.atlassian.net/wiki' },
};
apiRequest.mockResolvedValueOnce(response);
const ctx = mockExecuteCtx({ page: { mode: 'id', value: '123' }, labels: 'runbook' });
expect(await execute.call(ctx, 0)).toEqual(response);
});
it.each([
['whitespace only', ' '],
['an expression resolving to undefined', undefined],
])('throws without calling the API when the labels input is %s', async (_case, labels) => {
// By Title, so a guard placed after the page lookup would still issue a request
const ctx = mockExecuteCtx({ page: { mode: 'title', value: 'Doc' }, labels });
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
await expect(execute.call(ctx, 0)).rejects.toThrow("The 'Labels' parameter is empty");
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a label containing a space instead of letting Confluence split it', async () => {
// By Title, so a guard placed after the page lookup would still issue a request
const ctx = mockExecuteCtx({
page: { mode: 'title', value: 'Doc' },
labels: 'runbook, release notes',
});
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
await expect(execute.call(ctx, 0)).rejects.toThrow(
'The label "release notes" contains a space',
);
expect(apiRequest).not.toHaveBeenCalled();
});
it('resolves a By Title selection to its page ID before posting', async () => {
apiRequest.mockResolvedValueOnce({ results: [{ id: '777', title: 'Doc', spaceId: '1' }] });
const ctx = mockExecuteCtx({ page: { mode: 'title', value: 'Doc' }, labels: 'runbook' });
await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenNthCalledWith(
1,
'GET',
'/wiki/api/v2/pages',
{},
{ title: 'Doc', limit: 250 },
);
expect(apiRequest).toHaveBeenNthCalledWith(2, 'POST', '/wiki/rest/api/content/777/label', [
{ prefix: 'global', name: 'runbook' },
]);
});
});
@@ -0,0 +1,112 @@
import type { IDataObject } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { execute } from '../../../actions/page/removeLabel.operation';
import { confluenceApiRequest } from '../../../transport';
import { mockExecuteCtx } from '../../shared';
vi.mock('../../../transport', () => ({
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
confluenceApiRequest: vi.fn(),
}));
const apiRequest = vi.mocked(confluenceApiRequest);
describe('Confluence page:removeLabel operation', () => {
beforeEach(() => {
vi.clearAllMocks();
// A 204 with an empty body comes back as '' under `json: true`
apiRequest.mockResolvedValue('' as unknown as IDataObject);
});
it('deletes the trimmed label by name in the query string and reports it', async () => {
const ctx = mockExecuteCtx({
page: { mode: 'id', value: '123' },
labelName: ' runbook ',
});
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledTimes(1);
expect(apiRequest).toHaveBeenCalledWith(
'DELETE',
'/wiki/rest/api/content/123/label',
{},
{ name: 'runbook' },
);
expect(result).toEqual({ removed: true, pageId: '123', label: 'runbook' });
});
it.each([
['whitespace only', ' '],
['an expression resolving to undefined', undefined],
])('throws without calling the API when the label is %s', async (_case, labelName) => {
// By Title, so a guard placed after the page lookup would still issue a request
const ctx = mockExecuteCtx({ page: { mode: 'title', value: 'Doc' }, labelName });
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
await expect(execute.call(ctx, 0)).rejects.toThrow("The 'Label' parameter is empty");
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a comma-separated list instead of removing nothing', async () => {
const ctx = mockExecuteCtx({
page: { mode: 'title', value: 'Doc' },
labelName: 'qa-alpha, qa-beta',
});
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
await expect(execute.call(ctx, 0)).rejects.toThrow('commas are not allowed');
expect(apiRequest).not.toHaveBeenCalled();
});
it('rejects a label containing a space instead of removing nothing', async () => {
const ctx = mockExecuteCtx({
page: { mode: 'title', value: 'Doc' },
labelName: 'release notes',
});
await expect(execute.call(ctx, 0)).rejects.toThrow(NodeOperationError);
await expect(execute.call(ctx, 0)).rejects.toThrow(
'The label "release notes" contains a space',
);
expect(apiRequest).not.toHaveBeenCalled();
});
it('resolves a By Title selection to its page ID before deleting', async () => {
apiRequest.mockResolvedValueOnce({ results: [{ id: '777', title: 'Doc', spaceId: '1' }] });
const ctx = mockExecuteCtx({ page: { mode: 'title', value: 'Doc' }, labelName: 'runbook' });
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenNthCalledWith(
1,
'GET',
'/wiki/api/v2/pages',
{},
{ title: 'Doc', limit: 250 },
);
expect(apiRequest).toHaveBeenNthCalledWith(
2,
'DELETE',
'/wiki/rest/api/content/777/label',
{},
{ name: 'runbook' },
);
expect(result).toEqual({ removed: true, pageId: '777', label: 'runbook' });
});
it('passes a label containing a slash through the query string untouched', async () => {
const ctx = mockExecuteCtx({ page: { mode: 'id', value: '123' }, labelName: 'team/qa' });
const result = await execute.call(ctx, 0);
expect(apiRequest).toHaveBeenCalledWith(
'DELETE',
'/wiki/rest/api/content/123/label',
{},
{ name: 'team/qa' },
);
expect(result).toEqual({ removed: true, pageId: '123', label: 'team/qa' });
});
});
@@ -148,6 +148,57 @@ describe('Confluence router', () => {
expect(result).toEqual([[{ json: { id: '555', pageId: '1' }, pairedItem: { item: 0 } }]]);
});
it('dispatches page:addLabels and returns the label list as one item', async () => {
const response = {
results: [
{ id: '9', name: 'runbook', prefix: 'global', label: 'runbook' },
{ id: '10', name: 'q3', prefix: 'global', label: 'q3' },
],
start: 0,
limit: 200,
size: 2,
};
apiRequest.mockResolvedValue(response);
const result = await router.call(
mockExecuteCtx({
resource: 'page',
operation: 'addLabels',
page: { mode: 'id', value: '1' },
labels: 'runbook, q3',
}),
);
expect(apiRequest).toHaveBeenCalledWith('POST', '/wiki/rest/api/content/1/label', [
{ prefix: 'global', name: 'runbook' },
{ prefix: 'global', name: 'q3' },
]);
expect(result).toEqual([[{ json: response, pairedItem: { item: 0 } }]]);
});
it('dispatches page:removeLabel and returns the removal report', async () => {
apiRequest.mockResolvedValue('');
const result = await router.call(
mockExecuteCtx({
resource: 'page',
operation: 'removeLabel',
page: { mode: 'id', value: '1' },
labelName: 'runbook',
}),
);
expect(apiRequest).toHaveBeenCalledWith(
'DELETE',
'/wiki/rest/api/content/1/label',
{},
{ name: 'runbook' },
);
expect(result).toEqual([
[{ json: { removed: true, pageId: '1', label: 'runbook' }, pairedItem: { item: 0 } }],
]);
});
it('dispatches page:deleteComment and returns the deletion report', async () => {
const result = await router.call(
mockExecuteCtx({
@@ -124,6 +124,18 @@ describe('confluenceApiRequest', () => {
);
});
it('sends an array body through as an array', async () => {
const body = [{ prefix: 'global', name: 'a' }];
await confluenceApiRequest.call(ctx, 'POST', '/wiki/rest/api/content/1/label', body);
expect(mockHttpRequestWithAuthentication).toHaveBeenNthCalledWith(
2,
'confluenceCloudOAuth2Api',
expect.objectContaining({ body: [{ prefix: 'global', name: 'a' }] }),
);
});
it('defaults body and qs to empty objects', async () => {
await confluenceApiRequest.call(ctx, 'GET', '/wiki/api/v2/pages');
@@ -86,7 +86,7 @@ export async function confluenceApiRequest(
this: IExecuteFunctions | ILoadOptionsFunctions,
method: IHttpRequestMethods,
endpoint: string,
body: IDataObject = {},
body: IDataObject | IDataObject[] = {},
qs: IDataObject = {},
): Promise<IDataObject> {
const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);