From a6f07dbba00c6a31758cc3ac8ba2a5e29039f475 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 10 Aug 2026 09:31:37 +0100 Subject: [PATCH] feat(GitHub Node): Add timeout option to Dispatch and Wait for Completion (#35837) Co-authored-by: Claude Opus 5 (1M context) --- .../nodes-base/nodes/Github/Github.node.ts | 35 ++++-- .../Github/__tests__/node/Github.node.test.ts | 101 +++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Github/Github.node.ts b/packages/nodes-base/nodes/Github/Github.node.ts index 0913e352562..5c73329f660 100644 --- a/packages/nodes-base/nodes/Github/Github.node.ts +++ b/packages/nodes-base/nodes/Github/Github.node.ts @@ -10,12 +10,7 @@ import type { IWebhookResponseData, JsonObject, } from 'n8n-workflow'; -import { - NodeApiError, - NodeConnectionTypes, - NodeOperationError, - WAIT_INDEFINITELY, -} from 'n8n-workflow'; +import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { getFileSha, @@ -25,6 +20,8 @@ import { validateJSON, } from './GenericFunctions'; import { getRefs, getRepositories, getUsers, getWorkflows } from './SearchFunctions'; +import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util'; +import { limitWaitTimeOption } from '../../utils/sendAndWait/descriptions'; import { removeTrailingSlash } from '../../utils/utilities'; import { defaultWebhookDescription } from '../Webhook/description'; import { pullRequestFields } from './descriptions/PullRequestDescription'; @@ -798,6 +795,26 @@ export class Github implements INodeType { }, description: 'JSON object with input parameters for the workflow', }, + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add option', + default: {}, + displayOptions: { + show: { + resource: ['workflow'], + operation: ['dispatchAndWait'], + }, + }, + options: [ + { + ...limitWaitTimeOption, + description: + 'Whether to limit how long to wait for the webhook to be called before the execution resumes', + }, + ], + }, // ---------------------------------- // file @@ -2422,6 +2439,10 @@ export class Github implements INodeType { throw new NodeOperationError(this.getNode(), 'Inputs: Invalid JSON'); } + // Resolved before dispatching so a misconfigured wait limit doesn't leave an + // already-triggered GitHub workflow with nothing waiting on it + const waitTill = configureWaitTillDate(this); + endpoint = `/repos/${owner}/${repository}/actions/workflows/${workflowId}/dispatches`; body = { @@ -2453,7 +2474,7 @@ export class Github implements INodeType { // Add signed resumeUrl to metadata for frontend to use in waiting tooltip this.setMetadata({ resumeUrl }); - await this.putExecutionToWait(WAIT_INDEFINITELY); + await this.putExecutionToWait(waitTill); return [this.getInputData()]; } 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 index b4913826376..bdf880ae254 100644 --- a/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts +++ b/packages/nodes-base/nodes/Github/__tests__/node/Github.node.test.ts @@ -1,5 +1,5 @@ import { NodeTestHarness } from '@nodes-testing/node-test-harness'; -import { NodeApiError, NodeOperationError } from 'n8n-workflow'; +import { NodeApiError, NodeOperationError, WAIT_INDEFINITELY } from 'n8n-workflow'; import nock from 'nock'; import * as utilities from '../../../../utils/utilities'; @@ -699,4 +699,103 @@ describe('Test Github Node', () => { ); }); }); + + describe('Dispatch and Wait - Limit Wait Time', () => { + const now = 1683028800000; + let githubNode: Github; + let mockExecutionContext: any; + + // Only the `limitWaitTime` values vary between these tests; everything else is a + // valid dispatchAndWait configuration. + const setLimitWaitTime = (values: object | undefined) => { + mockExecutionContext.getNodeParameter.mockImplementation( + (parameterName: string, _itemIndex: number, defaultValue: unknown) => { + if (parameterName === 'options.limitWaitTime.values') return values ?? defaultValue; + if (parameterName === 'resource') return 'workflow'; + if (parameterName === 'operation') return 'dispatchAndWait'; + if (parameterName === 'authentication') return 'accessToken'; + if (parameterName === 'owner') return 'testOwner'; + if (parameterName === 'repository') return 'testRepository'; + if (parameterName === 'workflowId') return 147025216; + if (parameterName === 'ref') return 'main'; + if (parameterName === 'inputs') return '{}'; + return defaultValue; + }, + ); + }; + + beforeEach(() => { + vi.useFakeTimers({ now }); + githubNode = new Github(); + mockExecutionContext = { + getNode: vi.fn().mockReturnValue({ name: 'Github' }), + getNodeParameter: vi.fn(), + getInputData: vi.fn().mockReturnValue([{ json: {} }]), + continueOnFail: vi.fn().mockReturnValue(false), + putExecutionToWait: vi.fn(), + setMetadata: vi.fn(), + getCredentials: vi.fn().mockResolvedValue({ + server: 'https://api.github.com', + user: 'test', + accessToken: 'test', + }), + helpers: { + returnJsonArray: vi.fn().mockReturnValue([{ json: {} }]), + requestWithAuthentication: vi.fn().mockResolvedValue({}), + constructExecutionMetaData: vi.fn().mockReturnValue([{ json: {} }]), + }, + getWorkflowDataProxy: vi.fn().mockReturnValue({ + $execution: { resumeUrl: 'https://example.com/webhook' }, + }), + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should wait indefinitely when no limit is set', async () => { + setLimitWaitTime(undefined); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.putExecutionToWait).toHaveBeenCalledWith(WAIT_INDEFINITELY); + }); + + it('should wait for the configured time interval', async () => { + setLimitWaitTime({ + limitType: 'afterTimeInterval', + resumeAmount: 30, + resumeUnit: 'minutes', + }); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.putExecutionToWait).toHaveBeenCalledWith( + new Date(now + 30 * 60 * 1000), + ); + }); + + it('should wait until the configured date and time', async () => { + const maxDateAndTime = '2023-05-02T12:00:00.000Z'; + setLimitWaitTime({ limitType: 'atSpecifiedTime', maxDateAndTime }); + + await githubNode.execute.call(mockExecutionContext); + + expect(mockExecutionContext.putExecutionToWait).toHaveBeenCalledWith( + new Date(maxDateAndTime), + ); + }); + + it('should throw for an invalid date and time without dispatching the workflow', async () => { + setLimitWaitTime({ limitType: 'atSpecifiedTime', maxDateAndTime: 'not-a-date' }); + + await expect(githubNode.execute.call(mockExecutionContext)).rejects.toThrow( + NodeOperationError, + ); + + expect(mockExecutionContext.helpers.requestWithAuthentication).not.toHaveBeenCalled(); + expect(mockExecutionContext.putExecutionToWait).not.toHaveBeenCalled(); + }); + }); });