├── .env.example ├── .eslintrc ├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── .pullierc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── babel.config.cjs ├── commenter.js ├── config-processor.js ├── docs.js ├── index.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── plugins ├── base.js ├── index.js ├── jira │ └── index.js ├── required-file │ └── index.js ├── reviewers │ └── index.js └── welcome │ └── index.js ├── processor.js ├── renovate.json ├── static ├── pullie-flood-128.png ├── pullie-flood-256.png ├── pullie-light-128.png ├── pullie-light-256.png ├── pullie-transparent-128.png ├── pullie-transparent-256.png ├── pullie-white-128.png ├── pullie-white-256.png └── pullie.svg ├── test ├── fixtures │ ├── config.json │ ├── mock-key.pem │ └── payloads │ │ ├── mock-org-pullierc.json │ │ ├── mock-packagejson.json │ │ ├── mock-pullierc.json │ │ └── open-pr.json ├── integration │ ├── helpers.js │ └── index.test.js └── unit │ ├── commenter.test.js │ ├── config-processor.test.js │ ├── plugins.test.js │ ├── plugins │ ├── base.test.js │ ├── jira.test.js │ ├── required-file.test.js │ ├── reviewers.test.js │ └── welcome.test.js │ ├── processor.test.js │ └── utils.test.js ├── utils.js └── views └── home.hbs /.env.example: -------------------------------------------------------------------------------- 1 | # The ID of your GitHub App 2 | APP_ID= 3 | 4 | # The URL of your GitHub App 5 | APP_URL= 6 | 7 | # The webhook secret 8 | WEBHOOK_SECRET= 9 | 10 | # Optionally, you may change where the webhook path is. By default, it will be at the root (i.e. https://myserver.com/). 11 | #WEBHOOK_PATH=/api 12 | 13 | # Optionally, you may change where the docs are exposed. By default, it will be at /docs. 14 | #DOCS_PATH=/ 15 | 16 | # Optionally, you may disable the docs route altogether. 17 | #DISABLE_DOCS_ROUTE=true 18 | 19 | # If you load your private key into the same folder as the Pullie code, it will be automatically loaded. Otherwise, 20 | # you may uncomment one of the two lines below to either specify a path for the key, or include the key directly as 21 | # base64. 22 | #PRIVATE_KEY_PATH=/path/to/key.pem 23 | #PRIVATE_KEY= 24 | 25 | # The hostname of the GitHub Enterprise instance (e.g. github.mycompany.com) 26 | GHE_HOST= 27 | 28 | # For use with GitHub Enterprise Cloud, you may specify an Enterprise ID to allow. Requests from any repos outside of 29 | # this Enterprise will be ignored. The ID should be the numeric ID of your Enterprise. 30 | #GH_ENTERPRISE_ID= 31 | 32 | # You may choose to disable Pullie from running on public repos. This is especially useful when you are using the Jira 33 | # plugin and don't want to expose such information publicly. 34 | #NO_PUBLIC_REPOS=true 35 | 36 | # Use `trace` to get verbose logging or `info` to show less 37 | LOG_LEVEL=debug 38 | 39 | # Go to https://smee.io/new set this to the URL that you are redirected to. 40 | WEBHOOK_PROXY_URL= 41 | 42 | # Jira settings 43 | JIRA_PROTOCOL=https 44 | JIRA_HOST=jira.mycompany.com 45 | JIRA_USERNAME= 46 | JIRA_PASSWORD= 47 | 48 | # Optional format specifier for reviewers plugin comment. Can also be configured on a per-repo basis in .pullierc 49 | #REVIEWERS_COMMENT_FORMAT="Hi! I have requested reviews from a few of this repository's maintainers: %s.\n**DO NOT MERGE** this PR without two ✅ reviews (with at least one ✅ from the maintainers)" 50 | 51 | # Optional message to greet your new contributors 52 | #WELCOME_MESSAGE="Hey there, thanks for contributing to the project!" 53 | 54 | # Optional variable if defined, appends a suffix to the username pulled out from the email address, before checking if the user is a collaborator in the repo. 55 | #GITHUB_USER_SUFFIX="someusernamesuffix" 56 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "godaddy", 3 | "env": { 4 | "node": true 5 | }, 6 | "parser": "@babel/eslint-parser", 7 | "parserOptions": { 8 | "ecmaVersion": 2020 9 | }, 10 | "plugins": [ 11 | "eslint-plugin-import", 12 | "eslint-plugin-json" 13 | ], 14 | "rules": { 15 | "no-process-env": 0, 16 | "eqeqeq": ["error", "always", { "null": "always" }] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | package-lock.json binary 2 | dist/pullie.svg binary 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ main ] 9 | pull_request: 10 | branches: [ main ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x, 16.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm test 30 | - run: npm run coverage 31 | if: ${{ matrix.node-version == '14.x' }} # only report coverage on latest Node LTS 32 | - name: Coveralls 33 | if: ${{ matrix.node-version == '14.x' }} # only report coverage on latest Node LTS 34 | uses: coverallsapp/github-action@master 35 | with: 36 | github-token: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '28 0 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - '[0-9]+.[0-9]+.[0-9]+**' # Push events to tags matching semver 6 | 7 | name: Create Release 8 | 9 | jobs: 10 | build: 11 | name: Create Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | - name: Create Release 17 | id: create_release 18 | uses: actions/create-release@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 21 | with: 22 | tag_name: ${{ github.ref }} 23 | release_name: ${{ github.ref }} 24 | body: | 25 | See [CHANGELOG](/CHANGELOG.md) 26 | draft: false 27 | prerelease: ${{ contains(github.ref, '-') }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | .nyc_output 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | node_modules 29 | 30 | # Optional npm cache directory 31 | .npm 32 | 33 | # Optional REPL history 34 | .node_repl_history 35 | 36 | # User specific stuff 37 | .idea/ 38 | .DS_Store 39 | 40 | .env 41 | .env.local 42 | -------------------------------------------------------------------------------- /.pullierc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "jira", 4 | { 5 | "plugin": "reviewers", 6 | "config": { 7 | "howMany": 2 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 6.1.2 4 | 5 | - Update dependencies 6 | 7 | ## 6.1.1 8 | 9 | - Update dependencies 10 | 11 | ## 6.1.0 12 | 13 | - Add support for resolving maintainer emails using a standard suffix in reviewers plugin 14 | - Update dependencies 15 | 16 | ## 6.0.6 17 | 18 | - Update dependencies 19 | 20 | ## 6.0.5 21 | 22 | - Update dependencies 23 | 24 | ## 6.0.4 25 | 26 | - Update dependencies 27 | 28 | ## 6.0.3 29 | 30 | - Update dependencies 31 | 32 | ## 6.0.2 33 | 34 | - Update dependencies 35 | 36 | ## 6.0.1 37 | 38 | - Update `node-fetch` 39 | 40 | ## 6.0.0 41 | 42 | - **MAJOR:** Convert to ES Modules 43 | - Running Pullie directly? No change is required to consume this update 44 | - Using wrapper code to run Pullie in Serverless for example? You will probably need to update your wrapper to be ESM as well 45 | and apply corresponding shims as needed in your wrapping environment. See [this sample](https://github.com/nponeccop/serverless-openapi-templates/tree/master/esmodules) 46 | for an example of how to wrap an ESM handler using the Serverless Framework. 47 | - **MAJOR:** Update to `probot@12` 48 | - For most cases, this is not a breaking change, as Pullie already did not consume any of the changed or removed APIs. 49 | However, if you install `probot` in any wrapping code, you should update to use version 12. 50 | - **MAJOR:** Dropped Node.js 12 support 51 | - While Node.js 12 is still supported as an LTS release until mid-2022, Pullie's new ESM update made this a good oportunity 52 | to shift our support window to 14 or later. Node.js 14 is the latest LTS release as of August 2021. Pullie is also tested 53 | with Node.js 16. 54 | 55 | ## 6.0.0-alpha.1 56 | 57 | - **MAJOR:** Update to `probot@12` 58 | 59 | ## 6.0.0-alpha.0 60 | 61 | - **MAJOR:** Convert to ES Modules 62 | 63 | ## 5.0.1 64 | 65 | - Update dependencies 66 | 67 | ## 5.0.0 68 | 69 | - See [5.0.0-alpha.0](#500-alpha0) 70 | 71 | ## 5.0.0-alpha.0 72 | 73 | - **MAJOR:** Update to `probot@11` 74 | - Update other various dependencies 75 | 76 | ## 4.1.0 77 | 78 | - Replace various deprecated API calls into Probot in preparation for `probot@11` 79 | 80 | ## 4.0.1 81 | 82 | - Update dependencies 83 | 84 | ## 4.0.0 85 | 86 | - **MAJOR:** Drop support for `node@8` 87 | - Move from Travis CI to GitHub Actions 88 | 89 | ## 3.4.0 90 | 91 | - Update to `probot@10` 92 | 93 | ## 3.3.0 94 | 95 | - See 3.3.0-beta.0 96 | 97 | ## 3.3.0-beta.0 98 | 99 | - Replace `request` with `node-fetch` 100 | 101 | ## 3.2.1 102 | 103 | - Update deps 104 | 105 | ## 3.2.0 106 | 107 | - [feat] Add support for limiting Pullie to a single approved GitHub Enterprise Cloud Enterprise 108 | - [feat] Add support for limiting Pullie to only run on non-public repositories 109 | 110 | ## 3.1.0 111 | 112 | - [feat] Add `welcome` plugin 113 | 114 | ## 3.0.0 115 | 116 | - **BREAKING:** Suppress review requests for draft PRs unless `requestForDrafts` is set to `true` in reviewers plugin 117 | config. 118 | 119 | ## 3.0.0-beta2 120 | 121 | - [fix] Listen for `ready_for_review` events 122 | 123 | ## 3.0.0-beta 124 | 125 | - **BREAKING:** Suppress review requests for draft PRs unless `requestForDrafts` is set to `true` in reviewers plugin 126 | config. 127 | 128 | ## 2.0.1 129 | 130 | - Update dependencies 131 | 132 | ## 2.0.0 133 | 134 | - **MAJOR:** Final release of Pullie 2.0.0 135 | 136 | ## 2.0.0-rc6 137 | 138 | - [feat] Org-level configuration support 139 | 140 | ## 2.0.0-rc5 141 | 142 | - [fix] Check Collaborator rejects on unknown users, so handle that appropriately 143 | 144 | ## 2.0.0-rc4 145 | 146 | - [fix] Handle case with no `.pullierc` file better 147 | 148 | ## 2.0.0-rc3 149 | 150 | - [fix] Make resource paths relative in docs page 151 | 152 | ## 2.0.0-rc2 153 | 154 | - [fix] Make `setupDocsRoutes` synchronous 155 | 156 | ## 2.0.0-rc1 157 | 158 | - [fix] Fix static path in docs routes 159 | 160 | ## 2.0.0-rc0 161 | 162 | - **BREAKING:** Rewrite on [Probot](https://probot.github.io) 163 | 164 | ## 1.3.1 165 | 166 | - [dist] Update dependencies 167 | - [fix] Do not log HTTP calls for healthcheck route 168 | - [fix] Output port number that service is running on 169 | 170 | ## 1.3.0 171 | 172 | - [feat] Adjust `reviewers` plugin to post a medium-priority comment instead of high-priority 173 | - [feat] Adjust `requiredFile` plugin to prefix the comment message with a ⚠️ emoji so that attention is drawn to it better 174 | - [dist] Update dependencies to latest 175 | 176 | ## 1.2.0 177 | 178 | - [feat] Add `process` interceptor 179 | 180 | ## 1.1.1 181 | 182 | - [fix] Include PR number in error logs 183 | 184 | ## 1.1.0 185 | 186 | - [feat] Add logging for request info (e.g. HTTP status, path, remote IP) 187 | 188 | ## 1.0.1 189 | 190 | - [fix] Set `"main"` field of `package.json` properly 191 | 192 | ## 1.0.0 193 | 194 | - Birth of pullie 195 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [style]: https://standardjs.com/ 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Issues and PRs 13 | 14 | If you have suggestions for how this project could be improved, or want to report a bug, open an issue! We'd love all and any contributions. If you have questions, too, we'd love to hear them. 15 | 16 | We'd also love PRs. If you're thinking of a large PR, we advise opening up an issue first to talk about it, though! Look at the links below if you're not sure how to open a PR. 17 | 18 | ## Submitting a pull request 19 | 20 | 1. [Fork][fork] and clone the repository. 21 | 1. Configure and install the dependencies: `npm install`. 22 | 1. Make sure the tests pass on your machine: `npm test`, note: these tests also apply the linter, so there's no need to lint separately. 23 | 1. Create a new branch: `git checkout -b my-branch-name`. 24 | 1. Make your change, add tests, and make sure the tests still pass. 25 | 1. Push to your fork and [submit a pull request][pr]. 26 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 27 | 28 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 29 | 30 | - Follow the [style guide][style] which is using standard. Any linting errors should be shown when running `npm test`. 31 | - Write and update tests. 32 | - Keep your changes as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 33 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 34 | 35 | Work in Progress pull requests are also welcome to get feedback early on, or if there is something blocked you. 36 | 37 | ## Resources 38 | 39 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 40 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 41 | - [GitHub Help](https://help.github.com) 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 GoDaddy Operating Company, LLC. 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 | ![Node.js CI](https://github.com/godaddy/pullie/workflows/Node.js%20CI/badge.svg) [![Coverage Status](https://coveralls.io/repos/github/godaddy/pullie/badge.svg?branch=master)](https://coveralls.io/github/godaddy/pullie?branch=master) 2 | 3 | [![NPM](https://nodei.co/npm/pullie.png?downloads=true&stars=true)](https://nodei.co/npm/pullie/) 4 | 5 | # pullie 6 | 7 | > ⚠️ **DEPRECATED**: This package is no longer maintained and has been deprecated. Please use an alternative solution or contact the maintainers for more information. 8 | 9 | Pullie is a GitHub bot that makes your PRs better. It runs as a GitHub App and receives webhook calls whenever a pull 10 | request is made against a repo on which the app is installed. Plugins provide handy functionality like linking to Jira 11 | tickets, requesting reviews, and commenting about a missing required file change. 12 | 13 | ## System requirements 14 | 15 | Pullie runs on public GitHub or on GitHub Enterprise 2.14 or later. It requires Node.js 14 or later. 16 | 17 | ## How to run/deploy 18 | 19 | The easiest way to run Pullie is to clone this repo and run `npm start`. You can do so in a Docker container for easy 20 | deployment. 21 | 22 | ## Configuration 23 | 24 | You must specify configuration values via environment variables. You may do so using a `.env` file. See 25 | [`.env.example`](./.env.example) for a sample configuration with full documentation. 26 | 27 | ### Setting up your GitHub App 28 | 29 | Pullie runs as a GitHub App, so if you are installing it on your own GitHub Enterprise instance, you must register a 30 | GitHub App there first. Here's how: 31 | 32 | 1. Browse to your GHE server homepage 33 | 2. Go to your user icon in the top right and select **Settings** from the menu 34 | 3. Select **Developer Settings** on the left side 35 | 4. Select **GitHub Apps** on the left side 36 | 5. Press the **New GitHub App** button on the top right 37 | 6. Fill out the form as follows: 38 | 39 | - **GitHub App name:** Pullie 40 | - **Description (optional):** Pullie is a GitHub bot that makes your PRs better. It runs as a GitHub App and receives 41 | webhook calls whenever a pull request is made against a repo on which the app is installed. Plugins provide handy 42 | functionality like linking to Jira tickets, requesting reviews, and commenting about a missing required file change. 43 | - **Homepage URL:** The base URL of your Pullie deployment (e.g. https://pullie.example.com) 44 | - **User authorization callback URL:** Same URL as above 45 | - **Setup URL (optional):** Not needed -- leave this blank 46 | - **Webhook URL:** Your deployment's base URL (e.g. https://pullie.example.com/) (unless `WEBHOOK_PATH` is adjusted in 47 | config) 48 | - **Webhook secret (optional):** Choose a random string as your webhook secret (e.g. a random UUID perhaps) 49 | 50 | - **Permissions:** Leave all as **No access** _except_ the following: 51 | - **Repository administration:** Read-only 52 | - **Repository contents:** Read-only 53 | - **Repository metadata:** Read-only 54 | - **Pull requests:** Read & write 55 | - **Single file:** Read-only for `.pullierc` 56 | - **Organization members:** Read-only 57 | 58 | - **Subscribe to events:** Leave all unchecked _except_ **Pull request** 59 | 60 | - **Where can this GitHub App be installed?** This is up to you. If you choose **Only on this account**, other users in 61 | your GHE instance will not see your application. 62 | 63 | 7. Press the **Create GitHub App** button 64 | 8. Now you have a GitHub App, so we need to collect some information to use in our configuration file: 65 | - Scroll to the bottom of the configuration page for your new GitHub App 66 | - Copy the **ID** and use as your app's ID in the config file 67 | - Copy the **Client ID** and use as your app's client ID in the config file 68 | - Copy the **Client secret** and use as your app's client secret in the config file 69 | - Click the **Generate private key** button to create your app's private key file. Copy the file it downloads to your 70 | Pullie deployment in a secure place (e.g. by using Kubernetes Secrets) 71 | 9. Upload a logo for Pullie. You can use one of the PNG files in the `static` folder of the Pullie npm package if you'd 72 | like. 73 | 74 | ### Install your GitHub App on an org/user 75 | 76 | Now, you can install your GitHub App on an org or user. Select **Install App** on the left side of the App's config 77 | page and then press the green **Install** button on any org(s) and/or user(s) you'd like Pullie to run on. Pullie will 78 | not do anything unless a repo has a `.pullierc` file, so it is safe to install across an org. 79 | 80 | ## User documentation 81 | 82 | User docs are available at the docs URL of your Pullie deployment (e.g https://pullie.example.com/docs). Just browse 83 | there and you'll see full documentation on installing the App and configuring a repo to work with it. 84 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | We take security very seriously at GoDaddy. We appreciate your efforts to 4 | responsibly disclose your findings, and will make every effort to acknowledge 5 | your contributions. 6 | 7 | ## Where should I report security issues? 8 | 9 | In order to give the community time to respond and upgrade, we strongly urge you 10 | report all security issues privately. 11 | 12 | To report a security issue in one of our Open Source projects email us directly 13 | at **oss@godaddy.com** and include the word "SECURITY" in the subject line. 14 | 15 | This mail is delivered to our Open Source Security team. 16 | 17 | After the initial reply to your report, the team will keep you informed of the 18 | progress being made towards a fix and announcement, and may ask for additional 19 | information or guidance. 20 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | targets: { 5 | node: 'current' 6 | } 7 | }] 8 | ] 9 | }; 10 | -------------------------------------------------------------------------------- /commenter.js: -------------------------------------------------------------------------------- 1 | export default class Commenter { 2 | /** 3 | * Unified commenter module 4 | * 5 | * @constructor 6 | * @public 7 | */ 8 | constructor() { 9 | this.comments = []; 10 | } 11 | 12 | /** 13 | * Add a comment to the queue 14 | * 15 | * @memberof Commenter 16 | * @public 17 | * @param {String} msg The message to post 18 | * @param {Commenter.priority} priority The priority of the comment 19 | */ 20 | addComment(msg, priority) { 21 | if (!msg || typeof priority !== 'number' || priority < Commenter.priority.Low || priority > Commenter.priority.High) { 22 | throw new Error('Missing message or priority'); 23 | } 24 | 25 | this.comments.push({ 26 | msg, 27 | priority 28 | }); 29 | } 30 | 31 | /** 32 | * Flush the comment queue to a sorted string 33 | * 34 | * @memberof Commenter 35 | * @public 36 | * @returns {String} All the queued comments, sorted by descending order, concatenated into a string with appropriate formatting 37 | */ 38 | flushToString() { 39 | if (this.comments.length === 0) return null; 40 | 41 | this.comments.sort((a, b) => { 42 | return a.priority === b.priority ? 0 : a.priority > b.priority ? -1 : 1; // eslint-disable-line no-nested-ternary 43 | }); 44 | 45 | const commentList = this.comments.map(c => c.msg).join('\n\n---\n\n'); 46 | 47 | this.comments = []; 48 | 49 | return commentList; 50 | } 51 | 52 | /** 53 | * Priority options to pass to `addComment` 54 | * 55 | * @enum {Number} 56 | * @readonly 57 | * @public 58 | */ 59 | static priority = { 60 | Low: 0, 61 | Medium: 1, 62 | High: 2 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /config-processor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('./plugins/base.js')} BasePlugin 3 | * @typedef {{[key: string]: BasePlugin}} PluginManager 4 | */ 5 | /** 6 | * @typedef {Object} Plugin 7 | * @prop {string} plugin The name of the plugin 8 | * @prop {Object} [config] Arbitrary plugin-specific configuration 9 | */ 10 | /** 11 | * @typedef {Array} PluginList 12 | */ 13 | /** 14 | * @typedef {Object} PluginMergeManifest 15 | * @prop {string[]} [exclude] A list of plugin names to exclude from the base set 16 | * @prop {PluginList} [include] A list of plugins (names or objects) to merge on top of the base set 17 | */ 18 | /** 19 | * @typedef {Object} PullieConfig 20 | * @prop {PluginList} plugins The list of plugins to run 21 | */ 22 | /** 23 | * @typedef {Object} PullieRepoConfig 24 | * @prop {PluginList | PluginMergeManifest} plugins The list of plugins to run 25 | */ 26 | /** 27 | * @callback OnInvalidPluginCallback 28 | * @param {string} name The name of the invalid plugin 29 | */ 30 | 31 | import deepClone from 'clone-deep'; 32 | import deepMerge from 'deepmerge'; 33 | 34 | /** 35 | * Process the specified org-level and repo-level config into a merged configuration 36 | * 37 | * @param {PluginManager} pluginManager The plugin manager 38 | * @param {PullieConfig} orgConfig The org-level config 39 | * @param {PullieRepoConfig} repoConfig The repo-level config 40 | * @param {OnInvalidPluginCallback} [onInvalidPlugin] Function to call when an invalid plugin is encountered 41 | * @returns {PullieConfig} The merged config 42 | * @public 43 | */ 44 | export default function processConfig(pluginManager, orgConfig, repoConfig, onInvalidPlugin) { 45 | if (!orgConfig) { 46 | // Set up a default orgConfig so we can properly transform the repo config below 47 | orgConfig = { 48 | plugins: [] 49 | }; 50 | } 51 | 52 | if (!repoConfig) return { 53 | ...orgConfig 54 | }; 55 | 56 | const { plugins: orgPlugins, ...restOfOrgConfig } = orgConfig; 57 | const { plugins: repoPlugins, ...restOfRepoConfig } = repoConfig; 58 | 59 | // Start by deep-merging any config values other than plugins 60 | const config = deepMerge(restOfOrgConfig, restOfRepoConfig); 61 | let plugins = orgPlugins; 62 | 63 | // Now, check if the repo's plugins field is a merge manifest or a normal list 64 | if (Array.isArray(repoPlugins)) { 65 | // It is a list, treat it as an include list 66 | plugins = applyIncludeList({ pluginManager, orgPlugins, repoIncludeList: repoPlugins, onInvalidPlugin }); 67 | } else if (typeof repoPlugins === 'object') { 68 | // It is a merge manifest, handle excludes first 69 | if (repoPlugins.exclude) { 70 | plugins = applyExcludeList({ orgPlugins, repoExcludeList: repoPlugins.exclude }); 71 | } 72 | 73 | // Now handle includes 74 | if (repoPlugins.include) { 75 | plugins = applyIncludeList({ 76 | pluginManager, 77 | orgPlugins: plugins, 78 | repoIncludeList: repoPlugins.include, 79 | onInvalidPlugin }); 80 | } 81 | } 82 | 83 | config.plugins = plugins; 84 | 85 | return config; 86 | } 87 | 88 | /** 89 | * Apply the include list of plugin names and return the merged plugin list 90 | * 91 | * @param {Object} opts Options 92 | * @param {PluginManager} opts.pluginManager The plugin manager 93 | * @param {PluginList} opts.orgPlugins The list of plugins configured on the org 94 | * @param {PluginList} opts.repoIncludeList A list of plugins to merge into the config 95 | * @param {OnInvalidPluginCallback} [opts.onInvalidPlugin] Function to call when an invalid plugin is encountered 96 | * @returns {PluginList} The filtered list of plugins 97 | */ 98 | // eslint-disable-next-line max-statements, complexity 99 | export function applyIncludeList({ pluginManager, orgPlugins, repoIncludeList, onInvalidPlugin }) { 100 | const pluginEqual = { 101 | literal(x, y) { 102 | if (typeof x !== 'string') return false; 103 | return x === y 104 | || (/** @type {Plugin} */(y).plugin && /** @type {Plugin} */(y).plugin === x); 105 | }, 106 | obj(x, y) { 107 | if (typeof x !== 'object') return false; 108 | return x.plugin === y 109 | || (/** @type {Plugin} */(y).plugin && /** @type {Plugin} */(y).plugin === x.plugin); 110 | } 111 | }; 112 | 113 | const plugins = deepClone(orgPlugins); 114 | for (const pluginToInclude of repoIncludeList) { 115 | const pluginName = typeof pluginToInclude === 'string' ? pluginToInclude : pluginToInclude.plugin; 116 | const pluginInstance = pluginManager[pluginName]; 117 | if (!pluginInstance) { 118 | onInvalidPlugin && onInvalidPlugin(pluginName); 119 | // eslint-disable-next-line no-continue 120 | continue; 121 | } 122 | 123 | const existingPlugin = plugins.find(p => { 124 | return pluginEqual.literal(pluginToInclude, p) 125 | || pluginEqual.obj(pluginToInclude, p); 126 | }); 127 | 128 | if (!existingPlugin) { 129 | // If it's not in the existing list, just add it directly 130 | plugins.push(pluginToInclude); 131 | } else if (typeof pluginToInclude === 'object') { 132 | if (typeof existingPlugin === 'string' || !existingPlugin.config) { 133 | // If it is in the existing list but only as a string, we can just replace the string with the object. 134 | // Same story if the existing plugin has no config field set. 135 | const idx = plugins.indexOf(existingPlugin); 136 | plugins.splice(idx, 1, pluginToInclude); 137 | } else { 138 | // If it is in the existing list as an object, and the plugin to include has config specified, we have to merge 139 | // the config 140 | existingPlugin.config = pluginInstance.mergeConfig(existingPlugin.config, pluginToInclude.config); 141 | } 142 | } 143 | } 144 | 145 | return plugins; 146 | } 147 | 148 | /** 149 | * Apply the exclude list of plugin names and return the filtered plugin list 150 | * 151 | * @param {Object} opts Options 152 | * @param {PluginList} opts.orgPlugins The list of plugins configured on the org 153 | * @param {string[]} opts.repoExcludeList A list of plugin names to exclude 154 | * @returns {PluginList} The filtered list of plugins 155 | */ 156 | export function applyExcludeList({ orgPlugins, repoExcludeList }) { 157 | return orgPlugins.filter(plugin => { 158 | if (typeof plugin === 'string') { 159 | return !repoExcludeList.includes(plugin); 160 | } else if (plugin.plugin) { 161 | return !repoExcludeList.includes(plugin.plugin); 162 | } 163 | 164 | // Default to leaving things in the plugin list 165 | return true; 166 | }); 167 | } 168 | -------------------------------------------------------------------------------- /docs.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import handlebars from 'handlebars'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import Prism from 'prismjs'; 6 | import loadLanguages from 'prismjs/components/index.js'; 7 | loadLanguages(['json']); 8 | import resolveCwd from 'resolve-cwd'; 9 | import { fileURLToPath } from 'url'; 10 | 11 | import { createRequire } from 'module'; 12 | const require = createRequire(import.meta.url); 13 | const packageJson = require('./package.json'); 14 | 15 | /** 16 | * @typedef {import('express').Router} expressRouter 17 | */ 18 | /** 19 | * Setup doc site routes 20 | * 21 | * @param {expressRouter} router Express router to attach routes to 22 | */ 23 | export default function setupDocsRoutes(router) { 24 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 25 | // eslint-disable-next-line no-sync 26 | const docsSource = fs.readFileSync(path.join(__dirname, 'views/home.hbs'), { encoding: 'utf8' }); 27 | handlebars.registerHelper('code', options => new handlebars.SafeString( 28 | Prism.highlight(options.fn(), Prism.languages.json, 'json'))); 29 | const docsTemplate = handlebars.compile(docsSource); 30 | const docsHtml = docsTemplate({ 31 | APP_URL: process.env.APP_URL, 32 | VERSION: packageJson.version 33 | }); 34 | 35 | router.use('/static', express.static(path.join(__dirname, 'static'))); 36 | router.get('/', (req, res) => { 37 | res.send(docsHtml); 38 | }); 39 | router.get('/prism-coy.css', (req, res) => { 40 | res.sendFile(resolveCwd('prismjs/themes/prism-coy.css')); 41 | }); 42 | router.get('/healthcheck(.html)?', (req, res) => { 43 | res.send('page ok'); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import processPR from './processor.js'; 2 | import setupDocsRoutes from './docs.js'; 3 | 4 | /** 5 | * @typedef {import('probot').Application} ProbotApp 6 | * @typedef {import('express').Router} ExpressRouter 7 | * @typedef {(path?: string) => ExpressRouter} GetRouterFn 8 | */ 9 | 10 | /** 11 | * This is the main entrypoint to your Probot app 12 | * @param {ProbotApp} app Application 13 | * @param {Object} helpers Helpers 14 | * @param {GetRouterFn} helpers.getRouter Function to get an Express router 15 | */ 16 | export default function appFn(app, { getRouter }) { 17 | if (!process.env.DISABLE_DOCS_ROUTE) { 18 | const docsPath = process.env.DOCS_PATH || '/docs'; 19 | app.log.info('Setting up docs route at ' + docsPath); 20 | const router = getRouter(docsPath); 21 | setupDocsRoutes(router); 22 | } 23 | 24 | app.on('pull_request.opened', processPR); 25 | app.on('pull_request.edited', processPR); 26 | app.on('pull_request.ready_for_review', processPR); 27 | } 28 | 29 | export { setupDocsRoutes }; 30 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "node_modules" 4 | ], 5 | "compilerOptions": { 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pullie", 3 | "version": "6.1.2", 4 | "type": "module", 5 | "description": "A GitHub bot that makes your PRs better", 6 | "author": "GoDaddy.com Operating Company, LLC", 7 | "maintainers": [ 8 | "Jonathan Keslin " 9 | ], 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "git@github.com:godaddy/pullie.git" 14 | }, 15 | "homepage": "https://github.com/godaddy/pullie", 16 | "bugs": "https://github.com/godaddy/pullie/issues", 17 | "keywords": [ 18 | "pr", 19 | "github", 20 | "pullie" 21 | ], 22 | "scripts": { 23 | "lint": "eslint -c .eslintrc --fix *.js plugins/*.js plugins/*/*.js test/**/*.js", 24 | "lint:fix": "npm run lint -- --fix", 25 | "test:unit": "mocha test/unit/*.test.js test/unit/**/*.test.js", 26 | "test:unit:debug": "mocha --inspect-brk test/unit/*.test.js test/unit/**/*.test.js", 27 | "test:integration": "mocha test/integration/*.test.js", 28 | "test:integration:debug": "mocha --inspect-brk test/integration/*.test.js", 29 | "posttest": "npm run lint:fix", 30 | "test": "c8 mocha test/unit/*.test.js test/unit/**/*.test.js test/integration/*.test.js", 31 | "dev": "nodemon", 32 | "start": "probot run ./index.js", 33 | "coverage": "c8 report --reporter=lcov" 34 | }, 35 | "dependencies": { 36 | "array-shuffle": "^3.0.0", 37 | "clone-deep": "^4.0.1", 38 | "deepmerge": "^4.2.2", 39 | "diagnostics": "^2.0.2", 40 | "express": "^4.18.2", 41 | "handlebars": "^4.7.7", 42 | "node-fetch": "^3.3.0", 43 | "p-reduce": "^3.0.0", 44 | "prismjs": "^1.29.0", 45 | "probot": "^12.3.0", 46 | "resolve-cwd": "^3.0.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.17.5", 50 | "@babel/eslint-parser": "^7.17.0", 51 | "@babel/preset-env": "^7.16.11", 52 | "@octokit/rest": "^19.0.0", 53 | "acorn": "^8.7.0", 54 | "assume": "^2.3.0", 55 | "assume-sinon": "^1.1.0", 56 | "c8": "^7.11.0", 57 | "eslint": "^8.9.0", 58 | "eslint-config-godaddy": "^7.0.0", 59 | "eslint-plugin-import": "^2.25.4", 60 | "eslint-plugin-json": "^3.1.0", 61 | "eslint-plugin-mocha": "^10.0.0", 62 | "mocha": "^10.0.0", 63 | "nock": "^13.2.4", 64 | "nodemon": "^2.0.12", 65 | "sinon": "^15.0.0" 66 | }, 67 | "engines": { 68 | "node": ">= 14" 69 | }, 70 | "nodemonConfig": { 71 | "exec": "npm start", 72 | "watch": [ 73 | ".env", 74 | "." 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /plugins/base.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Abstract base Plugin class 3 | * 4 | * @abstract 5 | * @public 6 | */ 7 | export default class BasePlugin { 8 | /** 9 | * Merge the specified overrideConfig on top of the specified base config. 10 | * 11 | * @param {Object} baseConfig The base config 12 | * @param {Object} overrideConfig The override config 13 | * @returns {Object} The merged config 14 | * @public 15 | */ 16 | mergeConfig(baseConfig, overrideConfig) { 17 | return { 18 | ...baseConfig, 19 | ...overrideConfig 20 | }; 21 | } 22 | 23 | /** 24 | * Whether this plugin processes edit actions 25 | * @public 26 | * @returns {Boolean} Whether this plugin processes edit actions 27 | */ 28 | get processesEdits() { 29 | return false; 30 | } 31 | 32 | get processesReadyForReview() { 33 | return false; 34 | } 35 | 36 | /** 37 | * @typedef {import('@octokit/webhooks').EventPayloads.WebhookPayloadPullRequest} WebhookPayloadPullRequest 38 | * @typedef {WebhookPayloadPullRequest & { changes: Object }} WebhookPayloadPullRequestWithChanges 39 | * @typedef {import('probot').Context} ProbotContext 40 | * @typedef {import('../commenter')} Commenter 41 | */ 42 | /** 43 | * Abstract implementation for processRequest 44 | * 45 | * @param {ProbotContext} context webhook context 46 | * @param {Commenter} commenter Commenter object for aggregating comments to post 47 | * @param {Object} config Configuration for this plugin 48 | * @abstract 49 | * @throws {Error} Abstract method not implemented 50 | */ 51 | async processRequest(context, commenter, config) { 52 | // Use these params to make linters/ts happy 53 | void context; 54 | void commenter; 55 | void config; 56 | 57 | throw new Error('.processRequest not defined'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /plugins/index.js: -------------------------------------------------------------------------------- 1 | import Jira from './jira/index.js'; 2 | import RequiredFile from './required-file/index.js'; 3 | import Reviewers from './reviewers/index.js'; 4 | import Welcome from './welcome/index.js'; 5 | 6 | export default class PluginManager { 7 | constructor() { 8 | this.jira = new Jira(); 9 | this.requiredFile = new RequiredFile(); 10 | this.reviewers = new Reviewers(); 11 | this.welcome = new Welcome(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /plugins/jira/index.js: -------------------------------------------------------------------------------- 1 | /** @type {(url: string, options?: RequestInit) => Promise} */ 2 | import fetch from 'node-fetch'; 3 | import BasePlugin from '../base.js'; 4 | import Commenter from '../../commenter.js'; 5 | 6 | const HAS_JIRA_TICKET = /([A-Z]+-[1-9][0-9]*)/g; 7 | 8 | export default class JiraPlugin extends BasePlugin { 9 | constructor() { 10 | super(); 11 | this.jiraConfig = { 12 | protocol: process.env.JIRA_PROTOCOL, 13 | host: process.env.JIRA_HOST, 14 | username: process.env.JIRA_USERNAME, 15 | password: process.env.JIRA_PASSWORD 16 | }; 17 | } 18 | 19 | /** 20 | * Whether this plugin processes edit actions 21 | * @public 22 | * @override 23 | * @returns {Boolean} Whether this plugin processes edit actions 24 | */ 25 | get processesEdits() { 26 | return true; 27 | } 28 | 29 | /** 30 | * @typedef {import('@octokit/webhooks').EventPayloads.WebhookPayloadPullRequest} WebhookPayloadPullRequest 31 | * @typedef {WebhookPayloadPullRequest & { changes: Object }} WebhookPayloadPullRequestWithChanges 32 | * @typedef {import('probot').Context} ProbotContext 33 | */ 34 | /** 35 | * Process a PR webhook and perform needed JIRA actions 36 | * 37 | * @memberof JiraPlugin 38 | * @public 39 | * @override 40 | * @param {ProbotContext} context webhook context 41 | * @param {Commenter} commenter Commenter 42 | */ 43 | async processRequest(context, commenter) { // eslint-disable-line max-statements 44 | const data = context.payload; 45 | const isEdit = data.action === 'edited'; 46 | let oldTitle = null; 47 | 48 | if (isEdit) { 49 | oldTitle = data.changes && data.changes.title && data.changes.title.from; 50 | if (!oldTitle || oldTitle === data.pull_request.title) { 51 | // Title hasn't changed, nothing to do 52 | return; 53 | } 54 | } 55 | 56 | const title = data.pull_request.title; 57 | let ticketIds = this.extractTicketsFromString(title); 58 | 59 | if (ticketIds.length === 0) { 60 | // No tickets referenced in title, nothing to do 61 | return; 62 | } 63 | 64 | if (isEdit && oldTitle) { 65 | const oldTicketIds = this.extractTicketsFromString(oldTitle); 66 | ticketIds = ticketIds.filter(t => !oldTicketIds.includes(t)); 67 | } 68 | 69 | if (ticketIds.length === 0) { 70 | // No tickets referenced in title, nothing to do 71 | return; 72 | } 73 | 74 | return this.findTicketsAndPost(commenter, ticketIds); 75 | } 76 | 77 | /** 78 | * Find details on the specified list of tickets from Jira and post a comment with links 79 | * 80 | * @memberof JiraPlugin 81 | * @private 82 | * @param {Commenter} commenter Commenter 83 | * @param {String[]} ticketIds A list of ticket IDs 84 | */ 85 | async findTicketsAndPost(commenter, ticketIds) { 86 | const jql = `id in ('${ticketIds.join("', '")}')`; 87 | 88 | const res = await this.fetch(`${this.jiraConfig.protocol}://${this.jiraConfig.host}/rest/api/2/search`, { 89 | method: 'POST', 90 | headers: { 91 | 'Accept': 'application/json', 92 | 'Authorization': 'Basic ' + Buffer.from(this.jiraConfig.username + ':' + this.jiraConfig.password) 93 | .toString('base64'), 94 | 'Content-Type': 'application/json' 95 | }, 96 | body: JSON.stringify({ 97 | jql, 98 | startAt: 0, 99 | fields: ['summary'] 100 | }) 101 | }); 102 | 103 | if (!res || !res.ok) { 104 | throw new Error( 105 | `Error retrieving Jira ticket info. Status code: ${(res && res.status) || 'unknown'} from Jira.`); 106 | } 107 | 108 | const body = await res.json(); 109 | 110 | if (!body.issues.length) return; 111 | 112 | const ticketList = body.issues.reduce((acc, ticket) => { 113 | return acc + 114 | // eslint-disable-next-line max-len 115 | `\n- [\\[${ticket.key}\\] ${ticket.fields.summary}](${this.jiraConfig.protocol}://${this.jiraConfig.host}/browse/${ticket.key})`; 116 | }, ''); 117 | 118 | // call some API to post the comment on the PR 119 | const comment = `I found the following Jira ticket(s) referenced in this PR:\n${ticketList}`; 120 | commenter.addComment(comment, Commenter.priority.Low); 121 | } 122 | 123 | /** 124 | * Wrapper of Fetch API, used to allow stubbing for tests 125 | * 126 | * @param {string} url URL for request 127 | * @param {RequestInit} options Request options 128 | * @returns {Response} Response 129 | * @private 130 | */ 131 | fetch(url, options) { 132 | return fetch(url, options); 133 | } 134 | 135 | /** 136 | * Extract Jira ticket IDs from the specified string 137 | * 138 | * @memberof JiraPlugin 139 | * @private 140 | * @param {String} str The string from which to extract ticket IDs 141 | * @returns {String[]} A list of ticket IDs that were extracted 142 | */ 143 | extractTicketsFromString(str) { 144 | let match; 145 | const ticketIds = []; 146 | // eslint-disable-next-line no-cond-assign 147 | while ((match = HAS_JIRA_TICKET.exec(str)) !== null) { 148 | ticketIds.push(match[1]); 149 | } 150 | 151 | return ticketIds; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /plugins/required-file/index.js: -------------------------------------------------------------------------------- 1 | import BasePlugin from '../base.js'; 2 | import Commenter from '../../commenter.js'; 3 | 4 | export default class RequiredFilePlugin extends BasePlugin { 5 | /** 6 | * Whether this plugin processes edit actions 7 | * @public 8 | * @override 9 | * @returns {Boolean} Whether this plugin processes edit actions 10 | */ 11 | get processesEdits() { 12 | return false; 13 | } 14 | 15 | /** 16 | * Merge the specified overrideConfig on top of the specified base config. 17 | * 18 | * @param {Object} baseConfig The base config 19 | * @param {Object} overrideConfig The override config 20 | * @returns {Object} The merged config 21 | * @public 22 | * @override 23 | * @memberof RequiredFilePlugin 24 | */ 25 | mergeConfig(baseConfig, overrideConfig) { 26 | // Explicitly replace the files array with the one in overrides 27 | return { 28 | ...baseConfig, 29 | ...overrideConfig, 30 | files: overrideConfig.files 31 | }; 32 | } 33 | 34 | /** 35 | * @typedef {import('@octokit/webhooks').EventPayloads.WebhookPayloadPullRequest} WebhookPayloadPullRequest 36 | * @typedef {WebhookPayloadPullRequest & { changes: Object }} WebhookPayloadPullRequestWithChanges 37 | * @typedef {import('probot').Context} ProbotContext 38 | */ 39 | /** 40 | * Process a PR webhook and perform needed required file checks 41 | * 42 | * @memberof RequiredFilePlugin 43 | * @public 44 | * @override 45 | * @param {ProbotContext} context webhook context 46 | * @param {Commenter} commenter Commenter 47 | * @param {Object} config Configuration for this plugin 48 | * @param {String[]} config.files File paths to require in the PR 49 | */ 50 | async processRequest(context, commenter, config) { 51 | const files = config && config.files; 52 | if (!files) { 53 | throw new Error('Missing `files` field in plugin config'); 54 | } 55 | 56 | for (const f of files) { 57 | await this.checkFile(context, commenter, f); 58 | } 59 | } 60 | 61 | /** 62 | * @typedef RequiredFile 63 | * @prop {String} path Path to the file, relative to the root of the repo 64 | * @prop {String} [message] Message to print if the file is not edited in the PR 65 | */ 66 | /** 67 | * Verify that a given file exists in the repo and in the PR 68 | * 69 | * @memberof RequiredFilePlugin 70 | * @private 71 | * 72 | * @param {ProbotContext} context webhook context 73 | * @param {Commenter} commenter Commenter object for aggregating comments to post 74 | * @param {RequiredFile | string} file The file object, or path to check, relative to the root of the repo 75 | */ 76 | async checkFile(context, commenter, file) { 77 | const filePath = typeof file === 'string' ? file : (file && file.path); 78 | if (!filePath) { 79 | throw new Error('No file path specified for required file.'); 80 | } 81 | 82 | const message = '⚠️ ' + (typeof file === 'object' && file.message || 83 | `You're missing a change to ${filePath}, which is a requirement for changes to this repo.`); 84 | 85 | const existsRes = await context.octokit.repos.getContent({ 86 | ...context.repo(), 87 | path: filePath 88 | }); 89 | const exists = existsRes.status === 200; 90 | 91 | if (!exists) return; 92 | 93 | /** 94 | * @typedef {import('@octokit/rest').RestEndpointMethodTypes} OctokitTypes 95 | * @typedef {OctokitTypes["pulls"]["listFiles"]["response"]["data"]} PullsListFilesResponseItems 96 | * @type {PullsListFilesResponseItems} 97 | */ 98 | const filesInPR = await context.octokit.paginate(context.octokit.pulls.listFiles.endpoint.merge( 99 | context.pullRequest()), res => res.data); 100 | 101 | if (!filesInPR.some(f => { 102 | return f.filename === filePath; 103 | })) { 104 | commenter.addComment(message, Commenter.priority.High); 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /plugins/reviewers/index.js: -------------------------------------------------------------------------------- 1 | import arrayShuffle from 'array-shuffle'; 2 | import pReduce from 'p-reduce'; 3 | import BasePlugin from '../base.js'; 4 | import Commenter from '../../commenter.js'; 5 | import { parseBase64Json } from '../../utils.js'; 6 | 7 | const REVIEWER_REGEX = /([A-Za-z0-9-_]+)@/; 8 | 9 | export default class ReviewerPlugin extends BasePlugin { 10 | /** 11 | * Reviewer plugin - automatically requests reviews from contributors 12 | * 13 | * @constructor 14 | * @public 15 | */ 16 | constructor() { 17 | super(); 18 | this.defaultCommentFormat = process.env.REVIEWERS_COMMENT_FORMAT; 19 | } 20 | 21 | /** 22 | * Whether this plugin processes edit actions 23 | * @public 24 | * @override 25 | * @returns {Boolean} Whether this plugin processes edit actions 26 | */ 27 | get processesEdits() { 28 | return false; 29 | } 30 | 31 | get processesReadyForReview() { 32 | return true; 33 | } 34 | 35 | /** 36 | * @typedef {import('@octokit/webhooks').EventPayloads.WebhookPayloadPullRequest} WebhookPayloadPullRequest 37 | * @typedef {WebhookPayloadPullRequest & { changes: Object }} WebhookPayloadPullRequestWithChanges 38 | * @typedef {import('probot').Context} ProbotContext 39 | */ 40 | /** 41 | * Process a PR webhook and add needed reviewers 42 | * 43 | * @memberof ReviewerPlugin 44 | * @public 45 | * @override 46 | * @param {ProbotContext} context webhook context 47 | * @param {Commenter} commenter Commenter object for aggregating comments to post 48 | * @param {Object} config Configuration for this plugin 49 | * @param {String[]} [config.reviewers] List of reviewer usernames. If not specified, try to pull this from 50 | * package.json 51 | * @param {Number} [config.howMany] Number of reviewers to choose from the set of contributors and maintainers, 52 | * defaults to choosing all of the possible reviewers 53 | * @param {String} [config.commentFormat] Format for comment 54 | * @param {Boolean} [config.requestForDrafts=false] Whether to request reviews for draft PRs. Default false. 55 | */ 56 | async processRequest(context, commenter, config) { 57 | config = config || {}; 58 | const data = context.payload; 59 | const isReadyForReviewEvent = data.action === 'ready_for_review'; 60 | 61 | // Don't request reviews for draft PRs unless explicitly configured to 62 | if (data.pull_request.draft && !config.requestForDrafts) return; 63 | 64 | // If we are configured to request reviews for draft PRs, don't request them again after a PR is marked as ready 65 | // for review 66 | if (isReadyForReviewEvent && config.requestForDrafts) return; 67 | 68 | const commentFormat = config.commentFormat || this.defaultCommentFormat; 69 | if (config.reviewers) { 70 | await this.requestReviews(context, config.reviewers, config.howMany, commentFormat, commenter); 71 | return; 72 | } 73 | const packageInfo = await this.getPackageJson(context); 74 | if (!packageInfo) { 75 | // No package.json, nothing to do 76 | return; 77 | } 78 | 79 | const reviewers = this.getAllPossibleReviewers(packageInfo); 80 | await this.requestReviews(context, 81 | reviewers, 82 | config.howMany, 83 | commentFormat, 84 | commenter); 85 | 86 | } 87 | 88 | /** 89 | * Get all reviewers from the package.json, pulling from the package author, contributors list, and maintainers list 90 | * 91 | * @memberof ReviewerPlugin 92 | * @private 93 | * @param {Object} packageInfo Parsed package.json file 94 | * @returns {Object[] | String[]} List of reviewers in the raw format they're listed in package.json 95 | */ 96 | getAllPossibleReviewers(packageInfo) { 97 | const contributors = this.normalizeReviewerField(packageInfo.contributors); 98 | const maintainers = this.normalizeReviewerField(packageInfo.maintainers); 99 | 100 | const ret = contributors.concat(maintainers); 101 | if (packageInfo.author) ret.push(packageInfo.author); 102 | 103 | return ret; 104 | } 105 | 106 | /** 107 | * Normalize a reviewer list field from package.json 108 | * 109 | * If it is an array, just return the array 110 | * If it is a string or object, return a one-element array containing that item 111 | * Else, return an empty array 112 | * 113 | * @memberof ReviewerPlugin 114 | * @private 115 | * @param {String|String[]|Object[]} field The reviewer field to normalize 116 | * @returns {String[]|Object[]} The normalized list 117 | */ 118 | normalizeReviewerField(field) { 119 | let ret; 120 | if (!Array.isArray(field)) { 121 | if (typeof field === 'string' || typeof field === 'object') { 122 | ret = [field]; 123 | } else { 124 | ret = []; 125 | } 126 | } else { 127 | ret = field; 128 | } 129 | 130 | return ret; 131 | } 132 | 133 | /** 134 | * Select reviewers from the specified set of candidate reviewers and request reviews from them 135 | * 136 | * @memberof ReviewerPlugin 137 | * @private 138 | * @param {ProbotContext} context webhook context 139 | * @param {String[]|Object[]} reviewers A raw list of candidate reviewers 140 | * @param {Number} [howMany] How many reviewers to select, default is all 141 | * @param {String} [commentFormat] An optional comment format string to use when posting a comment about 142 | * the review request 143 | * @param {Commenter} [commenter] Commenter object for aggregating comments to post 144 | */ 145 | async requestReviews(context, reviewers, howMany, commentFormat, commenter) { // eslint-disable-line max-params 146 | if (!reviewers) { 147 | // No users to work with, nothing to do 148 | return; 149 | } 150 | 151 | const userList = await this.getUsersFromReviewersList(context, reviewers); 152 | if (!userList || userList.length === 0) { 153 | // No reviewers, nothing to do 154 | return; 155 | } 156 | 157 | let usersToRequest = userList; 158 | if (howMany) { 159 | const shuffled = arrayShuffle(userList); 160 | usersToRequest = shuffled.slice(0, howMany); 161 | } 162 | 163 | if (commentFormat) { 164 | const githubUsernames = usersToRequest.sort().map(username => `@${username}`).join(', '); 165 | commenter.addComment( 166 | commentFormat.replace('%s', githubUsernames), 167 | Commenter.priority.Medium 168 | ); 169 | } 170 | 171 | await context.octokit.pulls.requestReviewers({ 172 | ...context.pullRequest(), 173 | reviewers: usersToRequest 174 | }); 175 | } 176 | 177 | /** 178 | * Get the parsed contents of the repo's package.json file 179 | * 180 | * @memberof ReviewerPlugin 181 | * @private 182 | * @param {ProbotContext} context webhook context 183 | * @returns {Promise} parsed package.json 184 | */ 185 | async getPackageJson(context) { 186 | const pkg = await context.octokit.repos.getContent({ 187 | ...context.repo(), 188 | path: 'package.json' 189 | }); 190 | // @ts-expect-error 191 | if (pkg.status === 404) return; 192 | return await parseBase64Json(pkg.data); 193 | } 194 | 195 | /** 196 | * Convert a list of reviewers into a list of users to request review from 197 | * 198 | * @memberof ReviewerPlugin 199 | * @private 200 | * @param {ProbotContext} context webhook context 201 | * @param {Array} reviewers List of reviewers, often in the format `Joe Schmoe ` 202 | * @returns {Promise} Confirmed users to request review from 203 | */ 204 | async getUsersFromReviewersList(context, reviewers) { 205 | if (!reviewers || !reviewers.length) return; 206 | 207 | const maybeUsers = Array.from(new Set(reviewers.map(r => { 208 | const type = typeof r; 209 | let testSubject; 210 | if (type === 'string') { 211 | testSubject = r; 212 | } else if (type === 'object') { 213 | testSubject = r.email || ''; 214 | } else { 215 | return null; 216 | } 217 | const matches = REVIEWER_REGEX.exec(testSubject); 218 | if (!matches) return testSubject; 219 | return `${matches[1]}${process.env.GITHUB_USER_SUFFIX || ''}`; 220 | }).filter(r => { 221 | if (!r) return false; 222 | return r !== context.payload.pull_request.user.login; 223 | }))); 224 | 225 | return await pReduce(maybeUsers, async (memo, user) => { 226 | try { 227 | const res = await context.octokit.repos.checkCollaborator({ 228 | ...context.repo(), 229 | username: user 230 | }); 231 | 232 | return res.status === 204 ? memo.concat([user]) : memo; 233 | } catch (err) { 234 | return memo; 235 | } 236 | }, []); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /plugins/welcome/index.js: -------------------------------------------------------------------------------- 1 | import BasePlugin from '../base.js'; 2 | import Commenter from '../../commenter.js'; 3 | 4 | export default class WelcomePlugin extends BasePlugin { 5 | /** 6 | * Welcome plugin - automatically welcomes new contributors 7 | * 8 | * @constructor 9 | * @public 10 | */ 11 | constructor() { 12 | super(); 13 | this.welcomeMessage = process.env.WELCOME_MESSAGE; 14 | } 15 | 16 | /** 17 | * @typedef {import('@octokit/webhooks').EventPayloads.WebhookPayloadPullRequest} WebhookPayloadPullRequest 18 | * @typedef {WebhookPayloadPullRequest & { changes: Object }} WebhookPayloadPullRequestWithChanges 19 | * @typedef {import('probot').Context} ProbotContext 20 | */ 21 | /** 22 | * Process a PR and check to see if we have a new friend 23 | * 24 | * @memberof WelcomePlugin 25 | * @public 26 | * @override 27 | * @param {ProbotContext} context webhook context 28 | * @param {Commenter} commenter Commenter 29 | * @param {Object} [config] Configuration for this plugin 30 | * @param {string} [config.welcomeMessage] Org or repo-level configured welcome message for new contributors 31 | */ 32 | async processRequest(context, commenter, config = {}) { 33 | const message = config.welcomeMessage || this.welcomeMessage; 34 | 35 | if (!message) return; 36 | 37 | // Get all issues for repo with user as creator 38 | const response = await context.octokit.issues.listForRepo(context.repo({ 39 | state: 'all', 40 | creator: context.payload.pull_request.user.login 41 | })); 42 | 43 | // get all the PRs by the contributor 44 | const pullRequests = response.data.filter(data => data.pull_request); 45 | 46 | // if we only have one, then lets welcome them 47 | if (pullRequests.length === 1) { 48 | commenter.addComment(message, Commenter.priority.High); 49 | } 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /processor.js: -------------------------------------------------------------------------------- 1 | /* eslint no-continue: 0 */ 2 | import Commenter from './commenter.js'; 3 | import PluginManager from './plugins/index.js'; 4 | import { parseBase64Json } from './utils.js'; 5 | import processConfig from './config-processor.js'; 6 | 7 | /** 8 | * @typedef {import('@octokit/webhooks').EventPayloads.WebhookPayloadPullRequest} WebhookPayloadPullRequest 9 | * @typedef {WebhookPayloadPullRequest & { changes: Object }} WebhookPayloadPullRequestWithChanges 10 | * @typedef {import('probot').Context} ProbotContext 11 | * @typedef {import('./plugins/base.js')} BasePlugin 12 | * @typedef {{[pluginName: string]: BasePlugin}} PluginManagerType 13 | */ 14 | /** 15 | * Process a PR 16 | * 17 | * @param {ProbotContext} context PR webhook context 18 | * @returns {Promise} Completion promise 19 | * @public 20 | */ 21 | export default async function processPR(context) { 22 | return processPRInternal(context); 23 | } 24 | 25 | /** 26 | * Process a PR (internal function for testing) 27 | * @param {ProbotContext} context PR webhook context 28 | * @param {typeof Commenter} CommenterType Commenter type (for dependency injection) 29 | * @param {typeof PluginManager} PluginManagerType PluginManager type (for dependency injection) 30 | * @returns {Promise} Completion promise 31 | * @private 32 | */ 33 | // eslint-disable-next-line complexity, max-statements 34 | export async function processPRInternal(context, CommenterType = Commenter, PluginManagerType = PluginManager) { 35 | const logData = { 36 | repository: context.payload.repository.full_name, 37 | number: context.payload.number, 38 | requestId: context.id 39 | }; 40 | context.log.info(logData, 'Processing PR'); 41 | 42 | if (process.env.GH_ENTERPRISE_ID) { 43 | const ghecEnterpriseId = parseInt(process.env.GH_ENTERPRISE_ID, 10); 44 | if (!isNaN(ghecEnterpriseId) && 45 | context.payload.enterprise && 46 | context.payload.enterprise.id === ghecEnterpriseId) { 47 | context.log.info('PR is from the configured Enterprise'); 48 | } else { 49 | context.log.info('PR is not from the configured Enterprise, nothing to do'); 50 | return; 51 | } 52 | } 53 | 54 | if (process.env.NO_PUBLIC_REPOS === 'true' && !context.payload.repository.private) { 55 | context.log.info('Pullie has been disabled on public repos, nothing to do'); 56 | return; 57 | } 58 | 59 | let repoConfig; 60 | try { 61 | repoConfig = await getRepoConfig(context); 62 | } catch (err) { 63 | context.log.error({ 64 | requestId: context.id, 65 | err 66 | }, 'Error getting repository config'); 67 | return; 68 | } 69 | 70 | if (!repoConfig) { 71 | // No config specified for this repo, nothing to do 72 | context.log.info(logData, 'No config specified for repo, nothing to do'); 73 | return; 74 | } 75 | 76 | let orgConfig; 77 | try { 78 | orgConfig = await getOrgConfig(context); 79 | } catch (err) { 80 | context.log.warn({ 81 | requestId: context.id, 82 | err 83 | }, 'Error getting org config'); 84 | orgConfig = null; 85 | } 86 | 87 | /** @type {PluginManagerType} */ 88 | // @ts-ignore 89 | const pluginManager = new PluginManagerType(); 90 | const config = processConfig(pluginManager, orgConfig, repoConfig, invalidPlugin => { 91 | context.log.error({ 92 | repository: context.payload.repository.full_name, plugin: invalidPlugin, requestId: context.id 93 | }, 'Invalid plugin specified in repo config'); 94 | }); 95 | 96 | if (!Array.isArray(config.plugins) || config.plugins.length === 0) { 97 | // No plugins to run, nothing to do 98 | context.log.info(logData, 'No plugins to run, nothing to do'); 99 | return; 100 | } 101 | 102 | const commenter = new CommenterType(); 103 | for (const pluginConfig of config.plugins) { 104 | const pluginName = typeof pluginConfig === 'string' ? pluginConfig : pluginConfig.plugin; 105 | const plugin = pluginManager[pluginName]; 106 | if (!plugin) { 107 | context.log.error({ 108 | repository: context.payload.repository.full_name, plugin: pluginName, requestId: context.id 109 | }, 'Invalid plugin specified in config'); 110 | continue; 111 | } 112 | if (context.payload.action === 'edited' && !plugin.processesEdits) { 113 | continue; 114 | } 115 | if (context.payload.action === 'ready_for_review' && !plugin.processesReadyForReview) { 116 | continue; 117 | } 118 | const cfg = typeof pluginConfig === 'string' ? {} : pluginConfig.config; 119 | try { 120 | await plugin.processRequest(context, commenter, cfg); 121 | } catch (pluginProcessRequestErr) { 122 | context.log.error({ 123 | error: pluginProcessRequestErr, 124 | repository: context.payload.repository.full_name, 125 | number: context.payload.number, 126 | plugin: pluginName, 127 | requestId: context.id 128 | }, 'Error running plugin'); 129 | continue; 130 | } 131 | } 132 | 133 | context.log.info(logData, 'Finished processing PR'); 134 | const comment = commenter.flushToString(); 135 | if (comment) { 136 | await context.octokit.issues.createComment({ 137 | ...context.repo(), 138 | issue_number: context.payload.number, 139 | body: comment 140 | }); 141 | } 142 | } 143 | 144 | /** 145 | * Get config for the repo specified in the context 146 | * 147 | * @param {ProbotContext} context PR webhook context 148 | * @returns {Promise} Config for the repo 149 | */ 150 | async function getRepoConfig(context) { 151 | let pullieRcRes = {}; 152 | try { 153 | pullieRcRes = await context.octokit.repos.getContent({ 154 | ...context.repo(), 155 | path: '.pullierc' 156 | }); 157 | } catch (err) { 158 | // If there's no .pullierc, just skip this request. Otherwise, re-throw the error. 159 | if (err.status === 404) return; 160 | throw err; 161 | } 162 | 163 | return parseBase64Json(pullieRcRes.data); 164 | } 165 | 166 | /** 167 | * Get org-level config for the org specified in the context 168 | * 169 | * @param {ProbotContext} context PR webhook context 170 | * @returns {Promise} Config for the repo 171 | */ 172 | async function getOrgConfig(context) { 173 | let pullieRcRes = {}; 174 | try { 175 | pullieRcRes = await context.octokit.repos.getContent({ 176 | owner: context.payload.repository.owner.login, 177 | repo: '.github', 178 | path: '.pullierc' 179 | }); 180 | } catch (err) { 181 | // If there's no .pullierc, just skip this request. Otherwise, re-throw the error. 182 | if (err.status === 404) return; 183 | throw err; 184 | } 185 | 186 | return parseBase64Json(pullieRcRes.data); 187 | } 188 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /static/pullie-flood-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-flood-128.png -------------------------------------------------------------------------------- /static/pullie-flood-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-flood-256.png -------------------------------------------------------------------------------- /static/pullie-light-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-light-128.png -------------------------------------------------------------------------------- /static/pullie-light-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-light-256.png -------------------------------------------------------------------------------- /static/pullie-transparent-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-transparent-128.png -------------------------------------------------------------------------------- /static/pullie-transparent-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-transparent-256.png -------------------------------------------------------------------------------- /static/pullie-white-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-white-128.png -------------------------------------------------------------------------------- /static/pullie-white-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godaddy/pullie/3197bc7bf472e57b6b4379301e363a6eab6755d9/static/pullie-white-256.png -------------------------------------------------------------------------------- /static/pullie.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 57 | 58 | Asset 10 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /test/fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "http": 0, 3 | "github": { 4 | "apiUrl": "https://github.test.fake/api/v3/", 5 | "client_id": "some client ID", 6 | "client_secret": "some client secret", 7 | "appId": "some app ID", 8 | "appKeyPath": "test/fixtures/mock-key.pem", 9 | "publicUrl": "https://github.test.fake/github-apps/pullie", 10 | "secret": "some webhook secret" 11 | }, 12 | "jira": { 13 | "protocol": "https", 14 | "host": "jira.test.fake", 15 | "username": "some user with Jira access", 16 | "password": "password for that user", 17 | "apiVersion": 2, 18 | "strictSSL": true 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /test/fixtures/mock-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAli7V49NdZe+XYC1pLaHM0te8kiDmZBJ1u2HJHN8GdbROB6NO 3 | VpC3xK7NxQn6xpvZ9ux20NvcDvGle+DOptZztBH+np6h2jZQ1/kD1yG1eQvVH4th 4 | /9oqHuIjmIfO8lIe4Hyd5Fw5xHkGqVETTGR+0c7kdZIlHmkOregUGtMYZRUi4YG+ 5 | q0w+uFemiHpGKXbeCIAvkq7aIkisEzvPWfSyYdA6WJHpxFk7tD7D8VkzABLVRHCq 6 | AuyqPG39BhGZcGLXx5rGK56kDBJkyTR1t3DkHpwX+JKNG5UYNwOG4LcQj1fteeta 7 | TdkYUMjIyWbanlMYyC+dq7B5fe7el99jXQ1gXwIDAQABAoIBADKfiPOpzKLOtzzx 8 | MbHzB0LO+75aHq7+1faayJrVxqyoYWELuB1P3NIMhknzyjdmU3t7S7WtVqkm5Twz 9 | lBUC1q+NHUHEgRQ4GNokExpSP4SU63sdlaQTmv0cBxmkNarS6ZuMBgDy4XoLvaYX 10 | MSUf/uukDLhg0ehFS3BteVFtdJyllhDdTenF1Nb1rAeN4egt8XLsE5NQDr1szFEG 11 | xH5lb+8EDtzgsGpeIddWR64xP0lDIKSZWst/toYKWiwjaY9uZCfAhvYQ1RsO7L/t 12 | sERmpYgh+rAZUh/Lr98EI8BPSPhzFcSHmtqzzejvC5zrZPHcUimz0CGA3YBiLoJX 13 | V1OrxmECgYEAxkd8gpmVP+LEWB3lqpSvJaXcGkbzcDb9m0OPzHUAJDZtiIIf0UmO 14 | nvL68/mzbCHSj+yFjZeG1rsrAVrOzrfDCuXjAv+JkEtEx0DIevU1u60lGnevOeky 15 | r8Be7pmymFB9/gzQAd5ezIlTv/COgoO986a3h1yfhzrrzbqSiivw308CgYEAwecI 16 | aZZwqH3GifR+0+Z1B48cezA5tC8LZt5yObGzUfxKTWy30d7lxe9N59t0KUVt/QL5 17 | qVkd7mqGzsUMyxUN2U2HVnFTWfUFMhkn/OnCnayhILs8UlCTD2Xxoy1KbQH/9FIr 18 | xf0pbMNJLXeGfyRt/8H+BzSZKBw9opJBWE4gqfECgYBp9FdvvryHuBkt8UQCRJPX 19 | rWsRy6pY47nf11mnazpZH5Cmqspv3zvMapF6AIxFk0leyYiQolFWvAv+HFV5F6+t 20 | Si1mM8GCDwbA5zh6pEBDewHhw+UqMBh63HSeUhmi1RiOwrAA36CO8i+D2Pt+eQHv 21 | ir52IiPJcs4BUNrv5Q1BdwKBgBHgVNw3LGe8QMOTMOYkRwHNZdjNl2RPOgPf2jQL 22 | d/bFBayhq0jD/fcDmvEXQFxVtFAxKAc+2g2S8J67d/R5Gm/AQAvuIrsWZcY6n38n 23 | pfOXaLt1x5fnKcevpFlg4Y2vM4O416RHNLx8PJDehh3Oo/2CSwMrDDuwbtZAGZok 24 | icphAoGBAI74Tisfn+aeCZMrO8KxaWS5r2CD1KVzddEMRKlJvSKTY+dOCtJ+XKj1 25 | OsZdcDvDC5GtgcywHsYeOWHldgDWY1S8Z/PUo4eK9qBXYBXp3JEZQ1dqzFdz+Txi 26 | rBn2WsFLsxV9j2/ugm0PqWVBcU2bPUCwvaRu3SOms2teaLwGCkhr 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/fixtures/payloads/mock-org-pullierc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "jira", 4 | { 5 | "plugin": "reviewers", 6 | "config": { 7 | "howMany": 2 8 | } 9 | }, 10 | { 11 | "plugin": "requiredFile", 12 | "config": { 13 | "files": [ 14 | { 15 | "path": "SOMEFILE.md", 16 | "message": "Missing change to SOMEFILE.md" 17 | } 18 | ] 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/payloads/mock-packagejson.json: -------------------------------------------------------------------------------- 1 | { 2 | "contributors": [ 3 | "Joe Smith " 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/payloads/mock-pullierc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | { 4 | "plugin": "reviewers", 5 | "config": { 6 | "commentFormat": "Requested review from %s" 7 | } 8 | }, 9 | { 10 | "plugin": "requiredFile", 11 | "config": { 12 | "files": [ 13 | { 14 | "path": "CHANGELOG.md", 15 | "message": "Missing CHANGELOG entry" 16 | } 17 | ] 18 | } 19 | }, 20 | { 21 | "plugin": "welcome", 22 | "config": { 23 | "welcomeMessage": "o hai!" 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/payloads/open-pr.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 165, 4 | "pull_request": { 5 | "url": "https://github.test.fake/api/v3/repos/org/repo/pulls/165", 6 | "id": 123456, 7 | "node_id": "mock", 8 | "html_url": "https://github.test.fake/org/repo/pull/165", 9 | "diff_url": "https://github.test.fake/org/repo/pull/165.diff", 10 | "patch_url": "https://github.test.fake/org/repo/pull/165.patch", 11 | "issue_url": "https://github.test.fake/api/v3/repos/org/repo/issues/165", 12 | "number": 165, 13 | "state": "open", 14 | "locked": false, 15 | "title": "[AB-12345] Mock pull request", 16 | "user": { 17 | "login": "JDoe", 18 | "id": 123, 19 | "node_id": "mock", 20 | "avatar_url": "https://github.test.fake/avatars/u/123?", 21 | "gravatar_id": "", 22 | "url": "https://github.test.fake/api/v3/users/JDoe", 23 | "html_url": "https://github.test.fake/JDoe", 24 | "followers_url": "https://github.test.fake/api/v3/users/JDoe/followers", 25 | "following_url": "https://github.test.fake/api/v3/users/JDoe/following{/other_user}", 26 | "gists_url": "https://github.test.fake/api/v3/users/JDoe/gists{/gist_id}", 27 | "starred_url": "https://github.test.fake/api/v3/users/JDoe/starred{/owner}{/repo}", 28 | "subscriptions_url": "https://github.test.fake/api/v3/users/JDoe/subscriptions", 29 | "organizations_url": "https://github.test.fake/api/v3/users/JDoe/orgs", 30 | "repos_url": "https://github.test.fake/api/v3/users/JDoe/repos", 31 | "events_url": "https://github.test.fake/api/v3/users/JDoe/events{/privacy}", 32 | "received_events_url": "https://github.test.fake/api/v3/users/JDoe/received_events", 33 | "type": "User", 34 | "site_admin": false, 35 | "ldap_dn": "CN=John Doe,OU=Users & Groups,OU=Company,DC=mock" 36 | }, 37 | "body": "", 38 | "created_at": "2018-12-31T00:00:00Z", 39 | "updated_at": "2018-12-31T00:00:00Z", 40 | "closed_at": null, 41 | "merged_at": null, 42 | "merge_commit_sha": null, 43 | "assignee": null, 44 | "assignees": [ 45 | 46 | ], 47 | "requested_reviewers": [ 48 | 49 | ], 50 | "requested_teams": [ 51 | 52 | ], 53 | "labels": [ 54 | 55 | ], 56 | "milestone": null, 57 | "commits_url": "https://github.test.fake/api/v3/repos/org/repo/pulls/165/commits", 58 | "review_comments_url": "https://github.test.fake/api/v3/repos/org/repo/pulls/165/comments", 59 | "review_comment_url": "https://github.test.fake/api/v3/repos/org/repo/pulls/comments{/number}", 60 | "comments_url": "https://github.test.fake/api/v3/repos/org/repo/issues/165/comments", 61 | "statuses_url": "https://github.test.fake/api/v3/repos/org/repo/statuses/475e81e79c7880f9b5caa35bec50279c459ad2f9", 62 | "head": { 63 | "label": "org:JDoe/mock-branch", 64 | "ref": "JDoe/mock-branch", 65 | "sha": "475e81e79c7880f9b5caa35bec50279c459ad2f9", 66 | "user": { 67 | "login": "org", 68 | "id": 1, 69 | "node_id": "mock", 70 | "avatar_url": "https://github.test.fake/avatars/u/1?", 71 | "gravatar_id": "", 72 | "url": "https://github.test.fake/api/v3/users/org", 73 | "html_url": "https://github.test.fake/org", 74 | "followers_url": "https://github.test.fake/api/v3/users/org/followers", 75 | "following_url": "https://github.test.fake/api/v3/users/org/following{/other_user}", 76 | "gists_url": "https://github.test.fake/api/v3/users/org/gists{/gist_id}", 77 | "starred_url": "https://github.test.fake/api/v3/users/org/starred{/owner}{/repo}", 78 | "subscriptions_url": "https://github.test.fake/api/v3/users/org/subscriptions", 79 | "organizations_url": "https://github.test.fake/api/v3/users/org/orgs", 80 | "repos_url": "https://github.test.fake/api/v3/users/org/repos", 81 | "events_url": "https://github.test.fake/api/v3/users/org/events{/privacy}", 82 | "received_events_url": "https://github.test.fake/api/v3/users/org/received_events", 83 | "type": "Organization", 84 | "site_admin": false 85 | }, 86 | "repo": { 87 | "id": 2, 88 | "node_id": "mock", 89 | "name": "repo", 90 | "full_name": "org/repo", 91 | "owner": { 92 | "login": "org", 93 | "id": 1, 94 | "node_id": "mock", 95 | "avatar_url": "https://github.test.fake/avatars/u/1?", 96 | "gravatar_id": "", 97 | "url": "https://github.test.fake/api/v3/users/org", 98 | "html_url": "https://github.test.fake/org", 99 | "followers_url": "https://github.test.fake/api/v3/users/org/followers", 100 | "following_url": "https://github.test.fake/api/v3/users/org/following{/other_user}", 101 | "gists_url": "https://github.test.fake/api/v3/users/org/gists{/gist_id}", 102 | "starred_url": "https://github.test.fake/api/v3/users/org/starred{/owner}{/repo}", 103 | "subscriptions_url": "https://github.test.fake/api/v3/users/org/subscriptions", 104 | "organizations_url": "https://github.test.fake/api/v3/users/org/orgs", 105 | "repos_url": "https://github.test.fake/api/v3/users/org/repos", 106 | "events_url": "https://github.test.fake/api/v3/users/org/events{/privacy}", 107 | "received_events_url": "https://github.test.fake/api/v3/users/org/received_events", 108 | "type": "Organization", 109 | "site_admin": false 110 | }, 111 | "private": false, 112 | "html_url": "https://github.test.fake/org/repo", 113 | "description": "Mock repo description", 114 | "fork": false, 115 | "url": "https://github.test.fake/api/v3/repos/org/repo", 116 | "forks_url": "https://github.test.fake/api/v3/repos/org/repo/forks", 117 | "keys_url": "https://github.test.fake/api/v3/repos/org/repo/keys{/key_id}", 118 | "collaborators_url": "https://github.test.fake/api/v3/repos/org/repo/collaborators{/collaborator}", 119 | "teams_url": "https://github.test.fake/api/v3/repos/org/repo/teams", 120 | "hooks_url": "https://github.test.fake/api/v3/repos/org/repo/hooks", 121 | "issue_events_url": "https://github.test.fake/api/v3/repos/org/repo/issues/events{/number}", 122 | "events_url": "https://github.test.fake/api/v3/repos/org/repo/events", 123 | "assignees_url": "https://github.test.fake/api/v3/repos/org/repo/assignees{/user}", 124 | "branches_url": "https://github.test.fake/api/v3/repos/org/repo/branches{/branch}", 125 | "tags_url": "https://github.test.fake/api/v3/repos/org/repo/tags", 126 | "blobs_url": "https://github.test.fake/api/v3/repos/org/repo/git/blobs{/sha}", 127 | "git_tags_url": "https://github.test.fake/api/v3/repos/org/repo/git/tags{/sha}", 128 | "git_refs_url": "https://github.test.fake/api/v3/repos/org/repo/git/refs{/sha}", 129 | "trees_url": "https://github.test.fake/api/v3/repos/org/repo/git/trees{/sha}", 130 | "statuses_url": "https://github.test.fake/api/v3/repos/org/repo/statuses/{sha}", 131 | "languages_url": "https://github.test.fake/api/v3/repos/org/repo/languages", 132 | "stargazers_url": "https://github.test.fake/api/v3/repos/org/repo/stargazers", 133 | "contributors_url": "https://github.test.fake/api/v3/repos/org/repo/contributors", 134 | "subscribers_url": "https://github.test.fake/api/v3/repos/org/repo/subscribers", 135 | "subscription_url": "https://github.test.fake/api/v3/repos/org/repo/subscription", 136 | "commits_url": "https://github.test.fake/api/v3/repos/org/repo/commits{/sha}", 137 | "git_commits_url": "https://github.test.fake/api/v3/repos/org/repo/git/commits{/sha}", 138 | "comments_url": "https://github.test.fake/api/v3/repos/org/repo/comments{/number}", 139 | "issue_comment_url": "https://github.test.fake/api/v3/repos/org/repo/issues/comments{/number}", 140 | "contents_url": "https://github.test.fake/api/v3/repos/org/repo/contents/{+path}", 141 | "compare_url": "https://github.test.fake/api/v3/repos/org/repo/compare/{base}...{head}", 142 | "merges_url": "https://github.test.fake/api/v3/repos/org/repo/merges", 143 | "archive_url": "https://github.test.fake/api/v3/repos/org/repo/{archive_format}{/ref}", 144 | "downloads_url": "https://github.test.fake/api/v3/repos/org/repo/downloads", 145 | "issues_url": "https://github.test.fake/api/v3/repos/org/repo/issues{/number}", 146 | "pulls_url": "https://github.test.fake/api/v3/repos/org/repo/pulls{/number}", 147 | "milestones_url": "https://github.test.fake/api/v3/repos/org/repo/milestones{/number}", 148 | "notifications_url": "https://github.test.fake/api/v3/repos/org/repo/notifications{?since,all,participating}", 149 | "labels_url": "https://github.test.fake/api/v3/repos/org/repo/labels{/name}", 150 | "releases_url": "https://github.test.fake/api/v3/repos/org/repo/releases{/id}", 151 | "deployments_url": "https://github.test.fake/api/v3/repos/org/repo/deployments", 152 | "created_at": "2018-12-31T00:00:00Z", 153 | "updated_at": "2018-12-31T00:00:00Z", 154 | "pushed_at": "2018-12-31T00:00:00Z", 155 | "git_url": "git://github.test.fake/org/repo.git", 156 | "ssh_url": "git@github.test.fake:org/repo.git", 157 | "clone_url": "https://github.test.fake/org/repo.git", 158 | "svn_url": "https://github.test.fake/org/repo", 159 | "homepage": null, 160 | "size": 123, 161 | "stargazers_count": 2, 162 | "watchers_count": 2, 163 | "language": "JavaScript", 164 | "has_issues": true, 165 | "has_projects": true, 166 | "has_downloads": true, 167 | "has_wiki": true, 168 | "has_pages": false, 169 | "forks_count": 3, 170 | "mirror_url": null, 171 | "archived": false, 172 | "open_issues_count": 2, 173 | "license": null, 174 | "forks": 3, 175 | "open_issues": 2, 176 | "watchers": 2, 177 | "default_branch": "master" 178 | } 179 | }, 180 | "base": { 181 | "label": "org:master", 182 | "ref": "master", 183 | "sha": "af7f27b1fed7d31216a075cbb80e9862e3e2740b", 184 | "user": { 185 | "login": "org", 186 | "id": 1, 187 | "node_id": "mock", 188 | "avatar_url": "https://github.test.fake/avatars/u/1?", 189 | "gravatar_id": "", 190 | "url": "https://github.test.fake/api/v3/users/org", 191 | "html_url": "https://github.test.fake/org", 192 | "followers_url": "https://github.test.fake/api/v3/users/org/followers", 193 | "following_url": "https://github.test.fake/api/v3/users/org/following{/other_user}", 194 | "gists_url": "https://github.test.fake/api/v3/users/org/gists{/gist_id}", 195 | "starred_url": "https://github.test.fake/api/v3/users/org/starred{/owner}{/repo}", 196 | "subscriptions_url": "https://github.test.fake/api/v3/users/org/subscriptions", 197 | "organizations_url": "https://github.test.fake/api/v3/users/org/orgs", 198 | "repos_url": "https://github.test.fake/api/v3/users/org/repos", 199 | "events_url": "https://github.test.fake/api/v3/users/org/events{/privacy}", 200 | "received_events_url": "https://github.test.fake/api/v3/users/org/received_events", 201 | "type": "Organization", 202 | "site_admin": false 203 | }, 204 | "repo": { 205 | "id": 2, 206 | "node_id": "mock", 207 | "name": "repo", 208 | "full_name": "org/repo", 209 | "owner": { 210 | "login": "org", 211 | "id": 1, 212 | "node_id": "mock", 213 | "avatar_url": "https://github.test.fake/avatars/u/1?", 214 | "gravatar_id": "", 215 | "url": "https://github.test.fake/api/v3/users/org", 216 | "html_url": "https://github.test.fake/org", 217 | "followers_url": "https://github.test.fake/api/v3/users/org/followers", 218 | "following_url": "https://github.test.fake/api/v3/users/org/following{/other_user}", 219 | "gists_url": "https://github.test.fake/api/v3/users/org/gists{/gist_id}", 220 | "starred_url": "https://github.test.fake/api/v3/users/org/starred{/owner}{/repo}", 221 | "subscriptions_url": "https://github.test.fake/api/v3/users/org/subscriptions", 222 | "organizations_url": "https://github.test.fake/api/v3/users/org/orgs", 223 | "repos_url": "https://github.test.fake/api/v3/users/org/repos", 224 | "events_url": "https://github.test.fake/api/v3/users/org/events{/privacy}", 225 | "received_events_url": "https://github.test.fake/api/v3/users/org/received_events", 226 | "type": "Organization", 227 | "site_admin": false 228 | }, 229 | "private": false, 230 | "html_url": "https://github.test.fake/org/repo", 231 | "description": "Mock repo description", 232 | "fork": false, 233 | "url": "https://github.test.fake/api/v3/repos/org/repo", 234 | "forks_url": "https://github.test.fake/api/v3/repos/org/repo/forks", 235 | "keys_url": "https://github.test.fake/api/v3/repos/org/repo/keys{/key_id}", 236 | "collaborators_url": "https://github.test.fake/api/v3/repos/org/repo/collaborators{/collaborator}", 237 | "teams_url": "https://github.test.fake/api/v3/repos/org/repo/teams", 238 | "hooks_url": "https://github.test.fake/api/v3/repos/org/repo/hooks", 239 | "issue_events_url": "https://github.test.fake/api/v3/repos/org/repo/issues/events{/number}", 240 | "events_url": "https://github.test.fake/api/v3/repos/org/repo/events", 241 | "assignees_url": "https://github.test.fake/api/v3/repos/org/repo/assignees{/user}", 242 | "branches_url": "https://github.test.fake/api/v3/repos/org/repo/branches{/branch}", 243 | "tags_url": "https://github.test.fake/api/v3/repos/org/repo/tags", 244 | "blobs_url": "https://github.test.fake/api/v3/repos/org/repo/git/blobs{/sha}", 245 | "git_tags_url": "https://github.test.fake/api/v3/repos/org/repo/git/tags{/sha}", 246 | "git_refs_url": "https://github.test.fake/api/v3/repos/org/repo/git/refs{/sha}", 247 | "trees_url": "https://github.test.fake/api/v3/repos/org/repo/git/trees{/sha}", 248 | "statuses_url": "https://github.test.fake/api/v3/repos/org/repo/statuses/{sha}", 249 | "languages_url": "https://github.test.fake/api/v3/repos/org/repo/languages", 250 | "stargazers_url": "https://github.test.fake/api/v3/repos/org/repo/stargazers", 251 | "contributors_url": "https://github.test.fake/api/v3/repos/org/repo/contributors", 252 | "subscribers_url": "https://github.test.fake/api/v3/repos/org/repo/subscribers", 253 | "subscription_url": "https://github.test.fake/api/v3/repos/org/repo/subscription", 254 | "commits_url": "https://github.test.fake/api/v3/repos/org/repo/commits{/sha}", 255 | "git_commits_url": "https://github.test.fake/api/v3/repos/org/repo/git/commits{/sha}", 256 | "comments_url": "https://github.test.fake/api/v3/repos/org/repo/comments{/number}", 257 | "issue_comment_url": "https://github.test.fake/api/v3/repos/org/repo/issues/comments{/number}", 258 | "contents_url": "https://github.test.fake/api/v3/repos/org/repo/contents/{+path}", 259 | "compare_url": "https://github.test.fake/api/v3/repos/org/repo/compare/{base}...{head}", 260 | "merges_url": "https://github.test.fake/api/v3/repos/org/repo/merges", 261 | "archive_url": "https://github.test.fake/api/v3/repos/org/repo/{archive_format}{/ref}", 262 | "downloads_url": "https://github.test.fake/api/v3/repos/org/repo/downloads", 263 | "issues_url": "https://github.test.fake/api/v3/repos/org/repo/issues{/number}", 264 | "pulls_url": "https://github.test.fake/api/v3/repos/org/repo/pulls{/number}", 265 | "milestones_url": "https://github.test.fake/api/v3/repos/org/repo/milestones{/number}", 266 | "notifications_url": "https://github.test.fake/api/v3/repos/org/repo/notifications{?since,all,participating}", 267 | "labels_url": "https://github.test.fake/api/v3/repos/org/repo/labels{/name}", 268 | "releases_url": "https://github.test.fake/api/v3/repos/org/repo/releases{/id}", 269 | "deployments_url": "https://github.test.fake/api/v3/repos/org/repo/deployments", 270 | "created_at": "2018-12-31T00:00:00Z", 271 | "updated_at": "2018-12-31T00:00:00Z", 272 | "pushed_at": "2018-12-31T00:00:00Z", 273 | "git_url": "git://github.test.fake/org/repo.git", 274 | "ssh_url": "git@github.test.fake:org/repo.git", 275 | "clone_url": "https://github.test.fake/org/repo.git", 276 | "svn_url": "https://github.test.fake/org/repo", 277 | "homepage": null, 278 | "size": 123, 279 | "stargazers_count": 2, 280 | "watchers_count": 2, 281 | "language": "JavaScript", 282 | "has_issues": true, 283 | "has_projects": true, 284 | "has_downloads": true, 285 | "has_wiki": true, 286 | "has_pages": false, 287 | "forks_count": 3, 288 | "mirror_url": null, 289 | "archived": false, 290 | "open_issues_count": 2, 291 | "license": null, 292 | "forks": 3, 293 | "open_issues": 2, 294 | "watchers": 2, 295 | "default_branch": "master" 296 | } 297 | }, 298 | "_links": { 299 | "self": { 300 | "href": "https://github.test.fake/api/v3/repos/org/repo/pulls/165" 301 | }, 302 | "html": { 303 | "href": "https://github.test.fake/org/repo/pull/165" 304 | }, 305 | "issue": { 306 | "href": "https://github.test.fake/api/v3/repos/org/repo/issues/165" 307 | }, 308 | "comments": { 309 | "href": "https://github.test.fake/api/v3/repos/org/repo/issues/165/comments" 310 | }, 311 | "review_comments": { 312 | "href": "https://github.test.fake/api/v3/repos/org/repo/pulls/165/comments" 313 | }, 314 | "review_comment": { 315 | "href": "https://github.test.fake/api/v3/repos/org/repo/pulls/comments{/number}" 316 | }, 317 | "commits": { 318 | "href": "https://github.test.fake/api/v3/repos/org/repo/pulls/165/commits" 319 | }, 320 | "statuses": { 321 | "href": "https://github.test.fake/api/v3/repos/org/repo/statuses/475e81e79c7880f9b5caa35bec50279c459ad2f9" 322 | } 323 | }, 324 | "author_association": "COLLABORATOR", 325 | "merged": false, 326 | "mergeable": null, 327 | "rebaseable": null, 328 | "mergeable_state": "unknown", 329 | "merged_by": null, 330 | "comments": 0, 331 | "review_comments": 0, 332 | "maintainer_can_modify": false, 333 | "commits": 1, 334 | "additions": 2, 335 | "deletions": 0, 336 | "changed_files": 1 337 | }, 338 | "repository": { 339 | "id": 2, 340 | "node_id": "mock", 341 | "name": "repo", 342 | "full_name": "org/repo", 343 | "owner": { 344 | "login": "org", 345 | "id": 1, 346 | "node_id": "mock", 347 | "avatar_url": "https://github.test.fake/avatars/u/1?", 348 | "gravatar_id": "", 349 | "url": "https://github.test.fake/api/v3/users/org", 350 | "html_url": "https://github.test.fake/org", 351 | "followers_url": "https://github.test.fake/api/v3/users/org/followers", 352 | "following_url": "https://github.test.fake/api/v3/users/org/following{/other_user}", 353 | "gists_url": "https://github.test.fake/api/v3/users/org/gists{/gist_id}", 354 | "starred_url": "https://github.test.fake/api/v3/users/org/starred{/owner}{/repo}", 355 | "subscriptions_url": "https://github.test.fake/api/v3/users/org/subscriptions", 356 | "organizations_url": "https://github.test.fake/api/v3/users/org/orgs", 357 | "repos_url": "https://github.test.fake/api/v3/users/org/repos", 358 | "events_url": "https://github.test.fake/api/v3/users/org/events{/privacy}", 359 | "received_events_url": "https://github.test.fake/api/v3/users/org/received_events", 360 | "type": "Organization", 361 | "site_admin": false 362 | }, 363 | "private": false, 364 | "html_url": "https://github.test.fake/org/repo", 365 | "description": "Mock repo description", 366 | "fork": false, 367 | "url": "https://github.test.fake/api/v3/repos/org/repo", 368 | "forks_url": "https://github.test.fake/api/v3/repos/org/repo/forks", 369 | "keys_url": "https://github.test.fake/api/v3/repos/org/repo/keys{/key_id}", 370 | "collaborators_url": "https://github.test.fake/api/v3/repos/org/repo/collaborators{/collaborator}", 371 | "teams_url": "https://github.test.fake/api/v3/repos/org/repo/teams", 372 | "hooks_url": "https://github.test.fake/api/v3/repos/org/repo/hooks", 373 | "issue_events_url": "https://github.test.fake/api/v3/repos/org/repo/issues/events{/number}", 374 | "events_url": "https://github.test.fake/api/v3/repos/org/repo/events", 375 | "assignees_url": "https://github.test.fake/api/v3/repos/org/repo/assignees{/user}", 376 | "branches_url": "https://github.test.fake/api/v3/repos/org/repo/branches{/branch}", 377 | "tags_url": "https://github.test.fake/api/v3/repos/org/repo/tags", 378 | "blobs_url": "https://github.test.fake/api/v3/repos/org/repo/git/blobs{/sha}", 379 | "git_tags_url": "https://github.test.fake/api/v3/repos/org/repo/git/tags{/sha}", 380 | "git_refs_url": "https://github.test.fake/api/v3/repos/org/repo/git/refs{/sha}", 381 | "trees_url": "https://github.test.fake/api/v3/repos/org/repo/git/trees{/sha}", 382 | "statuses_url": "https://github.test.fake/api/v3/repos/org/repo/statuses/{sha}", 383 | "languages_url": "https://github.test.fake/api/v3/repos/org/repo/languages", 384 | "stargazers_url": "https://github.test.fake/api/v3/repos/org/repo/stargazers", 385 | "contributors_url": "https://github.test.fake/api/v3/repos/org/repo/contributors", 386 | "subscribers_url": "https://github.test.fake/api/v3/repos/org/repo/subscribers", 387 | "subscription_url": "https://github.test.fake/api/v3/repos/org/repo/subscription", 388 | "commits_url": "https://github.test.fake/api/v3/repos/org/repo/commits{/sha}", 389 | "git_commits_url": "https://github.test.fake/api/v3/repos/org/repo/git/commits{/sha}", 390 | "comments_url": "https://github.test.fake/api/v3/repos/org/repo/comments{/number}", 391 | "issue_comment_url": "https://github.test.fake/api/v3/repos/org/repo/issues/comments{/number}", 392 | "contents_url": "https://github.test.fake/api/v3/repos/org/repo/contents/{+path}", 393 | "compare_url": "https://github.test.fake/api/v3/repos/org/repo/compare/{base}...{head}", 394 | "merges_url": "https://github.test.fake/api/v3/repos/org/repo/merges", 395 | "archive_url": "https://github.test.fake/api/v3/repos/org/repo/{archive_format}{/ref}", 396 | "downloads_url": "https://github.test.fake/api/v3/repos/org/repo/downloads", 397 | "issues_url": "https://github.test.fake/api/v3/repos/org/repo/issues{/number}", 398 | "pulls_url": "https://github.test.fake/api/v3/repos/org/repo/pulls{/number}", 399 | "milestones_url": "https://github.test.fake/api/v3/repos/org/repo/milestones{/number}", 400 | "notifications_url": "https://github.test.fake/api/v3/repos/org/repo/notifications{?since,all,participating}", 401 | "labels_url": "https://github.test.fake/api/v3/repos/org/repo/labels{/name}", 402 | "releases_url": "https://github.test.fake/api/v3/repos/org/repo/releases{/id}", 403 | "deployments_url": "https://github.test.fake/api/v3/repos/org/repo/deployments", 404 | "created_at": "2018-12-31T00:00:00Z", 405 | "updated_at": "2018-12-31T00:00:00Z", 406 | "pushed_at": "2018-12-31T00:00:00Z", 407 | "git_url": "git://github.test.fake/org/repo.git", 408 | "ssh_url": "git@github.test.fake:org/repo.git", 409 | "clone_url": "https://github.test.fake/org/repo.git", 410 | "svn_url": "https://github.test.fake/org/repo", 411 | "homepage": null, 412 | "size": 123, 413 | "stargazers_count": 2, 414 | "watchers_count": 2, 415 | "language": "JavaScript", 416 | "has_issues": true, 417 | "has_projects": true, 418 | "has_downloads": true, 419 | "has_wiki": true, 420 | "has_pages": false, 421 | "forks_count": 3, 422 | "mirror_url": null, 423 | "archived": false, 424 | "open_issues_count": 2, 425 | "license": null, 426 | "forks": 3, 427 | "open_issues": 2, 428 | "watchers": 2, 429 | "default_branch": "master" 430 | }, 431 | "organization": { 432 | "login": "org", 433 | "id": 1, 434 | "node_id": "mock", 435 | "url": "https://github.test.fake/api/v3/orgs/org", 436 | "repos_url": "https://github.test.fake/api/v3/orgs/org/repos", 437 | "events_url": "https://github.test.fake/api/v3/orgs/org/events", 438 | "hooks_url": "https://github.test.fake/api/v3/orgs/org/hooks", 439 | "issues_url": "https://github.test.fake/api/v3/orgs/org/issues", 440 | "members_url": "https://github.test.fake/api/v3/orgs/org/members{/member}", 441 | "public_members_url": "https://github.test.fake/api/v3/orgs/org/public_members{/member}", 442 | "avatar_url": "https://github.test.fake/avatars/u/1?", 443 | "description": "" 444 | }, 445 | "sender": { 446 | "login": "JDoe", 447 | "id": 123, 448 | "node_id": "mock", 449 | "avatar_url": "https://github.test.fake/avatars/u/123?", 450 | "gravatar_id": "", 451 | "url": "https://github.test.fake/api/v3/users/JDoe", 452 | "html_url": "https://github.test.fake/JDoe", 453 | "followers_url": "https://github.test.fake/api/v3/users/JDoe/followers", 454 | "following_url": "https://github.test.fake/api/v3/users/JDoe/following{/other_user}", 455 | "gists_url": "https://github.test.fake/api/v3/users/JDoe/gists{/gist_id}", 456 | "starred_url": "https://github.test.fake/api/v3/users/JDoe/starred{/owner}{/repo}", 457 | "subscriptions_url": "https://github.test.fake/api/v3/users/JDoe/subscriptions", 458 | "organizations_url": "https://github.test.fake/api/v3/users/JDoe/orgs", 459 | "repos_url": "https://github.test.fake/api/v3/users/JDoe/repos", 460 | "events_url": "https://github.test.fake/api/v3/users/JDoe/events{/privacy}", 461 | "received_events_url": "https://github.test.fake/api/v3/users/JDoe/received_events", 462 | "type": "User", 463 | "site_admin": false, 464 | "ldap_dn": "CN=John Doe,OU=Users & Groups,OU=Company,DC=mock" 465 | }, 466 | "installation": { 467 | "id": 1 468 | } 469 | } -------------------------------------------------------------------------------- /test/integration/helpers.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | /** @type {(url: string, options?: RequestInit) => Promise} */ 3 | import fetch from 'node-fetch'; 4 | 5 | export async function assumeValidResponse(url, expectedBody) { 6 | const res = await fetch(url); 7 | assume(res.status).equals(200); 8 | const body = await res.text(); 9 | assume(body).includes(expectedBody); 10 | } 11 | -------------------------------------------------------------------------------- /test/integration/index.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import fs from 'fs'; 3 | import nock from 'nock'; 4 | import path from 'path'; 5 | /** @type {(url: string, options?: RequestInit) => Promise} */ 6 | import fetch from 'node-fetch'; 7 | import { fileURLToPath } from 'url'; 8 | import pullieApp from '../../index.js'; 9 | import { Server, Probot } from 'probot'; 10 | import { assumeValidResponse } from './helpers.js'; 11 | 12 | import { createRequire } from 'module'; 13 | const require = createRequire(import.meta.url); 14 | const openPRPayload = require('../fixtures/payloads/open-pr.json'); 15 | const mockOrgPullieRC = require('../fixtures/payloads/mock-org-pullierc.json'); 16 | const mockPullieRC = require('../fixtures/payloads/mock-pullierc.json'); 17 | const mockPackageJson = require('../fixtures/payloads/mock-packagejson.json'); 18 | 19 | function nockFile(scope, urlPath, contents, repo = 'repo') { 20 | scope.get(`/api/v3/repos/org/${repo}/contents/` + urlPath) 21 | .reply(200, { 22 | content: Buffer.from(contents).toString('base64'), 23 | path: urlPath 24 | }); 25 | } 26 | 27 | function verifyComment({ body }) { 28 | try { 29 | assume(body).contains('Mock ticket 1 title'); 30 | assume(body).contains('Requested review from @jsmith'); 31 | assume(body).contains('o hai!'); 32 | } catch (failedAssumption) { 33 | // eslint-disable-next-line no-console 34 | console.error(failedAssumption.toString()); 35 | return false; 36 | } 37 | 38 | return true; 39 | } 40 | 41 | describe('Pullie (integration)', function () { 42 | /** @type {Probot} */ 43 | let pullie; 44 | /** @type {string} */ 45 | let mockCert; 46 | 47 | before(function (done) { 48 | const __dirname = fileURLToPath(new URL('.', import.meta.url)); 49 | fs.readFile(path.join(__dirname, '../fixtures/mock-key.pem'), (err, cert) => { 50 | if (err) return done(err); 51 | mockCert = cert; 52 | done(); 53 | }); 54 | }); 55 | 56 | before(function () { 57 | const github = nock('https://github.test.fake') 58 | .post('/api/v3/app/installations/1/access_tokens') 59 | .reply(201, { 60 | token: 'mock_token', 61 | expires_at: '9999-12-31T00:00:00Z', 62 | permissions: { 63 | contents: 'read' 64 | } 65 | }) 66 | .get('/api/v3/repos/org/repo/collaborators/jsmith') 67 | .reply(204) 68 | .get('/api/v3/repos/org/repo/pulls/165/files') 69 | .reply(200, [ 70 | { 71 | filename: 'CHANGELOG.md' 72 | } 73 | ]) 74 | .post('/api/v3/repos/org/repo/pulls/165/requested_reviewers') 75 | .reply(200) 76 | .get('/api/v3/repos/org/repo/issues?state=all&creator=JDoe') 77 | .reply(200, [ 78 | { 79 | pull_request: {} 80 | } 81 | ]) 82 | .post('/api/v3/repos/org/repo/issues/165/comments', verifyComment) 83 | .reply(200); 84 | 85 | nockFile(github, '.pullierc', JSON.stringify(mockOrgPullieRC), '.github'); 86 | nockFile(github, '.pullierc', JSON.stringify(mockPullieRC)); 87 | nockFile(github, 'package.json', JSON.stringify(mockPackageJson)); 88 | nockFile(github, 'CHANGELOG.md', '# Mock changelog'); 89 | 90 | nock('https://jira.test.fake') 91 | .post('/rest/api/2/search') 92 | .reply(200, { 93 | issues: [ 94 | { 95 | key: 'AB-1234', 96 | fields: { 97 | summary: 'Mock ticket 1 title' 98 | } 99 | }, 100 | { 101 | key: 'FOO-5678', 102 | fields: { 103 | summary: 'Mock ticket 2 title' 104 | } 105 | } 106 | ] 107 | }); 108 | }); 109 | 110 | before(function () { 111 | process.env.JIRA_PROTOCOL = 'https'; 112 | process.env.JIRA_HOST = 'jira.test.fake'; 113 | process.env.JIRA_USERNAME = 'test_user'; 114 | process.env.JIRA_PASSWORD = 'test_password'; 115 | }); 116 | 117 | after(function () { 118 | nock.restore(); 119 | }); 120 | 121 | describe('API', function () { 122 | before(async function () { 123 | process.env.DISABLE_DOCS_ROUTE = 'true'; 124 | pullie = new Probot({ appId: 123, privateKey: mockCert, baseUrl: 'https://github.test.fake/api/v3' }); 125 | await pullie.load(pullieApp); 126 | nock.disableNetConnect(); 127 | }); 128 | 129 | after(function () { 130 | delete process.env.DISABLE_DOCS_ROUTE; 131 | nock.enableNetConnect(); 132 | }); 133 | 134 | it('properly processes a pull request', async function () { 135 | this.timeout(5000); 136 | await pullie.receive({ 137 | id: 'mock', 138 | name: 'pull_request', 139 | payload: openPRPayload 140 | }); 141 | if (!nock.isDone()) { 142 | // eslint-disable-next-line no-console 143 | console.error('pending mocks: %j', nock.pendingMocks()); 144 | } 145 | assume(nock.isDone()).is.true(); 146 | }); 147 | }); 148 | 149 | describe('Docs', function () { 150 | /** @type {import('probot').Server} */ 151 | let server; 152 | let baseUrl; 153 | before(async function () { 154 | server = new Server({ 155 | Probot: Probot.defaults({ appId: 123, privateKey: mockCert }) 156 | }); 157 | await server.load(pullieApp); 158 | const httpServer = await server.start(); 159 | // @ts-ignore 160 | const { port } = httpServer.address(); 161 | baseUrl = `http://localhost:${port}/docs`; 162 | }); 163 | 164 | after(async function () { 165 | await server.stop(); 166 | }); 167 | 168 | it('serves documentation at host root', async function () { 169 | await assumeValidResponse(baseUrl, ''); 170 | }); 171 | 172 | it('serves Prism CSS properly', async function () { 173 | await assumeValidResponse(baseUrl + '/prism-coy.css', 'prism'); 174 | }); 175 | 176 | it('serves healthcheck properly', async function () { 177 | await assumeValidResponse(baseUrl + '/healthcheck.html', 'page ok'); 178 | }); 179 | 180 | it('serves static files properly', async function () { 181 | await assumeValidResponse(baseUrl + '/static/pullie.svg', 'svg'); 182 | }); 183 | }); 184 | 185 | describe('No Docs', function () { 186 | /** @type {import('probot').Server} */ 187 | let server; 188 | let baseUrl; 189 | 190 | before(async function () { 191 | process.env.DISABLE_DOCS_ROUTE = 'true'; 192 | server = new Server({ 193 | Probot: Probot.defaults({ appId: 123, privateKey: mockCert }) 194 | }); 195 | await server.load(pullieApp); 196 | const httpServer = await server.start(); 197 | // @ts-ignore 198 | const { port } = httpServer.address(); 199 | baseUrl = `http://localhost:${port}/docs`; 200 | }); 201 | 202 | after(async function () { 203 | await server.stop(); 204 | }); 205 | 206 | it('does not initialize the docs route when DISABLE_DOCS_ROUTE is set', async function () { 207 | const res = await fetch(baseUrl); 208 | assume(res.status).equals(404); 209 | }); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /test/unit/commenter.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | 3 | import Commenter from '../../commenter.js'; 4 | 5 | describe('Commenter', function () { 6 | const commenter = new Commenter(); 7 | 8 | it('is a constructor', function () { 9 | assume(Commenter).is.a('function'); 10 | assume(Commenter).has.length(0); 11 | assume(commenter).is.instanceOf(Commenter); 12 | }); 13 | 14 | describe('.priority', function () { 15 | it('is an enum', function () { 16 | assume(Commenter).hasOwn('priority'); 17 | assume(Commenter.priority).has.length(3); 18 | assume(Commenter.priority).contains('Low'); 19 | assume(Commenter.priority).contains('Medium'); 20 | assume(Commenter.priority).contains('High'); 21 | assume(Commenter.priority.Low).lt(Commenter.priority.Medium); 22 | assume(Commenter.priority.Medium).lt(Commenter.priority.High); 23 | }); 24 | }); 25 | 26 | describe('.addComment', function () { 27 | it('throws on empty message', function () { 28 | assume(() => commenter.addComment(null, Commenter.priority.Low)).throws(); 29 | }); 30 | 31 | it('throws on invalid priority', function () { 32 | // @ts-ignore 33 | assume(() => commenter.addComment('comment', 'Low')).throws(); 34 | assume(() => commenter.addComment('comment', -1)).throws(); 35 | assume(() => commenter.addComment('comment', 3)).throws(); 36 | }); 37 | 38 | it('Enqueues a comment properly', function () { 39 | assume(commenter.comments).has.length(0); 40 | commenter.addComment('comment', Commenter.priority.Low); 41 | assume(commenter.comments).has.length(1); 42 | const lastComment = commenter.comments.pop(); 43 | assume(lastComment.msg).equals('comment'); 44 | assume(lastComment.priority).equals(Commenter.priority.Low); 45 | }); 46 | }); 47 | 48 | describe('.flushToString', function () { 49 | it('bails when there are no queued comments', function () { 50 | assume(commenter.comments).has.length(0); 51 | assume(commenter.flushToString()).equals(null); 52 | }); 53 | 54 | it('outputs a properly formatted comment', function () { 55 | commenter.addComment('comment', Commenter.priority.Low); 56 | assume(commenter.comments).has.length(1); 57 | assume(commenter.flushToString()).equals('comment'); 58 | assume(commenter.comments).has.length(0); 59 | }); 60 | 61 | it('outputs a sorted list of comments', function () { 62 | commenter.addComment('comment-low', Commenter.priority.Low); 63 | commenter.addComment('comment-high', Commenter.priority.High); 64 | commenter.addComment('comment-medium1', Commenter.priority.Medium); 65 | commenter.addComment('comment-medium2', Commenter.priority.Medium); 66 | assume(commenter.comments).has.length(4); 67 | assume(commenter.flushToString()) 68 | .equals('comment-high\n\n---\n\ncomment-medium1\n\n---\n\ncomment-medium2\n\n---\n\ncomment-low'); 69 | assume(commenter.comments).has.length(0); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /test/unit/config-processor.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import assumeSinon from 'assume-sinon'; 3 | import sinon from 'sinon'; 4 | 5 | import processConfig, { applyExcludeList, applyIncludeList } from '../../config-processor.js'; 6 | 7 | assume.use(assumeSinon); 8 | 9 | /** 10 | * @typedef {Object} Plugin 11 | * @prop {string} plugin The name of the plugin 12 | * @prop {Object} [config] Arbitrary plugin-specific configuration 13 | */ 14 | 15 | const mergeConfigStub = sinon.stub(); 16 | const mockPluginManager = { 17 | one: { 18 | processesEdits: false, 19 | processRequest: async (context, commenter, config) => { void context; void commenter; void config; }, 20 | mergeConfig: mergeConfigStub 21 | }, 22 | two: { 23 | processesEdits: false, 24 | processRequest: async (context, commenter, config) => { void context; void commenter; void config; }, 25 | mergeConfig: mergeConfigStub 26 | }, 27 | three: { 28 | processesEdits: false, 29 | processRequest: async (context, commenter, config) => { void context; void commenter; void config; }, 30 | mergeConfig: mergeConfigStub 31 | }, 32 | four: { 33 | processesEdits: false, 34 | processRequest: async (context, commenter, config) => { void context; void commenter; void config; }, 35 | mergeConfig: mergeConfigStub 36 | } 37 | }; 38 | 39 | describe('processConfig', function () { 40 | it('is a function', function () { 41 | assume(processConfig).is.a('function'); 42 | assume(processConfig).has.length(4); 43 | }); 44 | 45 | it('returns the org config when no repo config is specified', function () { 46 | const orgConfig = { 47 | foo: 'bar', 48 | plugins: [ 49 | 'blah' 50 | ] 51 | }; 52 | 53 | const processed = processConfig(mockPluginManager, orgConfig, null); 54 | // Not same object instance 55 | assume(processed).does.not.equal(orgConfig); 56 | assume(processed).deep.equals(orgConfig); 57 | }); 58 | 59 | it('returns the org config when the repo config is an empty object', function () { 60 | const orgConfig = { 61 | foo: 'bar', 62 | plugins: [ 63 | 'blah' 64 | ] 65 | }; 66 | 67 | const processed = processConfig(mockPluginManager, orgConfig, {}); 68 | // Not same object instance 69 | assume(processed).does.not.equal(orgConfig); 70 | assume(processed).deep.equals(orgConfig); 71 | }); 72 | 73 | it('merges properly when org config is not specified', function () { 74 | const repoConfig = { 75 | foo: 'rab', 76 | blah: { 77 | a: false, 78 | c: true 79 | }, 80 | plugins: [] 81 | }; 82 | 83 | const processed = processConfig(mockPluginManager, null, repoConfig); 84 | // Not same object instance 85 | assume(processed).does.not.equal(repoConfig); 86 | assume(processed).deep.equals(repoConfig); 87 | }); 88 | 89 | it('transforms an override-manifest-style repo config to a normal config when org config is not specified', 90 | function () { 91 | const repoConfig = { 92 | plugins: { 93 | include: [ 94 | 'one' 95 | ], 96 | exclude: [ 97 | 'fakePlugin' 98 | ] 99 | } 100 | }; 101 | 102 | const processed = processConfig(mockPluginManager, null, repoConfig); 103 | 104 | assume(processed).deep.equals({ 105 | plugins: [ 106 | 'one' 107 | ] 108 | }); 109 | }); 110 | 111 | it('deep merges fields other than plugins', function () { 112 | const orgConfig = { 113 | foo: 'bar', 114 | blah: { 115 | a: true, 116 | b: false 117 | }, 118 | plugins: [] 119 | }; 120 | const repoConfig = { 121 | foo: 'rab', 122 | blah: { 123 | a: false, 124 | c: true 125 | }, 126 | plugins: [] 127 | }; 128 | 129 | assume(processConfig(mockPluginManager, orgConfig, repoConfig)).deep.equals({ 130 | foo: 'rab', 131 | blah: { 132 | a: false, 133 | b: false, 134 | c: true 135 | }, 136 | plugins: [] 137 | }); 138 | }); 139 | 140 | it('merges in a basic array of repo plugins', function () { 141 | const orgConfig = { 142 | plugins: [ 143 | 'one', 144 | 'two' 145 | ] 146 | }; 147 | 148 | const repoConfig = { 149 | plugins: [ 150 | 'two', 151 | 'three' 152 | ] 153 | }; 154 | 155 | assume(processConfig(mockPluginManager, orgConfig, repoConfig)).deep.equals({ 156 | plugins: [ 157 | 'one', 158 | 'two', 159 | 'three' 160 | ] 161 | }); 162 | }); 163 | 164 | it('merges in an overrides-manifest-style repo plugins set that only has an include list', function () { 165 | const orgConfig = { 166 | plugins: [ 167 | 'one', 168 | 'two' 169 | ] 170 | }; 171 | 172 | const repoConfig = { 173 | plugins: { 174 | include: [ 175 | 'two', 176 | 'three' 177 | ] 178 | } 179 | }; 180 | 181 | assume(processConfig(mockPluginManager, orgConfig, repoConfig)).deep.equals({ 182 | plugins: [ 183 | 'one', 184 | 'two', 185 | 'three' 186 | ] 187 | }); 188 | }); 189 | 190 | it('merges in an overrides-manifest-style repo plugins set that only has an exclude list', function () { 191 | const orgConfig = { 192 | plugins: [ 193 | 'one', 194 | 'two' 195 | ] 196 | }; 197 | 198 | const repoConfig = { 199 | plugins: { 200 | exclude: [ 201 | 'one', 202 | 'four' 203 | ] 204 | } 205 | }; 206 | 207 | assume(processConfig(mockPluginManager, orgConfig, repoConfig)).deep.equals({ 208 | plugins: [ 209 | 'two' 210 | ] 211 | }); 212 | }); 213 | 214 | it('merges in an overrides-manifest-style repo plugins set', function () { 215 | const orgConfig = { 216 | plugins: [ 217 | 'one', 218 | 'two' 219 | ] 220 | }; 221 | 222 | const repoConfig = { 223 | plugins: { 224 | include: [ 225 | 'two', 226 | 'three' 227 | ], 228 | exclude: [ 229 | 'one', 230 | 'four' 231 | ] 232 | } 233 | }; 234 | 235 | assume(processConfig(mockPluginManager, orgConfig, repoConfig)).deep.equals({ 236 | plugins: [ 237 | 'two', 238 | 'three' 239 | ] 240 | }); 241 | }); 242 | 243 | it('calls the onInvalidPlugin callback when an invalid plugin is encountered', function () { 244 | const orgConfig = { 245 | plugins: [ 246 | 'one', 247 | 'two' 248 | ] 249 | }; 250 | 251 | const repoConfig = { 252 | plugins: [ 253 | 'fake' 254 | ] 255 | }; 256 | 257 | const onInvalidPluginStub = sinon.stub(); 258 | processConfig(mockPluginManager, orgConfig, repoConfig, onInvalidPluginStub); 259 | assume(onInvalidPluginStub).calledWith('fake'); 260 | }); 261 | 262 | describe('.applyIncludeList', function () { 263 | it('is a function', function () { 264 | assume(applyIncludeList).is.a('function'); 265 | assume(applyIncludeList).has.length(1); 266 | }); 267 | 268 | it('does not mutate the orgPlugins parameter', function () { 269 | const orgPlugins = [ 270 | 'one', 271 | 'two' 272 | ]; 273 | applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: ['three'] }); 274 | assume(orgPlugins).deep.equals([ 275 | 'one', 276 | 'two' 277 | ]); 278 | }); 279 | 280 | it('adds a plugin by name', function () { 281 | const orgPlugins = [ 282 | 'one', 283 | 'two' 284 | ]; 285 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: ['three'] })).deep.equals([ 286 | 'one', 287 | 'two', 288 | 'three' 289 | ]); 290 | }); 291 | 292 | it('does nothing when a string plugin already exists', function () { 293 | const orgPlugins = [ 294 | 'one', 295 | 'two' 296 | ]; 297 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: ['two'] })).deep.equals([ 298 | 'one', 299 | 'two' 300 | ]); 301 | }); 302 | 303 | it('does not change an existing plugin object when listed by name in the include list', function () { 304 | const orgPlugins = [ 305 | 'one', 306 | { 307 | plugin: 'two', 308 | config: { 309 | foo: 'bar' 310 | } 311 | } 312 | ]; 313 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: ['two'] })).deep.equals([ 314 | 'one', 315 | { 316 | plugin: 'two', 317 | config: { 318 | foo: 'bar' 319 | } 320 | } 321 | ]); 322 | }); 323 | 324 | it('ignores non-string-nor-object plugins', function () { 325 | const orgPlugins = [ 326 | 'one', 327 | 'two' 328 | ]; 329 | // @ts-ignore 330 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: [123] })).deep.equals([ 331 | 'one', 332 | 'two' 333 | ]); 334 | }); 335 | 336 | it('ignores objects without a `plugin` field', function () { 337 | const orgPlugins = [ 338 | 'one', 339 | 'two' 340 | ]; 341 | // @ts-ignore 342 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: [{}] })).deep.equals([ 343 | 'one', 344 | 'two' 345 | ]); 346 | }); 347 | 348 | it('adds in object plugins that were not previously present', function () { 349 | const orgPlugins = [ 350 | 'one', 351 | 'two' 352 | ]; 353 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: [{ 354 | plugin: 'three', 355 | config: { 356 | foo: 'bar' 357 | } 358 | }] })).deep.equals([ 359 | 'one', 360 | 'two', 361 | { 362 | plugin: 'three', 363 | config: { 364 | foo: 'bar' 365 | } 366 | } 367 | ]); 368 | }); 369 | 370 | it('replaces existing string plugins with new object plugins', function () { 371 | const orgPlugins = [ 372 | 'one', 373 | 'two' 374 | ]; 375 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: [{ 376 | plugin: 'two', 377 | config: { 378 | foo: 'bar' 379 | } 380 | }] })).deep.equals([ 381 | 'one', 382 | { 383 | plugin: 'two', 384 | config: { 385 | foo: 'bar' 386 | } 387 | } 388 | ]); 389 | }); 390 | 391 | it('replaces existing object plugins that have no config field', function () { 392 | const orgPlugins = [ 393 | 'one', 394 | { 395 | plugin: 'two', 396 | somethingElse: true 397 | } 398 | ]; 399 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: [{ 400 | plugin: 'two', 401 | config: { 402 | foo: 'bar' 403 | } 404 | }] })).deep.equals([ 405 | 'one', 406 | { 407 | plugin: 'two', 408 | config: { 409 | foo: 'bar' 410 | } 411 | } 412 | ]); 413 | }); 414 | 415 | it('ignores object plugins that are not known by the plugin manager', function () { 416 | const orgPlugins = [ 417 | { 418 | plugin: 'one', 419 | config: { 420 | foo: 'bar' 421 | } 422 | } 423 | ]; 424 | const onInvalidPluginStub = sinon.stub(); 425 | assume(applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList: [{ 426 | plugin: 'unknownPlugin', 427 | config: { 428 | baz: 'blah' 429 | } 430 | }, 431 | 'anotherUnknownPlugin'], onInvalidPlugin: onInvalidPluginStub })).deep.equals([ 432 | { 433 | plugin: 'one', 434 | config: { 435 | foo: 'bar' 436 | } 437 | } 438 | ]); 439 | assume(onInvalidPluginStub).calledWith('unknownPlugin'); 440 | }); 441 | 442 | it('merges config of existing object plugins with new object', function () { 443 | /** @type {(string | Plugin)[]} */ 444 | const orgPlugins = [ 445 | 'one', 446 | { 447 | plugin: 'two', 448 | config: { 449 | foo: 'bar', 450 | baz: 'blah' 451 | } 452 | } 453 | ]; 454 | const repoIncludeList = [{ 455 | plugin: 'two', 456 | config: { 457 | foo: 'rab', 458 | gah: 'meh' 459 | } 460 | }]; 461 | applyIncludeList({ pluginManager: mockPluginManager, orgPlugins, repoIncludeList }); 462 | 463 | const orgTwoConfig = /** @type {Plugin} */ (orgPlugins[1]).config; 464 | const repoTwoConfig = repoIncludeList[0].config; 465 | 466 | assume(mergeConfigStub).calledWithMatch( 467 | orgTwoConfig, 468 | repoTwoConfig 469 | ); 470 | }); 471 | }); 472 | 473 | describe('.applyExcludeList', function () { 474 | it('is a function', function () { 475 | assume(applyExcludeList).is.a('function'); 476 | assume(applyExcludeList).has.length(1); 477 | }); 478 | 479 | it('keeps all plugins in the list by default', function () { 480 | const orgPlugins = [ 481 | 'one', 482 | 'two' 483 | ]; 484 | assume(applyExcludeList({ orgPlugins, repoExcludeList: [] })).deep.equals(orgPlugins); 485 | }); 486 | 487 | it('ignores invalid entries in the org plugin list and keeps plugins by default', function () { 488 | const orgPlugins = [ 489 | 'one', 490 | 'two', 491 | 123 492 | ]; 493 | assume(applyExcludeList({ orgPlugins, repoExcludeList: [123] })).deep.equals(orgPlugins); 494 | }); 495 | 496 | it('excludes plugins that are listed by name', function () { 497 | const orgPlugins = [ 498 | 'one', 499 | 'two' 500 | ]; 501 | assume(applyExcludeList({ orgPlugins, repoExcludeList: ['one'] })).deep.equals(['two']); 502 | }); 503 | 504 | it('excludes plugins that are listed as objects', function () { 505 | const orgPlugins = [ 506 | { 507 | plugin: 'one', 508 | config: {} 509 | }, 510 | { 511 | plugin: 'two' 512 | } 513 | ]; 514 | assume(applyExcludeList({ orgPlugins, repoExcludeList: ['one'] })).deep.equals([{ plugin: 'two' }]); 515 | }); 516 | 517 | it('works properly with heterogeneous sets of org plugins', function () { 518 | const orgPlugins = [ 519 | { 520 | plugin: 'one', 521 | config: {} 522 | }, 523 | { 524 | plugin: 'two' 525 | }, 526 | 'three', 527 | 'four' 528 | ]; 529 | assume(applyExcludeList({ orgPlugins, repoExcludeList: ['one', 'three'] })).deep.equals([ 530 | { plugin: 'two' }, 'four' 531 | ]); 532 | }); 533 | }); 534 | }); 535 | -------------------------------------------------------------------------------- /test/unit/plugins.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | 3 | import Plugins from '../../plugins/index.js'; 4 | 5 | describe('Plugins', function () { 6 | it('Exposes all plugins as an object', function () { 7 | assume(Plugins).is.a('function'); 8 | const plugins = new Plugins(); 9 | assume(plugins).is.instanceOf(Plugins); 10 | assume(plugins).contains('jira'); 11 | assume(plugins).contains('requiredFile'); 12 | assume(plugins).contains('reviewers'); 13 | assume(plugins).contains('welcome'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/unit/plugins/base.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import BasePlugin from '../../../plugins/base.js'; 3 | 4 | describe('Base plugin', function () { 5 | /** @type {BasePlugin} */ 6 | let basePlugin; 7 | beforeEach(function () { 8 | basePlugin = new BasePlugin(); 9 | }); 10 | 11 | it('is a constructor', function () { 12 | assume(BasePlugin).is.a('function'); 13 | assume(basePlugin).is.instanceOf(BasePlugin); 14 | }); 15 | 16 | describe('.mergeConfig', function () { 17 | it('is a function', function () { 18 | assume(basePlugin.mergeConfig).is.a('function'); 19 | assume(basePlugin.mergeConfig).has.length(2); 20 | }); 21 | 22 | it('handles null base', function () { 23 | assume(basePlugin.mergeConfig(null, { foo: 'bar' })).deep.equals({ 24 | foo: 'bar' 25 | }); 26 | }); 27 | 28 | it('handles null overrides', function () { 29 | assume(basePlugin.mergeConfig({ foo: 'bar' }, null)).deep.equals({ 30 | foo: 'bar' 31 | }); 32 | }); 33 | 34 | it('shallow merges overrides over base', function () { 35 | assume(basePlugin.mergeConfig({ foo: 'bar', baz: 'blah' }, { foo: 'ack', woo: 'wah' })).deep.equals({ 36 | foo: 'ack', 37 | baz: 'blah', 38 | woo: 'wah' 39 | }); 40 | }); 41 | }); 42 | 43 | describe('.processesEdits', function () { 44 | it('is a getter', function () { 45 | const descriptor = Object.getOwnPropertyDescriptor(BasePlugin.prototype, 'processesEdits'); 46 | assume(descriptor).hasOwn('get'); 47 | assume(descriptor.get).is.a('function'); 48 | }); 49 | 50 | it('returns false', function () { 51 | assume(basePlugin.processesEdits).is.false(); 52 | }); 53 | }); 54 | 55 | describe('.processesReadyForReview', function () { 56 | it('is a getter', function () { 57 | const descriptor = Object.getOwnPropertyDescriptor(BasePlugin.prototype, 'processesReadyForReview'); 58 | assume(descriptor).hasOwn('get'); 59 | assume(descriptor.get).is.a('function'); 60 | }); 61 | 62 | it('returns false', function () { 63 | assume(basePlugin.processesReadyForReview).is.false(); 64 | }); 65 | }); 66 | 67 | describe('.processRequest', function () { 68 | it('is an async function', function () { 69 | assume(basePlugin.processRequest).is.an('asyncfunction'); 70 | assume(basePlugin.processRequest).has.length(3); 71 | }); 72 | 73 | it('throws since it is abstract', async function () { 74 | let rejected = false; 75 | try { 76 | await basePlugin.processRequest(null, null, null); 77 | } catch (err) { 78 | rejected = true; 79 | assume(err).is.truthy(); 80 | } 81 | assume(rejected).is.true(); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/unit/plugins/jira.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import assumeSinon from 'assume-sinon'; 3 | import sinon from 'sinon'; 4 | import JiraPlugin from '../../../plugins/jira/index.js'; 5 | 6 | assume.use(assumeSinon); 7 | 8 | describe('JiraPlugin', function () { 9 | let addCommentStub, commenter, fetchStub, jiraPlugin; 10 | 11 | before(function () { 12 | addCommentStub = sinon.stub(); 13 | commenter = { 14 | addComment: addCommentStub 15 | }; 16 | 17 | jiraPlugin = new JiraPlugin(); 18 | fetchStub = sinon.stub(jiraPlugin, 'fetch'); 19 | }); 20 | 21 | after(function () { 22 | sinon.restore(); 23 | }); 24 | 25 | it('is a constructor', function () { 26 | assume(JiraPlugin).is.a('function'); 27 | assume(JiraPlugin).has.length(0); 28 | assume(jiraPlugin).is.an('object'); 29 | }); 30 | 31 | it('processes edits', function () { 32 | assume(jiraPlugin.processesEdits).is.true(); 33 | }); 34 | 35 | describe('.processRequest', function () { 36 | beforeEach(function () { 37 | fetchStub.resetHistory(); 38 | addCommentStub.resetHistory(); 39 | }); 40 | 41 | it('is a function', function () { 42 | assume(jiraPlugin.processRequest).is.an('asyncfunction'); 43 | assume(jiraPlugin.processRequest).has.length(2); 44 | }); 45 | 46 | it(`bails out if the PR action is an edit and the title hasn't changed`, async function () { 47 | await jiraPlugin.processRequest({ 48 | payload: { 49 | action: 'edited', 50 | changes: { 51 | title: { 52 | from: 'title' 53 | } 54 | }, 55 | // @ts-ignore 56 | pull_request: { 57 | title: 'title' 58 | } 59 | } 60 | }, commenter); 61 | 62 | assume(fetchStub).has.not.been.called(); 63 | }); 64 | 65 | it(`bails out if the PR action is an edit and the title isn't listed as changed`, async function () { 66 | await jiraPlugin.processRequest({ 67 | payload: { 68 | action: 'edited', 69 | changes: { 70 | body: { 71 | from: 'body' 72 | } 73 | }, 74 | // @ts-ignore 75 | pull_request: { 76 | title: 'title' 77 | } 78 | } 79 | }, commenter); 80 | 81 | assume(fetchStub).has.not.been.called(); 82 | }); 83 | 84 | it('bails out if there are no ticket IDs in the PR title', async function () { 85 | await jiraPlugin.processRequest({ 86 | payload: { 87 | action: 'created', 88 | // @ts-ignore 89 | pull_request: { 90 | title: 'title with no Jira tickets' 91 | } 92 | } 93 | }, commenter); 94 | 95 | assume(fetchStub).has.not.been.called(); 96 | }); 97 | 98 | it('bails out on error from the Jira request', async function () { 99 | const mockError = new Error('mock error'); 100 | fetchStub.rejects(mockError); 101 | try { 102 | await jiraPlugin.processRequest({ 103 | payload: { 104 | action: 'created', 105 | // @ts-ignore 106 | pull_request: { 107 | title: '[AB-1234] title with 1 Jira ticket' 108 | } 109 | } 110 | }, commenter); 111 | } catch (err) { 112 | assume(err).is.truthy(); 113 | assume(err).equals(mockError); 114 | assume(fetchStub).has.been.called(); 115 | } 116 | }); 117 | 118 | it('bails out on invalid HTTP response from Jira', async function () { 119 | fetchStub.resolves(); 120 | try { 121 | await jiraPlugin.processRequest({ 122 | payload: { 123 | action: 'created', 124 | // @ts-ignore 125 | pull_request: { 126 | title: '[AB-1234] title with 1 Jira ticket' 127 | } 128 | } 129 | }, commenter); 130 | } catch (err) { 131 | assume(err).is.truthy(); 132 | assume(err.message).contains('Status code: unknown'); 133 | assume(fetchStub).has.been.called(); 134 | } 135 | }); 136 | 137 | it('bails out on invalid HTTP status code from Jira', async function () { 138 | fetchStub.resolves({ 139 | status: 404, 140 | ok: false 141 | }); 142 | try { 143 | await jiraPlugin.processRequest({ 144 | payload: { 145 | action: 'created', 146 | // @ts-ignore 147 | pull_request: { 148 | title: '[AB-1234] title with 1 Jira ticket' 149 | } 150 | } 151 | }, commenter); 152 | } catch (err) { 153 | assume(err).is.truthy(); 154 | assume(err.message).contains('Status code: 404'); 155 | assume(fetchStub).has.been.called(); 156 | } 157 | }); 158 | 159 | it('correctly parses 1 ticket from a PR and builds a comment with its title', async function () { 160 | fetchStub.resolves({ 161 | status: 200, 162 | ok: true, 163 | async json() { 164 | return { 165 | issues: [ 166 | { 167 | key: 'AB-1234', 168 | fields: { 169 | summary: 'Mock ticket title' 170 | } 171 | } 172 | ] 173 | }; 174 | } 175 | }); 176 | await jiraPlugin.processRequest({ 177 | payload: { 178 | action: 'created', 179 | // @ts-ignore 180 | pull_request: { 181 | title: '[AB-1234] title with 1 Jira ticket' 182 | } 183 | } 184 | }, commenter); 185 | 186 | // @ts-ignore 187 | assume(fetchStub).calledWithMatch( 188 | // @ts-ignore 189 | sinon.match.string, 190 | sinon.match({ 191 | body: sinon.match(strBody => { 192 | const body = JSON.parse(strBody); 193 | return body.jql && body.jql.includes('AB-1234') && !body.jql.includes(','); 194 | }) 195 | })); 196 | assume(addCommentStub).calledWithMatch('\\[AB-1234\\] Mock ticket title'); 197 | }); 198 | 199 | it('correctly parses 2 tickets from a PR and builds a comment with its title', async function () { 200 | fetchStub.resolves({ 201 | status: 200, 202 | ok: true, 203 | async json() { 204 | return { 205 | issues: [ 206 | { 207 | key: 'AB-1234', 208 | fields: { 209 | summary: 'Mock ticket 1 title' 210 | } 211 | }, 212 | { 213 | key: 'FOO-5678', 214 | fields: { 215 | summary: 'Mock ticket 2 title' 216 | } 217 | } 218 | ] 219 | }; 220 | } 221 | }); 222 | await jiraPlugin.processRequest({ 223 | payload: { 224 | action: 'created', 225 | // @ts-ignore 226 | pull_request: { 227 | title: '[AB-1234] title with 2 Jira tickets [FOO-5678]' 228 | } 229 | } 230 | }, commenter); 231 | 232 | assume(fetchStub).calledWithMatch( 233 | // @ts-ignore 234 | sinon.match.string, 235 | sinon.match({ 236 | body: sinon.match(strBody => { 237 | const body = JSON.parse(strBody); 238 | return body.jql && body.jql.includes('AB-1234') && body.jql.includes('FOO-5678') && body.jql.includes(','); 239 | }) 240 | })); 241 | assume(addCommentStub).calledWithMatch(sinon.match('\\[AB-1234\\] Mock ticket 1 title') 242 | .and(sinon.match('\\[FOO-5678\\] Mock ticket 2 title'))); 243 | }); 244 | 245 | it('correctly parses 2 tickets without brackets from a PR and builds a comment with its title', async function () { 246 | fetchStub.resolves({ 247 | status: 200, 248 | ok: true, 249 | async json() { 250 | return { 251 | issues: [ 252 | { 253 | key: 'AB-1234', 254 | fields: { 255 | summary: 'Mock ticket 1 title' 256 | } 257 | }, 258 | { 259 | key: 'FOO-5678', 260 | fields: { 261 | summary: 'Mock ticket 2 title' 262 | } 263 | } 264 | ] 265 | }; 266 | } 267 | }); 268 | await jiraPlugin.processRequest({ 269 | payload: { 270 | action: 'created', 271 | // @ts-ignore 272 | pull_request: { 273 | title: 'AB-1234 title with 2 Jira tickets FOO-5678' 274 | } 275 | } 276 | }, commenter); 277 | 278 | assume(fetchStub).calledWithMatch( 279 | // @ts-ignore 280 | sinon.match.string, 281 | sinon.match({ 282 | body: sinon.match(strBody => { 283 | const body = JSON.parse(strBody); 284 | return body.jql && body.jql.includes('AB-1234') && body.jql.includes('FOO-5678') && body.jql.includes(','); 285 | }) 286 | })); 287 | assume(addCommentStub).calledWithMatch(sinon.match('\\[AB-1234\\] Mock ticket 1 title') 288 | .and(sinon.match('\\[FOO-5678\\] Mock ticket 2 title'))); 289 | }); 290 | 291 | it(`bails if action is edit and ticket list hasn't changed`, async function () { 292 | await jiraPlugin.processRequest({ 293 | payload: { 294 | action: 'edited', 295 | // @ts-ignore 296 | pull_request: { 297 | title: '[AB-1234] title with 2 Jira tickets [FOO-5678]' 298 | }, 299 | changes: { 300 | title: { 301 | from: '[AB-1234] title that has changed with 2 Jira tickets [FOO-5678]' 302 | } 303 | } 304 | } 305 | }, commenter); 306 | 307 | assume(addCommentStub).has.not.been.called(); 308 | }); 309 | 310 | it('correctly parses 2 tickets from a PR edit and builds a comment with its title', async function () { 311 | fetchStub.resolves({ 312 | status: 200, 313 | ok: true, 314 | async json() { 315 | return { 316 | issues: [ 317 | { 318 | key: 'AB-1234', 319 | fields: { 320 | summary: 'Mock ticket 1 title' 321 | } 322 | }, 323 | { 324 | key: 'FOO-5678', 325 | fields: { 326 | summary: 'Mock ticket 2 title' 327 | } 328 | } 329 | ] 330 | }; 331 | } 332 | }); 333 | await jiraPlugin.processRequest({ 334 | payload: { 335 | action: 'edited', 336 | // @ts-ignore 337 | pull_request: { 338 | title: '[AB-1234][AB-3456] title with 2 Jira tickets [FOO-5678][FOO-7980]' 339 | }, 340 | changes: { 341 | title: { 342 | from: '[AB-3456] title with 2 Jira tickets [FOO-7980]' 343 | } 344 | } 345 | } 346 | }, commenter); 347 | 348 | assume(fetchStub).calledWithMatch( 349 | // @ts-ignore 350 | sinon.match.string, 351 | sinon.match({ 352 | body: sinon.match(strBody => { 353 | const body = JSON.parse(strBody); 354 | return body.jql && body.jql.includes('AB-1234') && body.jql.includes('FOO-5678') && body.jql.includes(','); 355 | }) 356 | })); 357 | assume(addCommentStub).calledWithMatch(sinon.match('\\[AB-1234\\] Mock ticket 1 title') 358 | .and(sinon.match('\\[FOO-5678\\] Mock ticket 2 title'))); 359 | }); 360 | 361 | it('does not post when no Jira tickets are actually found in Jira query', async function () { 362 | fetchStub.resolves({ 363 | status: 200, 364 | ok: true, 365 | async json() { 366 | return { 367 | issues: [] 368 | }; 369 | } 370 | }); 371 | await jiraPlugin.processRequest({ 372 | payload: { 373 | action: 'created', 374 | // @ts-ignore 375 | pull_request: { 376 | title: '[AB-1234][AB-3456] title with 2 Jira tickets' 377 | } 378 | } 379 | }, commenter); 380 | 381 | assume(fetchStub).calledWithMatch( 382 | // @ts-ignore 383 | sinon.match.string, 384 | sinon.match({ 385 | body: sinon.match(strBody => { 386 | const body = JSON.parse(strBody); 387 | return body.jql && body.jql.includes('AB-1234') && body.jql.includes('AB-3456') && body.jql.includes(','); 388 | }) 389 | })); 390 | assume(addCommentStub).has.not.been.called(); 391 | }); 392 | }); 393 | }); 394 | -------------------------------------------------------------------------------- /test/unit/plugins/required-file.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import assumeSinon from 'assume-sinon'; 3 | import sinon from 'sinon'; 4 | 5 | assume.use(assumeSinon); 6 | 7 | import RequiredFilePlugin from '../../../plugins/required-file/index.js'; 8 | 9 | const sandbox = sinon.createSandbox(); 10 | let getContentStub; 11 | let getFilesInPullRequestStub; 12 | let addCommentStub; 13 | let commenter; 14 | 15 | const requiredFilePlugin = new RequiredFilePlugin(); 16 | 17 | describe('RequiredFilePlugin', function () { 18 | afterEach(function () { 19 | sandbox.restore(); 20 | }); 21 | 22 | beforeEach(function () { 23 | getContentStub = sandbox.stub().resolves({ status: 200 }); 24 | getFilesInPullRequestStub = sandbox.stub().resolves([]); 25 | addCommentStub = sandbox.stub(); 26 | commenter = { 27 | addComment: addCommentStub, 28 | comments: null, 29 | flushToString: null 30 | }; 31 | }); 32 | 33 | it('is a constructor', function () { 34 | assume(RequiredFilePlugin).is.a('function'); 35 | assume(RequiredFilePlugin).has.length(0); 36 | assume(requiredFilePlugin).is.an('object'); 37 | }); 38 | 39 | it('does not process edits', function () { 40 | assume(requiredFilePlugin.processesEdits).is.false(); 41 | }); 42 | 43 | describe('.mergeConfig', function () { 44 | it('explicitly replaces the base `files` array', function () { 45 | const baseConfig = { 46 | files: ['one', 'two', 'three'], 47 | foo: 'bar', 48 | baz: 'blah' 49 | }; 50 | const overrideConfig = { 51 | files: ['four', 'five', 'six'], 52 | foo: 'rab', 53 | gah: 'meh' 54 | }; 55 | assume(requiredFilePlugin.mergeConfig(baseConfig, overrideConfig)).deep.equals({ 56 | files: ['four', 'five', 'six'], 57 | foo: 'rab', 58 | baz: 'blah', 59 | gah: 'meh' 60 | }); 61 | }); 62 | }); 63 | 64 | describe('.processRequest', function () { 65 | it('is a function', function () { 66 | assume(requiredFilePlugin.processRequest).is.an('asyncfunction'); 67 | assume(requiredFilePlugin.processRequest).has.length(3); 68 | }); 69 | 70 | it('bails out if no files list is specified in the plugin config', async function () { 71 | const checkFileSpy = sandbox.spy(requiredFilePlugin, 'checkFile'); 72 | try { 73 | // @ts-ignore 74 | await requiredFilePlugin.processRequest(null, commenter, {}); 75 | } catch (err) { 76 | assume(err).is.truthy(); 77 | assume(checkFileSpy).has.not.been.called(); 78 | } 79 | }); 80 | 81 | it('runs checkFile on all specified files', async function () { 82 | const checkFileStub = sandbox.stub(requiredFilePlugin, 'checkFile').resolves(); 83 | await requiredFilePlugin.processRequest(null, commenter, { 84 | files: [ 85 | 'one', 86 | 'two', 87 | 'three' 88 | ] 89 | }); 90 | 91 | assume(checkFileStub).has.been.called(3); 92 | }); 93 | }); 94 | 95 | describe('.checkFile', function () { 96 | let mockContext; 97 | 98 | beforeEach(function () { 99 | mockContext = { 100 | octokit: { 101 | pulls: { 102 | listFiles: { 103 | endpoint: { 104 | merge: sandbox.stub() 105 | } 106 | } 107 | }, 108 | repos: { 109 | getContent: getContentStub 110 | }, 111 | paginate: getFilesInPullRequestStub 112 | }, 113 | pullRequest() { 114 | return { 115 | ...this.repo(), 116 | pull_number: 1234 117 | }; 118 | }, 119 | repo() { 120 | return { 121 | owner: 'org', 122 | repo: 'repo' 123 | }; 124 | }, 125 | payload: { 126 | repository: { 127 | full_name: 'org/repo' 128 | }, 129 | pull_request: { 130 | number: 1234 131 | } 132 | } 133 | }; 134 | }); 135 | 136 | it('bails out if no file path is passed in', async function () { 137 | try { 138 | // @ts-ignore 139 | await requiredFilePlugin.checkFile(mockContext, commenter, null); 140 | } catch (err) { 141 | assume(err).is.truthy(); 142 | assume(err.message).equals('No file path specified for required file.'); 143 | } 144 | }); 145 | 146 | it('bails out if no file path is passed in with the file object', async function () { 147 | try { 148 | // @ts-ignore 149 | await requiredFilePlugin.checkFile(mockContext, commenter, {}); 150 | } catch (err) { 151 | assume(err).is.truthy(); 152 | assume(err.message).equals('No file path specified for required file.'); 153 | } 154 | }); 155 | 156 | it('bails out if checkIfFileExists returns an error', async function () { 157 | const mockError = new Error('mock error'); 158 | getContentStub.rejects(mockError); 159 | try { 160 | // @ts-ignore 161 | await requiredFilePlugin.checkFile(mockContext, commenter, 'file'); 162 | } catch (err) { 163 | assume(err).equals(mockError); 164 | } 165 | }); 166 | 167 | it(`bails out if the file doesn't exist in the first place`, async function () { 168 | getContentStub.resolves({ status: 404 }); 169 | // @ts-ignore 170 | await requiredFilePlugin.checkFile(mockContext, commenter, 'file'); 171 | assume(getFilesInPullRequestStub).has.not.been.called(); 172 | }); 173 | 174 | it('bails out if getFilesInPullRequest returns an error', async function () { 175 | getContentStub.resolves({ status: 200 }); 176 | const mockError = new Error('mock error'); 177 | getFilesInPullRequestStub.rejects(mockError); 178 | try { 179 | // @ts-ignore 180 | await requiredFilePlugin.checkFile(mockContext, commenter, 'file'); 181 | } catch (err) { 182 | assume(err).equals(mockError); 183 | assume(addCommentStub).has.not.been.called(); 184 | } 185 | }); 186 | 187 | it('is a no-op when the required file is included in the PR', async function () { 188 | getContentStub.resolves({ status: 200 }); 189 | getFilesInPullRequestStub.resolves([{ filename: 'file' }]); 190 | // @ts-ignore 191 | await requiredFilePlugin.checkFile(mockContext, commenter, 'file'); 192 | assume(addCommentStub).has.not.been.called(); 193 | }); 194 | 195 | it('adds a comment when a required file is not included in the PR', async function () { 196 | getContentStub.resolves({ status: 200 }); 197 | getFilesInPullRequestStub.resolves([{ filename: 'file' }]); 198 | // @ts-ignore 199 | await requiredFilePlugin.checkFile(mockContext, commenter, 'file2'); 200 | assume(addCommentStub).calledWithMatch('file2'); 201 | }); 202 | 203 | it(`bails out if the file doesn't exist in the first place and the file is an object`, async function () { 204 | getContentStub.resolves({ status: 404 }); 205 | // @ts-ignore 206 | await requiredFilePlugin.checkFile(mockContext, commenter, { path: 'file' }); 207 | assume(getFilesInPullRequestStub).has.not.been.called(); 208 | }); 209 | 210 | it('is a no-op when the required file is included in the PR and the file is an object', async function () { 211 | getContentStub.resolves({ status: 200 }); 212 | getFilesInPullRequestStub.resolves([{ filename: 'file' }]); 213 | // @ts-ignore 214 | await requiredFilePlugin.checkFile(mockContext, commenter, { path: 'file' }); 215 | assume(addCommentStub).has.not.been.called(); 216 | }); 217 | 218 | it('adds a comment when a required file is not included in the PR and the file is an object', async function () { 219 | getContentStub.resolves({ status: 200 }); 220 | getFilesInPullRequestStub.resolves([{ filename: 'file' }]); 221 | // @ts-ignore 222 | await requiredFilePlugin.checkFile(mockContext, commenter, { path: 'file2' }); 223 | assume(addCommentStub).calledWithMatch('file2'); 224 | }); 225 | 226 | it('adds a custom comment when a required file is not included in the PR and the file is an object', async function () { 227 | getContentStub.resolves({ status: 200 }); 228 | getFilesInPullRequestStub.resolves([{ filename: 'file' }]); 229 | // @ts-ignore 230 | await requiredFilePlugin.checkFile(mockContext, commenter, { path: 'file2', message: 'custom' }); 231 | assume(addCommentStub).calledWithMatch('custom'); 232 | }); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/unit/plugins/reviewers.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import assumeSinon from 'assume-sinon'; 3 | import sinon from 'sinon'; 4 | import clone from 'clone-deep'; 5 | 6 | assume.use(assumeSinon); 7 | 8 | import ReviewersPlugin from '../../../plugins/reviewers/index.js'; 9 | import Commenter from '../../../commenter.js'; 10 | 11 | const sandbox = sinon.createSandbox(); 12 | let requestReviewersStub; 13 | let getFileContentsStub; 14 | let userExistsStub; 15 | let addCommentStub; 16 | let commenter; 17 | 18 | const reviewersPlugin = new ReviewersPlugin(); 19 | 20 | describe('ReviewersPlugin', function () { 21 | afterEach(function () { 22 | sandbox.restore(); 23 | delete process.env.GITHUB_USER_SUFFIX; 24 | }); 25 | 26 | let mockContext; 27 | 28 | beforeEach(function () { 29 | requestReviewersStub = sandbox.stub().resolves(); 30 | getFileContentsStub = sandbox.stub().resolves({ status: 404 }); 31 | userExistsStub = sandbox.stub().resolves({ status: 404 }); 32 | addCommentStub = sandbox.stub(); 33 | commenter = { 34 | addComment: addCommentStub, 35 | comments: null, 36 | flushToString: null 37 | }; 38 | 39 | mockContext = { 40 | octokit: { 41 | pulls: { 42 | requestReviewers: requestReviewersStub 43 | }, 44 | repos: { 45 | checkCollaborator: userExistsStub, 46 | getContent: getFileContentsStub 47 | } 48 | }, 49 | issue() { 50 | return { 51 | ...this.repo(), 52 | number: 1234 53 | }; 54 | }, 55 | pullRequest() { 56 | return { 57 | ...this.repo(), 58 | pull_number: 1234 59 | }; 60 | }, 61 | repo() { 62 | return { 63 | owner: 'org', 64 | repo: 'repo' 65 | }; 66 | }, 67 | payload: { 68 | action: 'opened', 69 | repository: { 70 | full_name: 'org/repo' 71 | }, 72 | pull_request: { 73 | number: 1234, 74 | user: { 75 | login: 'jdoe' 76 | }, 77 | draft: false 78 | } 79 | } 80 | }; 81 | }); 82 | 83 | it('is a constructor', function () { 84 | assume(ReviewersPlugin).is.a('function'); 85 | assume(ReviewersPlugin).has.length(0); 86 | assume(reviewersPlugin).is.an('object'); 87 | }); 88 | 89 | it('does not process edits', function () { 90 | assume(reviewersPlugin.processesEdits).is.false(); 91 | }); 92 | 93 | it('processes "ready for review" actions', function () { 94 | assume(reviewersPlugin.processesReadyForReview).is.true(); 95 | }); 96 | 97 | describe('.processRequest', function () { 98 | it('is a function', function () { 99 | assume(reviewersPlugin.processRequest).is.an('asyncfunction'); 100 | assume(reviewersPlugin.processRequest).has.length(3); 101 | }); 102 | 103 | it('bails out if PR is a draft and requestForDrafts is false', async function () { 104 | const draftContext = clone(mockContext); 105 | draftContext.payload.pull_request.draft = true; 106 | 107 | const requestReviewsStub = sandbox.stub(reviewersPlugin, 'requestReviews'); 108 | await reviewersPlugin.processRequest(draftContext, commenter, null); 109 | assume(requestReviewsStub).has.not.been.called(); 110 | }); 111 | 112 | it('requests reviews if PR is a draft and requestForDrafts is true', async function () { 113 | const draftContext = clone(mockContext); 114 | draftContext.payload.pull_request.draft = true; 115 | 116 | const requestReviewsStub = sandbox.stub(reviewersPlugin, 'requestReviews').resolves(); 117 | await reviewersPlugin.processRequest(draftContext, commenter, { 118 | requestForDrafts: true, 119 | reviewers: ['foo'], 120 | howMany: 1 121 | }); 122 | assume(requestReviewsStub).has.been.called(); 123 | }); 124 | 125 | it('requests reviews if PR is marked ready for review and requestForDrafts is false', async function () { 126 | const draftContext = clone(mockContext); 127 | draftContext.payload.action = 'ready_for_review'; 128 | 129 | const requestReviewsStub = sandbox.stub(reviewersPlugin, 'requestReviews').resolves(); 130 | await reviewersPlugin.processRequest(draftContext, commenter, { 131 | requestForDrafts: false, 132 | reviewers: ['foo'], 133 | howMany: 1 134 | }); 135 | assume(requestReviewsStub).has.been.called(); 136 | }); 137 | 138 | it('bails out if PR is marked ready for review and requestForDrafts is true', async function () { 139 | // Since reviews would have already been requested when draft was opened 140 | const draftContext = clone(mockContext); 141 | draftContext.payload.action = 'ready_for_review'; 142 | 143 | const requestReviewsStub = sandbox.stub(reviewersPlugin, 'requestReviews').resolves(); 144 | await reviewersPlugin.processRequest(draftContext, commenter, { 145 | requestForDrafts: true, 146 | reviewers: ['foo'], 147 | howMany: 1 148 | }); 149 | assume(requestReviewsStub).has.not.been.called(); 150 | }); 151 | 152 | it('bails out if getPackageJson returns an error', async function () { 153 | const mockError = new Error('mock error'); 154 | sandbox.stub(reviewersPlugin, 'getPackageJson').rejects(mockError); 155 | try { 156 | // @ts-ignore 157 | await reviewersPlugin.processRequest(mockContext, commenter, null); 158 | } catch (err) { 159 | assume(err).equals(mockError); 160 | } 161 | }); 162 | 163 | it('bails out if no package.json is found', async function () { 164 | sandbox.stub(reviewersPlugin, 'getPackageJson').resolves(); 165 | // eslint-disable-next-line id-length 166 | const getAllPossibleReviewersSpy = sandbox.spy(reviewersPlugin, 'getAllPossibleReviewers'); 167 | // @ts-ignore 168 | await reviewersPlugin.processRequest(mockContext, commenter, null); 169 | assume(getAllPossibleReviewersSpy).has.not.been.called(); 170 | }); 171 | 172 | it('properly passes package.json info to getAllPossibleReviewers and requestReviews', async () => { 173 | const mockPackageInfo = {}; 174 | sandbox.stub(reviewersPlugin, 'getPackageJson') 175 | .resolves(mockPackageInfo); 176 | const mockReviewers = ['one', 'two']; 177 | // eslint-disable-next-line id-length 178 | const getAllPossibleReviewersStub = sandbox.stub(reviewersPlugin, 'getAllPossibleReviewers') 179 | .returns(mockReviewers); 180 | const requestReviewsStub = sandbox.stub(reviewersPlugin, 'requestReviews').resolves(); 181 | 182 | // @ts-ignore 183 | await reviewersPlugin.processRequest(mockContext, commenter, null); 184 | assume(getAllPossibleReviewersStub).calledWithMatch( 185 | sinon.match.same(mockPackageInfo) 186 | ); 187 | // @ts-ignore 188 | assume(requestReviewsStub).calledWithMatch( 189 | sinon.match.any, 190 | sinon.match.array.deepEquals(mockReviewers) 191 | ); 192 | }); 193 | 194 | it('skips loading candidate reviewers from package.json when reviewers are specified in config', async function () { 195 | const getPackageJsonStub = sandbox.stub(reviewersPlugin, 'getPackageJson'); 196 | const mockReviewers = ['one', 'two']; 197 | const requestReviewsStub = sandbox.stub(reviewersPlugin, 'requestReviews').resolves(); 198 | // @ts-ignore 199 | await reviewersPlugin.processRequest(mockContext, commenter, { 200 | reviewers: mockReviewers 201 | }); 202 | assume(getPackageJsonStub).has.not.been.called(); 203 | // @ts-ignore 204 | assume(requestReviewsStub).calledWithMatch( 205 | sinon.match.any, 206 | sinon.match.array.deepEquals(mockReviewers) 207 | ); 208 | }); 209 | }); 210 | 211 | describe('.getAllPossibleReviewers', function () { 212 | it('assembles the reviewers list from array contributors and maintainers fields, plus author in package.json', function () { 213 | assume(reviewersPlugin.getAllPossibleReviewers({ 214 | contributors: [ 215 | 'one', 216 | 'two' 217 | ], 218 | author: 'three', 219 | maintainers: [ 220 | 'four' 221 | ] 222 | })).deep.equals([ 223 | 'one', 224 | 'two', 225 | 'four', 226 | 'three' 227 | ]); 228 | }); 229 | 230 | it('assembles the reviewers list from string contributors, maintainers, and author fields in package.json', function () { 231 | assume(reviewersPlugin.getAllPossibleReviewers({ 232 | contributors: 'one', 233 | author: 'two', 234 | maintainers: 'three' 235 | })).deep.equals([ 236 | 'one', 237 | 'three', 238 | 'two' 239 | ]); 240 | }); 241 | 242 | it('assembles the reviewers list from a string author field in package.json', function () { 243 | assume(reviewersPlugin.getAllPossibleReviewers({ 244 | author: 'one' 245 | })).deep.equals([ 246 | 'one' 247 | ]); 248 | }); 249 | 250 | it('assembles the reviewers list from object fields in package.json', function () { 251 | assume(reviewersPlugin.getAllPossibleReviewers({ 252 | contributors: [ 253 | { 254 | name: 'one', 255 | email: 'one@test.com' 256 | }, 257 | { 258 | name: 'two', 259 | email: 'two@test.com' 260 | } 261 | ], 262 | maintainers: { 263 | name: 'three', 264 | email: 'three@test.com' 265 | } 266 | })).deep.equals([ 267 | { 268 | name: 'one', 269 | email: 'one@test.com' 270 | }, 271 | { 272 | name: 'two', 273 | email: 'two@test.com' 274 | }, 275 | { 276 | name: 'three', 277 | email: 'three@test.com' 278 | } 279 | ]); 280 | }); 281 | }); 282 | 283 | // normalizeReviewerField is already throroughly tested by the unit tests for getAllPossibleReviewers above 284 | 285 | describe('.requestReviews', function () { 286 | it('bails out if no reviewers are specified', async function () { 287 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList'); 288 | // @ts-ignore 289 | await reviewersPlugin.requestReviews({}, null, null, null, commenter); 290 | assume(getUsersFromReviewersListStub).has.not.been.called(); 291 | }); 292 | 293 | it('bails out if getUsersFromReviewersList returns an error', async function () { 294 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList') 295 | .rejects(new Error('getUsersFromReviewersListError')); 296 | try { 297 | // @ts-ignore 298 | await reviewersPlugin.requestReviews({}, [], null, null, commenter); 299 | } catch (err) { 300 | assume(getUsersFromReviewersListStub.called).is.true(); 301 | assume(err).is.truthy(); 302 | assume(err.message).equals('getUsersFromReviewersListError'); 303 | } 304 | }); 305 | 306 | it('bails out if no users are found to request review from', async function () { 307 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList') 308 | .resolves([]); 309 | // @ts-ignore 310 | await reviewersPlugin.requestReviews({}, ['one'], null, null, commenter); 311 | assume(getUsersFromReviewersListStub.called).is.true(); 312 | assume(requestReviewersStub).has.not.been.called(); 313 | }); 314 | 315 | it('extracts a subset of candidate reviewers based on howMany parameter', async function () { 316 | const candidateReviewers = [ 317 | 'one', 318 | 'two', 319 | 'three' 320 | ]; 321 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList') 322 | .resolves(candidateReviewers); 323 | const howManyRequested = 2; 324 | // @ts-ignore 325 | await reviewersPlugin.requestReviews(mockContext, candidateReviewers, howManyRequested, null, commenter); 326 | assume(getUsersFromReviewersListStub).has.been.called(); 327 | assume(requestReviewersStub).calledWithMatch({ 328 | reviewers: sinon.match(value => { // eslint-disable-line max-nested-callbacks 329 | return value && Array.isArray(value) && value.length === howManyRequested && 330 | value.every(r => { // eslint-disable-line max-nested-callbacks 331 | return candidateReviewers.includes(r); 332 | }); 333 | }) 334 | }); 335 | }); 336 | 337 | it('requests review from all candidate reviewers if howMany > numCandidates', async function () { 338 | const candidateReviewers = [ 339 | 'one', 340 | 'two', 341 | 'three' 342 | ]; 343 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList') 344 | .resolves(candidateReviewers); 345 | const howManyRequested = 4; 346 | // @ts-ignore 347 | await reviewersPlugin.requestReviews(mockContext, candidateReviewers, howManyRequested, null, commenter); 348 | assume(getUsersFromReviewersListStub).has.been.called(); 349 | assume(requestReviewersStub).calledWithMatch({ 350 | reviewers: sinon.match.array.contains(candidateReviewers).and(sinon.match(value => { 351 | return value.length === candidateReviewers.length; 352 | })) 353 | }); 354 | }); 355 | 356 | it('adds a comment listing requested reviewers when configured', async function () { 357 | const candidateReviewers = [ 358 | 'one', 359 | 'two', 360 | 'three' 361 | ]; 362 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList') 363 | .resolves(candidateReviewers); 364 | // @ts-ignore 365 | await reviewersPlugin.requestReviews(mockContext, candidateReviewers, null, 'Some comment %s', commenter); 366 | assume(getUsersFromReviewersListStub).has.been.called(); 367 | assume(addCommentStub).calledWithMatch('@one, @three, @two', Commenter.priority.Medium); 368 | }); 369 | 370 | it('requests review from all candidate reviewers if howMany is not specified', async function () { 371 | const candidateReviewers = [ 372 | 'one', 373 | 'two', 374 | 'three' 375 | ]; 376 | const getUsersFromReviewersListStub = sandbox.stub(reviewersPlugin, 'getUsersFromReviewersList') 377 | .resolves(candidateReviewers); 378 | // @ts-ignore 379 | await reviewersPlugin.requestReviews(mockContext, candidateReviewers, null, null, commenter); 380 | assume(getUsersFromReviewersListStub).has.been.called(); 381 | assume(requestReviewersStub).calledWithMatch({ 382 | reviewers: sinon.match.array.contains(candidateReviewers).and(sinon.match(value => { 383 | return value.length === candidateReviewers.length; 384 | })) 385 | }); 386 | }); 387 | }); 388 | 389 | describe('.getPackageJson', function () { 390 | it('is a function', function () { 391 | assume(reviewersPlugin.getPackageJson).is.an('asyncfunction'); 392 | assume(reviewersPlugin.getPackageJson).has.length(1); 393 | }); 394 | 395 | it('bails out when package.json is missing', async function () { 396 | getFileContentsStub.resolves({ status: 404 }); 397 | // @ts-ignore 398 | const ret = await reviewersPlugin.getPackageJson(mockContext); 399 | assume(ret).does.not.exist(); 400 | }); 401 | 402 | it('bails out when an error is encountered attempting to fetch package.json', async function () { 403 | const mockError = new Error('mock error'); 404 | getFileContentsStub.rejects(mockError); 405 | try { 406 | // @ts-ignore 407 | await reviewersPlugin.getPackageJson(mockContext); 408 | } catch (err) { 409 | assume(err).equals(mockError); 410 | } 411 | }); 412 | 413 | it('hands off the packaged file to parseBase64Json for parsing', async function () { 414 | const mockPackageJson = { 415 | foo: 'bar' 416 | }; 417 | const mockPkg = { 418 | content: Buffer.from(JSON.stringify(mockPackageJson)).toString('base64') 419 | }; 420 | getFileContentsStub.resolves({ 421 | status: 200, 422 | data: mockPkg 423 | }); 424 | // @ts-ignore 425 | const ret = await reviewersPlugin.getPackageJson(mockContext); 426 | assume(ret).eqls(mockPackageJson); 427 | }); 428 | }); 429 | 430 | describe('.getUsersFromReviewersList', function () { 431 | it('is a function', function () { 432 | assume(reviewersPlugin.getUsersFromReviewersList).is.an('asyncfunction'); 433 | assume(reviewersPlugin.getUsersFromReviewersList).has.length(2); 434 | }); 435 | 436 | it('bails out when reviewers is falsey', async function () { 437 | const users = await reviewersPlugin.getUsersFromReviewersList(null, null); 438 | assume(users).does.not.exist(); 439 | }); 440 | 441 | it('bails out when reviewers is empty', async function () { 442 | const users = await reviewersPlugin.getUsersFromReviewersList(null, []); 443 | assume(users).does.not.exist(); 444 | }); 445 | 446 | it('passes through an error when checkCollaborator fails', async function () { 447 | userExistsStub.rejects(new Error('userExists error')); 448 | try { 449 | // @ts-ignore 450 | await reviewersPlugin.getUsersFromReviewersList(mockContext, ['one', 'two']); 451 | } catch (err) { 452 | assume(err).is.truthy(); 453 | assume(err.message).equals('userExists error'); 454 | } 455 | }); 456 | 457 | it('properly gets filtered users from the reviewers list', async function () { 458 | userExistsStub.resolves({ status: 204 }); 459 | userExistsStub.withArgs(sinon.match({ 460 | username: 'Bob McNoEmail' 461 | })).resolves({ status: 404 }); 462 | // @ts-ignore 463 | const users = await reviewersPlugin.getUsersFromReviewersList(mockContext, [ 464 | 'Joe Schmoe ', 465 | 'John Doe ', 466 | 'Bob McNoEmail', 467 | { 468 | name: 'Hans Objectson', 469 | email: 'hobjectson@test.com' 470 | }, 471 | { 472 | name: 'Billy ObjectNoEmailHeimer' 473 | }, 474 | 12345 475 | ]); 476 | assume(users).eqls([ 477 | 'jschmoe', 478 | 'hobjectson' 479 | ]); 480 | }); 481 | 482 | it('properly gets filtered users with suffix from the reviewers list if GITHUB_USER_SUFFIX is set.', async function () { 483 | process.env.GITHUB_USER_SUFFIX = '-coolsuffix'; 484 | userExistsStub.resolves({ status: 204 }); 485 | userExistsStub 486 | .withArgs( 487 | sinon.match({ 488 | username: 'Bob McNoEmail', 489 | }) 490 | ) 491 | .resolves({ status: 404 }); 492 | // @ts-ignore 493 | const users = await reviewersPlugin.getUsersFromReviewersList( 494 | mockContext, 495 | [ 496 | 'Joe Schmoe ', 497 | 'John Doe ', 498 | 'Bob McNoEmail', 499 | { 500 | name: 'Hans Objectson', 501 | email: 'hobjectson@test.com', 502 | }, 503 | { 504 | name: 'Billy ObjectNoEmailHeimer', 505 | }, 506 | 12345 507 | ] 508 | ); 509 | assume(users).eqls([ 510 | 'jschmoe-coolsuffix', 511 | 'jdoe-coolsuffix', 512 | 'hobjectson-coolsuffix' 513 | ]); 514 | }); 515 | }); 516 | }); 517 | -------------------------------------------------------------------------------- /test/unit/plugins/welcome.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | import assumeSinon from 'assume-sinon'; 3 | import sinon from 'sinon'; 4 | 5 | assume.use(assumeSinon); 6 | 7 | import WelcomePlugin from '../../../plugins/welcome/index.js'; 8 | import Commenter from '../../../commenter.js'; 9 | 10 | const addCommentStub = sinon.stub(); 11 | const commenter = { 12 | addComment: addCommentStub 13 | }; 14 | 15 | const welcomePlugin = new WelcomePlugin(); 16 | 17 | describe('WelcomePlugin', function () { 18 | after(function () { 19 | sinon.restore(); 20 | }); 21 | 22 | let mockContext; 23 | 24 | beforeEach(function () { 25 | addCommentStub.reset(); 26 | 27 | mockContext = { 28 | octokit: { 29 | issues: { 30 | listForRepo: function () { 31 | return Promise.resolve({ 32 | data: [{ 33 | pull_request: { 34 | number: 1234, 35 | user: { 36 | login: 'jdoe' 37 | }, 38 | draft: false 39 | } 40 | }] 41 | }); 42 | } 43 | } 44 | }, 45 | repo() { 46 | return { 47 | owner: 'org', 48 | repo: 'repo' 49 | }; 50 | }, 51 | payload: { 52 | pull_request: { 53 | number: 1234, 54 | user: { 55 | login: 'jdoe' 56 | }, 57 | draft: false 58 | } 59 | } 60 | }; 61 | }); 62 | 63 | it('is a constructor', function () { 64 | assume(WelcomePlugin).is.a('function'); 65 | assume(WelcomePlugin).has.length(0); 66 | assume(welcomePlugin).is.an('object'); 67 | }); 68 | 69 | it('does not process edits', function () { 70 | assume(welcomePlugin.processesEdits).is.false(); 71 | }); 72 | 73 | describe('.processRequest', function () { 74 | it('is a function', function () { 75 | assume(welcomePlugin.processRequest).is.an('asyncfunction'); 76 | assume(welcomePlugin.processRequest).has.length(2); 77 | }); 78 | 79 | it('will return if no message is defined', async function () { 80 | await welcomePlugin.processRequest(mockContext, commenter, {}); 81 | 82 | assume(addCommentStub).has.not.been.called(); 83 | }); 84 | 85 | it('will add the welcome comment from env file if the user is new to the repo', async function () { 86 | process.env.WELCOME_MESSAGE = 'Thanks for making a contribution to the project!'; 87 | 88 | const instance = new WelcomePlugin(); 89 | 90 | await instance.processRequest(mockContext, commenter); 91 | 92 | assume(addCommentStub).has.been.called(); 93 | assume(addCommentStub.getCall(0).args).eql(['Thanks for making a contribution to the project!', Commenter.priority.High]); 94 | }); 95 | 96 | it('will add the welcome comment from custom config if the user is new to the repo', async function () { 97 | await welcomePlugin.processRequest(mockContext, commenter, { welcomeMessage: 'Welcome to the project!' }); 98 | 99 | assume(addCommentStub).has.been.called(); 100 | assume(addCommentStub.getCall(0).args).eql(['Welcome to the project!', Commenter.priority.High]); 101 | }); 102 | 103 | it('will do nothing if the user is already part of the repo', async function () { 104 | mockContext = { 105 | ...mockContext, 106 | octokit: { 107 | issues: { 108 | listForRepo: function () { 109 | return Promise.resolve({ 110 | data: [{ 111 | pull_request: { 112 | number: 1234, 113 | user: { 114 | login: 'jdoe' 115 | }, 116 | draft: false 117 | } 118 | }, 119 | { 120 | pull_request: { 121 | number: 5678, 122 | user: { 123 | login: 'jdoe' 124 | }, 125 | draft: false 126 | } 127 | }] 128 | }); 129 | } 130 | } 131 | } 132 | }; 133 | 134 | await welcomePlugin.processRequest(mockContext, commenter, { welcomeMessage: 'hey hey hey!' }); 135 | 136 | assume(addCommentStub).has.not.been.called(); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/unit/processor.test.js: -------------------------------------------------------------------------------- 1 | /* eslint max-statements: 0, no-console: 0 */ 2 | 3 | import assume from 'assume'; 4 | import assumeSinon from 'assume-sinon'; 5 | import sinon from 'sinon'; 6 | import BasePlugin from '../../plugins/base.js'; 7 | import processPRReal, { processPRInternal } from '../../processor.js'; 8 | 9 | assume.use(assumeSinon); 10 | 11 | let MOCK_COMMENT; 12 | class MockCommenter { 13 | flushToString() { 14 | return MOCK_COMMENT; 15 | } 16 | } 17 | const processRequestStub1 = sinon.stub().resolves(); 18 | const processRequestStub2 = sinon.stub().resolves(); 19 | const processRequestStub3 = sinon.stub().resolves(); 20 | class MockPluginManager { 21 | constructor() { 22 | this.mockPlugin1 = { 23 | processesEdits: false, 24 | processRequest: processRequestStub1, 25 | mergeConfig: BasePlugin.prototype.mergeConfig 26 | }; 27 | this.mockPlugin2 = { 28 | processesEdits: true, 29 | processRequest: processRequestStub2, 30 | mergeConfig: BasePlugin.prototype.mergeConfig 31 | }; 32 | this.mockPlugin3 = { 33 | processesEdits: true, 34 | processesReadyForReview: true, 35 | processRequest: processRequestStub3, 36 | mergeConfig: BasePlugin.prototype.mergeConfig 37 | }; 38 | } 39 | } 40 | 41 | /** 42 | * @typedef {import('../../processor').ProbotContext} ProbotContext 43 | */ 44 | /** 45 | * ProcessPR function with some dependencies injected 46 | * @param {ProbotContext} context PR webhook context 47 | * @returns {Promise} Completion promise 48 | */ 49 | async function processPR(context) { 50 | return processPRInternal(context, MockCommenter, MockPluginManager); 51 | } 52 | 53 | /** 54 | * Convert an object into a base64-encoded JSON string 55 | * 56 | * @param {Object} obj Object to convert 57 | * @returns {string} base64-encoded JSON string 58 | */ 59 | function otoa(obj) { 60 | return { content: Buffer.from(JSON.stringify(obj)).toString('base64') }; 61 | } 62 | 63 | const infoLogStub = sinon.stub(); 64 | const errorLogStub = sinon.stub(); 65 | const warnLogStub = sinon.stub(); 66 | const createCommentStub = sinon.stub().resolves(); 67 | const getContentStub = sinon.stub(); 68 | const mockContext = { 69 | log: { 70 | info: infoLogStub, 71 | warn: warnLogStub, 72 | error: errorLogStub 73 | }, 74 | octokit: { 75 | issues: { 76 | createComment: createCommentStub 77 | }, 78 | repos: { 79 | getContent: getContentStub 80 | } 81 | }, 82 | repo() { 83 | return { 84 | org: 'org', 85 | repo: 'repo' 86 | }; 87 | }, 88 | payload: null, 89 | id: 'MOCK-ID' 90 | }; 91 | 92 | describe('Processor', function () { 93 | beforeEach(function () { 94 | mockContext.payload = { 95 | action: 'opened', 96 | number: 123, 97 | repository: { 98 | full_name: 'org/repo', 99 | private: false, 100 | owner: { 101 | login: 'org' 102 | } 103 | } 104 | }; 105 | getContentStub.resolves({ 106 | status: 200, 107 | data: otoa({ 108 | plugins: [ 109 | 'mockPlugin1', 110 | { 111 | plugin: 'mockPlugin2', 112 | config: { 113 | foo: 'bar' 114 | } 115 | } 116 | ] 117 | }) 118 | }); 119 | getContentStub.withArgs(sinon.match({ 120 | repo: '.github' 121 | })).resolves({ 122 | status: 200, 123 | data: otoa({ 124 | plugins: [ 125 | { 126 | plugin: 'mockPlugin2', 127 | config: { 128 | baz: 'blah' 129 | } 130 | }, 131 | 'mockPlugin3' 132 | ] 133 | }) 134 | }); 135 | processRequestStub1.resolves(); 136 | processRequestStub2.resolves(); 137 | processRequestStub3.resolves(); 138 | MOCK_COMMENT = 'MOCK COMMENT'; 139 | delete process.env.GH_ENTERPRISE_ID; 140 | delete process.env.NO_PUBLIC_REPOS; 141 | }); 142 | 143 | afterEach(function () { 144 | sinon.resetHistory(); 145 | }); 146 | 147 | it('is an async function', function () { 148 | assume(processPRReal).is.an('asyncfunction'); 149 | assume(processPRReal).has.length(1); 150 | }); 151 | 152 | it('bails when PR is not from an Enterprise when one is required', async function () { 153 | process.env.GH_ENTERPRISE_ID = '123'; 154 | await processPR(mockContext); 155 | assume(infoLogStub).has.been.calledWith('PR is not from the configured Enterprise, nothing to do'); 156 | assume(getContentStub).has.not.been.called(); 157 | }); 158 | 159 | it('bails when PR is not from the configured Enterprise', async function () { 160 | process.env.GH_ENTERPRISE_ID = '123'; 161 | mockContext.payload.enterprise = { 162 | id: 456 163 | }; 164 | await processPR(mockContext); 165 | assume(infoLogStub).calledWith('PR is not from the configured Enterprise, nothing to do'); 166 | assume(getContentStub).has.not.been.called(); 167 | }); 168 | 169 | it('does not bail when PR is from the configured Enterprise', async function () { 170 | process.env.GH_ENTERPRISE_ID = '123'; 171 | mockContext.payload.enterprise = { 172 | id: 123 173 | }; 174 | await processPR(mockContext); 175 | assume(infoLogStub).calledWith('PR is from the configured Enterprise'); 176 | assume(getContentStub).has.been.called(); 177 | }); 178 | 179 | it('bails when PR is from a public repo and NO_PUBLIC_REPOS is enabled', async function () { 180 | process.env.NO_PUBLIC_REPOS = 'true'; 181 | await processPR(mockContext); 182 | assume(infoLogStub).calledWith('Pullie has been disabled on public repos, nothing to do'); 183 | assume(getContentStub).has.not.been.called(); 184 | }); 185 | 186 | it('bails when getRepoConfig rejects', async function () { 187 | const mockError = new Error('MOCK'); 188 | getContentStub.rejects(mockError); 189 | await processPR(mockContext); 190 | assume(errorLogStub).calledWithMatch(sinon.match({ 191 | err: mockError 192 | }), 'Error getting repository config'); 193 | }); 194 | 195 | it('bails when no config is present', async function () { 196 | getContentStub.rejects({ 197 | status: 404 198 | }); 199 | await processPR(mockContext); 200 | assume(infoLogStub).calledWith(sinon.match.object, 'No config specified for repo, nothing to do'); 201 | }); 202 | 203 | it('logs a warning when an error is encountered loading org-level config', async function () { 204 | getContentStub.withArgs(sinon.match({ 205 | repo: '.github' 206 | })).rejects({ 207 | status: 500, 208 | message: 'Some mock error' 209 | }); 210 | await processPR(mockContext); 211 | assume(warnLogStub).calledWith(sinon.match.object, 'Error getting org config'); 212 | assume(createCommentStub).has.been.called(); 213 | }); 214 | 215 | it('does not bail when no org-level config is found', async function () { 216 | getContentStub.withArgs(sinon.match({ 217 | repo: '.github' 218 | })).rejects({ 219 | status: 404 220 | }); 221 | await processPR(mockContext); 222 | assume(warnLogStub).has.not.been.called(); 223 | assume(createCommentStub).has.been.called(); 224 | }); 225 | 226 | it('bails when no plugins array is present in config', async function () { 227 | getContentStub.resetBehavior(); 228 | getContentStub.resolves({ 229 | status: 200, 230 | data: otoa({}) 231 | }); 232 | await processPR(mockContext); 233 | assume(infoLogStub).calledWith(sinon.match.object, 'No plugins to run, nothing to do'); 234 | }); 235 | 236 | it('bails when the config has an empty plugins array', async function () { 237 | getContentStub.resetBehavior(); 238 | getContentStub.resolves({ 239 | status: 200, 240 | data: otoa({ 241 | plugins: [] 242 | }) 243 | }); 244 | await processPR(mockContext); 245 | assume(infoLogStub).calledWith(sinon.match.object, 'No plugins to run, nothing to do'); 246 | }); 247 | 248 | it('does not bail when an unknown plugin is requested in repo config', async function () { 249 | getContentStub.resolves({ 250 | status: 200, 251 | data: otoa({ 252 | plugins: [ 253 | 'unknownPlugin' 254 | ] 255 | }) 256 | }); 257 | await processPR(mockContext); 258 | assume(errorLogStub).calledWithMatch(sinon.match({ 259 | plugin: 'unknownPlugin' 260 | }), 'Invalid plugin specified in repo config'); 261 | assume(infoLogStub).calledWith(sinon.match.object, 'Finished processing PR'); 262 | assume(createCommentStub).has.been.called(); 263 | }); 264 | 265 | it('does not bail when an unknown plugin is requested in org config', async function () { 266 | getContentStub.withArgs(sinon.match({ 267 | repo: '.github' 268 | })).resolves({ 269 | status: 200, 270 | data: otoa({ 271 | plugins: [ 272 | 'unknownPlugin' 273 | ] 274 | }) 275 | }); 276 | await processPR(mockContext); 277 | assume(errorLogStub).calledWithMatch(sinon.match({ 278 | plugin: 'unknownPlugin' 279 | }), 'Invalid plugin specified in config'); 280 | assume(infoLogStub).calledWith(sinon.match.object, 'Finished processing PR'); 281 | assume(createCommentStub).has.been.called(); 282 | }); 283 | 284 | it('runs all specified plugins', async function () { 285 | await processPR(mockContext); 286 | assume(errorLogStub).has.not.been.called(); 287 | assume(processRequestStub1).has.been.called(); 288 | assume(processRequestStub2).has.been.called(); 289 | assume(processRequestStub3).has.been.called(); 290 | assume(infoLogStub).calledWith(sinon.match.object, 'Finished processing PR'); 291 | assume(createCommentStub).has.been.called(); 292 | }); 293 | 294 | it('properly merges repo-level and org-level config for a plugin', async function () { 295 | await processPR(mockContext); 296 | assume(processRequestStub2).calledWithMatch(mockContext, sinon.match.instanceOf(MockCommenter), sinon.match({ 297 | foo: 'bar', 298 | baz: 'blah' 299 | })); 300 | }); 301 | 302 | it('skips plugins that do not process edits when processing an edit', async function () { 303 | mockContext.payload.action = 'edited'; 304 | await processPR(mockContext); 305 | assume(errorLogStub).has.not.been.called(); 306 | assume(processRequestStub1).has.not.been.called(); 307 | assume(processRequestStub2).has.been.called(); 308 | assume(processRequestStub3).has.been.called(); 309 | assume(infoLogStub).calledWith(sinon.match.object, 'Finished processing PR'); 310 | assume(createCommentStub).has.been.called(); 311 | }); 312 | 313 | it('skips plugins that do not process "ready for review" when processing a PR being marked as such', 314 | async function () { 315 | mockContext.payload.action = 'ready_for_review'; 316 | await processPR(mockContext); 317 | assume(errorLogStub).has.not.been.called(); 318 | assume(processRequestStub1).has.not.been.called(); 319 | assume(processRequestStub2).has.not.been.called(); 320 | assume(processRequestStub3).has.been.called(); 321 | assume(infoLogStub).calledWith(sinon.match.object, 'Finished processing PR'); 322 | assume(createCommentStub).has.been.called(); 323 | }); 324 | 325 | it('passes down plugin config', async function () { 326 | await processPR(mockContext); 327 | assume(processRequestStub2).calledWithMatch(sinon.match.object, sinon.match.object, sinon.match({ 328 | foo: 'bar' 329 | })); 330 | }); 331 | 332 | it('does not bail when a plugin fails', async function () { 333 | const mockError = new Error('mock error'); 334 | processRequestStub1.rejects(mockError); 335 | await processPR(mockContext); 336 | assume(errorLogStub).calledWithMatch(sinon.match({ 337 | error: mockError, 338 | repository: 'org/repo', 339 | number: 123, 340 | plugin: 'mockPlugin1', 341 | requestId: 'MOCK-ID' 342 | }), 'Error running plugin'); 343 | assume(processRequestStub2).has.been.called(); 344 | assume(infoLogStub).calledWith(sinon.match.object, 'Finished processing PR'); 345 | assume(createCommentStub).has.been.called(); 346 | }); 347 | 348 | it('does not post a comment when no comments are aggregated', async function () { 349 | MOCK_COMMENT = null; 350 | await processPR(mockContext); 351 | assume(createCommentStub).has.not.been.called(); 352 | }); 353 | }); 354 | -------------------------------------------------------------------------------- /test/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | import assume from 'assume'; 2 | 3 | import * as Utils from '../../utils.js'; 4 | 5 | describe('Utils', function () { 6 | describe('.parseBase64Json', function () { 7 | const mockPkg = { 8 | content: 'eyJmb28iOiAiYmFyIn0K', 9 | encoding: 'base64', 10 | url: 'mock', 11 | sha: 'mock', 12 | size: 1234 13 | }; 14 | 15 | it('is a function', function () { 16 | assume(Utils.parseBase64Json).is.a('function'); 17 | assume(Utils.parseBase64Json).has.length(1); 18 | }); 19 | 20 | it('returns undefined when passed a falsey input', function () { 21 | assume(Utils.parseBase64Json(void 0)).does.not.exist(); 22 | }); 23 | 24 | it('returns undefined when passed an input without a content field', function () { 25 | // @ts-ignore 26 | assume(Utils.parseBase64Json({})).does.not.exist(); 27 | }); 28 | 29 | it('properly parses a base64-encoded JSON package', function () { 30 | assume(Utils.parseBase64Json(mockPkg)).eqls({ 31 | foo: 'bar' 32 | }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('@octokit/rest').GitGetBlobResponse} GitBlobResponse 3 | */ 4 | /** 5 | * Parse a JSON file retrieved from GitHub 6 | * @param {GitBlobResponse} pkg package.json blob from GitHub 7 | * @returns {Object} Parsed package.json 8 | */ 9 | export function parseBase64Json(pkg) { 10 | let contents; 11 | 12 | if (pkg && pkg.content) { 13 | contents = Buffer.from(pkg.content, 'base64'); 14 | contents = JSON.parse(contents.toString()); 15 | } 16 | 17 | return contents; 18 | } 19 | -------------------------------------------------------------------------------- /views/home.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Pullie | A GitHub App that makes your PRs better 8 | 10 | 11 | 25 | 26 | 27 |
28 | 52 | 53 |
54 | Pullie logo 55 |

