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", ] 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..837a9ec 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, baseSha, headSha } = {} + ) { const timeStart = new Date(); + const sha = reportSha || pr.head.sha; + const ref = (reportRef || pr.head.ref).replace(/^refs\/heads\//, ""); const config = await context.config("dco.yml", { require: { @@ -34,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, }) ); @@ -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,40 @@ 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+)-/ + ); + if (!match) return; + const prNumber = parseInt(match[1], 10); + + let pr; + try { + ({ data: pr } = await context.octokit.rest.pulls.get( + context.repo({ pull_number: prNumber }) + )); + } catch (error) { + 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, { + // 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, + }); + }); + function isRecheckCommand(body) { if (!body) return false; return body 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/fixtures/merge_group.checks_requested.json b/test/fixtures/merge_group.checks_requested.json new file mode 100644 index 0000000..42d0487 --- /dev/null +++ b/test/fixtures/merge_group.checks_requested.json @@ -0,0 +1,62 @@ +{ + "action": "checks_requested", + "merge_group": { + "head_sha": "abc123def456abc123def456abc123def456abc1", + "head_ref": "refs/heads/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..1b268e4 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,205 @@ 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...abc123def456abc123def456abc123def456abc1" + ) + .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...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: + "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("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("skips check when PR lookup returns 404", 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("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, + merge_group: { + ...mergeGroupPayload.merge_group, + head_ref: + "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: + "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: bareRefPayload, + }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + + test("ignores merge_group with unrecognized head_ref format", async () => { + const unknownRefPayload = { + ...mergeGroupPayload, + merge_group: { + ...mergeGroupPayload.merge_group, + head_ref: "some-feature-branch/pr-113-abc123", + }, + }; + + const mock = nock("https://api.github.com"); + + await probot.receive({ + name: "merge_group", + payload: unknownRefPayload, + }); + + expect(mock.activeMocks()).toStrictEqual([]); + }); + }); });