├── .editorconfig ├── .env.example ├── .git-blame-ignore-revs ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ └── feature.md ├── dependabot.yml ├── settings.yml ├── stale.yml └── workflows │ ├── node-ci.yml │ ├── release.yml │ └── stats.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .remarkrc.json ├── .renovaterc.json ├── LICENSE ├── README.md ├── api └── github │ └── webhooks │ └── index.js ├── app.json ├── assets ├── icon.sketch └── powered-by-vercel.svg ├── cucumber.js ├── docs ├── configuration.md ├── plugins │ ├── branches.md │ ├── collaborators.md │ ├── environments.md │ ├── labels.md │ ├── milestones.md │ ├── repository.md │ ├── rulesets.md │ └── teams.md └── self-host.md ├── index.js ├── lib ├── mergeArrayByName.js ├── plugins │ ├── branches.js │ ├── collaborators.js │ ├── diffable.js │ ├── environments.js │ ├── labels.js │ ├── milestones.js │ ├── repository.js │ ├── rulesets.js │ └── teams.js └── settings.js ├── package-lock.json ├── package.json ├── script ├── bootstrap ├── console.cjs ├── server ├── test └── tunnel └── test ├── fixtures └── events │ ├── push.readme.json │ ├── push.settings.json │ └── repository.edited.json ├── integration ├── common.js ├── features │ ├── collaborators.feature │ ├── environments.feature │ ├── events.feature │ ├── labels.feature │ ├── milestones.feature │ ├── repository.feature │ ├── rulesets.feature │ ├── step_definitions │ │ ├── collaborators-steps.js │ │ ├── common-steps.js │ │ ├── config-steps.js │ │ ├── environments-steps.js │ │ ├── github-api-steps.js │ │ ├── labels-steps.js │ │ ├── milestones-steps.js │ │ ├── repository-events-steps.js │ │ ├── repository-steps.js │ │ ├── rulesets-steps.js │ │ └── team-steps.js │ └── teams.feature └── triggers │ ├── push.test.js │ ├── repository-created.test.js │ └── repository-edited.test.js └── unit ├── index.test.js └── lib ├── mergeArrayByName.test.js └── plugins ├── branches.test.js ├── collaborators.test.js ├── environments.test.js ├── labels.test.js ├── milestones.test.js ├── repository.test.js ├── rulesets.test.js └── teams.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # The ID of your GitHub App 2 | APP_ID= 3 | WEBHOOK_SECRET=development 4 | 5 | # Uncomment this to get verbose logging 6 | # LOG_LEVEL=trace # or `info` to show less 7 | 8 | # Go to https://smee.io/new set this to the URL that you are redirected to. 9 | # WEBHOOK_PROXY_URL= 10 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # This file keeps track of commits that can be safely ignored when running `git blame` 2 | # See https://michaelheap.com/git-ignore-rev/ 3 | # 4 | # Please add a comment with each commit SHA to explain why it should be skipped 5 | 6 | # Apply prettier-standard for the first time 7 | 8e3d7f1a0114735005976434eb2b6baba9097bbd 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Something is not working as expected 4 | labels: bug 5 | --- 6 | 7 | ## Problem Description 8 | 9 | ### What is actually happening 10 | 11 | ### What is the expected behavior 12 | 13 | ### Error output, if available 14 | 15 | ``` 16 | 17 | ``` 18 | 19 | ## Context 20 | 21 | ### Are you using the hosted instance of repository-settings/app or running your own? 22 | 23 | ### If running your own instance, are you using it with github.com or GitHub Enterprise? 24 | 25 | #### Version of repository-settings/app 26 | 27 | #### Version of GitHub Enterprise 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggestion for new or different behavior 4 | labels: enhancement 5 | --- 6 | 7 | ## Prerequisites: 8 | 9 | * Is the functionality available in the GitHub UI? If so, please provide a link to information about the feature. 10 | 11 | 15 | * Is the functionality available through the GitHub API? If the functionality is available, please provide links to the 16 | API documentation (https://developer.github.com/v3/) as well as the Octokit documentation (https://octokit.github.io/). 17 | 18 | * If the functionality is not yet available in the API, it would be helpful if you 19 | contacted support (https://support.github.com/) or posted in the Community Forum (https://github.community/). Please 20 | include a link to the forum post if you create one or a copy of the response from support. 21 | 22 | ## New Feature 23 | 24 | Please describe the desired new functionality: 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 0 8 | ignore: 9 | - dependency-name: probot 10 | versions: 11 | - 11.1.0 12 | - 11.2.1 13 | - 11.2.3 14 | - dependency-name: nock 15 | versions: 16 | - 13.0.10 17 | commit-message: 18 | prefix: fix 19 | prefix-development: chore 20 | include: scope 21 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | name: app 3 | description: Pull Requests for GitHub repository settings 4 | homepage: https://github.com/apps/settings 5 | topics: probot-app, github-app 6 | has_wiki: false 7 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for https://github.com/probot/stale 2 | _extends: .github 3 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 'on': 3 | push: 4 | branches: 5 | - master 6 | - beta 7 | - renovate/** 8 | pull_request: 9 | types: 10 | - opened 11 | - synchronize 12 | jobs: 13 | verify-matrix: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node: 18 | - 18.17.0 19 | - 20.6.1 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 22 | - name: Setup node 23 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 24 | with: 25 | node-version: ${{ matrix.node }} 26 | cache: npm 27 | - run: npm clean-install 28 | - run: npm test 29 | verify-dev: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 33 | - name: Setup node 34 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 35 | with: 36 | node-version-file: .nvmrc 37 | cache: npm 38 | - run: npm clean-install 39 | - run: corepack npm audit signatures 40 | - run: npm test 41 | verify: 42 | runs-on: ubuntu-latest 43 | needs: 44 | - verify-dev 45 | - verify-matrix 46 | if: ${{ !cancelled() }} 47 | steps: 48 | - name: All matrix versions passed 49 | if: ${{ !(contains(needs.*.result, 'failure')) }} 50 | run: exit 0 51 | - name: Some matrix version failed 52 | if: ${{ contains(needs.*.result, 'failure') }} 53 | run: exit 1 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | - beta 7 | permissions: 8 | contents: read # for checkout 9 | jobs: 10 | release: 11 | permissions: 12 | contents: write # to be able to publish a GitHub release 13 | issues: write # to be able to comment on released issues 14 | pull-requests: write # to be able to comment on released pull requests 15 | id-token: write # to enable use of OIDC for npm provenance 16 | name: release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 21 | with: 22 | node-version-file: .nvmrc 23 | cache: npm 24 | - run: npm clean-install 25 | - run: corepack npm audit signatures 26 | - run: npx semantic-release@24.2.5 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | NPM_TOKEN: ${{ secrets.REPOSITORY_SETTINGS_BOT_NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/stats.yml: -------------------------------------------------------------------------------- 1 | on: 2 | schedule: 3 | # https://crontab.guru/once-a-day 4 | - cron: 0 0 * * * 5 | workflow_dispatch: {} 6 | 7 | name: Stats 8 | jobs: 9 | stats: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: gr2m/app-stats-action@v1.x 13 | id: stats 14 | with: 15 | id: ${{ secrets.SETTINGS_APP_ID }} 16 | private_key: ${{ secrets.SETTINGS_PRIVATE_KEY }} 17 | - run: "echo installations: '${{ steps.stats.outputs.installations }}'" 18 | - run: "echo suspended: '${{ steps.stats.outputs.suspended_installations }}'" 19 | - run: "echo repositories: '${{ steps.stats.outputs.repositories }}'" 20 | - run: "echo most popular repositories: '${{ steps.stats.outputs.popular_repositories }}'" 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | .DS_Store 3 | node_modules/ 4 | private-key.pem 5 | .env 6 | *.pem 7 | .vercel 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.16.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.yml 3 | *.json 4 | -------------------------------------------------------------------------------- /.remarkrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@form8ion/remark-preset"] 3 | } 4 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>form8ion/renovate-config:js-app" 5 | ], 6 | "lockFileMaintenance": { 7 | "enabled": true, 8 | "automergeType": "pr" 9 | }, 10 | "customManagers": [ 11 | { 12 | "customType": "regex", 13 | "description": "Update semantic-release version used by npx", 14 | "fileMatch": [ 15 | "^\\.github/workflows/release\\.yml$" 16 | ], 17 | "matchStrings": [ 18 | "\\srun: npx semantic-release@(?.*?)\\s" 19 | ], 20 | "datasourceTemplate": "npm", 21 | "depNameTemplate": "semantic-release" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2017, Brandon Keepers 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Repository Settings 2 | 3 | This GitHub App syncs repository settings defined in `.github/settings.yml` to GitHub, enabling Pull Requests for repository settings. 4 | 5 | 6 | 7 | [![Node CI Workflow Status][github-actions-ci-badge]][github-actions-ci-link] 8 | [![Renovate][renovate-badge]][renovate-link] 9 | 10 | 11 | 12 | ## Table of Contents 13 | 14 | * [Usage](#usage) 15 | * [Install](#install) 16 | * [Hosted GitHub.com App](#hosted-githubcom-app) 17 | * [Self-Hosted App](#self-hosted-app) 18 | * [Configuration](#configuration) 19 | * [Security Implications](#security-implications) 20 | 21 | ## Usage 22 | 23 | ### Install 24 | 25 | To gain the benefits of the Repository Settings app, it will need to installed 26 | as a GitHub App on your repositories. 27 | First, choose which approach to using the Repository Settings App is most appropriate for you: 28 | 29 | #### Hosted GitHub.com App 30 | 31 | A hosted version is provided for use with GitHub.com. 32 | 33 | __[Install the app](https://github.com/apps/settings)__ on your repositories or 34 | entire organization. 35 | 36 | [![Powered by Vercel][vercel-badge]][vercel-link] 37 | 38 | #### Self-Hosted App 39 | 40 | If you would prefer to self-host your own instance, see the documentation about 41 | [self-hosting](docs/self-host.md) if you would like to run your own instance of this app. 42 | 43 | ### Configuration 44 | 45 | Now that you have the repository settings app installed for your repositories, 46 | see the documentation about [configuration](docs/configuration.md) for details 47 | about updating your repository settings through pull-requests. 48 | 49 | ## Security Implications 50 | 51 | > [!Caution] 52 | > Note that this app inherently _escalates anyone with `push` 53 | > permissions to the __admin__ role_, since they can push config settings to the 54 | > default branch, which will be synced. 55 | > Use caution when merging PRs and adding collaborators. 56 | 57 | One way to preserve admin/push permissions is to utilize the 58 | [GitHub CodeOwners feature](https://help.github.com/articles/about-codeowners/) 59 | to set one or more administrative users as the code owner of the 60 | `.github/settings.yml` file, and turn on "require code owner review" for the 61 | default branch. 62 | This does have the side effect of requiring code owner review for the entire 63 | branch, but helps preserve permission levels. 64 | 65 | [github-actions-ci-link]: https://github.com/repository-settings/app/actions?query=workflow%3A%22Node.js+CI%22+branch%3Amaster 66 | 67 | [github-actions-ci-badge]: https://github.com/repository-settings/app/workflows/Node.js%20CI/badge.svg 68 | 69 | [renovate-link]: https://renovatebot.com 70 | 71 | [renovate-badge]: https://img.shields.io/badge/renovate-enabled-brightgreen.svg?logo=renovatebot 72 | 73 | [vercel-badge]: https://github.com/repository-settings/app/raw/master/assets/powered-by-vercel.svg 74 | 75 | [vercel-link]: https://vercel.com?utm_source=repository-settings&utm_campaign=oss 76 | -------------------------------------------------------------------------------- /api/github/webhooks/index.js: -------------------------------------------------------------------------------- 1 | import { createNodeMiddleware, createProbot } from 'probot' 2 | 3 | import app from '../../../index.js' 4 | 5 | export default createNodeMiddleware(app, { 6 | probot: createProbot(), 7 | webhooksPath: '/api/github/webhooks' 8 | }) 9 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-settings", 3 | "description": "Pull Requests for GitHub repository settings", 4 | "keywords": ["node", "github"], 5 | "repository": "https://github.com/bkeepers/github-settings", 6 | "env": { 7 | "PRIVATE_KEY": { 8 | "description": "the private key you downloaded when creating the GitHub Integration" 9 | }, 10 | "INTEGRATION_ID": { 11 | "description": "the ID of your GitHub Integration" 12 | }, 13 | "WEBHOOK_SECRET": { 14 | "description": "the secret configured for your GitHub Integration" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/icon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/repository-settings/app/4939a41feee3bddb9d45d8c7eaf6b7d8eba397d8/assets/icon.sketch -------------------------------------------------------------------------------- /assets/powered-by-vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /cucumber.js: -------------------------------------------------------------------------------- 1 | const base = { 2 | formatOptions: { snippetInterface: 'async-await' }, 3 | import: ['test/integration/features/**/*.js'] 4 | } 5 | 6 | export default base 7 | 8 | export const wip = { 9 | ...base, 10 | tags: '@wip and not @skip' 11 | } 12 | 13 | export const noWip = { 14 | ...base, 15 | tags: 'not @skip and not @wip' 16 | } 17 | 18 | export const focus = { 19 | ...base, 20 | tags: '@focus' 21 | } 22 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Create a `.github/settings.yml` file in your repository. Changes to this file on the default branch will be synced to GitHub. 4 | 5 | ## Sections 6 | 7 | All sections are optional. Some do have required fields. 8 | 9 | Find details about each available section in their own page: 10 | 11 | * [Repository](./plugins/repository.md) 12 | * [Teams](./plugins/teams.md) 13 | * [Collaborators](./plugins/collaborators.md) 14 | * [Branches](./plugins/branches.md) 15 | * [Environments](./plugins/environments.md) 16 | * [Labels](./plugins/labels.md) 17 | * [Milestones](./plugins/milestones.md) 18 | 19 | ### Inheritance 20 | 21 | This app is built with [probot](https://github.com/probot/probot), and thus uses the [octokit-plugin-config](https://github.com/probot/octokit-plugin-config). 22 | This means you can inherit settings from another repo, and only override what you want to change. 23 | 24 | Individual settings in the arrays listed under `labels`, `teams`, and `branches` will be merged with the base repo if the `name` of an element in the array matches the `name` of an element in the corresponding array in the base repo. 25 | A possible future enhancement would be to make that work for the other settings arrays based on `username`, or `title`. 26 | This is not currently supported. 27 | 28 | #### To further clarify: 29 | Inheritance within the Protected Branches plugin allows you to override specific settings per branch. 30 | For example, your `.github` repo may set default protection on the `master` branch. 31 | You can then include `master` in your `branches` array, and only override the `required_approving_review_count`. 32 | Alternatively, you might only have a branch like `develop` in your `branches` array, and would still get `master` protection from your base repo. 33 | -------------------------------------------------------------------------------- /docs/plugins/branches.md: -------------------------------------------------------------------------------- 1 | # Branches 2 | 3 | https://docs.github.com/en/rest/branches/branch-protection 4 | 5 | > [!IMPORTANT] 6 | > Each top-level element under branch protection must be filled (eg: 7 | `required_pull_request_reviews`, `required_status_checks`, `enforce_admins` and 8 | `restrictions`). 9 | If you don't want to use one of them you must set it to `null` (see comments in 10 | the example below). 11 | Otherwise, none of the settings will be applied. 12 | 13 | ```yaml 14 | branches: 15 | - name: master 16 | # Branch Protection settings. Set to null to disable 17 | protection: 18 | # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. 19 | required_pull_request_reviews: 20 | # The number of approvals required. (1-6) 21 | required_approving_review_count: 1 22 | # Dismiss approved reviews automatically when a new commit is pushed. 23 | dismiss_stale_reviews: true 24 | # Blocks merge until code owners have reviewed. 25 | require_code_owner_reviews: true 26 | # Specify which users and teams can dismiss pull request reviews. Pass an empty dismissal_restrictions object to disable. User and team dismissal_restrictions are only available for organization-owned repositories. Omit this parameter for personal repositories. 27 | dismissal_restrictions: 28 | users: [] 29 | teams: [] 30 | # Required. Require status checks to pass before merging. Set to null to disable 31 | required_status_checks: 32 | # Required. Require branches to be up to date before merging. 33 | strict: true 34 | # Required. The list of status checks to require in order to merge into this branch 35 | contexts: [] 36 | # Required. Enforce all configured restrictions for administrators. Set to true to enforce required status checks for repository administrators. Set to null to disable. 37 | enforce_admins: true 38 | # Prevent merge commits from being pushed to matching branches 39 | required_linear_history: true 40 | # Required. Restrict who can push to this branch. Team and user restrictions are only available for organization-owned repositories. Set to null to disable. 41 | restrictions: 42 | apps: [] 43 | users: [] 44 | teams: [] 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/plugins/collaborators.md: -------------------------------------------------------------------------------- 1 | # Collaborators 2 | 3 | https://docs.github.com/en/rest/collaborators/collaborators 4 | 5 | ```yaml 6 | collaborators: 7 | - username: bkeepers 8 | permission: push 9 | - username: hubot 10 | permission: pull 11 | 12 | # Note: `permission` is only valid on organization-owned repositories. 13 | # The permission to grant the collaborator. Can be one of: 14 | # * `pull` - can pull, but not push to or administer this repository. 15 | # * `push` - can pull and push, but not administer this repository. 16 | # * `admin` - can pull, push and administer this repository. 17 | # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. 18 | # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/plugins/environments.md: -------------------------------------------------------------------------------- 1 | # Environments 2 | 3 | 4 | See https://docs.github.com/en/rest/deployments/environments#create-or-update-an-environment for available options. 5 | 6 | > [!IMPORTANT] 7 | > `deployment_branch_policy` differs from the API for ease of use. 8 | Either `protected_branches` (boolean) OR `custom_branches` (array of strings) can be provided; 9 | this will manage the API requirements under the hood. 10 | > 11 | > See https://docs.github.com/en/rest/deployments/branch-policies for documentation of `custom_branches`. 12 | If both are provided in an unexpected manner, `protected_branches` will be used. 13 | > 14 | > Either removing or simply not setting `deployment_branch_policy` will restore the default 'All branches' setting. 15 | 16 | ```markdown 17 | environments: 18 | - name: production 19 | wait_timer: 5 20 | reviewers: 21 | - id: 1 22 | type: 'Team' 23 | - id: 2 24 | type: 'User' 25 | deployment_branch_policy: 26 | protected_branches: true 27 | - name: development 28 | deployment_branch_policy: 29 | custom_branches: 30 | - main 31 | - dev/* 32 | - name: release/* 33 | type: branch 34 | - name: v* 35 | type: tag 36 | ``` 37 | -------------------------------------------------------------------------------- /docs/plugins/labels.md: -------------------------------------------------------------------------------- 1 | # Labels 2 | 3 | > [!NOTE] 4 | > Label color can start with `#`, e.g. `color: '#F341B2'`. 5 | 6 | > [!IMPORTANT] 7 | > If including the `#`, make sure to wrap it with quotes since it would otherwise be treated as a yaml comment! 8 | 9 | ```yaml 10 | labels: 11 | - name: bug 12 | color: CC0000 13 | description: An issue with the system 🐛. 14 | 15 | - name: feature 16 | # If including a `#`, make sure to wrap it with quotes! 17 | color: '#336699' 18 | description: New functionality. 19 | 20 | - name: Help Wanted 21 | # Provide a new name to rename an existing label 22 | new_name: first-timers-only 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/plugins/milestones.md: -------------------------------------------------------------------------------- 1 | # Milestones 2 | 3 | https://docs.github.com/en/rest/issues/milestones#update-a-milestone 4 | 5 | ```yaml 6 | milestones: 7 | - title: milestone-title 8 | description: milestone-description 9 | # The state of the milestone. Either `open` or `closed` 10 | state: open 11 | ``` 12 | -------------------------------------------------------------------------------- /docs/plugins/repository.md: -------------------------------------------------------------------------------- 1 | # Repository 2 | 3 | See https://docs.github.com/en/rest/reference/repos#update-a-repository for all 4 | available repository properties and descriptions of each. 5 | 6 | 7 | ```yaml 8 | repository: 9 | 10 | # The name of the repository. Changing this will rename the repository 11 | name: repo-name 12 | 13 | # A short description of the repository that will show up on GitHub 14 | description: description of repo 15 | 16 | # A URL with more information about the repository 17 | homepage: https://example.github.io/ 18 | 19 | # A comma-separated list of topics to set on the repository 20 | topics: github, probot 21 | 22 | # Either `true` to make the repository private, or `false` to make it public. 23 | private: false 24 | 25 | # Either `true` to enable issues for this repository, `false` to disable them. 26 | has_issues: true 27 | 28 | # Either `true` to enable projects for this repository, or `false` to disable them. 29 | # If projects are disabled for the organization, passing `true` will cause an API error. 30 | has_projects: true 31 | 32 | # Either `true` to enable the wiki for this repository, `false` to disable it. 33 | has_wiki: true 34 | 35 | # Either `true` to enable downloads for this repository, `false` to disable them. 36 | has_downloads: true 37 | 38 | # Updates the default branch for this repository. 39 | default_branch: master 40 | 41 | # Either `true` to allow squash-merging pull requests, or `false` to prevent 42 | # squash-merging. 43 | allow_squash_merge: true 44 | 45 | # Either `true` to allow merging pull requests with a merge commit, or `false` 46 | # to prevent merging pull requests with merge commits. 47 | allow_merge_commit: true 48 | 49 | # Either `true` to allow rebase-merging pull requests, or `false` to prevent 50 | # rebase-merging. 51 | allow_rebase_merge: true 52 | 53 | # Either `true` to enable automatic deletion of branches on merge, or `false` to disable 54 | delete_branch_on_merge: true 55 | 56 | # Either `true` to enable automated security fixes, or `false` to disable 57 | # automated security fixes. 58 | enable_automated_security_fixes: true 59 | 60 | # Either `true` to enable vulnerability alerts, or `false` to disable 61 | # vulnerability alerts. 62 | enable_vulnerability_alerts: true 63 | ``` 64 | -------------------------------------------------------------------------------- /docs/plugins/rulesets.md: -------------------------------------------------------------------------------- 1 | # Repository Rulesets 2 | 3 | > [!WARNING] 4 | > Support for Repository Rulesets is still under development. 5 | > Details may still change, like how the configuration is defined in the `settings.yml`. 6 | > Please follow [#732](https://github.com/repository-settings/app/issues/732) for progress updates. 7 | > Feedback is appreciated, but adopt cautiously, with the expectation of breaking changes until support is fully released. 8 | 9 | See https://docs.github.com/en/rest/repos/rules#update-a-repository-ruleset for 10 | all available ruleset properties and a description of each. 11 | 12 | ```yaml 13 | rulesets: 14 | - name: prevent destruction of the default branch 15 | target: branch 16 | enforcement: active 17 | conditions: 18 | ref_name: 19 | include: 20 | - "~DEFAULT_BRANCH" 21 | exclude: [] 22 | rules: 23 | - type: deletion 24 | - type: non_fast_forward 25 | 26 | - name: verification must pass 27 | target: branch 28 | enforcement: active 29 | conditions: 30 | ref_name: 31 | include: 32 | - "~DEFAULT_BRANCH" 33 | exclude: [] 34 | rules: 35 | - type: required_status_checks 36 | parameters: 37 | strict_required_status_checks_policy: false # Required field 38 | required_status_checks: 39 | - context: test 40 | integration_id: 123456 41 | bypass_actors: 42 | - actor_id: 5 43 | actor_type: RepositoryRole 44 | bypass_mode: pull_request 45 | 46 | - name: changes must be reviewed 47 | target: branch 48 | enforcement: active 49 | conditions: 50 | ref_name: 51 | include: 52 | - "~DEFAULT_BRANCH" 53 | exclude: [] 54 | rules: 55 | - type: pull_request 56 | parameters: 57 | required_approving_review_count: 1 58 | require_code_owner_review: true 59 | bypass_actors: 60 | - actor_id: 654321 61 | actor_type: Integration 62 | bypass_mode: always 63 | ``` 64 | -------------------------------------------------------------------------------- /docs/plugins/teams.md: -------------------------------------------------------------------------------- 1 | # Teams 2 | 3 | See https://docs.github.com/en/rest/teams/teams#add-or-update-team-repository-permissions for available options 4 | 5 | ```yaml 6 | teams: 7 | - name: core 8 | # The permission to grant the team. Can be one of: 9 | # * `pull` - can pull, but not push to or administer this repository. 10 | # * `push` - can pull and push, but not administer this repository. 11 | # * `admin` - can pull, push and administer this repository. 12 | # * `maintain` - Recommended for project managers who need to manage the repository without access to sensitive or destructive actions. 13 | # * `triage` - Recommended for contributors who need to proactively manage issues and pull requests without write access. 14 | permission: admin 15 | - name: docs 16 | permission: push 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/self-host.md: -------------------------------------------------------------------------------- 1 | # Self-Hosted App 2 | 3 | 4 | 5 | ![node][node-badge] 6 | 7 | 8 | 9 | ## Table of Contents 10 | 11 | * [Deploy a Self-Hosted Instance](#deploy-a-self-hosted-instance) 12 | * [Install `@repository-settings/app` as a dependency of your own app (recommended)](#install-repository-settingsapp-as-a-dependency-of-your-own-app-recommended) 13 | * [Depend on the package from npm](#depend-on-the-package-from-npm) 14 | * [Example node.js app](#example-nodejs-app) 15 | * [Deploy a fork of this repository](#deploy-a-fork-of-this-repository) 16 | * [Permissions & events](#permissions--events) 17 | * [Permissions](#permissions) 18 | * [Organization Permissions](#organization-permissions) 19 | * [Events](#events) 20 | 21 | ## Deploy a Self-Hosted Instance 22 | 23 | Multiple options exist for deploying a self-hosted instance: 24 | 25 | ### Install `@repository-settings/app` as a dependency of your own app (recommended) 26 | 27 | This option offers you the most flexibility 28 | 29 | #### Depend on the package from npm 30 | 31 | ```shell 32 | npm install @repository-settings/app 33 | ``` 34 | 35 | #### Example node.js app 36 | 37 | ```js 38 | import {Server, Probot, ProbotOctokit} from 'probot'; 39 | import app from '@repository-settings/app'; 40 | 41 | async function start() { 42 | const log = getLog(); 43 | const server = new Server({ 44 | Probot: Probot.defaults({ 45 | appId: process.env.APP_ID, 46 | privateKey: process.env.PRIVATE_KEY, 47 | secret: process.env.WEBHOOK_SECRET, 48 | Octokit: ProbotOctokit.defaults({ 49 | baseUrl: process.env.GH_API 50 | }), 51 | log: log.child({ 52 | name: 'repository-settings' 53 | }) 54 | }), 55 | log: log.child({ 56 | name: 'server' 57 | }) 58 | }); 59 | 60 | process.on('SIGTERM', async () => { 61 | console.log('Received SIGTERM, stopping server!'); 62 | 63 | await server.stop(); 64 | }); 65 | 66 | await server.load(app); 67 | 68 | await server.start(); 69 | }; 70 | 71 | start(); 72 | ``` 73 | 74 | ### Deploy a fork of this repository 75 | 76 | Alternatively, you can fork this repository and modify to suit your environment. 77 | 78 | ## Permissions & events 79 | 80 | This plugin requires these **Permissions & events** for the GitHub Integration: 81 | 82 | ### Permissions 83 | 84 | - Administration: **Read & Write** 85 | - Contents: **Read only** 86 | - Issues: **Read & Write** 87 | - Single file: **Read & Write** 88 | - Path: `.github/settings.yml` 89 | - Actions: **Read only** 90 | 91 | ### Organization Permissions 92 | 93 | - Members: **Read & Write** 94 | 95 | ### Events 96 | 97 | - Push 98 | - Repository 99 | 100 | [node-badge]: https://img.shields.io/node/v/@repository-settings/app?logo=node.js 101 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import mergeArrayByName from './lib/mergeArrayByName.js' 2 | import SettingsApp from './lib/settings.js' 3 | 4 | /** 5 | * @param {import('probot').Probot} robot 6 | */ 7 | export default (robot, _, Settings = SettingsApp) => { 8 | async function syncSettings (context, repo = context.repo()) { 9 | const config = await context.config('settings.yml', {}, { arrayMerge: mergeArrayByName }) 10 | return Settings.sync(context.octokit, repo, config) 11 | } 12 | 13 | robot.on('push', async context => { 14 | const { payload } = context 15 | const { repository } = payload 16 | 17 | const defaultBranch = payload.ref === 'refs/heads/' + repository.default_branch 18 | if (!defaultBranch) { 19 | robot.log.debug('Not working on the default branch, returning...') 20 | return 21 | } 22 | 23 | const settingsModified = payload.commits.find(commit => { 24 | return commit.added.includes(Settings.FILE_NAME) || commit.modified.includes(Settings.FILE_NAME) 25 | }) 26 | 27 | if (!settingsModified) { 28 | robot.log.debug(`No changes in '${Settings.FILE_NAME}' detected, returning...`) 29 | return 30 | } 31 | 32 | return syncSettings(context) 33 | }) 34 | 35 | robot.on('repository.edited', async context => { 36 | const { payload } = context 37 | const { changes, repository } = payload 38 | 39 | if (!Object.prototype.hasOwnProperty.call(changes, 'default_branch')) { 40 | robot.log.debug('Repository configuration was edited but the default branch was not affected, returning...') 41 | return 42 | } 43 | 44 | robot.log.debug(`Default branch changed from '${changes.default_branch.from}' to '${repository.default_branch}'`) 45 | 46 | return syncSettings(context) 47 | }) 48 | 49 | robot.on('repository.created', async context => syncSettings(context)) 50 | } 51 | -------------------------------------------------------------------------------- /lib/mergeArrayByName.js: -------------------------------------------------------------------------------- 1 | // https://github.com/KyleAMathews/deepmerge#arraymerge 2 | 3 | import merge from 'deepmerge' 4 | 5 | function findMatchingIndex (sourceItem, target) { 6 | if (Object.prototype.hasOwnProperty.call(sourceItem, 'name')) { 7 | return target 8 | .filter(targetItem => Object.prototype.hasOwnProperty.call(targetItem, 'name')) 9 | .findIndex(targetItem => sourceItem.name === targetItem.name) 10 | } 11 | } 12 | 13 | export default function mergeByName (target, source, options) { 14 | const destination = target.slice() 15 | 16 | source.forEach(sourceItem => { 17 | const matchingIndex = findMatchingIndex(sourceItem, target) 18 | if (matchingIndex > -1) { 19 | destination[matchingIndex] = merge(target[matchingIndex], sourceItem, options) 20 | } else { 21 | destination.push(sourceItem) 22 | } 23 | }) 24 | 25 | return destination 26 | } 27 | -------------------------------------------------------------------------------- /lib/plugins/branches.js: -------------------------------------------------------------------------------- 1 | const previewHeaders = { 2 | accept: 3 | 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' 4 | } 5 | 6 | export default class Branches { 7 | constructor (github, repo, settings) { 8 | this.github = github 9 | this.repo = repo 10 | this.branches = settings 11 | } 12 | 13 | sync () { 14 | return Promise.all( 15 | this.branches 16 | .filter(branch => branch.protection !== undefined) 17 | .map(branch => { 18 | const params = Object.assign(this.repo, { branch: branch.name }) 19 | 20 | if (this.isEmpty(branch.protection)) { 21 | return this.github.repos.deleteBranchProtection(params) 22 | } else { 23 | Object.assign(params, branch.protection, { headers: previewHeaders }) 24 | return this.github.repos.updateBranchProtection(params) 25 | } 26 | }) 27 | ) 28 | } 29 | 30 | isEmpty (maybeEmpty) { 31 | return maybeEmpty === null || Object.keys(maybeEmpty).length === 0 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/plugins/collaborators.js: -------------------------------------------------------------------------------- 1 | import Diffable from './diffable.js' 2 | 3 | export default class Collaborators extends Diffable { 4 | constructor (...args) { 5 | super(...args) 6 | 7 | if (this.entries) { 8 | // Force all usernames to lowercase to avoid comparison issues. 9 | this.entries.forEach(collaborator => { 10 | collaborator.username = collaborator.username.toLowerCase() 11 | }) 12 | } 13 | } 14 | 15 | find () { 16 | return this.github.repos 17 | .listCollaborators({ repo: this.repo.repo, owner: this.repo.owner, affiliation: 'direct' }) 18 | .then(res => { 19 | return res.data.map(user => { 20 | return { 21 | // Force all usernames to lowercase to avoid comparison issues. 22 | username: user.login.toLowerCase(), 23 | permission: 24 | (user.permissions.admin && 'admin') || 25 | (user.permissions.push && 'push') || 26 | (user.permissions.pull && 'pull') 27 | } 28 | }) 29 | }) 30 | } 31 | 32 | comparator (existing, attrs) { 33 | return existing.username === attrs.username 34 | } 35 | 36 | changed (existing, attrs) { 37 | return existing.permission !== attrs.permission 38 | } 39 | 40 | update (existing, attrs) { 41 | return this.add(attrs) 42 | } 43 | 44 | add (attrs) { 45 | return this.github.repos.addCollaborator(Object.assign({}, attrs, this.repo)) 46 | } 47 | 48 | remove (existing) { 49 | return this.github.repos.removeCollaborator(Object.assign({ username: existing.username }, this.repo)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/plugins/diffable.js: -------------------------------------------------------------------------------- 1 | // Base class to make it easy to check for changes to a list of items 2 | // 3 | // class Thing extends Diffable { 4 | // find() { 5 | // } 6 | // 7 | // comparator(existing, attrs) { 8 | // } 9 | // 10 | // changed(existing, attrs) { 11 | // } 12 | // 13 | // update(existing, attrs) { 14 | // } 15 | // 16 | // add(attrs) { 17 | // } 18 | // 19 | // remove(existing) { 20 | // } 21 | // } 22 | export default class Diffable { 23 | constructor (github, repo, entries) { 24 | this.github = github 25 | this.repo = repo 26 | this.entries = entries 27 | } 28 | 29 | sync () { 30 | if (this.entries) { 31 | return this.find().then(existingRecords => { 32 | const changes = [] 33 | 34 | this.entries.forEach(attrs => { 35 | const existing = existingRecords.find(record => { 36 | return this.comparator(record, attrs) 37 | }) 38 | 39 | if (!existing) { 40 | changes.push(this.add(attrs)) 41 | } else if (this.changed(existing, attrs)) { 42 | changes.push(this.update(existing, attrs)) 43 | } 44 | }) 45 | 46 | existingRecords.forEach(x => { 47 | if (!this.entries.find(y => this.comparator(x, y))) { 48 | changes.push(this.remove(x)) 49 | } 50 | }) 51 | 52 | return Promise.all(changes) 53 | }) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/plugins/environments.js: -------------------------------------------------------------------------------- 1 | import Diffable from './diffable.js' 2 | 3 | const environmentRepoEndpoint = '/repos/:org/:repo/environments/:environment_name' 4 | 5 | function shouldUseProtectedBranches (protectedBranches, customBranchPolicies) { 6 | return !!(protectedBranches || customBranchPolicies === undefined || customBranchPolicies === null) 7 | } 8 | 9 | function attributeSorter (a, b) { 10 | if (a.id < b.id) return -1 11 | if (a.id > b.id) return 1 12 | if (a.type < b.type) return -1 13 | if (a.type > b.type) return 1 14 | return 0 15 | } 16 | 17 | function reviewersToString (reviewers) { 18 | if (reviewers === null || reviewers === undefined) { 19 | return '' 20 | } else { 21 | reviewers.sort(attributeSorter) 22 | 23 | return JSON.stringify( 24 | reviewers.map(reviewer => { 25 | return { 26 | id: reviewer.id, 27 | type: reviewer.type 28 | } 29 | }) 30 | ) 31 | } 32 | } 33 | 34 | function deploymentBranchPolicyToString (attrs) { 35 | if (attrs === null || attrs === undefined) { 36 | return '' 37 | } else { 38 | return JSON.stringify( 39 | shouldUseProtectedBranches(attrs.protected_branches, attrs.custom_branches) 40 | ? { protected_branches: true } 41 | : { 42 | custom_branches: attrs.custom_branches.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) 43 | } 44 | ) 45 | } 46 | } 47 | 48 | function waitTimerHasChanged (existing, attrs) { 49 | return (existing.wait_timer || 0) !== attrs.wait_timer 50 | } 51 | 52 | function reviewersHasChanged (existing, attrs) { 53 | return reviewersToString(existing.reviewers) !== reviewersToString(attrs.reviewers) 54 | } 55 | 56 | function deploymentBranchPolicyHasChanged (existing, attrs) { 57 | return ( 58 | deploymentBranchPolicyToString(existing.deployment_branch_policy) !== 59 | deploymentBranchPolicyToString(attrs.deployment_branch_policy) 60 | ) 61 | } 62 | 63 | export default class Environments extends Diffable { 64 | constructor (...args) { 65 | super(...args) 66 | 67 | if (this.entries) { 68 | // Force all names to lowercase to avoid comparison issues. 69 | this.entries.forEach(environment => { 70 | environment.name = environment.name.toLowerCase() 71 | if (environment.deployment_branch_policy && environment.deployment_branch_policy.custom_branches) { 72 | environment.deployment_branch_policy.custom_branches = environment.deployment_branch_policy.custom_branches.map( 73 | rule => ({ 74 | name: rule.name || rule, 75 | type: rule.type || 'branch' 76 | }) 77 | ) 78 | } 79 | }) 80 | } 81 | } 82 | 83 | async find () { 84 | const { 85 | data: { environments } 86 | } = await this.github.request('GET /repos/:org/:repo/environments', { 87 | org: this.repo.owner, 88 | repo: this.repo.repo 89 | }) 90 | 91 | return Promise.all( 92 | environments.map(async environment => { 93 | if (environment.deployment_branch_policy) { 94 | if (environment.deployment_branch_policy.custom_branch_policies) { 95 | const branchPolicies = await this.getDeploymentBranchPolicies( 96 | this.repo.owner, 97 | this.repo.repo, 98 | environment.name 99 | ) 100 | environment.deployment_branch_policy = { 101 | custom_branches: branchPolicies.map(_ => ({ 102 | name: _.name, 103 | type: _.type 104 | })) 105 | } 106 | } else { 107 | environment.deployment_branch_policy = { 108 | protected_branches: true 109 | } 110 | } 111 | } 112 | 113 | return { 114 | ...environment, 115 | // Force all names to lowercase to avoid comparison issues. 116 | name: environment.name.toLowerCase() 117 | } 118 | }) 119 | ) 120 | } 121 | 122 | comparator (existing, attrs) { 123 | return existing.name === attrs.name 124 | } 125 | 126 | changed (existing, attrs) { 127 | if (!attrs.wait_timer) attrs.wait_timer = 0 128 | 129 | return ( 130 | waitTimerHasChanged(existing, attrs) || 131 | reviewersHasChanged(existing, attrs) || 132 | deploymentBranchPolicyHasChanged(existing, attrs) 133 | ) 134 | } 135 | 136 | async update (existing, attrs) { 137 | if (existing.deployment_branch_policy && existing.deployment_branch_policy.custom_branches) { 138 | const branchPolicies = await this.getDeploymentBranchPolicies(this.repo.owner, this.repo.repo, existing.name) 139 | 140 | await Promise.all( 141 | branchPolicies.map(branchPolicy => 142 | this.github.request( 143 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 144 | { 145 | org: this.repo.owner, 146 | repo: this.repo.repo, 147 | environment_name: existing.name, 148 | id: branchPolicy.id 149 | } 150 | ) 151 | ) 152 | ) 153 | } 154 | 155 | return this.add(attrs) 156 | } 157 | 158 | async add (attrs) { 159 | await this.github.request(`PUT ${environmentRepoEndpoint}`, this.toParams({ name: attrs.name }, attrs)) 160 | 161 | if (attrs.deployment_branch_policy && attrs.deployment_branch_policy.custom_branches) { 162 | await Promise.all( 163 | attrs.deployment_branch_policy.custom_branches.map(rule => 164 | this.github.request('POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { 165 | org: this.repo.owner, 166 | repo: this.repo.repo, 167 | environment_name: attrs.name, 168 | name: rule.name, 169 | type: rule.type 170 | }) 171 | ) 172 | ) 173 | } 174 | } 175 | 176 | remove (existing) { 177 | return this.github.request(`DELETE ${environmentRepoEndpoint}`, { 178 | environment_name: existing.name, 179 | repo: this.repo.repo, 180 | org: this.repo.owner 181 | }) 182 | } 183 | 184 | async getDeploymentBranchPolicies (owner, repo, environmentName) { 185 | const { 186 | data: { branch_policies: branchPolicies } 187 | } = await this.github.request('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { 188 | org: owner, 189 | repo, 190 | environment_name: environmentName 191 | }) 192 | 193 | return branchPolicies 194 | } 195 | 196 | toParams (existing, attrs) { 197 | const deploymentBranchPolicy = attrs.deployment_branch_policy 198 | ? shouldUseProtectedBranches( 199 | attrs.deployment_branch_policy.protected_branches, 200 | attrs.deployment_branch_policy.custom_branches 201 | ) 202 | ? { protected_branches: true, custom_branch_policies: false } 203 | : { protected_branches: false, custom_branch_policies: true } 204 | : null 205 | 206 | return { 207 | environment_name: existing.name, 208 | repo: this.repo.repo, 209 | org: this.repo.owner, 210 | wait_timer: attrs.wait_timer, 211 | reviewers: attrs.reviewers, 212 | deployment_branch_policy: deploymentBranchPolicy 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /lib/plugins/labels.js: -------------------------------------------------------------------------------- 1 | import Diffable from './diffable.js' 2 | const previewHeaders = { accept: 'application/vnd.github.symmetra-preview+json' } 3 | 4 | export default class Labels extends Diffable { 5 | constructor (...args) { 6 | super(...args) 7 | 8 | if (this.entries) { 9 | this.entries.forEach(label => { 10 | // Force color to string since some hex colors can be numerical (e.g. 999999) 11 | if (label.color) { 12 | label.color = String(label.color).replace(/^#/, '') 13 | if (label.color.length < 6) { 14 | label.color = label.color.padStart(6, '0') 15 | } 16 | } 17 | }) 18 | } 19 | } 20 | 21 | find () { 22 | const options = this.github.issues.listLabelsForRepo.endpoint.merge(this.wrapAttrs({ per_page: 100 })) 23 | return this.github.paginate(options) 24 | } 25 | 26 | comparator (existing, attrs) { 27 | return existing.name === attrs.name 28 | } 29 | 30 | changed (existing, attrs) { 31 | return 'new_name' in attrs || existing.color !== attrs.color || existing.description !== attrs.description 32 | } 33 | 34 | update (existing, attrs) { 35 | return this.github.issues.updateLabel(this.wrapAttrs(attrs)) 36 | } 37 | 38 | add (attrs) { 39 | return this.github.issues.createLabel(this.wrapAttrs(attrs)) 40 | } 41 | 42 | remove (existing) { 43 | return this.github.issues.deleteLabel(this.wrapAttrs({ name: existing.name })) 44 | } 45 | 46 | wrapAttrs (attrs) { 47 | return Object.assign({}, attrs, this.repo, { headers: previewHeaders }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/plugins/milestones.js: -------------------------------------------------------------------------------- 1 | import Diffable from './diffable.js' 2 | 3 | export default class Milestones extends Diffable { 4 | constructor (...args) { 5 | super(...args) 6 | 7 | if (this.entries) { 8 | this.entries.forEach(milestone => { 9 | if (milestone.due_on) { 10 | delete milestone.due_on 11 | } 12 | }) 13 | } 14 | } 15 | 16 | find () { 17 | const options = this.github.issues.listMilestones.endpoint.merge( 18 | Object.assign({ per_page: 100, state: 'all' }, this.repo) 19 | ) 20 | return this.github.paginate(options) 21 | } 22 | 23 | comparator (existing, attrs) { 24 | return existing.title === attrs.title 25 | } 26 | 27 | changed (existing, attrs) { 28 | return existing.description !== attrs.description || existing.state !== attrs.state 29 | } 30 | 31 | update (existing, attrs) { 32 | const { owner, repo } = this.repo 33 | 34 | return this.github.issues.updateMilestone( 35 | Object.assign({ milestone_number: existing.number }, attrs, { owner, repo }) 36 | ) 37 | } 38 | 39 | add (attrs) { 40 | const { owner, repo } = this.repo 41 | 42 | return this.github.issues.createMilestone(Object.assign({}, attrs, { owner, repo })) 43 | } 44 | 45 | remove (existing) { 46 | const { owner, repo } = this.repo 47 | 48 | return this.github.issues.deleteMilestone(Object.assign({ milestone_number: existing.number }, { owner, repo })) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/plugins/repository.js: -------------------------------------------------------------------------------- 1 | const enableAutomatedSecurityFixes = ({ github, settings, enabled }) => { 2 | if (enabled === undefined) { 3 | return Promise.resolve() 4 | } 5 | 6 | const args = { 7 | owner: settings.owner, 8 | repo: settings.repo, 9 | mediaType: { 10 | previews: ['london'] 11 | } 12 | } 13 | const methodName = enabled ? 'enableAutomatedSecurityFixes' : 'disableAutomatedSecurityFixes' 14 | 15 | return github.repos[methodName](args) 16 | } 17 | 18 | const enableVulnerabilityAlerts = ({ github, settings, enabled }) => { 19 | if (enabled === undefined) { 20 | return Promise.resolve() 21 | } 22 | 23 | const args = { 24 | owner: settings.owner, 25 | repo: settings.repo, 26 | mediaType: { 27 | previews: ['dorian'] 28 | } 29 | } 30 | const methodName = enabled ? 'enableVulnerabilityAlerts' : 'disableVulnerabilityAlerts' 31 | 32 | return github.repos[methodName](args) 33 | } 34 | 35 | export default class Repository { 36 | constructor (github, repo, settings) { 37 | this.github = github 38 | this.settings = Object.assign({ mediaType: { previews: ['baptiste'] } }, settings, repo) 39 | this.topics = this.settings.topics 40 | delete this.settings.topics 41 | 42 | this.enableVulnerabilityAlerts = this.settings.enable_vulnerability_alerts 43 | delete this.settings.enable_vulnerability_alerts 44 | 45 | this.enableAutomatedSecurityFixes = this.settings.enable_automated_security_fixes 46 | delete this.settings.enable_automated_security_fixes 47 | } 48 | 49 | sync () { 50 | this.settings.name = this.settings.name || this.settings.repo 51 | return this.github.repos 52 | .update(this.settings) 53 | .then(() => { 54 | if (this.topics) { 55 | return this.github.repos.replaceAllTopics({ 56 | owner: this.settings.owner, 57 | repo: this.settings.repo, 58 | names: this.topics.split(/\s*,\s*/), 59 | mediaType: { 60 | previews: ['mercy'] 61 | } 62 | }) 63 | } 64 | }) 65 | .then(() => enableVulnerabilityAlerts({ enabled: this.enableVulnerabilityAlerts, ...this })) 66 | .then(() => enableAutomatedSecurityFixes({ enabled: this.enableAutomatedSecurityFixes, ...this })) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/plugins/rulesets.js: -------------------------------------------------------------------------------- 1 | import deepEqual from 'deep-equal' 2 | 3 | import Diffable from './diffable.js' 4 | 5 | export default class Rulesets extends Diffable { 6 | async find () { 7 | const { data: rulesets } = await this.github.repos.getRepoRulesets({ ...this.repo, includes_parents: false }) 8 | 9 | const expandedRulesetsData = await Promise.all( 10 | rulesets.map(({ id }) => this.github.repos.getRepoRuleset({ ...this.repo, ruleset_id: id })) 11 | ) 12 | 13 | return expandedRulesetsData.map(({ data }) => data) 14 | } 15 | 16 | comparator (existing, attrs) { 17 | return existing.name === attrs.name 18 | } 19 | 20 | changed (existing, attrs) { 21 | const { 22 | id, 23 | _links, 24 | created_at: createdAt, 25 | updated_at: updatedAd, 26 | source_type: sourceType, 27 | source, 28 | node_id: nodeId, 29 | current_user_can_bypass: currentUserCanBypass, 30 | ...existingAttrs 31 | } = existing 32 | 33 | return !deepEqual(existingAttrs, attrs) 34 | } 35 | 36 | update (existing, attrs) { 37 | return this.github.repos.updateRepoRuleset({ ...this.repo, ruleset_id: existing.id, ...attrs }) 38 | } 39 | 40 | remove (existing) { 41 | return this.github.repos.deleteRepoRuleset({ ...this.repo, ruleset_id: existing.id }) 42 | } 43 | 44 | async add (attrs) { 45 | await this.github.repos.createRepoRuleset({ ...this.repo, ...attrs }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/plugins/teams.js: -------------------------------------------------------------------------------- 1 | import Diffable from './diffable.js' 2 | 3 | // it is necessary to use this endpoint until GitHub Enterprise supports 4 | // the modern version under /orgs 5 | const teamRepoEndpoint = '/teams/:team_id/repos/:owner/:repo' 6 | 7 | export default class Teams extends Diffable { 8 | find () { 9 | return this.github.repos.listTeams(this.repo).then(res => res.data) 10 | } 11 | 12 | comparator (existing, attrs) { 13 | return existing.slug === attrs.name 14 | } 15 | 16 | changed (existing, attrs) { 17 | return existing.permission !== attrs.permission 18 | } 19 | 20 | update (existing, attrs) { 21 | return this.github.request(`PUT ${teamRepoEndpoint}`, this.toParams(existing, attrs)) 22 | } 23 | 24 | async add (attrs) { 25 | const { data: existing } = await this.github.request('GET /orgs/:org/teams/:team_slug', { 26 | org: this.repo.owner, 27 | team_slug: attrs.name 28 | }) 29 | 30 | return this.github.request(`PUT ${teamRepoEndpoint}`, this.toParams(existing, attrs)) 31 | } 32 | 33 | remove (existing) { 34 | return this.github.request(`DELETE ${teamRepoEndpoint}`, { 35 | team_id: existing.id, 36 | ...this.repo, 37 | org: this.repo.owner 38 | }) 39 | } 40 | 41 | toParams (existing, attrs) { 42 | return { 43 | team_id: existing.id, 44 | owner: this.repo.owner, 45 | repo: this.repo.repo, 46 | org: this.repo.owner, 47 | permission: attrs.permission 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/settings.js: -------------------------------------------------------------------------------- 1 | import Repository from './plugins/repository.js' 2 | import Labels from './plugins/labels.js' 3 | import Collaborators from './plugins/collaborators.js' 4 | import Teams from './plugins/teams.js' 5 | import Milestones from './plugins/milestones.js' 6 | import Branches from './plugins/branches.js' 7 | import Environments from './plugins/environments.js' 8 | import Rulesets from './plugins/rulesets.js' 9 | 10 | export default class Settings { 11 | static sync (github, repo, config) { 12 | return new Settings(github, repo, config).update() 13 | } 14 | 15 | constructor (github, repo, config) { 16 | this.github = github 17 | this.repo = repo 18 | this.config = config 19 | } 20 | 21 | update () { 22 | const { branches, ...rest } = this.config 23 | 24 | return Promise.all( 25 | Object.entries(rest).map(([section, config]) => { 26 | return this.processSection(section, config) 27 | }) 28 | ).then(() => { 29 | if (branches) { 30 | return this.processSection('branches', branches) 31 | } 32 | }) 33 | } 34 | 35 | processSection (section, config) { 36 | const debug = { repo: this.repo } 37 | debug[section] = config 38 | 39 | const Plugin = Settings.PLUGINS[section] 40 | return new Plugin(this.github, this.repo, config).sync() 41 | } 42 | } 43 | 44 | Settings.FILE_NAME = '.github/settings.yml' 45 | 46 | Settings.PLUGINS = { 47 | repository: Repository, 48 | labels: Labels, 49 | collaborators: Collaborators, 50 | teams: Teams, 51 | milestones: Milestones, 52 | branches: Branches, 53 | environments: Environments, 54 | rulesets: Rulesets 55 | } 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repository-settings/app", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Pull Requests for GitHub repository settings", 5 | "repository": "github:repository-settings/app", 6 | "type": "module", 7 | "main": "./index.js", 8 | "exports": "./index.js", 9 | "scripts": { 10 | "dev": "nodemon", 11 | "start": "probot run ./index.js", 12 | "test": "npm-run-all --print-label --parallel lint:* --parallel test:*", 13 | "lint:js": "prettier-standard --lint --check", 14 | "lint:js:fix": "prettier-standard --format --lint", 15 | "lint:lockfile": "lockfile-lint --path package-lock.json --type npm --validate-https --allowed-hosts npm", 16 | "lint:engines": "ls-engines", 17 | "lint:peer": "npm ls >/dev/null", 18 | "lint:publish": "publint --strict", 19 | "test:unit": "NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 jest 'test/unit/'", 20 | "test:unit:watch": "npm run test:unit -- --watch", 21 | "test:integration": "run-s 'test:integration:base -- --profile noWip'", 22 | "test:integration:base": "NODE_OPTIONS=--enable-source-maps DEBUG=any cucumber-js test/integration", 23 | "test:integration:debug": "DEBUG=test run-s test:integration", 24 | "test:integration:wip": "run-s 'test:integration:base -- --profile wip'", 25 | "test:integration:wip:debug": "DEBUG=test run-s 'test:integration:wip'", 26 | "test:integration:focus": "run-s 'test:integration:base -- --profile focus'", 27 | "generate:md": "remark . --output" 28 | }, 29 | "author": "Brandon Keepers", 30 | "license": "ISC", 31 | "dependencies": { 32 | "deep-equal": "2.2.3", 33 | "deepmerge": "4.3.1", 34 | "js-yaml": "4.1.0", 35 | "probot": "13.3.9" 36 | }, 37 | "devDependencies": { 38 | "@cucumber/cucumber": "11.3.0", 39 | "@form8ion/remark-preset": "1.0.5", 40 | "@travi/any": "3.1.2", 41 | "http-status-codes": "2.3.0", 42 | "jest": "29.7.0", 43 | "jest-when": "3.7.0", 44 | "lockfile-lint": "4.14.1", 45 | "ls-engines": "0.9.3", 46 | "msw": "2.6.8", 47 | "nodemon": "3.1.10", 48 | "npm-run-all2": "8.0.4", 49 | "prettier-standard": "16.4.1", 50 | "publint": "0.3.12", 51 | "smee-client": "4.2.1", 52 | "standard": "17.1.2" 53 | }, 54 | "standard": { 55 | "env": [ 56 | "jest" 57 | ] 58 | }, 59 | "engines": { 60 | "node": "^18.17 || >=20.6.1" 61 | }, 62 | "jest": { 63 | "testEnvironment": "node" 64 | }, 65 | "nodemonConfig": { 66 | "exec": "npm start", 67 | "watch": [ 68 | ".env", 69 | "." 70 | ] 71 | }, 72 | "files": [ 73 | "index.js", 74 | "lib/" 75 | ], 76 | "publishConfig": { 77 | "access": "public", 78 | "provenance": true 79 | }, 80 | "packageManager": "npm@11.4.1" 81 | } 82 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | npm install 6 | 7 | # Copy .env template 8 | [[ -f .env ]] || cp .env.sample .env 9 | -------------------------------------------------------------------------------- /script/console.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Console for experimenting with GitHub API requests. 3 | // 4 | // Usage: GITHUB_TOKEN=xxx script/Console 5 | // 6 | 7 | require('dotenv').config(); 8 | 9 | if (!process.env.GITHUB_TOKEN) { 10 | console.error('GITHUB_TOKEN environment variable must be set.'); 11 | console.error('Create a personal access token at https://github.com/settings/tokens/new'); 12 | process.exit(1); 13 | } 14 | 15 | const repl = require('repl').start('> '); 16 | const GitHubApi = require('github'); 17 | const github = new GitHubApi(); 18 | 19 | github.authenticate({ 20 | type: 'oauth', 21 | token: process.env.GITHUB_TOKEN 22 | }) 23 | 24 | repl.context.github = github; 25 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | npm start | ./node_modules/.bin/bunyan 6 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | npm test 6 | -------------------------------------------------------------------------------- /script/tunnel: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ngrok http -bind-tls=true 3000 4 | -------------------------------------------------------------------------------- /test/fixtures/events/push.readme.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/master", 3 | "before": "3c8cbf33403deeb9a7c8860b72f1135cb3d0a960", 4 | "after": "ec6cb5901ea3cbfb0f1833cc4c45486b4b93492f", 5 | "created": false, 6 | "deleted": false, 7 | "forced": false, 8 | "base_ref": null, 9 | "compare": "https://github.com/bkeepers-inc/botland/compare/3c8cbf33403d...ec6cb5901ea3", 10 | "commits": [ 11 | { 12 | "id": "ec6cb5901ea3cbfb0f1833cc4c45486b4b93492f", 13 | "tree_id": "ed6046b33b8bea92b77d93a9b18e2899f908e2bd", 14 | "distinct": true, 15 | "message": "Update README.md", 16 | "timestamp": "2017-01-22T10:28:23-06:00", 17 | "url": "https://github.com/bkeepers-inc/botland/commit/ec6cb5901ea3cbfb0f1833cc4c45486b4b93492f", 18 | "author": { 19 | "name": "Brandon Keepers", 20 | "email": "bkeepers@github.com", 21 | "username": "bkeepers" 22 | }, 23 | "committer": { 24 | "name": "GitHub", 25 | "email": "noreply@github.com", 26 | "username": "web-flow" 27 | }, 28 | "added": [ 29 | 30 | ], 31 | "removed": [ 32 | 33 | ], 34 | "modified": [ 35 | "README.md" 36 | ] 37 | } 38 | ], 39 | "head_commit": { 40 | "id": "ec6cb5901ea3cbfb0f1833cc4c45486b4b93492f", 41 | "tree_id": "ed6046b33b8bea92b77d93a9b18e2899f908e2bd", 42 | "distinct": true, 43 | "message": "Update README.md", 44 | "timestamp": "2017-01-22T10:28:23-06:00", 45 | "url": "https://github.com/bkeepers-inc/botland/commit/ec6cb5901ea3cbfb0f1833cc4c45486b4b93492f", 46 | "author": { 47 | "name": "Brandon Keepers", 48 | "email": "bkeepers@github.com", 49 | "username": "bkeepers" 50 | }, 51 | "committer": { 52 | "name": "GitHub", 53 | "email": "noreply@github.com", 54 | "username": "web-flow" 55 | }, 56 | "added": [ 57 | 58 | ], 59 | "removed": [ 60 | 61 | ], 62 | "modified": [ 63 | "README.md" 64 | ] 65 | }, 66 | "repository": { 67 | "id": 68308403, 68 | "name": "botland", 69 | "full_name": "bkeepers-inc/botland", 70 | "owner": { 71 | "name": "bkeepers-inc", 72 | "email": null 73 | }, 74 | "private": true, 75 | "html_url": "https://github.com/bkeepers-inc/botland", 76 | "description": "Playground for testing bots.", 77 | "fork": false, 78 | "url": "https://github.com/bkeepers-inc/botland", 79 | "forks_url": "https://api.github.com/repos/bkeepers-inc/botland/forks", 80 | "keys_url": "https://api.github.com/repos/bkeepers-inc/botland/keys{/key_id}", 81 | "collaborators_url": "https://api.github.com/repos/bkeepers-inc/botland/collaborators{/collaborator}", 82 | "teams_url": "https://api.github.com/repos/bkeepers-inc/botland/teams", 83 | "hooks_url": "https://api.github.com/repos/bkeepers-inc/botland/hooks", 84 | "issue_events_url": "https://api.github.com/repos/bkeepers-inc/botland/issues/events{/number}", 85 | "events_url": "https://api.github.com/repos/bkeepers-inc/botland/events", 86 | "assignees_url": "https://api.github.com/repos/bkeepers-inc/botland/assignees{/user}", 87 | "branches_url": "https://api.github.com/repos/bkeepers-inc/botland/branches{/branch}", 88 | "tags_url": "https://api.github.com/repos/bkeepers-inc/botland/tags", 89 | "blobs_url": "https://api.github.com/repos/bkeepers-inc/botland/git/blobs{/sha}", 90 | "git_tags_url": "https://api.github.com/repos/bkeepers-inc/botland/git/tags{/sha}", 91 | "git_refs_url": "https://api.github.com/repos/bkeepers-inc/botland/git/refs{/sha}", 92 | "trees_url": "https://api.github.com/repos/bkeepers-inc/botland/git/trees{/sha}", 93 | "statuses_url": "https://api.github.com/repos/bkeepers-inc/botland/statuses/{sha}", 94 | "languages_url": "https://api.github.com/repos/bkeepers-inc/botland/languages", 95 | "stargazers_url": "https://api.github.com/repos/bkeepers-inc/botland/stargazers", 96 | "contributors_url": "https://api.github.com/repos/bkeepers-inc/botland/contributors", 97 | "subscribers_url": "https://api.github.com/repos/bkeepers-inc/botland/subscribers", 98 | "subscription_url": "https://api.github.com/repos/bkeepers-inc/botland/subscription", 99 | "commits_url": "https://api.github.com/repos/bkeepers-inc/botland/commits{/sha}", 100 | "git_commits_url": "https://api.github.com/repos/bkeepers-inc/botland/git/commits{/sha}", 101 | "comments_url": "https://api.github.com/repos/bkeepers-inc/botland/comments{/number}", 102 | "issue_comment_url": "https://api.github.com/repos/bkeepers-inc/botland/issues/comments{/number}", 103 | "contents_url": "https://api.github.com/repos/bkeepers-inc/botland/contents/{+path}", 104 | "compare_url": "https://api.github.com/repos/bkeepers-inc/botland/compare/{base}...{head}", 105 | "merges_url": "https://api.github.com/repos/bkeepers-inc/botland/merges", 106 | "archive_url": "https://api.github.com/repos/bkeepers-inc/botland/{archive_format}{/ref}", 107 | "downloads_url": "https://api.github.com/repos/bkeepers-inc/botland/downloads", 108 | "issues_url": "https://api.github.com/repos/bkeepers-inc/botland/issues{/number}", 109 | "pulls_url": "https://api.github.com/repos/bkeepers-inc/botland/pulls{/number}", 110 | "milestones_url": "https://api.github.com/repos/bkeepers-inc/botland/milestones{/number}", 111 | "notifications_url": "https://api.github.com/repos/bkeepers-inc/botland/notifications{?since,all,participating}", 112 | "labels_url": "https://api.github.com/repos/bkeepers-inc/botland/labels{/name}", 113 | "releases_url": "https://api.github.com/repos/bkeepers-inc/botland/releases{/id}", 114 | "deployments_url": "https://api.github.com/repos/bkeepers-inc/botland/deployments", 115 | "created_at": 1473954711, 116 | "updated_at": "2016-12-19T19:41:29Z", 117 | "pushed_at": 1485102504, 118 | "git_url": "git://github.com/bkeepers-inc/botland.git", 119 | "ssh_url": "git@github.com:bkeepers-inc/botland.git", 120 | "clone_url": "https://github.com/bkeepers-inc/botland.git", 121 | "svn_url": "https://github.com/bkeepers-inc/botland", 122 | "homepage": "", 123 | "size": 2, 124 | "stargazers_count": 0, 125 | "watchers_count": 0, 126 | "language": "JavaScript", 127 | "has_issues": true, 128 | "has_downloads": true, 129 | "has_wiki": true, 130 | "has_pages": false, 131 | "forks_count": 0, 132 | "mirror_url": null, 133 | "open_issues_count": 6, 134 | "forks": 0, 135 | "open_issues": 6, 136 | "watchers": 0, 137 | "default_branch": "master", 138 | "stargazers": 0, 139 | "master_branch": "master", 140 | "organization": "bkeepers-inc" 141 | }, 142 | "pusher": { 143 | "name": "bkeepers", 144 | "email": "bkeepers@github.com" 145 | }, 146 | "organization": { 147 | "login": "bkeepers-inc", 148 | "id": 11724939, 149 | "url": "https://api.github.com/orgs/bkeepers-inc", 150 | "repos_url": "https://api.github.com/orgs/bkeepers-inc/repos", 151 | "events_url": "https://api.github.com/orgs/bkeepers-inc/events", 152 | "hooks_url": "https://api.github.com/orgs/bkeepers-inc/hooks", 153 | "issues_url": "https://api.github.com/orgs/bkeepers-inc/issues", 154 | "members_url": "https://api.github.com/orgs/bkeepers-inc/members{/member}", 155 | "public_members_url": "https://api.github.com/orgs/bkeepers-inc/public_members{/member}", 156 | "avatar_url": "https://avatars.githubusercontent.com/u/11724939?v=3", 157 | "description": null 158 | }, 159 | "sender": { 160 | "login": "bkeepers", 161 | "id": 173, 162 | "avatar_url": "https://avatars.githubusercontent.com/u/173?v=3", 163 | "gravatar_id": "", 164 | "url": "https://api.github.com/users/bkeepers", 165 | "html_url": "https://github.com/bkeepers", 166 | "followers_url": "https://api.github.com/users/bkeepers/followers", 167 | "following_url": "https://api.github.com/users/bkeepers/following{/other_user}", 168 | "gists_url": "https://api.github.com/users/bkeepers/gists{/gist_id}", 169 | "starred_url": "https://api.github.com/users/bkeepers/starred{/owner}{/repo}", 170 | "subscriptions_url": "https://api.github.com/users/bkeepers/subscriptions", 171 | "organizations_url": "https://api.github.com/users/bkeepers/orgs", 172 | "repos_url": "https://api.github.com/users/bkeepers/repos", 173 | "events_url": "https://api.github.com/users/bkeepers/events{/privacy}", 174 | "received_events_url": "https://api.github.com/users/bkeepers/received_events", 175 | "type": "User", 176 | "site_admin": true 177 | }, 178 | "installation": { 179 | "id": 6274 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /test/fixtures/events/push.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/master", 3 | "before": "4512a4e4ee3fe8b92aa5be7b06773a5b2304f4c8", 4 | "after": "3c8cbf33403deeb9a7c8860b72f1135cb3d0a960", 5 | "created": false, 6 | "deleted": false, 7 | "forced": false, 8 | "base_ref": null, 9 | "compare": "https://github.com/bkeepers-inc/botland/compare/4512a4e4ee3f...3c8cbf33403d", 10 | "commits": [ 11 | { 12 | "id": "3c8cbf33403deeb9a7c8860b72f1135cb3d0a960", 13 | "tree_id": "0511cfb889506a2864e09040ad9936b422c476a9", 14 | "distinct": true, 15 | "message": "Create settings.yml", 16 | "timestamp": "2017-01-19T23:59:33-06:00", 17 | "url": "https://github.com/bkeepers-inc/botland/commit/3c8cbf33403deeb9a7c8860b72f1135cb3d0a960", 18 | "author": { 19 | "name": "Brandon Keepers", 20 | "email": "bkeepers@github.com", 21 | "username": "bkeepers" 22 | }, 23 | "committer": { 24 | "name": "GitHub", 25 | "email": "noreply@github.com", 26 | "username": "web-flow" 27 | }, 28 | "added": [ 29 | ".github/settings.yml" 30 | ], 31 | "removed": [], 32 | "modified": [] 33 | } 34 | ], 35 | "head_commit": { 36 | "id": "3c8cbf33403deeb9a7c8860b72f1135cb3d0a960", 37 | "tree_id": "0511cfb889506a2864e09040ad9936b422c476a9", 38 | "distinct": true, 39 | "message": "Create settings.yml", 40 | "timestamp": "2017-01-19T23:59:33-06:00", 41 | "url": "https://github.com/bkeepers-inc/botland/commit/3c8cbf33403deeb9a7c8860b72f1135cb3d0a960", 42 | "author": { 43 | "name": "Brandon Keepers", 44 | "email": "bkeepers@github.com", 45 | "username": "bkeepers" 46 | }, 47 | "committer": { 48 | "name": "GitHub", 49 | "email": "noreply@github.com", 50 | "username": "web-flow" 51 | }, 52 | "added": [ 53 | ".github/settings.yml" 54 | ], 55 | "removed": [], 56 | "modified": [] 57 | }, 58 | "repository": { 59 | "id": 68308403, 60 | "name": "botland", 61 | "full_name": "bkeepers-inc/botland", 62 | "owner": { 63 | "name": "bkeepers-inc", 64 | "email": null 65 | }, 66 | "private": true, 67 | "html_url": "https://github.com/bkeepers-inc/botland", 68 | "description": "Playground for testing bots.", 69 | "fork": false, 70 | "url": "https://github.com/bkeepers-inc/botland", 71 | "forks_url": "https://api.github.com/repos/bkeepers-inc/botland/forks", 72 | "keys_url": "https://api.github.com/repos/bkeepers-inc/botland/keys{/key_id}", 73 | "collaborators_url": "https://api.github.com/repos/bkeepers-inc/botland/collaborators{/collaborator}", 74 | "teams_url": "https://api.github.com/repos/bkeepers-inc/botland/teams", 75 | "hooks_url": "https://api.github.com/repos/bkeepers-inc/botland/hooks", 76 | "issue_events_url": "https://api.github.com/repos/bkeepers-inc/botland/issues/events{/number}", 77 | "events_url": "https://api.github.com/repos/bkeepers-inc/botland/events", 78 | "assignees_url": "https://api.github.com/repos/bkeepers-inc/botland/assignees{/user}", 79 | "branches_url": "https://api.github.com/repos/bkeepers-inc/botland/branches{/branch}", 80 | "tags_url": "https://api.github.com/repos/bkeepers-inc/botland/tags", 81 | "blobs_url": "https://api.github.com/repos/bkeepers-inc/botland/git/blobs{/sha}", 82 | "git_tags_url": "https://api.github.com/repos/bkeepers-inc/botland/git/tags{/sha}", 83 | "git_refs_url": "https://api.github.com/repos/bkeepers-inc/botland/git/refs{/sha}", 84 | "trees_url": "https://api.github.com/repos/bkeepers-inc/botland/git/trees{/sha}", 85 | "statuses_url": "https://api.github.com/repos/bkeepers-inc/botland/statuses/{sha}", 86 | "languages_url": "https://api.github.com/repos/bkeepers-inc/botland/languages", 87 | "stargazers_url": "https://api.github.com/repos/bkeepers-inc/botland/stargazers", 88 | "contributors_url": "https://api.github.com/repos/bkeepers-inc/botland/contributors", 89 | "subscribers_url": "https://api.github.com/repos/bkeepers-inc/botland/subscribers", 90 | "subscription_url": "https://api.github.com/repos/bkeepers-inc/botland/subscription", 91 | "commits_url": "https://api.github.com/repos/bkeepers-inc/botland/commits{/sha}", 92 | "git_commits_url": "https://api.github.com/repos/bkeepers-inc/botland/git/commits{/sha}", 93 | "comments_url": "https://api.github.com/repos/bkeepers-inc/botland/comments{/number}", 94 | "issue_comment_url": "https://api.github.com/repos/bkeepers-inc/botland/issues/comments{/number}", 95 | "contents_url": "https://api.github.com/repos/bkeepers-inc/botland/contents/{+path}", 96 | "compare_url": "https://api.github.com/repos/bkeepers-inc/botland/compare/{base}...{head}", 97 | "merges_url": "https://api.github.com/repos/bkeepers-inc/botland/merges", 98 | "archive_url": "https://api.github.com/repos/bkeepers-inc/botland/{archive_format}{/ref}", 99 | "downloads_url": "https://api.github.com/repos/bkeepers-inc/botland/downloads", 100 | "issues_url": "https://api.github.com/repos/bkeepers-inc/botland/issues{/number}", 101 | "pulls_url": "https://api.github.com/repos/bkeepers-inc/botland/pulls{/number}", 102 | "milestones_url": "https://api.github.com/repos/bkeepers-inc/botland/milestones{/number}", 103 | "notifications_url": "https://api.github.com/repos/bkeepers-inc/botland/notifications{?since,all,participating}", 104 | "labels_url": "https://api.github.com/repos/bkeepers-inc/botland/labels{/name}", 105 | "releases_url": "https://api.github.com/repos/bkeepers-inc/botland/releases{/id}", 106 | "deployments_url": "https://api.github.com/repos/bkeepers-inc/botland/deployments", 107 | "created_at": 1473954711, 108 | "updated_at": "2016-12-19T19:41:29Z", 109 | "pushed_at": 1484891974, 110 | "git_url": "git://github.com/bkeepers-inc/botland.git", 111 | "ssh_url": "git@github.com:bkeepers-inc/botland.git", 112 | "clone_url": "https://github.com/bkeepers-inc/botland.git", 113 | "svn_url": "https://github.com/bkeepers-inc/botland", 114 | "homepage": "", 115 | "size": 1, 116 | "stargazers_count": 0, 117 | "watchers_count": 0, 118 | "language": "JavaScript", 119 | "has_issues": true, 120 | "has_downloads": true, 121 | "has_wiki": true, 122 | "has_pages": false, 123 | "forks_count": 0, 124 | "mirror_url": null, 125 | "open_issues_count": 6, 126 | "forks": 0, 127 | "open_issues": 6, 128 | "watchers": 0, 129 | "default_branch": "master", 130 | "stargazers": 0, 131 | "master_branch": "master", 132 | "organization": "bkeepers-inc" 133 | }, 134 | "pusher": { 135 | "name": "bkeepers", 136 | "email": "bkeepers@github.com" 137 | }, 138 | "organization": { 139 | "login": "bkeepers-inc", 140 | "id": 11724939, 141 | "url": "https://api.github.com/orgs/bkeepers-inc", 142 | "repos_url": "https://api.github.com/orgs/bkeepers-inc/repos", 143 | "events_url": "https://api.github.com/orgs/bkeepers-inc/events", 144 | "hooks_url": "https://api.github.com/orgs/bkeepers-inc/hooks", 145 | "issues_url": "https://api.github.com/orgs/bkeepers-inc/issues", 146 | "members_url": "https://api.github.com/orgs/bkeepers-inc/members{/member}", 147 | "public_members_url": "https://api.github.com/orgs/bkeepers-inc/public_members{/member}", 148 | "avatar_url": "https://avatars.githubusercontent.com/u/11724939?v=3", 149 | "description": null 150 | }, 151 | "sender": { 152 | "login": "bkeepers", 153 | "id": 173, 154 | "avatar_url": "https://avatars.githubusercontent.com/u/173?v=3", 155 | "gravatar_id": "", 156 | "url": "https://api.github.com/users/bkeepers", 157 | "html_url": "https://github.com/bkeepers", 158 | "followers_url": "https://api.github.com/users/bkeepers/followers", 159 | "following_url": "https://api.github.com/users/bkeepers/following{/other_user}", 160 | "gists_url": "https://api.github.com/users/bkeepers/gists{/gist_id}", 161 | "starred_url": "https://api.github.com/users/bkeepers/starred{/owner}{/repo}", 162 | "subscriptions_url": "https://api.github.com/users/bkeepers/subscriptions", 163 | "organizations_url": "https://api.github.com/users/bkeepers/orgs", 164 | "repos_url": "https://api.github.com/users/bkeepers/repos", 165 | "events_url": "https://api.github.com/users/bkeepers/events{/privacy}", 166 | "received_events_url": "https://api.github.com/users/bkeepers/received_events", 167 | "type": "User", 168 | "site_admin": true 169 | }, 170 | "installation": { 171 | "id": 6274 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /test/fixtures/events/repository.edited.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "edited", 3 | "changes": { 4 | "default_branch": { 5 | "from": "mvegter-patch-2" 6 | } 7 | }, 8 | "repository": { 9 | "id": 220051293, 10 | "node_id": "MDEwOlJlcG9zaXRvcnkyMjAwNTEyOTM=", 11 | "name": "repo-a", 12 | "full_name": "Martijn-Workspace/repo-a", 13 | "private": false, 14 | "owner": { 15 | "login": "Martijn-Workspace", 16 | "id": 57455045, 17 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjU3NDU1MDQ1", 18 | "avatar_url": "https://avatars0.githubusercontent.com/u/57455045?v=4", 19 | "gravatar_id": "", 20 | "url": "https://api.github.com/users/Martijn-Workspace", 21 | "html_url": "https://github.com/Martijn-Workspace", 22 | "followers_url": "https://api.github.com/users/Martijn-Workspace/followers", 23 | "following_url": "https://api.github.com/users/Martijn-Workspace/following{/other_user}", 24 | "gists_url": "https://api.github.com/users/Martijn-Workspace/gists{/gist_id}", 25 | "starred_url": "https://api.github.com/users/Martijn-Workspace/starred{/owner}{/repo}", 26 | "subscriptions_url": "https://api.github.com/users/Martijn-Workspace/subscriptions", 27 | "organizations_url": "https://api.github.com/users/Martijn-Workspace/orgs", 28 | "repos_url": "https://api.github.com/users/Martijn-Workspace/repos", 29 | "events_url": "https://api.github.com/users/Martijn-Workspace/events{/privacy}", 30 | "received_events_url": "https://api.github.com/users/Martijn-Workspace/received_events", 31 | "type": "Organization", 32 | "site_admin": false 33 | }, 34 | "html_url": "https://github.com/Martijn-Workspace/repo-a", 35 | "description": "description of repo", 36 | "fork": false, 37 | "url": "https://api.github.com/repos/Martijn-Workspace/repo-a", 38 | "forks_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/forks", 39 | "keys_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/keys{/key_id}", 40 | "collaborators_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/collaborators{/collaborator}", 41 | "teams_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/teams", 42 | "hooks_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/hooks", 43 | "issue_events_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/issues/events{/number}", 44 | "events_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/events", 45 | "assignees_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/assignees{/user}", 46 | "branches_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/branches{/branch}", 47 | "tags_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/tags", 48 | "blobs_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/git/blobs{/sha}", 49 | "git_tags_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/git/tags{/sha}", 50 | "git_refs_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/git/refs{/sha}", 51 | "trees_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/git/trees{/sha}", 52 | "statuses_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/statuses/{sha}", 53 | "languages_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/languages", 54 | "stargazers_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/stargazers", 55 | "contributors_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/contributors", 56 | "subscribers_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/subscribers", 57 | "subscription_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/subscription", 58 | "commits_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/commits{/sha}", 59 | "git_commits_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/git/commits{/sha}", 60 | "comments_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/comments{/number}", 61 | "issue_comment_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/issues/comments{/number}", 62 | "contents_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/contents/{+path}", 63 | "compare_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/compare/{base}...{head}", 64 | "merges_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/merges", 65 | "archive_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/{archive_format}{/ref}", 66 | "downloads_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/downloads", 67 | "issues_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/issues{/number}", 68 | "pulls_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/pulls{/number}", 69 | "milestones_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/milestones{/number}", 70 | "notifications_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/notifications{?since,all,participating}", 71 | "labels_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/labels{/name}", 72 | "releases_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/releases{/id}", 73 | "deployments_url": "https://api.github.com/repos/Martijn-Workspace/repo-a/deployments", 74 | "created_at": "2019-11-06T17:19:47Z", 75 | "updated_at": "2019-12-01T16:02:54Z", 76 | "pushed_at": "2019-11-24T11:18:20Z", 77 | "git_url": "git://github.com/Martijn-Workspace/repo-a.git", 78 | "ssh_url": "git@github.com:Martijn-Workspace/repo-a.git", 79 | "clone_url": "https://github.com/Martijn-Workspace/repo-a.git", 80 | "svn_url": "https://github.com/Martijn-Workspace/repo-a", 81 | "homepage": "https://example.github.io/", 82 | "size": 11, 83 | "stargazers_count": 0, 84 | "watchers_count": 0, 85 | "language": null, 86 | "has_issues": false, 87 | "has_projects": true, 88 | "has_downloads": true, 89 | "has_wiki": true, 90 | "has_pages": false, 91 | "forks_count": 0, 92 | "mirror_url": null, 93 | "archived": false, 94 | "disabled": false, 95 | "open_issues_count": 0, 96 | "license": null, 97 | "forks": 0, 98 | "open_issues": 0, 99 | "watchers": 0, 100 | "default_branch": "master" 101 | }, 102 | "organization": { 103 | "login": "Martijn-Workspace", 104 | "id": 57455045, 105 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjU3NDU1MDQ1", 106 | "url": "https://api.github.com/orgs/Martijn-Workspace", 107 | "repos_url": "https://api.github.com/orgs/Martijn-Workspace/repos", 108 | "events_url": "https://api.github.com/orgs/Martijn-Workspace/events", 109 | "hooks_url": "https://api.github.com/orgs/Martijn-Workspace/hooks", 110 | "issues_url": "https://api.github.com/orgs/Martijn-Workspace/issues", 111 | "members_url": "https://api.github.com/orgs/Martijn-Workspace/members{/member}", 112 | "public_members_url": "https://api.github.com/orgs/Martijn-Workspace/public_members{/member}", 113 | "avatar_url": "https://avatars0.githubusercontent.com/u/57455045?v=4", 114 | "description": null 115 | }, 116 | "sender": { 117 | "login": "probot-settings-label[bot]", 118 | "id": 58135831, 119 | "node_id": "MDM6Qm90NTgxMzU4MzE=", 120 | "avatar_url": "https://avatars3.githubusercontent.com/u/25134477?v=4", 121 | "gravatar_id": "", 122 | "url": "https://api.github.com/users/probot-settings-label%5Bbot%5D", 123 | "html_url": "https://github.com/apps/probot-settings-label", 124 | "followers_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/followers", 125 | "following_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/following{/other_user}", 126 | "gists_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/gists{/gist_id}", 127 | "starred_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/starred{/owner}{/repo}", 128 | "subscriptions_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/subscriptions", 129 | "organizations_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/orgs", 130 | "repos_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/repos", 131 | "events_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/events{/privacy}", 132 | "received_events_url": "https://api.github.com/users/probot-settings-label%5Bbot%5D/received_events", 133 | "type": "Bot", 134 | "site_admin": false 135 | }, 136 | "installation": { 137 | "id": 5368170, 138 | "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNTM2ODE3MA==" 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /test/integration/common.js: -------------------------------------------------------------------------------- 1 | import { Probot } from 'probot' 2 | import nock from 'nock' 3 | import any from '@travi/any' 4 | import settingsBot from '../../index' 5 | import settings from '../../lib/settings' 6 | 7 | nock.disableNetConnect() 8 | 9 | const repository = { 10 | default_branch: 'master', 11 | name: 'botland', 12 | owner: { 13 | name: 'bkeepers-inc', 14 | login: 'bkeepers-inc', 15 | email: null 16 | } 17 | } 18 | 19 | function loadInstance () { 20 | const probot = new Probot({ appId: 1, privateKey: 'test', githubToken: 'test' }) 21 | probot.load(settingsBot) 22 | 23 | return probot 24 | } 25 | 26 | function initializeNock () { 27 | return nock('https://api.github.com') 28 | } 29 | 30 | function teardownNock (githubScope) { 31 | expect(githubScope.pendingMocks()).toStrictEqual([]) 32 | 33 | nock.cleanAll() 34 | } 35 | 36 | function buildPushEvent () { 37 | return { 38 | name: 'push', 39 | payload: { 40 | ref: 'refs/heads/master', 41 | repository, 42 | commits: [{ modified: [settings.FILE_NAME], added: [] }] 43 | } 44 | } 45 | } 46 | 47 | function buildRepositoryEditedEvent () { 48 | return { 49 | name: 'repository.edited', 50 | payload: { 51 | changes: { default_branch: { from: any.word() } }, 52 | repository 53 | } 54 | } 55 | } 56 | 57 | function buildRepositoryCreatedEvent () { 58 | return { 59 | name: 'repository.created', 60 | payload: { repository } 61 | } 62 | } 63 | 64 | function buildTriggerEvent () { 65 | return any.fromList([buildPushEvent(), buildRepositoryCreatedEvent(), buildRepositoryEditedEvent()]) 66 | } 67 | 68 | export { 69 | loadInstance, 70 | initializeNock, 71 | teardownNock, 72 | buildTriggerEvent, 73 | buildRepositoryCreatedEvent, 74 | buildRepositoryEditedEvent, 75 | repository 76 | } 77 | -------------------------------------------------------------------------------- /test/integration/features/collaborators.feature: -------------------------------------------------------------------------------- 1 | Feature: Collaborators 2 | 3 | Scenario: Grant Collaborator Access 4 | Given no collaborator has been granted access to the repository 5 | And a collaborator is granted "push" privileges in the config 6 | When a settings sync is triggered 7 | Then the collaborator has "push" access granted to it 8 | 9 | Scenario: Update Collaborator Access 10 | Given a collaborator has been granted "push" privileges to the repository 11 | And the collaborator privileges are updated to "admin" in the config 12 | When a settings sync is triggered 13 | Then the collaborator has "admin" access granted to it 14 | 15 | Scenario: Remove Collaborator Access 16 | Given a collaborator has been granted "push" privileges to the repository 17 | And the collaborator privileges are removed in the config 18 | When a settings sync is triggered 19 | Then the collaborator has privileges to the repo revoked 20 | -------------------------------------------------------------------------------- /test/integration/features/environments.feature: -------------------------------------------------------------------------------- 1 | Feature: Environments 2 | 3 | Scenario: Define an Environment 4 | Given no environments are defined 5 | And an environment is defined in the config 6 | When a settings sync is triggered 7 | Then the environment is available 8 | 9 | Scenario: Update an Environment 10 | Given an environment exists 11 | And the environment is modified in the config 12 | When a settings sync is triggered 13 | Then the environment is updated 14 | 15 | Scenario: Delete an Environment 16 | Given an environment exists 17 | And the environment is removed from the config 18 | When a settings sync is triggered 19 | Then the environment is no longer available 20 | 21 | Scenario: Define an Environment with Reviewers 22 | Given no environments are defined 23 | And an environment is defined in the config with reviewers 24 | When a settings sync is triggered 25 | Then the environment is available with reviewers 26 | 27 | Scenario: Update the reviewer type for an environment 28 | Given an environment exists with reviewers defined 29 | And a reviewer has its type changed 30 | When a settings sync is triggered 31 | Then the reviewer type is updated 32 | 33 | Scenario: Update the id of a reviewer for an environment 34 | Given an environment exists with reviewers defined 35 | And a reviewer has its id changed 36 | When a settings sync is triggered 37 | Then the reviewer id is updated 38 | 39 | Scenario: Add a reviewer to an environment 40 | Given an environment exists 41 | And a reviewer is added to the environment 42 | When a settings sync is triggered 43 | Then the reviewer is defined for the environment 44 | 45 | Scenario: Remove a reviewer from an environment 46 | Given an environment exists with reviewers defined 47 | And a reviewer is removed from the environment in the config 48 | When a settings sync is triggered 49 | Then the reviewer is removed from the environment 50 | 51 | Scenario: Define an Environment with a protected branches Deployment Branch Policy 52 | Given no environments are defined 53 | And an environment is defined in the config with a protected branches deployment branch policy 54 | When a settings sync is triggered 55 | Then the environment is available with a protected branches deployment branch policy 56 | 57 | Scenario: Define an Environment with a custom branches Deployment Branch Policy 58 | Given no environments are defined 59 | And an environment is defined in the config with a custom branches deployment branch policy 60 | When a settings sync is triggered 61 | Then the environment is available with a custom branches deployment branch policy 62 | 63 | Scenario: Define a protected deployment Branch Policy for an exiting environment 64 | Given an environment exists 65 | And a protected deployment branch policy is defined for the environment 66 | When a settings sync is triggered 67 | Then the protected branches deployment branch policy is available for the environment 68 | 69 | Scenario: Define a custom branches Deployment Branch Policy for an exiting environment 70 | Given an environment exists 71 | And a custom deployment branch policy is defined for the environment 72 | When a settings sync is triggered 73 | Then the custom branches deployment branch policy is available for the environment 74 | 75 | Scenario: Update the protected branches Deployment Branch Policy for an environment 76 | Given an environment exists with a "protected" branches deployment branch policy 77 | And a custom deployment branch policy is defined for the environment 78 | When a settings sync is triggered 79 | Then the custom branches deployment branch policy is available for the environment 80 | 81 | Scenario: Update the custom branches Deployment Branch Policy for an environment 82 | Given an environment exists with a "custom" branches deployment branch policy 83 | And a protected deployment branch policy is defined for the environment 84 | When a settings sync is triggered 85 | Then custom deployment branch policies are removed 86 | And the protected branches deployment branch policy is available for the environment 87 | 88 | Scenario: Reviewers are unchanged, but are sorted differently than the api 89 | Given an environment exists with reviewers defined 90 | And an environment is defined in the config with the same reviewers but sorted differently 91 | When a settings sync is triggered 92 | Then no update will happen 93 | 94 | Scenario: Unchanged wait-timer considered equivalent to default 95 | Given an environment exists without wait-timer defined 96 | And wait-timer is not defined for the environment in the config 97 | When a settings sync is triggered 98 | Then no update will happen 99 | 100 | Scenario: Unchanged deployment branch policy 101 | Given an environment exists with a "custom" branches deployment branch policy 102 | And an environment is defined in the config with the same custom branches deployment branch policy but sorted differently 103 | When a settings sync is triggered 104 | Then no update will happen 105 | -------------------------------------------------------------------------------- /test/integration/features/events.feature: -------------------------------------------------------------------------------- 1 | Feature: Events that do not result in a sync 2 | 3 | Scenario: Push to a non-default branch 4 | Given changes to the settings file are to be pushed to a non-default branch 5 | When the settings file changes are pushed 6 | Then a sync does not get triggered 7 | 8 | Scenario: Repository created when repository does not have a settings file 9 | Given the repository has no settings file 10 | When the repository is created 11 | Then a sync does not get triggered 12 | 13 | Scenario: Repository edited, but default branch was not changed 14 | Given the default branch is not changed as part of updating the repository 15 | When the repository is edited 16 | Then a sync does not get triggered 17 | 18 | Scenario: Repository edited when repository does not have a settings file 19 | Given the repository has no settings file 20 | When the repository is edited 21 | Then a sync does not get triggered 22 | -------------------------------------------------------------------------------- /test/integration/features/labels.feature: -------------------------------------------------------------------------------- 1 | Feature: Labels 2 | 3 | Scenario: Create Label 4 | Given no labels exist 5 | And a label is added 6 | When a settings sync is triggered 7 | Then the label is available 8 | 9 | Scenario: Create Label with leading `#` on the color code 10 | Given no labels exist 11 | And a label is added with a leading `#` on the color code 12 | When a settings sync is triggered 13 | Then the label is available 14 | 15 | Scenario: Update Label Color 16 | Given a label exists 17 | And the color is updated on the existing label 18 | When a settings sync is triggered 19 | Then the label has the updated color 20 | 21 | @wip 22 | Scenario: Update Label Color with color led by `#` 23 | 24 | @wip 25 | Scenario: Rename a Label 26 | Given a label exists 27 | And the name is updated on the existing label 28 | When a settings sync is triggered 29 | Then the label has the updated color 30 | 31 | @wip 32 | Scenario: Label with color matching config if `#` were stripped 33 | Then no call to update the color is performed 34 | 35 | @wip 36 | Scenario: Config suggests a name update that has already happened 37 | Then no call to update the name is performed 38 | 39 | Scenario: Remove Label 40 | Given a label exists 41 | And the label is removed from the config 42 | When a settings sync is triggered 43 | Then the label is no longer available 44 | 45 | @wip 46 | Scenario: Label with a short color code 47 | 48 | @wip 49 | Scenario: Label with numerical color code 50 | -------------------------------------------------------------------------------- /test/integration/features/milestones.feature: -------------------------------------------------------------------------------- 1 | Feature: Milestones 2 | 3 | Scenario: Add Milestone 4 | Given no milestones exist 5 | And a milestone is added 6 | When a settings sync is triggered 7 | Then the milestone is available 8 | 9 | Scenario: Update a Milestone 10 | Given a milestone exists 11 | And the milestone is updated in the config 12 | When a settings sync is triggered 13 | Then updated milestone is available 14 | 15 | Scenario: Delete a milestone 16 | Given a milestone exists 17 | And the milestone is removed from the config 18 | When a settings sync is triggered 19 | Then the milestone is no longer available 20 | -------------------------------------------------------------------------------- /test/integration/features/repository.feature: -------------------------------------------------------------------------------- 1 | Feature: Repository 2 | 3 | Scenario: Basic repository settings 4 | Given basic repository config is defined 5 | When a settings sync is triggered 6 | Then the repository will be configured 7 | 8 | Scenario: Repository with topics defined 9 | Given topics are defined in the repository config 10 | When a settings sync is triggered 11 | Then topics are updated 12 | 13 | Scenario: Repository with vulnerability alerts enabled 14 | Given vulnerability alerts are "enabled" in the config 15 | When a settings sync is triggered 16 | Then vulnerability alerts are "enabled" 17 | 18 | Scenario: Repository with vulnerability alerts disabled 19 | Given vulnerability alerts are "disabled" in the config 20 | When a settings sync is triggered 21 | Then vulnerability alerts are "disabled" 22 | 23 | Scenario: Repository with security fixes enabled 24 | Given security fixes are "enabled" in the config 25 | When a settings sync is triggered 26 | Then security fixes are "enabled" 27 | 28 | Scenario: Repository with security fixes disabled 29 | Given security fixes are "disabled" in the config 30 | When a settings sync is triggered 31 | Then security fixes are "disabled" 32 | -------------------------------------------------------------------------------- /test/integration/features/rulesets.feature: -------------------------------------------------------------------------------- 1 | Feature: Repository Rulesets 2 | 3 | Scenario: Add a ruleset 4 | Given no rulesets are defined for the repository 5 | And a ruleset is defined in the config 6 | When a settings sync is triggered 7 | Then the ruleset is enabled for the repository 8 | 9 | Scenario: Update a ruleset 10 | Given a ruleset exists for the repository 11 | And the ruleset is modified in the config 12 | When a settings sync is triggered 13 | Then the ruleset is updated 14 | 15 | Scenario: Delete a ruleset 16 | Given a ruleset exists for the repository 17 | And the ruleset is removed from the config 18 | When a settings sync is triggered 19 | Then the ruleset is deleted 20 | 21 | Scenario: No Updates 22 | Given a ruleset exists for the repository 23 | And no ruleset updates are made to the config 24 | When a settings sync is triggered 25 | Then no ruleset updates are triggered 26 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/collaborators-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | 4 | import { Given, Then } from '@cucumber/cucumber' 5 | import assert from 'node:assert' 6 | import { http, HttpResponse } from 'msw' 7 | import any from '@travi/any' 8 | 9 | import settings from '../../../../lib/settings.js' 10 | 11 | import { repository } from './common-steps.js' 12 | 13 | const collaboratorLogin = any.word() 14 | 15 | Given('no collaborator has been granted access to the repository', async function () { 16 | this.server.use( 17 | http.get( 18 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/collaborators`, 19 | ({ request }) => { 20 | const url = new URL(request.url) 21 | const collaboratorAffiliation = url.searchParams.get('affiliation') 22 | 23 | if (collaboratorAffiliation === 'direct') { 24 | return HttpResponse.json([]) 25 | } 26 | } 27 | ) 28 | ) 29 | }) 30 | 31 | Given('a collaborator has been granted {string} privileges to the repository', async function (accessLevel) { 32 | this.server.use( 33 | http.get( 34 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/collaborators`, 35 | ({ request }) => { 36 | const url = new URL(request.url) 37 | const collaboratorAffiliation = url.searchParams.get('affiliation') 38 | 39 | if (collaboratorAffiliation === 'direct') { 40 | return HttpResponse.json([{ login: collaboratorLogin, permissions: { [accessLevel]: true } }]) 41 | } 42 | } 43 | ) 44 | ) 45 | }) 46 | 47 | Given('a collaborator is granted {string} privileges in the config', async function (accessLevel) { 48 | this.server.use( 49 | http.get( 50 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 51 | settings.FILE_NAME 52 | )}`, 53 | ({ request }) => { 54 | return HttpResponse.arrayBuffer( 55 | Buffer.from(dump({ collaborators: [{ username: collaboratorLogin, permission: accessLevel }] })) 56 | ) 57 | } 58 | ), 59 | http.put( 60 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/collaborators/${collaboratorLogin}`, 61 | async ({ request }) => { 62 | this.collaboratorPermissionLevel = (await request.json()).permission 63 | 64 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 65 | } 66 | ) 67 | ) 68 | }) 69 | 70 | Given('the collaborator privileges are updated to {string} in the config', async function (accessLevel) { 71 | this.server.use( 72 | http.get( 73 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 74 | settings.FILE_NAME 75 | )}`, 76 | ({ request }) => { 77 | return HttpResponse.arrayBuffer( 78 | Buffer.from(dump({ collaborators: [{ username: collaboratorLogin, permission: accessLevel }] })) 79 | ) 80 | } 81 | ), 82 | http.put( 83 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/collaborators/${collaboratorLogin}`, 84 | async ({ request }) => { 85 | this.collaboratorPermissionLevel = (await request.json()).permission 86 | 87 | return new HttpResponse(null, { status: StatusCodes.OK }) 88 | } 89 | ) 90 | ) 91 | }) 92 | 93 | Given('the collaborator privileges are removed in the config', async function () { 94 | this.server.use( 95 | http.get( 96 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 97 | settings.FILE_NAME 98 | )}`, 99 | ({ request }) => { 100 | return HttpResponse.arrayBuffer(Buffer.from(dump({ collaborators: [] }))) 101 | } 102 | ), 103 | http.delete( 104 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/collaborators/:collaboratorLogin`, 105 | async ({ params }) => { 106 | this.removedCollaboratorLogin = params.collaboratorLogin 107 | 108 | return new HttpResponse(null, { status: StatusCodes.NO_CONTENT }) 109 | } 110 | ) 111 | ) 112 | }) 113 | 114 | Then('the collaborator has {string} access granted to it', async function (accessLevel) { 115 | assert.equal(this.collaboratorPermissionLevel, accessLevel) 116 | }) 117 | 118 | Then('the collaborator has privileges to the repo revoked', async function () { 119 | assert.equal(this.removedCollaboratorLogin, collaboratorLogin) 120 | }) 121 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/common-steps.js: -------------------------------------------------------------------------------- 1 | import { Probot, ProbotOctokit } from 'probot' 2 | 3 | import any from '@travi/any' 4 | import { Before, When } from '@cucumber/cucumber' 5 | import settingsBot from '../../../../index.js' 6 | import { buildRepositoryCreatedEvent, buildRepositoryEditedEvent } from './repository-events-steps.js' 7 | import { buildPushEvent } from './config-steps.js' 8 | 9 | export const repository = { 10 | default_branch: 'master', 11 | name: 'botland', 12 | owner: { 13 | name: 'bkeepers-inc', 14 | login: 'bkeepers-inc', 15 | email: null 16 | } 17 | } 18 | 19 | async function loadInstance () { 20 | const probot = new Probot({ 21 | appId: 1, 22 | privateKey: 'test', 23 | githubToken: 'test', 24 | Octokit: ProbotOctokit.defaults(instanceOptions => ({ 25 | ...instanceOptions, 26 | retry: { enabled: false }, 27 | throttle: { enabled: false } 28 | })) 29 | }) 30 | await probot.load(settingsBot) 31 | 32 | return probot 33 | } 34 | 35 | function buildTriggerEvent () { 36 | return any.fromList([buildPushEvent(), buildRepositoryCreatedEvent(), buildRepositoryEditedEvent()]) 37 | } 38 | 39 | Before(async function () { 40 | this.probot = await loadInstance() 41 | }) 42 | 43 | When('a settings sync is triggered', async function () { 44 | await this.probot.receive(buildTriggerEvent()) 45 | }) 46 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/config-steps.js: -------------------------------------------------------------------------------- 1 | import { Given, Then, When } from '@cucumber/cucumber' 2 | 3 | import settings from '../../../../lib/settings.js' 4 | import any from '@travi/any' 5 | import { http, HttpResponse } from 'msw' 6 | import { StatusCodes } from 'http-status-codes' 7 | import { repository } from './common-steps.js' 8 | 9 | export function buildPushEvent ({ pushBranch } = {}) { 10 | return { 11 | name: 'push', 12 | payload: { 13 | ref: `refs/heads/${pushBranch || repository.default_branch}`, 14 | repository, 15 | commits: [{ modified: [settings.FILE_NAME], added: [] }] 16 | } 17 | } 18 | } 19 | 20 | Given('the repository has no settings file', async function () { 21 | this.server.use( 22 | http.get( 23 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 24 | settings.FILE_NAME 25 | )}`, 26 | ({ request }) => { 27 | return new HttpResponse(null, { status: StatusCodes.NOT_FOUND }) 28 | } 29 | ), 30 | http.get( 31 | `https://api.github.com/repos/${repository.owner.name}/.github/contents/${encodeURIComponent( 32 | settings.FILE_NAME 33 | )}`, 34 | ({ request }) => { 35 | return new HttpResponse(null, { status: StatusCodes.NOT_FOUND }) 36 | } 37 | ) 38 | ) 39 | }) 40 | 41 | Given('changes to the settings file are to be pushed to a non-default branch', async function () { 42 | this.pushBranch = any.word() 43 | }) 44 | 45 | When('the settings file changes are pushed', async function () { 46 | await this.probot.receive(buildPushEvent({ pushBranch: this.pushBranch })) 47 | }) 48 | 49 | Then('a sync does not get triggered', async function () { 50 | // a call to an unexpected endpoint will error, so lack of an error satisfies this step 51 | 52 | return undefined 53 | }) 54 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/environments-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | 4 | import { Given, Then } from '@cucumber/cucumber' 5 | import { http, HttpResponse } from 'msw' 6 | import any from '@travi/any' 7 | import assert from 'node:assert' 8 | 9 | import { repository } from './common-steps.js' 10 | import settings from '../../../../lib/settings.js' 11 | 12 | const possibleReviewerTypes = ['User', 'Team'] 13 | 14 | function anyReviewer () { 15 | return { id: any.integer(), type: any.fromList(possibleReviewerTypes) } 16 | } 17 | 18 | Given('no environments are defined', async function () { 19 | this.server.use( 20 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments`, () => { 21 | return HttpResponse.json({ environments: [] }) 22 | }) 23 | ) 24 | }) 25 | 26 | Given('an environment exists', async function () { 27 | this.environment = { name: any.word(), wait_timer: any.integer(), deployment_branch_policy: null } 28 | 29 | this.server.use( 30 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments`, () => { 31 | return HttpResponse.json({ environments: [this.environment] }) 32 | }) 33 | ) 34 | }) 35 | 36 | Given('an environment exists without wait-timer defined', async function () { 37 | this.environment = { name: any.word(), deployment_branch_policy: null } 38 | 39 | this.server.use( 40 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments`, () => { 41 | return HttpResponse.json({ environments: [this.environment] }) 42 | }) 43 | ) 44 | }) 45 | 46 | Given('an environment exists with reviewers defined', async function () { 47 | this.environment = { 48 | name: any.word(), 49 | wait_timer: any.integer(), 50 | deployment_branch_policy: null, 51 | reviewers: any.listOf(anyReviewer) 52 | } 53 | 54 | this.server.use( 55 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments`, () => { 56 | return HttpResponse.json({ environments: [this.environment] }) 57 | }) 58 | ) 59 | }) 60 | 61 | Given('an environment exists with a {string} branches deployment branch policy', async function (policyType) { 62 | this.environment = { 63 | name: any.word(), 64 | wait_timer: any.integer(), 65 | deployment_branch_policy: { 66 | protected_branches: policyType === 'protected', 67 | custom_branch_policies: policyType === 'custom' 68 | } 69 | } 70 | 71 | this.server.use( 72 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments`, () => { 73 | return HttpResponse.json({ environments: [this.environment] }) 74 | }) 75 | ) 76 | 77 | if (policyType === 'custom') { 78 | this.customBranches = any.listOf(() => ({ 79 | name: any.word(), 80 | id: any.integer(), 81 | type: any.fromList(['branch']) 82 | })) 83 | this.removedDeploymentBranchPolicyIds = {} 84 | 85 | this.server.use( 86 | http.get( 87 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}/deployment-branch-policies`, 88 | () => { 89 | return HttpResponse.json({ branch_policies: this.customBranches }) 90 | } 91 | ), 92 | http.delete( 93 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}/deployment-branch-policies/:id`, 94 | ({ params }) => { 95 | this.removedDeploymentBranchPolicyIds[params.id] = true 96 | 97 | return new HttpResponse(null, { status: StatusCodes.NO_CONTENT }) 98 | } 99 | ) 100 | ) 101 | } 102 | }) 103 | 104 | Given('an environment is defined in the config', async function () { 105 | this.environmentName = any.word() 106 | 107 | this.server.use( 108 | http.get( 109 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 110 | settings.FILE_NAME 111 | )}`, 112 | ({ request }) => { 113 | return HttpResponse.arrayBuffer(Buffer.from(dump({ environments: [{ name: this.environmentName }] }))) 114 | } 115 | ), 116 | http.put( 117 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environmentName}`, 118 | async ({ params, request }) => { 119 | this.createdEnvironment = await request.json() 120 | 121 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 122 | } 123 | ) 124 | ) 125 | }) 126 | 127 | Given('an environment is defined in the config with reviewers', async function () { 128 | this.environmentName = any.word() 129 | this.reviewers = any.listOf(anyReviewer) 130 | 131 | this.server.use( 132 | http.get( 133 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 134 | settings.FILE_NAME 135 | )}`, 136 | ({ request }) => { 137 | return HttpResponse.arrayBuffer( 138 | Buffer.from(dump({ environments: [{ name: this.environmentName, reviewers: this.reviewers }] })) 139 | ) 140 | } 141 | ), 142 | http.put( 143 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environmentName}`, 144 | async ({ params, request }) => { 145 | this.createdEnvironment = await request.json() 146 | 147 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 148 | } 149 | ) 150 | ) 151 | }) 152 | 153 | Given('wait-timer is not defined for the environment in the config', async function () { 154 | const { wait_timer: waitTimer, ...environmentWithoutWaitTimer } = this.environment 155 | 156 | this.server.use( 157 | http.get( 158 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 159 | settings.FILE_NAME 160 | )}`, 161 | ({ request }) => { 162 | return HttpResponse.arrayBuffer( 163 | Buffer.from( 164 | dump({ 165 | environments: [environmentWithoutWaitTimer] 166 | }) 167 | ) 168 | ) 169 | } 170 | ) 171 | ) 172 | }) 173 | 174 | Given('the environment is modified in the config', async function () { 175 | this.environmentUpdates = { wait_timer: any.integer(), deployment_branch_policy: null } 176 | 177 | this.server.use( 178 | http.get( 179 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 180 | settings.FILE_NAME 181 | )}`, 182 | ({ request }) => { 183 | return HttpResponse.arrayBuffer( 184 | Buffer.from( 185 | dump({ 186 | environments: [{ name: this.environment.name, ...this.environmentUpdates }] 187 | }) 188 | ) 189 | ) 190 | } 191 | ), 192 | http.put( 193 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 194 | async ({ request }) => { 195 | this.updatedEnvironment = await request.json() 196 | 197 | return new HttpResponse(null, { status: StatusCodes.OK }) 198 | } 199 | ) 200 | ) 201 | }) 202 | 203 | Given('the environment is removed from the config', async function () { 204 | this.server.use( 205 | http.get( 206 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 207 | settings.FILE_NAME 208 | )}`, 209 | ({ request }) => { 210 | return HttpResponse.arrayBuffer(Buffer.from(dump({ environments: [] }))) 211 | } 212 | ), 213 | http.delete( 214 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/:environmentName`, 215 | async ({ params }) => { 216 | this.removedEnvironment = params.environmentName 217 | 218 | return new HttpResponse(null, { status: StatusCodes.NO_CONTENT }) 219 | } 220 | ) 221 | ) 222 | }) 223 | 224 | Given('a reviewer has its type changed', async function () { 225 | const [reviewerToBeUpdated, ...unchangedReviewers] = this.environment.reviewers 226 | const alternativeType = possibleReviewerTypes.find(type => type !== reviewerToBeUpdated.type) 227 | this.updatedReviewer = { ...reviewerToBeUpdated, type: alternativeType } 228 | 229 | this.server.use( 230 | http.get( 231 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 232 | settings.FILE_NAME 233 | )}`, 234 | ({ request }) => { 235 | return HttpResponse.arrayBuffer( 236 | Buffer.from( 237 | dump({ 238 | environments: [{ ...this.environment, reviewers: [...unchangedReviewers, this.updatedReviewer] }] 239 | }) 240 | ) 241 | ) 242 | } 243 | ), 244 | http.put( 245 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 246 | async ({ request }) => { 247 | this.updatedEnvironment = await request.json() 248 | 249 | return new HttpResponse(null, { status: StatusCodes.OK }) 250 | } 251 | ) 252 | ) 253 | }) 254 | 255 | Given('a reviewer has its id changed', async function () { 256 | const [reviewerToBeUpdated, ...unchangedReviewers] = this.environment.reviewers 257 | this.updatedReviewer = { ...reviewerToBeUpdated, id: any.integer() } 258 | 259 | this.server.use( 260 | http.get( 261 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 262 | settings.FILE_NAME 263 | )}`, 264 | ({ request }) => { 265 | return HttpResponse.arrayBuffer( 266 | Buffer.from( 267 | dump({ 268 | environments: [{ ...this.environment, reviewers: [...unchangedReviewers, this.updatedReviewer] }] 269 | }) 270 | ) 271 | ) 272 | } 273 | ), 274 | http.put( 275 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 276 | async ({ request }) => { 277 | this.updatedEnvironment = await request.json() 278 | 279 | return new HttpResponse(null, { status: StatusCodes.OK }) 280 | } 281 | ) 282 | ) 283 | }) 284 | 285 | Given('a reviewer is added to the environment', async function () { 286 | this.addedReviewer = anyReviewer() 287 | 288 | this.server.use( 289 | http.get( 290 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 291 | settings.FILE_NAME 292 | )}`, 293 | ({ request }) => { 294 | return HttpResponse.arrayBuffer( 295 | Buffer.from( 296 | dump({ 297 | environments: [{ ...this.environment, reviewers: [this.addedReviewer] }] 298 | }) 299 | ) 300 | ) 301 | } 302 | ), 303 | http.put( 304 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 305 | async ({ request }) => { 306 | this.updatedEnvironment = await request.json() 307 | 308 | return new HttpResponse(null, { status: StatusCodes.OK }) 309 | } 310 | ) 311 | ) 312 | }) 313 | 314 | Given('a reviewer is removed from the environment in the config', async function () { 315 | const [removedReviewer, ...remainingReviewers] = this.environment.reviewers 316 | this.removedReviewer = removedReviewer 317 | 318 | this.server.use( 319 | http.get( 320 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 321 | settings.FILE_NAME 322 | )}`, 323 | ({ request }) => { 324 | return HttpResponse.arrayBuffer( 325 | Buffer.from( 326 | dump({ 327 | environments: [{ ...this.environment, reviewers: remainingReviewers }] 328 | }) 329 | ) 330 | ) 331 | } 332 | ), 333 | http.put( 334 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 335 | async ({ request }) => { 336 | this.updatedEnvironment = await request.json() 337 | 338 | return new HttpResponse(null, { status: StatusCodes.OK }) 339 | } 340 | ) 341 | ) 342 | }) 343 | 344 | Given('an environment is defined in the config with a protected branches deployment branch policy', async function () { 345 | this.environmentName = any.word() 346 | 347 | this.server.use( 348 | http.get( 349 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 350 | settings.FILE_NAME 351 | )}`, 352 | ({ request }) => { 353 | return HttpResponse.arrayBuffer( 354 | Buffer.from( 355 | dump({ 356 | environments: [ 357 | { 358 | name: this.environmentName, 359 | deployment_branch_policy: { protected_branches: true } 360 | } 361 | ] 362 | }) 363 | ) 364 | ) 365 | } 366 | ), 367 | http.put( 368 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environmentName}`, 369 | async ({ request }) => { 370 | this.savedEnvironment = await request.json() 371 | 372 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 373 | } 374 | ) 375 | ) 376 | }) 377 | 378 | Given('an environment is defined in the config with a custom branches deployment branch policy', async function () { 379 | this.environmentName = any.word() 380 | this.customBranches = any.listOf(() => ({ 381 | name: any.word(), 382 | id: any.integer(), 383 | type: any.fromList(['branch']) 384 | })) 385 | this.customBranchNames = this.customBranches.map(branch => branch.name) 386 | this.createdDeploymentBranchPolicyNames = {} 387 | 388 | this.server.use( 389 | http.get( 390 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 391 | settings.FILE_NAME 392 | )}`, 393 | ({ request }) => { 394 | return HttpResponse.arrayBuffer( 395 | Buffer.from( 396 | dump({ 397 | environments: [ 398 | { 399 | name: this.environmentName, 400 | deployment_branch_policy: { protected_branches: false, custom_branches: this.customBranches } 401 | } 402 | ] 403 | }) 404 | ) 405 | ) 406 | } 407 | ), 408 | http.put( 409 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environmentName}`, 410 | async ({ request }) => { 411 | this.savedEnvironment = await request.json() 412 | 413 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 414 | } 415 | ), 416 | http.post( 417 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environmentName}/deployment-branch-policies`, 418 | async ({ request }) => { 419 | const policyName = (await request.json()).name 420 | this.createdDeploymentBranchPolicyNames[policyName] = true 421 | 422 | return new HttpResponse(null, { status: StatusCodes.OK }) 423 | } 424 | ) 425 | ) 426 | }) 427 | 428 | Given('a protected deployment branch policy is defined for the environment', async function () { 429 | this.server.use( 430 | http.get( 431 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 432 | settings.FILE_NAME 433 | )}`, 434 | ({ request }) => { 435 | return HttpResponse.arrayBuffer( 436 | Buffer.from( 437 | dump({ 438 | environments: [ 439 | { 440 | ...this.environment, 441 | deployment_branch_policy: { protected_branches: true } 442 | } 443 | ] 444 | }) 445 | ) 446 | ) 447 | } 448 | ), 449 | http.put( 450 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 451 | async ({ request }) => { 452 | this.savedEnvironment = await request.json() 453 | 454 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 455 | } 456 | ) 457 | ) 458 | }) 459 | 460 | Given('a custom deployment branch policy is defined for the environment', async function () { 461 | this.customBranchNames = any.listOf(any.word) 462 | this.createdDeploymentBranchPolicyNames = {} 463 | 464 | this.server.use( 465 | http.get( 466 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 467 | settings.FILE_NAME 468 | )}`, 469 | ({ request }) => { 470 | return HttpResponse.arrayBuffer( 471 | Buffer.from( 472 | dump({ 473 | environments: [ 474 | { 475 | ...this.environment, 476 | deployment_branch_policy: { protected_branches: false, custom_branches: this.customBranchNames } 477 | } 478 | ] 479 | }) 480 | ) 481 | ) 482 | } 483 | ), 484 | http.put( 485 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}`, 486 | async ({ request }) => { 487 | this.savedEnvironment = await request.json() 488 | 489 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 490 | } 491 | ), 492 | http.post( 493 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/environments/${this.environment.name}/deployment-branch-policies`, 494 | async ({ request }) => { 495 | const policyName = (await request.json()).name 496 | this.createdDeploymentBranchPolicyNames[policyName] = true 497 | 498 | return new HttpResponse(null, { status: StatusCodes.OK }) 499 | } 500 | ) 501 | ) 502 | }) 503 | 504 | Given('an environment is defined in the config with the same reviewers but sorted differently', async function () { 505 | const ascendingIdSortedReviewers = this.environment.reviewers.sort((a, b) => a.id - b.id) 506 | 507 | this.server.use( 508 | http.get( 509 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 510 | settings.FILE_NAME 511 | )}`, 512 | ({ request }) => { 513 | return HttpResponse.arrayBuffer( 514 | Buffer.from(dump({ environments: [{ ...this.environment, reviewers: ascendingIdSortedReviewers }] })) 515 | ) 516 | } 517 | ) 518 | ) 519 | }) 520 | 521 | Given( 522 | 'an environment is defined in the config with the same custom branches deployment branch policy but sorted differently', 523 | async function () { 524 | this.server.use( 525 | http.get( 526 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 527 | settings.FILE_NAME 528 | )}`, 529 | ({ request }) => { 530 | return HttpResponse.arrayBuffer( 531 | Buffer.from( 532 | dump({ 533 | environments: [ 534 | { 535 | ...this.environment, 536 | deployment_branch_policy: { 537 | protected_branches: false, 538 | custom_branches: this.customBranches.map(branch => branch.name).reverse() 539 | } 540 | } 541 | ] 542 | }) 543 | ) 544 | ) 545 | } 546 | ) 547 | ) 548 | } 549 | ) 550 | 551 | Then('the environment is available', async function () { 552 | assert.deepEqual(this.createdEnvironment, { deployment_branch_policy: null }) 553 | }) 554 | 555 | Then('the environment is available with reviewers', async function () { 556 | assert.deepEqual(this.createdEnvironment, { deployment_branch_policy: null, reviewers: this.reviewers }) 557 | }) 558 | 559 | Then('the environment is updated', async function () { 560 | assert.deepEqual(this.updatedEnvironment, this.environmentUpdates) 561 | }) 562 | 563 | Then('the environment is no longer available', async function () { 564 | assert.equal(this.removedEnvironment, this.environment.name) 565 | }) 566 | 567 | Then('the reviewer type is updated', async function () { 568 | assert.equal(this.updatedEnvironment.reviewers.length, this.environment.reviewers.length) 569 | assert.deepEqual( 570 | this.updatedEnvironment.reviewers.find(reviewer => reviewer.id === this.updatedReviewer.id).type, 571 | this.updatedReviewer.type 572 | ) 573 | }) 574 | 575 | Then('the reviewer id is updated', async function () { 576 | assert.equal(this.updatedEnvironment.reviewers.length, this.environment.reviewers.length) 577 | assert.deepEqual( 578 | this.updatedEnvironment.reviewers.find(reviewer => reviewer.id === this.updatedReviewer.id), 579 | this.updatedReviewer 580 | ) 581 | }) 582 | 583 | Then('the reviewer is defined for the environment', async function () { 584 | assert.deepEqual(this.updatedEnvironment.reviewers, [this.addedReviewer]) 585 | }) 586 | 587 | Then('the reviewer is removed from the environment', async function () { 588 | assert.equal(this.updatedEnvironment.reviewers.length, this.environment.reviewers.length - 1) 589 | assert.equal( 590 | this.updatedEnvironment.reviewers.find(reviewer => reviewer.id === this.removedReviewer.id), 591 | undefined 592 | ) 593 | }) 594 | 595 | Then('the environment is available with a protected branches deployment branch policy', async function () { 596 | assert.deepEqual(this.savedEnvironment, { 597 | deployment_branch_policy: { protected_branches: true, custom_branch_policies: false } 598 | }) 599 | }) 600 | 601 | Then('the protected branches deployment branch policy is available for the environment', async function () { 602 | const { name, deployment_branch_policy: policy, ...existingEnvironment } = this.environment 603 | 604 | assert.deepEqual(this.savedEnvironment, { 605 | ...existingEnvironment, 606 | deployment_branch_policy: { protected_branches: true, custom_branch_policies: false } 607 | }) 608 | }) 609 | 610 | Then('the environment is available with a custom branches deployment branch policy', async function () { 611 | assert.deepEqual(this.savedEnvironment, { 612 | deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } 613 | }) 614 | assert.deepEqual(this.customBranchNames, Object.keys(this.createdDeploymentBranchPolicyNames)) 615 | }) 616 | 617 | Then('the custom branches deployment branch policy is available for the environment', async function () { 618 | const { name, deployment_branch_policy: policy, ...existingEnvironment } = this.environment 619 | 620 | assert.deepEqual(this.savedEnvironment, { 621 | ...existingEnvironment, 622 | deployment_branch_policy: { protected_branches: false, custom_branch_policies: true } 623 | }) 624 | assert.deepEqual(this.customBranchNames.sort(), Object.keys(this.createdDeploymentBranchPolicyNames)) 625 | }) 626 | 627 | Then('custom deployment branch policies are removed', async function () { 628 | assert.deepEqual( 629 | Object.keys(this.removedDeploymentBranchPolicyIds), 630 | this.customBranches.map(branch => branch.id) 631 | ) 632 | }) 633 | 634 | Then('no update will happen', async function () { 635 | // absence of an error means no update calls were made 636 | return undefined 637 | }) 638 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/github-api-steps.js: -------------------------------------------------------------------------------- 1 | import { After, AfterAll, Before, BeforeAll } from '@cucumber/cucumber' 2 | import { setupServer } from 'msw/node' 3 | import any from '@travi/any' 4 | 5 | const server = setupServer() 6 | export const githubToken = any.word() 7 | 8 | BeforeAll(async function () { 9 | server.listen() 10 | }) 11 | 12 | Before(function () { 13 | this.server = server 14 | }) 15 | 16 | After(function () { 17 | server.resetHandlers() 18 | }) 19 | 20 | AfterAll(function () { 21 | server.close() 22 | }) 23 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/labels-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | 4 | import any from '@travi/any' 5 | import { Given, Then } from '@cucumber/cucumber' 6 | import { http, HttpResponse } from 'msw' 7 | import assert from 'node:assert' 8 | 9 | import settings from '../../../../lib/settings.js' 10 | 11 | import { repository } from './common-steps.js' 12 | 13 | Given('no labels exist', async function () { 14 | this.server.use( 15 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/labels`, ({ request }) => { 16 | return HttpResponse.json([]) 17 | }) 18 | ) 19 | }) 20 | 21 | Given('a label exists', async function () { 22 | this.label = { name: any.word(), color: any.word() } 23 | 24 | this.server.use( 25 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/labels`, ({ request }) => { 26 | return HttpResponse.json([this.label]) 27 | }) 28 | ) 29 | }) 30 | 31 | Given('a label is added', async function () { 32 | this.label = { name: any.word(), color: any.word() } 33 | 34 | this.server.use( 35 | http.get( 36 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 37 | settings.FILE_NAME 38 | )}`, 39 | ({ request }) => { 40 | return HttpResponse.arrayBuffer(Buffer.from(dump({ labels: [this.label] }))) 41 | } 42 | ), 43 | http.post( 44 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/labels`, 45 | async ({ request }) => { 46 | this.savedLabel = await request.json() 47 | 48 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 49 | } 50 | ) 51 | ) 52 | }) 53 | 54 | Given('a label is added with a leading `#` on the color code', async function () { 55 | this.label = { name: any.word(), color: any.word() } 56 | 57 | this.server.use( 58 | http.get( 59 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 60 | settings.FILE_NAME 61 | )}`, 62 | ({ request }) => { 63 | return HttpResponse.arrayBuffer( 64 | Buffer.from(dump({ labels: [{ ...this.label, color: `#${this.label.color}` }] })) 65 | ) 66 | } 67 | ), 68 | http.post( 69 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/labels`, 70 | async ({ request }) => { 71 | this.savedLabel = await request.json() 72 | 73 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 74 | } 75 | ) 76 | ) 77 | }) 78 | 79 | Given('the color is updated on the existing label', async function () { 80 | this.newColor = any.word() 81 | 82 | this.server.use( 83 | http.get( 84 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 85 | settings.FILE_NAME 86 | )}`, 87 | ({ request }) => { 88 | return HttpResponse.arrayBuffer( 89 | Buffer.from(dump({ labels: [{ name: this.label.name, color: this.newColor }] })) 90 | ) 91 | } 92 | ), 93 | http.patch( 94 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/labels/${this.label.name}`, 95 | async ({ request }) => { 96 | this.updatedColor = (await request.json()).color 97 | 98 | return new HttpResponse(null, { status: StatusCodes.OK }) 99 | } 100 | ) 101 | ) 102 | }) 103 | 104 | Given('the label is removed from the config', async function () { 105 | this.server.use( 106 | http.get( 107 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 108 | settings.FILE_NAME 109 | )}`, 110 | ({ request }) => { 111 | return HttpResponse.arrayBuffer(Buffer.from(dump({ labels: [] }))) 112 | } 113 | ), 114 | http.delete( 115 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/labels/:labelName`, 116 | async ({ params }) => { 117 | this.removedLabel = params.labelName 118 | 119 | return new HttpResponse(null, { status: StatusCodes.NO_CONTENT }) 120 | } 121 | ) 122 | ) 123 | }) 124 | 125 | Then('the label is available', async function () { 126 | assert.deepEqual(this.savedLabel, this.label) 127 | }) 128 | 129 | Then('the label has the updated color', async function () { 130 | assert.equal(this.updatedColor, this.newColor) 131 | }) 132 | 133 | Then('the label is no longer available', async function () { 134 | assert.deepEqual(this.removedLabel, this.label.name) 135 | }) 136 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/milestones-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | 4 | import { Given, Then } from '@cucumber/cucumber' 5 | import { http, HttpResponse } from 'msw' 6 | import assert from 'node:assert' 7 | import any from '@travi/any' 8 | 9 | import settings from '../../../../lib/settings.js' 10 | 11 | import { repository } from './common-steps.js' 12 | 13 | Given('no milestones exist', async function () { 14 | this.server.use( 15 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/milestones`, ({ request }) => { 16 | return HttpResponse.json([]) 17 | }) 18 | ) 19 | }) 20 | Given('a milestone exists', async function () { 21 | this.milestone = { title: any.word(), description: any.sentence(), state: any.word(), number: any.integer() } 22 | 23 | this.server.use( 24 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/milestones`, ({ request }) => { 25 | return HttpResponse.json([this.milestone]) 26 | }) 27 | ) 28 | }) 29 | 30 | Given('a milestone is added', async function () { 31 | this.milestone = { title: any.word(), description: any.sentence(), state: any.word() } 32 | 33 | this.server.use( 34 | http.get( 35 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 36 | settings.FILE_NAME 37 | )}`, 38 | ({ request }) => { 39 | return HttpResponse.arrayBuffer(Buffer.from(dump({ milestones: [this.milestone] }))) 40 | } 41 | ), 42 | http.post( 43 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/milestones`, 44 | async ({ request }) => { 45 | this.savedMilestone = await request.json() 46 | 47 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 48 | } 49 | ) 50 | ) 51 | }) 52 | 53 | Given('the milestone is updated in the config', async function () { 54 | this.milestoneUpdates = { description: any.sentence(), state: any.word() } 55 | 56 | this.server.use( 57 | http.get( 58 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 59 | settings.FILE_NAME 60 | )}`, 61 | ({ request }) => { 62 | return HttpResponse.arrayBuffer( 63 | Buffer.from(dump({ milestones: [{ ...this.milestone, ...this.milestoneUpdates }] })) 64 | ) 65 | } 66 | ), 67 | http.patch( 68 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/milestones/${this.milestone.number}`, 69 | async ({ request }) => { 70 | this.updatedMilestone = await request.json() 71 | 72 | return new HttpResponse(null, { status: StatusCodes.OK }) 73 | } 74 | ) 75 | ) 76 | }) 77 | 78 | Given('the milestone is removed from the config', async function () { 79 | this.server.use( 80 | http.get( 81 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 82 | settings.FILE_NAME 83 | )}`, 84 | ({ request }) => { 85 | return HttpResponse.arrayBuffer(Buffer.from(dump({ milestones: [] }))) 86 | } 87 | ), 88 | http.delete( 89 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/milestones/:milestoneNumber`, 90 | async ({ params }) => { 91 | this.removedMilestoneNumber = params.milestoneNumber 92 | 93 | return new HttpResponse(null, { status: StatusCodes.OK }) 94 | } 95 | ) 96 | ) 97 | }) 98 | 99 | Then('the milestone is available', async function () { 100 | assert.deepEqual(this.savedMilestone, this.milestone) 101 | }) 102 | 103 | Then('updated milestone is available', async function () { 104 | assert.deepEqual(this.updatedMilestone, { 105 | number: this.milestone.number, 106 | title: this.milestone.title, 107 | ...this.milestoneUpdates 108 | }) 109 | }) 110 | 111 | Then('the milestone is no longer available', async function () { 112 | assert.equal(this.removedMilestoneNumber, this.milestone.number) 113 | }) 114 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/repository-events-steps.js: -------------------------------------------------------------------------------- 1 | import { Given, When } from '@cucumber/cucumber' 2 | import any from '@travi/any' 3 | 4 | import { repository } from './common-steps.js' 5 | 6 | export function buildRepositoryCreatedEvent () { 7 | return { 8 | name: 'repository.created', 9 | payload: { repository } 10 | } 11 | } 12 | 13 | export function buildRepositoryEditedEvent ({ changes } = {}) { 14 | return { 15 | name: 'repository.edited', 16 | payload: { 17 | changes: { ...(changes || { default_branch: { from: any.word() } }) }, 18 | repository 19 | } 20 | } 21 | } 22 | 23 | Given('the default branch is not changed as part of updating the repository', async function () { 24 | this.repositoryEditedChanges = any.simpleObject() 25 | }) 26 | 27 | When('the repository is created', async function () { 28 | await this.probot.receive(buildRepositoryCreatedEvent()) 29 | }) 30 | 31 | When('the repository is edited', async function () { 32 | await this.probot.receive(buildRepositoryEditedEvent({ changes: this.repositoryEditedChanges })) 33 | }) 34 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/repository-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | 4 | import { Given, Then } from '@cucumber/cucumber' 5 | import { http, HttpResponse } from 'msw' 6 | import any from '@travi/any' 7 | import assert from 'node:assert' 8 | 9 | import { repository } from './common-steps.js' 10 | import settings from '../../../../lib/settings.js' 11 | 12 | Given('basic repository config is defined', async function () { 13 | this.repository = { 14 | name: repository.name, 15 | description: any.sentence(), 16 | default_branch: 'main', 17 | visibility: any.fromList(['public', 'private', 'internal']), 18 | homepage: any.url() 19 | } 20 | 21 | this.server.use( 22 | http.get( 23 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 24 | settings.FILE_NAME 25 | )}`, 26 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ repository: this.repository }))) 27 | ), 28 | http.patch(`https://api.github.com/repos/${repository.owner.name}/${repository.name}`, async ({ request }) => { 29 | this.repositoryDetails = await request.json() 30 | 31 | return new HttpResponse(null, { status: StatusCodes.OK }) 32 | }) 33 | ) 34 | }) 35 | 36 | Given('topics are defined in the repository config', async function () { 37 | this.repository = { 38 | name: repository.name, 39 | topics: any.listOf(any.word).join(', ') 40 | } 41 | 42 | this.server.use( 43 | http.get( 44 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 45 | settings.FILE_NAME 46 | )}`, 47 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ repository: this.repository }))) 48 | ), 49 | http.patch(`https://api.github.com/repos/${repository.owner.name}/${repository.name}`, async ({ request }) => { 50 | return new HttpResponse(null, { status: StatusCodes.OK }) 51 | }), 52 | http.put(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/topics`, async ({ request }) => { 53 | this.updatedTopics = (await request.json()).names 54 | 55 | return new HttpResponse(null, { status: StatusCodes.OK }) 56 | }) 57 | ) 58 | }) 59 | 60 | Given('vulnerability alerts are {string} in the config', async function (enablement) { 61 | this.repository = { 62 | name: repository.name, 63 | enable_vulnerability_alerts: enablement === 'enabled' 64 | } 65 | 66 | this.server.use( 67 | http.get( 68 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 69 | settings.FILE_NAME 70 | )}`, 71 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ repository: this.repository }))) 72 | ), 73 | http.patch(`https://api.github.com/repos/${repository.owner.name}/${repository.name}`, async ({ request }) => { 74 | return new HttpResponse(null, { status: StatusCodes.OK }) 75 | }), 76 | http[this.repository.enable_vulnerability_alerts ? 'put' : 'delete']( 77 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/vulnerability-alerts`, 78 | async ({ request }) => { 79 | this.vulnerabilityAlertEnablement = enablement 80 | 81 | return new HttpResponse(null, { 82 | status: this.repository.enable_vulnerability_alerts ? StatusCodes.OK : StatusCodes.NO_CONTENT 83 | }) 84 | } 85 | ) 86 | ) 87 | }) 88 | 89 | Given('security fixes are {string} in the config', async function (enablement) { 90 | this.repository = { 91 | name: repository.name, 92 | enable_automated_security_fixes: enablement === 'enabled' 93 | } 94 | 95 | this.server.use( 96 | http.get( 97 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 98 | settings.FILE_NAME 99 | )}`, 100 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ repository: this.repository }))) 101 | ), 102 | http.patch(`https://api.github.com/repos/${repository.owner.name}/${repository.name}`, async ({ request }) => { 103 | return new HttpResponse(null, { status: StatusCodes.OK }) 104 | }), 105 | http[this.repository.enable_automated_security_fixes ? 'put' : 'delete']( 106 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/automated-security-fixes`, 107 | async ({ request }) => { 108 | this.securityFixesEnablement = enablement 109 | 110 | return new HttpResponse(null, { 111 | status: this.repository.enable_automated_security_fixes ? StatusCodes.OK : StatusCodes.NO_CONTENT 112 | }) 113 | } 114 | ) 115 | ) 116 | }) 117 | 118 | Then('the repository will be configured', async function () { 119 | assert.deepEqual(this.repositoryDetails, this.repository) 120 | }) 121 | 122 | Then('topics are updated', async function () { 123 | assert.deepEqual(this.updatedTopics, this.repository.topics.split(', ')) 124 | }) 125 | 126 | Then('vulnerability alerts are {string}', async function (enablement) { 127 | assert.equal(this.vulnerabilityAlertEnablement, enablement) 128 | }) 129 | 130 | Then('security fixes are {string}', async function (enablement) { 131 | assert.equal(this.securityFixesEnablement, enablement) 132 | }) 133 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/rulesets-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | 4 | import { Given, Then } from '@cucumber/cucumber' 5 | import assert from 'node:assert' 6 | import { http, HttpResponse } from 'msw' 7 | 8 | import { repository } from './common-steps.js' 9 | import settings from '../../../../lib/settings.js' 10 | import any from '@travi/any' 11 | 12 | const rulesetId = any.integer() 13 | const rulesetName = any.word() 14 | const existingRules = any.listOf(any.simpleObject) 15 | 16 | Given('no rulesets are defined for the repository', async function () { 17 | this.server.use( 18 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/rulesets`, ({ request }) => 19 | HttpResponse.json([]) 20 | ) 21 | ) 22 | }) 23 | 24 | Given('a ruleset exists for the repository', async function () { 25 | const existingRulesetSubset = { 26 | id: rulesetId, 27 | name: rulesetName, 28 | _links: any.simpleObject(), 29 | created_at: any.string(), 30 | updated_at: any.string(), 31 | source_type: any.word(), 32 | source: any.string(), 33 | node_id: any.string() 34 | } 35 | const existingRuleset = { ...existingRulesetSubset } 36 | const existingRulesets = [existingRuleset] 37 | 38 | this.server.use( 39 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/rulesets`, ({ request }) => { 40 | const url = new URL(request.url) 41 | 42 | if (url.searchParams.get('includes_parents') === 'false') return HttpResponse.json(existingRulesets) 43 | 44 | return HttpResponse.json([ 45 | ...existingRulesets, 46 | ...any.listOf(() => ({ id: any.integer(), ...any.simpleObject() })) 47 | ]) 48 | }), 49 | http.get( 50 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/rulesets/${rulesetId}`, 51 | ({ request }) => 52 | HttpResponse.json({ 53 | ...existingRuleset, 54 | rules: existingRules, 55 | current_user_can_bypass: any.boolean() 56 | }) 57 | ) 58 | ) 59 | }) 60 | 61 | Given('a ruleset is defined in the config', async function () { 62 | this.ruleset = { name: any.word() } 63 | 64 | this.server.use( 65 | http.get( 66 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 67 | settings.FILE_NAME 68 | )}`, 69 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ rulesets: [this.ruleset] }))) 70 | ), 71 | http.post( 72 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/rulesets`, 73 | async ({ request }) => { 74 | this.createdRuleset = await request.json() 75 | 76 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 77 | } 78 | ) 79 | ) 80 | }) 81 | 82 | Given('the ruleset is modified in the config', async function () { 83 | const additionalRule = any.simpleObject() 84 | this.updatedRuleset = { name: rulesetName, rules: [...existingRules, additionalRule] } 85 | 86 | this.server.use( 87 | http.get( 88 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 89 | settings.FILE_NAME 90 | )}`, 91 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ rulesets: [this.updatedRuleset] }))) 92 | ), 93 | http.put( 94 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/rulesets/${rulesetId}`, 95 | async ({ request }) => { 96 | this.rulesetUpdate = await request.json() 97 | 98 | return new HttpResponse(null, { status: StatusCodes.OK }) 99 | } 100 | ) 101 | ) 102 | }) 103 | 104 | Given('the ruleset is removed from the config', async function () { 105 | this.server.use( 106 | http.get( 107 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 108 | settings.FILE_NAME 109 | )}`, 110 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ rulesets: [] }))) 111 | ), 112 | http.delete( 113 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/rulesets/:rulesetId`, 114 | async ({ params }) => { 115 | this.removedRuleset = params.rulesetId 116 | 117 | return new HttpResponse(null, { status: StatusCodes.NO_CONTENT }) 118 | } 119 | ) 120 | ) 121 | }) 122 | 123 | Given('no ruleset updates are made to the config', async function () { 124 | const existingRuleset = { name: rulesetName, rules: existingRules } 125 | 126 | this.server.use( 127 | http.get( 128 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 129 | settings.FILE_NAME 130 | )}`, 131 | ({ request }) => HttpResponse.arrayBuffer(Buffer.from(dump({ rulesets: [existingRuleset] }))) 132 | ) 133 | ) 134 | }) 135 | 136 | Then('the ruleset is enabled for the repository', async function () { 137 | assert.deepEqual(this.createdRuleset, this.ruleset) 138 | }) 139 | 140 | Then('the ruleset is updated', async function () { 141 | assert.deepEqual(this.rulesetUpdate, this.updatedRuleset) 142 | }) 143 | 144 | Then('the ruleset is deleted', async function () { 145 | assert.equal(this.removedRuleset, rulesetId) 146 | }) 147 | 148 | Then('no ruleset updates are triggered', async function () { 149 | return undefined 150 | }) 151 | -------------------------------------------------------------------------------- /test/integration/features/step_definitions/team-steps.js: -------------------------------------------------------------------------------- 1 | import { dump } from 'js-yaml' 2 | import { StatusCodes } from 'http-status-codes' 3 | import assert from 'node:assert' 4 | 5 | import { Given, Then } from '@cucumber/cucumber' 6 | import { http, HttpResponse } from 'msw' 7 | import any from '@travi/any' 8 | 9 | import settings from '../../../../lib/settings.js' 10 | 11 | import { repository } from './common-steps.js' 12 | 13 | const teamName = any.word() 14 | const teamId = any.integer() 15 | 16 | Given('no team has been granted access to the repository', async function () { 17 | this.server.use( 18 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/teams`, ({ request }) => { 19 | return HttpResponse.json([]) 20 | }) 21 | ) 22 | }) 23 | 24 | Given('a team has been granted {string} privileges to the repository', async function (accessLevel) { 25 | this.server.use( 26 | http.get(`https://api.github.com/repos/${repository.owner.name}/${repository.name}/teams`, ({ request }) => { 27 | return HttpResponse.json([{ slug: teamName, id: teamId, permission: accessLevel }]) 28 | }) 29 | ) 30 | }) 31 | 32 | Given('a team is granted {string} privileges in the config', async function (accessLevel) { 33 | this.server.use( 34 | http.get( 35 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 36 | settings.FILE_NAME 37 | )}`, 38 | ({ request }) => { 39 | return HttpResponse.arrayBuffer(Buffer.from(dump({ teams: [{ name: teamName, permission: accessLevel }] }))) 40 | } 41 | ), 42 | http.get(`https://api.github.com/orgs/${repository.owner.name}/teams/${teamName}`, ({ request }) => { 43 | return HttpResponse.json({ id: teamId }) 44 | }), 45 | http.put( 46 | `https://api.github.com/teams/${teamId}/repos/${repository.owner.name}/${repository.name}`, 47 | async ({ request }) => { 48 | this.teamPermissionLevel = (await request.json()).permission 49 | 50 | return new HttpResponse(null, { status: StatusCodes.CREATED }) 51 | } 52 | ) 53 | ) 54 | }) 55 | 56 | Given('the team privileges are updated to {string} in the config', async function (accessLevel) { 57 | this.server.use( 58 | http.get( 59 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 60 | settings.FILE_NAME 61 | )}`, 62 | ({ request }) => { 63 | return HttpResponse.arrayBuffer(Buffer.from(dump({ teams: [{ name: teamName, permission: accessLevel }] }))) 64 | } 65 | ), 66 | http.put( 67 | `https://api.github.com/teams/${teamId}/repos/${repository.owner.name}/${repository.name}`, 68 | async ({ request }) => { 69 | this.teamPermissionLevel = (await request.json()).permission 70 | 71 | return new HttpResponse(null, { status: StatusCodes.OK }) 72 | } 73 | ) 74 | ) 75 | }) 76 | 77 | Given('the team privileges are removed in the config', async function () { 78 | this.server.use( 79 | http.get( 80 | `https://api.github.com/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent( 81 | settings.FILE_NAME 82 | )}`, 83 | ({ request }) => { 84 | return HttpResponse.arrayBuffer(Buffer.from(dump({ teams: [] }))) 85 | } 86 | ), 87 | http.delete( 88 | `https://api.github.com/teams/:teamId/repos/${repository.owner.name}/${repository.name}`, 89 | async ({ params }) => { 90 | this.removedTeamId = params.teamId 91 | 92 | return new HttpResponse(null, { status: StatusCodes.NO_CONTENT }) 93 | } 94 | ) 95 | ) 96 | }) 97 | 98 | Then('the team has {string} access granted to it', async function (accessLevel) { 99 | assert.equal(this.teamPermissionLevel, accessLevel) 100 | }) 101 | 102 | Then('the team has privileges to the repo revoked', async function () { 103 | assert.equal(this.removedTeamId, teamId) 104 | }) 105 | -------------------------------------------------------------------------------- /test/integration/features/teams.feature: -------------------------------------------------------------------------------- 1 | Feature: Teams 2 | 3 | Scenario: Grant Team Access 4 | Given no team has been granted access to the repository 5 | And a team is granted "push" privileges in the config 6 | When a settings sync is triggered 7 | Then the team has "push" access granted to it 8 | 9 | Scenario: Update Team Access 10 | Given a team has been granted "push" privileges to the repository 11 | And the team privileges are updated to "admin" in the config 12 | When a settings sync is triggered 13 | Then the team has "admin" access granted to it 14 | 15 | Scenario: Remove Team Access 16 | Given a team has been granted "push" privileges to the repository 17 | And the team privileges are removed in the config 18 | When a settings sync is triggered 19 | Then the team has privileges to the repo revoked 20 | -------------------------------------------------------------------------------- /test/integration/triggers/push.test.js: -------------------------------------------------------------------------------- 1 | import Settings from '../../../lib/settings' 2 | import { initializeNock, loadInstance, repository, teardownNock } from '../common' 3 | 4 | describe('push trigger', function () { 5 | let probot, githubScope 6 | 7 | beforeEach(() => { 8 | githubScope = initializeNock() 9 | probot = loadInstance() 10 | }) 11 | 12 | afterEach(() => { 13 | teardownNock(githubScope) 14 | }) 15 | 16 | it('does not apply configuration when not on the default branch', async () => { 17 | await probot.receive({ 18 | name: 'push', 19 | payload: { 20 | ref: 'refs/heads/wip', 21 | repository, 22 | commits: [{ modified: [Settings.FILE_NAME], added: [] }] 23 | } 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/integration/triggers/repository-created.test.js: -------------------------------------------------------------------------------- 1 | import { NOT_FOUND } from 'http-status-codes' 2 | import Settings from '../../../lib/settings' 3 | import { buildRepositoryCreatedEvent, initializeNock, loadInstance, repository, teardownNock } from '../common' 4 | 5 | describe('repository.created trigger', function () { 6 | let probot, githubScope 7 | 8 | beforeEach(() => { 9 | githubScope = initializeNock() 10 | probot = loadInstance() 11 | }) 12 | 13 | afterEach(() => { 14 | teardownNock(githubScope) 15 | }) 16 | 17 | it('does not apply configuration when the repository does not have a settings.yml', async () => { 18 | githubScope 19 | .get(`/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent(Settings.FILE_NAME)}`) 20 | .reply(NOT_FOUND, { 21 | message: 'Not Found', 22 | documentation_url: 'https://developer.github.com/v3/repos/contents/#get-contents' 23 | }) 24 | githubScope 25 | .get(`/repos/${repository.owner.name}/.github/contents/${encodeURIComponent(Settings.FILE_NAME)}`) 26 | .reply(NOT_FOUND, { 27 | message: 'Not Found', 28 | documentation_url: 'https://developer.github.com/v3/repos/contents/#get-contents' 29 | }) 30 | 31 | await probot.receive(buildRepositoryCreatedEvent()) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /test/integration/triggers/repository-edited.test.js: -------------------------------------------------------------------------------- 1 | import { NOT_FOUND } from 'http-status-codes' 2 | import any from '@travi/any' 3 | import Settings from '../../../lib/settings' 4 | import { buildRepositoryEditedEvent, initializeNock, loadInstance, repository, teardownNock } from '../common' 5 | 6 | describe('repository.edited trigger', function () { 7 | let probot, githubScope 8 | 9 | beforeEach(() => { 10 | githubScope = initializeNock() 11 | probot = loadInstance() 12 | }) 13 | 14 | afterEach(() => { 15 | teardownNock(githubScope) 16 | }) 17 | 18 | it('does not apply configuration when the default branch was not changed', async () => { 19 | await probot.receive({ 20 | name: 'repository.edited', 21 | payload: { 22 | changes: any.simpleObject() 23 | } 24 | }) 25 | }) 26 | 27 | it('does not apply configuration when the repository does not have a settings.yml', async () => { 28 | githubScope 29 | .get(`/repos/${repository.owner.name}/${repository.name}/contents/${encodeURIComponent(Settings.FILE_NAME)}`) 30 | .reply(NOT_FOUND, { 31 | message: 'Not Found', 32 | documentation_url: 'https://developer.github.com/v3/repos/contents/#get-contents' 33 | }) 34 | githubScope 35 | .get(`/repos/${repository.owner.name}/.github/contents/${encodeURIComponent(Settings.FILE_NAME)}`) 36 | .reply(NOT_FOUND, { 37 | message: 'Not Found', 38 | documentation_url: 'https://developer.github.com/v3/repos/contents/#get-contents' 39 | }) 40 | 41 | await probot.receive(buildRepositoryEditedEvent()) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/unit/index.test.js: -------------------------------------------------------------------------------- 1 | import { Probot, ProbotOctokit } from 'probot' 2 | import any from '@travi/any' 3 | import plugin from '../../index.js' 4 | import { readFileSync } from 'fs' 5 | import { jest } from '@jest/globals' 6 | 7 | const pushSettings = JSON.parse(readFileSync(new URL('../fixtures/events/push.settings.json', import.meta.url))) 8 | const pushReadme = JSON.parse(readFileSync(new URL('../fixtures/events/push.readme.json', import.meta.url))) 9 | const repositoryEdited = JSON.parse(readFileSync(new URL('../fixtures/events/repository.edited.json', import.meta.url))) 10 | 11 | describe('plugin', () => { 12 | let app, event, sync 13 | 14 | beforeEach(() => { 15 | class Octokit { 16 | static defaults () { 17 | return ProbotOctokit 18 | } 19 | 20 | constructor () { 21 | this.config = { 22 | get: jest.fn().mockReturnValue({}) 23 | } 24 | } 25 | 26 | auth () { 27 | return this 28 | } 29 | } 30 | 31 | app = new Probot({ secret: any.string(), Octokit }) 32 | 33 | event = { 34 | name: 'push', 35 | payload: pushSettings 36 | } 37 | sync = jest.fn() 38 | 39 | plugin(app, {}, { sync, FILE_NAME: '.github/settings.yml' }) 40 | }) 41 | 42 | describe('with settings modified on master', () => { 43 | it('syncs settings', async () => { 44 | await app.receive(event) 45 | expect(sync).toHaveBeenCalled() 46 | }) 47 | }) 48 | 49 | describe('on another branch', () => { 50 | beforeEach(() => { 51 | event.payload.ref = 'refs/heads/other-branch' 52 | }) 53 | 54 | it('does not sync settings', async () => { 55 | await app.receive(event) 56 | expect(sync).not.toHaveBeenCalled() 57 | }) 58 | }) 59 | 60 | describe('with other files modified', () => { 61 | beforeEach(() => { 62 | event.payload = pushReadme 63 | }) 64 | 65 | it('does not sync settings', async () => { 66 | await app.receive(event) 67 | expect(sync).not.toHaveBeenCalled() 68 | }) 69 | }) 70 | 71 | describe('default branch changed', () => { 72 | beforeEach(() => { 73 | event = { 74 | name: 'repository.edited', 75 | payload: repositoryEdited 76 | } 77 | }) 78 | 79 | it('does sync settings', async () => { 80 | await app.receive(event) 81 | expect(sync).toHaveBeenCalled() 82 | }) 83 | }) 84 | 85 | describe('repository created', () => { 86 | beforeEach(() => { 87 | event = { 88 | name: 'repository.created', 89 | payload: { 90 | repository: { 91 | owner: { 92 | login: 'Martijn-Workspace' 93 | } 94 | } 95 | } 96 | } 97 | }) 98 | 99 | it('does sync settings', async () => { 100 | await app.receive(event) 101 | expect(sync).toHaveBeenCalled() 102 | }) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /test/unit/lib/mergeArrayByName.test.js: -------------------------------------------------------------------------------- 1 | import branchArrayMerge from '../../../lib/mergeArrayByName' 2 | import YAML from 'js-yaml' 3 | 4 | describe('mergeArrayByName', () => { 5 | it('works', () => { 6 | const target = [ 7 | { 8 | name: 'master', 9 | shouldChange: 'did not change', 10 | shouldKeep: 'kept' 11 | }, 12 | { 13 | name: 'develop' 14 | } 15 | ] 16 | 17 | const source = [ 18 | { 19 | name: 'master', 20 | shouldChange: 'did change' 21 | }, 22 | { 23 | name: 'added' 24 | } 25 | ] 26 | 27 | const merged = branchArrayMerge(target, source) 28 | 29 | expect(merged).toEqual([ 30 | { 31 | name: 'master', 32 | shouldChange: 'did change', 33 | shouldKeep: 'kept' 34 | }, 35 | { 36 | name: 'develop' 37 | }, 38 | { 39 | name: 'added' 40 | } 41 | ]) 42 | }) 43 | 44 | it('works in a realistic scenario', () => { 45 | const target = YAML.load(` 46 | branches: 47 | - name: master 48 | protection: 49 | required_pull_request_reviews: 50 | required_approving_review_count: 1 51 | dismiss_stale_reviews: false 52 | require_code_owner_reviews: true 53 | dismissal_restrictions: {} 54 | required_status_checks: 55 | strict: true 56 | contexts: [] 57 | enforce_admins: false 58 | restrictions: 59 | `) 60 | 61 | const source = YAML.load(` 62 | branches: 63 | - name: master 64 | protection: 65 | required_pull_request_reviews: 66 | required_approving_review_count: 2 67 | `) 68 | 69 | const expected = [ 70 | { 71 | name: 'master', 72 | protection: { 73 | required_pull_request_reviews: { 74 | required_approving_review_count: 2, 75 | dismiss_stale_reviews: false, 76 | require_code_owner_reviews: true, 77 | dismissal_restrictions: {} 78 | }, 79 | required_status_checks: { strict: true, contexts: [] }, 80 | enforce_admins: false, 81 | restrictions: null 82 | } 83 | } 84 | ] 85 | 86 | const merged = branchArrayMerge(target.branches, source.branches) 87 | 88 | expect(merged).toEqual(expected) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/branches.test.js: -------------------------------------------------------------------------------- 1 | import Branches from '../../../../lib/plugins/branches' 2 | import { jest } from '@jest/globals' 3 | 4 | describe('Branches', () => { 5 | let github 6 | 7 | function configure (config) { 8 | return new Branches(github, { owner: 'bkeepers', repo: 'test' }, config) 9 | } 10 | 11 | beforeEach(() => { 12 | github = { 13 | repos: { 14 | updateBranchProtection: jest.fn().mockImplementation(() => Promise.resolve('updateBranchProtection')), 15 | deleteBranchProtection: jest.fn().mockImplementation(() => Promise.resolve('deleteBranchProtection')) 16 | } 17 | } 18 | }) 19 | 20 | describe('sync', () => { 21 | it('syncs branch protection settings', () => { 22 | const plugin = configure([ 23 | { 24 | name: 'master', 25 | protection: { 26 | required_status_checks: { 27 | strict: true, 28 | contexts: ['travis-ci'] 29 | }, 30 | enforce_admins: true, 31 | required_pull_request_reviews: { 32 | require_code_owner_reviews: true 33 | } 34 | } 35 | } 36 | ]) 37 | 38 | return plugin.sync().then(() => { 39 | expect(github.repos.updateBranchProtection).toHaveBeenCalledWith({ 40 | owner: 'bkeepers', 41 | repo: 'test', 42 | branch: 'master', 43 | required_status_checks: { 44 | strict: true, 45 | contexts: ['travis-ci'] 46 | }, 47 | enforce_admins: true, 48 | required_pull_request_reviews: { 49 | require_code_owner_reviews: true 50 | }, 51 | headers: { 52 | accept: 53 | 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' 54 | } 55 | }) 56 | }) 57 | }) 58 | 59 | describe('when the "protection" config is empty object', () => { 60 | it('removes branch protection', () => { 61 | const plugin = configure([ 62 | { 63 | name: 'master', 64 | protection: {} 65 | } 66 | ]) 67 | 68 | return plugin.sync().then(() => { 69 | expect(github.repos.updateBranchProtection).not.toHaveBeenCalled() 70 | expect(github.repos.deleteBranchProtection).toHaveBeenCalledWith({ 71 | owner: 'bkeepers', 72 | repo: 'test', 73 | branch: 'master' 74 | }) 75 | }) 76 | }) 77 | }) 78 | 79 | describe('when the "protection" config is set to `null`', () => { 80 | it('removes branch protection', () => { 81 | const plugin = configure([ 82 | { 83 | name: 'master', 84 | protection: null 85 | } 86 | ]) 87 | 88 | return plugin.sync().then(() => { 89 | expect(github.repos.updateBranchProtection).not.toHaveBeenCalled() 90 | expect(github.repos.deleteBranchProtection).toHaveBeenCalledWith({ 91 | owner: 'bkeepers', 92 | repo: 'test', 93 | branch: 'master' 94 | }) 95 | }) 96 | }) 97 | }) 98 | 99 | describe('when the "protection" config is set to an empty array', () => { 100 | it('removes branch protection', () => { 101 | const plugin = configure([ 102 | { 103 | name: 'master', 104 | protection: [] 105 | } 106 | ]) 107 | 108 | return plugin.sync().then(() => { 109 | expect(github.repos.updateBranchProtection).not.toHaveBeenCalled() 110 | expect(github.repos.deleteBranchProtection).toHaveBeenCalledWith({ 111 | owner: 'bkeepers', 112 | repo: 'test', 113 | branch: 'master' 114 | }) 115 | }) 116 | }) 117 | }) 118 | 119 | describe('when the "protection" config is set to `false`', () => { 120 | it('removes branch protection', () => { 121 | const plugin = configure([ 122 | { 123 | name: 'master', 124 | protection: false 125 | } 126 | ]) 127 | 128 | return plugin.sync().then(() => { 129 | expect(github.repos.updateBranchProtection).not.toHaveBeenCalled() 130 | expect(github.repos.deleteBranchProtection).toHaveBeenCalledWith({ 131 | owner: 'bkeepers', 132 | repo: 'test', 133 | branch: 'master' 134 | }) 135 | }) 136 | }) 137 | }) 138 | 139 | describe('when the "protection" key is not present', () => { 140 | it('makes no change to branch protection', () => { 141 | const plugin = configure([ 142 | { 143 | name: 'master' 144 | } 145 | ]) 146 | 147 | return plugin.sync().then(() => { 148 | expect(github.repos.updateBranchProtection).not.toHaveBeenCalled() 149 | expect(github.repos.deleteBranchProtection).not.toHaveBeenCalled() 150 | }) 151 | }) 152 | }) 153 | 154 | describe('when multiple branches are configured', () => { 155 | it('updates them each appropriately', () => { 156 | const plugin = configure([ 157 | { 158 | name: 'master', 159 | protection: { enforce_admins: true } 160 | }, 161 | { 162 | name: 'other', 163 | protection: { enforce_admins: false } 164 | } 165 | ]) 166 | 167 | return plugin.sync().then(() => { 168 | expect(github.repos.updateBranchProtection).toHaveBeenCalledTimes(2) 169 | 170 | expect(github.repos.updateBranchProtection).toHaveBeenLastCalledWith({ 171 | owner: 'bkeepers', 172 | repo: 'test', 173 | branch: 'other', 174 | enforce_admins: false, 175 | headers: { 176 | accept: 177 | 'application/vnd.github.hellcat-preview+json,application/vnd.github.luke-cage-preview+json,application/vnd.github.zzzax-preview+json' 178 | } 179 | }) 180 | }) 181 | }) 182 | }) 183 | }) 184 | 185 | describe('return values', () => { 186 | it('returns updateBranchProtection Promise', () => { 187 | const plugin = configure([ 188 | { 189 | name: 'master', 190 | protection: { enforce_admins: true } 191 | } 192 | ]) 193 | 194 | return plugin.sync().then(result => { 195 | expect(result.length).toBe(1) 196 | expect(result[0]).toBe('updateBranchProtection') 197 | }) 198 | }) 199 | it('returns deleteBranchProtection Promise', () => { 200 | const plugin = configure([ 201 | { 202 | name: 'master', 203 | protection: null 204 | } 205 | ]) 206 | 207 | return plugin.sync().then(result => { 208 | expect(result.length).toBe(1) 209 | expect(result[0]).toBe('deleteBranchProtection') 210 | }) 211 | }) 212 | }) 213 | }) 214 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/collaborators.test.js: -------------------------------------------------------------------------------- 1 | import Collaborators from '../../../../lib/plugins/collaborators' 2 | import { jest } from '@jest/globals' 3 | 4 | describe('Collaborators', () => { 5 | let github 6 | 7 | function configure (config) { 8 | return new Collaborators(github, { owner: 'bkeepers', repo: 'test' }, config) 9 | } 10 | 11 | beforeEach(() => { 12 | github = { 13 | repos: { 14 | listCollaborators: jest.fn().mockImplementation(() => Promise.resolve([])), 15 | removeCollaborator: jest.fn().mockImplementation(() => Promise.resolve()), 16 | addCollaborator: jest.fn().mockImplementation(() => Promise.resolve()) 17 | } 18 | } 19 | }) 20 | 21 | describe('sync', () => { 22 | it('syncs collaborators', () => { 23 | const plugin = configure([ 24 | { username: 'bkeepers', permission: 'admin' }, 25 | { username: 'added-user', permission: 'push' }, 26 | { username: 'updated-permission', permission: 'push' }, 27 | { username: 'DIFFERENTcase', permission: 'push' } 28 | ]) 29 | 30 | github.repos.listCollaborators.mockReturnValueOnce( 31 | Promise.resolve({ 32 | data: [ 33 | { login: 'bkeepers', permissions: { admin: true, push: true, pull: true } }, 34 | { login: 'updated-permission', permissions: { admin: false, push: false, pull: true } }, 35 | { login: 'removed-user', permissions: { admin: false, push: true, pull: true } }, 36 | { login: 'differentCase', permissions: { admin: false, push: true, pull: true } } 37 | ] 38 | }) 39 | ) 40 | 41 | return plugin.sync().then(() => { 42 | expect(github.repos.addCollaborator).toHaveBeenCalledWith({ 43 | owner: 'bkeepers', 44 | repo: 'test', 45 | username: 'added-user', 46 | permission: 'push' 47 | }) 48 | 49 | expect(github.repos.addCollaborator).toHaveBeenCalledWith({ 50 | owner: 'bkeepers', 51 | repo: 'test', 52 | username: 'updated-permission', 53 | permission: 'push' 54 | }) 55 | 56 | expect(github.repos.addCollaborator).toHaveBeenCalledTimes(2) 57 | 58 | expect(github.repos.removeCollaborator).toHaveBeenCalledWith({ 59 | owner: 'bkeepers', 60 | repo: 'test', 61 | username: 'removed-user' 62 | }) 63 | 64 | expect(github.repos.removeCollaborator).toHaveBeenCalledTimes(1) 65 | }) 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/environments.test.js: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when' 2 | import { jest } from '@jest/globals' 3 | import Environments from '../../../../lib/plugins/environments.js' 4 | 5 | describe('Environments', () => { 6 | let github 7 | const org = 'bkeepers' 8 | const repo = 'test' 9 | 10 | function configure (config) { 11 | return new Environments(github, { owner: org, repo }, config) 12 | } 13 | 14 | beforeEach(() => { 15 | github = { 16 | request: jest.fn().mockReturnValue(Promise.resolve(true)) 17 | } 18 | }) 19 | 20 | describe('sync', () => { 21 | it('syncs environments', () => { 22 | const plugin = configure([ 23 | { name: 'changed-wait-timer', wait_timer: 10 }, 24 | { 25 | name: 'changed-reviewers-type', 26 | reviewers: [ 27 | { id: 1, type: 'User' }, 28 | { id: 2, type: 'User' } 29 | ] 30 | }, 31 | { 32 | name: 'new-environment', 33 | wait_timer: 1, 34 | reviewers: [ 35 | { 36 | id: 1, 37 | type: 'Team' 38 | }, 39 | { 40 | id: 2, 41 | type: 'User' 42 | } 43 | ], 44 | deployment_branch_policy: { 45 | custom_branches: ['dev/*', 'dev-*', { name: 'v*', type: 'tag' }] 46 | } 47 | }, 48 | { 49 | name: 'unchanged-environment', 50 | deployment_branch_policy: { 51 | custom_branches: ['dev/*', 'dev-*', { name: 'v*', type: 'tag' }, { name: '*.*.*', type: 'tag' }] 52 | } 53 | }, 54 | { 55 | name: 'changed-environment', 56 | deployment_branch_policy: { 57 | custom_branches: ['dev/*', { name: '*.*.*', type: 'tag' }] 58 | } 59 | }, 60 | { 61 | name: 'changed-branch-policy', 62 | deployment_branch_policy: { 63 | protected_branches: true 64 | } 65 | }, 66 | { 67 | name: 'unchanged-reviewers-unsorted', 68 | reviewers: [ 69 | { 70 | id: 2, 71 | type: 'User' 72 | }, 73 | { 74 | id: 1, 75 | type: 'Team' 76 | } 77 | ] 78 | }, 79 | { name: 'Different-case', wait_timer: 0 } 80 | ]) 81 | 82 | when(github.request) 83 | .calledWith('GET /repos/:org/:repo/environments', { org, repo }) 84 | .mockResolvedValue({ 85 | data: { 86 | environments: [ 87 | { name: 'different-Case', wait_timer: 0 }, 88 | { name: 'changed-wait-timer', wait_timer: 0 }, 89 | { 90 | name: 'changed-reviewers-type', 91 | reviewers: [ 92 | { id: 1, type: 'Team' }, 93 | { id: 2, type: 'User' } 94 | ] 95 | }, 96 | { 97 | name: 'unchanged-environment', 98 | deployment_branch_policy: { 99 | protected_branches: false, 100 | custom_branch_policies: true 101 | } 102 | }, 103 | { 104 | name: 'changed-environment', 105 | deployment_branch_policy: { 106 | protected_branches: false, 107 | custom_branch_policies: true 108 | } 109 | }, 110 | { 111 | name: 'changed-branch-policy', 112 | deployment_branch_policy: { 113 | protected_branches: false, 114 | custom_branch_policies: true 115 | } 116 | }, 117 | { 118 | name: 'unchanged-reviewers-unsorted', 119 | reviewers: [ 120 | { id: 1, type: 'Team' }, 121 | { id: 2, type: 'User' } 122 | ] 123 | }, 124 | { name: 'deleted', wait_timer: 0 } 125 | ] 126 | } 127 | }) 128 | 129 | when(github.request) 130 | .calledWith('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { 131 | org, 132 | repo, 133 | environment_name: 'changed-branch-policy' 134 | }) 135 | .mockResolvedValue({ 136 | data: { 137 | branch_policies: [ 138 | { 139 | id: 3, 140 | node_id: '3', 141 | name: 'v*', 142 | type: 'tag' 143 | }, 144 | { 145 | id: 2, 146 | node_id: '2', 147 | name: 'dev-*', 148 | type: 'branch' 149 | }, 150 | { 151 | id: 1, 152 | node_id: '1', 153 | name: 'dev/*', 154 | type: 'branch' 155 | } 156 | ] 157 | } 158 | }) 159 | 160 | when(github.request) 161 | .calledWith('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { 162 | org, 163 | repo, 164 | environment_name: 'unchanged-environment' 165 | }) 166 | .mockResolvedValue({ 167 | data: { 168 | branch_policies: [ 169 | { 170 | id: 4, 171 | node_id: '4', 172 | name: '*.*.*', 173 | type: 'tag' 174 | }, 175 | { 176 | id: 3, 177 | node_id: '3', 178 | name: 'v*', 179 | type: 'tag' 180 | }, 181 | { 182 | id: 2, 183 | node_id: '2', 184 | name: 'dev-*', 185 | type: 'branch' 186 | }, 187 | { 188 | id: 1, 189 | node_id: '1', 190 | name: 'dev/*', 191 | type: 'branch' 192 | } 193 | ] 194 | } 195 | }) 196 | 197 | when(github.request) 198 | .calledWith('GET /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', { 199 | org, 200 | repo, 201 | environment_name: 'changed-environment' 202 | }) 203 | .mockResolvedValue({ 204 | data: { 205 | branch_policies: [ 206 | { 207 | id: 3, 208 | node_id: '3', 209 | name: 'v*', 210 | type: 'tag' 211 | }, 212 | { 213 | id: 2, 214 | node_id: '2', 215 | name: 'dev-*', 216 | type: 'branch' 217 | }, 218 | { 219 | id: 1, 220 | node_id: '1', 221 | name: 'dev/*', 222 | type: 'branch' 223 | } 224 | ] 225 | } 226 | }) 227 | 228 | return plugin.sync().then(() => { 229 | expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { 230 | org, 231 | repo, 232 | environment_name: 'changed-wait-timer', 233 | deployment_branch_policy: null, 234 | wait_timer: 10 235 | }) 236 | 237 | expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { 238 | org, 239 | repo, 240 | environment_name: 'changed-reviewers-type', 241 | deployment_branch_policy: null, 242 | wait_timer: 0, 243 | reviewers: [ 244 | { id: 1, type: 'User' }, 245 | { id: 2, type: 'User' } 246 | ] 247 | }) 248 | 249 | expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { 250 | org, 251 | repo, 252 | environment_name: 'new-environment', 253 | wait_timer: 1, 254 | reviewers: [ 255 | { 256 | id: 1, 257 | type: 'Team' 258 | }, 259 | { 260 | id: 2, 261 | type: 'User' 262 | } 263 | ], 264 | deployment_branch_policy: { 265 | protected_branches: false, 266 | custom_branch_policies: true 267 | } 268 | }) 269 | 270 | expect(github.request).toHaveBeenCalledWith( 271 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 272 | { 273 | org, 274 | repo, 275 | environment_name: 'new-environment', 276 | name: 'dev/*', 277 | type: 'branch' 278 | } 279 | ) 280 | 281 | expect(github.request).toHaveBeenCalledWith( 282 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 283 | { 284 | org, 285 | repo, 286 | environment_name: 'new-environment', 287 | name: 'dev-*', 288 | type: 'branch' 289 | } 290 | ) 291 | 292 | expect(github.request).toHaveBeenCalledWith( 293 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 294 | { 295 | org, 296 | repo, 297 | environment_name: 'new-environment', 298 | name: 'v*', 299 | type: 'tag' 300 | } 301 | ) 302 | 303 | expect(github.request).not.toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { 304 | org, 305 | repo, 306 | environment_name: 'unchanged-environment', 307 | deployment_branch_policy: { 308 | protected_branches: false, 309 | custom_branch_policies: true 310 | } 311 | }) 312 | 313 | expect(github.request).not.toHaveBeenCalledWith( 314 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 315 | { 316 | org, 317 | repo, 318 | environment_name: 'unchanged-environment', 319 | name: 'dev/*', 320 | type: 'branch' 321 | } 322 | ) 323 | 324 | expect(github.request).not.toHaveBeenCalledWith( 325 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 326 | { 327 | org, 328 | repo, 329 | environment_name: 'unchanged-environment', 330 | name: 'dev-*', 331 | type: 'branch' 332 | } 333 | ) 334 | 335 | expect(github.request).not.toHaveBeenCalledWith( 336 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 337 | { 338 | org, 339 | repo, 340 | environment_name: 'unchanged-environment', 341 | name: 'v*', 342 | type: 'tag' 343 | } 344 | ) 345 | 346 | expect(github.request).not.toHaveBeenCalledWith( 347 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 348 | { 349 | org, 350 | repo, 351 | environment_name: 'unchanged-environment', 352 | name: '*.*.*', 353 | type: 'tag' 354 | } 355 | ) 356 | 357 | expect(github.request).not.toHaveBeenCalledWith( 358 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 359 | { 360 | org, 361 | repo, 362 | environment_name: 'unchanged-environment', 363 | id: 4 364 | } 365 | ) 366 | 367 | expect(github.request).not.toHaveBeenCalledWith( 368 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 369 | { 370 | org, 371 | repo, 372 | environment_name: 'unchanged-environment', 373 | id: 3 374 | } 375 | ) 376 | 377 | expect(github.request).not.toHaveBeenCalledWith( 378 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 379 | { 380 | org, 381 | repo, 382 | environment_name: 'unchanged-environment', 383 | id: 2 384 | } 385 | ) 386 | 387 | expect(github.request).not.toHaveBeenCalledWith( 388 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 389 | { 390 | org, 391 | repo, 392 | environment_name: 'unchanged-environment', 393 | id: 1 394 | } 395 | ) 396 | 397 | expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { 398 | org, 399 | repo, 400 | environment_name: 'changed-environment', 401 | wait_timer: 0, 402 | deployment_branch_policy: { 403 | protected_branches: false, 404 | custom_branch_policies: true 405 | } 406 | }) 407 | 408 | expect(github.request).toHaveBeenCalledWith( 409 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 410 | { 411 | org, 412 | repo, 413 | environment_name: 'changed-environment', 414 | name: 'dev/*', 415 | type: 'branch' 416 | } 417 | ) 418 | 419 | expect(github.request).not.toHaveBeenCalledWith( 420 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 421 | { 422 | org, 423 | repo, 424 | environment_name: 'changed-environment', 425 | name: 'dev-*', 426 | type: 'branch' 427 | } 428 | ) 429 | 430 | expect(github.request).not.toHaveBeenCalledWith( 431 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 432 | { 433 | org, 434 | repo, 435 | environment_name: 'changed-environment', 436 | name: 'v*', 437 | type: 'tag' 438 | } 439 | ) 440 | 441 | expect(github.request).toHaveBeenCalledWith( 442 | 'POST /repos/:org/:repo/environments/:environment_name/deployment-branch-policies', 443 | { 444 | org, 445 | repo, 446 | environment_name: 'changed-environment', 447 | name: '*.*.*', 448 | type: 'tag' 449 | } 450 | ) 451 | 452 | expect(github.request).toHaveBeenCalledWith( 453 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 454 | { 455 | org, 456 | repo, 457 | environment_name: 'changed-environment', 458 | id: 3 459 | } 460 | ) 461 | 462 | expect(github.request).toHaveBeenCalledWith( 463 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 464 | { 465 | org, 466 | repo, 467 | environment_name: 'changed-environment', 468 | id: 2 469 | } 470 | ) 471 | 472 | expect(github.request).toHaveBeenCalledWith( 473 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 474 | { 475 | org, 476 | repo, 477 | environment_name: 'changed-environment', 478 | id: 1 479 | } 480 | ) 481 | 482 | expect(github.request).toHaveBeenCalledWith( 483 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 484 | { 485 | org, 486 | repo, 487 | environment_name: 'changed-branch-policy', 488 | id: 1 489 | } 490 | ) 491 | 492 | expect(github.request).toHaveBeenCalledWith( 493 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 494 | { 495 | org, 496 | repo, 497 | environment_name: 'changed-branch-policy', 498 | id: 2 499 | } 500 | ) 501 | 502 | expect(github.request).toHaveBeenCalledWith( 503 | 'DELETE /repos/:org/:repo/environments/:environment_name/deployment-branch-policies/:id', 504 | { 505 | org, 506 | repo, 507 | environment_name: 'changed-branch-policy', 508 | id: 3 509 | } 510 | ) 511 | 512 | expect(github.request).toHaveBeenCalledWith('PUT /repos/:org/:repo/environments/:environment_name', { 513 | org, 514 | repo, 515 | environment_name: 'changed-branch-policy', 516 | wait_timer: 0, 517 | deployment_branch_policy: { 518 | protected_branches: true, 519 | custom_branch_policies: false 520 | } 521 | }) 522 | 523 | expect(github.request).toHaveBeenCalledWith('DELETE /repos/:org/:repo/environments/:environment_name', { 524 | org, 525 | repo, 526 | environment_name: 'deleted' 527 | }) 528 | }) 529 | }) 530 | }) 531 | }) 532 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/labels.test.js: -------------------------------------------------------------------------------- 1 | import Labels from '../../../../lib/plugins/labels' 2 | import { jest } from '@jest/globals' 3 | 4 | describe('Labels', () => { 5 | let github 6 | 7 | function configure (config) { 8 | return new Labels(github, { owner: 'bkeepers', repo: 'test' }, config) 9 | } 10 | 11 | beforeEach(() => { 12 | github = { 13 | paginate: jest.fn().mockImplementation(() => Promise.resolve()), 14 | issues: { 15 | listLabelsForRepo: { 16 | endpoint: { 17 | merge: jest.fn().mockImplementation(() => {}) 18 | } 19 | }, 20 | createLabel: jest.fn().mockImplementation(() => Promise.resolve()), 21 | deleteLabel: jest.fn().mockImplementation(() => Promise.resolve()), 22 | updateLabel: jest.fn().mockImplementation(() => Promise.resolve()) 23 | } 24 | } 25 | }) 26 | 27 | describe('sync', () => { 28 | it('syncs labels', () => { 29 | github.paginate.mockReturnValueOnce( 30 | Promise.resolve([ 31 | { name: 'no-change', color: 'FF0000', description: '' }, 32 | { name: 'new-color', color: 0, description: '' }, // YAML treats `color: 000000` as an integer 33 | { name: 'new-description', color: '000000', description: '' }, 34 | { name: 'update-me', color: '0000FF', description: '' }, 35 | { name: 'delete-me', color: '000000', description: '' } 36 | ]) 37 | ) 38 | 39 | const plugin = configure([ 40 | { name: 'no-change', color: 'FF0000', description: '' }, 41 | { new_name: 'new-name', name: 'update-me', color: 'FFFFFF', description: '' }, 42 | { name: 'new-color', color: '999999', description: '' }, 43 | { name: 'new-description', color: '#000000', description: 'Hello world' }, 44 | { name: 'added' } 45 | ]) 46 | 47 | return plugin.sync().then(() => { 48 | expect(github.issues.deleteLabel).toHaveBeenCalledWith({ 49 | owner: 'bkeepers', 50 | repo: 'test', 51 | name: 'delete-me', 52 | headers: { accept: 'application/vnd.github.symmetra-preview+json' } 53 | }) 54 | 55 | expect(github.issues.createLabel).toHaveBeenCalledWith({ 56 | owner: 'bkeepers', 57 | repo: 'test', 58 | name: 'added', 59 | headers: { accept: 'application/vnd.github.symmetra-preview+json' } 60 | }) 61 | 62 | expect(github.issues.updateLabel).toHaveBeenCalledWith({ 63 | owner: 'bkeepers', 64 | repo: 'test', 65 | name: 'update-me', 66 | new_name: 'new-name', 67 | color: 'FFFFFF', 68 | description: '', 69 | headers: { accept: 'application/vnd.github.symmetra-preview+json' } 70 | }) 71 | 72 | expect(github.issues.updateLabel).toHaveBeenCalledWith({ 73 | owner: 'bkeepers', 74 | repo: 'test', 75 | name: 'new-color', 76 | color: '999999', 77 | description: '', 78 | headers: { accept: 'application/vnd.github.symmetra-preview+json' } 79 | }) 80 | 81 | expect(github.issues.updateLabel).toHaveBeenCalledWith({ 82 | owner: 'bkeepers', 83 | repo: 'test', 84 | name: 'new-description', 85 | color: '000000', 86 | description: 'Hello world', 87 | headers: { accept: 'application/vnd.github.symmetra-preview+json' } 88 | }) 89 | 90 | expect(github.issues.deleteLabel).toHaveBeenCalledTimes(1) 91 | expect(github.issues.updateLabel).toHaveBeenCalledTimes(3) 92 | expect(github.issues.createLabel).toHaveBeenCalledTimes(1) 93 | }) 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/milestones.test.js: -------------------------------------------------------------------------------- 1 | import Milestones from '../../../../lib/plugins/milestones' 2 | import { jest } from '@jest/globals' 3 | 4 | describe('Milestones', () => { 5 | let github 6 | 7 | function configure (config) { 8 | return new Milestones(github, { owner: 'bkeepers', repo: 'test' }, config) 9 | } 10 | 11 | beforeEach(() => { 12 | github = { 13 | paginate: jest.fn().mockImplementation(() => Promise.resolve()), 14 | issues: { 15 | listMilestones: { 16 | endpoint: { 17 | merge: jest.fn().mockImplementation(() => {}) 18 | } 19 | }, 20 | createMilestone: jest.fn().mockImplementation(() => Promise.resolve()), 21 | deleteMilestone: jest.fn().mockImplementation(() => Promise.resolve()), 22 | updateMilestone: jest.fn().mockImplementation(() => Promise.resolve()) 23 | } 24 | } 25 | }) 26 | 27 | describe('sync', () => { 28 | it('syncs milestones', async () => { 29 | github.paginate.mockReturnValueOnce( 30 | Promise.resolve([ 31 | { title: 'no-change', description: 'no-change-description', due_on: null, state: 'open', number: 5 }, 32 | { title: 'new-description', description: 'old-description', due_on: null, state: 'open', number: 2 }, 33 | { title: 'new-state', description: 'FF0000', due_on: null, state: 'open', number: 4 }, 34 | { title: 'remove-milestone', description: 'old-description', due_on: null, state: 'open', number: 1 } 35 | ]) 36 | ) 37 | 38 | const plugin = configure([ 39 | { title: 'no-change', description: 'no-change-description', due_on: '2019-03-29T07:00:00Z', state: 'open' }, 40 | { title: 'new-description', description: 'modified-description' }, 41 | { title: 'new-state', state: 'closed' }, 42 | { title: 'added' } 43 | ]) 44 | 45 | await plugin.sync() 46 | 47 | expect(github.issues.deleteMilestone).toHaveBeenCalledWith({ 48 | owner: 'bkeepers', 49 | repo: 'test', 50 | milestone_number: 1 51 | }) 52 | 53 | expect(github.issues.createMilestone).toHaveBeenCalledWith({ 54 | owner: 'bkeepers', 55 | repo: 'test', 56 | title: 'added' 57 | }) 58 | 59 | expect(github.issues.updateMilestone).toHaveBeenCalledWith({ 60 | owner: 'bkeepers', 61 | repo: 'test', 62 | title: 'new-description', 63 | description: 'modified-description', 64 | milestone_number: 2 65 | }) 66 | 67 | expect(github.issues.updateMilestone).toHaveBeenCalledWith({ 68 | owner: 'bkeepers', 69 | repo: 'test', 70 | title: 'new-state', 71 | state: 'closed', 72 | milestone_number: 4 73 | }) 74 | 75 | expect(github.issues.deleteMilestone).toHaveBeenCalledTimes(1) 76 | expect(github.issues.updateMilestone).toHaveBeenCalledTimes(2) 77 | expect(github.issues.createMilestone).toHaveBeenCalledTimes(1) 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/repository.test.js: -------------------------------------------------------------------------------- 1 | import Repository from '../../../../lib/plugins/repository' 2 | import { jest } from '@jest/globals' 3 | 4 | describe('Repository', () => { 5 | let github 6 | 7 | function configure (config) { 8 | return new Repository(github, { owner: 'bkeepers', repo: 'test' }, config) 9 | } 10 | 11 | beforeEach(() => { 12 | github = { 13 | repos: { 14 | get: jest.fn().mockImplementation(() => Promise.resolve({})), 15 | update: jest.fn().mockImplementation(() => Promise.resolve()), 16 | replaceAllTopics: jest.fn().mockImplementation(() => Promise.resolve()), 17 | enableVulnerabilityAlerts: jest.fn().mockImplementation(() => Promise.resolve()), 18 | disableVulnerabilityAlerts: jest.fn().mockImplementation(() => Promise.resolve()), 19 | enableAutomatedSecurityFixes: jest.fn().mockImplementation(() => Promise.resolve()), 20 | disableAutomatedSecurityFixes: jest.fn().mockImplementation(() => Promise.resolve()) 21 | } 22 | } 23 | }) 24 | 25 | describe('sync', () => { 26 | it('syncs repository settings', () => { 27 | const plugin = configure({ 28 | name: 'test', 29 | description: 'Hello World!' 30 | }) 31 | return plugin.sync().then(() => { 32 | expect(github.repos.update).toHaveBeenCalledWith({ 33 | owner: 'bkeepers', 34 | repo: 'test', 35 | name: 'test', 36 | description: 'Hello World!', 37 | mediaType: { previews: ['baptiste'] } 38 | }) 39 | }) 40 | }) 41 | 42 | it('handles renames', () => { 43 | const plugin = configure({ 44 | name: 'new-name' 45 | }) 46 | return plugin.sync().then(() => { 47 | expect(github.repos.update).toHaveBeenCalledWith({ 48 | owner: 'bkeepers', 49 | repo: 'test', 50 | name: 'new-name', 51 | mediaType: { previews: ['baptiste'] } 52 | }) 53 | }) 54 | }) 55 | 56 | it('syncs topics', () => { 57 | const plugin = configure({ 58 | topics: 'foo, bar' 59 | }) 60 | 61 | return plugin.sync().then(() => { 62 | expect(github.repos.replaceAllTopics).toHaveBeenCalledWith({ 63 | owner: 'bkeepers', 64 | repo: 'test', 65 | names: ['foo', 'bar'], 66 | mediaType: { 67 | previews: ['mercy'] 68 | } 69 | }) 70 | }) 71 | }) 72 | 73 | describe('vulnerability alerts', () => { 74 | it('it skips if not set', () => { 75 | const plugin = configure({ 76 | enable_vulnerability_alerts: undefined 77 | }) 78 | 79 | return plugin.sync().then(() => { 80 | expect(github.repos.enableVulnerabilityAlerts).not.toHaveBeenCalledWith({ 81 | owner: 'bkeepers', 82 | repo: 'test', 83 | mediaType: { 84 | previews: ['dorian'] 85 | } 86 | }) 87 | }) 88 | }) 89 | 90 | it('enables vulerability alerts when set to true', () => { 91 | const plugin = configure({ 92 | enable_vulnerability_alerts: true 93 | }) 94 | 95 | return plugin.sync().then(() => { 96 | expect(github.repos.enableVulnerabilityAlerts).toHaveBeenCalledWith({ 97 | owner: 'bkeepers', 98 | repo: 'test', 99 | mediaType: { 100 | previews: ['dorian'] 101 | } 102 | }) 103 | }) 104 | }) 105 | 106 | it('disables vulerability alerts when set to false', () => { 107 | const plugin = configure({ 108 | enable_vulnerability_alerts: false 109 | }) 110 | 111 | return plugin.sync().then(() => { 112 | expect(github.repos.disableVulnerabilityAlerts).toHaveBeenCalledWith({ 113 | owner: 'bkeepers', 114 | repo: 'test', 115 | mediaType: { 116 | previews: ['dorian'] 117 | } 118 | }) 119 | }) 120 | }) 121 | }) 122 | 123 | describe('automated security fixes', () => { 124 | it('it skips if not set', () => { 125 | const plugin = configure({ 126 | enable_automated_security_fixes: undefined 127 | }) 128 | 129 | return plugin.sync().then(() => { 130 | expect(github.repos.enableAutomatedSecurityFixes).not.toHaveBeenCalledWith({ 131 | owner: 'bkeepers', 132 | repo: 'test', 133 | mediaType: { 134 | previews: ['london'] 135 | } 136 | }) 137 | }) 138 | }) 139 | 140 | it('enables vulerability alerts when set to true', () => { 141 | const plugin = configure({ 142 | enable_automated_security_fixes: true 143 | }) 144 | 145 | return plugin.sync().then(() => { 146 | expect(github.repos.enableAutomatedSecurityFixes).toHaveBeenCalledWith({ 147 | owner: 'bkeepers', 148 | repo: 'test', 149 | mediaType: { 150 | previews: ['london'] 151 | } 152 | }) 153 | }) 154 | }) 155 | 156 | it('disables vulerability alerts when set to false', () => { 157 | const plugin = configure({ 158 | enable_automated_security_fixes: false 159 | }) 160 | 161 | return plugin.sync().then(() => { 162 | expect(github.repos.disableAutomatedSecurityFixes).toHaveBeenCalledWith({ 163 | owner: 'bkeepers', 164 | repo: 'test', 165 | mediaType: { 166 | previews: ['london'] 167 | } 168 | }) 169 | }) 170 | }) 171 | }) 172 | }) 173 | }) 174 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/rulesets.test.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | import { when } from 'jest-when' 3 | import any from '@travi/any' 4 | 5 | import Rulesets from '../../../../lib/plugins/rulesets' 6 | 7 | function configure (github, owner, repo, config) { 8 | return new Rulesets(github, { owner, repo }, config) 9 | } 10 | 11 | describe('rulesets', () => { 12 | let github 13 | const owner = any.word() 14 | const repo = any.word() 15 | 16 | beforeEach(() => { 17 | github = { 18 | repos: { 19 | createRepoRuleset: jest.fn(), 20 | deleteRepoRuleset: jest.fn(), 21 | getRepoRuleset: jest.fn(), 22 | getRepoRulesets: jest.fn(), 23 | updateRepoRuleset: jest.fn() 24 | } 25 | } 26 | }) 27 | 28 | it('should sync rulesets', async () => { 29 | const additionalRule = any.simpleObject() 30 | const existingRulesetId = any.integer() 31 | const secondExistingRulesetId = any.integer() 32 | const removedRulesetId = any.integer() 33 | const existingRuleset = { name: any.word(), rules: [any.simpleObject()] } 34 | const secondExistingRuleset = { name: any.word(), rules: [any.simpleObject()] } 35 | const newRuleset = { name: any.word() } 36 | const plugin = configure(github, owner, repo, [ 37 | newRuleset, 38 | { ...existingRuleset, rules: [...existingRuleset.rules, additionalRule] }, 39 | secondExistingRuleset 40 | ]) 41 | const existingRulesets = [ 42 | { id: existingRulesetId, ...existingRuleset }, 43 | { id: secondExistingRulesetId, ...secondExistingRuleset }, 44 | { id: removedRulesetId, name: any.word() } 45 | ] 46 | when(github.repos.getRepoRulesets) 47 | .calledWith({ owner, repo, includes_parents: false }) 48 | .mockResolvedValue({ 49 | data: existingRulesets.map( 50 | ({ rules, conditions, bypass_actors: bypassActors, ...rulesetListProperties }) => rulesetListProperties 51 | ) 52 | }) 53 | existingRulesets.forEach(ruleset => { 54 | when(github.repos.getRepoRuleset) 55 | .calledWith({ owner, repo, ruleset_id: ruleset.id }) 56 | .mockResolvedValue({ data: ruleset }) 57 | }) 58 | 59 | await plugin.sync() 60 | 61 | expect(github.repos.createRepoRuleset).toHaveBeenCalledWith({ owner, repo, ...newRuleset }) 62 | expect(github.repos.updateRepoRuleset).toHaveBeenCalledWith({ 63 | owner, 64 | repo, 65 | ruleset_id: existingRulesetId, 66 | ...existingRuleset, 67 | rules: [...existingRuleset.rules, additionalRule] 68 | }) 69 | expect(github.repos.updateRepoRuleset).not.toHaveBeenCalledWith({ 70 | owner, 71 | repo, 72 | ruleset_id: secondExistingRulesetId, 73 | ...secondExistingRuleset 74 | }) 75 | expect(github.repos.deleteRepoRuleset).toHaveBeenCalledWith({ owner, repo, ruleset_id: removedRulesetId }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/unit/lib/plugins/teams.test.js: -------------------------------------------------------------------------------- 1 | import { when } from 'jest-when' 2 | import any from '@travi/any' 3 | import Teams from '../../../../lib/plugins/teams' 4 | import { jest } from '@jest/globals' 5 | 6 | describe('Teams', () => { 7 | let github 8 | const addedTeamName = 'added' 9 | const addedTeamId = any.integer() 10 | const updatedTeamName = 'updated-permission' 11 | const updatedTeamId = any.integer() 12 | const removedTeamName = 'removed' 13 | const removedTeamId = any.integer() 14 | const unchangedTeamName = 'unchanged' 15 | const unchangedTeamId = any.integer() 16 | const org = 'bkeepers' 17 | 18 | function configure (config) { 19 | return new Teams(github, { owner: 'bkeepers', repo: 'test' }, config) 20 | } 21 | 22 | beforeEach(() => { 23 | github = { 24 | paginate: jest.fn().mockImplementation(() => Promise.resolve()), 25 | repos: { 26 | listTeams: jest.fn().mockImplementation(() => 27 | Promise.resolve({ 28 | data: [ 29 | { id: unchangedTeamId, slug: unchangedTeamName, permission: 'push' }, 30 | { id: removedTeamId, slug: removedTeamName, permission: 'push' }, 31 | { id: updatedTeamId, slug: updatedTeamName, permission: 'pull' } 32 | ] 33 | }) 34 | ) 35 | }, 36 | request: jest.fn() 37 | } 38 | }) 39 | 40 | describe('sync', () => { 41 | it('syncs teams', async () => { 42 | const plugin = configure([ 43 | { name: unchangedTeamName, permission: 'push' }, 44 | { name: updatedTeamName, permission: 'admin' }, 45 | { name: addedTeamName, permission: 'pull' } 46 | ]) 47 | when(github.request) 48 | .calledWith('GET /orgs/:org/teams/:team_slug', { org, team_slug: addedTeamName }) 49 | .mockResolvedValue({ data: { id: addedTeamId } }) 50 | 51 | await plugin.sync() 52 | 53 | expect(github.request).toHaveBeenCalledWith('PUT /teams/:team_id/repos/:owner/:repo', { 54 | org, 55 | owner: org, 56 | repo: 'test', 57 | team_id: updatedTeamId, 58 | permission: 'admin' 59 | }) 60 | 61 | expect(github.request).toHaveBeenCalledWith('PUT /teams/:team_id/repos/:owner/:repo', { 62 | org, 63 | owner: org, 64 | repo: 'test', 65 | team_id: addedTeamId, 66 | permission: 'pull' 67 | }) 68 | 69 | expect(github.request).toHaveBeenCalledWith('DELETE /teams/:team_id/repos/:owner/:repo', { 70 | org, 71 | owner: org, 72 | repo: 'test', 73 | team_id: removedTeamId 74 | }) 75 | }) 76 | }) 77 | }) 78 | --------------------------------------------------------------------------------