Repository: electron/trop
Pull request #336: feat: enforce backport approval with checks
Author: login: dsanders11
Created: 2025-05-02T01:23:12Z | Merged: 2025-05-02T04:48:47Z

There's currently nothing blocking PRs with `backport/requested 🗳` from being merged before they've been formally approved. This adds a check which can be set as a required check for merging on `e/e`, same as how trop's "Valid Backport" check is a required check.

The check is dead simple, it's marked as queued when the `backport/requested 🗳` label is present (so that it can block merging), otherwise it's marked as success (if no `backport/*` labels are present or `backport/approved ✅` is present).


Diff:
diff --git a/spec/fixtures/backport_pull_request.labeled.json b/spec/fixtures/backport_pull_request.labeled.json
new file mode 100644
index 0000000..9b0484d
--- /dev/null
+++ b/spec/fixtures/backport_pull_request.labeled.json
@@ -0,0 +1,43 @@
+{
+  "name": "pull_request",
+  "payload": {
+    "action": "labeled",
+    "number": 7,
+    "pull_request": {
+      "number": 7,
+      "url": "my_cool_url",
+      "title": "CHANGE README",
+      "merged": true,
+      "user": {
+        "login": "codebytere"
+      },
+      "body": "Backport of #12345",
+      "labels": [{
+        "name": "todo",
+        "color": "8cb728"
+      }],
+      "head": {
+        "sha": "ABC"
+      },
+      "base": {
+        "ref": "main",
+        "repo": {
+          "default_branch": "main"
+        }
+      }
+    },
+    "label": {
+      "name": "todo",
+      "color": "8cb728"
+    },
+    "repository": {
+      "name": "probot-test",
+      "owner": {
+        "login": "codebytere"
+      }
+    },
+    "installation": {
+      "id": 103619
+    }
+  }
+}
diff --git a/spec/fixtures/backport_pull_request.unlabeled.json b/spec/fixtures/backport_pull_request.unlabeled.json
new file mode 100644
index 0000000..1576842
--- /dev/null
+++ b/spec/fixtures/backport_pull_request.unlabeled.json
@@ -0,0 +1,43 @@
+{
+  "name": "pull_request",
+  "payload": {
+    "action": "unlabeled",
+    "number": 7,
+    "pull_request": {
+      "number": 7,
+      "url": "my_cool_url",
+      "title": "CHANGE README",
+      "merged": true,
+      "user": {
+        "login": "codebytere"
+      },
+      "body": "Backport of #12345",
+      "labels": [{
+        "name": "todo",
+        "color": "8cb728"
+      }],
+      "head": {
+        "sha": "ABC"
+      },
+      "base": {
+        "ref": "main",
+        "repo": {
+          "default_branch": "main"
+        }
+      }
+    },
+    "label": {
+      "name": "todo",
+      "color": "8cb728"
+    },
+    "repository": {
+      "name": "probot-test",
+      "owner": {
+        "login": "codebytere"
+      }
+    },
+    "installation": {
+      "id": 103619
+    }
+  }
+}
diff --git a/spec/index.spec.ts b/spec/index.spec.ts
index cc5369e..8be590e 100644
--- a/spec/index.spec.ts
+++ b/spec/index.spec.ts
@@ -6,7 +6,11 @@ import nock from 'nock';
 import { Probot, ProbotOctokit } from 'probot';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 
