├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── approve.test.ts ├── approve.ts ├── main.test.ts └── main.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/index.js linguist-generated=true 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | name: Lint and test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out code 10 | uses: actions/checkout@v4 11 | 12 | - name: Setup nodejs 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: "20" 16 | 17 | - name: Install dependencies 18 | run: npm ci 19 | 20 | - name: Check style with prettier 21 | run: npm run format-check 22 | 23 | - name: Run tests 24 | run: npm test 25 | 26 | - name: Compare the expected and actual dist/ directories 27 | run: | 28 | npm run build 29 | if [ "$(git diff --ignore-blank-lines --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then 30 | echo "Detected uncommitted changes after build. See status below:" 31 | git diff 32 | exit 1 33 | fi 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[typescript]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Harry Marr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto Approve GitHub Action 2 | 3 | [![CI](https://github.com/hmarr/auto-approve-action/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/hmarr/auto-approve-action/actions/workflows/ci.yml) 4 | 5 | **Name:** `hmarr/auto-approve-action` 6 | 7 | Automatically approve GitHub pull requests. 8 | 9 | **Important:** use v4 or later, as earlier versions use deprecated versions of node. If you're on an old version of GHES (with an old version of the node interpreter) you may need to use an easier version until you can upgrade. 10 | 11 | ## Usage instructions 12 | 13 | Create a workflow file (e.g. `.github/workflows/auto-approve.yml`) that contains a step that `uses: hmarr/auto-approve-action@v4`. Here's an example workflow file: 14 | 15 | ```yaml 16 | name: Auto approve 17 | on: pull_request_target 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | pull-requests: write 24 | steps: 25 | - uses: hmarr/auto-approve-action@v4 26 | ``` 27 | 28 | Combine with an `if` clause to only auto-approve certain users. For example, to auto-approve [Dependabot][dependabot] pull requests, use: 29 | 30 | ```yaml 31 | name: Auto approve 32 | 33 | on: pull_request_target 34 | 35 | jobs: 36 | auto-approve: 37 | runs-on: ubuntu-latest 38 | permissions: 39 | pull-requests: write 40 | if: github.actor == 'dependabot[bot]' 41 | steps: 42 | - uses: hmarr/auto-approve-action@v4 43 | ``` 44 | 45 | If you want to use this action from a workflow file that doesn't run on the `pull_request` or `pull_request_target` events, use the `pull-request-number` input: 46 | 47 | ```yaml 48 | name: Auto approve 49 | 50 | on: 51 | workflow_dispatch: 52 | inputs: 53 | pullRequestNumber: 54 | description: Pull request number to auto-approve 55 | required: false 56 | 57 | jobs: 58 | auto-approve: 59 | runs-on: ubuntu-latest 60 | permissions: 61 | pull-requests: write 62 | steps: 63 | - uses: hmarr/auto-approve-action@v4 64 | with: 65 | pull-request-number: ${{ github.event.inputs.pullRequestNumber }} 66 | ``` 67 | 68 | Optionally, you can provide a message for the review: 69 | 70 | ```yaml 71 | name: Auto approve 72 | 73 | on: pull_request_target 74 | 75 | jobs: 76 | auto-approve: 77 | runs-on: ubuntu-latest 78 | permissions: 79 | pull-requests: write 80 | if: github.actor == 'dependabot[bot]' 81 | steps: 82 | - uses: hmarr/auto-approve-action@v4 83 | with: 84 | review-message: "Auto approved automated PR" 85 | ``` 86 | 87 | ### Approving on behalf of a different user 88 | 89 | By default, this will use the [automatic GitHub token](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) that's provided to the workflow. This means the approval will come from the "github-actions" bot user. Make sure you enable the `pull-requests: write` permission in your workflow. 90 | 91 | To approve the pull request as a different user, pass a GitHub Personal Access Token into the `github-token` input. In order to approve the pull request, the token needs the `repo` scope enabled. 92 | 93 | ```yaml 94 | name: Auto approve 95 | 96 | on: pull_request_target 97 | 98 | jobs: 99 | auto-approve: 100 | runs-on: ubuntu-latest 101 | steps: 102 | - uses: hmarr/auto-approve-action@v4 103 | with: 104 | github-token: ${{ secrets.SOME_USERS_PAT }} 105 | ``` 106 | 107 | ### Approving Dependabot pull requests 108 | 109 | When a workflow is run in response to a Dependabot pull request using the `pull_request` event, the workflow won't have access to secrets. If you're trying to use a Personal Access Token (as above) but getting an error on Dependabot pull requests, this is probably why. 110 | 111 | Fortunately the fix is simple: use the `pull_request_target` event instead of `pull_request`. This runs the workflow in the context of the base branch of the pull request, which does have access to secrets. 112 | 113 | ## Why? 114 | 115 | GitHub lets you prevent merges of unapproved pull requests. However, it's occasionally useful to selectively circumvent this restriction - for instance, some people want Dependabot's automated pull requests to not require approval. 116 | 117 | [dependabot]: https://github.com/marketplace/dependabot 118 | 119 | ## Code owners 120 | 121 | If you're using a [CODEOWNERS file](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners), you'll need to give this action a personal access token for a user listed as a code owner. Rather than using a real user's personal access token, you're probably better off creating a dedicated bot user, and adding it to a team which you assign as the code owner. That way you can restrict the bot user's permissions as much as possible, and your workflow won't break when people leave the team. 122 | 123 | ## Development and release process 124 | 125 | Each major version corresponds to a branch (e.g. `v3`, `v4`). The latest major version (`v4` at the time of writing) is the repository's default branch. Releases are tagged with semver-style version numbers (e.g. `v1.2.3`). 126 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Auto Approve' 2 | description: 'Automatically approve pull requests' 3 | branding: 4 | icon: 'check-circle' 5 | color: 'green' 6 | inputs: 7 | github-token: 8 | default: ${{ github.token }} 9 | description: 'The GITHUB_TOKEN secret' 10 | required: false 11 | pull-request-number: 12 | description: '(optional) The ID of a pull request to auto-approve. By default, this action tries to use the pull_request event payload.' 13 | required: false 14 | review-message: 15 | description: '(optional) The message of the pull request review.' 16 | required: false 17 | runs: 18 | using: 'node20' 19 | main: 'dist/index.js' 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | transform: { 4 | '^.+\\.(ts|tsx)?$': 'ts-jest' 5 | } 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auto-approve-action", 3 | "version": "3.1.0", 4 | "description": "Automatically approve pull requests", 5 | "main": "dist/main.ts", 6 | "scripts": { 7 | "build": "ncc build src/main.ts", 8 | "format": "prettier --write **/*.ts", 9 | "format-check": "prettier --check **/*.ts", 10 | "test": "jest", 11 | "test:watch": "jest --watchAll" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/hmarr/auto-approve-action.git" 16 | }, 17 | "keywords": [ 18 | "actions" 19 | ], 20 | "author": "hmarr", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/hmarr/auto-approve-action/issues" 24 | }, 25 | "homepage": "https://github.com/hmarr/auto-approve-action#readme", 26 | "dependencies": { 27 | "@actions/core": "^1.10.1", 28 | "@actions/github": "^6.0.0" 29 | }, 30 | "devDependencies": { 31 | "@types/jest": "^29.5.12", 32 | "@types/node": "^20.11.16", 33 | "@vercel/ncc": "^0.38.1", 34 | "jest": "^29.7.0", 35 | "msw": "^2.1.5", 36 | "nock": "^13.5.1", 37 | "prettier": "^3.2.4", 38 | "ts-jest": "^29.1.2", 39 | "typescript": "^5.3.3" 40 | }, 41 | "prettier": {} 42 | } -------------------------------------------------------------------------------- /src/approve.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import { Context } from "@actions/github/lib/context"; 3 | import { approve } from "./approve"; 4 | import { HttpResponse, http } from "msw"; 5 | import { setupServer } from "msw/node"; 6 | 7 | const originalEnv = process.env; 8 | 9 | beforeEach(() => { 10 | jest.restoreAllMocks(); 11 | jest.spyOn(core, "setFailed").mockImplementation(jest.fn()); 12 | jest.spyOn(core, "info").mockImplementation(jest.fn()); 13 | 14 | process.env = { GITHUB_REPOSITORY: "hmarr/test" }; 15 | }); 16 | 17 | afterEach(() => { 18 | process.env = originalEnv; 19 | }); 20 | 21 | const mockServer = setupServer(); 22 | beforeAll(() => mockServer.listen({ onUnhandledRequest: "error" })); 23 | afterEach(() => mockServer.resetHandlers()); 24 | afterAll(() => mockServer.close()); 25 | 26 | function mockOctokit( 27 | method: "get" | "post" | "put" | "delete", 28 | path: string, 29 | status: number, 30 | body: any, 31 | ) { 32 | let isDone = false; 33 | mockServer.use( 34 | http[method](`https://api.github.com${path}`, () => { 35 | isDone = true; 36 | return HttpResponse.json(body, { status: status ?? 200 }); 37 | }), 38 | ); 39 | return { isDone: () => isDone }; 40 | } 41 | 42 | const apiMocks = { 43 | getUser: (status?: number, body?: object) => 44 | mockOctokit("get", "/user", status ?? 200, body ?? { login: "hmarr" }), 45 | getPull: (status?: number, body?: object) => 46 | mockOctokit( 47 | "get", 48 | "/repos/hmarr/test/pulls/101", 49 | status ?? 200, 50 | body ?? { head: { sha: "24c5451bbf1fb09caa3ac8024df4788aff4d4974" } }, 51 | ), 52 | getReviews: (status?: number, body?: any) => 53 | mockOctokit( 54 | "get", 55 | "/repos/hmarr/test/pulls/101/reviews", 56 | status ?? 200, 57 | body ?? [], 58 | ), 59 | createReview: () => 60 | mockOctokit("post", "/repos/hmarr/test/pulls/101/reviews", 200, {}), 61 | }; 62 | 63 | test("a review is successfully created with a PAT", async () => { 64 | apiMocks.getUser(); 65 | apiMocks.getPull(); 66 | apiMocks.getReviews(); 67 | const createReview = apiMocks.createReview(); 68 | 69 | expect( 70 | await approve({ 71 | token: "gh-tok", 72 | context: ghContext(), 73 | octokitOpts: { request: fetch }, 74 | }), 75 | ).toBeTruthy(); 76 | expect(createReview.isDone()).toBe(true); 77 | }); 78 | 79 | test("a review is successfully created with an Actions token", async () => { 80 | apiMocks.getUser(); 81 | apiMocks.getPull(); 82 | apiMocks.getReviews(); 83 | const createReview = apiMocks.createReview(); 84 | 85 | expect( 86 | await approve({ 87 | token: "gh-tok", 88 | context: ghContext(), 89 | octokitOpts: { request: fetch }, 90 | }), 91 | ).toBeTruthy(); 92 | expect(createReview.isDone()).toBe(true); 93 | }); 94 | 95 | test("when a review is successfully created with message", async () => { 96 | apiMocks.getUser(); 97 | apiMocks.getPull(); 98 | apiMocks.getReviews(); 99 | const createReview = apiMocks.createReview(); 100 | 101 | expect( 102 | await approve({ 103 | token: "gh-tok", 104 | context: ghContext(), 105 | reviewMessage: "Review body", 106 | octokitOpts: { request: fetch }, 107 | }), 108 | ).toBeTruthy(); 109 | expect(createReview.isDone()).toBe(true); 110 | }); 111 | 112 | test("when a review is successfully created using pull-request-number", async () => { 113 | apiMocks.getUser(); 114 | mockOctokit("get", "/repos/hmarr/test/pulls/102", 200, { 115 | head: { sha: "24c5451bbf1fb09caa3ac8024df4788aff4d4974" }, 116 | }); 117 | mockOctokit("get", "/repos/hmarr/test/pulls/102/reviews", 200, []); 118 | 119 | const createReview = mockOctokit( 120 | "post", 121 | "/repos/hmarr/test/pulls/102/reviews", 122 | 200, 123 | { id: 1 }, 124 | ); 125 | 126 | expect( 127 | await approve({ 128 | token: "gh-tok", 129 | context: ghContext(), 130 | prNumber: 102, 131 | octokitOpts: { request: fetch }, 132 | }), 133 | ).toBeTruthy(); 134 | 135 | expect(createReview.isDone()).toBe(true); 136 | }); 137 | 138 | test("when a review has already been approved by current user", async () => { 139 | apiMocks.getUser(); 140 | apiMocks.getPull(); 141 | apiMocks.getReviews(200, [ 142 | { 143 | user: { login: "hmarr" }, 144 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 145 | state: "APPROVED", 146 | }, 147 | ]); 148 | const createReview = apiMocks.createReview(); 149 | 150 | expect( 151 | await approve({ 152 | token: "gh-tok", 153 | context: ghContext(), 154 | octokitOpts: { request: fetch }, 155 | }), 156 | ).toBeFalsy(); 157 | 158 | expect(createReview.isDone()).toBe(false); 159 | expect(core.info).toHaveBeenCalledWith( 160 | expect.stringContaining( 161 | "Current user already approved pull request #101, nothing to do", 162 | ), 163 | ); 164 | }); 165 | 166 | test("when a review is pending", async () => { 167 | apiMocks.getUser(); 168 | apiMocks.getPull(); 169 | apiMocks.getReviews(200, [ 170 | { 171 | user: { login: "hmarr" }, 172 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 173 | state: "PENDING", 174 | }, 175 | ]); 176 | const createReview = apiMocks.createReview(); 177 | 178 | expect( 179 | await approve({ 180 | token: "gh-tok", 181 | context: ghContext(), 182 | prNumber: 101, 183 | octokitOpts: { request: fetch }, 184 | }), 185 | ).toBeTruthy(); 186 | expect(createReview.isDone()).toBe(true); 187 | }); 188 | 189 | test("when a review is dismissed", async () => { 190 | apiMocks.getUser(); 191 | apiMocks.getPull(); 192 | apiMocks.getReviews(200, [ 193 | { 194 | user: { login: "hmarr" }, 195 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 196 | state: "DISMISSED", 197 | }, 198 | ]); 199 | const createReview = apiMocks.createReview(); 200 | 201 | expect( 202 | await approve({ 203 | token: "gh-tok", 204 | context: ghContext(), 205 | prNumber: 101, 206 | octokitOpts: { request: fetch }, 207 | }), 208 | ).toBeTruthy(); 209 | expect(createReview.isDone()).toBe(true); 210 | }); 211 | 212 | test("when a review is dismissed, but an earlier review is approved", async () => { 213 | apiMocks.getUser(); 214 | apiMocks.getPull(); 215 | apiMocks.getReviews(200, [ 216 | { 217 | user: { login: "hmarr" }, 218 | commit_id: "6a9ec7556f0a7fa5b49527a1eea4878b8a22d2e0", 219 | state: "APPROVED", 220 | }, 221 | { 222 | user: { login: "hmarr" }, 223 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 224 | state: "DISMISSED", 225 | }, 226 | ]); 227 | const createReview = apiMocks.createReview(); 228 | 229 | expect( 230 | await approve({ 231 | token: "gh-tok", 232 | context: ghContext(), 233 | prNumber: 101, 234 | octokitOpts: { request: fetch }, 235 | }), 236 | ).toBeTruthy(); 237 | expect(createReview.isDone()).toBe(true); 238 | }); 239 | 240 | test("when a review is not approved", async () => { 241 | apiMocks.getUser(); 242 | apiMocks.getPull(); 243 | apiMocks.getReviews(200, [ 244 | { 245 | user: { login: "hmarr" }, 246 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 247 | state: "CHANGES_REQUESTED", 248 | }, 249 | ]); 250 | const createReview = apiMocks.createReview(); 251 | 252 | expect( 253 | await approve({ 254 | token: "gh-tok", 255 | context: ghContext(), 256 | prNumber: 101, 257 | octokitOpts: { request: fetch }, 258 | }), 259 | ).toBeTruthy(); 260 | expect(createReview.isDone()).toBe(true); 261 | }); 262 | 263 | test("when a review is commented", async () => { 264 | apiMocks.getUser(); 265 | apiMocks.getPull(); 266 | apiMocks.getReviews(200, [ 267 | { 268 | user: { login: "hmarr" }, 269 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 270 | state: "COMMENTED", 271 | }, 272 | ]); 273 | const createReview = apiMocks.createReview(); 274 | 275 | expect( 276 | await approve({ 277 | token: "gh-tok", 278 | context: ghContext(), 279 | prNumber: 101, 280 | octokitOpts: { request: fetch }, 281 | }), 282 | ).toBeTruthy(); 283 | expect(createReview.isDone()).toBe(true); 284 | }); 285 | 286 | test("when a review has already been approved by another user", async () => { 287 | apiMocks.getUser(); 288 | apiMocks.getPull(); 289 | apiMocks.getReviews(200, [ 290 | { 291 | user: { login: "some" }, 292 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 293 | state: "APPROVED", 294 | }, 295 | ]); 296 | const createReview = apiMocks.createReview(); 297 | 298 | expect( 299 | await approve({ 300 | token: "gh-tok", 301 | context: ghContext(), 302 | prNumber: 101, 303 | octokitOpts: { request: fetch }, 304 | }), 305 | ).toBeTruthy(); 306 | expect(createReview.isDone()).toBe(true); 307 | }); 308 | 309 | test("when a review has already been approved by unknown user", async () => { 310 | apiMocks.getUser(); 311 | apiMocks.getPull(); 312 | apiMocks.getReviews(200, [ 313 | { 314 | user: null, 315 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 316 | state: "APPROVED", 317 | }, 318 | ]); 319 | const createReview = apiMocks.createReview(); 320 | 321 | expect( 322 | await approve({ 323 | token: "gh-tok", 324 | context: ghContext(), 325 | prNumber: 101, 326 | octokitOpts: { request: fetch }, 327 | }), 328 | ).toBeTruthy(); 329 | expect(createReview.isDone()).toBe(true); 330 | }); 331 | 332 | test("when a review has been previously approved by user and but requests a re-review", async () => { 333 | apiMocks.getUser(); 334 | apiMocks.getPull(200, { 335 | head: { sha: "24c5451bbf1fb09caa3ac8024df4788aff4d4974" }, 336 | requested_reviewers: [{ login: "hmarr" }], 337 | }); 338 | apiMocks.getReviews(200, [ 339 | { 340 | user: { login: "some" }, 341 | commit_id: "24c5451bbf1fb09caa3ac8024df4788aff4d4974", 342 | state: "APPROVED", 343 | }, 344 | ]); 345 | 346 | const createReview = apiMocks.createReview(); 347 | 348 | expect( 349 | await approve({ 350 | token: "gh-tok", 351 | context: ghContext(), 352 | prNumber: 101, 353 | octokitOpts: { request: fetch }, 354 | }), 355 | ).toBeTruthy(); 356 | expect(createReview.isDone()).toBe(true); 357 | }); 358 | 359 | test("without a pull request", async () => { 360 | const createReview = apiMocks.createReview(); 361 | expect( 362 | await approve({ 363 | token: "gh-tok", 364 | context: new Context(), 365 | octokitOpts: { request: fetch }, 366 | }), 367 | ).toBeFalsy(); 368 | expect(createReview.isDone()).toBe(false); 369 | expect(core.setFailed).toHaveBeenCalledWith( 370 | expect.stringContaining("Make sure you're triggering this"), 371 | ); 372 | }); 373 | 374 | test("when the token is invalid", async () => { 375 | apiMocks.getUser(401, { message: "Bad credentials" }); 376 | apiMocks.getPull(401, { message: "Bad credentials" }); 377 | apiMocks.getReviews(401, { message: "Bad credentials" }); 378 | const createReview = apiMocks.createReview(); 379 | 380 | expect( 381 | await approve({ 382 | token: "gh-tok", 383 | context: ghContext(), 384 | octokitOpts: { request: fetch }, 385 | }), 386 | ).toBeFalsy(); 387 | expect(createReview.isDone()).toBe(false); 388 | expect(core.setFailed).toHaveBeenCalledWith( 389 | expect.stringContaining("`github-token` input parameter"), 390 | ); 391 | }); 392 | 393 | test("when the token doesn't have write permissions", async () => { 394 | apiMocks.getUser(); 395 | apiMocks.getPull(); 396 | apiMocks.getReviews(); 397 | mockOctokit("post", "/repos/hmarr/test/pulls/101/reviews", 403, { 398 | message: "Resource not accessible by integration", 399 | }); 400 | 401 | expect( 402 | await approve({ 403 | token: "gh-tok", 404 | context: ghContext(), 405 | octokitOpts: { request: fetch }, 406 | }), 407 | ).toBeFalsy(); 408 | expect(core.setFailed).toHaveBeenCalledWith( 409 | expect.stringContaining("pull_request_target"), 410 | ); 411 | }); 412 | 413 | test("when a user tries to approve their own pull request", async () => { 414 | apiMocks.getUser(); 415 | apiMocks.getPull(); 416 | apiMocks.getReviews(); 417 | mockOctokit("post", "/repos/hmarr/test/pulls/101/reviews", 422, { 418 | message: "Unprocessable Entity", 419 | }); 420 | 421 | expect( 422 | await approve({ 423 | token: "gh-tok", 424 | context: ghContext(), 425 | octokitOpts: { request: fetch }, 426 | }), 427 | ).toBeFalsy(); 428 | expect(core.setFailed).toHaveBeenCalledWith( 429 | expect.stringContaining("same user account"), 430 | ); 431 | }); 432 | 433 | test("when pull request does not exist or the token doesn't have access", async () => { 434 | apiMocks.getUser(); 435 | apiMocks.getPull(404, { message: "Not Found" }); 436 | apiMocks.getReviews(404, { message: "Not Found" }); 437 | const createReview = apiMocks.createReview(); 438 | 439 | expect( 440 | await approve({ 441 | token: "gh-tok", 442 | context: ghContext(), 443 | octokitOpts: { request: fetch }, 444 | }), 445 | ).toBeFalsy(); 446 | expect(createReview.isDone()).toBe(false); 447 | expect(core.setFailed).toHaveBeenCalledWith( 448 | expect.stringContaining("doesn't have access"), 449 | ); 450 | }); 451 | 452 | test("when the token is read-only", async () => { 453 | apiMocks.getUser(); 454 | apiMocks.getPull(); 455 | apiMocks.getReviews(); 456 | mockOctokit("post", "/repos/hmarr/test/pulls/101/reviews", 403, { 457 | message: "Not Authorized", 458 | }); 459 | 460 | expect( 461 | await approve({ 462 | token: "gh-tok", 463 | context: ghContext(), 464 | octokitOpts: { request: fetch }, 465 | }), 466 | ).toBeFalsy(); 467 | expect(core.setFailed).toHaveBeenCalledWith( 468 | expect.stringContaining("are read-only"), 469 | ); 470 | }); 471 | 472 | test("when the token doesn't have write access to the repository", async () => { 473 | apiMocks.getUser(); 474 | apiMocks.getPull(); 475 | apiMocks.getReviews(); 476 | mockOctokit("post", "/repos/hmarr/test/pulls/101/reviews", 404, { 477 | message: "Not Found", 478 | }); 479 | 480 | expect( 481 | await approve({ 482 | token: "gh-tok", 483 | context: ghContext(), 484 | octokitOpts: { request: fetch }, 485 | }), 486 | ).toBeFalsy(); 487 | expect(core.setFailed).toHaveBeenCalledWith( 488 | expect.stringContaining("doesn't have access"), 489 | ); 490 | }); 491 | 492 | function ghContext(): Context { 493 | const ctx = new Context(); 494 | ctx.payload = { 495 | pull_request: { 496 | number: 101, 497 | }, 498 | }; 499 | return ctx; 500 | } 501 | -------------------------------------------------------------------------------- /src/approve.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import { RequestError } from "@octokit/request-error"; 4 | import { Context } from "@actions/github/lib/context"; 5 | import { GitHub } from "@actions/github/lib/utils"; 6 | 7 | interface ApproveOptions { 8 | token: string; 9 | context: Context; 10 | prNumber?: number; 11 | reviewMessage?: string; 12 | 13 | // This lets us use the native fetch function in tests. @actions/github swaps out 14 | // the default fetch implementation with its own, which doesn't work with msw. 15 | octokitOpts?: Parameters[1]; 16 | } 17 | 18 | export async function approve({ 19 | token, 20 | context, 21 | prNumber, 22 | reviewMessage, 23 | octokitOpts, 24 | }: ApproveOptions): Promise { 25 | if (!prNumber) { 26 | prNumber = context.payload.pull_request?.number; 27 | } 28 | 29 | if (!prNumber) { 30 | core.setFailed( 31 | "Event payload missing `pull_request` key, and no `pull-request-number` provided as input." + 32 | "Make sure you're triggering this action on the `pull_request` or `pull_request_target` events.", 33 | ); 34 | return false; 35 | } 36 | 37 | const client = github.getOctokit(token, octokitOpts); 38 | 39 | try { 40 | const { owner, repo } = context.repo; 41 | 42 | core.info(`Fetching user, pull request information, and existing reviews`); 43 | const [login, { data: pr }, { data: reviews }] = await Promise.all([ 44 | getLoginForToken(client), 45 | client.rest.pulls.get({ owner, repo, pull_number: prNumber }), 46 | client.rest.pulls.listReviews({ owner, repo, pull_number: prNumber }), 47 | ]); 48 | 49 | core.info(`Current user is ${login}`); 50 | 51 | const prHead = pr.head.sha; 52 | core.info(`Commit SHA is ${prHead}`); 53 | 54 | // Only the most recent review for a user counts towards the review state 55 | const latestReviewForUser = [...reviews] 56 | .reverse() 57 | .find(({ user }) => user?.login === login); 58 | const alreadyReviewed = latestReviewForUser?.state === "APPROVED"; 59 | 60 | // If there's an approved review from a user, but there's an outstanding review request, 61 | // we need to create a new review. Review requests mean that existing "APPROVED" reviews 62 | // don't count towards the mergeability of the PR. 63 | const outstandingReviewRequest = pr.requested_reviewers?.some( 64 | (reviewer) => reviewer.login == login, 65 | ); 66 | 67 | if (alreadyReviewed && !outstandingReviewRequest) { 68 | core.info( 69 | `Current user already approved pull request #${prNumber}, nothing to do`, 70 | ); 71 | return false; 72 | } 73 | 74 | core.info( 75 | `Pull request #${prNumber} has not been approved yet, creating approving review`, 76 | ); 77 | await client.rest.pulls.createReview({ 78 | owner: context.repo.owner, 79 | repo: context.repo.repo, 80 | pull_number: prNumber, 81 | body: reviewMessage, 82 | event: "APPROVE", 83 | }); 84 | core.info(`Approved pull request #${prNumber}`); 85 | } catch (error) { 86 | if (error instanceof RequestError) { 87 | switch (error.status) { 88 | case 401: 89 | core.setFailed( 90 | `${error.message}. Please check that the \`github-token\` input ` + 91 | "parameter is set correctly.", 92 | ); 93 | break; 94 | case 403: 95 | core.setFailed( 96 | `${error.message}. In some cases, the GitHub token used for actions triggered ` + 97 | "from `pull_request` events are read-only, which can cause this problem. " + 98 | "Switching to the `pull_request_target` event typically resolves this issue.", 99 | ); 100 | break; 101 | case 404: 102 | core.setFailed( 103 | `${error.message}. This typically means the token you're using doesn't have ` + 104 | "access to this repository. Use the built-in `${{ secrets.GITHUB_TOKEN }}` token " + 105 | "or review the scopes assigned to your personal access token.", 106 | ); 107 | break; 108 | case 422: 109 | core.setFailed( 110 | `${error.message}. This typically happens when you try to approve the pull ` + 111 | "request with the same user account that created the pull request. Try using " + 112 | "the built-in `${{ secrets.GITHUB_TOKEN }}` token, or if you're using a personal " + 113 | "access token, use one that belongs to a dedicated bot account.", 114 | ); 115 | break; 116 | default: 117 | core.setFailed(`Error (code ${error.status}): ${error.message}`); 118 | } 119 | return false; 120 | } 121 | 122 | if (error instanceof Error) { 123 | core.setFailed(error); 124 | } else { 125 | core.setFailed("Unknown error"); 126 | } 127 | return false; 128 | } 129 | 130 | return true; 131 | } 132 | 133 | async function getLoginForToken( 134 | client: InstanceType, 135 | ): Promise { 136 | try { 137 | const { data: user } = await client.rest.users.getAuthenticated(); 138 | return user.login; 139 | } catch (error) { 140 | if (error instanceof RequestError) { 141 | // If you use the GITHUB_TOKEN provided by GitHub Actions to fetch the current user 142 | // you get a 403. For now we'll assume any 403 means this is an Actions token. 143 | if (error.status === 403) { 144 | return "github-actions[bot]"; 145 | } 146 | } 147 | throw error; 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/main.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import { Context } from "@actions/github/lib/context"; 4 | import { setupServer } from "msw/node"; 5 | import { approve } from "./approve"; 6 | import { run } from "./main"; 7 | 8 | jest.mock("./approve"); 9 | const mockedApprove = jest.mocked(approve); 10 | 11 | jest.mock("@actions/github"); 12 | const mockedGithub = jest.mocked(github); 13 | 14 | afterAll(() => { 15 | jest.unmock("./approve"); 16 | jest.unmock("@actions/github"); 17 | }); 18 | 19 | const originalEnv = process.env; 20 | 21 | const mockServer = setupServer(); 22 | beforeAll(() => mockServer.listen({ onUnhandledRequest: "error" })); 23 | afterAll(() => mockServer.close()); 24 | 25 | beforeEach(() => { 26 | jest.restoreAllMocks(); 27 | mockedApprove.mockReset(); 28 | jest.spyOn(core, "setFailed").mockImplementation(jest.fn()); 29 | 30 | process.env = { 31 | GITHUB_REPOSITORY: "hmarr/test", 32 | "INPUT_GITHUB-TOKEN": "tok-xyz", 33 | }; 34 | }); 35 | 36 | afterEach(() => { 37 | process.env = originalEnv; 38 | }); 39 | 40 | test("passes the review message to approve", async () => { 41 | mockedGithub.context = ghContext(); 42 | process.env["INPUT_REVIEW-MESSAGE"] = "LGTM"; 43 | await run(); 44 | expect(mockedApprove).toHaveBeenCalledWith({ 45 | token: "tok-xyz", 46 | context: expect.anything(), 47 | prNumber: 101, 48 | reviewMessage: "LGTM", 49 | }); 50 | }); 51 | 52 | test("calls approve when no PR number is provided", async () => { 53 | mockedGithub.context = ghContext(); 54 | await run(); 55 | expect(mockedApprove).toHaveBeenCalledWith({ 56 | token: "tok-xyz", 57 | context: expect.anything(), 58 | prNumber: 101, 59 | reviewMessage: undefined, 60 | }); 61 | }); 62 | 63 | test("calls approve when a valid PR number is provided", async () => { 64 | process.env["INPUT_PULL-REQUEST-NUMBER"] = "456"; 65 | await run(); 66 | expect(mockedApprove).toHaveBeenCalledWith({ 67 | token: "tok-xyz", 68 | context: expect.anything(), 69 | prNumber: 456, 70 | reviewMessage: undefined, 71 | }); 72 | }); 73 | 74 | test("errors when an invalid PR number is provided", async () => { 75 | process.env["INPUT_PULL-REQUEST-NUMBER"] = "not a number"; 76 | await run(); 77 | expect(mockedApprove).not.toHaveBeenCalled(); 78 | }); 79 | 80 | function ghContext(): Context { 81 | const ctx = new Context(); 82 | ctx.payload = { 83 | pull_request: { 84 | number: 101, 85 | }, 86 | }; 87 | return ctx; 88 | } 89 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import { approve } from "./approve"; 4 | 5 | export async function run() { 6 | try { 7 | const token = core.getInput("github-token"); 8 | const reviewMessage = core.getInput("review-message"); 9 | await approve({ 10 | token, 11 | context: github.context, 12 | prNumber: prNumber(), 13 | reviewMessage: reviewMessage || undefined, 14 | }); 15 | } catch (error) { 16 | if (error instanceof Error) { 17 | core.setFailed(error.message); 18 | } else { 19 | core.setFailed("Unknown error"); 20 | } 21 | } 22 | } 23 | 24 | function prNumber(): number { 25 | if (core.getInput("pull-request-number") !== "") { 26 | const prNumber = parseInt(core.getInput("pull-request-number"), 10); 27 | if (Number.isNaN(prNumber)) { 28 | throw new Error("Invalid `pull-request-number` value"); 29 | } 30 | return prNumber; 31 | } 32 | 33 | if (!github.context.payload.pull_request) { 34 | throw new Error( 35 | "This action must be run using a `pull_request` event or " + 36 | "have an explicit `pull-request-number` provided", 37 | ); 38 | } 39 | return github.context.payload.pull_request.number; 40 | } 41 | 42 | if (require.main === module) { 43 | run(); 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "noImplicitAny": false, 9 | "esModuleInterop": true 10 | }, 11 | "exclude": ["node_modules"] 12 | } 13 | --------------------------------------------------------------------------------