diff --git a/limit-pull-requests/action.yml b/limit-pull-requests/action.yml index 9c0f9ec3..cd9d8d7f 100644 --- a/limit-pull-requests/action.yml +++ b/limit-pull-requests/action.yml @@ -39,65 +39,5 @@ inputs: default: "false" runs: - using: composite - steps: - - name: Check the number of pull requests - id: count-pull-requests - run: | - # If the user is exempted, assume they have no pull requests. - if grep -Fiqx '${{ github.actor }}' <<<"$EXCEPT_USERS"; then - echo "::notice::@${{ github.actor }} is exempted from the limit." - echo "count=0" >>"$GITHUB_OUTPUT" - exit 0 - fi - if grep -Fiqx '${{ github.event.pull_request.author_association }}' <<<"$EXCEPT_AUTHOR_ASSOCIATIONS"; then - echo "::notice::@{{ github.actor }} is a ${{ github.event.pull_request.author_association }} exempted from the limit." - echo "count=0" >>"$GITHUB_OUTPUT" - exit 0 - fi - - count="$( - gh api \ - --method GET \ - --header 'Accept: application/vnd.github+json' \ - --header 'X-GitHub-Api-Version: 2022-11-28' \ - --field state=open \ - --paginate \ - '/repos/{owner}/{repo}/pulls' | - jq \ - --raw-output \ - --arg USER '${{ github.actor }}' \ - 'map(select(.user.login == $USER)) | length' - )" - echo "::notice::@${{ github.actor }} has $count open pull request(s)." - echo "count=$count" >>"$GITHUB_OUTPUT" - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ inputs.token }} - EXCEPT_USERS: ${{ inputs.except-users }} - EXCEPT_AUTHOR_ASSOCIATIONS: ${{ inputs.except-author-associations }} - shell: bash - - - name: Comment on pull request - if: > - fromJSON(steps.count-pull-requests.outputs.count) > fromJSON(inputs.comment-limit) && - inputs.comment != '' - run: | - gh pr comment '${{ github.event.pull_request.number }}' \ - --body="${COMMENT_BODY}" - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ inputs.token }} - COMMENT_BODY: ${{ inputs.comment }} - shell: bash - - - name: Close pull request - if: > - fromJSON(steps.count-pull-requests.outputs.count) > fromJSON(inputs.close-limit) && - inputs.close == 'true' - run: | - gh pr close '${{ github.event.pull_request.number }}' - env: - GH_REPO: ${{ github.repository }} - GH_TOKEN: ${{ inputs.token }} - shell: bash + using: node20 + main: main.mjs diff --git a/limit-pull-requests/main.mjs b/limit-pull-requests/main.mjs new file mode 100644 index 00000000..99f7fa13 --- /dev/null +++ b/limit-pull-requests/main.mjs @@ -0,0 +1,74 @@ +import core from "@actions/core"; +import github from "@actions/github"; + +async function main() { + try { + const eventName = github.context.eventName; + if (!["pull_request", "pull_request_target"].includes(eventName)) { + core.setFailed(`${eventName} is not a supported event. Only pull_request ` + + `and pull_request_target are supported.`); + return; + } + + const token = core.getInput("token", { required: true }); + + const exceptUsersInput = core.getInput("except-users"); + const exceptUsers = exceptUsersInput ? exceptUsersInput.split(",") : []; + const exceptAuthorAssocsInput = + core.getInput("except-author-associations"); + const exceptAuthorAssocs = + exceptAuthorAssocsInput ? exceptAuthorAssocsInput.split(",") : []; + + const commentLimit = core.getInput("comment-limit"); + const comment = core.getInput("comment"); + const closeLimit = core.getInput("close-limit"); + const close = core.getBooleanInput("close"); + + if (!comment && !close) { + core.info("No action specified; exiting."); + return; + } + if (exceptUsers.includes(github.context.actor)) { + core.info(`@${github.context.actor} is exempted from the limit.`); + return; + } + if (exceptAuthorAssocs.includes( + github.context.payload.pull_request.author_association)) { + core.info(`@${github.context.actor} is exempted from the limit.`); + return; + } + + const client = github.getOctokit(token); + const openPrs = await client.paginate(client.rest.pulls.list, { + ...github.context.repo, + state: "open", + per_page: 100, + }); + const prCount = openPrs + .filter(pr => pr.user.login === github.context.actor) + .length; + core.info(`@${github.context.actor} has ${prCount} open PR(s).`); + + if (comment && prCount >= commentLimit) { + await client.rest.issues.createComment({ + ...github.context.repo, + issue_number: github.context.payload.pull_request.number, + body: comment, + }); + core.notice(`Soft limit reached; commented on PR.`); + } + + if (close && prCount >= closeLimit) { + await client.rest.pulls.update({ + ...github.context.repo, + pull_number: github.context.payload.pull_request.number, + state: "closed", + }); + core.notice(`Hard limit reached; closed PR.`); + } + } catch (error) { + core.setFailed(error); + } +} + +await main(); diff --git a/limit-pull-requests/main.test.mjs b/limit-pull-requests/main.test.mjs new file mode 100644 index 00000000..7ea1eac4 --- /dev/null +++ b/limit-pull-requests/main.test.mjs @@ -0,0 +1,130 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import util from "node:util"; + +describe("limit-pull-requests", async () => { + const token = "fake-token"; + const commentLimit = 3; + const comment = "Please don't open too many PRs!"; + const closeLimit = 5; + const close = true; + const exemptedUser = "exempted-user"; + + const prNumber = 12345; + + const GITHUB_ACTOR = "fake-actor"; + const GITHUB_EVENT_NAME = "pull_request_target"; + + let directory; + let eventPath; + + before(async () => { + process.env.GITHUB_ACTOR = GITHUB_ACTOR; + process.env.GITHUB_EVENT_NAME = GITHUB_EVENT_NAME; + + directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "limit-pull-requests-")); + eventPath = path.join(directory, "event.json"); + await fs.promises.writeFile(eventPath, JSON.stringify({ + pull_request: { + number: prNumber, + author_association: "CONTRIBUTOR", + }, + })); + + process.env.GITHUB_EVENT_PATH = eventPath; + }); + + after(async () => { + await fs.promises.rm(directory, { recursive: true }); + }); + + beforeEach(async () => { + mockInput("token", token); + mockInput("comment-limit", commentLimit.toString()); + mockInput("comment", comment); + mockInput("close-limit", closeLimit.toString()); + mockInput("close", close.toString()); + mockInput("except-users", exemptedUser); + }); + + it("does nothing if no limit is reached", async () => { + const actor = GITHUB_ACTOR; + const mockPool = githubMockPool(); + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls?per_page=100&state=open`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { user: { login: actor } }, + { user: { login: "some-other-actor-1" } }, + { user: { login: "some-other-actor-2" } }, + { user: { login: "some-other-actor-3" } }, + ]); + + await loadMain(); + }); + + it("posts a comment and closes the PR if limits are reached", async () => { + const actor = GITHUB_ACTOR; + const mockPool = githubMockPool(); + + mockPool.intercept({ + method: "GET", + path: `/repos/${GITHUB_REPOSITORY}/pulls?per_page=100&state=open`, + headers: { + Authorization: `token ${token}`, + }, + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, [ + { user: { login: actor } }, + { user: { login: actor } }, + { user: { login: actor } }, + { user: { login: actor } }, + { user: { login: actor } }, + { user: { login: actor } }, + { user: { login: "some-other-actor-1" } }, + { user: { login: "some-other-actor-2" } }, + { user: { login: "some-other-actor-3" } }, + ]); + + mockPool.intercept({ + method: "POST", + path: `/repos/${GITHUB_REPOSITORY}/issues/${prNumber}/comments`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + body: comment, + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}); + + mockPool.intercept({ + method: "PATCH", + path: `/repos/${GITHUB_REPOSITORY}/pulls/${prNumber}`, + headers: { + Authorization: `token ${token}`, + }, + body: (body) => util.isDeepStrictEqual(JSON.parse(body), { + state: "closed", + }), + }).defaultReplyHeaders({ + "Content-Type": "application/json", + }).reply(200, {}); + + await loadMain(); + }); + + it("does nothing if limits are reached but user is exempted", async () => { + process.env.GITHUB_ACTOR = exemptedUser; + await loadMain(); + }); +});