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