diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 72b899e665..2fe6936689 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -202,6 +202,7 @@ export const SIMULATE_NODE_TYPE = 'n8n-nodes-base.simulate'; export const SIMULATE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.simulateTrigger'; export const AI_TRANSFORM_NODE_TYPE = 'n8n-nodes-base.aiTransform'; export const FORM_NODE_TYPE = 'n8n-nodes-base.form'; +export const GITHUB_NODE_TYPE = 'n8n-nodes-base.github'; export const SLACK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.slackTrigger'; export const TELEGRAM_TRIGGER_NODE_TYPE = 'n8n-nodes-base.telegramTrigger'; export const FACEBOOK_LEAD_ADS_TRIGGER_NODE_TYPE = 'n8n-nodes-base.facebookLeadAdsTrigger'; diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index 7097a3a765..a9b9bc625c 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -1106,6 +1106,7 @@ "ndv.output.runNodeHint": "Execute this node to view data", "ndv.output.runNodeHintSubNode": "Output will appear here once the parent node is run", "ndv.output.waitNodeWaitingForWebhook": "Execution will continue when webhook is received on ", + "ndv.output.githubNodeWaitingForWebhook": "Execution will continue when the following webhook URL is called: ", "ndv.output.sendAndWaitWaitingApproval": "Execution will continue after the user's response", "ndv.output.waitNodeWaitingForFormSubmission": "Execution will continue when form is submitted on ", "ndv.output.waitNodeWaiting": "Execution will continue when wait time is over", diff --git a/packages/frontend/editor-ui/src/utils/executionUtils.test.ts b/packages/frontend/editor-ui/src/utils/executionUtils.test.ts index b14b60e3a0..1ed50d98cb 100644 --- a/packages/frontend/editor-ui/src/utils/executionUtils.test.ts +++ b/packages/frontend/editor-ui/src/utils/executionUtils.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { displayForm, executionFilterToQueryFilter, waitingNodeTooltip } from './executionUtils'; import type { INode, IRunData, IPinData } from 'n8n-workflow'; import { type INodeUi } from '../Interface'; -import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE } from '@/constants'; +import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, GITHUB_NODE_TYPE } from '@/constants'; import { createTestNode } from '@/__tests__/mocks'; const WAIT_NODE_TYPE = 'waitNode'; @@ -29,6 +29,7 @@ vi.mock('@/plugins/i18n', () => ({ 'ndv.output.waitNodeWaiting': 'Waiting for execution to resume...', 'ndv.output.waitNodeWaitingForFormSubmission': 'Waiting for form submission: ', 'ndv.output.waitNodeWaitingForWebhook': 'Waiting for webhook call: ', + 'ndv.output.githubNodeWaitingForWebhook': 'Waiting for webhook call: ', 'ndv.output.sendAndWaitWaitingApproval': 'Waiting for approval...', }; return texts[key] || key; @@ -283,6 +284,24 @@ describe('waitingNodeTooltip', () => { expect(waitingNodeTooltip(node)).toBe('Waiting for approval...'); }); + it('should handle GitHub dispatchAndWait operation', () => { + const node: INodeUi = { + id: '1', + name: 'GitHub', + type: GITHUB_NODE_TYPE, + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'dispatchAndWait', + }, + }; + + const expectedUrl = 'http://localhost:5678/webhook-waiting/123'; + expect(waitingNodeTooltip(node)).toBe( + `Waiting for webhook call: ${expectedUrl}`, + ); + }); + it('should ignore object-type webhook suffix', () => { const node: INodeUi = { id: '1', diff --git a/packages/frontend/editor-ui/src/utils/executionUtils.ts b/packages/frontend/editor-ui/src/utils/executionUtils.ts index 77181c7b03..fae38c4fea 100644 --- a/packages/frontend/editor-ui/src/utils/executionUtils.ts +++ b/packages/frontend/editor-ui/src/utils/executionUtils.ts @@ -9,7 +9,7 @@ import type { } from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter, INodeUi } from '@/Interface'; import { isEmpty } from '@/utils/typesUtils'; -import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE } from '../constants'; +import { FORM_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, GITHUB_NODE_TYPE } from '../constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useRootStore } from '@/stores/root.store'; import { i18n } from '@/plugins/i18n'; @@ -147,6 +147,11 @@ export const waitingNodeTooltip = (node: INodeUi | null | undefined) => { try { const resume = node?.parameters?.resume; + if (node?.type === GITHUB_NODE_TYPE && node.parameters?.operation === 'dispatchAndWait') { + const resumeUrl = `${useRootStore().webhookWaitingUrl}/${useWorkflowsStore().activeExecutionId}`; + const message = i18n.baseText('ndv.output.githubNodeWaitingForWebhook'); + return `${message}${resumeUrl}`; + } if (resume) { if (!['webhook', 'form'].includes(resume as string)) { return i18n.baseText('ndv.output.waitNodeWaiting'); diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 291726c850..699d157394 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -6,8 +6,16 @@ import type { INodeExecutionData, INodeType, INodeTypeDescription, + IWebhookFunctions, + IWebhookResponseData, + JsonObject, +} from 'n8n-workflow'; +import { + NodeApiError, + NodeConnectionTypes, + NodeOperationError, + WAIT_INDEFINITELY, } from 'n8n-workflow'; -import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { getFileSha, @@ -16,15 +24,19 @@ import { isBase64, validateJSON, } from './GenericFunctions'; -import { getRepositories, getUsers, getWorkflows } from './SearchFunctions'; +import { getRefs, getRepositories, getUsers, getWorkflows } from './SearchFunctions'; +import { defaultWebhookDescription } from '../Webhook/description'; export class Github implements INodeType { description: INodeTypeDescription = { displayName: 'GitHub', name: 'github', - icon: { light: 'file:github.svg', dark: 'file:github.dark.svg' }, + icon: { + light: 'file:github.svg', + dark: 'file:github.dark.svg', + }, group: ['input'], - version: 1, + version: [1, 1.1], subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Consume GitHub API', defaults: { @@ -33,6 +45,15 @@ export class Github implements INodeType { usableAsTool: true, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], + webhooks: [ + { + ...defaultWebhookDescription, + path: '', + restartWebhook: true, + httpMethod: 'POST', + responseMode: 'onReceived', + }, + ], credentials: [ { name: 'githubApi', @@ -220,7 +241,7 @@ export class Github implements INodeType { name: 'List', value: 'list', description: 'List contents of a folder', - action: 'List a file', + action: 'List files', }, ], default: 'create', @@ -396,9 +417,6 @@ export class Github implements INodeType { default: 'create', }, - // ---------------------------------- - // workflow - // ---------------------------------- { displayName: 'Operation', name: 'operation', @@ -422,6 +440,13 @@ export class Github implements INodeType { description: 'Dispatch a workflow event', action: 'Dispatch a workflow event', }, + { + name: 'Dispatch and Wait for Completion', + value: 'dispatchAndWait', + description: + 'Dispatch a workflow event and wait for a webhook to be called before proceeding', + action: 'Dispatch a workflow event and wait for completion', + }, { name: 'Enable', value: 'enable', @@ -450,86 +475,17 @@ export class Github implements INodeType { default: 'dispatch', }, { - displayName: 'Workflow', - name: 'workflowId', - type: 'resourceLocator', - default: { mode: 'list', value: '' }, - required: true, - modes: [ - { - displayName: 'From List', - name: 'list', - type: 'list', - placeholder: 'Select a workflow...', - typeOptions: { - searchListMethod: 'getWorkflows', - }, - }, - { - displayName: 'By ID', - name: 'name', - type: 'string', - placeholder: 'e.g. 12345678', - validation: [ - { - type: 'regex', - properties: { - regex: '\\d+', - errorMessage: 'Not a valid Github Workflow ID', - }, - }, - ], - }, - { - displayName: 'By File Name', - name: 'filename', - type: 'string', - placeholder: 'e.g. main.yaml or main.yml', - validation: [ - { - type: 'regex', - properties: { - regex: '[a-zA-Z0-9_-]+.(yaml|yml)', - errorMessage: 'Not a valid Github Workflow File Name', - }, - }, - ], - }, - ], + displayName: + 'Your execution will pause until a webhook is called. This URL will be generated at runtime and passed to your Github workflow as a resumeUrl input.', + name: 'webhookNotice', + type: 'notice', displayOptions: { show: { resource: ['workflow'], - operation: ['disable', 'dispatch', 'get', 'getUsage', 'enable'], + operation: ['dispatchAndWait'], }, }, - description: 'The workflow to dispatch', - }, - { - displayName: 'Ref', - name: 'ref', - type: 'string', - default: 'main', - required: true, - displayOptions: { - show: { - resource: ['workflow'], - operation: ['dispatch'], - }, - }, - description: 'The git reference for the workflow dispatch (branch, tag, or commit SHA)', - }, - { - displayName: 'Inputs', - name: 'inputs', - type: 'json', - default: '{}', - displayOptions: { - show: { - resource: ['workflow'], - operation: ['dispatch'], - }, - }, - description: 'JSON object with input parameters for the workflow', + default: '', }, // ---------------------------------- @@ -599,7 +555,10 @@ export class Github implements INodeType { displayName: 'Repository Name', name: 'repository', type: 'resourceLocator', - default: { mode: 'list', value: '' }, + default: { + mode: 'list', + value: '', + }, required: true, modes: [ { @@ -656,6 +615,142 @@ export class Github implements INodeType { }, }, + // ---------------------------------- + // workflow + // ---------------------------------- + { + displayName: 'Workflow', + name: 'workflowId', + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + required: true, + modes: [ + { + displayName: 'Workflow', + name: 'list', + type: 'list', + placeholder: 'Select a workflow...', + typeOptions: { + searchListMethod: 'getWorkflows', + searchable: true, + }, + }, + { + displayName: 'By File Name', + name: 'filename', + type: 'string', + placeholder: 'e.g. main.yaml or main.yml', + validation: [ + { + type: 'regex', + properties: { + regex: '[a-zA-Z0-9_-]+.(yaml|yml)', + errorMessage: 'Not a valid Github Workflow File Name', + }, + }, + ], + }, + { + displayName: 'By ID', + name: 'name', + type: 'string', + placeholder: 'e.g. 12345678', + validation: [ + { + type: 'regex', + properties: { + regex: '\\d+', + errorMessage: 'Not a valid Github Workflow ID', + }, + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['workflow'], + operation: ['disable', 'dispatch', 'dispatchAndWait', 'get', 'getUsage', 'enable'], + }, + }, + description: 'The workflow to dispatch', + }, + { + displayName: 'Ref', + name: 'ref', + type: 'string', + default: 'main', + required: true, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['dispatch', 'dispatchAndWait'], + '@version': [{ _cnd: { lte: 1 } }], + }, + }, + description: 'The git reference for the workflow dispatch (branch or tag name)', + }, + { + displayName: 'Ref', + name: 'ref', + type: 'resourceLocator', + default: { + mode: 'list', + value: '', + }, + required: true, + modes: [ + { + displayName: 'From List', + name: 'list', + type: 'list', + placeholder: 'Select a branch, tag, or commit...', + typeOptions: { + searchListMethod: 'getRefs', + searchable: true, + }, + }, + { + displayName: 'By Name', + name: 'name', + type: 'string', + placeholder: 'e.g. main', + validation: [ + { + type: 'regex', + properties: { + regex: '^[a-zA-Z0-9/._-]+$', + errorMessage: 'Not a valid branch, tag', + }, + }, + ], + }, + ], + displayOptions: { + show: { + resource: ['workflow'], + operation: ['dispatch', 'dispatchAndWait'], + '@version': [{ _cnd: { gte: 1.1 } }], + }, + }, + description: 'The git reference for the workflow dispatch (branch, tag, or commit SHA)', + }, + { + displayName: 'Inputs', + name: 'inputs', + type: 'json', + default: '{}', + displayOptions: { + show: { + resource: ['workflow'], + operation: ['dispatch', 'dispatchAndWait'], + }, + }, + description: 'JSON object with input parameters for the workflow', + }, + // ---------------------------------- // file // ---------------------------------- @@ -867,7 +962,6 @@ export class Github implements INodeType { placeholder: '', hint: 'The name of the output binary field to put the file in', }, - { displayName: 'Additional Parameters', name: 'additionalParameters', @@ -1148,6 +1242,7 @@ export class Github implements INodeType { }, ], }, + // ---------------------------------- // issue:get // ---------------------------------- @@ -1163,7 +1258,7 @@ export class Github implements INodeType { resource: ['issue'], }, }, - description: 'The number of the issue get data of', + description: 'The issue number to get data for', }, // ---------------------------------- @@ -1181,7 +1276,7 @@ export class Github implements INodeType { resource: ['issue'], }, }, - description: 'The number of the issue to lock', + description: 'The issue number to lock', }, { displayName: 'Lock Reason', @@ -1216,7 +1311,7 @@ export class Github implements INodeType { }, ], default: 'resolved', - description: 'The reason to lock the issue', + description: 'The reason for locking the issue', }, // ---------------------------------- @@ -1382,6 +1477,7 @@ export class Github implements INodeType { }, ], }, + // ---------------------------------- // release:getAll // ---------------------------------- @@ -1700,9 +1796,11 @@ export class Github implements INodeType { }, ], }, + // ---------------------------------- // rerview // ---------------------------------- + // ---------------------------------- // review:getAll // ---------------------------------- @@ -1783,6 +1881,7 @@ export class Github implements INodeType { default: 50, description: 'Max number of results to return', }, + // ---------------------------------- // review:create // ---------------------------------- @@ -1871,6 +1970,7 @@ export class Github implements INodeType { }, ], }, + // ---------------------------------- // review:update // ---------------------------------- @@ -1887,6 +1987,7 @@ export class Github implements INodeType { default: '', description: 'The body of the review', }, + // ---------------------------------- // user:getRepositories // ---------------------------------- @@ -1954,6 +2055,7 @@ export class Github implements INodeType { }, description: 'The email address of the invited user', }, + // ---------------------------------- // organization:getRepositories // ---------------------------------- @@ -1993,12 +2095,21 @@ export class Github implements INodeType { methods = { listSearch: { - getUsers, + getRefs, getRepositories, + getUsers, getWorkflows, }, }; + async webhook(this: IWebhookFunctions): Promise { + const requestObject = this.getRequestObject(); + + return { + workflowData: [this.helpers.returnJsonArray(requestObject.body)], + }; + } + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; @@ -2061,6 +2172,53 @@ export class Github implements INodeType { const resource = this.getNodeParameter('resource', 0); const fullOperation = `${resource}:${operation}`; + if (resource === 'workflow' && operation === 'dispatchAndWait') { + const owner = this.getNodeParameter('owner', 0, '', { extractValue: true }) as string; + const repository = this.getNodeParameter('repository', 0, '', { + extractValue: true, + }) as string; + const workflowId = this.getNodeParameter('workflowId', 0, '', { + extractValue: true, + }) as string; + const ref = this.getNodeParameter('ref', 0, '', { extractValue: true }) as string; + + const inputs = validateJSON(this.getNodeParameter('inputs', 0) as string) as IDataObject; + if (!inputs) { + throw new NodeOperationError(this.getNode(), 'Inputs: Invalid JSON'); + } + + endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`; + + body = { + ref, + inputs, + }; + + // Generate a webhook URL for the GitHub workflow to call when done + const resumeUrl = this.getWorkflowDataProxy(0).$execution.resumeUrl; + + body.inputs = { + ...inputs, + resumeUrl, + }; + + try { + responseData = await githubApiRequest.call(this, 'POST', endpoint, body); + } catch (error) { + if (error.httpCode === '404' || error.statusCode === 404) { + throw new NodeOperationError( + this.getNode(), + 'The workflow to dispatch could not be found. Adjust the "workflow" parameter setting to dispatch the workflow correctly.', + { itemIndex: 0 }, + ); + } + throw new NodeApiError(this.getNode(), error as JsonObject); + } + + await this.putExecutionToWait(WAIT_INDEFINITELY); + return [this.getInputData()]; + } + for (let i = 0; i < items.length; i++) { try { // Reset all values @@ -2504,22 +2662,27 @@ export class Github implements INodeType { requestMethod = 'PUT'; - const workflowId = this.getNodeParameter('workflowId', i) as string; + const workflowId = this.getNodeParameter('workflowId', i, '', { + extractValue: true, + }) as string; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/disable`; - } else if (operation === 'dispatch') { + } + if (operation === 'dispatch') { // ---------------------------------- // dispatch // ---------------------------------- requestMethod = 'POST'; - const workflowId = this.getNodeParameter('workflowId', i, undefined, { + const workflowId = this.getNodeParameter('workflowId', i, '', { extractValue: true, }) as string; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`; - body.ref = this.getNodeParameter('ref', i) as string; + + const ref = this.getNodeParameter('ref', i, '', { extractValue: true }) as string; + body.ref = ref; const inputs = validateJSON( this.getNodeParameter('inputs', i) as string, @@ -2537,7 +2700,9 @@ export class Github implements INodeType { requestMethod = 'PUT'; - const workflowId = this.getNodeParameter('workflowId', i) as string; + const workflowId = this.getNodeParameter('workflowId', i, '', { + extractValue: true, + }) as string; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/enable`; } else if (operation === 'get') { @@ -2547,7 +2712,9 @@ export class Github implements INodeType { requestMethod = 'GET'; - const workflowId = this.getNodeParameter('workflowId', i) as string; + const workflowId = this.getNodeParameter('workflowId', i, '', { + extractValue: true, + }) as string; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}`; } else if (operation === 'getUsage') { @@ -2557,7 +2724,9 @@ export class Github implements INodeType { requestMethod = 'GET'; - const workflowId = this.getNodeParameter('workflowId', i) as string; + const workflowId = this.getNodeParameter('workflowId', i, '', { + extractValue: true, + }) as string; endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/timing`; } else if (operation === 'list') { diff --git a/packages/nodes-base/nodes/Github/SearchFunctions.ts b/packages/nodes-base/nodes/Github/SearchFunctions.ts index ff77121464..648d82e628 100644 --- a/packages/nodes-base/nodes/Github/SearchFunctions.ts +++ b/packages/nodes-base/nodes/Github/SearchFunctions.ts @@ -26,6 +26,10 @@ type RepositorySearchResponse = { total_count: number; }; +type RefItem = { + ref: string; +}; + export async function getUsers( this: ILoadOptionsFunctions, filter?: string, @@ -115,3 +119,54 @@ export async function getWorkflows( const nextPaginationToken = page * per_page < responseData.total_count ? page + 1 : undefined; return { results, paginationToken: nextPaginationToken }; } + +export async function getRefs( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, +): Promise { + const owner = this.getCurrentNodeParameter('owner', { extractValue: true }); + const repository = this.getCurrentNodeParameter('repository', { extractValue: true }); + const page = paginationToken ? +paginationToken : 1; + const per_page = 100; + + const responseData: RefItem[] = await githubApiRequest.call( + this, + 'GET', + `/repos/${owner}/${repository}/git/refs`, + {}, + { page, per_page }, + ); + + const refs: INodeListSearchItems[] = []; + + for (const ref of responseData) { + const refPath = ref.ref.split('/'); + const refType = refPath[1]; + const refName = refPath.slice(2).join('/'); + + let description = ''; + if (refType === 'heads') { + description = `Branch: ${refName}`; + } else if (refType === 'tags') { + description = `Tag: ${refName}`; + } else { + description = `${refType}: ${refName}`; + } + + refs.push({ + name: refName, + value: refName, + description, + }); + } + + if (filter) { + const filteredRefs = refs.filter((ref) => + ref.name.toLowerCase().includes(filter.toLowerCase()), + ); + return { results: filteredRefs }; + } + const nextPaginationToken = responseData.length === per_page ? page + 1 : undefined; + return { results: refs, paginationToken: nextPaginationToken }; +} diff --git a/packages/nodes-base/nodes/Github/__tests__/GenericFunctions.test.ts b/packages/nodes-base/nodes/Github/__tests__/GenericFunctions.test.ts new file mode 100644 index 0000000000..20a3d9238f --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/GenericFunctions.test.ts @@ -0,0 +1,184 @@ +import type { IExecuteFunctions, IHookFunctions } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; + +import { + githubApiRequest, + getFileSha, + githubApiRequestAllItems, + isBase64, + validateJSON, +} from '../GenericFunctions'; + +const mockExecuteHookFunctions = { + getNodeParameter: jest.fn().mockImplementation((param: string) => { + if (param === 'authentication') return 'accessToken'; + return undefined; + }), + getCredentials: jest.fn().mockResolvedValue({ + server: 'https://api.github.com', + }), + helpers: { + requestWithAuthentication: jest.fn(), + }, + getCurrentNodeParameter: jest.fn(), + getWebhookName: jest.fn(), + getWebhookDescription: jest.fn(), + getNodeWebhookUrl: jest.fn(), + getNode: jest.fn().mockReturnValue({ + id: 'test-node-id', + name: 'test-node', + }), +} as unknown as IExecuteFunctions | IHookFunctions; + +describe('GenericFunctions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('githubApiRequest', () => { + it('should make a successful API request', async () => { + const method = 'GET'; + const endpoint = '/repos/test-owner/test-repo'; + const body = {}; + const responseData = { id: 123, name: 'test-repo' }; + + (mockExecuteHookFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await githubApiRequest.call(mockExecuteHookFunctions, method, endpoint, body); + + expect(result).toEqual(responseData); + expect(mockExecuteHookFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubApi', + { + method: 'GET', + headers: { 'User-Agent': 'n8n' }, + body: {}, + qs: undefined, + uri: 'https://api.github.com/repos/test-owner/test-repo', + json: true, + }, + ); + }); + + it('should throw a NodeApiError on API failure', async () => { + const method = 'GET'; + const endpoint = '/repos/test-owner/test-repo'; + const body = {}; + const error = new Error('API Error'); + + (mockExecuteHookFunctions.helpers.requestWithAuthentication as jest.Mock).mockRejectedValue( + error, + ); + + await expect( + githubApiRequest.call(mockExecuteHookFunctions, method, endpoint, body), + ).rejects.toThrow(NodeApiError); + }); + }); + + describe('getFileSha', () => { + it('should return the SHA of a file', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const filePath = 'README.md'; + const branch = 'main'; + const responseData = { sha: 'abc123' }; + + (mockExecuteHookFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getFileSha.call( + mockExecuteHookFunctions, + owner, + repository, + filePath, + branch, + ); + + expect(result).toBe('abc123'); + expect(mockExecuteHookFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubApi', + { + method: 'GET', + headers: { 'User-Agent': 'n8n' }, + body: {}, + qs: { ref: 'main' }, + uri: 'https://api.github.com/repos/test-owner/test-repo/contents/README.md', + json: true, + }, + ); + }); + + it('should throw a NodeOperationError if SHA is missing', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const filePath = 'README.md'; + const responseData = {}; + + (mockExecuteHookFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + await expect( + getFileSha.call(mockExecuteHookFunctions, owner, repository, filePath), + ).rejects.toThrow(NodeOperationError); + }); + }); + + describe('githubApiRequestAllItems', () => { + it('should fetch all items with pagination', async () => { + const method = 'GET'; + const endpoint = '/repos/test-owner/test-repo/issues'; + const body = {}; + const query = { state: 'open' }; + const responseData1 = [{ id: 1, title: 'Issue 1' }]; + const responseData2 = [{ id: 2, title: 'Issue 2' }]; + + (mockExecuteHookFunctions.helpers.requestWithAuthentication as jest.Mock) + .mockResolvedValueOnce({ headers: { link: 'next' }, body: responseData1 }) + .mockResolvedValueOnce({ headers: {}, body: responseData2 }); + + const result = await githubApiRequestAllItems.call( + mockExecuteHookFunctions, + method, + endpoint, + body, + query, + ); + + expect(result).toEqual([...responseData1, ...responseData2]); + expect(mockExecuteHookFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(2); + }); + }); + + describe('isBase64', () => { + it('should return true for valid Base64 strings', () => { + expect(isBase64('aGVsbG8gd29ybGQ=')).toBe(true); + expect(isBase64('Zm9vYmFy')).toBe(true); + }); + + it('should return false for invalid Base64 strings', () => { + expect(isBase64('not base64')).toBe(false); + expect(isBase64('123!@#')).toBe(false); + }); + }); + + describe('validateJSON', () => { + it('should return parsed JSON for valid JSON strings', () => { + const jsonString = '{"key": "value"}'; + const result = validateJSON(jsonString); + + expect(result).toEqual({ key: 'value' }); + }); + + it('should return undefined for invalid JSON strings', () => { + const invalidJsonString = 'not json'; + const result = validateJSON(invalidJsonString); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Github/__tests__/Github.node.test.ts b/packages/nodes-base/nodes/Github/__tests__/Github.node.test.ts deleted file mode 100644 index 5700a424aa..0000000000 --- a/packages/nodes-base/nodes/Github/__tests__/Github.node.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import nock from 'nock'; - -import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; - -const workflows = getWorkflowFilenames(__dirname); - -describe('Test Github Node', () => { - describe('Workflow Dispatch', () => { - const now = 1683028800000; - const owner = 'testOwner'; - const repository = 'testRepository'; - const workflowId = 147025216; - const usersResponse = { - total_count: 12, - items: [ - { - login: 'testOwner', - id: 1, - }, - ], - }; - const repositoriesResponse = { - total_count: 40, - items: [ - { - id: 3081286, - name: 'testRepository', - }, - ], - }; - const workflowsResponse = { - total_count: 2, - workflows: [ - { - id: workflowId, - node_id: 'MDg6V29ya2Zsb3cxNjEzMzU=', - name: 'CI', - path: '.github/workflows/blank.yaml', - state: 'active', - created_at: '2020-01-08T23:48:37.000-08:00', - updated_at: '2020-01-08T23:50:21.000-08:00', - url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/161335', - html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/161335', - badge_url: 'https://github.com/octo-org/octo-repo/workflows/CI/badge.svg', - }, - { - id: 269289, - node_id: 'MDE4OldvcmtmbG93IFNlY29uZGFyeTI2OTI4OQ==', - name: 'Linter', - path: '.github/workflows/linter.yaml', - state: 'active', - created_at: '2020-01-08T23:48:37.000-08:00', - updated_at: '2020-01-08T23:50:21.000-08:00', - url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/269289', - html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/269289', - badge_url: 'https://github.com/octo-org/octo-repo/workflows/Linter/badge.svg', - }, - ], - }; - - beforeAll(async () => { - jest.useFakeTimers({ doNotFake: ['nextTick'], now }); - await initBinaryDataService(); - }); - - beforeEach(async () => { - const baseUrl = 'https://api.github.com'; - nock(baseUrl) - .persist() - .defaultReplyHeaders({ 'Content-Type': 'application/json' }) - .get('/search/users') - .query(true) - .reply(200, usersResponse) - .get('/search/repositories') - .query(true) - .reply(200, repositoriesResponse) - .get(`/repos/${owner}/${repository}/actions/workflows`) - .reply(200, workflowsResponse) - .post(`/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, { - ref: 'main', - inputs: {}, - }) - .reply(200, {}); - }); - - testWorkflows(workflows); - }); -}); diff --git a/packages/nodes-base/nodes/Github/__tests__/SearchFunctions.test.ts b/packages/nodes-base/nodes/Github/__tests__/SearchFunctions.test.ts new file mode 100644 index 0000000000..9e218f8445 --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/SearchFunctions.test.ts @@ -0,0 +1,499 @@ +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { getUsers, getRepositories, getWorkflows, getRefs } from '../SearchFunctions'; + +const mockLoadOptionsFunctions = { + getNodeParameter: jest.fn(), + getCredentials: jest.fn().mockResolvedValue({ + server: 'https://api.github.com', + }), + helpers: { + requestWithAuthentication: jest.fn(), + }, + getCurrentNodeParameter: jest.fn(), +} as unknown as ILoadOptionsFunctions; + +describe('Search Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getUsers', () => { + it('should fetch users', async () => { + const filter = 'test-user'; + const responseData = { + items: [ + { login: 'test-user-1', html_url: 'https://github.com/test-user-1' }, + { login: 'test-user-2', html_url: 'https://github.com/test-user-2' }, + ], + total_count: 2, + }; + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getUsers.call(mockLoadOptionsFunctions, filter); + + expect(result).toEqual({ + results: [ + { name: 'test-user-1', value: 'test-user-1', url: 'https://github.com/test-user-1' }, + { name: 'test-user-2', value: 'test-user-2', url: 'https://github.com/test-user-2' }, + ], + paginationToken: undefined, + }); + + expect(mockLoadOptionsFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: expect.objectContaining({ page: 1 }), + }), + ); + }); + + it('should handle pagination', async () => { + const filter = 'test-user'; + const responseData = { + items: [ + { login: 'test-user-1', html_url: 'https://github.com/test-user-1' }, + { login: 'test-user-2', html_url: 'https://github.com/test-user-2' }, + ], + total_count: 200, + }; + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getUsers.call(mockLoadOptionsFunctions, filter); + + expect(result).toEqual({ + results: [ + { name: 'test-user-1', value: 'test-user-1', url: 'https://github.com/test-user-1' }, + { name: 'test-user-2', value: 'test-user-2', url: 'https://github.com/test-user-2' }, + ], + paginationToken: 2, + }); + }); + + it('should use paginationToken when provided', async () => { + const filter = 'test-user'; + const paginationToken = '3'; + const responseData = { + items: [ + { login: 'test-user-5', html_url: 'https://github.com/test-user-5' }, + { login: 'test-user-6', html_url: 'https://github.com/test-user-6' }, + ], + total_count: 200, + }; + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getUsers.call(mockLoadOptionsFunctions, filter, paginationToken); + + expect(result).toEqual({ + results: [ + { name: 'test-user-5', value: 'test-user-5', url: 'https://github.com/test-user-5' }, + { name: 'test-user-6', value: 'test-user-6', url: 'https://github.com/test-user-6' }, + ], + paginationToken: undefined, + }); + + expect(mockLoadOptionsFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: expect.objectContaining({ page: 3 }), + }), + ); + }); + }); + + describe('getRepositories', () => { + it('should fetch repositories', async () => { + const filter = 'test-repo'; + const owner = 'test-owner'; + const responseData = { + items: [ + { name: 'test-repo-1', html_url: 'https://github.com/test-owner/test-repo-1' }, + { name: 'test-repo-2', html_url: 'https://github.com/test-owner/test-repo-2' }, + ], + total_count: 2, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockReturnValue(owner); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getRepositories.call(mockLoadOptionsFunctions, filter); + + expect(result).toEqual({ + results: [ + { + name: 'test-repo-1', + value: 'test-repo-1', + url: 'https://github.com/test-owner/test-repo-1', + }, + { + name: 'test-repo-2', + value: 'test-repo-2', + url: 'https://github.com/test-owner/test-repo-2', + }, + ], + paginationToken: undefined, + }); + }); + + it('should fetch repositories without filter', async () => { + const owner = 'test-owner'; + const responseData = { + items: [ + { name: 'test-repo-1', html_url: 'https://github.com/test-owner/test-repo-1' }, + { name: 'test-repo-2', html_url: 'https://github.com/test-owner/test-repo-2' }, + ], + total_count: 2, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockReturnValue(owner); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getRepositories.call(mockLoadOptionsFunctions); + + expect(result).toEqual({ + results: [ + { + name: 'test-repo-1', + value: 'test-repo-1', + url: 'https://github.com/test-owner/test-repo-1', + }, + { + name: 'test-repo-2', + value: 'test-repo-2', + url: 'https://github.com/test-owner/test-repo-2', + }, + ], + paginationToken: undefined, + }); + }); + + it('should use paginationToken when provided', async () => { + const filter = 'test-repo'; + const paginationToken = '3'; + const owner = 'test-owner'; + const responseData = { + items: [ + { name: 'test-repo-5', html_url: 'https://github.com/test-owner/test-repo-5' }, + { name: 'test-repo-6', html_url: 'https://github.com/test-owner/test-repo-6' }, + ], + total_count: 200, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockReturnValue(owner); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getRepositories.call(mockLoadOptionsFunctions, filter, paginationToken); + + expect(result).toEqual({ + results: [ + { + name: 'test-repo-5', + value: 'test-repo-5', + url: 'https://github.com/test-owner/test-repo-5', + }, + { + name: 'test-repo-6', + value: 'test-repo-6', + url: 'https://github.com/test-owner/test-repo-6', + }, + ], + paginationToken: undefined, + }); + + expect(mockLoadOptionsFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: expect.objectContaining({ page: 3 }), + }), + ); + }); + + it('should handle empty repositories', async () => { + const filter = 'test-repo'; + const owner = 'test-owner'; + const responseData = { + items: [], + total_count: 0, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockReturnValue(owner); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getRepositories.call(mockLoadOptionsFunctions, filter); + + expect(result).toEqual({ + results: [], + paginationToken: undefined, + }); + }); + }); + + describe('getWorkflows', () => { + it('should fetch workflows', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const responseData = { + workflows: [ + { id: '1', name: 'workflow-1' }, + { id: '2', name: 'workflow-2' }, + ], + total_count: 2, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock) + .mockReturnValueOnce(owner) + .mockReturnValueOnce(repository); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getWorkflows.call(mockLoadOptionsFunctions); + + expect(result).toEqual({ + results: [ + { name: 'workflow-1', value: '1' }, + { name: 'workflow-2', value: '2' }, + ], + paginationToken: undefined, + }); + }); + + it('should handle pagination', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const responseData = { + workflows: [ + { id: '1', name: 'workflow-1' }, + { id: '2', name: 'workflow-2' }, + ], + total_count: 200, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock) + .mockReturnValueOnce(owner) + .mockReturnValueOnce(repository); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getWorkflows.call(mockLoadOptionsFunctions); + + expect(result).toEqual({ + results: [ + { name: 'workflow-1', value: '1' }, + { name: 'workflow-2', value: '2' }, + ], + paginationToken: 2, + }); + }); + + it('should use paginationToken when provided and return next page token', async () => { + const paginationToken = '1'; + const owner = 'test-owner'; + const repository = 'test-repo'; + const responseData = { + workflows: [ + { id: '3', name: 'workflow-3' }, + { id: '4', name: 'workflow-4' }, + ], + total_count: 300, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock) + .mockReturnValueOnce(owner) + .mockReturnValueOnce(repository); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getWorkflows.call(mockLoadOptionsFunctions, paginationToken); + + expect(result).toEqual({ + results: [ + { name: 'workflow-3', value: '3' }, + { name: 'workflow-4', value: '4' }, + ], + paginationToken: 2, + }); + + expect(mockLoadOptionsFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: expect.objectContaining({ page: 1 }), + }), + ); + }); + + it('should handle empty workflows', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const responseData = { + workflows: [], + total_count: 0, + }; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock) + .mockReturnValueOnce(owner) + .mockReturnValueOnce(repository); + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + responseData, + ); + + const result = await getWorkflows.call(mockLoadOptionsFunctions); + + expect(result).toEqual({ + results: [], + paginationToken: undefined, + }); + }); + }); + + describe('getRefs', () => { + it('should fetch branches and tags using git/refs endpoint', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const refsResponse = [ + { ref: 'refs/heads/Main' }, + { ref: 'refs/heads/Dev' }, + { ref: 'refs/tags/v1.0.0' }, + { ref: 'refs/tags/v2.0.0' }, + { ref: 'refs/Pull/123/head' }, + ]; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockImplementation( + (param: string) => { + if (param === 'owner') return owner; + if (param === 'repository') return repository; + }, + ); + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + refsResponse, + ); + + const result = await getRefs.call(mockLoadOptionsFunctions); + + expect(result).toEqual({ + results: [ + { name: 'Main', value: 'Main', description: 'Branch: Main' }, + { name: 'Dev', value: 'Dev', description: 'Branch: Dev' }, + { name: 'v1.0.0', value: 'v1.0.0', description: 'Tag: v1.0.0' }, + { name: 'v2.0.0', value: 'v2.0.0', description: 'Tag: v2.0.0' }, + { name: '123/head', value: '123/head', description: 'Pull: 123/head' }, + ], + paginationToken: undefined, + }); + }); + + it('should use paginationToken when provided', async () => { + const paginationToken = '3'; + const owner = 'test-owner'; + const repository = 'test-repo'; + const refsResponse = [{ ref: 'refs/heads/branch-5' }, { ref: 'refs/heads/branch-6' }]; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockImplementation( + (param: string) => { + if (param === 'owner') return owner; + if (param === 'repository') return repository; + }, + ); + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + refsResponse, + ); + + const result = await getRefs.call(mockLoadOptionsFunctions, undefined, paginationToken); + + expect(result).toEqual({ + results: [ + { name: 'branch-5', value: 'branch-5', description: 'Branch: branch-5' }, + { name: 'branch-6', value: 'branch-6', description: 'Branch: branch-6' }, + ], + paginationToken: undefined, + }); + + expect(mockLoadOptionsFunctions.helpers.requestWithAuthentication).toHaveBeenCalledWith( + 'githubOAuth2Api', + expect.objectContaining({ + method: 'GET', + qs: expect.objectContaining({ page: 3 }), + }), + ); + }); + + it('should filter refs based on the provided filter', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const refsResponse = [ + { ref: 'refs/heads/main' }, + { ref: 'refs/heads/dev' }, + { ref: 'refs/tags/v1.0.0' }, + { ref: 'refs/tags/v2.0.0' }, + ]; + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockImplementation( + (param: string) => { + if (param === 'owner') return owner; + if (param === 'repository') return repository; + }, + ); + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + refsResponse, + ); + + const result = await getRefs.call(mockLoadOptionsFunctions, 'v1'); + + expect(result).toEqual({ + results: [{ name: 'v1.0.0', value: 'v1.0.0', description: 'Tag: v1.0.0' }], + }); + }); + + it('should handle pagination correctly', async () => { + const owner = 'test-owner'; + const repository = 'test-repo'; + const refsResponse = Array(100) + .fill(0) + .map((_, i) => ({ + ref: i % 2 === 0 ? `refs/heads/branch-${i}` : `refs/tags/tag-${i}`, + })); + + (mockLoadOptionsFunctions.getCurrentNodeParameter as jest.Mock).mockImplementation( + (param: string) => { + if (param === 'owner') return owner; + if (param === 'repository') return repository; + }, + ); + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValue( + refsResponse, + ); + + const result = await getRefs.call(mockLoadOptionsFunctions); + + expect(result.paginationToken).toBe(2); + expect(result.results.length).toBe(100); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Github/__tests__/node/Github.dispatchAndWait.node.test.ts b/packages/nodes-base/nodes/Github/__tests__/node/Github.dispatchAndWait.node.test.ts new file mode 100644 index 0000000000..5db2a3336a --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/node/Github.dispatchAndWait.node.test.ts @@ -0,0 +1,93 @@ +import nock from 'nock'; + +import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; + +const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('GithubDispatchAndWaitWorkflow.json'), +); + +describe('Test Github Node - Dispatch and Wait', () => { + describe('Workflow Dispatch and Wait', () => { + const now = 1683028800000; + const owner = 'Owner'; + const repository = 'test-github-actions'; + const workflowId = 145370278; + const ref = 'test-branch'; + + const usersResponse = { + total_count: 1, + items: [ + { + login: owner, + id: 1, + }, + ], + }; + + const repositoriesResponse = { + total_count: 1, + items: [ + { + id: 3081286, + name: repository, + }, + ], + }; + + const workflowsResponse = { + total_count: 1, + workflows: [ + { + id: workflowId, + node_id: 'MDg6V29ya2Zsb3cxNjEzMzU=', + name: 'New Test Workflow', + path: '.github/workflows/test.yaml', + state: 'active', + created_at: '2020-01-08T23:48:37.000-08:00', + updated_at: '2020-01-08T23:50:21.000-08:00', + url: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}`, + html_url: `https://github.com/${owner}/${repository}/blob/master/.github/workflows/test.yaml`, + badge_url: `https://github.com/${owner}/${repository}/workflows/New%20Test%20Workflow/badge.svg`, + }, + ], + }; + + const refsResponse = [{ ref: `refs/heads/${ref}` }]; + + beforeAll(async () => { + jest.useFakeTimers({ doNotFake: ['nextTick'], now }); + await initBinaryDataService(); + }); + + beforeEach(async () => { + const baseUrl = 'https://api.github.com'; + nock.cleanAll(); + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/search/users') + .query(true) + .reply(200, usersResponse) + .get('/search/repositories') + .query(true) + .reply(200, repositoriesResponse) + .get(`/repos/${owner}/${repository}/actions/workflows`) + .reply(200, workflowsResponse) + .get(`/repos/${owner}/${repository}/git/refs`) + .reply(200, refsResponse) + .post( + `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, + (body) => { + return body.ref === ref && body.inputs && body.inputs.resumeUrl; + }, + ) + .reply(200, {}); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + testWorkflows(workflows); + }); +}); diff --git a/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts b/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts new file mode 100644 index 0000000000..bbb5471495 --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts @@ -0,0 +1,533 @@ +import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import nock from 'nock'; + +import { getWorkflowFilenames, initBinaryDataService, testWorkflows } from '@test/nodes/Helpers'; + +import { Github } from '../../Github.node'; + +const workflows = getWorkflowFilenames(__dirname).filter((filename) => + filename.includes('GithubTestWorkflow.json'), +); + +describe('Test Github Node', () => { + describe('Workflow Dispatch', () => { + const now = 1683028800000; + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + const usersResponse = { + total_count: 12, + items: [ + { + login: 'testOwner', + id: 1, + }, + ], + }; + const repositoriesResponse = { + total_count: 40, + items: [ + { + id: 3081286, + name: 'testRepository', + }, + ], + }; + const workflowsResponse = { + total_count: 2, + workflows: [ + { + id: workflowId, + node_id: 'MDg6V29ya2Zsb3cxNjEzMzU=', + name: 'CI', + path: '.github/workflows/blank.yaml', + state: 'active', + created_at: '2020-01-08T23:48:37.000-08:00', + updated_at: '2020-01-08T23:50:21.000-08:00', + url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/161335', + html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/161335', + badge_url: 'https://github.com/octo-org/octo-repo/workflows/CI/badge.svg', + }, + { + id: 269289, + node_id: 'MDE4OldvcmtmbG93IFNlY29uZGFyeTI2OTI4OQ==', + name: 'Linter', + path: '.github/workflows/linter.yaml', + state: 'active', + created_at: '2020-01-08T23:48:37.000-08:00', + updated_at: '2020-01-08T23:50:21.000-08:00', + url: 'https://api.github.com/repos/octo-org/octo-repo/actions/workflows/269289', + html_url: 'https://github.com/octo-org/octo-repo/blob/master/.github/workflows/269289', + badge_url: 'https://github.com/octo-org/octo-repo/workflows/Linter/badge.svg', + }, + ], + }; + + beforeAll(async () => { + jest.useFakeTimers({ doNotFake: ['nextTick'], now }); + await initBinaryDataService(); + }); + + beforeEach(async () => { + const baseUrl = 'https://api.github.com'; + nock(baseUrl) + .persist() + .defaultReplyHeaders({ 'Content-Type': 'application/json' }) + .get('/search/users') + .query(true) + .reply(200, usersResponse) + .get('/search/repositories') + .query(true) + .reply(200, repositoriesResponse) + .get(`/repos/${owner}/${repository}/actions/workflows`) + .reply(200, workflowsResponse) + .post(`/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, { + ref: 'main', + inputs: {}, + }) + .reply(200, {}); + }); + + testWorkflows(workflows); + }); + + describe('Error Handling', () => { + let githubNode: Github; + let mockExecutionContext: any; + + beforeEach(() => { + githubNode = new Github(); + mockExecutionContext = { + getNode: jest.fn().mockReturnValue({ name: 'Github' }), + getNodeParameter: jest.fn(), + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + continueOnFail: jest.fn().mockReturnValue(false), + putExecutionToWait: jest.fn(), + getCredentials: jest.fn().mockResolvedValue({ + server: 'https://api.github.com', + user: 'test', + accessToken: 'test', + }), + helpers: { + returnJsonArray: jest.fn().mockReturnValue([{ json: {} }]), + httpRequest: jest.fn(), + httpRequestWithAuthentication: jest.fn(), + requestWithAuthentication: jest + .fn() + .mockImplementation(async (_credentialType, options) => { + if (options.uri.includes('dispatches') && options.method === 'POST') { + const error: any = new Error('Not Found'); + error.statusCode = 404; + error.message = 'Not Found'; + throw error; + } + return {}; + }), + request: jest.fn(), + constructExecutionMetaData: jest.fn().mockReturnValue([{ json: {} }]), + assertBinaryData: jest.fn(), + prepareBinaryData: jest.fn(), + }, + getWorkflowDataProxy: jest.fn().mockReturnValue({ + $execution: { + resumeUrl: 'https://example.com/webhook', + }, + }), + }; + }); + + it('should throw NodeOperationError for invalid JSON inputs', async () => { + mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { + if (parameterName === 'inputs') { + return 'invalid json'; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'dispatchAndWait'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return ''; + }); + + await expect(async () => { + await githubNode.execute.call(mockExecutionContext); + }).rejects.toThrow(NodeOperationError); + }); + + it('should throw NodeOperationError for 404 errors when dispatching a workflow', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.helpers.requestWithAuthentication.mockRejectedValueOnce({ + statusCode: 404, + message: 'Not Found', + }); + + mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + return workflowId; + } + if (parameterName === 'inputs') { + return '{}'; + } + if (parameterName === 'ref') { + return 'main'; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'dispatchAndWait'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return ''; + }); + + await expect(async () => { + await githubNode.execute.call(mockExecutionContext); + }).rejects.toThrow(/The workflow to dispatch could not be found/); + }); + + it('should throw NodeApiError for general API errors', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.helpers.requestWithAuthentication.mockRejectedValueOnce({ + statusCode: 500, + message: 'Internal Server Error', + }); + + mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + return workflowId; + } + if (parameterName === 'inputs') { + return '{}'; + } + if (parameterName === 'ref') { + return 'main'; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'dispatch'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return ''; + }); + + await expect(async () => { + await githubNode.execute.call(mockExecutionContext); + }).rejects.toThrow(); + }); + + it('should throw NodeApiError for general API errors in dispatchAndWait operation', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.getWorkflowDataProxy = jest.fn().mockReturnValue({ + $execution: { + resumeUrl: 'https://example.com/webhook', + }, + }); + + mockExecutionContext.helpers.requestWithAuthentication.mockRejectedValueOnce({ + statusCode: 500, + message: 'Internal Server Error', + }); + + mockExecutionContext.getNodeParameter.mockImplementation((parameterName: string) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + return workflowId; + } + if (parameterName === 'inputs') { + return '{}'; + } + if (parameterName === 'ref') { + return 'main'; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'dispatchAndWait'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return ''; + }); + + await expect(async () => { + await githubNode.execute.call(mockExecutionContext); + }).rejects.toThrow(NodeApiError); + + expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'POST', + uri: expect.stringContaining( + `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`, + ), + body: expect.objectContaining({ + ref: 'main', + inputs: expect.objectContaining({ + resumeUrl: 'https://example.com/webhook', + }), + }), + }), + ); + }); + }); + + describe('Workflow Operations', () => { + let githubNode: Github; + let mockExecutionContext: any; + + beforeEach(() => { + githubNode = new Github(); + mockExecutionContext = { + getNode: jest.fn().mockReturnValue({ name: 'Github' }), + getNodeParameter: jest.fn(), + getInputData: jest.fn().mockReturnValue([{ json: {} }]), + continueOnFail: jest.fn().mockReturnValue(false), + getCredentials: jest.fn().mockResolvedValue({ + server: 'https://api.github.com', + user: 'test', + accessToken: 'test', + }), + helpers: { + returnJsonArray: jest.fn().mockReturnValue([{ json: {} }]), + requestWithAuthentication: jest.fn().mockResolvedValue({}), + constructExecutionMetaData: jest.fn().mockReturnValue([{ json: {} }]), + }, + }; + }); + + it('should use extractValue for workflowId in disable operation', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.getNodeParameter.mockImplementation( + (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + expect(options).toBeDefined(); + expect(options.extractValue).toBe(true); + return workflowId; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'disable'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return defaultValue; + }, + ); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'PUT', + uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}/disable`, + }), + ); + }); + + it('should use extractValue for workflowId in enable operation', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.getNodeParameter.mockImplementation( + (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + expect(options).toBeDefined(); + expect(options.extractValue).toBe(true); + return workflowId; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'enable'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return defaultValue; + }, + ); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'PUT', + uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}/enable`, + }), + ); + }); + + it('should use extractValue for workflowId in get operation', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.getNodeParameter.mockImplementation( + (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + expect(options).toBeDefined(); + expect(options.extractValue).toBe(true); + return workflowId; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'get'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return defaultValue; + }, + ); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'GET', + uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}`, + }), + ); + }); + + it('should use extractValue for workflowId in getUsage operation', async () => { + const owner = 'testOwner'; + const repository = 'testRepository'; + const workflowId = 147025216; + + mockExecutionContext.getNodeParameter.mockImplementation( + (parameterName: string, _itemIndex: number, defaultValue: string, options?: any) => { + if (parameterName === 'owner') { + return owner; + } + if (parameterName === 'repository') { + return repository; + } + if (parameterName === 'workflowId') { + expect(options).toBeDefined(); + expect(options.extractValue).toBe(true); + return workflowId; + } + if (parameterName === 'resource') { + return 'workflow'; + } + if (parameterName === 'operation') { + return 'getUsage'; + } + if (parameterName === 'authentication') { + return 'accessToken'; + } + return defaultValue; + }, + ); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.helpers.requestWithAuthentication).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + method: 'GET', + uri: `https://api.github.com/repos/${owner}/${repository}/actions/workflows/${workflowId}/timing`, + }), + ); + }); + }); + + describe('Parameter Extraction', () => { + it('should use extractValue for workflowId parameter', () => { + const githubNode = new Github(); + const description = githubNode.description; + + const workflowIdParam = description.properties.find((prop) => prop.name === 'workflowId'); + + expect(workflowIdParam).toBeDefined(); + expect(workflowIdParam?.type).toBe('resourceLocator'); + + const workflowOperations = description.properties.find( + (prop) => + prop.name === 'operation' && prop.displayOptions?.show?.resource?.includes('workflow'), + ); + + expect(workflowOperations).toBeDefined(); + expect(workflowOperations?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ value: 'disable' }), + expect.objectContaining({ value: 'dispatch' }), + expect.objectContaining({ value: 'enable' }), + expect.objectContaining({ value: 'get' }), + expect.objectContaining({ value: 'getUsage' }), + ]), + ); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Github/__tests__/node/Github.webhook.test.ts b/packages/nodes-base/nodes/Github/__tests__/node/Github.webhook.test.ts new file mode 100644 index 0000000000..406ab5c38f --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/node/Github.webhook.test.ts @@ -0,0 +1,64 @@ +import type { IWebhookFunctions } from 'n8n-workflow'; + +import { Github } from '../../Github.node'; + +describe('Github Node - Webhook Method', () => { + let githubNode: Github; + let mockWebhookFunctions: IWebhookFunctions; + + beforeEach(() => { + githubNode = new Github(); + + mockWebhookFunctions = { + getRequestObject: jest.fn(), + getResponseObject: jest.fn(), + getNodeParameter: jest.fn(), + getNode: jest.fn(), + helpers: { + returnJsonArray: jest.fn(), + }, + } as unknown as IWebhookFunctions; + }); + + it('should process webhook request and return workflowData', async () => { + const sampleWebhookBody = { + action: 'opened', + issue: { + number: 123, + title: 'Test Issue', + body: 'This is a test issue', + user: { + login: 'testuser', + }, + }, + repository: { + name: 'test-repo', + owner: { + login: 'test-owner', + }, + }, + }; + + const mockRequestObject = { + body: sampleWebhookBody, + headers: { + 'x-github-event': 'issues', + 'x-github-delivery': '72d3162e-cc78-11e3-81ab-4c9367dc0958', + }, + }; + + (mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue(mockRequestObject); + (mockWebhookFunctions.helpers.returnJsonArray as jest.Mock).mockReturnValue([ + sampleWebhookBody, + ]); + + const result = await githubNode.webhook.call(mockWebhookFunctions); + + expect(result).toEqual({ + workflowData: [[sampleWebhookBody]], + }); + + expect(mockWebhookFunctions.getRequestObject).toHaveBeenCalled(); + expect(mockWebhookFunctions.helpers.returnJsonArray).toHaveBeenCalledWith(sampleWebhookBody); + }); +}); diff --git a/packages/nodes-base/nodes/Github/__tests__/node/GithubDispatchAndWaitWorkflow.json b/packages/nodes-base/nodes/Github/__tests__/node/GithubDispatchAndWaitWorkflow.json new file mode 100644 index 0000000000..abfd3daacd --- /dev/null +++ b/packages/nodes-base/nodes/Github/__tests__/node/GithubDispatchAndWaitWorkflow.json @@ -0,0 +1,76 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [200, -360], + "id": "0889ff06-41e2-4786-a0fe-ca330c3711e7", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resource": "workflow", + "operation": "dispatchAndWait", + "owner": { + "__rl": true, + "value": "Owner", + "mode": "list", + "cachedResultName": "Owner", + "cachedResultUrl": "https://github.com/Owner" + }, + "repository": { + "__rl": true, + "value": "test-github-actions", + "mode": "list", + "cachedResultName": "test-github-actions", + "cachedResultUrl": "https://github.com/Owner/test-github-actions" + }, + "workflowId": { + "__rl": true, + "value": 145370278, + "mode": "list", + "cachedResultName": "New Test Workflow" + }, + "ref": { + "__rl": true, + "value": "test-branch", + "mode": "list", + "cachedResultName": "test-branch" + } + }, + "type": "n8n-nodes-base.github", + "typeVersion": 1.1, + "position": [220, 0], + "id": "105cb5f0-bcc3-4397-ba06-bda8cf46c71b", + "name": "Dispatch and Wait for Completion", + "webhookId": "02bfeade-db6c-412a-8627-fe3a9952e8ee", + "credentials": { + "githubApi": { + "id": "RtvkwCTqGZ2sLhB8", + "name": "GitHub account 3" + } + } + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Dispatch and Wait for Completion", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Dispatch and Wait for Completion": [ + { + "json": {} + } + ] + } +} diff --git a/packages/nodes-base/nodes/Github/__tests__/GithubTestWorkflow.json b/packages/nodes-base/nodes/Github/__tests__/node/GithubTestWorkflow.json similarity index 93% rename from packages/nodes-base/nodes/Github/__tests__/GithubTestWorkflow.json rename to packages/nodes-base/nodes/Github/__tests__/node/GithubTestWorkflow.json index 7404c5afa9..1ea3cd487e 100644 --- a/packages/nodes-base/nodes/Github/__tests__/GithubTestWorkflow.json +++ b/packages/nodes-base/nodes/Github/__tests__/node/GithubTestWorkflow.json @@ -79,8 +79,5 @@ "json": {} } ] - }, - "meta": { - "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" } }