From ebf0c7b4d9d47a2e256ab0cef48cf000ff55042e Mon Sep 17 00:00:00 2001
From: David Straub <Scinvention@gmail.com>
Date: Mon, 18 Mar 2024 15:53:25 -0400
Subject: [PATCH] feat(platform): re-attempt platform automerge on github and
 gitlab  (#26567)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 docs/usage/configuration-options.md           |   3 +-
 lib/modules/platform/github/index.spec.ts     | 129 +++++++++++++++++-
 lib/modules/platform/github/index.ts          |  17 +++
 lib/modules/platform/gitlab/index.spec.ts     |  32 +++++
 lib/modules/platform/gitlab/index.ts          |   8 ++
 lib/modules/platform/types.ts                 |   7 +
 .../repository/update/branch/index.spec.ts    |   4 +
 lib/workers/repository/update/branch/index.ts |  21 ++-
 8 files changed, 214 insertions(+), 7 deletions(-)

diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md
index 4d166ae3ea..d289ca7702 100644
--- a/docs/usage/configuration-options.md
+++ b/docs/usage/configuration-options.md
@@ -3150,8 +3150,7 @@ If enabled Renovate will pin Docker images or GitHub Actions by means of their S
 
 If you have enabled `automerge` and set `automergeType=pr` in the Renovate config, then leaving `platformAutomerge` as `true` speeds 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.
+On GitHub and GitLab, Renovate re-enables the PR for platform-native automerge whenever it's rebased.
 
 `platformAutomerge` will configure PRs to be merged after all (if any) branch policies have been met.
 This option is available for Azure, Gitea, GitHub and GitLab.
diff --git a/lib/modules/platform/github/index.spec.ts b/lib/modules/platform/github/index.spec.ts
index d0a33bb4ea..995b2091f1 100644
--- a/lib/modules/platform/github/index.spec.ts
+++ b/lib/modules/platform/github/index.spec.ts
@@ -19,7 +19,12 @@ import * as _hostRules from '../../../util/host-rules';
 import { setBaseUrl } from '../../../util/http/github';
 import { toBase64 } from '../../../util/string';
 import { hashBody } from '../pr-body';
-import type { CreatePRConfig, RepoParams, UpdatePrConfig } from '../types';
+import type {
+  CreatePRConfig,
+  ReattemptPlatformAutomergeConfig,
+  RepoParams,
+  UpdatePrConfig,
+} from '../types';
 import * as branch from './branch';
 import type { ApiPageCache, GhRestPr } from './types';
 import * as github from '.';
@@ -3330,6 +3335,128 @@ describe('modules/platform/github/index', () => {
     });
   });
 
