diff --git a/packages/nodes-base/nodes/Confluence/actions/page/addLabels.operation.ts b/packages/nodes-base/nodes/Confluence/actions/page/addLabels.operation.ts new file mode 100644 index 00000000000..7aa2ea39c25 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/page/addLabels.operation.ts @@ -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 })), + ); +}; diff --git a/packages/nodes-base/nodes/Confluence/actions/page/index.ts b/packages/nodes-base/nodes/Confluence/actions/page/index.ts index a07549debe5..1841855b5d0 100644 --- a/packages/nodes-base/nodes/Confluence/actions/page/index.ts +++ b/packages/nodes-base/nodes/Confluence/actions/page/index.ts @@ -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, ]; diff --git a/packages/nodes-base/nodes/Confluence/actions/page/removeLabel.operation.ts b/packages/nodes-base/nodes/Confluence/actions/page/removeLabel.operation.ts new file mode 100644 index 00000000000..38e9944f492 --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/actions/page/removeLabel.operation.ts @@ -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 }; +}; diff --git a/packages/nodes-base/nodes/Confluence/actions/router.ts b/packages/nodes-base/nodes/Confluence/actions/router.ts index 75b3d6c39cc..1e005aafe9b 100644 --- a/packages/nodes-base/nodes/Confluence/actions/router.ts +++ b/packages/nodes-base/nodes/Confluence/actions/router.ts @@ -46,6 +46,9 @@ export async function router(this: IExecuteFunctions): Promise { 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 }, diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/addLabels.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/addLabels.operation.test.ts new file mode 100644 index 00000000000..664e910b12d --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/addLabels.operation.test.ts @@ -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' }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/page/removeLabel.operation.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/page/removeLabel.operation.test.ts new file mode 100644 index 00000000000..afa9145d18e --- /dev/null +++ b/packages/nodes-base/nodes/Confluence/test/actions/page/removeLabel.operation.test.ts @@ -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' }); + }); +}); diff --git a/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts index ce3b7c2c99c..2f6ef6488a6 100644 --- a/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/actions/router.test.ts @@ -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({ diff --git a/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts b/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts index 59484538302..21a75bc335d 100644 --- a/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts +++ b/packages/nodes-base/nodes/Confluence/test/transport/index.test.ts @@ -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'); diff --git a/packages/nodes-base/nodes/Confluence/transport/index.ts b/packages/nodes-base/nodes/Confluence/transport/index.ts index 495535127a9..8d11e8570ed 100644 --- a/packages/nodes-base/nodes/Confluence/transport/index.ts +++ b/packages/nodes-base/nodes/Confluence/transport/index.ts @@ -86,7 +86,7 @@ export async function confluenceApiRequest( this: IExecuteFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, endpoint: string, - body: IDataObject = {}, + body: IDataObject | IDataObject[] = {}, qs: IDataObject = {}, ): Promise { const credentials = await this.getCredentials(CONFLUENCE_CREDENTIAL_NAME);