From eef4c2f11f9c8ab819b332482af38aa426db6ed8 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov <zharinov@users.noreply.github.com> Date: Wed, 20 Oct 2021 06:31:03 +0300 Subject: [PATCH] feat(github): Use native auto-merge when possible (#12045) --- docs/usage/configuration-options.md | 5 +- lib/platform/github/graphql.ts | 16 +++ lib/platform/github/index.spec.ts | 212 +++++++++++++++++++++++++++- lib/platform/github/index.ts | 63 +++++++++ lib/platform/github/types.ts | 7 + lib/util/cache/repository/types.ts | 1 + lib/util/http/github.ts | 6 +- 7 files changed, 307 insertions(+), 3 deletions(-) diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index d035dafc8b..4956dbc50a 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -1669,8 +1669,11 @@ If enabled Renovate will pin Docker images by means of their SHA256 digest and n If you have enabled `automerge` and set `automergeType=pr` in the Renovate config, then `platformAutomerge` is enabled by default to speed up merging via the platform's native automerge functionality. +Renovate tries platform-native automerge only when it initially creates the PR. +Any PR that is being updated will be automerged with the Renovate-based automerge. + `platformAutomerge` will configure PRs to be merged after all (if any) branch policies have been met. -This option is available for Azure and GitLab. +This option is available for Azure, GitHub and GitLab. It falls back to Renovate-based automerge if the platform-native automerge is not available. Though this option is enabled by default, you can fine tune the behavior by setting `packageRules` if you want to use it selectively (e.g. per-package). diff --git a/lib/platform/github/graphql.ts b/lib/platform/github/graphql.ts index bc944b80af..8c52f763a7 100644 --- a/lib/platform/github/graphql.ts +++ b/lib/platform/github/graphql.ts @@ -173,3 +173,19 @@ query($owner: String!, $name: String!) { } } `; + +export const enableAutoMergeMutation = ` +mutation EnablePullRequestAutoMerge( + $pullRequestId: ID! +) { + enablePullRequestAutoMerge( + input: { + pullRequestId: $pullRequestId + } + ) { + pullRequest { + number + } + } +} +`; diff --git a/lib/platform/github/index.spec.ts b/lib/platform/github/index.spec.ts index 6553c70c0d..880532cf53 100644 --- a/lib/platform/github/index.spec.ts +++ b/lib/platform/github/index.spec.ts @@ -6,8 +6,10 @@ import { REPOSITORY_RENAMED, } from '../../constants/error-messages'; import { BranchStatus, PrState, VulnerabilityAlert } from '../../types'; +import * as _repoCache from '../../util/cache/repository'; +import { Cache } from '../../util/cache/repository/types'; import * as _git from '../../util/git'; -import type { Platform } from '../types'; +import type { CreatePRConfig, Platform } from '../types'; const githubApiHost = 'https://api.github.com'; @@ -15,6 +17,7 @@ describe('platform/github/index', () => { let github: Platform; let hostRules: jest.Mocked<typeof import('../../util/host-rules')>; let git: jest.Mocked<typeof _git>; + let repoCache: jest.Mocked<typeof _repoCache>; beforeEach(async () => { // reset module jest.resetModules(); @@ -33,6 +36,8 @@ describe('platform/github/index', () => { hostRules.find.mockReturnValue({ token: '123test', }); + jest.mock('../../util/cache/repository'); + repoCache = mocked(await import('../../util/cache/repository')); }); const graphqlOpenPullRequests = loadFixture('graphql/pullrequest-1.json'); @@ -1865,6 +1870,211 @@ describe('platform/github/index', () => { expect(pr).toMatchSnapshot(); expect(httpMock.getTrace()).toMatchSnapshot(); }); + describe('automerge', () => { + const createdPrResp = { + number: 123, + node_id: 'abcd', + head: { repo: { full_name: 'some/repo' } }, + }; + + const graphqlAutomergeResp = { + data: { + enablePullRequestAutoMerge: { + pullRequest: { + number: 123, + }, + }, + }, + }; + + const graphqlAutomergeErrorResp = { + ...graphqlAutomergeResp, + errors: [ + { + type: 'UNPROCESSABLE', + message: + 'Pull request is not in the correct state to enable auto-merge', + }, + ], + }; + + const prConfig: CreatePRConfig = { + sourceBranch: 'some-branch', + targetBranch: 'dev', + prTitle: 'The Title', + prBody: 'Hello world', + labels: ['deps', 'renovate'], + platformOptions: { usePlatformAutomerge: true }, + }; + + const mockScope = async (): Promise<httpMock.Scope> => { + const scope = httpMock.scope(githubApiHost); + initRepoMock(scope, 'some/repo'); + scope + .post('/repos/some/repo/pulls') + .reply(200, createdPrResp) + .post('/repos/some/repo/issues/123/labels') + .reply(200, []); + await github.initRepo({ + repository: 'some/repo', + token: 'token', + } as any); + return scope; + }; + + const graphqlGetRepo = { + method: 'POST', + url: 'https://api.github.com/graphql', + graphql: { query: { repository: {} } }, + }; + + const restCreatePr = { + method: 'POST', + url: 'https://api.github.com/repos/some/repo/pulls', + }; + + const restAddLabels = { + method: 'POST', + url: 'https://api.github.com/repos/some/repo/issues/123/labels', + }; + + const graphqlAutomerge = { + method: 'POST', + url: 'https://api.github.com/graphql', + graphql: { + mutation: { + __vars: { $pullRequestId: 'ID!' }, + enablePullRequestAutoMerge: {}, + }, + variables: { pullRequestId: 'abcd' }, + }, + }; + + let cache: Cache; + beforeEach(() => { + cache = {}; + repoCache.getCache.mockReturnValue(cache); + }); + + it('should set automatic merge', async () => { + const scope = await mockScope(); + scope.post('/graphql').reply(200, graphqlAutomergeResp); + + const pr = await github.createPr(prConfig); + + expect(pr).toMatchObject({ number: 123 }); + expect(httpMock.getTrace()).toMatchObject([ + graphqlGetRepo, + restCreatePr, + restAddLabels, + graphqlAutomerge, + ]); + }); + + it('should stop trying after GraphQL error', async () => { + const scope = await mockScope(); + scope + .post('/graphql') + .reply(200, graphqlAutomergeErrorResp) + .post('/repos/some/repo/pulls') + .reply(200, createdPrResp) + .post('/repos/some/repo/issues/123/labels') + .reply(200, []); + + await github.createPr(prConfig); + await github.createPr(prConfig); + + expect(httpMock.getTrace()).toMatchObject([ + graphqlGetRepo, + restCreatePr, + restAddLabels, + graphqlAutomerge, + restCreatePr, + restAddLabels, + ]); + }); + + it('should retry 24 hours after GraphQL error', async () => { + const scope = await mockScope(); + scope + .post('/graphql') + .reply(200, graphqlAutomergeErrorResp) + .post('/repos/some/repo/pulls') + .reply(200, createdPrResp) + .post('/repos/some/repo/issues/123/labels') + .reply(200, []) + .post('/repos/some/repo/pulls') + .reply(200, createdPrResp) + .post('/repos/some/repo/issues/123/labels') + .reply(200, []) + .post('/graphql') + .reply(200, graphqlAutomergeResp); + + // Error occured + const t1 = DateTime.local().toMillis(); + await github.createPr(prConfig); + const t2 = DateTime.local().toMillis(); + + expect(cache.lastPlatformAutomergeFailure).toBeString(); + + let failedAt = DateTime.fromISO(cache.lastPlatformAutomergeFailure); + + expect(failedAt.toMillis()).toBeGreaterThanOrEqual(t1); + expect(failedAt.toMillis()).toBeLessThanOrEqual(t2); + + // Too early + failedAt = failedAt.minus({ hours: 12 }); + cache.lastPlatformAutomergeFailure = failedAt.toISO(); + await github.createPr(prConfig); + expect(cache.lastPlatformAutomergeFailure).toEqual(failedAt.toISO()); + + // Now should retry + failedAt = failedAt.minus({ hours: 12 }); + cache.lastPlatformAutomergeFailure = failedAt.toISO(); + await github.createPr(prConfig); + + expect(httpMock.getTrace()).toMatchObject([ + // 1 + graphqlGetRepo, + restCreatePr, + restAddLabels, + graphqlAutomerge, // error + // 2 + restCreatePr, + restAddLabels, + // 3 + restCreatePr, + restAddLabels, + graphqlAutomerge, // retry + ]); + }); + + it('should keep trying after HTTP error', async () => { + const scope = await mockScope(); + scope + .post('/graphql') + .reply(500) + .post('/repos/some/repo/pulls') + .reply(200, createdPrResp) + .post('/repos/some/repo/issues/123/labels') + .reply(200, []) + .post('/graphql') + .reply(200, graphqlAutomergeResp); + + await github.createPr(prConfig); + await github.createPr(prConfig); + + expect(httpMock.getTrace()).toMatchObject([ + graphqlGetRepo, + restCreatePr, + restAddLabels, + graphqlAutomerge, + restCreatePr, + restAddLabels, + graphqlAutomerge, + ]); + }); + }); }); describe('getPr(prNo)', () => { it('should return null if no prNo is passed', async () => { diff --git a/lib/platform/github/index.ts b/lib/platform/github/index.ts index ef6810b089..aa7debfa87 100644 --- a/lib/platform/github/index.ts +++ b/lib/platform/github/index.ts @@ -19,6 +19,7 @@ import { import { logger } from '../../logger'; import { BranchStatus, PrState, VulnerabilityAlert } from '../../types'; import { ExternalHostError } from '../../types/errors/external-host-error'; +import { getCache } from '../../util/cache/repository'; import * as git from '../../util/git'; import * as hostRules from '../../util/host-rules'; import * as githubHttp from '../../util/http/github'; @@ -36,6 +37,7 @@ import type { Issue, MergePRConfig, PlatformParams, + PlatformPrOptions, PlatformResult, Pr, RepoParams, @@ -45,6 +47,7 @@ import type { import { smartTruncate } from '../utils/pr-body'; import { closedPrsQuery, + enableAutoMergeMutation, getIssuesQuery, openPrsQuery, repoInfoQuery, @@ -55,6 +58,7 @@ import { BranchProtection, CombinedBranchStatus, Comment, + GhAutomergeResponse, GhBranchStatus, GhGraphQlPr, GhRepo, @@ -1377,6 +1381,63 @@ export async function ensureCommentRemoval({ // Pull Request +async function tryPrAutomerge( + prNumber: number, + prNodeId: string, + platformOptions: PlatformPrOptions +): Promise<boolean> { + if (!platformOptions?.usePlatformAutomerge) { + return; + } + + const repoCache = getCache(); + const { lastPlatformAutomergeFailure } = repoCache; + if (lastPlatformAutomergeFailure) { + const lastFailedAt = DateTime.fromISO(lastPlatformAutomergeFailure); + const now = DateTime.local(); + if (now < lastFailedAt.plus({ hours: 24 })) { + logger.debug( + { prNumber }, + 'GitHub-native automerge: skipping attempt due to earlier failure' + ); + return; + } + delete repoCache.lastPlatformAutomergeFailure; + } + + try { + const variables = { pullRequestId: prNodeId }; + const queryOptions = { variables }; + const { errors } = await githubApi.requestGraphql<GhAutomergeResponse>( + enableAutoMergeMutation, + queryOptions + ); + if (errors) { + const disabledByPlatform = errors.find( + ({ type, message }) => + type === 'UNPROCESSABLE' && + message === + 'Pull request is not in the correct state to enable auto-merge' + ); + + // istanbul ignore else + if (disabledByPlatform) { + logger.debug( + { prNumber }, + 'GitHub automerge is not enabled for this repository' + ); + + const now = DateTime.local(); + repoCache.lastPlatformAutomergeFailure = now.toISO(); + } else { + logger.debug({ prNumber, errors }, 'GitHub automerge unknown error'); + } + } + } catch (err) { + logger.warn({ prNumber, err }, 'GitHub automerge: HTTP request error'); + } +} + // Creates PR and returns PR number export async function createPr({ sourceBranch, @@ -1385,6 +1446,7 @@ export async function createPr({ prBody: rawBody, labels, draftPR = false, + platformOptions, }: CreatePRConfig): Promise<Pr> { const body = sanitize(rawBody); const base = targetBranch; @@ -1423,6 +1485,7 @@ export async function createPr({ pr.sourceBranch = sourceBranch; pr.sourceRepo = pr.head.repo.full_name; await addLabels(pr.number, labels); + await tryPrAutomerge(pr.number, pr.node_id, platformOptions); return pr; } diff --git a/lib/platform/github/types.ts b/lib/platform/github/types.ts index 8b6736a10c..9c38ea8401 100644 --- a/lib/platform/github/types.ts +++ b/lib/platform/github/types.ts @@ -39,6 +39,7 @@ export interface GhRestPr extends GhPr { created_at: string; closed_at: string; user?: { login?: string }; + node_id: string; } export interface GhGraphQlPr extends GhPr { @@ -93,3 +94,9 @@ export interface GhRepo { }; }; } + +export interface GhAutomergeResponse { + enablePullRequestAutoMerge: { + pullRequest: { number: number }; + }; +} diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index bca06bd77c..d3e7d073ba 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -39,4 +39,5 @@ export interface Cache { revision?: number; init?: RepoInitConfig; scan?: Record<string, BaseBranchCache>; + lastPlatformAutomergeFailure?: string; } diff --git a/lib/util/http/github.ts b/lib/util/http/github.ts index bc578f223f..ec267a4f5a 100644 --- a/lib/util/http/github.ts +++ b/lib/util/http/github.ts @@ -36,7 +36,11 @@ interface GithubGraphqlRepoData<T = unknown> { interface GithubGraphqlResponse<T = unknown> { data?: T; - errors?: { message: string; locations: unknown }[]; + errors?: { + type?: string; + message: string; + locations: unknown; + }[]; } function handleGotError( -- GitLab