+  describe('reattemptPlatformAutomerge(number, platformOptions)', () => {
+    const getPrListResp = [
+      {
+        number: 1234,
+        base: { sha: '1234' },
+        head: { ref: 'somebranch', repo: { full_name: 'some/repo' } },
+        state: 'open',
+        title: 'Some PR',
+      },
+    ];
+    const getPrResp = {
+      number: 123,
+      node_id: 'abcd',
+      head: { repo: { full_name: 'some/repo' } },
+    };
+
+    const graphqlAutomergeResp = {
+      data: {
+        enablePullRequestAutoMerge: {
+          pullRequest: {
+            number: 123,
+          },
+        },
+      },
+    };
+
+    const pr: ReattemptPlatformAutomergeConfig = {
+      number: 123,
+      platformOptions: { usePlatformAutomerge: true },
+    };
+
+    const mockScope = async (repoOpts: any = {}): Promise<httpMock.Scope> => {
+      const scope = httpMock.scope(githubApiHost);
+      initRepoMock(scope, 'some/repo', repoOpts);
+      scope
+        .get(
+          '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
+        )
+        .reply(200, getPrListResp);
+      scope.get('/repos/some/repo/pulls/123').reply(200, getPrResp);
+      await github.initRepo({ repository: 'some/repo' });
+      return scope;
+    };
+
+    const graphqlGetRepo = {
+      method: 'POST',
+      url: 'https://api.github.com/graphql',
+      graphql: { query: { repository: {} } },
+    };
+
+    const restGetPrList = {
+      method: 'GET',
+      url: 'https://api.github.com/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
+    };
+
+    const restGetPr = {
+      method: 'GET',
+      url: 'https://api.github.com/repos/some/repo/pulls/123',
+    };
+
+    const graphqlAutomerge = {
+      method: 'POST',
+      url: 'https://api.github.com/graphql',
+      graphql: {
+        mutation: {
+          __vars: {
+            $pullRequestId: 'ID!',
+            $mergeMethod: 'PullRequestMergeMethod!',
+          },
+          enablePullRequestAutoMerge: {
+            __args: {
+              input: {
+                pullRequestId: '$pullRequestId',
+                mergeMethod: '$mergeMethod',
+              },
+            },
+          },
+        },
+        variables: {
+          pullRequestId: 'abcd',
+          mergeMethod: 'REBASE',
+        },
+      },
+    };
+
+    it('should set automatic merge', async () => {
+      const scope = await mockScope();
+      scope.post('/graphql').reply(200, graphqlAutomergeResp);
+
+      await expect(github.reattemptPlatformAutomerge(pr)).toResolve();
+
+      expect(logger.logger.debug).toHaveBeenLastCalledWith(
+        'PR platform automerge re-attempted...prNo: 123',
+      );
+
+      expect(httpMock.getTrace()).toMatchObject([
+        graphqlGetRepo,
+        restGetPrList,
+        restGetPr,
+        graphqlAutomerge,
+      ]);
+    });
+
+    it('handles unknown error', async () => {
+      const scope = httpMock.scope(githubApiHost);
+      initRepoMock(scope, 'some/repo');
+      await github.initRepo({ repository: 'some/repo' });
+      scope
+        .get(
+          '/repos/some/repo/pulls?per_page=100&state=all&sort=updated&direction=desc&page=1',
+        )
+        .replyWithError('unknown error');
+
+      await expect(github.reattemptPlatformAutomerge(pr)).toResolve();
+
+      expect(logger.logger.warn).toHaveBeenCalledWith(
+        { err: new Error('external-host-error') },
+        'Error re-attempting PR platform automerge',
+      );
+    });
+  });
+
   describe('mergePr(prNo)', () => {
     it('should merge the PR', async () => {
       const scope = httpMock.scope(githubApiHost);
diff --git a/lib/modules/platform/github/index.ts b/lib/modules/platform/github/index.ts
index e629dd6e6e..def3a90e63 100644
--- a/lib/modules/platform/github/index.ts
+++ b/lib/modules/platform/github/index.ts
@@ -60,6 +60,7 @@ import type {
   PlatformParams,
   PlatformPrOptions,
   PlatformResult,
+  ReattemptPlatformAutomergeConfig,
   RepoParams,
   RepoResult,
   UpdatePrConfig,
@@ -1794,6 +1795,22 @@ export async function updatePr({
   }
 }
 
+export async function reattemptPlatformAutomerge({
+  number,
+  platformOptions,
+}: ReattemptPlatformAutomergeConfig): Promise<void> {
+  try {
+    const result = (await getPr(number))!;
+    const { node_id } = result;
+
+    await tryPrAutomerge(number, node_id, platformOptions);
+
+    logger.debug(`PR platform automerge re-attempted...prNo: ${number}`);
+  } catch (err) {
+    logger.warn({ err }, 'Error re-attempting PR platform automerge');
+  }
+}
+
 export async function mergePr({
   branchName,
   id: prNo,
diff --git a/lib/modules/platform/gitlab/index.spec.ts b/lib/modules/platform/gitlab/index.spec.ts
index 676df2e47e..1cf1984c01 100644
--- a/lib/modules/platform/gitlab/index.spec.ts
+++ b/lib/modules/platform/gitlab/index.spec.ts
@@ -2876,6 +2876,38 @@ describe('modules/platform/gitlab/index', () => {
     });
   });
 
+  describe('reattemptPlatformAutomerge(number, platformOptions)', () => {
+    const pr = {
+      number: 12345,
+      platformOptions: {
+        usePlatformAutomerge: true,
+      },
+    };
+
+    it('should set automatic merge', async () => {
+      await initPlatform('13.3.6-ee');
+      httpMock
+        .scope(gitlabApiHost)
+        .get('/api/v4/projects/undefined/merge_requests/12345')
+        .reply(200)
+        .get('/api/v4/projects/undefined/merge_requests/12345')
+        .reply(200, {
+          merge_status: 'can_be_merged',
+          pipeline: {
+            status: 'running',
+          },
+        })
+        .put('/api/v4/projects/undefined/merge_requests/12345/merge')
+        .reply(200);
+
+      await expect(gitlab.reattemptPlatformAutomerge?.(pr)).toResolve();
+
+      expect(logger.debug).toHaveBeenLastCalledWith(
+        'PR platform automerge re-attempted...prNo: 12345',
+      );
+    });
+  });
+
   describe('mergePr(pr)', () => {
     it('merges the PR', async () => {
       httpMock
diff --git a/lib/modules/platform/gitlab/index.ts b/lib/modules/platform/gitlab/index.ts
index a84b83868c..263c51c25b 100644
--- a/lib/modules/platform/gitlab/index.ts
+++ b/lib/modules/platform/gitlab/index.ts
@@ -48,6 +48,7 @@ import type {
   PlatformPrOptions,
   PlatformResult,
   Pr,
+  ReattemptPlatformAutomergeConfig,
   RepoParams,
   RepoResult,
   UpdatePrConfig,
@@ -848,8 +849,15 @@ export async function updatePr({
   if (platformOptions?.autoApprove) {
     await approvePr(iid);
   }
+}
 
+export async function reattemptPlatformAutomerge({
+  number: iid,
+  platformOptions,
+}: ReattemptPlatformAutomergeConfig): Promise<void> {
   await tryPrAutomerge(iid, platformOptions);
+
+  logger.debug(`PR platform automerge re-attempted...prNo: ${iid}`);
 }
 
 export async function mergePr({ id }: MergePRConfig): Promise<boolean> {
diff --git a/lib/modules/platform/types.ts b/lib/modules/platform/types.ts
index b6f75f2a58..156dc63015 100644
--- a/lib/modules/platform/types.ts
+++ b/lib/modules/platform/types.ts
@@ -123,6 +123,10 @@ export interface UpdatePrConfig {
   state?: 'open' | 'closed';
   targetBranch?: string;
 }
+export interface ReattemptPlatformAutomergeConfig {
+  number: number;
+  platformOptions?: PlatformPrOptions;
+}
 export interface EnsureIssueConfig {
   title: string;
   reuseTitle?: string;
@@ -226,6 +230,9 @@ export interface Platform {
   getPr(number: number): Promise<Pr | null>;
   findPr(findPRConfig: FindPRConfig): Promise<Pr | null>;
   refreshPr?(number: number): Promise<void>;
+  reattemptPlatformAutomerge?(
+    prConfig: ReattemptPlatformAutomergeConfig,
+  ): Promise<void>;
   getBranchStatus(
     branchName: string,
     internalChecksAsSuccess: boolean,
diff --git a/lib/workers/repository/update/branch/index.spec.ts b/lib/workers/repository/update/branch/index.spec.ts
index 25e4511fa8..e5f17516c6 100644
--- a/lib/workers/repository/update/branch/index.spec.ts
+++ b/lib/workers/repository/update/branch/index.spec.ts
@@ -108,6 +108,7 @@ describe('workers/repository/update/branch/index', () => {
     beforeEach(() => {
       scm.branchExists.mockResolvedValue(false);
       prWorker.ensurePr = jest.fn();
+      prWorker.getPlatformPrOptions = jest.fn();
       prAutomerge.checkAutoMerge = jest.fn();
       // TODO: incompatible types (#22198)
       config = {
@@ -133,6 +134,9 @@ describe('workers/repository/update/branch/index', () => {
           state: '',
         }),
       });
+      prWorker.getPlatformPrOptions.mockReturnValue({
+        usePlatformAutomerge: true,
+      });
       GlobalConfig.set(adminConfig);
       // TODO: fix types, jest is using wrong overload (#22198)
       sanitize.sanitize.mockImplementation((input) => input!);
diff --git a/lib/workers/repository/update/branch/index.ts b/lib/workers/repository/update/branch/index.ts
index 6e23c8f6c7..762d55622c 100644
--- a/lib/workers/repository/update/branch/index.ts
+++ b/lib/workers/repository/update/branch/index.ts
@@ -36,7 +36,7 @@ import * as template from '../../../../util/template';
 import { isLimitReached } from '../../../global/limits';
 import type { BranchConfig, BranchResult, PrBlockedBy } from '../../../types';
 import { embedChangelogs } from '../../changelog';
-import { ensurePr } from '../pr';
+import { ensurePr, getPlatformPrOptions } from '../pr';
 import { checkAutoMerge } from '../pr/automerge';
 import { setArtifactErrorStatus } from './artifacts';
 import { tryBranchAutomerge } from './automerge';
@@ -572,9 +572,22 @@ export async function processBranch(
       await scm.checkoutBranch(config.baseBranch);
       updatesVerified = true;
     }
-    // istanbul ignore if
-    if (branchPr && platform.refreshPr) {
-      await platform.refreshPr(branchPr.number);
+
+    if (branchPr) {
+      const platformOptions = getPlatformPrOptions(config);
+      if (
+        platformOptions.usePlatformAutomerge &&
+        platform.reattemptPlatformAutomerge
+      ) {
+        await platform.reattemptPlatformAutomerge({
+          number: branchPr.number,
+          platformOptions,
+        });
+      }
+      // istanbul ignore if
+      if (platform.refreshPr) {
+        await platform.refreshPr(branchPr.number);
+      }
     }
     if (!commitSha && !branchExists) {
       return {
-- 
GitLab