feat(GitHub Node): Add timeout option to Dispatch and Wait for Completion (#35837)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon 2026-08-10 09:31:37 +01:00 committed by GitHub
parent 4664e9029a
commit a6f07dbba0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 128 additions and 8 deletions

View File

@ -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()];
}

View File

@ -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();
});
});
});