Hi, I'm Pullie!

56 |

I'm a GitHub App that helps make your pull requests better.

57 |
58 |

I run as a GitHub App and receive a webhook call whenever a pull request is made against a repo on which I'm 59 | installed. Plugins provide handy functionality like linking to Jira tickets, requesting reviews, and 60 | commenting about a missing required file change.

61 | Tell me more! 62 |
63 | 64 |
65 |

Getting started

66 |

You can start using Pullie in two simple steps.

67 |
    68 |
  1. Install Pullie on your repo(s)
  2. 69 |
  3. Add a .pullierc file to each repo
  4. 70 |
71 |
72 | 73 |
74 |

Step 1: Install Pullie

75 |

Just click the button below to install Pullie onto your repo(s).

76 |

You can choose to install it on an entire org or just a few repositories. Without a .pullierc 77 | file in the repo, Pullie won't do anything, so it's safe to install across an org and then fill in 78 | .pullierc files later.

79 | Install Pullie 81 |
82 | 83 |
84 |

Step 2: Configure Pullie

85 |

Pullie can be configured per-repo or per-org in a .pullierc file.

86 |

The .pullierc file is just a simple JSON configuration file. It sets the list of plugins that 87 | should be run on the repo for new PRs. Just toss this file in the root of your repo:

