From 82eeb9d56ab5f55733c662b591c399d06d628bda Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Thu, 16 Jul 2026 12:36:15 -0700 Subject: [PATCH 01/11] Feat: Add merge_group.checks_requested support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run the DCO status check when commits are added to a merge queue. Fetches the original PR using the PR number parsed from the merge group's head_ref, runs DCO validation against the PR's commits, and reports the result on the merge group's head_sha. The merge_group.head_ref fixture uses the real GitHub payload format (no refs/heads/ prefix). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- app.yml | 1 + index.js | 36 ++++++-- .../merge_group.checks_requested.json | 62 +++++++++++++ test/index.test.js | 92 +++++++++++++++++++ 4 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/merge_group.checks_requested.json diff --git a/app.yml b/app.yml index a3e9d91..65bdc34 100644 --- a/app.yml +++ b/app.yml @@ -43,6 +43,7 @@ default_events: - pull_request_review - pull_request_review_comment - push + - merge_group # - release # - repository # - repository_import diff --git a/index.js b/index.js index f3754a4..f94a9f5 100644 --- a/index.js +++ b/index.js @@ -17,8 +17,14 @@ module.exports = (app) => { check ); - async function check(context, pr = context.payload.pull_request) { + async function check( + context, + pr = context.payload.pull_request, + { reportSha, reportRef } = {} + ) { const timeStart = new Date(); + const sha = reportSha || pr.head.sha; + const ref = reportRef || pr.head.ref; const config = await context.config("dco.yml", { require: { @@ -52,8 +58,8 @@ module.exports = (app) => { .create( context.repo({ name: "DCO", - head_branch: pr.head.ref, - head_sha: pr.head.sha, + head_branch: ref, + head_sha: sha, status: "completed", started_at: timeStart, conclusion: "success", @@ -71,7 +77,7 @@ module.exports = (app) => { context.log.info("resource not accessible, creating status instead"); // create status const params = { - sha: pr.head.sha, + sha, context: "DCO", state: "success", description: "All commits are signed off!", @@ -100,8 +106,8 @@ module.exports = (app) => { .create( context.repo({ name: "DCO", - head_branch: pr.head.ref, - head_sha: pr.head.sha, + head_branch: ref, + head_sha: sha, status: "completed", started_at: timeStart, conclusion: "action_required", @@ -130,7 +136,7 @@ module.exports = (app) => { 140 ); const params = { - sha: pr.head.sha, + sha, context: "DCO", state: "failure", description, @@ -143,6 +149,22 @@ module.exports = (app) => { } } + app.on("merge_group.checks_requested", async (context) => { + const mergeGroup = context.payload.merge_group; + const match = mergeGroup.head_ref.match(/\/pr-(\d+)-/); + if (!match) return; + const prNumber = parseInt(match[1], 10); + + const { data: pr } = await context.octokit.rest.pulls.get( + context.repo({ pull_number: prNumber }) + ); + + await check(context, pr, { + reportSha: mergeGroup.head_sha, + reportRef: mergeGroup.head_ref, + }); + }); + function isRecheckCommand(body) { if (!body) return false; return body diff --git a/test/fixtures/merge_group.checks_requested.json b/test/fixtures/merge_group.checks_requested.json new file mode 100644 index 0000000..15ae7ab --- /dev/null +++ b/test/fixtures/merge_group.checks_requested.json @@ -0,0 +1,62 @@ +{ + "action": "checks_requested", + "merge_group": { + "head_sha": "abc123def456abc123def456abc123def456abc1", + "head_ref": "gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + "base_sha": "607c64cd8e37eb2db939f99a17bee5c7d1a90a31", + "base_ref": "refs/heads/master", + "head_commit": { + "id": "abc123def456abc123def456abc123def456abc1", + "tree_id": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "message": "Merge pull request #113 into master", + "timestamp": "2017-09-22T23:21:03Z", + "author": { + "name": "GitHub", + "email": "noreply@github.com" + }, + "committer": { + "name": "GitHub", + "email": "noreply@github.com" + } + } + }, + "repository": { + "id": 68474533, + "name": "test", + "full_name": "robotland/test", + "owner": { + "login": "robotland", + "id": 11724939, + "avatar_url": "https://avatars2.githubusercontent.com/u/11724939?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/robotland", + "html_url": "https://github.com/robotland", + "type": "Organization", + "site_admin": false + }, + "private": false, + "html_url": "https://github.com/robotland/test", + "description": "a trainable robot that responds to activity on GitHub", + "fork": false, + "url": "https://api.github.com/repos/robotland/test", + "default_branch": "master" + }, + "organization": { + "login": "robotland", + "id": 11724939, + "url": "https://api.github.com/orgs/robotland" + }, + "sender": { + "login": "bkeepers", + "id": 173, + "avatar_url": "https://avatars0.githubusercontent.com/u/173?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/bkeepers", + "html_url": "https://github.com/bkeepers", + "type": "User", + "site_admin": true + }, + "installation": { + "id": 13055 + } +} diff --git a/test/index.test.js b/test/index.test.js index 8f7bf5f..1bd72f1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -9,6 +9,7 @@ const payload = require("./fixtures/pull_request.opened"); const payloadSuccess = require("./fixtures/pull_request.opened-success"); const pullRequestReviewPayload = require("./fixtures/pull_request_review.submitted"); const pullRequestReviewCommentPayload = require("./fixtures/pull_request_review_comment.created"); +const mergeGroupPayload = require("./fixtures/merge_group.checks_requested"); const compare = require("./fixtures/compare"); const compareSuccess = require("./fixtures/compare-success"); @@ -728,4 +729,95 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); }); + + describe("merge_group event", () => { + test("creates a failing check on merge queue entry", async () => { + const mock = nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(200, payload.pull_request) + + .get("/repos/robotland/test/contents/.github%2Fdco.yml") + .reply(404) + .get("/repos/robotland/.github/contents/.github%2Fdco.yml") + .reply(404) + + .get( + "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...e76ed6025cec8879c75454a6efd6081d46de4c94" + ) + .reply(200, compare) + + .post("/repos/robotland/test/check-runs", (body) => { + body.started_at = "2018-07-14T18:18:54.156Z"; + body.completed_at = "2018-07-14T18:18:54.156Z"; + expect(body).toMatchObject({ + conclusion: "action_required", + head_branch: + "gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + head_sha: "abc123def456abc123def456abc123def456abc1", + name: "DCO", + status: "completed", + }); + return true; + }) + .reply(200); + + await probot.receive({ name: "merge_group", payload: mergeGroupPayload }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + + test("creates a passing check on merge queue entry", async () => { + const mock = nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(200, payload.pull_request) + + .get("/repos/robotland/test/contents/.github%2Fdco.yml") + .reply(404) + .get("/repos/robotland/.github/contents/.github%2Fdco.yml") + .reply(404) + + .get( + "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...e76ed6025cec8879c75454a6efd6081d46de4c94" + ) + .reply(200, compareSuccess) + + .post("/repos/robotland/test/check-runs", (body) => { + body.started_at = "2018-07-14T18:18:54.156Z"; + body.completed_at = "2018-07-14T18:18:54.156Z"; + expect(body).toMatchObject({ + conclusion: "success", + head_branch: + "gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + head_sha: "abc123def456abc123def456abc123def456abc1", + name: "DCO", + status: "completed", + }); + return true; + }) + .reply(200); + + await probot.receive({ name: "merge_group", payload: mergeGroupPayload }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + + test("ignores merge_group with unrecognized head_ref format", async () => { + const unknownRefPayload = { + ...mergeGroupPayload, + merge_group: { + ...mergeGroupPayload.merge_group, + head_ref: "refs/heads/some-other-branch", + }, + }; + + const mock = nock("https://api.github.com"); + + await probot.receive({ + name: "merge_group", + payload: unknownRefPayload, + }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + }); }); From 2ec8adf55e16a20297bd441ae42f2157e61299c6 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Mon, 13 Jul 2026 17:49:36 -0700 Subject: [PATCH 02/11] Fix: add new fixture to REUSE.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register test/fixtures/merge_group.checks_requested.json in REUSE.toml so reuse lint passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- REUSE.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/REUSE.toml b/REUSE.toml index 2dcc9fe..68f8738 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -13,6 +13,7 @@ path = [ "test/fixtures/pull_request.opened.json", "test/fixtures/pull_request_review.submitted.json", "test/fixtures/pull_request_review_comment.created.json", + "test/fixtures/merge_group.checks_requested.json", "test/fixtures/push.not-signed-off.json", "test/fixtures/push.signed-off.json", ] From 1319657b388a32ed2a23f39a7f10c54d59cef1c5 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Thu, 16 Jul 2026 15:44:49 -0700 Subject: [PATCH 03/11] Fix: tighten merge_group head_ref regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchor the regex to the gh-readonly-queue/ prefix so only real merge queue refs are matched, preventing accidental matches on unrelated refs containing /pr-N-. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 2 +- test/index.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index f94a9f5..83ecad0 100644 --- a/index.js +++ b/index.js @@ -151,7 +151,7 @@ module.exports = (app) => { app.on("merge_group.checks_requested", async (context) => { const mergeGroup = context.payload.merge_group; - const match = mergeGroup.head_ref.match(/\/pr-(\d+)-/); + const match = mergeGroup.head_ref.match(/^gh-readonly-queue\/.+\/pr-(\d+)-/); if (!match) return; const prNumber = parseInt(match[1], 10); diff --git a/test/index.test.js b/test/index.test.js index 1bd72f1..0a042c9 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -806,7 +806,7 @@ allowRemediationCommits: ...mergeGroupPayload, merge_group: { ...mergeGroupPayload.merge_group, - head_ref: "refs/heads/some-other-branch", + head_ref: "some-feature-branch/pr-113-abc123", }, }; From b4a9943bb2920125df549de99c05d0c94c7733ed Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Thu, 16 Jul 2026 16:45:24 -0700 Subject: [PATCH 04/11] Fix: check all merge group commits, not just one PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use merge_group.base_sha...merge_group.head_sha for the commit comparison so all commits in a batched merge group are DCO-validated, not just the single PR parsed from head_ref. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 8 +++++--- test/index.test.js | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 83ecad0..03e0c68 100644 --- a/index.js +++ b/index.js @@ -20,7 +20,7 @@ module.exports = (app) => { async function check( context, pr = context.payload.pull_request, - { reportSha, reportRef } = {} + { reportSha, reportRef, baseSha, headSha } = {} ) { const timeStart = new Date(); const sha = reportSha || pr.head.sha; @@ -40,8 +40,8 @@ module.exports = (app) => { const compare = await context.octokit.rest.repos.compareCommits( context.repo({ - base: pr.base.sha, - head: pr.head.sha, + base: baseSha || pr.base.sha, + head: headSha || pr.head.sha, }) ); @@ -162,6 +162,8 @@ module.exports = (app) => { await check(context, pr, { reportSha: mergeGroup.head_sha, reportRef: mergeGroup.head_ref, + baseSha: mergeGroup.base_sha, + headSha: mergeGroup.head_sha, }); }); diff --git a/test/index.test.js b/test/index.test.js index 0a042c9..a352784 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -742,7 +742,7 @@ allowRemediationCommits: .reply(404) .get( - "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...e76ed6025cec8879c75454a6efd6081d46de4c94" + "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...abc123def456abc123def456abc123def456abc1" ) .reply(200, compare) @@ -777,7 +777,7 @@ allowRemediationCommits: .reply(404) .get( - "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...e76ed6025cec8879c75454a6efd6081d46de4c94" + "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...abc123def456abc123def456abc123def456abc1" ) .reply(200, compareSuccess) From d8d39820f515bdb737f45098cc71aa5d79bb5041 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Fri, 17 Jul 2026 12:29:44 -0700 Subject: [PATCH 05/11] Style: wrap long regex line for biome line width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break merge_group.head_ref.match() call across two lines to satisfy biome's lineWidth: 80 formatting rule. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 03e0c68..6c9813e 100644 --- a/index.js +++ b/index.js @@ -151,7 +151,9 @@ module.exports = (app) => { app.on("merge_group.checks_requested", async (context) => { const mergeGroup = context.payload.merge_group; - const match = mergeGroup.head_ref.match(/^gh-readonly-queue\/.+\/pr-(\d+)-/); + const match = mergeGroup.head_ref.match( + /^gh-readonly-queue\/.+\/pr-(\d+)-/ + ); if (!match) return; const prNumber = parseInt(match[1], 10); From 86b65c69a3189034c4c68f021c3d5dd6a198dc88 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Mon, 20 Jul 2026 09:33:46 -0700 Subject: [PATCH 06/11] Test: add 403 fallback test for merge_group Add test verifying that when check-runs returns 403 on a merge_group event, the app falls back to the statuses API using the merge group head_sha (not the PR head sha). Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- test/__snapshots__/index.test.js.snap | 9 ++++++++ test/index.test.js | 32 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/test/__snapshots__/index.test.js.snap b/test/__snapshots__/index.test.js.snap index 2bfd929..1c00262 100644 --- a/test/__snapshots__/index.test.js.snap +++ b/test/__snapshots__/index.test.js.snap @@ -16,6 +16,15 @@ Object { } `; +exports[`dco merge_group event falls back to status API when check-runs returns 403 1`] = ` +Object { + "context": "DCO", + "description": "The sign-off is missing.", + "state": "failure", + "target_url": "https://github.com/probot/dco#how-it-works", +} +`; + exports[`dco pull_request event allowRemediationCommits.thirdParty: true creates a failing check with remidiation instructions 1`] = ` Object { "actions": Array [ diff --git a/test/index.test.js b/test/index.test.js index a352784..a3bded5 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -801,6 +801,38 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); + test("falls back to status API when check-runs returns 403", async () => { + const mock = nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(200, payload.pull_request) + + .get("/repos/robotland/test/contents/.github%2Fdco.yml") + .reply(404) + .get("/repos/robotland/.github/contents/.github%2Fdco.yml") + .reply(404) + + .get( + "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...abc123def456abc123def456abc123def456abc1" + ) + .reply(200, compare) + + .post("/repos/robotland/test/check-runs") + .reply(403) + + .post( + "/repos/robotland/test/statuses/abc123def456abc123def456abc123def456abc1", + (body) => { + expect(body).toMatchSnapshot(); + return true; + } + ) + .reply(201); + + await probot.receive({ name: "merge_group", payload: mergeGroupPayload }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + test("ignores merge_group with unrecognized head_ref format", async () => { const unknownRefPayload = { ...mergeGroupPayload, From 644c774329f45fc64f7ecf14a6411dcb1ace57fe Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Mon, 20 Jul 2026 10:05:56 -0700 Subject: [PATCH 07/11] Fix: handle pulls.get failure in merge_group handler Add try/catch around the pulls.get call so that a 404 or other error gracefully returns instead of throwing an unhandled exception. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 14 +++++++++++--- test/index.test.js | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index 6c9813e..287fc38 100644 --- a/index.js +++ b/index.js @@ -157,9 +157,17 @@ module.exports = (app) => { if (!match) return; const prNumber = parseInt(match[1], 10); - const { data: pr } = await context.octokit.rest.pulls.get( - context.repo({ pull_number: prNumber }) - ); + let pr; + try { + ({ data: pr } = await context.octokit.rest.pulls.get( + context.repo({ pull_number: prNumber }) + )); + } catch (error) { + context.log.info( + `merge_group: could not fetch PR #${prNumber}: ${error.message}` + ); + return; + } await check(context, pr, { reportSha: mergeGroup.head_sha, diff --git a/test/index.test.js b/test/index.test.js index a3bded5..408d5e0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -833,6 +833,16 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); + test("skips check when PR lookup fails", async () => { + const mock = nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(404); + + await probot.receive({ name: "merge_group", payload: mergeGroupPayload }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + test("ignores merge_group with unrecognized head_ref format", async () => { const unknownRefPayload = { ...mergeGroupPayload, From 99f984f9645a9f8c382b41c1c1c181b7c68545fc Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Mon, 20 Jul 2026 15:43:57 -0700 Subject: [PATCH 08/11] Fix: relax regex to handle refs/heads/ prefix in head_ref Update the merge_group head_ref regex from /^gh-readonly-queue\/.../ to /(?:^|\/)gh-readonly-queue\/.../ so payloads with a refs/heads/ prefix are matched and not silently dropped. Add test covering the refs/heads/ prefixed head_ref case. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 2 +- test/index.test.js | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 287fc38..926fc61 100644 --- a/index.js +++ b/index.js @@ -152,7 +152,7 @@ module.exports = (app) => { app.on("merge_group.checks_requested", async (context) => { const mergeGroup = context.payload.merge_group; const match = mergeGroup.head_ref.match( - /^gh-readonly-queue\/.+\/pr-(\d+)-/ + /(?:^|\/)gh-readonly-queue\/.+\/pr-(\d+)-/ ); if (!match) return; const prNumber = parseInt(match[1], 10); diff --git a/test/index.test.js b/test/index.test.js index 408d5e0..dbf74eb 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -843,6 +843,53 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); + test("creates a passing check when head_ref has refs/heads/ prefix", async () => { + const prefixedRefPayload = { + ...mergeGroupPayload, + merge_group: { + ...mergeGroupPayload.merge_group, + head_ref: + "refs/heads/gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + }, + }; + + const mock = nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(200, payload.pull_request) + + .get("/repos/robotland/test/contents/.github%2Fdco.yml") + .reply(404) + .get("/repos/robotland/.github/contents/.github%2Fdco.yml") + .reply(404) + + .get( + "/repos/robotland/test/compare/607c64cd8e37eb2db939f99a17bee5c7d1a90a31...abc123def456abc123def456abc123def456abc1" + ) + .reply(200, compareSuccess) + + .post("/repos/robotland/test/check-runs", (body) => { + body.started_at = "2018-07-14T18:18:54.156Z"; + body.completed_at = "2018-07-14T18:18:54.156Z"; + expect(body).toMatchObject({ + conclusion: "success", + head_branch: + "refs/heads/gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + head_sha: "abc123def456abc123def456abc123def456abc1", + name: "DCO", + status: "completed", + }); + return true; + }) + .reply(200); + + await probot.receive({ + name: "merge_group", + payload: prefixedRefPayload, + }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + test("ignores merge_group with unrecognized head_ref format", async () => { const unknownRefPayload = { ...mergeGroupPayload, From 0e106855e0c2d753904111cc85b642488eb3ea33 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Mon, 20 Jul 2026 17:10:26 -0700 Subject: [PATCH 09/11] Fix: strip refs/heads/ from head_branch; clarify headSha - Strip refs/heads/ prefix from reportRef before using it as head_branch in check runs, consistent with how pr.head.ref is always a bare branch name - Add comments at merge_group call site explaining why reportSha and headSha share the same value Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 5 ++++- test/index.test.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 926fc61..ae23a0f 100644 --- a/index.js +++ b/index.js @@ -24,7 +24,7 @@ module.exports = (app) => { ) { const timeStart = new Date(); const sha = reportSha || pr.head.sha; - const ref = reportRef || pr.head.ref; + const ref = (reportRef || pr.head.ref).replace(/^refs\/heads\//, ""); const config = await context.config("dco.yml", { require: { @@ -170,8 +170,11 @@ module.exports = (app) => { } await check(context, pr, { + // Report the check result on the merge group's temporary merge commit. reportSha: mergeGroup.head_sha, reportRef: mergeGroup.head_ref, + // Compare the full merge group range so all batched PRs are validated. + // headSha equals reportSha: both target the merge group head commit. baseSha: mergeGroup.base_sha, headSha: mergeGroup.head_sha, }); diff --git a/test/index.test.js b/test/index.test.js index dbf74eb..183891d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -873,7 +873,7 @@ allowRemediationCommits: expect(body).toMatchObject({ conclusion: "success", head_branch: - "refs/heads/gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + "gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", head_sha: "abc123def456abc123def456abc123def456abc1", name: "DCO", status: "completed", From 293e91a5fea203b6fbd82395fabdc65516996535 Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Tue, 21 Jul 2026 09:48:31 -0700 Subject: [PATCH 10/11] Fix: update fixture to match real GitHub merge_group format Real GitHub merge_group.checks_requested payloads always include refs/heads/ prefix in head_ref (confirmed from live webhook captures). Update the fixture and repurpose the prefix test to instead cover the bare head_ref format. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- test/fixtures/merge_group.checks_requested.json | 2 +- test/index.test.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/fixtures/merge_group.checks_requested.json b/test/fixtures/merge_group.checks_requested.json index 15ae7ab..42d0487 100644 --- a/test/fixtures/merge_group.checks_requested.json +++ b/test/fixtures/merge_group.checks_requested.json @@ -2,7 +2,7 @@ "action": "checks_requested", "merge_group": { "head_sha": "abc123def456abc123def456abc123def456abc1", - "head_ref": "gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + "head_ref": "refs/heads/gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", "base_sha": "607c64cd8e37eb2db939f99a17bee5c7d1a90a31", "base_ref": "refs/heads/master", "head_commit": { diff --git a/test/index.test.js b/test/index.test.js index 183891d..58123c4 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -843,13 +843,13 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); - test("creates a passing check when head_ref has refs/heads/ prefix", async () => { - const prefixedRefPayload = { + test("creates a passing check when head_ref has no refs/heads/ prefix", async () => { + const bareRefPayload = { ...mergeGroupPayload, merge_group: { ...mergeGroupPayload.merge_group, head_ref: - "refs/heads/gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", + "gh-readonly-queue/master/pr-113-e76ed6025cec8879c75454a6efd6081d46de4c94", }, }; @@ -884,7 +884,7 @@ allowRemediationCommits: await probot.receive({ name: "merge_group", - payload: prefixedRefPayload, + payload: bareRefPayload, }); expect(mock.activeMocks()).toStrictEqual([]); From 661e87e99666c09f7e6f04532cf1f13fd0209a1a Mon Sep 17 00:00:00 2001 From: Trevor Bramwell Date: Tue, 21 Jul 2026 09:58:44 -0700 Subject: [PATCH 11/11] Fix: only swallow 404/403 on merge_group PR lookup Previously all errors from pulls.get() were caught and returned early, silently dropping the DCO check on transient 5xx/timeout failures. Now only 404 and 403 are handled gracefully; other errors are rethrown. Add tests for the 403 early-return and 422 rethrow paths. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Trevor Bramwell --- index.js | 11 +++++++---- test/index.test.js | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index ae23a0f..837a9ec 100644 --- a/index.js +++ b/index.js @@ -163,10 +163,13 @@ module.exports = (app) => { context.repo({ pull_number: prNumber }) )); } catch (error) { - context.log.info( - `merge_group: could not fetch PR #${prNumber}: ${error.message}` - ); - return; + if (error.status === 404 || error.status === 403) { + context.log.info( + `merge_group: could not fetch PR #${prNumber}: ${error.message}` + ); + return; + } + throw error; } await check(context, pr, { diff --git a/test/index.test.js b/test/index.test.js index 58123c4..1b268e4 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -833,7 +833,7 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); - test("skips check when PR lookup fails", async () => { + test("skips check when PR lookup returns 404", async () => { const mock = nock("https://api.github.com") .get("/repos/robotland/test/pulls/113") .reply(404); @@ -843,6 +843,27 @@ allowRemediationCommits: expect(mock.activeMocks()).toStrictEqual([]); }); + test("skips check when PR lookup returns 403", async () => { + const mock = nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(403); + + await probot.receive({ name: "merge_group", payload: mergeGroupPayload }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + + test("rethrows non-404/403 errors from PR lookup", async () => { + // Use 422 (not retried by octokit) to verify non-404/403 errors propagate + nock("https://api.github.com") + .get("/repos/robotland/test/pulls/113") + .reply(422); + + await expect( + probot.receive({ name: "merge_group", payload: mergeGroupPayload }) + ).rejects.toThrow(); + }); + test("creates a passing check when head_ref has no refs/heads/ prefix", async () => { const bareRefPayload = { ...mergeGroupPayload,