diff --git a/lib/ci/run_ci.js b/lib/ci/run_ci.js index d0572e7e..6edd5833 100644 --- a/lib/ci/run_ci.js +++ b/lib/ci/run_ci.js @@ -27,7 +27,7 @@ export class RunPRJob { this.certifySafe = certifySafe || Promise.all([this.prData.getReviews(), this.prData.getPR()]).then(() => - new PRChecker(cli, this.prData, request, {}).checkCommitsAfterReview() + new PRChecker(cli, this.prData, request, {}).checkCommitsAfterReviewOrLabel() ); } diff --git a/lib/pr_checker.js b/lib/pr_checker.js index 37cdbd51..7d00ca92 100644 --- a/lib/pr_checker.js +++ b/lib/pr_checker.js @@ -523,6 +523,32 @@ export default class PRChecker { return true; } + async checkCommitsAfterReviewOrLabel() { + if (this.checkCommitsAfterReview()) return true; + + await Promise.all([this.data.getLabeledEvents(), this.data.getCollaborators()]); + + const { + cli, data, pr + } = this; + + const { updatedAt } = pr.timelineItems; + const requestCiLabels = data.labeledEvents.findLast( + ({ createdAt, label: { name } }) => name === 'request-ci' && createdAt > updatedAt + ); + if (requestCiLabels == null) return false; + + const { actor: { login } } = requestCiLabels; + const collaborators = Array.from(data.collaborators.values(), + (c) => c.login.toLowerCase()); + if (collaborators.includes(login.toLowerCase())) { + cli.info('request-ci label was added by a Collaborator after the last push event.'); + return true; + } + + return false; + } + checkCommitsAfterReview() { const { commits, reviews, cli, argv diff --git a/lib/pr_data.js b/lib/pr_data.js index 53379b8a..1ee4adec 100644 --- a/lib/pr_data.js +++ b/lib/pr_data.js @@ -5,6 +5,7 @@ import { } from './user_status.js'; // lib/queries/*.gql file names +const LABELED_EVENTS_QUERY = 'PRLabeledEvents'; const PR_QUERY = 'PR'; const REVIEWS_QUERY = 'Reviews'; const COMMENTS_QUERY = 'PRComments'; @@ -33,6 +34,7 @@ export default class PRData { this.comments = []; this.commits = []; this.reviewers = []; + this.labeledEvents = []; } getThread() { @@ -90,6 +92,14 @@ export default class PRData { ]); } + async getLabeledEvents() { + const { prid, owner, repo, cli, request, prStr } = this; + const vars = { prid, owner, repo }; + cli.updateSpinner(`Getting labels from ${prStr}`); + this.labeledEvents = (await request.gql(LABELED_EVENTS_QUERY, vars)) + .repository.pullRequest.timelineItems.nodes; + } + async getComments() { const { prid, owner, repo, cli, request, prStr } = this; const vars = { prid, owner, repo }; diff --git a/lib/queries/PRLabeledEvents.gql b/lib/queries/PRLabeledEvents.gql new file mode 100644 index 00000000..f3a0f233 --- /dev/null +++ b/lib/queries/PRLabeledEvents.gql @@ -0,0 +1,19 @@ +query PRLabeledEvents($prid: Int!, $owner: String!, $repo: String!, $after: String) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prid) { + timelineItems(itemTypes: LABELED_EVENT, after: $after, last: 100) { + nodes { + ... on LabeledEvent { + actor { + login + } + label { + name + } + createdAt + } + } + } + } + } +} diff --git a/test/fixtures/data.js b/test/fixtures/data.js index b60fe933..b78333fe 100644 --- a/test/fixtures/data.js +++ b/test/fixtures/data.js @@ -134,3 +134,12 @@ for (const subdir of readdirSync(path('./jenkins'))) { readJSON(`./jenkins/${subdir}/${item}`); } }; + +export const labeledEvents = {}; + +for (const item of readdirSync(path('./labeled_events'))) { + if (!item.endsWith('.json')) { + continue; + } + labeledEvents[basename(item, '.json')] = readJSON(`./labeled_events/${item}`); +} diff --git a/test/fixtures/first_timer_pr.json b/test/fixtures/first_timer_pr.json index 0ae29b6a..658434c3 100644 --- a/test/fixtures/first_timer_pr.json +++ b/test/fixtures/first_timer_pr.json @@ -22,6 +22,7 @@ } ] }, + "timelineItems": { "updatedAt": "2017-10-24T11:13:43Z" }, "title": "test: awesome changes", "baseRefName": "main", "headRefName": "awesome-changes" diff --git a/test/fixtures/labeled_events/no-request-ci.json b/test/fixtures/labeled_events/no-request-ci.json new file mode 100644 index 00000000..edee9def --- /dev/null +++ b/test/fixtures/labeled_events/no-request-ci.json @@ -0,0 +1,12 @@ +[ + { + "actor": { "login": "nodejs-github-bot" }, + "label": { "name": "doc" }, + "createdAt": "2024-05-13T15:57:10Z" + }, + { + "actor": { "login": "nodejs-github-bot" }, + "label": { "name": "test_runner" }, + "createdAt": "2024-05-13T15:57:10Z" + } +] diff --git a/test/fixtures/labeled_events/old-request-ci-collaborator.json b/test/fixtures/labeled_events/old-request-ci-collaborator.json new file mode 100644 index 00000000..24a16f78 --- /dev/null +++ b/test/fixtures/labeled_events/old-request-ci-collaborator.json @@ -0,0 +1,12 @@ +[ + { + "actor": { "login": "nodejs-github-bot" }, + "label": { "name": "doc" }, + "createdAt": "2024-05-13T15:57:10Z" + }, + { + "actor": { "login": "foo" }, + "label": { "name": "request-ci" }, + "createdAt": "1999-10-24T11:13:43Z" + } +] diff --git a/test/fixtures/labeled_events/recent-request-ci-collaborator.json b/test/fixtures/labeled_events/recent-request-ci-collaborator.json new file mode 100644 index 00000000..54792093 --- /dev/null +++ b/test/fixtures/labeled_events/recent-request-ci-collaborator.json @@ -0,0 +1,12 @@ +[ + { + "actor": { "login": "nodejs-github-bot" }, + "label": { "name": "doc" }, + "createdAt": "2024-05-13T15:57:10Z" + }, + { + "actor": { "login": "foo" }, + "label": { "name": "request-ci" }, + "createdAt": "2024-05-13T15:57:10Z" + } +] diff --git a/test/fixtures/labeled_events/recent-request-ci-non-collaborator.json b/test/fixtures/labeled_events/recent-request-ci-non-collaborator.json new file mode 100644 index 00000000..f799a03c --- /dev/null +++ b/test/fixtures/labeled_events/recent-request-ci-non-collaborator.json @@ -0,0 +1,12 @@ +[ + { + "actor": { "login": "nodejs-github-bot" }, + "label": { "name": "doc" }, + "createdAt": "2024-05-13T15:57:10Z" + }, + { + "actor": { "login": "random-person" }, + "label": { "name": "request-ci" }, + "createdAt": "2024-05-13T15:57:10Z" + } +] diff --git a/test/unit/pr_checker.test.js b/test/unit/pr_checker.test.js index 3e10a71c..e71837f7 100644 --- a/test/unit/pr_checker.test.js +++ b/test/unit/pr_checker.test.js @@ -40,6 +40,7 @@ import { semverMajorPR, conflictingPR, closedPR, + labeledEvents, mergedPR, pullRequests } from '../fixtures/data.js'; @@ -2048,6 +2049,100 @@ describe('PRChecker', () => { }); }); + describe('checkCommitsAfterReviewOrLabel', () => { + it('should return true if PR passes post review checks', async() => { + const cli = new TestCLI(); + const checker = new PRChecker(cli, { + pr: semverMajorPR, + reviewers: allGreenReviewers, + comments: commentsWithLGTM, + reviews: approvingReviews, + commits: simpleCommits, + collaborators, + authorIsNew: () => true, + getThread: PRData.prototype.getThread + }, {}, argv); + + const status = await checker.checkCommitsAfterReviewOrLabel(); + assert.strictEqual(status, true); + }); + + describe('without approvals', () => { + const data = { + pr: firstTimerPR, + reviewers: noReviewers, + comments: [], + reviews: [], + commits: [], + collaborators: [], + labeledEvents: [], + authorIsNew: () => true, + getThread: PRData.prototype.getThread, + getLabeledEvents: async() => { + data.labeledEvents = []; + }, + getCollaborators: async() => { + data.collaborators = collaborators; + } + }; + + it('should return false if PR has no labels', async() => { + const cli = new TestCLI(); + data.getLabeledEvents = async() => { + data.labeledEvents = []; + }; + const checker = new PRChecker(cli, data, {}, argv); + + const status = await checker.checkCommitsAfterReviewOrLabel(); + assert.strictEqual(status, false); + }); + + it('should return false if PR has no request-ci label', async() => { + const cli = new TestCLI(); + data.getLabeledEvents = async() => { + data.labeledEvents = labeledEvents['no-request-ci']; + }; + const checker = new PRChecker(cli, data, {}, argv); + + const status = await checker.checkCommitsAfterReviewOrLabel(); + assert.strictEqual(status, false); + }); + + it('should return false if PR has request-ci from non-collaborator', async() => { + const cli = new TestCLI(); + data.getLabeledEvents = async() => { + data.labeledEvents = labeledEvents['recent-request-ci-non-collaborator']; + }; + const checker = new PRChecker(cli, data, {}, argv); + + const status = await checker.checkCommitsAfterReviewOrLabel(); + assert.strictEqual(status, false); + }); + + it('should return false if PR has outdated request-ci from a collaborator', async() => { + const cli = new TestCLI(); + data.getLabeledEvents = async() => { + data.labeledEvents = labeledEvents['old-request-ci-collaborator']; + }; + const checker = new PRChecker(cli, data, {}, argv); + + const status = await checker.checkCommitsAfterReviewOrLabel(); + assert.strictEqual(status, false); + }); + + it('should return true if PR has recent request-ci from a collaborator', async() => { + const cli = new TestCLI(); + data.getLabeledEvents = async() => { + data.labeledEvents = labeledEvents['recent-request-ci-collaborator']; + }; + const checker = new PRChecker(cli, data, {}, argv); + + const status = await checker.checkCommitsAfterReviewOrLabel(); + assert.strictEqual(status, true); + }); + }); + }); + describe('checkCommitsAfterReview', () => { const cli = new TestCLI();