-import { SKIP_CHECK_LABEL } from '../src/constants';
+import {
+  BACKPORT_APPROVED_LABEL,
+  BACKPORT_REQUESTED_LABEL,
+  SKIP_CHECK_LABEL,
+} from '../src/constants';
 import { CheckRunStatus, PRChange } from '../src/enums';
 import { default as trop } from '../src/index';
 import {
@@ -43,6 +47,18 @@ const newPRBackportOpenedEventPath = path.join(
   'backport_pull_request.opened.json',
 );
 
+const backportPRLabeledEventPath = path.join(
+  __dirname,
+  'fixtures',
+  'backport_pull_request.labeled.json',
+);
+
+const backportPRUnlabeledEventPath = path.join(
+  __dirname,
+  'fixtures',
+  'backport_pull_request.unlabeled.json',
+);
+
 const noBackportLabel = {
   name: 'no-backport',
   color: '000',
@@ -53,6 +69,16 @@ const targetLabel = {
   color: 'fff',
 };
 
+const backportApprovedLabel = {
+  name: BACKPORT_APPROVED_LABEL,
+  color: 'fff',
+};
+
+const backportRequestedLabel = {
+  name: BACKPORT_REQUESTED_LABEL,
+  color: 'fff',
+};
+
 vi.mock('../src/utils', () => ({
   labelClosedPR: vi.fn(),
   isAuthorizedUser: vi.fn().mockResolvedValue([true]),
@@ -77,6 +103,9 @@ vi.mock('../src/utils/checks-util', () => ({
   getBackportInformationCheck: vi.fn().mockResolvedValue({ status: 'thing' }),
   updateBackportInformationCheck: vi.fn().mockResolvedValue(undefined),
   queueBackportInformationCheck: vi.fn().mockResolvedValue(undefined),
+  getBackportApprovalCheck: vi.fn().mockResolvedValue(undefined),
+  updateBackportApprovalCheck: vi.fn().mockResolvedValue(undefined),
+  queueBackportApprovalCheck: vi.fn().mockResolvedValue(undefined),
 }));
 
 describe('trop', () => {
@@ -292,6 +321,113 @@ describe('trop', () => {
         conclusion: CheckRunStatus.SUCCESS,
       });
     });
+
+    it('passes the backport approval check if there is no "backport/requested" label in a new PR', async () => {
+      const event = JSON.parse(
+        await fs.readFile(newPROpenedEventPath, 'utf-8'),
+      );
+
+      event.payload.pull_request.labels = [];
+
+      await robot.receive(event);
+
+      const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck)
+        .mock.calls[0][2];
+
+      expect(updatePayload).toMatchObject({
+        title: 'Backport Approval Not Required',
+        summary: 'This PR does not need backport approval.',
+        conclusion: CheckRunStatus.SUCCESS,
+      });
+    });
+
+    it('queues the backport approval check if there is the "backport/requested" label in a new PR', async () => {
+      const event = JSON.parse(
+        await fs.readFile(newPROpenedEventPath, 'utf-8'),
+      );
+
+      event.payload.pull_request.labels = [backportRequestedLabel];
+
+      await robot.receive(event);
+
+      expect(checkUtils.queueBackportApprovalCheck).toHaveBeenCalledTimes(1);
+    });
+
+    it('passes the backport approval check if there is the "backport/approved" label in a new PR', async () => {
+      const event = JSON.parse(
+        await fs.readFile(newPROpenedEventPath, 'utf-8'),
+      );
+
+      event.payload.pull_request.labels = [backportApprovedLabel];
+
+      await robot.receive(event);
+
+      const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck)
+        .mock.calls[0][2];
+
+      expect(updatePayload).toMatchObject({
+        title: 'Backport Approved',
+        summary: 'This PR has been approved for backporting.',
+        conclusion: CheckRunStatus.SUCCESS,
+      });
+    });
+  });
+
+  describe('pull_request.labeled event', () => {
+    it('queues the backport approval check if the "backport/requested" label is added', async () => {
+      const event = JSON.parse(
+        await fs.readFile(backportPRLabeledEventPath, 'utf-8'),
+      );
+
+      event.payload.label = backportApprovedLabel;
+      event.payload.pull_request.labels = [backportRequestedLabel];
+
+      await robot.receive(event);
+
+      expect(checkUtils.queueBackportApprovalCheck).toHaveBeenCalledTimes(1);
+    });
+
+    it('passes the backport approval check if the "backport/approved" label is added', async () => {
+      const event = JSON.parse(
+        await fs.readFile(backportPRLabeledEventPath, 'utf-8'),
+      );
+
+      event.payload.label = backportApprovedLabel;
+      event.payload.pull_request.labels = [backportApprovedLabel];
+
+      await robot.receive(event);
+
+      const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck)
+        .mock.calls[0][2];
+
+      expect(updatePayload).toMatchObject({
+        title: 'Backport Approved',
+        summary: 'This PR has been approved for backporting.',
+        conclusion: CheckRunStatus.SUCCESS,
+      });
+    });
+  });
+
+  describe('pull_request.unlabeled event', () => {
+    it('passes the backport approval check if all "backport/*" labels are removed', async () => {
+      const event = JSON.parse(
+        await fs.readFile(backportPRUnlabeledEventPath, 'utf-8'),
+      );
+
+      event.payload.label = backportRequestedLabel;
+      event.payload.pull_request.labels = [];
+
+      await robot.receive(event);
+
+      const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck)
+        .mock.calls[0][2];
+
+      expect(updatePayload).toMatchObject({
+        title: 'Backport Approval Not Required',
+        summary: 'This PR does not need backport approval.',
+        conclusion: CheckRunStatus.SUCCESS,
+      });
+    });
   });
 
   describe('pull_request.closed event', () => {
diff --git a/src/constants.ts b/src/constants.ts
index c4fd313..fa51758 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,5 +1,7 @@
 export const CHECK_PREFIX = 'Backportable? - ';
 
+export const BACKPORT_APPROVAL_CHECK = 'Backport Approval Enforcement';
+
 export const BACKPORT_INFORMATION_CHECK = 'Backport Labels Added';
 
 export const NUM_SUPPORTED_VERSIONS = parseInt(
@@ -22,6 +24,9 @@ export const SEMVER_LABELS = {
 export const SKIP_CHECK_LABEL =
   process.env.SKIP_CHECK_LABEL || 'backport-check-skip';
 
+export const BACKPORT_APPROVED_LABEL =
+  process.env.BACKPORT_APPROVED_LABEL || 'backport/approved ✅';
+
 export const BACKPORT_REQUESTED_LABEL =
   process.env.BACKPORT_REQUESTED_LABEL || 'backport/requested 🗳';
 
diff --git a/src/index.ts b/src/index.ts
index 11b8323..3e74fc7 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,8 @@ import {
   removeLabel,
 } from './utils/label-utils';
 import {
+  BACKPORT_APPROVED_LABEL,
+  BACKPORT_REQUESTED_LABEL,
   CHECK_PREFIX,
   NO_BACKPORT_LABEL,
   SKIP_CHECK_LABEL,
@@ -27,8 +29,11 @@ import {
 import { updateManualBackport } from './operations/update-manual-backport';
 import { getSupportedBranches, isBranchSupported } from './utils/branch-util';
 import {
+  getBackportApprovalCheck,
   getBackportInformationCheck,
+  queueBackportApprovalCheck,
   queueBackportInformationCheck,
+  updateBackportApprovalCheck,
   updateBackportInformationCheck,
   updateBackportValidityCheck,
 } from './utils/checks-util';
@@ -383,12 +388,39 @@ const probotHandler: ApplicationFunction = async (robot, { getRouter }) => {
       }
 
       let backportCheck = await getBackportInformationCheck(context);
+      let backportApprovalCheck = await getBackportApprovalCheck(context);
 
       if (!backportCheck) {
         await queueBackportInformationCheck(context);
         backportCheck = (await getBackportInformationCheck(context))!;
       }
 
+      if (!backportApprovalCheck) {
+        await queueBackportApprovalCheck(context);
+        backportApprovalCheck = (await getBackportApprovalCheck(context))!;
+      }
+
+      const isBackportApproved = pr.labels.some(
+        (prLabel) => prLabel.name === BACKPORT_APPROVED_LABEL,
+      );
+      const isBackportRequested = pr.labels.some(
+        (prLabel) => prLabel.name === BACKPORT_REQUESTED_LABEL,
+      );
+
+      if (isBackportApproved) {
+        await updateBackportApprovalCheck(context, backportApprovalCheck, {
+          title: 'Backport Approved',
+          summary: 'This PR has been approved for backporting.',
+          conclusion: CheckRunStatus.SUCCESS,
+        });
+      } else if (!isBackportRequested) {
+        await updateBackportApprovalCheck(context, backportCheck, {
+          title: 'Backport Approval Not Required',
+          summary: 'This PR does not need backport approval.',
+          conclusion: CheckRunStatus.SUCCESS,
+        });
+      }
+
       const isNoBackport = pr.labels.some(
         (prLabel) => prLabel.name === NO_BACKPORT_LABEL,
       );
diff --git a/src/utils/checks-util.ts b/src/utils/checks-util.ts
index 426f820..35910d1 100644
--- a/src/utils/checks-util.ts
+++ b/src/utils/checks-util.ts
@@ -1,5 +1,9 @@
 import { CheckRunStatus, LogLevel } from '../enums';
-import { BACKPORT_INFORMATION_CHECK, CHECK_PREFIX } from '../constants';
+import {
+  BACKPORT_APPROVAL_CHECK,
+  BACKPORT_INFORMATION_CHECK,
+  CHECK_PREFIX,
+} from '../constants';
 import {
   SimpleWebHookRepoContext,
   WebHookPR,
@@ -94,6 +98,63 @@ export async function queueBackportInformationCheck(context: WebHookPRContext) {
   );
 }
 
+export async function getBackportApprovalCheck(context: WebHookPRContext) {
+  const pr = context.payload.pull_request;
+  const allChecks = await context.octokit.checks.listForRef(
+    context.repo({
+      ref: pr.head.sha,
+      per_page: 100,
+    }),
+  );
+
+  const backportCheck = allChecks.data.check_runs.filter((run) =>
+    run.name.startsWith(BACKPORT_APPROVAL_CHECK),
+  );
+
+  return backportCheck.length > 0 ? backportCheck[0] : null;
+}
+
+export async function updateBackportApprovalCheck(
+  context: WebHookPRContext,
+  backportCheck: BackportCheck,
+  statusItems: {
+    conclusion: CheckRunStatus;
+    title: string;
+    summary: string;
+  },
+) {
+  await context.octokit.checks.update(
+    context.repo({
+      check_run_id: backportCheck.id,
+      name: backportCheck.name,
+      conclusion: statusItems.conclusion as CheckRunStatus,
+      completed_at: new Date().toISOString(),
+      details_url: 'https://github.com/electron/trop',
+      output: {
+        title: statusItems.title,
+        summary: statusItems.summary,
+      },
+    }),
+  );
+}
+
+export async function queueBackportApprovalCheck(context: WebHookPRContext) {
+  const pr = context.payload.pull_request;
+
+  await context.octokit.checks.create(
+    context.repo({
+      name: BACKPORT_APPROVAL_CHECK,
+      head_sha: pr.head.sha,
+      status: 'queued',
+      details_url: 'https://github.com/electron/trop',
+      output: {
+        title: 'Needs Backport Approval',
+        summary: 'This PR requires backport approval.',
+      },
+    }),
+  );
+}
+
 export async function getOrCreateCheckRun(
   context: SimpleWebHookRepoContext,
   pr: WebHookPR,


Reviews:
user: login: VerteDinde
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T04:48:22Z
user: login: codebytere
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-04T05:58:00Z
user: login: dsanders11
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-05T00:51:02Z

Review comments:
diff_hunk: @@ -383,12 +388,39 @@ const probotHandler: ApplicationFunction = async (robot, { getRouter }) => {
       }
 
       let backportCheck = await getBackportInformationCheck(context);
+      let backportApprovalCheck = await getBackportApprovalCheck(context);
 
       if (!backportCheck) {
         await queueBackportInformationCheck(context);
         backportCheck = (await getBackportInformationCheck(context))!;
       }
 
+      if (!backportApprovalCheck) {
+        await queueBackportApprovalCheck(context);
path: src/index.ts
user: login: codebytere
body: @dsanders11 I don't think we should add a check to every single PR, at least not the ones targeting main. Can we scope it to PRs to release branches?
created_at: 2025-05-04T05:57:56Z
author_association: MEMBER
diff_hunk: @@ -383,12 +388,39 @@ const probotHandler: ApplicationFunction = async (robot, { getRouter }) => {
       }
 
       let backportCheck = await getBackportInformationCheck(context);
+      let backportApprovalCheck = await getBackportApprovalCheck(context);
 
       if (!backportCheck) {
         await queueBackportInformationCheck(context);
         backportCheck = (await getBackportInformationCheck(context))!;
       }
 
+      if (!backportApprovalCheck) {
+        await queueBackportApprovalCheck(context);
path: src/index.ts
user: login: dsanders11
body: @codebytere, I think it needs to be on every PR so that we can make it a required check? Otherwise would the check being missing block merging PRs without it?
created_at: 2025-05-05T00:51:02Z
author_association: MEMBER

PR comments:
user: login: ckerr
created_at: 2025-05-03T23:48:39Z
body: @dsanders11 this change seems to be requiring backport approval for _any_ backport, not just ones that have `backport/requested`.

E.g. https://github.com/electron/electron/pull/46919 is a backwards-compatible code cleanup that doesn't have any user-facing changes and its labels are `semver/patch` + `target/37-x-y` + `target/36-x-y`. Trop isn't going green on this PR, saying "This PR requires backport approval."

Is this intentional behavior?
author_association: MEMBER
user: login: dsanders11
created_at: 2025-05-03T23:52:55Z
body: @ckerr, the implementation in this PR was incorrect and masked by our test fixtures being incorrect. The test issue is fixed, but I won't be able to open a fix for this implementation until Monday. If it's blocking stuff on `e/e` we can revert this PR.
author_association: MEMBER
user: login: ckerr
created_at: 2025-05-04T00:41:59Z
body: @dsanders11 thanks for the reply, I didn't expect a weekend response! No, this isn't blocking anything urgent. Have a great weekend!
author_association: MEMBER