From 47b5dec5fb00d9050c71fd33a458201efff85515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobiasz=20K=C4=99dzierski?= Date: Mon, 26 Oct 2020 15:34:16 +0100 Subject: [PATCH] Handle reviews --- LICENSE | 2 +- README.md | 57 +++++++++++++++-- action.yml | 5 +- dist/index.js | 137 +++++++++++++++++++++++++++++++++++----- package.json | 2 +- src/main.ts | 172 ++++++++++++++++++++++++++++++++++++++++++++------ 6 files changed, 328 insertions(+), 47 deletions(-) diff --git a/LICENSE b/LICENSE index a67dca8..7d02166 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ The MIT License (MIT) -Copyright (c) 2018 GitHub, Inc. and contributors +Copyright (c) 2020 GitHub, Inc. and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c808581..a23ec7a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ - [Inputs and outputs](#inputs-and-outputs) - [Inputs](#inputs) - [Outputs](#outputs) +- [Examples](#examples) + - [Workflow Run event](#workflow-run-event) - [Development environment](#development-environment) - [License](#license) @@ -20,19 +22,64 @@ # Context and motivation -TODO +Label When Approved is an action that checks is Pull Request is approved and assign label to it. +Label is not set or removed when Pull Request has awaiting requested changes. + +Setting label is optional that only output can be used in the workflow. + +The required input `require_committers_approval` says is approval can be done by people with read access to the repo +or by anyone. It may be useful in repositories which requires committers approvals like [Apache Software Foundation](https://github.com/apache/) +projects. # Inputs and outputs ## Inputs -| Input | Required | Default | Comment | -|-------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------| -| `token` | yes | | The github token passed from `${{ secrets.GITHUB_TOKEN }}` | +| Input | Required | Example | Comment | +|-------------------------------|----------|-------------------------------|-------------------------------------------------------------------------| +| `token` | yes | `${{ secrets.GITHUB_TOKEN }}` | The github token passed from `${{ secrets.GITHUB_TOKEN }}` | +| `label` | no | `Approved by committers` | Label to be added/removed to the Pull Request if approved/not approved | +| `require_committers_approval` | no | `true` | Is approval from user with write permission required | ## Outputs -TODO +| Output | | +|----------------|------------------------------| +| `isApproved` | is Pull Reqeuest is approved | +| `labelSet` | is label was set | +| `labelRemoved` | is label was removed | + +# Examples + +### Workflow Run event + +```yaml +name: Label when approved +on: pull_request_review + +jobs: + + label-when-approved: + name: "Label when approved" + runs-on: ubuntu-latest + outputs: + isApprovedByCommiters: ${{ steps.label-when-approved-by-commiters.outputs.isApproved }} + isApprovedByAnyone: ${{ steps.label-when-approved-by-anyone.outputs.isApproved }} + steps: + - name: Label when approved by commiters + uses: TobKed/label-when-approved-action@v0.1 + id: label-when-approved-by-commiters + with: + token: ${{ secrets.GITHUB_TOKEN }} + label: 'ready to merge (committers)' + require_committers_approval: 'true' + - name: Label when approved by anyone + uses: TobKed/label-when-approved-action@v0.1 + id: label-when-approved-by-anyone + with: + token: ${{ secrets.GITHUB_TOKEN }} +``` + ## Development environment diff --git a/action.yml b/action.yml index caa7b4d..bd6f4e3 100644 --- a/action.yml +++ b/action.yml @@ -7,10 +7,11 @@ inputs: required: true label: description: A label to be checked/added/removed - required: true + required: false require_committers_approval: description: Is approval from person with write access to repo required - required: true + required: false + default: 'false' runs: using: 'node12' main: 'dist/index.js' diff --git a/dist/index.js b/dist/index.js index 2ab317a..c7ea88f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1466,6 +1466,10 @@ function getRequiredEnv(key) { } return value; } +function verboseOutput(name, value) { + core.info(`Setting output: ${name}: ${value}`); + core.setOutput(name, value); +} function getPullRequest(octokit, context, owner, repo) { return __awaiter(this, void 0, void 0, function* () { const pullRequestNumber = context.payload.pull_request @@ -1492,31 +1496,38 @@ function getPullRequestLabels(pullRequest) { } function getReviews(octokit, owner, repo, number, getComitters) { return __awaiter(this, void 0, void 0, function* () { - const reviews = yield octokit.pulls.listReviews({ + let reviews = []; + const options = octokit.pulls.listReviews.endpoint.merge({ owner, repo, // eslint-disable-next-line @typescript-eslint/camelcase pull_number: number }); - const reviewers = reviews ? reviews.data.map(review => review.user.login) : []; + yield octokit.paginate(options).then(r => { + reviews = r; + }); + const reviewers = reviews ? reviews.map(review => review.user.login) : []; + const reviewersAlreadyChecked = []; const committers = []; if (getComitters) { + core.info('Checking reviewers permissions:'); for (const reviewer of reviewers) { - if (!committers.includes(reviewer)) { + if (!reviewersAlreadyChecked.includes(reviewer)) { const p = yield octokit.repos.getCollaboratorPermissionLevel({ owner, repo, username: reviewer }); const permission = p.data.permission; - core.info(`\nChecking: "${reviewer}" permissions: ${permission}.\n`); if (permission === 'admin' || permission === 'write') { committers.push(reviewer); } + core.info(`\t${reviewer}: ${permission}`); + reviewersAlreadyChecked.push(reviewer); } } } - return [reviews.data, reviewers, committers]; + return [reviews, reviewers, committers]; }); } function processReviews(reviews, reviewers, committers, requireCommittersApproval) { @@ -1532,9 +1543,9 @@ function processReviews(reviews, reviewers, committers, requireCommittersApprova } } } - core.info(`User reviews:`); + core.info(`Reviews:`); for (const user in reviewStates) { - core.info(`User "${user}" : "${reviewStates[user]}"`); + core.info(`\t${user}: ${reviewStates[user].toLowerCase()}`); } for (const user in reviewStates) { if (reviewStates[user] === 'APPROVED') { @@ -1552,6 +1563,7 @@ function processReviews(reviews, reviewers, committers, requireCommittersApprova } function setLabel(octokit, owner, repo, pullRequestNumber, label) { return __awaiter(this, void 0, void 0, function* () { + core.info(`Setting label: ${label}`); yield octokit.issues.addLabels({ // eslint-disable-next-line @typescript-eslint/camelcase issue_number: pullRequestNumber, @@ -1563,6 +1575,7 @@ function setLabel(octokit, owner, repo, pullRequestNumber, label) { } function removeLabel(octokit, owner, repo, pullRequestNumber, label) { return __awaiter(this, void 0, void 0, function* () { + core.info(`Removing label: ${label}`); yield octokit.issues.removeLabel({ // eslint-disable-next-line @typescript-eslint/camelcase issue_number: pullRequestNumber, @@ -1572,6 +1585,61 @@ function removeLabel(octokit, owner, repo, pullRequestNumber, label) { }); }); } +function getWorkflowId(octokit, runId, owner, repo) { + return __awaiter(this, void 0, void 0, function* () { + const reply = yield octokit.actions.getWorkflowRun({ + owner, + repo, + // eslint-disable-next-line @typescript-eslint/camelcase + run_id: runId + }); + core.info(`The source run ${runId} is in ${reply.data.workflow_url} workflow`); + const workflowIdString = reply.data.workflow_url.split('/').pop() || ''; + if (!(workflowIdString.length > 0)) { + throw new Error('Could not resolve workflow'); + } + return parseInt(workflowIdString); + }); +} +function getPrWorkflowRunsIds(octokit, owner, repo, branch, sha, skipRunId) { + return __awaiter(this, void 0, void 0, function* () { + const workflowRuns = yield octokit.actions.listRepoWorkflowRuns({ + owner, + repo, + branch, + event: 'pull_request', + status: 'completed', + // eslint-disable-next-line @typescript-eslint/camelcase + per_page: 100 + }); + // may be no need to rerun pending/queued runs + const filteredRunsIds = []; + const filteredWorklowRunsIds = []; + for (const workflowRun of workflowRuns.data.workflow_runs) { + const workflowId = parseInt(workflowRun.workflow_url.split('/').pop() || '0'); + if (workflowRun.head_sha === sha && + !filteredRunsIds.includes(workflowId) && + workflowId !== skipRunId) { + filteredRunsIds.push(workflowId); + filteredWorklowRunsIds.push(workflowRun.id); + } + } + return filteredWorklowRunsIds; + }); +} +function rerunWorkflows(octokit, owner, repo, runIds) { + return __awaiter(this, void 0, void 0, function* () { + core.info(`Rerun worklowws: ${runIds}`); + for (const runId of runIds) { + yield octokit.actions.reRunWorkflow({ + owner, + repo, + // eslint-disable-next-line @typescript-eslint/camelcase + run_id: runId + }); + } + }); +} function printDebug(item, description) { return __awaiter(this, void 0, void 0, function* () { const itemJson = JSON.stringify(item); @@ -1579,17 +1647,25 @@ function printDebug(item, description) { }); } function run() { + var _a, _b; return __awaiter(this, void 0, void 0, function* () { const token = core.getInput('token', { required: true }); - const userLabel = core.getInput('label', { required: true }); + const userLabel = core.getInput('label', { required: false }) || 'not set'; const requireCommittersApproval = core.getInput('require_committers_approval', { - required: true - }); + required: false + }) === 'true'; const octokit = new github.GitHub(token); const context = github.context; const repository = getRequiredEnv('GITHUB_REPOSITORY'); const eventName = getRequiredEnv('GITHUB_EVENT_NAME'); + const selfRunId = parseInt(getRequiredEnv('GITHUB_RUN_ID')); const [owner, repo] = repository.split('/'); + const selfWorkflowId = yield getWorkflowId(octokit, selfRunId, owner, repo); + const branch = (_a = context.payload.pull_request) === null || _a === void 0 ? void 0 : _a.head.ref; + const sha = (_b = context.payload.pull_request) === null || _b === void 0 ? void 0 : _b.head.sha; + core.info(`\n############### Set Label When Approved start ##################\n` + + `label: "${userLabel}"\n` + + `requireCommittersApproval: ${requireCommittersApproval}`); if (eventName !== 'pull_request_review') { throw Error(`This action is only useful in "pull_request_review" triggered runs and you used it in "${eventName}"`); } @@ -1598,15 +1674,42 @@ function run() { // LABELS const labelNames = getPullRequestLabels(pullRequest); // REVIEWS - const [reviews, reviewers, committers] = yield getReviews(octokit, owner, repo, pullRequest.number, requireCommittersApproval === 'true'); - const isApproved = processReviews(reviews, reviewers, committers, requireCommittersApproval === 'true'); + const [reviews, reviewers, committers] = yield getReviews(octokit, owner, repo, pullRequest.number, requireCommittersApproval); + const isApproved = processReviews(reviews, reviewers, committers, requireCommittersApproval); // HANDLE LABEL - if (isApproved && !labelNames.includes(userLabel)) { - setLabel(octokit, owner, repo, pullRequest.number, userLabel); - } - else if (!isApproved && labelNames.includes(userLabel)) { - removeLabel(octokit, owner, repo, pullRequest.number, userLabel); + let isLabelShouldBeSet = false; + let isLabelShouldBeRemoved = false; + if (userLabel !== 'not set') { + isLabelShouldBeSet = isApproved && !labelNames.includes(userLabel); + isLabelShouldBeRemoved = !isApproved && labelNames.includes(userLabel); + if (isLabelShouldBeSet) { + setLabel(octokit, owner, repo, pullRequest.number, userLabel); + } + else if (isLabelShouldBeRemoved) { + removeLabel(octokit, owner, repo, pullRequest.number, userLabel); + } } + //// Future option to rerun workflows if PR approved + //// Rerun workflow can have dynamic matrixes which check presence of labels + //// it is not possible to rerun successful runs + //// https://github.community/t/cannot-re-run-a-successful-workflow-run-using-the-rest-api/123661 + // + // if (isLabelShouldBeSet) { + // const prWorkflowRunsIds = await getPrWorkflowRunsIds( + // octokit, + // owner, + // repo, + // branch, + // sha, + // selfWorkflowId + // ) + // + // await rerunWorkflows(octokit, owner, repo, prWorkflowRunsIds) + // } + // OUTPUT + verboseOutput('isApproved', String(isApproved)); + verboseOutput('labelSet', String(isLabelShouldBeSet)); + verboseOutput('labelRemoved', String(isLabelShouldBeRemoved)); }); } run() diff --git a/package.json b/package.json index ec8796a..49111ea 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "node", "setup" ], - "author": "YourNameOrOrganization", + "author": "Tobiasz Kedzierski", "license": "MIT", "dependencies": { "@actions/core": "^1.2.2", diff --git a/src/main.ts b/src/main.ts index 0d5469c..dac2b83 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,11 @@ function getRequiredEnv(key: string): string { return value } +function verboseOutput(name: string, value: string): void { + core.info(`Setting output: ${name}: ${value}`) + core.setOutput(name, value) +} + async function getPullRequest( octokit: github.GitHub, context: Context, @@ -48,31 +53,39 @@ async function getReviews( number: number, getComitters: boolean ): Promise<[rest.PullsListReviewsResponseItem[], string[], string[]]> { - const reviews = await octokit.pulls.listReviews({ + let reviews: rest.PullsListReviewsResponseItem[] = [] + const options = octokit.pulls.listReviews.endpoint.merge({ owner, repo, // eslint-disable-next-line @typescript-eslint/camelcase pull_number: number }) - const reviewers = reviews ? reviews.data.map(review => review.user.login) : [] + await octokit.paginate(options).then(r => { + reviews = r + }) + + const reviewers = reviews ? reviews.map(review => review.user.login) : [] + const reviewersAlreadyChecked: string[] = [] const committers: string[] = [] if (getComitters) { + core.info('Checking reviewers permissions:') for (const reviewer of reviewers) { - if (!committers.includes(reviewer)) { + if (!reviewersAlreadyChecked.includes(reviewer)) { const p = await octokit.repos.getCollaboratorPermissionLevel({ owner, repo, username: reviewer }) const permission = p.data.permission - core.info(`\nChecking: "${reviewer}" permissions: ${permission}.\n`) if (permission === 'admin' || permission === 'write') { committers.push(reviewer) } + core.info(`\t${reviewer}: ${permission}`) + reviewersAlreadyChecked.push(reviewer) } } } - return [reviews.data, reviewers, committers] + return [reviews, reviewers, committers] } function processReviews( @@ -94,9 +107,9 @@ function processReviews( } } - core.info(`User reviews:`) + core.info(`Reviews:`) for (const user in reviewStates) { - core.info(`User "${user}" : "${reviewStates[user]}"`) + core.info(`\t${user}: ${reviewStates[user].toLowerCase()}`) } for (const user in reviewStates) { @@ -122,6 +135,7 @@ async function setLabel( pullRequestNumber: number, label: string ): Promise { + core.info(`Setting label: ${label}`) await octokit.issues.addLabels({ // eslint-disable-next-line @typescript-eslint/camelcase issue_number: pullRequestNumber, @@ -138,6 +152,7 @@ async function removeLabel( pullRequestNumber: number, label: string ): Promise { + core.info(`Removing label: ${label}`) await octokit.issues.removeLabel({ // eslint-disable-next-line @typescript-eslint/camelcase issue_number: pullRequestNumber, @@ -147,8 +162,84 @@ async function removeLabel( }) } +async function getWorkflowId( + octokit: github.GitHub, + runId: number, + owner: string, + repo: string +): Promise { + const reply = await octokit.actions.getWorkflowRun({ + owner, + repo, + // eslint-disable-next-line @typescript-eslint/camelcase + run_id: runId + }) + core.info(`The source run ${runId} is in ${reply.data.workflow_url} workflow`) + const workflowIdString = reply.data.workflow_url.split('/').pop() || '' + if (!(workflowIdString.length > 0)) { + throw new Error('Could not resolve workflow') + } + return parseInt(workflowIdString) +} + +async function getPrWorkflowRunsIds( + octokit: github.GitHub, + owner: string, + repo: string, + branch: string, + sha: string, + skipRunId: number +): Promise { + const workflowRuns = await octokit.actions.listRepoWorkflowRuns({ + owner, + repo, + branch, + event: 'pull_request', + status: 'completed', + // eslint-disable-next-line @typescript-eslint/camelcase + per_page: 100 + }) + // may be no need to rerun pending/queued runs + const filteredRunsIds: number[] = [] + const filteredWorklowRunsIds: number[] = [] + + for (const workflowRun of workflowRuns.data.workflow_runs) { + const workflowId = parseInt( + workflowRun.workflow_url.split('/').pop() || '0' + ) + + if ( + workflowRun.head_sha === sha && + !filteredRunsIds.includes(workflowId) && + workflowId !== skipRunId + ) { + filteredRunsIds.push(workflowId) + filteredWorklowRunsIds.push(workflowRun.id) + } + } + + return filteredWorklowRunsIds +} + +async function rerunWorkflows( + octokit: github.GitHub, + owner: string, + repo: string, + runIds: number[] +): Promise { + core.info(`Rerun worklowws: ${runIds}`) + for (const runId of runIds) { + await octokit.actions.reRunWorkflow({ + owner, + repo, + // eslint-disable-next-line @typescript-eslint/camelcase + run_id: runId + }) + } +} + async function printDebug( - item: object | string | boolean, + item: object | string | boolean | number, description: string ): Promise { const itemJson = JSON.stringify(item) @@ -157,18 +248,26 @@ async function printDebug( async function run(): Promise { const token = core.getInput('token', {required: true}) - const userLabel = core.getInput('label', {required: true}) - const requireCommittersApproval = core.getInput( - 'require_committers_approval', - { - required: true - } - ) + const userLabel = core.getInput('label', {required: false}) || 'not set' + const requireCommittersApproval = + core.getInput('require_committers_approval', { + required: false + }) === 'true' const octokit = new github.GitHub(token) const context = github.context const repository = getRequiredEnv('GITHUB_REPOSITORY') const eventName = getRequiredEnv('GITHUB_EVENT_NAME') + const selfRunId = parseInt(getRequiredEnv('GITHUB_RUN_ID')) const [owner, repo] = repository.split('/') + const selfWorkflowId = await getWorkflowId(octokit, selfRunId, owner, repo) + const branch = context.payload.pull_request?.head.ref + const sha = context.payload.pull_request?.head.sha + + core.info( + `\n############### Set Label When Approved start ##################\n` + + `label: "${userLabel}"\n` + + `requireCommittersApproval: ${requireCommittersApproval}` + ) if (eventName !== 'pull_request_review') { throw Error( @@ -188,21 +287,52 @@ async function run(): Promise { owner, repo, pullRequest.number, - requireCommittersApproval === 'true' + requireCommittersApproval ) const isApproved = processReviews( reviews, reviewers, committers, - requireCommittersApproval === 'true' + requireCommittersApproval ) // HANDLE LABEL - if (isApproved && !labelNames.includes(userLabel)) { - setLabel(octokit, owner, repo, pullRequest.number, userLabel) - } else if (!isApproved && labelNames.includes(userLabel)) { - removeLabel(octokit, owner, repo, pullRequest.number, userLabel) + let isLabelShouldBeSet = false + let isLabelShouldBeRemoved = false + + if (userLabel !== 'not set') { + isLabelShouldBeSet = isApproved && !labelNames.includes(userLabel) + isLabelShouldBeRemoved = !isApproved && labelNames.includes(userLabel) + + if (isLabelShouldBeSet) { + setLabel(octokit, owner, repo, pullRequest.number, userLabel) + } else if (isLabelShouldBeRemoved) { + removeLabel(octokit, owner, repo, pullRequest.number, userLabel) + } } + + //// Future option to rerun workflows if PR approved + //// Rerun workflow can have dynamic matrixes which check presence of labels + //// it is not possible to rerun successful runs + //// https://github.community/t/cannot-re-run-a-successful-workflow-run-using-the-rest-api/123661 + // + // if (isLabelShouldBeSet) { + // const prWorkflowRunsIds = await getPrWorkflowRunsIds( + // octokit, + // owner, + // repo, + // branch, + // sha, + // selfWorkflowId + // ) + // + // await rerunWorkflows(octokit, owner, repo, prWorkflowRunsIds) + // } + + // OUTPUT + verboseOutput('isApproved', String(isApproved)) + verboseOutput('labelSet', String(isLabelShouldBeSet)) + verboseOutput('labelRemoved', String(isLabelShouldBeRemoved)) } run()