diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 19c808faca..64e668888c 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -1998,6 +1998,13 @@ const options: RenovateOptions[] = [ type: 'string', default: 'automergeComment', }, + { + name: 'automergeFailureComment', + description: + 'If an error occurs while automerging, a comment will be created to signal the user. Only used if `automergeFailureComment=on-error`.', + type: 'string', + default: 'never', + }, { name: 'ignoreTests', description: 'Set to `true` to enable automerging without tests.', diff --git a/lib/config/types.ts b/lib/config/types.ts index 0798f99d7c..8613d72cb6 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -34,6 +34,7 @@ export interface RenovateSharedConfig { addLabels?: string[]; autoReplaceGlobalMatch?: boolean; automerge?: boolean; + autoMergeFailureComment?: string; automergeSchedule?: string[]; automergeStrategy?: MergeStrategy; branchName?: string; diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts index eb133f6eca..ba22da9971 100644 --- a/lib/modules/platform/github/index.spec.ts +++ b/lib/modules/platform/github/index.spec.ts @@ -467,6 +467,7 @@ describe('modules/platform/github/index', () => { isArchived: false, nameWithOwner: repository, autoMergeAllowed: true, + automergeFailureComment: 'never', hasIssuesEnabled: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, @@ -2798,6 +2799,18 @@ describe('modules/platform/github/index', () => { platformPrOptions: { usePlatformAutomerge: true }, }; + const prConfigComment: CreatePRConfig = { + sourceBranch: 'some-branch', + targetBranch: 'dev', + prTitle: 'The Title', + prBody: 'Hello world', + labels: ['deps', 'renovate'], + platformPrOptions: { + usePlatformAutomerge: true, + automergeFailureComment: 'on-error', + }, + }; + const mockScope = async (repoOpts: any = {}): Promise => { const scope = httpMock.scope(githubApiHost); initRepoMock(scope, 'some/repo', repoOpts); @@ -2975,6 +2988,16 @@ describe('modules/platform/github/index', () => { ]); }); + it('should handle GraphQL errors with automergeFailureComment', async () => { + const scope = await mockScope({ automergeFailureComment: 'on-error' }); + scope + .post('/repos/some/repo/issues/123/comments') + .reply(200) + .post('/graphql') + .reply(200, graphqlAutomergeErrorResp); + await expect(github.createPr(prConfigComment)).toResolve(); + }); + it('should handle REST API errors', async () => { const scope = await mockScope(); scope.post('/graphql').reply(500); diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts index 93488565ba..2bf4559991 100644 --- a/lib/modules/platform/github/index.ts +++ b/lib/modules/platform/github/index.ts @@ -1697,13 +1697,21 @@ async function tryPrAutomerge( { prNumber, errors: res.errors }, 'GitHub-native automerge: fail', ); + if (platformPrOptions.automergeFailureComment === 'on-error') { + logger.warn('This automerge request failed'); + await addComment( + prNumber, + 'The Automerge Request for this PR has failed. Please attend.', + ); + return; + } return; } - logger.debug(`GitHub-native automerge: success...PrNo: ${prNumber}`); } catch (err) /* istanbul ignore next: missing test #22198 */ { logger.warn({ prNumber, err }, 'GitHub-native automerge: REST API error'); } + return; } // Creates PR and returns PR number diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts index 81a8a84994..60ac53c864 100644 --- a/lib/modules/platform/types.ts +++ b/lib/modules/platform/types.ts @@ -100,6 +100,7 @@ export interface Issue { export type PlatformPrOptions = { autoApprove?: boolean; automergeStrategy?: MergeStrategy; + automergeFailureComment?: string; azureWorkItemId?: number; bbUseDefaultReviewers?: boolean; bbAutoResolvePrTasks?: boolean; diff --git a/lib/workers/repository/update/pr/index.ts b/lib/workers/repository/update/pr/index.ts index edc8517705..6be1fbb653 100644 --- a/lib/workers/repository/update/pr/index.ts +++ b/lib/workers/repository/update/pr/index.ts @@ -57,6 +57,7 @@ export function getPlatformPrOptions( return { autoApprove: !!config.autoApprove, automergeStrategy: config.automergeStrategy, + automergeFailureComment: config.automergeFailureComment, azureWorkItemId: config.azureWorkItemId ?? 0, bbAutoResolvePrTasks: !!config.bbAutoResolvePrTasks, bbUseDefaultReviewers: !!config.bbUseDefaultReviewers, diff --git a/lib/workers/types.ts b/lib/workers/types.ts index e4c6477b45..019af16ec5 100644 --- a/lib/workers/types.ts +++ b/lib/workers/types.ts @@ -116,6 +116,7 @@ export interface BranchConfig LegacyAdminConfig, PlatformPrOptions { automergeComment?: string; + automergeFailureComment?: string; automergeType?: string; automergedPreviously?: boolean; baseBranch: string; diff --git a/test/documentation.spec.ts b/test/documentation.spec.ts index 16f0c431cb..c400ed2d18 100644 --- a/test/documentation.spec.ts +++ b/test/documentation.spec.ts @@ -19,12 +19,12 @@ describe('documentation', () => { }); describe('website-documentation', () => { - function getConfigOptionSubHeaders( + async function getConfigOptionSubHeaders( file: string, configOption: string, - ): string[] { + ): Promise { const subHeadings = []; - const content = fs.readFileSync(`docs/usage/${file}`, 'utf8'); + const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const reg = regEx(`##\\s${configOption}[\\s\\S]+?\n##\\s`); const match = reg.exec(content); const subHeadersMatch = match?.[0]?.matchAll(/\n###\s(?\w+)\n/g); @@ -39,8 +39,8 @@ describe('documentation', () => { } describe('docs/usage/configuration-options.md', () => { - function getConfigHeaders(file: string): string[] { - const content = fs.readFileSync(`docs/usage/${file}`, 'utf8'); + async function getConfigHeaders(file: string): Promise { + const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n## (.*?)\n/g) ?? []; return matches.map((match) => match.substring(4, match.length - 1)); } @@ -54,20 +54,20 @@ describe('documentation', () => { .sort(); } - it('has doc headers sorted alphabetically', () => { - expect(getConfigHeaders('configuration-options.md')).toEqual( - getConfigHeaders('configuration-options.md').sort(), + it('has doc headers sorted alphabetically', async () => { + expect(await getConfigHeaders('configuration-options.md')).toEqual( + (await getConfigHeaders('configuration-options.md')).sort(), ); }); - it('has headers for every required option', () => { - expect(getConfigHeaders('configuration-options.md')).toEqual( + it('has headers for every required option', async () => { + expect(await getConfigHeaders('configuration-options.md')).toEqual( getRequiredConfigOptions(), ); }); - function getConfigSubHeaders(file: string): string[] { - const content = fs.readFileSync(`docs/usage/${file}`, 'utf8'); + async function getConfigSubHeaders(file: string): Promise { + const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n### (.*?)\n/g) ?? []; return matches .map((match) => match.substring(5, match.length - 1)) @@ -100,21 +100,26 @@ describe('documentation', () => { return parentNames; } - it('has headers for every required sub-option', () => { - expect(getConfigSubHeaders('configuration-options.md')).toEqual( + it('has headers for every required sub-option', async () => { + expect(await getConfigSubHeaders('configuration-options.md')).toEqual( getRequiredConfigSubOptions(), ); }); - it.each([...getParentNames()])( + test.each([...getParentNames()])( '%s has sub-headers sorted alphabetically', - (parentName: string) => { + async (parentName: string) => { expect( - getConfigOptionSubHeaders('configuration-options.md', parentName), - ).toEqual( - getConfigOptionSubHeaders( + await getConfigOptionSubHeaders( 'configuration-options.md', parentName, + ), + ).toEqual( + ( + await getConfigOptionSubHeaders( + 'configuration-options.md', + parentName, + ) ).sort(), ); }, @@ -122,8 +127,8 @@ describe('documentation', () => { }); describe('docs/usage/self-hosted-configuration.md', () => { - function getSelfHostedHeaders(file: string): string[] { - const content = fs.readFileSync(`docs/usage/${file}`, 'utf8'); + async function getSelfHostedHeaders(file: string): Promise { + const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n## (.*?)\n/g) ?? []; return matches.map((match) => match.substring(4, match.length - 1)); } @@ -135,32 +140,40 @@ describe('documentation', () => { .sort(); } - it('has headers sorted alphabetically', () => { - expect(getSelfHostedHeaders('self-hosted-configuration.md')).toEqual( - getSelfHostedHeaders('self-hosted-configuration.md').sort(), + it('has headers sorted alphabetically', async () => { + expect( + await getSelfHostedHeaders('self-hosted-configuration.md'), + ).toEqual( + (await getSelfHostedHeaders('self-hosted-configuration.md')).sort(), ); }); - it('has headers for every required option', () => { - expect(getSelfHostedHeaders('self-hosted-configuration.md')).toEqual( - getRequiredSelfHostedOptions(), - ); + it('has headers for every required option', async () => { + expect( + await getSelfHostedHeaders('self-hosted-configuration.md'), + ).toEqual(getRequiredSelfHostedOptions()); }); }); describe('docs/usage/self-hosted-experimental.md', () => { - function getSelfHostedExperimentalConfigHeaders(file: string): string[] { - const content = fs.readFileSync(`docs/usage/${file}`, 'utf8'); + async function getSelfHostedExperimentalConfigHeaders( + file: string, + ): Promise { + const content = await fs.readFile(`docs/usage/${file}`, 'utf8'); const matches = content.match(/\n## (.*?)\n/g) ?? []; return matches.map((match) => match.substring(4, match.length - 1)); } - it('has headers sorted alphabetically', () => { + it('has headers sorted alphabetically', async () => { expect( - getSelfHostedExperimentalConfigHeaders('self-hosted-experimental.md'), - ).toEqual( - getSelfHostedExperimentalConfigHeaders( + await getSelfHostedExperimentalConfigHeaders( 'self-hosted-experimental.md', + ), + ).toEqual( + ( + await getSelfHostedExperimentalConfigHeaders( + 'self-hosted-experimental.md', + ) ).sort(), ); });