88 | 89 |
90 |
{{#code}}{
 91 |   "plugins": [
 92 |     "jira",
 93 |     {
 94 |       "plugin": "requiredFile",
 95 |       "config": {
 96 |         "files": [
 97 |           {
 98 |             "path": "CHANGELOG.md",
 99 |             "message": "A `CHANGELOG.md` entry is required for changes to this repo. Please add an entry unless you have a good reason not to."
100 |           }
101 |         ]
102 |       }
103 |     },
104 |     {
105 |       "plugin": "reviewers",
106 |       "config": {
107 |         "howMany": 2
108 |       }
109 |     }
110 |   ]
111 | }{{/code}}
112 |
Sample .pullierc configuration file
113 |
114 |

115 | 🎉 That's it! Your repo now has Pullie installed. For more details on configuring each plugin, keep reading. 116 |

117 |
118 | 119 |
120 |

Plugin documentation

121 |

The top-level export of the .pullierc file must be an object. Its only field must be entitled 122 | plugins and be set to an array of plugin configurations.

123 |

The plugin entries in the array are either strings or objects. For simple no-configuration plugins, you may 124 | simply pass the string name of the plugin. To pass configuration data to the plugin, you must pass an object 125 | with two fields: plugin — the name of the plugin, and config — a 126 | configuration object. Each plugin's configuration object will be different. 127 |

128 | 129 |

Jira

130 |

The Jira plugin will scan your pull request's title for Jira ticket keys and add a comment to the 131 | PR with link(s) to the ticket(s). 132 |

133 |

Ticket keys need to be in the form PROJECT-12345. More than one ticket can be listed.

134 |

Configuration

135 |

The Jira plugin takes no configuration. Just list it as a string in your .pullierc:

136 |
137 |
{{#code}}{
138 |   "plugins": [
139 |     "jira"
140 |   ]
141 | }{{/code}}
142 |
Sample Jira plugin config
143 |
144 | 145 |

Required File

146 |

The Required File plugin will ensure that the PR has a change to one or more specified files.

147 |

If a specified file is not present in the repo, it won't be required in PRs. File paths are relative to the 148 | root of the repo. If a PR doesn't have a change to a specified file, Pullie will post a comment with either a 149 | default message, or a custom one if configured. 150 |

151 |

Configuration

152 |

The Required File plugin takes one configuration field — files — which contains an 153 | array of either string file paths, or objects specifying file paths and custom messages. message 154 | can contain GitHub-flavored markdown.

155 |
156 |
{{#code}}{
157 |   "plugins": [
158 |     {
159 |       "plugin": "requiredFile",
160 |       "config": {
161 |         "files": [
162 |           "test/test.js",
163 |           {
164 |             "path": "CHANGELOG.md",
165 |             "message": "A `CHANGELOG.md` entry is required for changes to this repo. Please add an entry unless you have a good reason not to."
166 |           }
167 |         ]
168 |       }
169 |     }
170 |   ]
171 | }{{/code}}
172 |
Sample Required File plugin config
173 |
174 | 175 |

Reviewers

176 |

The Reviewers plugin will request review from a set of candidate reviewers.

177 |

Reviewers can either be specified directly in the .pullierc file, or are pulled from the repo's 178 | package.json file.

179 |

To use the package.json file, you must list reviewers in the author, 180 | maintainers, and/or contributors fields, and those reviewers must be listed either 181 | as objects with email specified, or as strings in the standard Name 182 | <email@domain.com> format. 183 |

184 |

Configuration

185 |

The Reviewers plugin takes two optional configuration fields: howMany and 186 | reviewers.

187 |

howMany specifies the number of reviewers to choose randomly from the set 188 | of candidate reviewers. If omitted, Pullie will request review from all candidate reviewers.

189 |

reviewers specifies a list of candidate reviewers as an array of objects containing an 190 | email field, strings in the standard Name <email@domain.com> format, or strings 191 | containing raw GitHub usernames.

192 |

commentFormat specifies an optional comment to post when reviews are requested. The token 193 | %s will be replaced with a list of @-mentioned usernames for the reviewers being requested. 194 | If omitted, no comment will be posted.

195 |

requestForDrafts specifies whether reviews should be requested for draft PRs. The default 196 | behavior is false, meaning that draft PRs will not have reviews requested. After a draft PR is 197 | marked as ready for review, Pullie will request reviews accordingly.

198 |

Candidate reviewers are checked against the GitHub API to ensure that they actually exist as users before 199 | their review is requested. 200 |

201 |
202 |
{{#code}}{
203 |   "plugins": [
204 |     {
205 |       "plugin": "reviewers",
206 |       "config": {
207 |         "howMany": 2,
208 |         "reviewers": [
209 |           "jschmoe",
210 |           "Bob Smith ",
211 |           {
212 |             "name": "John Doe"
213 |             "email": "jdoe@domain.com"
214 |           }
215 |         ],
216 |         "commentFormat": "I've requested review from %s. Please get approval from them before merging."
217 |       }
218 |     }
219 |   ]
220 | }{{/code}}
221 |
Sample Reviewers plugin config
222 |
223 | 224 |

Welcome

225 |

The Welcome plugin will post a welcome message on PRs submitted by new contributors.

226 |

It determines who is a new contributor by scanning the repo for prior PRs by the user.

227 |

Configuration

228 |

The Welcome plugin takes one configuration field: welcomeMessage.

229 |

welcomeMessage specifies the message to post on PRs by new contributors.

230 |
231 |
{{#code}}{
232 |   "plugins": [
233 |     {
234 |       "plugin": "welcome",
235 |       "config": {
236 |         "welcomeMessage": "Thanks for opening your first pull request! We appreciate your contribution."
237 |       }
238 |     }
239 |   ]
240 | }{{/code}}
241 |
Sample Welcome plugin config
242 |
243 |
244 | 245 |
246 |

Org-level configuration

247 |

Pullie can be configured at the repo or org level. Configuration settings are merged between 248 | the two.

249 |

Org-level configuration is stored in the .github repo for your org. If you don't have such a 250 | repo, you can create one by following 251 | GitHub's instructions here. 253 |

254 |

Org-level configuration is optional. If you only have a repo-level configuration file, that's fine. 255 | Repo-level configuration is required and Pullie will ignore your repo if it cannot find a 256 | .pullierc file at your repo's root. When processing a PR, Pullie will attempt to load repo-level 257 | configuration and then org-level configuration. If it finds both, it will intelligently merge the repo-level 258 | configuration on top of the org-level configuration. 259 |

260 |

To use org-level configuration, you must allow Pullie to access the .github repo in your org. 261 | If you install Pullie on all repos in your org, that's fine. If you choose specific repos, you'll have to 262 | explicitly add the .github repo to that list for org-level configuration to work.

263 |

If you use org-level configuration, your repo-level configuration files may become quite a bit slimmer. 264 | Indeed, if you want to just accept the defaults specified in your org-level configuration, your repo-level 265 | file may simply contain an empty object ({}). 266 |

267 |

Overrides manifests

268 |

Repo-level configuration may be specified as a traditional .pullierc file as 269 | described above, or it may be specified in an overrides manifest.

270 |

Overrides manifests are similar to a traditional Pullie configuration file, but define the 271 | plugins field as an object with include and exclude fields. 272 |

273 |
274 |
{{#code}}{
275 |   "plugins": {
276 |     "include": [
277 |       "jira",
278 |       {
279 |         "plugin": "reviewers",
280 |         "config": {
281 |           "howMany": 2
282 |         }
283 |       }
284 |     ],
285 |     "exclude": [
286 |       "requiredFile"
287 |     ]
288 |   }
289 | }{{/code}}
290 |
Sample overrides manifest config
291 |
292 |

When merging in an overrides manifest, Pullie performs this process:

293 |
    294 |
  1. Begin with the org-level configuration
  2. 295 |
  3. Exclude any plugins listed in exclude
  4. 296 |
  5. Merge in any plugins listed in include
  6. 297 |
298 |

Plugin-level configuration is merged intelligently — each plugin defines how configuration should be 299 | merged. Most just do a basic shallow merge. If you specify your repo-level configuration as a traditional 300 | Pullie configuration file, it will be treated as if it were an overrides manifest with just an 301 | include field. 302 |

303 |
304 |
305 | 315 | 316 | 317 | --------------------------------------------------------------------------------