├── .codecov.yml ├── .dockerignore ├── .env.template ├── .github ├── mergeable.settings.yml ├── mergeable.yml └── workflows │ ├── release.yml │ └── testing.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __fixtures__ ├── e2e │ ├── check.create.response.json │ ├── check.update.response.json │ ├── config.v1.default.yml │ ├── helper.js │ ├── issue.listComment.response.json │ ├── pr.listFile.response.json │ ├── pr.payload.json │ └── settings.v1.default.yml ├── setup │ └── jestSetUp.js └── unit │ └── helper.js ├── __tests__ ├── e2e │ └── smoke.test.js └── unit │ ├── actions │ ├── action.test.js │ ├── assign.test.js │ ├── checks.test.js │ ├── close.test.js │ ├── comment.test.js │ ├── labels.test.js │ ├── lib │ │ └── searchAndReplaceSpecialAnnotation.test.js │ ├── merge.test.js │ └── request_review.test.js │ ├── configuration │ ├── configuration.test.js │ └── transformers │ │ ├── v1Config.test.js │ │ └── v2Config.test.js │ ├── filters │ ├── and.test.js │ ├── author.test.js │ ├── baseRef.test.js │ ├── filter.test.js │ ├── not.test.js │ ├── options_processor │ │ └── options │ │ │ └── boolean.test.js │ ├── or.test.js │ ├── payload.test.js │ └── repository.test.js │ ├── flex │ └── flex.test.js │ ├── github │ └── api.test.js │ ├── interceptors │ ├── checkReRun.test.js │ ├── milestoned.test.js │ └── push.test.js │ ├── mergeable.test.js │ ├── metaData.test.js │ ├── settings │ └── settings.test.js │ └── validators │ ├── age.test.js │ ├── and.test.js │ ├── approvals.test.js │ ├── assignee.test.js │ ├── author.test.js │ ├── baseRef.test.js │ ├── changeset.test.js │ ├── commit.test.js │ ├── contents.test.js │ ├── dependent.test.js │ ├── description.test.js │ ├── headRef.test.js │ ├── label.test.js │ ├── lastComment.test.js │ ├── milestone.test.js │ ├── not.test.js │ ├── options_processor │ ├── assignees.test.js │ ├── options.test.js │ ├── options │ │ ├── and.test.js │ │ ├── begins_with.test.js │ │ ├── ends_with.test.js │ │ ├── jira.test.js │ │ ├── lib │ │ │ └── consolidateResults.test.js │ │ ├── max.test.js │ │ ├── min.test.js │ │ ├── must_exclude.test.js │ │ ├── must_include.test.js │ │ ├── no_empty.test.js │ │ ├── or.test.js │ │ └── required.test.js │ ├── owners.test.js │ └── teams.test.js │ ├── or.test.js │ ├── project.test.js │ ├── size.test.js │ ├── stale.test.js │ ├── title.test.js │ └── validator.test.js ├── _config.yml ├── compose.yaml ├── docs ├── Makefile ├── actions │ ├── assign.rst │ ├── checks.rst │ ├── close.rst │ ├── comment.rst │ ├── labels.rst │ ├── merge.rst │ └── request_review.rst ├── annotations.rst ├── changelog.rst ├── conf.py ├── configurable.rst ├── configuration.rst ├── contributing.rst ├── deployment.rst ├── filters │ ├── author.rst │ ├── baseRef.rst │ ├── payload.rst │ └── repository.rst ├── index.rst ├── operators │ ├── and.rst │ ├── not.rst │ └── or.rst ├── options │ ├── begins_with.rst │ ├── boolean.rst │ ├── ends_with.rst │ ├── jira.rst │ ├── max.rst │ ├── min.rst │ ├── must_exclude.rst │ ├── must_include.rst │ ├── no_empty.rst │ └── required.rst ├── recipes.rst ├── support.rst ├── usage.rst └── validators │ ├── age.rst │ ├── approval.rst │ ├── assignee.rst │ ├── author.rst │ ├── baseRef.rst │ ├── changeset.rst │ ├── commit.rst │ ├── contents.rst │ ├── dependent.rst │ ├── description.rst │ ├── headRef.rst │ ├── label.rst │ ├── lastComment.rst │ ├── milestone.rst │ ├── project.rst │ ├── size.rst │ ├── stale.rst │ └── title.rst ├── helm └── mergeable │ ├── .helmignore │ ├── Chart.lock │ ├── Chart.yaml │ ├── charts │ └── redis-9.5.0.tgz │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── deployment.yaml │ ├── hpa.yaml │ ├── ingress.yaml │ ├── namespace.yaml │ ├── prometheus-rules.yaml │ ├── prometheus-servicemonitor.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── tests │ │ └── test-connection.yaml │ └── values.yaml ├── index.js ├── lib ├── actions │ ├── action.js │ ├── assign.js │ ├── checks.js │ ├── close.js │ ├── comment.js │ ├── handlebars │ │ └── populateTemplate.js │ ├── labels.js │ ├── lib │ │ ├── createCheckName.js │ │ └── searchAndReplaceSpecialAnnotation.js │ ├── merge.js │ └── request_review.js ├── cache │ └── cache.js ├── configuration │ ├── configuration.js │ ├── lib │ │ └── consts.js │ └── transformers │ │ ├── v1Config.js │ │ └── v2Config.js ├── context.js ├── errors │ ├── teamNotFoundError.js │ └── unSupportedSettingError.js ├── eventAware.js ├── filters │ ├── and.js │ ├── author.js │ ├── baseRef.js │ ├── filter.js │ ├── lib │ │ └── logicalConnectiveValidatorProcessor.js │ ├── not.js │ ├── options_processor │ │ ├── name.js │ │ ├── options.js │ │ ├── options │ │ │ ├── and.js │ │ │ ├── boolean.js │ │ │ ├── lib │ │ │ │ ├── consolidateResults.js │ │ │ │ ├── constructError.js │ │ │ │ └── constructOutput.js │ │ │ ├── must_exclude.js │ │ │ ├── must_include.js │ │ │ ├── none_of.js │ │ │ ├── one_of.js │ │ │ └── or.js │ │ ├── topics.js │ │ └── visibility.js │ ├── or.js │ ├── payload.js │ └── repository.js ├── flex │ ├── flex.js │ └── lib │ │ ├── createPromises.js │ │ ├── getActionPromises.js │ │ ├── getFilterPromises.js │ │ ├── getValidatorPromises.js │ │ ├── logAndProcessConfigErrors.js │ │ └── processWorkflow.js ├── github │ └── api.js ├── interceptors │ ├── checkReRun.js │ ├── index.js │ ├── interceptor.js │ ├── milestoned.js │ └── push.js ├── logger.js ├── mergeable.js ├── metaData.js ├── register.js ├── settings │ ├── lib │ │ └── consts.js │ ├── settings.js │ └── transformers │ │ └── v1Settings.js ├── stats │ ├── extractValidationStats.js │ └── githubPrometheusStats.js ├── utils │ ├── githubRateLimitEndpoint.js │ └── logTypes.js └── validators │ ├── age.js │ ├── and.js │ ├── approvals.js │ ├── assignee.js │ ├── author.js │ ├── baseRef.js │ ├── changeset.js │ ├── commit.js │ ├── contents.js │ ├── dependent.js │ ├── description.js │ ├── headRef.js │ ├── label.js │ ├── lastComment.js │ ├── lib │ └── logicalConnectiveValidatorProcessor.js │ ├── milestone.js │ ├── not.js │ ├── options_processor │ ├── assignees.js │ ├── deepValidation.js │ ├── gitPattern.js │ ├── listProcessor.js │ ├── options.js │ ├── options │ │ ├── and.js │ │ ├── begins_with.js │ │ ├── ends_with.js │ │ ├── jira.js │ │ ├── lib │ │ │ ├── andOrProcessor.js │ │ │ ├── consolidateResults.js │ │ │ ├── constructErrorOutput.js │ │ │ └── constructOutput.js │ │ ├── max.js │ │ ├── min.js │ │ ├── must_exclude.js │ │ ├── must_include.js │ │ ├── no_empty.js │ │ ├── none_of.js │ │ ├── one_of.js │ │ ├── or.js │ │ └── required.js │ ├── owners.js │ ├── requestedReviewers.js │ └── teams.js │ ├── or.js │ ├── project.js │ ├── size.js │ ├── stale.js │ ├── title.js │ └── validator.js ├── m.png ├── mergeable-flex.png ├── mergeable.png ├── package-lock.json ├── package.json ├── scheduler ├── fixtures │ └── installation-created.json ├── index.js └── test.js ├── screenshot.gif └── version1.md /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 91% 6 | threshold: 1% 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Include any files or directories that you don't want to be copied to your 2 | # container here (e.g., local build artifacts, temporary files, etc.). 3 | # 4 | # For more help, visit the .dockerignore file reference guide at 5 | # https://docs.docker.com/go/build-context-dockerignore/ 6 | 7 | npm-debug.log 8 | *.pem 9 | .env 10 | coverage 11 | **/.classpath 12 | **/.dockerignore 13 | **/.env 14 | **/.git 15 | **/.gitignore 16 | **/.project 17 | **/.settings 18 | **/.toolstarget 19 | **/.vs 20 | **/.vscode 21 | **/.next 22 | **/.cache 23 | **/*.*proj.user 24 | **/*.dbmdl 25 | **/*.jfm 26 | **/charts 27 | **/docker-compose* 28 | **/compose.y*ml 29 | **/Dockerfile* 30 | **/node_modules 31 | **/npm-debug.log 32 | **/obj 33 | **/secrets.dev.yaml 34 | **/values.dev.yaml 35 | **/build 36 | **/dist 37 | LICENSE 38 | README.md 39 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # Environment variables required for running the bot locally. 2 | # Don't edit me directly - copy to a file called `.env` and edit there. 3 | # See ./docs/deploy.md for more info. 4 | 5 | # This is shown on the GitHub app page after creating your app. 6 | APP_ID= 7 | 8 | # Provide the key or path to key file (you don't need both). 9 | # You should have downloaded the key after creating your app on GitHub. 10 | PRIVATE_KEY= 11 | PRIVATE_KEY_PATH= 12 | 13 | # Set this to the GitHub webhook secret configured for your app. 14 | WEBHOOK_SECRET= 15 | 16 | # Set this to the webhook URL created with `smee`. 17 | WEBHOOK_PROXY_URL= 18 | 19 | # Needed only for GitHub Enterprise deployments. 20 | GHE_HOST= 21 | 22 | # name of the GitHub App 23 | APP_NAME= 24 | 25 | # Set this to `true` if you want to use the configuration from the `.github` repository as the default. 26 | # This will make the repository config be merged with the organization-wide config. 27 | USE_ORG_AS_DEFAULT_CONFIG= 28 | 29 | ## Where to store cache - supported values; memory, redis 30 | CACHE_STORAGE=memory 31 | 32 | ## How many objects should be living in cache. After this limit, older will be garbaged. 33 | CACHE_MEMORY_MAX= 34 | 35 | ## Time to live of Cache objects 36 | CACHE_TTL= 37 | 38 | ## If refreshThreshold is set and if the ttl method is available for the used store, 39 | ## after retrieving a value from cache TTL will be checked. If the remaining TTL is less than refreshThreshold, 40 | ## the system will spawn a background worker to update the value, following same rules as standard fetching. 41 | ## In the meantime, the system will return the old value until expiration. 42 | CACHE_REDIS_REFRESH_THRESHOLD= 43 | 44 | ## Redis cache configuration 45 | CACHE_REDIS_HOST= 46 | CACHE_REDIS_PORT= 47 | CACHE_REDIS_PASSWORD= 48 | CACHE_REDIS_DB= 49 | -------------------------------------------------------------------------------- /.github/mergeable.settings.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | mergeable: 4 | use_config_from_pull_request: true 5 | use_config_cache: false 6 | use_org_as_default_config: false 7 | -------------------------------------------------------------------------------- /.github/mergeable.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | mergeable: 3 | - when: pull_request.*, pull_request_review.* 4 | validate: 5 | - do: title 6 | # Enforce semantic release convention. 7 | must_include: 8 | regex: ^(feat|docs|chore|fix|refactor|test|style|perf)(\(\w+\))?:.+$ 9 | message: Semantic release conventions must be followed. 10 | # All todo check boxes must be checked. 11 | - do: description 12 | must_exclude: 13 | regex: \[ \] 14 | message: There are incomplete TODO task(s) unchecked. 15 | - do: approvals 16 | min: 17 | count: 1 18 | or: 19 | - required: 20 | reviewers: [ jusx ] 21 | - required: 22 | reviewers: [ shine2lay ] 23 | - do: or 24 | # if the PR is a new feature or a fix, it must be logged in the changelog 25 | validate: 26 | - do: and 27 | validate: 28 | - do: title 29 | must_include: 30 | regex: ^(feat|fix)(\(\w+\))?:.+$ 31 | - do: changeset 32 | must_include: 33 | regex: 'docs/changelog.rst' 34 | message: 'new features or fixes needed to be logged to the changelog' 35 | - do: title 36 | must_exclude: 37 | regex: ^(feat|fix)(\(\w+\))?:.+$ 38 | message: 'new features or fixes needed to be logged to the changelog' 39 | pass: 40 | - do: merge 41 | merge_method: 'squash' 42 | - do: checks 43 | status: 'success' 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | DOCKER_IMAGE_NAME: mergeability/mergeable 10 | 11 | jobs: 12 | build: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Semantic Release 19 | id: semantic 20 | uses: cycjimmy/semantic-release-action@v4 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 24 | - name: Login to Docker Hub 25 | uses: docker/login-action@v3 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | if: ${{ steps.semantic.outputs.new_release_version }} 30 | - uses: buildpacks/github-actions/setup-pack@v5.7.2 31 | if: ${{ steps.semantic.outputs.new_release_version }} 32 | - name: Build and publish docker image 33 | if: ${{ steps.semantic.outputs.new_release_version }} 34 | run: pack build ${{ env.DOCKER_IMAGE_NAME }} -t ${{ env.DOCKER_IMAGE_NAME }}:${{ steps.semantic.outputs.new_release_version }} -t ${{ env.DOCKER_IMAGE_NAME }}:latest -B paketobuildpacks/builder-jammy-tiny -b docker.io/paketobuildpacks/nodejs --publish 35 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Use Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: '20.x' 14 | - run: npm ci 15 | - run: npm run build --if-present 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Eclipse ### 2 | .classpath 3 | .metadata 4 | .project 5 | .settings/ 6 | .apt_generated 7 | bin/ 8 | tmp/ 9 | *.tmp 10 | *.bak 11 | *.swp 12 | *~.nib 13 | .loadpath 14 | .recommenders 15 | 16 | ### Intellij ### 17 | .idea/ 18 | *.iml 19 | 20 | ### VisualStudioCode ### 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | .factorypath 27 | 28 | ### VisualStudioCode Patch ### 29 | # Ignore all local history of files 30 | .history 31 | 32 | *.log 33 | 34 | ### Visual Code 35 | .vscode/ 36 | .factorypath 37 | 38 | ### Misc ### 39 | .DS_Store 40 | cloud-config 41 | .version 42 | tmp/ 43 | null 44 | **/tests-results 45 | **/golden-data/ 46 | !/geneva-server/src/**/target 47 | .scannerwork/ 48 | node_modules 49 | npm-debug.log 50 | *.pem 51 | .env 52 | coverage 53 | /node_modules 54 | /dist 55 | /tmp 56 | .env 57 | /coverage 58 | /coverage_* 59 | /artifacts 60 | .DS_Store 61 | jsconfig.json 62 | /test-reports 63 | /cypress/videos 64 | /cypress/screenshots 65 | .classpath 66 | .project 67 | .settings/ 68 | .vscode/ 69 | ssl/ 70 | dive.log 71 | test-dist/ 72 | test-results.xml 73 | .version 74 | mochawesome-report/ 75 | mochawesome.json 76 | mochawesome.html 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [style]: https://standardjs.com/ 6 | [code-of-conduct]: CODE_OF_CONDUCT.md 7 | 8 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 9 | 10 | Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. 11 | 12 | ## Submitting a pull request 13 | 14 | 1. [Fork][fork] and clone the repository 15 | 1. Configure and install the dependencies: `npm install` 16 | 1. Make sure the tests pass on your machine: `npm test`, note: these tests also apply the linter, so no need to lint separately 17 | 1. Create a new branch: `git checkout -b my-branch-name` 18 | 1. Make your change, add tests, and make sure the tests still pass 19 | 1. Push to your fork and submit a pull request 20 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 21 | 22 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 23 | 24 | - Follow the [style guide](https://standardjs.com/) which is using standard. Any linting errors will be shown when running `npm test` 25 | - Write and update tests. 26 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 27 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 28 | 29 | Work in Progress pull request are also welcome to get feedback early on, or if there is something blocked you. 30 | 31 | ## Resources 32 | 33 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 34 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 35 | - [GitHub Help](https://help.github.com) 36 | -------------------------------------------------------------------------------- /__fixtures__/e2e/config.v1.default.yml: -------------------------------------------------------------------------------- 1 | mergeable: 2 | pull_requests: 3 | title: 4 | and: 5 | - begins_with: 6 | match: 'begins_with_text' 7 | - ends_with: 8 | match: 'ends_with_text' 9 | or: 10 | - must_include: 11 | regex: 'must_be_included_text' 12 | - begins_with: 13 | match: 'begins_with_text' 14 | must_exclude: 15 | regex: 'must_be_excluded_text' 16 | must_include: 17 | regex: 'must_be_included_text' 18 | begins_with: 19 | match: 'begins_with_text' 20 | ends_with: 21 | match: 'ends_with_text' 22 | assignee: 23 | and: 24 | - max: 25 | count: 4 26 | - min: 27 | count: 1 28 | or: 29 | - max: 30 | count: 4 31 | - min: 32 | count: 1 33 | required: 34 | reviewers: ['shine2lay', 'shinelay'] 35 | max: 36 | count: 4 37 | min: 38 | count: 1 39 | no_empty: 40 | enabled: true -------------------------------------------------------------------------------- /__fixtures__/e2e/issue.listComment.response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "user": { 4 | "login": "Mergeable[bot]" 5 | }, 6 | "body": "Comment by Mergeable" 7 | } 8 | ] -------------------------------------------------------------------------------- /__fixtures__/e2e/pr.listFile.response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "sha": "bbcd538c8e72b8c175046e27cc8f907076331401", 4 | "filename": "file1.txt", 5 | "status": "added", 6 | "additions": 103, 7 | "deletions": 21, 8 | "changes": 124, 9 | "blob_url": "https://github.com/octocat/Hello-World/blob/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt", 10 | "raw_url": "https://github.com/octocat/Hello-World/raw/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt", 11 | "contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/file1.txt?ref=6dcb09b5b57875f334f61aebed695e2e4193db5e", 12 | "patch": "@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test" 13 | } 14 | ] -------------------------------------------------------------------------------- /__fixtures__/e2e/settings.v1.default.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | mergeable: 3 | use_config_from_pull_request: true 4 | use_config_cache: false 5 | use_org_as_default_config: false 6 | -------------------------------------------------------------------------------- /__fixtures__/setup/jestSetUp.js: -------------------------------------------------------------------------------- 1 | // mock Logger 2 | const mockLogTypes = require('../../lib/utils/logTypes') 3 | 4 | jest.mock('../../lib/logger.js', () => { 5 | return { 6 | init: jest.fn(), 7 | create: () => { 8 | return { 9 | info: jest.fn(), 10 | warn: jest.fn(), 11 | error: jest.fn(), 12 | debug: jest.fn(), 13 | fatal: jest.fn() 14 | } 15 | }, 16 | logTypes: mockLogTypes 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /__tests__/unit/actions/close.test.js: -------------------------------------------------------------------------------- 1 | const Close = require('../../../lib/actions/close') 2 | const Helper = require('../../../__fixtures__/unit/helper') 3 | 4 | test.each([ 5 | undefined, 6 | 'pull_request', 7 | 'issues', 8 | 'issue_comment', 9 | 'schedule' 10 | ])('check that close is called for %s events', async (eventName) => { 11 | const close = new Close() 12 | const context = Helper.mockContext({ eventName: eventName }) 13 | const schedulerResult = { 14 | validationSuites: [{ 15 | schedule: { 16 | issues: [{ number: 1, user: { login: 'scheduler' } }], 17 | pulls: [] 18 | } 19 | }] 20 | } 21 | 22 | await close.afterValidate(context, {}, '', schedulerResult) 23 | expect(context.octokit.issues.update.mock.calls.length).toBe(1) 24 | }) 25 | 26 | test('check that issue is closed', async () => { 27 | const close = new Close() 28 | const context = Helper.mockContext() 29 | 30 | await close.afterValidate(context) 31 | expect(context.octokit.issues.update.mock.calls.length).toBe(1) 32 | expect(context.octokit.issues.update.mock.calls[0][0].state).toBe('closed') 33 | }) 34 | 35 | test('check that issues from scheduler are closed', async () => { 36 | const close = new Close() 37 | const context = Helper.mockContext({ eventName: 'schedule' }) 38 | const schedulerResult = {} 39 | schedulerResult.validationSuites = [{ 40 | schedule: { 41 | issues: [{ number: 1, user: { login: 'scheduler' } }, { number: 2, user: { login: 'scheduler' } }, { number: 3, user: { login: 'scheduler' } }], 42 | pulls: [] 43 | } 44 | }] 45 | await close.afterValidate(context, {}, '', schedulerResult) 46 | expect(context.octokit.issues.update.mock.calls.length).toBe(3) 47 | expect(context.octokit.issues.update.mock.calls[0][0].state).toBe('closed') 48 | expect(context.octokit.issues.update.mock.calls[1][0].state).toBe('closed') 49 | expect(context.octokit.issues.update.mock.calls[2][0].state).toBe('closed') 50 | }) 51 | -------------------------------------------------------------------------------- /__tests__/unit/actions/lib/searchAndReplaceSpecialAnnotation.test.js: -------------------------------------------------------------------------------- 1 | const searchAndReplaceSpecialAnnotations = require('../../../../lib/actions/lib/searchAndReplaceSpecialAnnotation') 2 | 3 | describe('searchAndReplaceSpecialAnnotations', () => { 4 | const SPECIAL_ANNOTATION = { 5 | '@author': 'creator', 6 | '@action': 'created', 7 | '@sender': 'initiator', 8 | '@bot': 'Mergeable[bot]', 9 | '@repository': 'botrepo' 10 | } 11 | const tests = [ 12 | { 13 | name: 'does not affect input if no special annotations are found', 14 | message: 'no special annotations', 15 | expected: 'no special annotations' 16 | }, 17 | { 18 | name: 'special annotation at the beginning of string works properly', 19 | message: '$annotation$ says hello!', 20 | expected: '$annotation$ says hello!' 21 | }, 22 | { 23 | name: 'escape character works properly', 24 | message: 'this is \\@author', 25 | expected: 'this is @author' 26 | }, 27 | { 28 | name: 'special annotation at the end of string works properly', 29 | message: 'this is $annotation$', 30 | expected: 'this is $annotation$' 31 | }, 32 | { 33 | name: '@@annotation is replaced, prepending @ remains', 34 | message: 'this is @$annotation$', 35 | expected: 'this is @$annotation$' 36 | }, 37 | { 38 | name: 'replaces special annotation anywhere in the text', 39 | message: 'this is something$annotation$ speaking', 40 | expected: 'this is something$annotation$ speaking' 41 | } 42 | ] 43 | 44 | test.each(tests)( 45 | '$name', 46 | async ({ message, expected }) => { 47 | const payload = { 48 | user: { 49 | login: 'creator' 50 | } 51 | } 52 | const evt = { 53 | action: 'created', 54 | repository: { 55 | full_name: 'botrepo' 56 | }, 57 | sender: { 58 | login: 'initiator' 59 | } 60 | } 61 | 62 | for (const annotation of Object.keys(SPECIAL_ANNOTATION)) { 63 | const messageWithAnnotation = message.replace('$annotation$', annotation) 64 | const messageWithReplacement = expected.replace('$annotation$', SPECIAL_ANNOTATION[annotation]) 65 | expect(searchAndReplaceSpecialAnnotations(messageWithAnnotation, payload, evt)).toBe(messageWithReplacement) 66 | } 67 | } 68 | ) 69 | }) 70 | -------------------------------------------------------------------------------- /__tests__/unit/filters/baseRef.test.js: -------------------------------------------------------------------------------- 1 | const BaseRef = require('../../../lib/filters/baseRef') 2 | const Helper = require('../../../__fixtures__/unit/helper') 3 | 4 | test('should fail with unexpected baseRef', async () => { 5 | const baseRef = new BaseRef() 6 | const settings = { 7 | do: 'baseRef', 8 | must_include: { 9 | regex: 'some-other-ref' 10 | } 11 | } 12 | const filter = await baseRef.processFilter(createMockContext('some-ref'), settings) 13 | expect(filter.status).toBe('fail') 14 | }) 15 | 16 | test('should pass with expected baseRef', async () => { 17 | const baseRef = new BaseRef() 18 | const settings = { 19 | do: 'baseRef', 20 | must_include: { 21 | regex: 'some-ref' 22 | } 23 | } 24 | const filter = await baseRef.processFilter(createMockContext('some-ref'), settings) 25 | expect(filter.status).toBe('pass') 26 | }) 27 | 28 | test('should fail with excluded baseRef', async () => { 29 | const baseRef = new BaseRef() 30 | const settings = { 31 | do: 'baseRef', 32 | must_exclude: { 33 | regex: 'some-ref' 34 | } 35 | } 36 | const filter = await baseRef.processFilter(createMockContext('some-ref'), settings) 37 | expect(filter.status).toBe('fail') 38 | }) 39 | 40 | test('should pass with excluded baseRef', async () => { 41 | const baseRef = new BaseRef() 42 | const settings = { 43 | do: 'baseRef', 44 | must_exclude: { 45 | regex: 'some-other-ref' 46 | } 47 | } 48 | const filter = await baseRef.processFilter(createMockContext('some-ref'), settings) 49 | expect(filter.status).toBe('pass') 50 | }) 51 | 52 | const createMockContext = (baseRef) => { 53 | return Helper.mockContext({ baseRef }) 54 | } 55 | -------------------------------------------------------------------------------- /__tests__/unit/filters/options_processor/options/boolean.test.js: -------------------------------------------------------------------------------- 1 | const BooleanMatch = require('../../../../../lib/filters/options_processor/options/boolean') 2 | 3 | const filterContext = undefined 4 | 5 | const filter = { 6 | name: 'payload', 7 | supportedOptions: [ 8 | 'boolean', 9 | 'must_exclude', 10 | 'must_include'] 11 | } 12 | 13 | const verify = (match, input, result) => { 14 | const rule = { boolean: { match } } 15 | const res = BooleanMatch.process(filterContext, filter, input, rule) 16 | expect(res.status).toBe(result) 17 | return res 18 | } 19 | 20 | test('return pass if input is a string that meets the criteria', () => { 21 | verify(true, 'true', 'pass') 22 | verify(false, 'false', 'pass') 23 | }) 24 | 25 | test('return pass if input is a boolean that meets the criteria', () => { 26 | verify(true, true, 'pass') 27 | verify(false, false, 'pass') 28 | }) 29 | 30 | test('return fail if input does not meet the criteria', () => { 31 | verify(true, false, 'fail') 32 | verify(false, 'true', 'fail') 33 | }) 34 | 35 | test('return error if inputs are not in expected format', async () => { 36 | const rule = { boolean: { match: true } } 37 | const input = ['true'] 38 | try { 39 | const config = BooleanMatch.process(filterContext, filter, input, rule) 40 | expect(config).toBeUndefined() 41 | } catch (e) { 42 | expect(e.message).toBe('Input type invalid, expected strings "true" or "false", or boolean literal `true` or `false` as input') 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /__tests__/unit/interceptors/milestoned.test.js: -------------------------------------------------------------------------------- 1 | const Milestoned = require('../../../lib/interceptors/milestoned') 2 | const Helper = require('../../../__fixtures__/unit/helper') 3 | require('object-dot').extend() 4 | 5 | const milestoned = new Milestoned() 6 | test('#valid', () => { 7 | const context = Helper.mockContext() 8 | expect(milestoned.valid(context)).toBe(false) 9 | 10 | context.eventName = 'issues' 11 | expect(milestoned.valid(context)).toBe(false) 12 | 13 | context.payload.issue = {} 14 | context.payload.action = 'milestoned' 15 | expect(milestoned.valid(context)).toBe(false) 16 | 17 | context.payload.issue.pull_request = {} 18 | expect(milestoned.valid(context)).toBe(true) 19 | 20 | context.payload.action = 'demilestoned' 21 | expect(milestoned.valid(context)).toBe(true) 22 | 23 | context.payload.action = 'edited' 24 | expect(milestoned.valid(context)).toBe(false) 25 | }) 26 | 27 | test('#process', async () => { 28 | let context = Helper.mockContext() 29 | context.eventName = 'issues' 30 | context.payload.action = 'milestoned' 31 | Object.set(context, 'payload.issue.pull_request', {}) 32 | context.payload.issue.number = 12 33 | context.octokit.pulls.get.mockReturnValue({ data: { number: 12 } }) 34 | 35 | // make sure we setup context correctly. 36 | expect(milestoned.valid(context)).toBe(true) 37 | 38 | context = await milestoned.process(context) 39 | expect(context.payload.pull_request.number).toBe(12) 40 | expect(context.eventName).toBe('pull_request') 41 | }) 42 | -------------------------------------------------------------------------------- /__tests__/unit/mergeable.test.js: -------------------------------------------------------------------------------- 1 | const { Mergeable } = require('../../lib/mergeable') 2 | 3 | describe('Mergeable', () => { 4 | test('starting in dev mode and flexed correctly', async () => { 5 | const mergeable = startMergeable('development') 6 | expect(mergeable.schedule).toBeCalledWith(mockRobot, { interval: 2000 }) 7 | expect(mergeable.flex).toHaveBeenCalledTimes(1) 8 | }) 9 | 10 | test('starting in production mode and flexed correctly', async () => { 11 | const mergeable = startMergeable('production') 12 | expect(mergeable.schedule).toBeCalledWith(mockRobot, { interval: 60 * 60 * 1000 }) 13 | expect(mergeable.flex).toBeCalledWith(mockRobot) 14 | }) 15 | }) 16 | 17 | const startMergeable = (mode, version) => { 18 | process.env.MERGEABLE_SCHEDULER = true 19 | const mergeable = new Mergeable(mode, version) 20 | mergeable.schedule = jest.fn() 21 | mergeable.flex = jest.fn() 22 | mergeable.start(mockRobot) 23 | return mergeable 24 | } 25 | 26 | const mockRobot = { 27 | on: jest.fn(), 28 | log: { 29 | child: () => { 30 | return { 31 | debug: jest.fn(), 32 | info: jest.fn() 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /__tests__/unit/metaData.test.js: -------------------------------------------------------------------------------- 1 | const MetaData = require('../../lib/metaData') 2 | 3 | const dataText = '' 4 | 5 | test('#deserialize', () => { 6 | const json = MetaData.deserialize(` 7 | #### :x: Validator: TITLE * :x: 8 | ***title must begins with "feat,test,chore" 9 | *** Input : use-case: title Settings : \`\`\`{"begins_with":{"match":["feat","test","chore"]}}\`\`\` 10 | 11 | `) 12 | expect(json.id).toBe(1) 13 | expect(json.eventName).toBe('pull_request') 14 | expect(json.action).toBe('unlabeled') 15 | }) 16 | 17 | test('#serialize', () => { 18 | const obj = { 19 | id: 1, 20 | eventName: 'issues', 21 | action: 'milestoned' 22 | } 23 | 24 | const seText = MetaData.serialize(obj) 25 | expect(seText).toBe(dataText) 26 | expect(MetaData.deserialize(seText)).toEqual(obj) 27 | }) 28 | 29 | test('#exists', () => { 30 | expect(MetaData.exists(dataText)).toBe(true) 31 | expect(MetaData.exists('abc ')).toBe(false) 33 | expect(MetaData.exists(undefined)).toBe(false) 34 | }) 35 | -------------------------------------------------------------------------------- /__tests__/unit/validators/headRef.test.js: -------------------------------------------------------------------------------- 1 | const HeadRef = require('../../../lib/validators/headRef') 2 | const Helper = require('../../../__fixtures__/unit/helper') 3 | 4 | test('fail gracefully if invalid regex', async () => { 5 | const headRef = new HeadRef() 6 | 7 | const settings = { 8 | do: 'headRef', 9 | must_exclude: { 10 | regex: '@#$@#$@#$' 11 | } 12 | } 13 | 14 | const headRefValidation = await headRef.processValidate(mockContext('WIP HeadRef'), settings) 15 | expect(headRefValidation.status).toBe('pass') 16 | }) 17 | 18 | test('checks that it fail when exclude regex is in headRef', async () => { 19 | const headRef = new HeadRef() 20 | 21 | const settings = { 22 | do: 'headRef', 23 | must_include: { 24 | regex: '^\\(feat\\)|^\\(doc\\)|^\\(fix\\)' 25 | }, 26 | must_exclude: { 27 | regex: 'wip' 28 | } 29 | } 30 | 31 | let headRefValidation = await headRef.processValidate(mockContext('WIP HeadRef'), settings) 32 | expect(headRefValidation.status).toBe('fail') 33 | 34 | headRefValidation = await headRef.processValidate(mockContext('(feat) WIP HeadRef'), settings) 35 | expect(headRefValidation.status).toBe('fail') 36 | }) 37 | 38 | test('checks that advance setting of must_include works', async () => { 39 | const headRef = new HeadRef() 40 | 41 | const includeList = '`^\\(feat\\)|^\\(doc\\)|^\\(fix\\)`' 42 | const testMessage = 'this is a test message' 43 | 44 | const settings = { 45 | do: 'headRef', 46 | must_include: { 47 | regex: includeList, 48 | message: testMessage 49 | }, 50 | must_exclude: { 51 | regex: 'wip' 52 | } 53 | } 54 | 55 | let headRefValidation = await headRef.processValidate(mockContext('include HeadRef'), settings) 56 | expect(headRefValidation.status).toBe('fail') 57 | expect(headRefValidation.validations[0].description).toBe(testMessage) 58 | 59 | headRefValidation = await headRef.processValidate(mockContext('(feat) WIP HeadRef'), settings) 60 | 61 | expect(headRefValidation.status).toBe('fail') 62 | }) 63 | 64 | const mockContext = headRef => { 65 | const context = Helper.mockContext({ headRef: headRef }) 66 | return context 67 | } 68 | -------------------------------------------------------------------------------- /__tests__/unit/validators/milestone.test.js: -------------------------------------------------------------------------------- 1 | const Milestone = require('../../../lib/validators/milestone') 2 | const Helper = require('../../../__fixtures__/unit/helper') 3 | 4 | test('should be false when a different milestone is specified', async () => { 5 | const milestone = new Milestone() 6 | const settings = { 7 | do: 'milestone', 8 | must_include: { 9 | regex: 'Version 2' 10 | } 11 | } 12 | const validation = await milestone.processValidate(createMockContext({ title: 'Version 1' }), settings) 13 | expect(validation.status).toBe('fail') 14 | }) 15 | 16 | test('shoud be false when milestone is set in settings but null in PR', async () => { 17 | const milestone = new Milestone() 18 | const settings = { 19 | do: 'milestone', 20 | must_include: { 21 | regex: 'Version 1' 22 | } 23 | } 24 | const validation = await milestone.processValidate(createMockContext(), settings) 25 | expect(validation.status).toBe('fail') 26 | }) 27 | 28 | test('description should be correct', async () => { 29 | const milestone = new Milestone() 30 | const settings = { 31 | do: 'milestone', 32 | must_include: { 33 | regex: 'Version 1' 34 | } 35 | } 36 | 37 | const validation = await milestone.processValidate(createMockContext(), settings) 38 | expect(validation.validations[0].description).toBe('milestone does not include "Version 1"') 39 | }) 40 | 41 | test('checks that deep validation works if it closes an issue with milestone requirement', async () => { 42 | const milestone = new Milestone() 43 | const settings = { 44 | do: 'milestone', 45 | must_include: { 46 | regex: 'Version 1' 47 | } 48 | } 49 | 50 | const validation = await milestone.processValidate(createMockContext(null, 'closes #1', { milestone: { title: 'Version 1' } }), settings) 51 | expect(validation.status).toBe('pass') 52 | }) 53 | 54 | test('checks that deep validation return false if it does not closes an issue with milestone requirement', async () => { 55 | const milestone = new Milestone() 56 | const settings = { 57 | do: 'milestone', 58 | must_include: { 59 | regex: 'Version 1' 60 | } 61 | } 62 | const validation = await milestone.processValidate(createMockContext(null, 'closes #1', { milestone: { title: 'Version 2' } }), settings) 63 | expect(validation.status).toBe('fail') 64 | }) 65 | 66 | const createMockContext = (milestone, body, deepValidation) => { 67 | return Helper.mockContext({ milestone, body, deepValidation }) 68 | } 69 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/assignees.test.js: -------------------------------------------------------------------------------- 1 | const Helper = require('../../../../__fixtures__/unit/helper') 2 | const assignees = require('../../../../lib/validators/options_processor/assignees') 3 | 4 | test('that assignees are correctly retrieved', async () => { 5 | const res = await assignees.process(createMockPR(), Helper.mockContext()) 6 | expect(res.length).toBe(1) 7 | expect(res[0]).toBe('bob') 8 | }) 9 | 10 | const createMockPR = () => { 11 | return Helper.mockContext({ 12 | user: { 13 | login: 'creator' 14 | }, 15 | number: 1, 16 | assignees: [{ login: 'bob' }] 17 | }).payload.pull_request 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options.test.js: -------------------------------------------------------------------------------- 1 | const options = require('../../../../lib/validators/options_processor/options') 2 | 3 | test('return correct output if all inputs are valid', async () => { 4 | let rule = { do: 'label', must_include: { regex: 'A' } } 5 | let input = ['A', 'C'] 6 | let res = await options.process('label', input, rule) 7 | expect(res.status).toBe('pass') 8 | 9 | rule = [{ do: 'label', must_include: { regex: 'A' } }, { do: 'label', must_exclude: { regex: 'B' } }] 10 | input = ['A', 'C'] 11 | res = await options.process('label', input, rule) 12 | expect(res.status).toBe('pass') 13 | }) 14 | 15 | test('return error if unsupported options are provided', async () => { 16 | const rule = { do: 'label', must_be_include: { regex: 'A' } } 17 | const input = ['A'] 18 | const res = await options.process('label', input, rule) 19 | expect(res.status).toBe('error') 20 | expect(res.validations[0].description).toBe('Cannot find module \'./options/must_be_include\' from \'lib/validators/options_processor/options.js\'') 21 | }) 22 | 23 | test('return raw output if returnRawOutput is set to true', async () => { 24 | const rule = { do: 'label', must_include: { regex: 'A' } } 25 | const input = 'A' 26 | const res = await options.process('label', input, rule, true) 27 | expect(Array.isArray(res)).toBe(true) 28 | }) 29 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/begins_with.test.js: -------------------------------------------------------------------------------- 1 | const beginsWith = require('../../../../../lib/validators/options_processor/options/begins_with') 2 | 3 | const validatorContext = { 4 | name: 'label', 5 | supportedOptions: [ 6 | 'and', 7 | 'or', 8 | 'begins_with', 9 | 'ends_with', 10 | 'max', 11 | 'min', 12 | 'must_exclude', 13 | 'must_include', 14 | 'no_empty', 15 | 'required'] 16 | } 17 | 18 | test('return pass if input begins with the rule and matches is an array', () => { 19 | const match = ['feat', 'core'] 20 | expectMatchToBe(match, 'feat: the test', 'pass') 21 | expectMatchToBe(match, 'core: the test', 'pass') 22 | expectMatchToBe(match, 'some title', 'fail') 23 | }) 24 | 25 | test('return pass if input begins with the rule', () => { 26 | const match = 'the' 27 | expectMatchToBe(match, 'the test', 'pass') 28 | expectMatchToBe(match, ['A', 'B', 'the test'], 'pass') 29 | }) 30 | 31 | test('return fail if input does not begins with the rule', async () => { 32 | const match = 'the' 33 | expectMatchToBe(match, 'test the', 'fail') 34 | }) 35 | 36 | test('return error if inputs are not in expected format', async () => { 37 | const rule = { begins_with: { regex: 'the' } } 38 | const input = 'the test' 39 | try { 40 | const config = beginsWith.process(validatorContext, input, rule) 41 | expect(config).toBeUndefined() 42 | } catch (e) { 43 | expect(e.message).toBe('Failed to run the test because \'match\' is not provided for \'begins_with\' option. Please check README for more information about configuration') 44 | } 45 | }) 46 | 47 | const expectMatchToBe = (match, input, result) => { 48 | const rule = { begins_with: { match: match } } 49 | const res = beginsWith.process(validatorContext, input, rule) 50 | expect(res.status).toBe(result) 51 | } 52 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/ends_with.test.js: -------------------------------------------------------------------------------- 1 | const endsWith = require('../../../../../lib/validators/options_processor/options/ends_with') 2 | 3 | const validatorContext = { 4 | name: 'label', 5 | supportedOptions: [ 6 | 'and', 7 | 'or', 8 | 'begins_with', 9 | 'ends_with', 10 | 'max', 11 | 'min', 12 | 'must_exclude', 13 | 'must_include', 14 | 'no_empty', 15 | 'required'] 16 | } 17 | 18 | test('return pass if input ends with the rule and matches is an array', async () => { 19 | const match = ['end1', 'end2'] 20 | 21 | expectMatchToBe(match, 'the test end1', 'pass') 22 | expectMatchToBe(match, 'the test end2', 'pass') 23 | expectMatchToBe(match, 'some title', 'fail') 24 | }) 25 | 26 | test('return pass if input meets the criteria', async () => { 27 | const match = 'test' 28 | 29 | expectMatchToBe(match, 'the test', 'pass') 30 | expectMatchToBe(match, ['A', 'B', 'the test'], 'pass') 31 | expectMatchToBe(match, ['the test'], 'pass') 32 | }) 33 | 34 | test('return fail if input does not meet the criteria', async () => { 35 | expectMatchToBe('test', 'test the', 'fail') 36 | }) 37 | 38 | test('return error if inputs are not in expected format', async () => { 39 | const rule = { ends_with: { regex: 'test' } } 40 | const input = 'the test' 41 | try { 42 | const config = endsWith.process(validatorContext, input, rule) 43 | expect(config).toBeUndefined() 44 | } catch (e) { 45 | expect(e.message).toBe('Failed to run the test because \'match\' is not provided for \'ends_with\' option. Please check README for more information about configuration') 46 | } 47 | }) 48 | 49 | const expectMatchToBe = (match, input, result) => { 50 | const rule = { ends_with: { match: match } } 51 | const res = endsWith.process(validatorContext, input, rule) 52 | expect(res.status).toBe(result) 53 | } 54 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/lib/consolidateResults.test.js: -------------------------------------------------------------------------------- 1 | const consolidateResult = require('../../../../../../lib/validators/options_processor/options/lib/consolidateResults') 2 | 3 | test('returns pass status if no fail or error status exists', async () => { 4 | const validateResults = [{ status: 'pass' }] 5 | const validatorContext = { name: 'label' } 6 | const res = consolidateResult(validateResults, validatorContext) 7 | expect(res.status).toBe('pass') 8 | expect(res.name).toBe('label') 9 | }) 10 | 11 | test('returns fail status if fail status exists but no error status exists', async () => { 12 | const validateResults = [{ status: 'pass' }, { status: 'fail' }] 13 | const validatorContext = { name: 'label' } 14 | const res = consolidateResult(validateResults, validatorContext) 15 | expect(res.status).toBe('fail') 16 | }) 17 | 18 | test('returns error status if error status exists', async () => { 19 | const validateResults = [{ status: 'pass' }, { status: 'fail' }, { status: 'error' }] 20 | const validatorContext = { name: 'label' } 21 | const res = consolidateResult(validateResults, validatorContext) 22 | expect(res.status).toBe('error') 23 | }) 24 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/min.test.js: -------------------------------------------------------------------------------- 1 | const min = require('../../../../../lib/validators/options_processor/options/min') 2 | const validatorContext = { 3 | name: 'label', 4 | supportedOptions: [ 5 | 'and', 6 | 'or', 7 | 'begins_with', 8 | 'ends_with', 9 | 'max', 10 | 'min', 11 | 'must_exclude', 12 | 'must_include', 13 | 'no_empty', 14 | 'required'] 15 | } 16 | 17 | test('return pass if input meets the criteria', async () => { 18 | const rule = { min: { count: 3 } } 19 | const input = ['A', 'B', 'C'] 20 | const res = min.process(validatorContext, input, rule) 21 | expect(res.status).toBe('pass') 22 | }) 23 | 24 | test('return fail if input does not meet the criteria', async () => { 25 | const rule = { min: { count: 3 } } 26 | const input = ['A', 'B'] 27 | const res = min.process(validatorContext, input, rule) 28 | expect(res.status).toBe('fail') 29 | }) 30 | 31 | test('return error if inputs are not in expected format', async () => { 32 | const rule = { min: { regex: 3 } } 33 | const input = ['the test'] 34 | try { 35 | const config = min.process(validatorContext, input, rule) 36 | expect(config).toBeUndefined() 37 | } catch (e) { 38 | expect(e.message).toBe('Failed to run the test because \'count\' is not provided for \'min\' option. Please check README for more information about configuration') 39 | } 40 | }) 41 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/no_empty.test.js: -------------------------------------------------------------------------------- 1 | const noEmpty = require('../../../../../lib/validators/options_processor/options/no_empty') 2 | 3 | const validatorContext = { 4 | name: 'label', 5 | supportedOptions: [ 6 | 'and', 7 | 'or', 8 | 'begins_with', 9 | 'ends_with', 10 | 'max', 11 | 'min', 12 | 'must_exclude', 13 | 'must_include', 14 | 'no_empty', 15 | 'required'] 16 | } 17 | 18 | const verify = (enabled, input, inputArr, result) => { 19 | const rule = { no_empty: { enabled: enabled } } 20 | let res = noEmpty.process(validatorContext, input, rule) 21 | expect(res.status).toBe(result) 22 | 23 | res = noEmpty.process(validatorContext, inputArr, rule) 24 | expect(res.status).toBe(result) 25 | } 26 | 27 | test('return pass if input meets the criteria', () => { 28 | verify(true, 'NOT EMPTY', [''], 'pass') 29 | verify(false, 'NOT EMPTY', [''], 'pass') 30 | }) 31 | 32 | test('return fail if input does not meet the criteria', () => { 33 | verify(true, '', [], 'fail') 34 | verify(true, null, [], 'fail') 35 | verify(true, undefined, [], 'fail') 36 | verify(false, '', [''], 'pass') 37 | }) 38 | 39 | test('return error if input does not meet the criteria', () => { 40 | const rule = { no_empty: { enabled: true } } 41 | let input = 1 42 | try { 43 | const config = noEmpty.process(validatorContext, input, rule) 44 | expect(config).toBeDefined() 45 | } catch (e) { 46 | expect(e.message).toBe('Input type invalid, expected string or Array as input') 47 | } 48 | 49 | input = [1] 50 | try { 51 | const config = noEmpty.process(validatorContext, input, rule) 52 | expect(config).toBeDefined() 53 | } catch (e) { 54 | expect(e.message).toBe('Input type invalid, expected string or Array as input') 55 | } 56 | }) 57 | 58 | test('return error if inputs are not in expected format', async () => { 59 | const rule = { no_empty: { regex: true } } 60 | const input = 'the test' 61 | try { 62 | const config = noEmpty.process(validatorContext, input, rule) 63 | expect(config).toBeUndefined() 64 | } catch (e) { 65 | expect(e.message).toBe('Failed to run the test because \'enabled\' is not provided for \'no_empty\' option. Please check README for more information about configuration') 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/or.test.js: -------------------------------------------------------------------------------- 1 | const or = require('../../../../../lib/validators/options_processor/options/or') 2 | 3 | const validatorContext = { 4 | name: 'OptionName', 5 | supportedOptions: [ 6 | 'and', 7 | 'or', 8 | 'begins_with', 9 | 'ends_with', 10 | 'max', 11 | 'min', 12 | 'must_exclude', 13 | 'must_include', 14 | 'no_empty', 15 | 'required'] 16 | } 17 | test('return pass if input passes one of the OR conditions', async () => { 18 | const rule = { 19 | or: [ 20 | { must_include: { regex: 'A' } }, 21 | { must_exclude: { regex: 'B' } } 22 | ] 23 | } 24 | const input = ['A', 'C'] 25 | const res = await or.process(validatorContext, input, rule) 26 | expect(res.status).toBe('pass') 27 | }) 28 | 29 | test('return fail if none of the input passes one of the OR conditions', async () => { 30 | const rule = { 31 | or: [ 32 | { required: { reviewers: ['user1'] } }, 33 | { required: { reviewers: ['user2'] } } 34 | ] 35 | } 36 | const input = ['user0', 'user5', 'user3'] 37 | const res = await or.process(validatorContext, input, rule) 38 | expect(res.status).toBe('fail') 39 | }) 40 | 41 | test('return error if inputs are not in expected format', async () => { 42 | const rule = { and: { must_include: { regex: 'A' } } } 43 | const input = 'the test' 44 | try { 45 | const config = await or.process(validatorContext, input, rule) 46 | expect(config).toBeUndefined() 47 | } catch (e) { 48 | expect(e.message).toBe('Input type invalid, expected array type as input') 49 | } 50 | }) 51 | 52 | test('return error if option is not valid', async () => { 53 | const rule = { or: [{ must_include: { regexs: 'A' } }, { must_exclude: { regex: 'B' } }] } 54 | const input = ['A'] 55 | const res = await or.process(validatorContext, input, rule) 56 | expect(res.status).toBe('error') 57 | }) 58 | 59 | test('return error if sub option is not valid', async () => { 60 | const rule = { or: [{ must_inclde: { regex: 'A' } }, { must_exclude: { regex: 'B' } }] } 61 | const input = ['A'] 62 | const res = await or.process(validatorContext, input, rule) 63 | expect(res.status).toBe('error') 64 | }) 65 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/options/required.test.js: -------------------------------------------------------------------------------- 1 | const required = require('../../../../../lib/validators/options_processor/options/required') 2 | 3 | const validatorContext = { name: 'ValidatorName' } 4 | 5 | test('return pass if input meets the criteria', async () => { 6 | const rule = { required: { reviewers: ['shine2lay', 'jusx'] } } 7 | const input = ['shine2lay', 'jusx'] 8 | const res = required.process(validatorContext, input, rule) 9 | expect(res.status).toBe('pass') 10 | }) 11 | 12 | test('return fail if input does not meet the criteria', async () => { 13 | const rule = { required: { reviewers: ['shine2lay', 'jusx'] } } 14 | const input = ['jusx'] 15 | const res = required.process(validatorContext, input, rule) 16 | expect(res.status).toBe('fail') 17 | }) 18 | 19 | test('return pass when reviewers list not provided', () => { 20 | const rule = { required: { owners: true } } 21 | const input = ['jusx'] 22 | const res = required.process(validatorContext, input, rule) 23 | expect(res.status).toBe('pass') 24 | }) 25 | 26 | test('case sensitivity', () => { 27 | const rule = { required: { reviewers: ['shine2lay', 'jusxtest'] } } 28 | const input = ['jusxTest'] 29 | const res = required.process(validatorContext, input, rule) 30 | expect(res.status).toBe('fail') 31 | }) 32 | 33 | test('case sensitivity with correct case insensitivity.', () => { 34 | const rule = { required: { reviewers: ['camelCaseUser', 'jusxtest'] } } 35 | const input = ['jusxTest'] 36 | const res = required.process(validatorContext, input, rule) 37 | expect(res.description).toBe('ValidatorName: camelCaseUser required') 38 | }) 39 | -------------------------------------------------------------------------------- /__tests__/unit/validators/options_processor/teams.test.js: -------------------------------------------------------------------------------- 1 | const Helper = require('../../../../__fixtures__/unit/helper') 2 | const teams = require('../../../../lib/validators/options_processor/teams') 3 | const teamNotFoundError = require('../../../../lib/errors/teamNotFoundError') 4 | 5 | test('teams members are extracted properly', async () => { 6 | const members = [ 7 | { login: 'member1' }, 8 | { login: 'member2' } 9 | ] 10 | 11 | const res = await teams.extractTeamMembers(createMockContext(members), ['org/team1', 'org/team2']) 12 | expect(res.length).toBe(2) 13 | expect(res[0]).toBe('member1') 14 | expect(res[1]).toBe('member2') 15 | }) 16 | 17 | test('throws teamNotFound error if at least one team is not found', async () => { 18 | const context = createMockContext() 19 | context.octokit.teams.listMembersInOrg = () => { 20 | const error = new Error('404 error') 21 | error.status = 404 22 | throw error 23 | } 24 | 25 | try { 26 | await teams.extractTeamMembers(context, ['org/team1', 'org/team2']) 27 | } catch (err) { 28 | expect(err instanceof teamNotFoundError).toBe(true) 29 | } 30 | }) 31 | 32 | const createMockContext = (listMembers) => { 33 | return Helper.mockContext({ 34 | listMembers 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /__tests__/unit/validators/project.test.js: -------------------------------------------------------------------------------- 1 | const Project = require('../../../lib/validators/project') 2 | const Helper = require('../../../__fixtures__/unit/helper') 3 | 4 | const settings = { 5 | do: 'project', 6 | must_include: { 7 | regex: 'Project One' 8 | } 9 | } 10 | 11 | test('that mergeable is true when PR number is in Project', async () => { 12 | const projects = new Project() 13 | const validation = await projects.processValidate(createMockContext({ number: 1 }), settings) 14 | expect(validation.status).toBe('pass') 15 | }) 16 | 17 | test('that mergeable is false when PR number is not in Project', async () => { 18 | const projects = new Project() 19 | const validation = await projects.processValidate(createMockContext({ number: 3 }), settings) 20 | expect(validation.status).toBe('fail') 21 | }) 22 | 23 | test('test description is correct', async () => { 24 | const projects = new Project() 25 | const validation = await projects.processValidate(createMockContext({ number: 3 }), settings) 26 | expect(validation.status).toBe('fail') 27 | expect(validation.validations[0].description).toBe('Must be in the "Project One" project.') 28 | }) 29 | 30 | test('test deep validation works', async () => { 31 | const projects = new Project() 32 | const validation = await projects.processValidate(createMockContext({ number: 3, description: 'closes #1' }), settings) 33 | expect(validation.status).toBe('pass') 34 | expect(validation.validations[0].description).toBe('Required Project is present') 35 | }) 36 | 37 | const createMockContext = ({ description, number }) => { 38 | const repoProjects = [ 39 | { name: 'Project One', id: 1 }, 40 | { name: 'Porject Two', id: 2 } 41 | ] 42 | const projectColumns = [ 43 | { id: 1 } 44 | ] 45 | const projectCards = [ 46 | { content_url: 'testRepo/issues/1' }, 47 | { content_url: 'testRepo/issues/2' } 48 | ] 49 | 50 | return Helper.mockContext({ body: description, number: number, repoProjects: repoProjects, projectColumns: projectColumns, projectCards: projectCards }) 51 | } 52 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-tactile -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | # Comments are provided throughout this file to help you get started. 2 | # If you need more help, visit the Docker Compose reference guide at 3 | # https://docs.docker.com/go/compose-spec-reference/ 4 | 5 | # Here the instructions define your application as a service called "server". 6 | # This service is built from the Dockerfile in the current directory. 7 | # You can add other services your application may depend on here, such as a 8 | # database or a cache. For examples, see the Awesome Compose repository: 9 | # https://github.com/docker/awesome-compose 10 | services: 11 | server: 12 | build: 13 | context: . 14 | environment: 15 | NODE_ENV: production 16 | ports: 17 | - 3000:3000 18 | 19 | # The commented out section below is an example of how to define a PostgreSQL 20 | # database that your application can use. `depends_on` tells Docker Compose to 21 | # start the database before your application. The `db-data` volume persists the 22 | # database data between container restarts. The `db-password` secret is used 23 | # to set the database password. You must create `db/password.txt` and add 24 | # a password of your choosing to it before running `docker-compose up`. 25 | # depends_on: 26 | # db: 27 | # condition: service_healthy 28 | # db: 29 | # image: postgres 30 | # restart: always 31 | # user: postgres 32 | # secrets: 33 | # - db-password 34 | # volumes: 35 | # - db-data:/var/lib/postgresql/data 36 | # environment: 37 | # - POSTGRES_DB=example 38 | # - POSTGRES_PASSWORD_FILE=/run/secrets/db-password 39 | # expose: 40 | # - 5432 41 | # healthcheck: 42 | # test: [ "CMD", "pg_isready" ] 43 | # interval: 10s 44 | # timeout: 5s 45 | # retries: 5 46 | # volumes: 47 | # db-data: 48 | # secrets: 49 | # db-password: 50 | # file: db/password.txt 51 | 52 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/actions/assign.rst: -------------------------------------------------------------------------------- 1 | Assign 2 | ^^^^^^^^ 3 | 4 | You can assign specific people to a pull request or issue. 5 | 6 | :: 7 | 8 | - do: assign 9 | assignees: [ 'shine2lay', 'jusx', '@author' ] # only array accepted, use @author for PR/Issue author, use @sender for event initiator, use @bot for Mergable bot 10 | 11 | Supported Events: 12 | :: 13 | 14 | 'pull_request.*', 'issues.*', 'issue_comment.*' -------------------------------------------------------------------------------- /docs/actions/close.rst: -------------------------------------------------------------------------------- 1 | Close 2 | ^^^^^^^^ 3 | 4 | You can close a pull request or issue. 5 | 6 | :: 7 | 8 | - do: close 9 | 10 | Supported Events: 11 | :: 12 | 13 | 'schedule.repository', 'pull_request.*', 'issues.*', 'issue_comment.*' 14 | -------------------------------------------------------------------------------- /docs/actions/comment.rst: -------------------------------------------------------------------------------- 1 | Comment 2 | ^^^^^^^^ 3 | 4 | You can add a comment to a pull request or issue. 5 | 6 | :: 7 | 8 | - do: comment 9 | payload: 10 | body: > 11 | Your very long comment can go here. 12 | Annotations are replaced: 13 | - @author 14 | - @sender 15 | - @bot 16 | - @repository 17 | - @action 18 | - {{formatDate}} # today's date and time 19 | - {{formatDate created_at}} # PR/issue creation date and time 20 | - {{title}} # PR/issue title 21 | - {{body}} # PR/issue description 22 | leave_old_comment: true # Optional, by default old comments are deleted, if true, old comments will be left alone 23 | 24 | Supported Events: 25 | :: 26 | 27 | 'schedule.repository', 'pull_request.*', 'issues.*', 'issue_comment.*' 28 | -------------------------------------------------------------------------------- /docs/actions/merge.rst: -------------------------------------------------------------------------------- 1 | Merge 2 | ^^^^^^^^ 3 | 4 | You can merge a pull request and specify the merge method used. 5 | 6 | :: 7 | 8 | - do: merge 9 | merge_method: 'merge' # Optional, default is 'merge'. Other options : 'rebase', 'squash' 10 | # template variables for next two items come from result of https://docs.github.com/en/rest/reference/pulls#get-a-pull-request 11 | # use triple curly braces to avoid html escaping 12 | commit_title: '{{{ title }}} (#{{{ number }}})' # Optional, override commit title 13 | commit_message: '{{{ body }}}' # Optional, override commit message 14 | 15 | 16 | Supported Events: 17 | :: 18 | 19 | 'pull_request.*', 'pull_request_review.*', 'status.*', 'check_suite.*', 'issue_comment.*' 20 | -------------------------------------------------------------------------------- /docs/actions/request_review.rst: -------------------------------------------------------------------------------- 1 | Request Review 2 | ^^^^^^^^^^^^^^^ 3 | 4 | You can request specific reviews from specific reviewers, teams, or both for a pull request. 5 | 6 | :: 7 | 8 | - do: request_review 9 | reviewers: ['name1', 'name2'] 10 | teams: ['developers'] # team names without organization 11 | 12 | Supported Events: 13 | :: 14 | 15 | 'pull_request.*' 16 | -------------------------------------------------------------------------------- /docs/annotations.rst: -------------------------------------------------------------------------------- 1 | .. _annotations-page: 2 | Annotations 3 | ===================================== 4 | 5 | Annotations allows the use of dynamic values in your recipes. 6 | 7 | To bypass the annotation, use ``\`` prefix. (i.e ``\@author`` will be replaced with literal `@author`) 8 | 9 | :: 10 | 11 | @author : replaced with the login of creator of issues/PR 12 | @sender : replaced with the login of initiator of the ocurred event 13 | @bot : replaced with the name of the Mergeable bot 14 | @repository : replaced with the name of repository of issues/PR 15 | @action : replaced with action of the ocurred event 16 | 17 | 18 | Actions supported: 19 | :: 20 | 21 | 'assign', 'comment', 'checks' 22 | 23 | 24 | .. hint:: 25 | Don't see any annotation that fits your needs? Let us know by creating an `issue `_ on github. 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/configurable.rst: -------------------------------------------------------------------------------- 1 | .. _configurable-variables-page: 2 | List of Configurable variables inside mergeable 3 | =============================================== 4 | 5 | APP_NAME : when checks are created, the app name will be utilized. Default : 'Mergeable' 6 | MERGEABLE_VERSION : Specify the version of mergeable to be used. Default (also latest) : 'flex' 7 | MERGEABLE_SCHEDULER : When enabled, the bot create "schedule.repository" events to be triggered on a specified interval. Default: false 8 | MERGEABLE_SCHEDULER_INTERVAL : The interval (in seconds) in which the "schedule.repository" will be triggered for each repo. Default : 3600 (one hour) 9 | CONFIG_PATH : The file path of the configuration for the bot, root path is '.github/'. Default : 'mergeable.yml' 10 | USE_CONFIG_CACHE : store repo's configuration (if exists) in memory instead of fetching new config everytime. Default: false 11 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ===================================== 3 | 4 | We need your help: 5 | 6 | * Have an **idea** for a new **feature**? Please create a new `issue `_ and tell us! 7 | * Fix a **bug**, implement a new **validator** or **action** and open a `pull request `_! 8 | 9 | .. note:: 10 | For development and testing, you'll want to read :ref:`deploying`. -------------------------------------------------------------------------------- /docs/filters/author.rst: -------------------------------------------------------------------------------- 1 | Author 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: author 7 | must_include: 8 | regex: 'user-1' 9 | message: 'Custom include message...' # optional 10 | must_exclude: 11 | regex: 'user-2' 12 | message: 'Custom exclude message...' # optional 13 | team: 'org/team-slug' # verify that the author is in the team 14 | one_of: ['user-1', '@org/team-slug'] # verify author for being one of the users or a team member 15 | none_of: ['user-2', '@bot'] # verify author for not being one of the users or the mergeable bot 16 | 17 | you can use ``and`` and ``or`` options to create more complex filters 18 | 19 | :: 20 | 21 | - do: author 22 | and: 23 | - must_exclude: 24 | regex: 'bot-user-1' 25 | message: 'Custom message...' 26 | or: 27 | - must_include: 28 | regex: 'user-1' 29 | message: 'Custom message...' 30 | - must_include: 31 | regex: 'user-2' 32 | message: 'Custom message...' 33 | 34 | you can also nest ``and`` and ``or`` options 35 | 36 | :: 37 | 38 | - do: author 39 | and: 40 | - or: 41 | - must_include: 42 | regex: 'user-1' 43 | message: 'Custom message...' 44 | - must_include: 45 | regex: 'user-2' 46 | message: 'Custom message...' 47 | - must_exclude: 48 | regex: 'bot-user-1' 49 | message: 'Custom message...' 50 | 51 | Supported Events: 52 | :: 53 | 54 | 'pull_request.*', 'pull_request_review.*' 55 | -------------------------------------------------------------------------------- /docs/filters/baseRef.rst: -------------------------------------------------------------------------------- 1 | BaseRef 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: baseRef 7 | must_include: 8 | regex: 'some-ref' 9 | message: 'Custom message...' 10 | # all of the message sub-option is optional 11 | 12 | you can use ``and`` and ``or`` options to create more complex filters 13 | 14 | :: 15 | 16 | - do: baseRef 17 | and: 18 | - must_exclude: 19 | regex: 'some-other-ref' 20 | message: 'Custom message...' 21 | or: 22 | - must_include: 23 | regex: 'some-ref' 24 | message: 'Custom message...' 25 | - must_include: 26 | regex: 'some-other-ref' 27 | message: 'Custom message...' 28 | 29 | you can also nest ``and`` and ``or`` options 30 | 31 | :: 32 | 33 | - do: baseRef 34 | and: 35 | - or: 36 | - must_include: 37 | regex: 'some-ref' 38 | message: 'Custom message...' 39 | - must_include: 40 | regex: 'some-other-ref' 41 | message: 'Custom message...' 42 | - must_exclude: 43 | regex: 'yet-another-ref' 44 | message: 'Custom message...' 45 | 46 | Supported Events: 47 | :: 48 | 49 | 'pull_request.*', 'pull_request_review.*' 50 | -------------------------------------------------------------------------------- /docs/filters/payload.rst: -------------------------------------------------------------------------------- 1 | Payload 2 | ^^^^^^^^^^^^^^ 3 | 4 | Check against any available fields within the payload, each event can have different field, please refer to `github API documentation `_ for available fields. 5 | 6 | An example to check if a `pull_request_review` event has ``state`` of `changes_requested` 7 | 8 | :: 9 | 10 | - do: payload 11 | review: 12 | state: 13 | must_include: 14 | regex: 'changes_requested' 15 | 16 | To check if a `pull_request` event is not a `draft` 17 | 18 | :: 19 | 20 | - do: payload 21 | pull_request: 22 | draft: 23 | boolean: 24 | match: false 25 | 26 | An example to check if a `pull_request` event has a ``label`` named `foo` 27 | 28 | :: 29 | 30 | - do: payload 31 | pull_request: 32 | labels: 33 | must_include: 34 | regex: 'foo' 35 | key: 'name' 36 | 37 | An example to check whether the initiator of the event is part of a team but not excluded. 38 | 39 | :: 40 | 41 | - do: payload 42 | sender: 43 | login: 44 | one_of: ['@org/team-slug'] 45 | none_of: ['banned_user', '@author', '@bot'] 46 | 47 | 48 | Each field must be checked using one of the following options 49 | 50 | :: 51 | 52 | boolean: 53 | match: true/false 54 | must_include: 55 | regex: 'This text must be included' 56 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 57 | key: 'name' # Optional. If checking an array of objects, this specifies the key to check. 58 | must_exclude: 59 | regex: 'Text to exclude' 60 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 61 | key: 'name' # Optional. If checking an array of objects, this specifies the key to check. 62 | one_of: ['user-1', 'user-2'] # Compares the field value for occurance in the list of strings, case-insensitive, annotations supported 63 | none_of: ['@author', '@bot'] # Compares the field value for absence in the list of strings, case-insensitive, annotations supported 64 | 65 | 66 | Supported Events: 67 | :: 68 | 69 | 'pull_request.*', 'pull_request_review.*', 'issues.*', 'issue_comment.*' 70 | -------------------------------------------------------------------------------- /docs/filters/repository.rst: -------------------------------------------------------------------------------- 1 | Repository 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: repository 7 | visibility: 'public' # Can be public or private 8 | name: 9 | must_include: 10 | regex: 'my-repo-name' 11 | must_exclude: 12 | regex: 'other-repo-name' 13 | topics: 14 | must_include: 15 | regex: 'my-topic' 16 | message: 'Custom message...' 17 | must_exclude: 18 | regex: 'other-topic' 19 | message: 'Custom message...' 20 | # all of the message sub-option is optional 21 | 22 | you can use ``and`` and ``or`` options to create more complex filters 23 | 24 | :: 25 | 26 | - do: repository 27 | topics: 28 | and: 29 | - must_include: 30 | regex: 'topic-1' 31 | message: 'Custom message...' 32 | - must_include: 33 | regex: 'topic-2' 34 | message: 'Custom message...' 35 | or: 36 | - must_include: 37 | regex: 'topic-3' 38 | message: 'Custom message...' 39 | - must_include: 40 | regex: 'topic-4' 41 | message: 'Custom message...' 42 | 43 | you can also nest ``and`` and ``or`` options 44 | 45 | :: 46 | 47 | - do: repository 48 | topics: 49 | and: 50 | - or: 51 | - must_include: 52 | regex: 'topic-1' 53 | message: 'Custom message...' 54 | - must_include: 55 | regex: 'topic-2' 56 | message: 'Custom message...' 57 | - must_include: 58 | regex: 'topic-3' 59 | message: 'Custom message...' 60 | 61 | Supported Events: 62 | :: 63 | 64 | 'pull_request.*', 'pull_request_review.*' 65 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Mergeable documentation master file, created by 2 | sphinx-quickstart on Mon Apr 13 03:55:45 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Mergeable 7 | ===================================== 8 | 9 | Mergeable helps automate your team's GitHub workflow without a single line of code. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | usage.rst 15 | configuration.rst 16 | recipes.rst 17 | deployment.rst 18 | contributing.rst 19 | support.rst 20 | changelog.rst 21 | -------------------------------------------------------------------------------- /docs/operators/and.rst: -------------------------------------------------------------------------------- 1 | And 2 | ^^^^^^^^^^ 3 | 4 | ``And``, ``Or``, and ``Not`` can be used to create more complex validation/filter check 5 | 6 | :: 7 | 8 | filter: 9 | - do: and 10 | filter: 11 | - do: author 12 | must_include: 'user-1' 13 | - do: repository 14 | visibility: public 15 | validate: 16 | - do: and 17 | validate: 18 | - do: title 19 | begins_with: '[WIP]' 20 | - do: label 21 | must_include: 'Ready to Merge' 22 | 23 | you can also create nested ``And``, ``Or``, and ``Not`` 24 | 25 | :: 26 | 27 | filter: 28 | - do: and 29 | filter: 30 | - do: or 31 | filter: 32 | - do: author 33 | must_include: 'user-1' 34 | - do: author 35 | must_include: 'user-2' 36 | - do: repository 37 | visibility: public 38 | validate: 39 | - do: and 40 | validate: 41 | - do: or 42 | validate: 43 | - do: title 44 | begins_with: 'feat:' 45 | - do: label 46 | must_include: 'feature' 47 | - do: label 48 | must_include: 'Ready to Merge' 49 | -------------------------------------------------------------------------------- /docs/operators/not.rst: -------------------------------------------------------------------------------- 1 | Not 2 | ^^^^^^^^^^ 3 | 4 | ``And``, ``Or``, and ``Not`` can be used to create more complex validation/filter check 5 | 6 | :: 7 | 8 | filter: 9 | - do: not 10 | filter: 11 | - do: author 12 | must_include: 'user-1' 13 | - do: repository 14 | visibility: public 15 | validate: 16 | - do: not 17 | validate: 18 | - do: title 19 | begins_with: '[WIP]' 20 | - do: label 21 | must_include: 'Ready to Merge' 22 | 23 | you can also create nested ``And``, ``Or``, and ``Not`` 24 | 25 | :: 26 | 27 | filter: 28 | - do: not 29 | filter: 30 | - do: or 31 | filter: 32 | - do: author 33 | must_include: 'user-1' 34 | - do: author 35 | must_include: 'user-2' 36 | validate: 37 | - do: and 38 | validate: 39 | - do: not 40 | validate: 41 | - do: title 42 | begins_with: 'feat:' 43 | - do: label 44 | must_include: 'feature' 45 | - do: label 46 | must_include: 'Ready to Merge' 47 | -------------------------------------------------------------------------------- /docs/operators/or.rst: -------------------------------------------------------------------------------- 1 | Or 2 | ^^^^^^^^^^ 3 | 4 | ``And``, ``Or``, and ``Not`` can be used to create more complex validation/filter check 5 | 6 | :: 7 | 8 | filter: 9 | - do: or 10 | filter: 11 | - do: author 12 | must_include: 'user-1' 13 | - do: repository 14 | visibility: public 15 | validate: 16 | - do: or 17 | validate: 18 | - do: title 19 | begins_with: '[WIP]' 20 | - do: label 21 | must_include: 'Ready to Merge' 22 | 23 | you can also create nested ``And``, ``Or``, and ``Not`` 24 | 25 | :: 26 | 27 | filter: 28 | - do: and 29 | filter: 30 | - do: or 31 | filter: 32 | - do: author 33 | must_include: 'user-1' 34 | - do: author 35 | must_include: 'user-2' 36 | - do: repository 37 | visibility: public 38 | validate: 39 | - do: and 40 | validate: 41 | - do: or 42 | validate: 43 | - do: title 44 | begins_with: '[WIP]' 45 | - do: label 46 | must_include: '[WIP]' 47 | - do: label 48 | must_include: 'DO NOT MERGE' 49 | -------------------------------------------------------------------------------- /docs/options/begins_with.rst: -------------------------------------------------------------------------------- 1 | BeginsWith 2 | ^^^^^^^^^^ 3 | 4 | ``begins_with`` can be used to validate inputs if begin with given string 5 | 6 | :: 7 | 8 | - do: milestone 9 | begins_with: 10 | match: 'A String' # array of strings 11 | message: 'Some message...' 12 | 13 | .. list-table:: Supported Params 14 | :widths: 25 50 25 25 15 | :header-rows: 1 16 | 17 | * - Param 18 | - Description 19 | - Required 20 | - Default Message 21 | * - match 22 | - message to validate input with 23 | - Yes 24 | - 25 | * - message 26 | - Message to show if the validation fails 27 | - No 28 | - [INPUT NAME] must begins with [MATCH] 29 | 30 | Supported Validators: 31 | :: 32 | 33 | 'changeset', 'content', 'description', 'label', 'milestone', 'title' 34 | -------------------------------------------------------------------------------- /docs/options/boolean.rst: -------------------------------------------------------------------------------- 1 | Boolean 2 | ^^^^^^^ 3 | 4 | ``boolean`` can be used to validate if the input is exactly `true` or `false`. This does not pass truthy values. 5 | 6 | For example, if the `pull_request.draft` value is `false`: 7 | 8 | :: 9 | 10 | { 11 | "action": "opened", 12 | "number": 2, 13 | "pull_request": { 14 | "draft": false 15 | 16 | This will pass validation, because the value of `draft` exactly matches `false`: 17 | 18 | :: 19 | 20 | - do: payload 21 | pull_request: 22 | draft: 23 | boolean: 24 | match: false 25 | message: 'Custom message...' # this is optional, a default message is used when not specified. 26 | 27 | 28 | .. list-table:: Supported Params 29 | :widths: 25 50 25 25 30 | :header-rows: 1 31 | 32 | * - Param 33 | - Description 34 | - Required 35 | - Default Message 36 | * - match 37 | - Bool value to check for 38 | - Yes 39 | - 40 | * - message 41 | - Message to show if the validation fails 42 | - No 43 | - The [INPUT NAME] must be [match] 44 | 45 | Supported Filters: 46 | :: 47 | 48 | 'payload' 49 | -------------------------------------------------------------------------------- /docs/options/ends_with.rst: -------------------------------------------------------------------------------- 1 | EndsWith 2 | ^^^^^^^^ 3 | 4 | ``ends_with`` can be used to validate inputs if end with given string 5 | 6 | :: 7 | 8 | - do: milestone 9 | ends_with: 10 | match: 'A String' # array of strings 11 | message: 'Some message...' 12 | 13 | .. list-table:: Supported Params 14 | :widths: 25 50 25 25 15 | :header-rows: 1 16 | 17 | * - Param 18 | - Description 19 | - Required 20 | - Default Message 21 | * - match 22 | - message to validate input with 23 | - Yes 24 | - 25 | * - message 26 | - Message to show if the validation fails 27 | - No 28 | - [INPUT NAME] must ends with [MATCH] 29 | 30 | Supported Validators: 31 | :: 32 | 33 | 'changeset', 'content', 'description', 'label', 'milestone', 'title' 34 | -------------------------------------------------------------------------------- /docs/options/jira.rst: -------------------------------------------------------------------------------- 1 | Jira 2 | ^^^^ 3 | 4 | ``jira`` can be used to validate inputs string in Atlassian Jira with APIs 5 | 6 | .. note:: 7 | This option work in self-hosted version only. 8 | 9 | .. list-table:: Required Environment Variables 10 | :widths: 25 50 25 25 11 | :header-rows: 1 12 | 13 | * - Env 14 | - Description 15 | - Required 16 | - Default 17 | * - JIRA_PASSWORD 18 | - Password to authenticate with JIRA 19 | - Yes 20 | - 21 | * - JIRA_USERNAME 22 | - Username to authenticate with JIRA 23 | - Yes 24 | - 25 | * - JIRA_HOST 26 | - Host to authenticate with JIRA 27 | - yes 28 | - 29 | * - JIRA_PROTOCOL 30 | - Protocol to establish connection with JIRA 31 | - no 32 | - https 33 | * - JIRA_VERSION 34 | - JIRA API Version to use 35 | - no 36 | - 2 37 | * - JIRA_STRICT_SSL 38 | - Force SSL while establishing connection 39 | - no 40 | - yes 41 | 42 | :: 43 | 44 | - do: headRef 45 | jira: 46 | regex: '[A-Z][A-Z0-9]+-\d+' 47 | regex_flag: none 48 | message: 'The Jira ticket does not valid' 49 | 50 | .. list-table:: Supported Params 51 | :widths: 25 50 25 25 52 | :header-rows: 1 53 | 54 | * - Param 55 | - Description 56 | - Required 57 | - Default Message 58 | * - regex 59 | - Regex enabled message to validate input with 60 | - Yes 61 | - 62 | * - message 63 | - Message to show if the validation fails 64 | - No 65 | - [INPUT NAME] does not include [REGEX] 66 | * - regex_flag 67 | - Regex flag to be used with regex param to validate inputs 68 | - No 69 | - i 70 | 71 | Supported Validators: 72 | :: 73 | 74 | 'commit', 'description', 'headRef', 'label', 'milestone', 'title' 75 | -------------------------------------------------------------------------------- /docs/options/max.rst: -------------------------------------------------------------------------------- 1 | Max 2 | ^^^ 3 | 4 | ``max`` can be used to validate inputs length that is no more than given integer. 5 | 6 | :: 7 | 8 | - do: assignee 9 | max: 10 | count: 2 # There should not be more than 2 assignees 11 | message: 'test string' # this is optional 12 | 13 | .. list-table:: Supported Params 14 | :widths: 25 50 25 25 15 | :header-rows: 1 16 | 17 | * - Param 18 | - Description 19 | - Required 20 | - Default Message 21 | * - count 22 | - number to validate input's length 23 | - Yes 24 | - 25 | * - message 26 | - Message to show if the validation fails 27 | - No 28 | - [INPUT NAME] count is more than [COUNT] 29 | 30 | Supported Validators: 31 | :: 32 | 33 | 'approvals', 'assignee', 'changeset', 'label' 34 | -------------------------------------------------------------------------------- /docs/options/min.rst: -------------------------------------------------------------------------------- 1 | Min 2 | ^^^ 3 | 4 | ``min`` can be used to validate inputs length that isn't less than given integer. 5 | 6 | :: 7 | 8 | - do: assignee 9 | min: 10 | count: 2 # There should be more than 2 assignees 11 | message: 'test string' # this is optional 12 | 13 | .. list-table:: Supported Params 14 | :widths: 25 50 25 25 15 | :header-rows: 1 16 | 17 | * - Param 18 | - Description 19 | - Required 20 | - Default Message 21 | * - count 22 | - number to validate input's length 23 | - Yes 24 | - 25 | * - message 26 | - Message to show if the validation fails 27 | - No 28 | - [INPUT NAME] count is less than [COUNT] 29 | 30 | Supported Validators: 31 | :: 32 | 33 | 'approvals', 'assignee', 'changeset', 'label', 'size' 34 | -------------------------------------------------------------------------------- /docs/options/must_exclude.rst: -------------------------------------------------------------------------------- 1 | MustExclude 2 | ^^^^^^^^^^^ 3 | 4 | ``must_exclude`` can be used to validate input that excludes the given regex supported message. 5 | 6 | :: 7 | 8 | - do: headRef 9 | must_exclude: 10 | regex: '^(feature|hotfix)\/.+$' 11 | message: | 12 | Your pull request doesn't adhere to the branch naming convention described there!k 13 | 14 | You can also use an array of regex matchers. If any of them match, the validation will fail. 15 | 16 | :: 17 | 18 | - do: headRef 19 | must_exclude: 20 | regex: 21 | - "^bug" 22 | - "^breaking" 23 | - "^test" 24 | message: | 25 | Your pull request doesn't adhere to the branch naming convention described there!k 26 | 27 | .. list-table:: Supported Params 28 | :widths: 25 50 25 25 29 | :header-rows: 1 30 | 31 | * - Param 32 | - Description 33 | - Required 34 | - Default Message 35 | * - regex 36 | - Regex or array enabled message to validate input with 37 | - Yes 38 | - 39 | * - message 40 | - Message to show if the validation fails 41 | - No 42 | - [INPUT NAME] does not include [REGEX] 43 | * - regex_flag 44 | - Regex flag to be used with regex param to validate inputs 45 | - No 46 | - i 47 | 48 | Supported Validators: 49 | :: 50 | 51 | 'author', 'baseRef', 'headRef', 'changeset', 'content', 'description', 'label', 'lastComment', 'milestone', 'title' 52 | -------------------------------------------------------------------------------- /docs/options/must_include.rst: -------------------------------------------------------------------------------- 1 | MustInclude 2 | ^^^^^^^^^^^ 3 | 4 | ``must_include`` can be used to validate input that includes the given regex supported message. 5 | 6 | :: 7 | 8 | - do: headRef 9 | must_include: 10 | regex: '^(feature|hotfix)\/.+$' 11 | message: | 12 | Your pull request doesn't adhere to the branch naming convention described there!k 13 | 14 | You can also use an array of regex matchers. If any of them match, the validation will pass. 15 | 16 | :: 17 | 18 | - do: headRef 19 | must_include: 20 | regex: 21 | - "^feature" 22 | - "^hotfix" 23 | - "^fix" 24 | message: | 25 | Your pull request doesn't adhere to the branch naming convention described there!k 26 | 27 | .. list-table:: Supported Params 28 | :widths: 25 50 25 25 29 | :header-rows: 1 30 | 31 | * - Param 32 | - Description 33 | - Required 34 | - Default Message 35 | * - regex 36 | - Regex or array enabled message to validate input with 37 | - Yes 38 | - 39 | * - message 40 | - Message to show if the validation fails 41 | - No 42 | - [INPUT NAME] does not include [REGEX] 43 | * - regex_flag 44 | - Regex flag to be used with regex param to validate inputs 45 | - No 46 | - i 47 | 48 | Supported Validators: 49 | :: 50 | 51 | 'author', 'baseRef', 'headRef', 'changeset', 'commit', 'content', 'description', 'label', 'lastComment', 'milestone', 'project', 'title' 52 | -------------------------------------------------------------------------------- /docs/options/no_empty.rst: -------------------------------------------------------------------------------- 1 | NoEmpty 2 | ^^^^^^^ 3 | 4 | ``no_empty`` can be used to validate if input is not empty 5 | 6 | :: 7 | 8 | - do: description 9 | no_empty: 10 | enabled: false # Cannot be empty when true. 11 | message: 'Custom message...' # this is optional, a default message is used when not specified. 12 | 13 | .. list-table:: Supported Params 14 | :widths: 25 50 25 25 15 | :header-rows: 1 16 | 17 | * - Param 18 | - Description 19 | - Required 20 | - Default Message 21 | * - enabled 22 | - Bool value to enable/disable the option 23 | - Yes 24 | - 25 | * - message 26 | - Message to show if the validation fails 27 | - No 28 | - The [INPUT NAME] can't be empty 29 | 30 | Supported Validators: 31 | :: 32 | 33 | 'changeset', 'description', 'label', 'milestone', 'title' 34 | -------------------------------------------------------------------------------- /docs/options/required.rst: -------------------------------------------------------------------------------- 1 | Required 2 | ^^^^^^^^ 3 | 4 | ``required`` can be used to validate if input meets the conditions given with params 5 | 6 | :: 7 | 8 | - do: approvals 9 | required: 10 | reviewers: [ user1, user2 ] # list of github usernames required to review 11 | owners: true # Optional boolean. When true, the file .github/CODEOWNERS is read and owners made required reviewers 12 | assignees: true # Optional boolean. When true, PR assignees are made required reviewers. 13 | requested_reviewers: true # Optional boolean. When true, all the requested reviewer's approval is required 14 | message: 'Custom message...' 15 | 16 | .. list-table:: Supported Params 17 | :widths: 25 50 25 25 18 | :header-rows: 1 19 | 20 | * - Param 21 | - Description 22 | - Required 23 | - Default Message 24 | * - reviewers 25 | - An array value for github users/teams to be required to do the validation 26 | - No 27 | - [] 28 | * - owners 29 | - The file .github/CODEOWNERS is read and owners made required reviewers 30 | - No 31 | - [] 32 | * - assignees 33 | - PR assignees are made required reviewers. 34 | - No 35 | - [] 36 | * - requested_reviewers 37 | - All the requested reviewer's approval is required 38 | - No 39 | - [] 40 | * - message 41 | - Message to show if the validation fails 42 | - No 43 | - [INPUT NAME] does not include [REGEX] 44 | 45 | Supported Validators: 46 | :: 47 | 48 | 'approvals' 49 | -------------------------------------------------------------------------------- /docs/support.rst: -------------------------------------------------------------------------------- 1 | Support 2 | ===================================== 3 | 4 | Found a bug? Have a question? Or just want to chat? 5 | 6 | * Find us on `Gitter `_. 7 | * Create an `Issue `_. 8 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===================================== 3 | 4 | #. `Install `_ the Mergeable GitHub App. 5 | #. Create your recipe(s) using :ref:`configuration-page` or check out some ready to use examples at :ref:`recipes-page`. 6 | #. Commit and push the recipes to your repository at ``.github/mergeable.yml`` 7 | #. (Optional) You can also create a default configuration for an organisation by 8 | creating a repo called `.github` and adding your file there. See :ref:`organisation-wide-defaults` 9 | for details. 10 | 11 | .. note:: 12 | You can also deploy to your own server. See :ref:`deploying` -------------------------------------------------------------------------------- /docs/validators/age.rst: -------------------------------------------------------------------------------- 1 | Age 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: age // validate based on the age of PR 7 | created_at: 8 | days: 1 9 | message: 'PR needs to at least 1 day old in order to merge' # optional, custom message to display if the validation fails 10 | updated_at: 11 | days: 1 12 | message: 'PR needs to be update free for 1 day before merging' # optional, custom message to display if the validation fails 13 | 14 | Supported Events: 15 | :: 16 | 17 | 'pull_request.*', 'pull_request_review.*', 'issue_comment.*' 18 | -------------------------------------------------------------------------------- /docs/validators/approval.rst: -------------------------------------------------------------------------------- 1 | Approvals 2 | ^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: approvals 7 | min: 8 | count: 2 # Number of minimum reviewers. In this case 2. 9 | message: 'Custom message...' 10 | required: 11 | reviewers: [ user1, user2 ] # list of github usernames required to review 12 | owners: true # Optional boolean. When true, the file .github/CODEOWNERS is read and owners made required reviewers 13 | assignees: true # Optional boolean. When true, PR assignees are made required reviewers. 14 | requested_reviewers: true # Optional boolean. When true, all the requested reviewer's approval is required 15 | message: 'Custom message...' 16 | block: 17 | changes_requested: true # If true, block all approvals when one of the reviewers gave 'changes_requested' review 18 | message: 'Custom message...' 19 | limit: 20 | teams: ['org/team_slug'] # when the option is present, only the approvals from the team members will count 21 | users: ['user1', 'user2'] # when the option is present, approvals from users in this list will count 22 | owners: true # Optional boolean. When true, the file .github/CODEOWNER is read and only owners approval will count 23 | exclude: 24 | users: ['bot1', 'bot2'] # when the option is present, approvals from users in this list will NOT count 25 | 26 | 27 | .. note:: 28 | in ``limit`` options, if more than one sub option is present, the union of the results will be used. 29 | 30 | .. note:: 31 | If you receive an error for `Resource not accessible by integration' for Owners file, it means you haven't given mergeable read file permission 32 | 33 | .. note:: 34 | ``owners`` file now support teams as well, make sure to use `@organization/team-slug` format. 35 | 36 | 37 | Supported Events: 38 | :: 39 | 40 | 'pull_request.*', 'pull_request_review.*' 41 | -------------------------------------------------------------------------------- /docs/validators/assignee.rst: -------------------------------------------------------------------------------- 1 | Assignee 2 | ^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: assignee 7 | max: 8 | count: 2 # There should not be more than 2 assignees 9 | message: 'test string' # this is optional 10 | min: 11 | count: 2 # min number of assignees 12 | message: 'test string' # this is optional 13 | 14 | Supported Events: 15 | :: 16 | 17 | 'pull_request.*', 'pull_request_review.*', 'issues.*', 'issue_comment.*' 18 | 19 | -------------------------------------------------------------------------------- /docs/validators/author.rst: -------------------------------------------------------------------------------- 1 | Author 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: author 7 | must_include: 8 | regex: 'user-1' 9 | message: 'Custom include message...' # optional 10 | must_exclude: 11 | regex: 'user-2' 12 | message: 'Custom exclude message...' # optional 13 | team: 'org/team-slug' # verify that the author is in the team 14 | one_of: ['user-1', '@org/team-slug'] # verify author for being one of the users or a team member 15 | none_of: ['user-2', '@bot'] # verify author for not being one of the users or the mergeable bot 16 | 17 | you can use ``and`` and ``or`` options to create more complex filters 18 | 19 | :: 20 | 21 | - do: author 22 | and: 23 | - must_exclude: 24 | regex: 'bot-user-1' 25 | message: 'Custom message...' 26 | or: 27 | - must_include: 28 | regex: 'user-1' 29 | message: 'Custom message...' 30 | - must_include: 31 | regex: 'user-2' 32 | message: 'Custom message...' 33 | 34 | you can also nest ``and`` and ``or`` options 35 | 36 | :: 37 | 38 | - do: author 39 | and: 40 | - or: 41 | - must_include: 42 | regex: 'user-1' 43 | message: 'Custom message...' 44 | - must_include: 45 | regex: 'user-2' 46 | message: 'Custom message...' 47 | - must_exclude: 48 | regex: 'bot-user-1' 49 | message: 'Custom message...' 50 | 51 | Supported Events: 52 | :: 53 | 54 | 'pull_request.*', 'pull_request_review.*', 'issues.*', 'issue_comment.*' 55 | -------------------------------------------------------------------------------- /docs/validators/baseRef.rst: -------------------------------------------------------------------------------- 1 | BaseRef 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: baseRef 7 | must_include: 8 | regex: 'master|feature-branch1' 9 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 10 | message: 'Custom message...' 11 | must_exclude: 12 | regex: 'feature-branch2' 13 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 14 | message: 'Custom message...' 15 | mediaType: # Optional. Required by status.* events to enable the groot preview on some Github Enterprise servers 16 | previews: 'array' 17 | 18 | 19 | Simple example: 20 | :: 21 | 22 | - do: baseRef 23 | must_exclude: 24 | regex: 'master' 25 | message: 'Merging into repo:master is forbidden' 26 | 27 | 28 | Example with groot preview enabled (for status.* events on some older Github Enterprise servers) 29 | :: 30 | 31 | - do: baseRef 32 | must_include: 33 | regex: 'master|main' 34 | message: 'Auto-merging is only enabled for default branch' 35 | mediaType: 36 | previews: 37 | - groot 38 | 39 | 40 | Supported Events: 41 | :: 42 | 43 | 'pull_request.*', 'pull_request_review.*', 'check_suite.*', 'status.*' 44 | -------------------------------------------------------------------------------- /docs/validators/commit.rst: -------------------------------------------------------------------------------- 1 | Commit 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: commit 7 | message: 8 | regex: '^(feat|docs|chore|fix|refactor|test|style|perf)(\(\w+\))?:.+$' 9 | message: 'Custom message' # Semantic release conventions must be followed 10 | skip_merge: true # Optional, Default is true. Will skip commit with message that includes 'Merge' 11 | oldest_only: false # Optional, Default is false. Only check the regex against the oldest commit 12 | newest_only: false # Optional, Default is false. Only check the regex against the newest commit 13 | single_commit_only: false # Optional, Default is false. only process this validator if there is one commit 14 | message_type: '' # Optional, only check regex against the field specified. Default is '', which processes the 'message' field. Can also be set to 'author_email' or 'committer_email' 15 | jira: 16 | regex: '[A-Z][A-Z0-9]+-\d+' 17 | regex_flag: none 18 | message: 'The Jira ticket does not exist' 19 | 20 | Supported Events: 21 | :: 22 | 23 | 'pull_request.*', 'pull_request_review.*' -------------------------------------------------------------------------------- /docs/validators/contents.rst: -------------------------------------------------------------------------------- 1 | Contents 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: contents 7 | files: # determine which files contents to validate 8 | pr_diff: true # If true, validator will grab all the added and modified files in the head of the PR 9 | ignore: ['.github/mergeable.yml'] # Optional, default ['.github/mergeable.yml'], pattern of files to ignore 10 | must_include: 11 | regex: 'yarn.lock' 12 | message: 'Custom message...' 13 | must_exclude: 14 | regex: 'package.json' 15 | message: 'Custom message...' 16 | begins_with: 17 | match: 'A String' # or array of strings 18 | message: 'Some message...' 19 | ends_with: 20 | match: 'A String' # or array of strings 21 | message: 'Come message...' 22 | 23 | Supported Events: 24 | :: 25 | 26 | 'pull_request.*', 'pull_request_review.*' -------------------------------------------------------------------------------- /docs/validators/dependent.rst: -------------------------------------------------------------------------------- 1 | Dependent 2 | ^^^^^^^^^^ 3 | Validates that the files specified are all part of a pull request (added or modified). 4 | :: 5 | 6 | - do: dependent 7 | files: ['package.json', 'yarn.lock'] # list of files that are dependent on one another and must all be part of the changes in a PR. 8 | message: 'Custom message...' # this is optional, a default message is used when not specified. 9 | 10 | Alternatively, to validate dependent files only when a specific file is part of the pull request, use the changed option: 11 | 12 | :: 13 | 14 | - do: dependent 15 | changed: 16 | file: package.json 17 | files: ['package-lock.json', 'yarn.lock'] 18 | message: 'Custom message...' # this is optional, a default message is used when not specified. 19 | 20 | The above will validate that both the files package-lock.json and yarn.lock is part of the modified or added files if and only if package.json is part of the PR. 21 | 22 | Supported Events: 23 | :: 24 | 25 | 'pull_request.*', 'pull_request_review.*' -------------------------------------------------------------------------------- /docs/validators/headRef.rst: -------------------------------------------------------------------------------- 1 | HeadRef 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: headRef 7 | must_include: 8 | regex: 'feature-branch1' 9 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 10 | message: 'Custom message...' 11 | must_exclude: 12 | regex: 'feature-branch2' 13 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 14 | message: 'Custom message...' 15 | jira: 16 | regex: '[A-Z][A-Z0-9]+-\d+' 17 | regex_flag: none 18 | message: 'The Jira ticket does not exist' 19 | 20 | 21 | Simple example: 22 | :: 23 | 24 | - do: headRef 25 | must_include: 26 | regex: '^(feature|hotfix)\/.+$' 27 | message: | 28 | Your pull request doesn't adhere to the branch naming convention described there!k 29 | 30 | 31 | Supported Events: 32 | :: 33 | 34 | 'pull_request.*', 'pull_request_review.*' 35 | -------------------------------------------------------------------------------- /docs/validators/label.rst: -------------------------------------------------------------------------------- 1 | Label 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: label 7 | no_empty: 8 | enabled: false # Cannot be empty when true. 9 | message: 'Custom message...' 10 | must_include: 11 | regex: 'type|chore|wont' 12 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 13 | message: 'Custom message...' 14 | must_exclude: 15 | regex: 'DO NOT MERGE' 16 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 17 | message: 'Custom message...' 18 | begins_with: 19 | match: 'A String' # or array of strings 20 | message: 'Some message...' 21 | ends_with: 22 | match: 'A String' # or array of strings 23 | message: 'Come message...' 24 | jira: 25 | regex: '[A-Z][A-Z0-9]+-\d+' 26 | regex_flag: none 27 | message: 'The Jira ticket does not exist' 28 | # all of the message sub-option is optional 29 | 30 | :: 31 | 32 | - do: label 33 | and: 34 | - must_include: 35 | regex: 'big|medium|small' 36 | message: 'Custom message...' 37 | - must_include: 38 | regex: 'type|chore|wont' 39 | message: 'Custom message...' 40 | or: 41 | - must_include: 42 | regex: 'Ready to merge' 43 | message: 'Custom message...' 44 | - must_include: 45 | regex: 'DO NOT MERGE' 46 | message: 'Custom message...' 47 | 48 | you can also nest ``and`` and ``or`` options 49 | 50 | :: 51 | 52 | - do: label 53 | and: 54 | - or: 55 | - must_include: 56 | regex: 'feat|fix|chore' 57 | message: 'Custom message...' 58 | - must_include: 59 | regex: 'major|minor|patch' 60 | message: 'Custom message...' 61 | - must_include: 62 | regex: 'Ready to merge' 63 | message: 'Custom message...' 64 | 65 | 66 | Supported Events: 67 | :: 68 | 69 | 'pull_request.*', 'pull_request_review.*', 'issues.*', 'issue_comment.*' -------------------------------------------------------------------------------- /docs/validators/lastComment.rst: -------------------------------------------------------------------------------- 1 | LastComment 2 | ^^^^^^^^^^^ 3 | Validates that the newly created comment contains or excludes given text. When an existing comment is edited, then exactly this one is validated instead. 4 | 5 | :: 6 | 7 | - do: lastComment 8 | must_include: 9 | regex: '/sign' 10 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 11 | message: 'Contributor Agreement signed...' 12 | must_exclude: 13 | regex: 'incompliant' 14 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 15 | message: 'Violates compliance...' 16 | comment_author: 17 | one_of: ['user-1', '@author'] # when the option is present, ONLY comments from users in this list will be considered, use @author for PR/Issue author 18 | none_of: ['user-2', '@author'] # when the option is present, comments from users in this list will NOT be considered, use @author for PR/Issue author 19 | no_bots: true # by default comments from any bots will NOT be considered, set to false to exclude only specific bots explicitly in 'comment_author' option 20 | 21 | Simple example: 22 | :: 23 | 24 | # check if the last comment contains only the word 'merge' 25 | - do: lastComment 26 | must_include: 27 | regex: '^merge$' 28 | 29 | Complex example: 30 | :: 31 | 32 | # check if the last comment, not posted by PR/Issue author, meets one of these conditions 33 | # it might have been posted by a bot, except Mergeble itself 34 | - do: lastComment 35 | comment_author: 36 | none_of: ['Mergeable[bot]', '@author'] 37 | no_bots: false 38 | or: 39 | - and: 40 | - must_exclude: 41 | regex: 'block|wip|stale' 42 | message: 'pre-requisites are not fulfilled...' 43 | - must_include: 44 | regex: 'agreed|confirmed|compliant' 45 | message: 'pre-requisites are fulfilled...' 46 | - must_include: 47 | regex: '^/override$' 48 | message: 'skip pre-requisite check...' 49 | 50 | Supported Events: 51 | :: 52 | 53 | 'pull_request.*', 'pull_request_review.*', 'issues.*', 'issue_comment.*' 54 | -------------------------------------------------------------------------------- /docs/validators/milestone.rst: -------------------------------------------------------------------------------- 1 | Milestone 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: milestone 7 | no_empty: 8 | enabled: true # Cannot be empty when true. 9 | message: 'Custom message...' 10 | must_include: 11 | regex: 'type|chore|wont' 12 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 13 | message: 'Custom message...' 14 | must_exclude: 15 | regex: 'DO NOT MERGE' 16 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 17 | message: 'Custom message...' 18 | begins_with: 19 | match: 'A String' # array of strings 20 | message: 'Some message...' 21 | ends_with: 22 | match: 'A String' # array list of strings 23 | message: 'Come message...' 24 | jira: 25 | regex: '[A-Z][A-Z0-9]+-\d+' 26 | regex_flag: none 27 | message: 'The Jira ticket does not exist' 28 | # all of the message sub-option is optional 29 | 30 | :: 31 | 32 | - do: milestone 33 | and: 34 | - must_include: 35 | regex: 'V1' 36 | message: 'Custom message...' 37 | - must_include: 38 | regex: 'October' 39 | message: 'Custom message...' 40 | or: 41 | - must_include: 42 | regex: 'V2' 43 | message: 'Custom message...' 44 | - must_include: 45 | regex: 'Non breaking Changes' 46 | message: 'Custom message...' 47 | 48 | you can also nest ``and`` and ``or`` options 49 | 50 | :: 51 | 52 | - do: milestone 53 | and: 54 | - or: 55 | - must_include: 56 | regex: 'V1' 57 | message: 'Custom message...' 58 | - must_include: 59 | regex: 'September' 60 | message: 'Custom message...' 61 | - must_include: 62 | regex: 'V2' 63 | message: 'Custom message...' 64 | 65 | 66 | .. note:: 67 | When a closing keyword is used in the description of a pull request. The annotated issue will be validated against the conditions as well. 68 | 69 | Supported Events: 70 | :: 71 | 72 | 'pull_request.*', 'pull_request_review.*', 'issues.*' -------------------------------------------------------------------------------- /docs/validators/project.rst: -------------------------------------------------------------------------------- 1 | Project 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: project 7 | must_include: 8 | regex: 'type|chore|wont' 9 | message: 'Custom message...' 10 | 11 | .. note:: 12 | When a closing keyword is used in the description of a pull request. The annotated issue will be validated against the conditions as well. 13 | 14 | Supported Events: 15 | :: 16 | 17 | 'pull_request.*', 'pull_request_review.*', 'issues.*' -------------------------------------------------------------------------------- /docs/validators/stale.rst: -------------------------------------------------------------------------------- 1 | Stale 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: stale 7 | days: 20 # number of days ago. 8 | type: pull_request, issues # what items to search for. 9 | ignore_drafts: true # if set to true, the stale check will ignore draft items 10 | ignore_milestones: true # if set to true, the stale check will ignore items that have an associated milestone 11 | ignore_projects: true # if set to true, the stale check will ignore items that have an associated project 12 | label: # optional property to filter the items that are actioned upon 13 | match: ['label1_to_match', 'label2_to_match'] # only items with matching labels will be actioned upon and marked as stale 14 | ignore: ['label1_to_ignore', 'label2_to_ignore'] # items with these labels will be ignored and not marked as stale 15 | time_constraint: # Optional, run the validator only if it in within the time constraint 16 | time_zone: 'America/Los_Angeles' # Optional, UTC time by default, for valid timezones see `here `_ 17 | hours_between: ['9', '17'] # Optional, 24 hours by default, run only if [0] >= Hour Now <= [1] 18 | days_of_week: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] # Optional, 7 days a week by default, specific the days of the week in which to run the validator 19 | 20 | .. note:: 21 | This is a special use case. The schedule event runs on an interval. When used with stale, it will search for issues and/or pull request that are n days old. See a full example » 22 | 23 | Supported Events: 24 | :: 25 | 26 | 'schedule.repository' 27 | -------------------------------------------------------------------------------- /docs/validators/title.rst: -------------------------------------------------------------------------------- 1 | Title 2 | ^^^^^^^^^^^^^^ 3 | 4 | :: 5 | 6 | - do: title 7 | no_empty: 8 | enabled: true # Cannot be empty when true. A bit redundant in this case since GitHub don't really allow it. :-) 9 | message: 'Custom message...' 10 | must_include: 11 | regex: 'doc|feat|fix|chore' 12 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 13 | message: 'Custom message...' 14 | must_exclude: 15 | regex: 'DO NOT MERGE|WIP' 16 | regex_flag: 'none' # Optional. Specify the flag for Regex. default is 'i', to disable default use 'none' 17 | message: 'Custom message...' 18 | begins_with: 19 | match: ['doc','feat','fix','chore'] 20 | message: 'Some message...' 21 | ends_with: 22 | match: 'A String' # or array of strings 23 | message: 'Come message...' 24 | # all of the message sub-option is optional 25 | jira: 26 | regex: '[A-Z][A-Z0-9]+-\d+' 27 | regex_flag: none 28 | message: 'The Jira ticket does not exist' 29 | 30 | :: 31 | 32 | - do: title 33 | and: 34 | - must_include: 35 | regex: 'ISSUE-\d+' 36 | message: 'Custom message...' 37 | - must_include: 38 | regex: 'type:.+' 39 | message: 'Custom message...' 40 | or: 41 | - must_include: 42 | regex: 'feat|chore|fix' 43 | message: 'Custom message...' 44 | - must_include: 45 | regex: 'major|minor|patch' 46 | message: 'Custom message...' 47 | 48 | you can also nest ``and`` and ``or`` options 49 | 50 | :: 51 | 52 | - do: title 53 | and: 54 | - or: 55 | - must_include: 56 | regex: 'feat|fix|chore' 57 | message: 'Custom message...' 58 | - must_include: 59 | regex: 'major|minor|patch' 60 | message: 'Custom message...' 61 | - must_include: 62 | regex: 'ISSUE-\d+' 63 | message: 'Custom message...' 64 | 65 | 66 | Supported Events: 67 | :: 68 | 69 | 'pull_request.*', 'pull_request_review.*', 'issues.*', 'issue_comment.*' 70 | -------------------------------------------------------------------------------- /helm/mergeable/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/mergeable/Chart.lock: -------------------------------------------------------------------------------- 1 | dependencies: 2 | - name: redis 3 | repository: https://charts.bitnami.com/bitnami 4 | version: 16.12.1 5 | digest: sha256:3abd8630fe9d83d8fa557c41a5556df9953bd9a90d3bdfcc8bf99cedee1f5af3 6 | generated: "2022-06-13T09:45:25.520228989+02:00" 7 | -------------------------------------------------------------------------------- /helm/mergeable/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: mergeable 3 | description: A Helm chart for Kubernetes 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | appVersion: 1.16.0 24 | 25 | # Dependencies for the chart 26 | dependencies: 27 | - name: redis 28 | version: 16.12.1 29 | repository: https://charts.bitnami.com/bitnami 30 | -------------------------------------------------------------------------------- /helm/mergeable/charts/redis-9.5.0.tgz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mergeability/mergeable/6f552642173530981daf7bd8a0ba8483c6f0f882/helm/mergeable/charts/redis-9.5.0.tgz -------------------------------------------------------------------------------- /helm/mergeable/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "mergeable.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "mergeable.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "mergeable.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "mergeable.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /helm/mergeable/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "mergeable.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 7 | {{- end }} 8 | 9 | {{/* 10 | Create a default fully qualified app name. 11 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 12 | If release name contains chart name it will be used as a full name. 13 | */}} 14 | {{- define "mergeable.fullname" -}} 15 | {{- if .Values.fullnameOverride }} 16 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 17 | {{- else }} 18 | {{- $name := default .Chart.Name .Values.nameOverride }} 19 | {{- if contains $name .Release.Name }} 20 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 21 | {{- else }} 22 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 23 | {{- end }} 24 | {{- end }} 25 | {{- end }} 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "mergeable.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 32 | {{- end }} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "mergeable.labels" -}} 38 | helm.sh/chart: {{ include "mergeable.chart" . }} 39 | {{ include "mergeable.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end }} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "mergeable.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "mergeable.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end }} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "mergeable.serviceAccountName" -}} 58 | {{- if .Values.serviceAccount.create }} 59 | {{- default (include "mergeable.fullname" .) .Values.serviceAccount.name }} 60 | {{- else }} 61 | {{- default "default" .Values.serviceAccount.name }} 62 | {{- end }} 63 | {{- end }} 64 | -------------------------------------------------------------------------------- /helm/mergeable/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2beta1 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "mergeable.fullname" . }} 6 | labels: 7 | {{- include "mergeable.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "mergeable.fullname" . }} 13 | minReplicas: {{ .Values.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} 21 | {{- end }} 22 | {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} 23 | - type: Resource 24 | resource: 25 | name: memory 26 | targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} 27 | {{- end }} 28 | {{- end }} 29 | -------------------------------------------------------------------------------- /helm/mergeable/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "mergeable.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | {{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 5 | apiVersion: networking.k8s.io/v1beta1 6 | {{- else -}} 7 | apiVersion: extensions/v1beta1 8 | {{- end }} 9 | kind: Ingress 10 | metadata: 11 | name: {{ $fullName }} 12 | labels: 13 | {{- include "mergeable.labels" . | nindent 4 }} 14 | {{- with .Values.ingress.annotations }} 15 | annotations: 16 | {{- toYaml . | nindent 4 }} 17 | {{- end }} 18 | spec: 19 | {{- if .Values.ingress.tls }} 20 | tls: 21 | {{- range .Values.ingress.tls }} 22 | - hosts: 23 | {{- range .hosts }} 24 | - {{ . | quote }} 25 | {{- end }} 26 | secretName: {{ .secretName }} 27 | {{- end }} 28 | {{- end }} 29 | rules: 30 | {{- range .Values.ingress.hosts }} 31 | - host: {{ .host | quote }} 32 | http: 33 | paths: 34 | {{- range .paths }} 35 | - path: {{ . }} 36 | backend: 37 | serviceName: {{ $fullName }} 38 | servicePort: {{ $svcPort }} 39 | {{- end }} 40 | {{- end }} 41 | {{- end }} 42 | -------------------------------------------------------------------------------- /helm/mergeable/templates/namespace.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.namespace.enabled }} 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: {{ include "mergeable.fullname" . }} 6 | {{- with .Values.namespace.annotations }} 7 | annotations: {{- toYaml . | nindent 8 }} 8 | {{- end }} 9 | {{- with .Values.namespace.labels }} 10 | labels: {{- toYaml . | nindent 8 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/mergeable/templates/prometheus-rules.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.prometheus.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PrometheusRule 4 | metadata: 5 | name: {{ include "mergeable.fullname" . }} 6 | labels: 7 | {{- include "mergeable.labels" . | nindent 4 }} 8 | prometheus: kube-prometheus 9 | role: alert-rules 10 | spec: 11 | groups: 12 | {{ toYaml .Values.prometheus.rules | nindent 4 }} 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /helm/mergeable/templates/prometheus-servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if and .Values.prometheus.enabled }} 2 | --- 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | labels: 7 | {{- include "mergeable.labels" $ | nindent 4 }} 8 | prometheus: kube-prometheus 9 | name: {{ include "mergeable.fullname" . }} 10 | spec: 11 | endpoints: 12 | - interval: {{ .Values.prometheus.service.metricInterval | default "30s" }} 13 | path: {{ .Values.prometheus.service.metricPath | default "/metrics" }} 14 | port: {{ .Values.prometheus.service.metricPortName | default "http" }} 15 | jobLabel: {{ include "mergeable.fullname" . }} 16 | namespaceSelector: 17 | matchNames: 18 | - "{{ $.Release.Namespace }}" 19 | selector: 20 | matchLabels: 21 | app: {{ include "mergeable.fullname" . }} 22 | sampleLimit: {{ .Values.prometheus.service.sampleLimit | default 5000}} 23 | {{- end }} 24 | -------------------------------------------------------------------------------- /helm/mergeable/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "mergeable.fullname" . }} 5 | labels: 6 | {{- include "mergeable.labels" . | nindent 4 }} 7 | app: {{ include "mergeable.fullname" . }} 8 | spec: 9 | type: {{ .Values.service.type }} 10 | ports: 11 | - port: {{ .Values.service.port }} 12 | targetPort: http 13 | protocol: TCP 14 | name: http 15 | selector: 16 | {{- include "mergeable.selectorLabels" . | nindent 4 }} 17 | -------------------------------------------------------------------------------- /helm/mergeable/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "mergeable.serviceAccountName" . }} 6 | labels: 7 | {{- include "mergeable.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/mergeable/templates/tests/test-connection.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: "{{ include "mergeable.fullname" . }}-test-connection" 5 | labels: 6 | {{- include "mergeable.labels" . | nindent 4 }} 7 | annotations: 8 | "helm.sh/hook": test-success 9 | spec: 10 | containers: 11 | - name: wget 12 | image: busybox 13 | command: ['wget'] 14 | args: ['{{ include "mergeable.fullname" . }}:{{ .Values.service.port }}'] 15 | restartPolicy: Never 16 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { Mergeable } = require('./lib/mergeable') 2 | const logger = require('./lib/logger') 3 | const githubRateLimitEndpoint = require('./lib/utils/githubRateLimitEndpoint') 4 | const prometheusMiddleware = require('express-prometheus-middleware') 5 | 6 | module.exports = (robot, { getRouter }) => { 7 | if (getRouter !== undefined) { 8 | const router = getRouter() 9 | 10 | if (process.env.ENABLE_GITHUB_RATELIMIT_ENDPOINT === 'true') { 11 | // endpoint to fetch github given installation rate limit 12 | router.get('/github-ratelimit/:installationId', githubRateLimitEndpoint(robot)) 13 | } 14 | 15 | if (process.env.ENABLE_METRICS_ENDPOINT === 'true') { 16 | // expose prometheus metrics 17 | router.use(prometheusMiddleware()) 18 | } 19 | } 20 | 21 | logger.init(robot.log) 22 | const mergeable = new Mergeable(process.env.NODE_ENV) 23 | mergeable.start(robot) 24 | } 25 | -------------------------------------------------------------------------------- /lib/actions/assign.js: -------------------------------------------------------------------------------- 1 | const { Action } = require('./action') 2 | const searchAndReplaceSpecialAnnotations = require('./lib/searchAndReplaceSpecialAnnotation') 3 | 4 | class Assign extends Action { 5 | constructor () { 6 | super('assign') 7 | this.supportedEvents = [ 8 | 'pull_request.*', 9 | 'issues.*', 10 | 'issue_comment.*' 11 | ] 12 | } 13 | 14 | // there is nothing to do 15 | async beforeValidate () {} 16 | 17 | async afterValidate (context, settings, name, results) { 18 | const evt = this.getEventAttributes(context) 19 | const payload = this.getPayload(context) 20 | const issueNumber = payload.number 21 | const assignees = settings.assignees.map(assignee => searchAndReplaceSpecialAnnotations(assignee, payload, evt)) 22 | const checkResults = await Promise.all(assignees.map( 23 | assignee => assignee === payload.user.login 24 | ? assignee 25 | : this.githubAPI.checkUserCanBeAssigned(context, issueNumber, assignee))) 26 | 27 | const authorizedAssignees = checkResults.filter(assignee => assignee !== null) 28 | 29 | return this.githubAPI.addAssignees(context, issueNumber, authorizedAssignees) 30 | } 31 | } 32 | 33 | module.exports = Assign 34 | -------------------------------------------------------------------------------- /lib/actions/close.js: -------------------------------------------------------------------------------- 1 | const { Action } = require('./action') 2 | 3 | class Close extends Action { 4 | constructor () { 5 | super('close') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'issues.*', 9 | 'issue_comment.*', 10 | 'schedule.repository' 11 | ] 12 | } 13 | 14 | // there is nothing to do 15 | async beforeValidate () {} 16 | 17 | async afterValidate (context, settings, name, results) { 18 | const items = this.getActionables(context, results) 19 | 20 | return Promise.all( 21 | // eslint-disable-next-line array-callback-return 22 | items.map(issue => { 23 | this.githubAPI.updateIssues( 24 | context, 25 | issue.number, 26 | 'closed' 27 | ) 28 | }) 29 | ) 30 | } 31 | } 32 | 33 | module.exports = Close 34 | -------------------------------------------------------------------------------- /lib/actions/handlebars/populateTemplate.js: -------------------------------------------------------------------------------- 1 | const handlebars = require('handlebars') 2 | const searchAndReplaceSpecialAnnotations = require('../lib/searchAndReplaceSpecialAnnotation') 3 | const _ = require('lodash') 4 | 5 | handlebars.registerHelper('breaklines', function (text) { 6 | text = handlebars.Utils.escapeExpression(text) 7 | text = text.replace(/(\r\n|\n|\r|\n\n)/gm, '
') 8 | return new handlebars.SafeString(text) 9 | }) 10 | 11 | handlebars.registerHelper('toUpperCase', function (str) { 12 | return str.toUpperCase() 13 | }) 14 | 15 | handlebars.registerHelper('formatDate', function (str) { 16 | let date = new Date() 17 | if (str === undefined) { 18 | return str 19 | } 20 | if (typeof str === 'string') { 21 | try { 22 | date = new Date(str) 23 | } catch { 24 | return str 25 | } 26 | } 27 | return date.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }) 28 | }) 29 | 30 | handlebars.registerHelper('displaySettings', function (settings) { 31 | return `\`\`\`${JSON.stringify(settings)}\`\`\`` 32 | }) 33 | 34 | handlebars.registerHelper('ifEquals', function (arg1, arg2, options) { 35 | return (arg1 === arg2) ? options.fn(this) : options.inverse(this) 36 | }) 37 | 38 | handlebars.registerHelper('statusIcon', function (str) { 39 | switch (str) { 40 | case 'pass': 41 | return ':heavy_check_mark:' 42 | case 'fail': 43 | return ':x:' 44 | case 'error': 45 | return ':heavy_exclamation_mark:' 46 | case 'info': 47 | return ':information_source:' 48 | default: 49 | return `Unknown Status given: ${str}` 50 | } 51 | }) 52 | 53 | const populateTemplate = (template, validationResult, payload, event) => { 54 | const newTemplate = searchAndReplaceSpecialAnnotations(template, payload, event) 55 | const handlebarsTemplate = handlebars.compile(newTemplate) 56 | return handlebarsTemplate(_.merge({}, payload, validationResult)) 57 | } 58 | 59 | module.exports = populateTemplate 60 | -------------------------------------------------------------------------------- /lib/actions/lib/createCheckName.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | const createCheckName = (name) => { 4 | return _.isUndefined(name) ? 'Mergeable' : `Mergeable: ${name}` 5 | } 6 | 7 | module.exports = createCheckName 8 | -------------------------------------------------------------------------------- /lib/actions/lib/searchAndReplaceSpecialAnnotation.js: -------------------------------------------------------------------------------- 1 | const SPECIAL_ANNOTATION = { 2 | '@author': (payload, event) => payload.user.login, 3 | '@action': (payload, event) => event.action, 4 | '@bot': (payload, event) => process.env.APP_NAME ? `${process.env.APP_NAME}[bot]` : 'Mergeable[bot]', 5 | '@repository': (payload, event) => event.repository?.full_name ?? '', 6 | '@sender': (payload, event) => event.sender.login ?? '' 7 | } 8 | 9 | const searchAndReplaceSpecialAnnotations = (template, payload, event) => { 10 | let newTemplate = template 11 | 12 | for (const annotation of Object.keys(SPECIAL_ANNOTATION)) { 13 | const specialAnnotationRegex = new RegExp(`(? reviewer.login) 19 | 20 | let reviewers = settings.reviewers || [] 21 | 22 | // remove author since they can not be requested for a review 23 | reviewers = reviewers.filter(reviewer => reviewer !== payload.user.login) 24 | 25 | const reviewerToRequest = _.difference(reviewers, requestedReviewer) 26 | const prNumber = payload.number 27 | 28 | // get Collaborators 29 | const collaborators = await this.githubAPI.listCollaborators(context, context.repo()) 30 | 31 | // remove anyone in the array that is not a collaborator 32 | const collaboratorsToRequest = _.intersection(reviewerToRequest, collaborators) 33 | 34 | const requestedTeams = payload.requested_teams.map(team => team.slug) 35 | 36 | const teams = settings.teams || [] 37 | 38 | const teamsToRequest = _.difference(teams, requestedTeams) 39 | 40 | if (collaboratorsToRequest.length === 0 && teamsToRequest.length === 0) { 41 | return 42 | } 43 | return this.githubAPI.requestReviewers( 44 | context, 45 | prNumber, 46 | reviewerToRequest, 47 | teamsToRequest 48 | ) 49 | } 50 | } 51 | 52 | module.exports = RequestReview 53 | -------------------------------------------------------------------------------- /lib/cache/cache.js: -------------------------------------------------------------------------------- 1 | const cacheManager = require('cache-manager') 2 | 3 | class Cache { 4 | constructor () { 5 | this.cache = null 6 | 7 | switch (process.env.CACHE_STORAGE) { 8 | case 'redis': { 9 | const redisStore = require('cache-manager-ioredis') 10 | this.cache = cacheManager.caching({ store: redisStore, host: process.env.CACHE_REDIS_HOST, port: process.env.CACHE_REDIS_PORT, password: process.env.CACHE_REDIS_HOST, db: process.env.CACHE_REDIS_DB, ttl: process.env.CACHE_TTL, refreshThreshold: process.env.CACHE_REDIS_REFRESH_THRESHOLD }) 11 | break 12 | } 13 | case 'memory': { 14 | this.cache = cacheManager.caching({ store: 'memory', max: process.env.CACHE_MEMORY_MAX, ttl: process.env.CACHE_TTL }) 15 | break 16 | } 17 | default: { 18 | this.cache = cacheManager.caching({ store: 'memory', max: process.env.CACHE_MEMORY_MAX, ttl: process.env.CACHE_TTL }) 19 | break 20 | } 21 | } 22 | return this.cache 23 | } 24 | } 25 | 26 | module.exports = Cache 27 | -------------------------------------------------------------------------------- /lib/context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The Mergeable context is a wrapper and extension of the probot context with some convenience 3 | * methods (in the future). 4 | */ 5 | class Context { 6 | constructor (context) { 7 | this.probotContext = context 8 | this.eventId = context.id 9 | this.eventName = context.name 10 | this.payload = context.payload 11 | this.octokit = context.octokit 12 | this.log = context.log 13 | } 14 | 15 | getEvent () { 16 | return (this.payload && this.payload.action) ? `${this.eventName}.${this.payload.action}` : this.eventName 17 | } 18 | 19 | repo (obj) { 20 | return this.probotContext.repo(obj) 21 | } 22 | } 23 | 24 | module.exports = Context 25 | -------------------------------------------------------------------------------- /lib/errors/teamNotFoundError.js: -------------------------------------------------------------------------------- 1 | class TeamNotFoundError extends Error { 2 | constructor (message) { 3 | super(message) 4 | this.name = 'TeamNotFoundError' 5 | } 6 | } 7 | 8 | module.exports = TeamNotFoundError 9 | -------------------------------------------------------------------------------- /lib/errors/unSupportedSettingError.js: -------------------------------------------------------------------------------- 1 | class UnSupportedSettingError extends Error { 2 | constructor (message) { 3 | super(message) 4 | this.name = 'UnSupportedSettingError' 5 | } 6 | } 7 | 8 | module.exports = UnSupportedSettingError 9 | -------------------------------------------------------------------------------- /lib/eventAware.js: -------------------------------------------------------------------------------- 1 | class EventAware { 2 | /** 3 | * @param eventName 4 | * An event name to be evaluated for support. The name is as in the GitHub 5 | * webhook format of issues.opened, pull_request.opened, etc 6 | * 7 | * @return boolean true if the EventAware object supports the event. i.e. issues.opened 8 | */ 9 | isEventSupported (eventName) { 10 | const eventObject = eventName.split('.')[0] 11 | const relevantEvent = this.supportedEvents.filter(event => event.split('.')[0] === eventObject || event === '*') 12 | return relevantEvent.indexOf('*') > -1 || 13 | relevantEvent.indexOf(`${eventObject}.*`) > -1 || 14 | relevantEvent.indexOf(eventName) > -1 15 | } 16 | 17 | getPayload (context, allPayload) { 18 | if (allPayload) { 19 | return context.payload 20 | } 21 | 22 | if (context.eventName === 'issues' || context.eventName === 'issue_comment') { 23 | return context.payload.issue 24 | } else if (context.eventName === 'pull_request_review') { 25 | return context.payload.pull_request 26 | } else { 27 | return context.payload[context.eventName] 28 | } 29 | } 30 | 31 | getEventAttributes (context) { 32 | if (context.eventName === 'schedule') { 33 | return { 34 | action: '', 35 | repository: {}, 36 | sender: {} 37 | } 38 | } 39 | return { 40 | action: context.payload.action, 41 | repository: context.payload.repository, 42 | sender: context.payload.sender 43 | } 44 | } 45 | } 46 | 47 | module.exports = EventAware 48 | -------------------------------------------------------------------------------- /lib/filters/and.js: -------------------------------------------------------------------------------- 1 | const { Filter } = require('./filter') 2 | const logicalConnectiveValidatorProcessor = require('./lib/logicalConnectiveValidatorProcessor') 3 | 4 | class And extends Filter { 5 | constructor () { 6 | super('and') 7 | this.supportedEvents = [ 8 | '*' 9 | ] 10 | this.supportedOptions = [ 11 | 'filter' 12 | ] 13 | this.supportedSettings = {} 14 | } 15 | 16 | async filter (context, settings, registry) { 17 | return logicalConnectiveValidatorProcessor(context, settings.filter, registry, 'And') 18 | } 19 | 20 | // skip validating settings 21 | validateSettings (supportedSettings, settingToCheck) {} 22 | } 23 | 24 | module.exports = And 25 | -------------------------------------------------------------------------------- /lib/filters/author.js: -------------------------------------------------------------------------------- 1 | const { Filter } = require('./filter') 2 | const Teams = require('../validators/options_processor/teams') 3 | class Author extends Filter { 4 | constructor () { 5 | super('author') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*' 9 | ] 10 | this.supportedSettings = { 11 | must_include: { 12 | regex: 'string', 13 | regex_flag: 'string', 14 | message: 'string' 15 | }, 16 | must_exclude: { 17 | regex: 'string', 18 | regex_flag: 'string', 19 | message: 'string' 20 | }, 21 | team: 'string', 22 | one_of: 'array', 23 | none_of: 'array' 24 | } 25 | } 26 | 27 | async filter (context, settings) { 28 | const payload = this.getPayload(context) 29 | 30 | if (settings.team) { 31 | const result = await Teams.processTeamOption(context, settings, payload) 32 | if (result.status !== 'pass') { 33 | return result 34 | } 35 | delete settings.team 36 | } 37 | 38 | return this.processOptions(context, payload.user.login, settings) 39 | } 40 | } 41 | 42 | module.exports = Author 43 | -------------------------------------------------------------------------------- /lib/filters/baseRef.js: -------------------------------------------------------------------------------- 1 | const { Filter } = require('./filter') 2 | 3 | class BaseRef extends Filter { 4 | constructor () { 5 | super('baseRef') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*' 9 | ] 10 | this.supportedSettings = { 11 | must_include: { 12 | regex: 'string', 13 | regex_flag: 'string', 14 | message: 'string' 15 | }, 16 | must_exclude: { 17 | regex: 'string', 18 | regex_flag: 'string', 19 | message: 'string' 20 | } 21 | } 22 | } 23 | 24 | async filter (context, settings) { 25 | const payload = this.getPayload(context) 26 | return this.processOptions(context, payload.base.ref, settings) 27 | } 28 | } 29 | 30 | module.exports = BaseRef 31 | -------------------------------------------------------------------------------- /lib/filters/not.js: -------------------------------------------------------------------------------- 1 | const { Filter } = require('./filter') 2 | const logicalConnectiveValidatorProcessor = require('./lib/logicalConnectiveValidatorProcessor') 3 | 4 | class Not extends Filter { 5 | constructor () { 6 | super('not') 7 | this.supportedEvents = [ 8 | '*' 9 | ] 10 | this.supportedOptions = [ 11 | 'filter' 12 | ] 13 | this.supportedSettings = {} 14 | } 15 | 16 | async filter (context, settings, registry) { 17 | return logicalConnectiveValidatorProcessor(context, settings.filter, registry, 'Not') 18 | } 19 | 20 | // skip validating settings 21 | validateSettings (supportedSettings, settingToCheck) {} 22 | } 23 | 24 | module.exports = Not 25 | -------------------------------------------------------------------------------- /lib/filters/options_processor/name.js: -------------------------------------------------------------------------------- 1 | const Options = require('./options') 2 | 3 | class Name { 4 | static async process (context, filter, settings) { 5 | const input = context.payload.repository.name 6 | const result = await Options.process(context, filter, input, settings.name) 7 | return { input: { name: input }, result } 8 | } 9 | } 10 | 11 | module.exports = Name 12 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options.js: -------------------------------------------------------------------------------- 1 | const consolidateResult = require('./options/lib/consolidateResults') 2 | const constructError = require('./options/lib/constructError') 3 | const constructOutput = require('./options/lib/constructOutput') 4 | const { forEach } = require('p-iteration') 5 | 6 | /** 7 | * Filter Processor 8 | * Process filters based on the set of rules 9 | * 10 | * Params must be in the follow format 11 | * filter: { 12 | * name: name 13 | * } 14 | * 15 | * Settings: [{ 16 | * option: either JSON object or Array of JSON objects 17 | * }] 18 | * 19 | * @param context 20 | * @param filter 21 | * @param settings 22 | * @returns {{mergeable, description}} 23 | */ 24 | 25 | class Options { 26 | static async process (context, filter, input, settings) { 27 | const output = [] 28 | 29 | if (!Array.isArray(settings)) { 30 | settings = [settings] 31 | } 32 | 33 | await forEach(settings, async (setting) => { 34 | await forEach(Object.keys(setting), async (key) => { 35 | if (key === 'do') return 36 | const rule = {} 37 | rule[key] = setting[key] 38 | try { 39 | if (filter.supportedOptions && filter.supportedOptions.indexOf(key) === -1) { 40 | output.push(constructError(filter, input, rule, `The '${key}' option is not supported for '${filter.name}' filter, please see README for all available options`)) 41 | } else { 42 | const result = await require(`./options/${key}`).process(context, filter, input, rule) 43 | output.push(constructOutput(filter, input, rule, result)) 44 | } 45 | } catch (err) { 46 | output.push(constructError(filter, input, rule, err.message)) 47 | } 48 | }) 49 | }) 50 | 51 | return consolidateResult(output, filter) 52 | } 53 | } 54 | 55 | module.exports = Options 56 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/and.js: -------------------------------------------------------------------------------- 1 | const andProcessor = require('../../../validators/options_processor/options/and') 2 | 3 | class AndProcessor { 4 | static async process (context, filter, input, rule) { 5 | return andProcessor.process(filter, input, rule) 6 | } 7 | } 8 | 9 | module.exports = AndProcessor 10 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/boolean.js: -------------------------------------------------------------------------------- 1 | const MATCH_NOT_FOUND_ERROR = 'Failed to run the test because \'match\' is not provided for \'boolean\' option. Please check README for more information about configuration' 2 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected strings "true" or "false", or boolean literal `true` or `false` as input' 3 | 4 | class BooleanMatch { 5 | static process (context, filter, input, rule) { 6 | const match = rule.boolean.match 7 | 8 | if (match == null) { 9 | throw new Error(MATCH_NOT_FOUND_ERROR) 10 | } 11 | 12 | if (input !== 'true' && input !== 'false' && typeof input !== 'boolean') { 13 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 14 | } 15 | 16 | let description = rule.boolean.message 17 | if (!description) description = `The ${filter.name} must be ${match}` 18 | 19 | const DEFAULT_SUCCESS_MESSAGE = `The ${filter.name} is ${match}` 20 | 21 | const isMergeable = input.toString() === match.toString() 22 | 23 | return { 24 | status: isMergeable ? 'pass' : 'fail', 25 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 26 | } 27 | } 28 | } 29 | 30 | module.exports = BooleanMatch 31 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/lib/consolidateResults.js: -------------------------------------------------------------------------------- 1 | const consolidateResults = require('../../../../validators/options_processor/options/lib/consolidateResults') 2 | 3 | module.exports = (result, filter) => { 4 | const results = consolidateResults(result, filter) 5 | delete Object.assign(results, { filters: results.validations }).validations 6 | return results 7 | } 8 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/lib/constructError.js: -------------------------------------------------------------------------------- 1 | const constructOuput = require('./constructOutput') 2 | 3 | module.exports = (filter, input, rule, error, details) => { 4 | const result = { 5 | status: 'error', 6 | description: error 7 | } 8 | return constructOuput(filter, input, rule, result, details) 9 | } 10 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/lib/constructOutput.js: -------------------------------------------------------------------------------- 1 | const constructOutput = require('../../../../validators/options_processor/options/lib/constructOutput') 2 | 3 | module.exports = (filter, input, rule, result, error) => { 4 | const output = constructOutput(filter, input, rule, result, error) 5 | delete Object.assign(output, { filter: output.validator }).validator 6 | return output 7 | } 8 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/must_exclude.js: -------------------------------------------------------------------------------- 1 | const mustExclude = require('../../../validators/options_processor/options/must_exclude') 2 | 3 | class MustExclude { 4 | static process (context, filter, input, rule) { 5 | return mustExclude.process(filter, input, rule) 6 | } 7 | } 8 | 9 | module.exports = MustExclude 10 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/must_include.js: -------------------------------------------------------------------------------- 1 | const mustInclude = require('../../../validators/options_processor/options/must_include') 2 | 3 | class MustInclude { 4 | static process (context, filter, input, rule) { 5 | return mustInclude.process(filter, input, rule) 6 | } 7 | } 8 | 9 | module.exports = MustInclude 10 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/none_of.js: -------------------------------------------------------------------------------- 1 | const listProcessor = require('../../../validators/options_processor/listProcessor') 2 | const noneOf = require('../../../validators/options_processor/options/none_of') 3 | 4 | class NoneOf { 5 | static async process (context, filter, input, rule) { 6 | const candidates = await listProcessor.process(rule.none_of, context) 7 | return noneOf.process(filter, input, { none_of: candidates }) 8 | } 9 | } 10 | 11 | module.exports = NoneOf 12 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/one_of.js: -------------------------------------------------------------------------------- 1 | const listProcessor = require('../../../validators/options_processor/listProcessor') 2 | const oneOf = require('../../../validators/options_processor/options/one_of') 3 | 4 | class OneOf { 5 | static async process (context, filter, input, rule) { 6 | const candidates = await listProcessor.process(rule.one_of, context) 7 | return oneOf.process(filter, input, { one_of: candidates }) 8 | } 9 | } 10 | 11 | module.exports = OneOf 12 | -------------------------------------------------------------------------------- /lib/filters/options_processor/options/or.js: -------------------------------------------------------------------------------- 1 | const orProcessor = require('../../../validators/options_processor/options/or') 2 | 3 | class OrProcessor { 4 | static async process (context, filter, input, rule) { 5 | return orProcessor.process(filter, input, rule) 6 | } 7 | } 8 | 9 | module.exports = OrProcessor 10 | -------------------------------------------------------------------------------- /lib/filters/options_processor/topics.js: -------------------------------------------------------------------------------- 1 | const Options = require('./options') 2 | const CacheManager = require('../../cache/cache') 3 | const GithubAPI = require('../../github/api') 4 | 5 | // Setup the cache manager 6 | const cacheManager = new CacheManager() 7 | 8 | class Topics { 9 | static async process (context, filter, settings) { 10 | const input = await repoTopics(context) 11 | const result = await Options.process(context, filter, input, settings.topics) 12 | return { input, result } 13 | } 14 | } 15 | 16 | const repoTopics = async (context) => { 17 | const repo = context.repo() 18 | const globalSettings = context.globalSettings 19 | 20 | if (globalSettings.use_config_cache !== undefined && globalSettings.use_config_cache === true) { 21 | const names = await cacheManager.get(`${repo.owner}/${repo.repo}/topics`) 22 | if (names) { 23 | return names 24 | } 25 | } 26 | 27 | const response = await GithubAPI.getAllTopics(context, { 28 | owner: context.payload.repository.owner.login, 29 | repo: context.payload.repository.name 30 | }) 31 | 32 | if (globalSettings.use_config_cache !== undefined && globalSettings.use_config_cache === true) { 33 | cacheManager.set(`${repo.owner}/${repo.repo}/topics`, response) 34 | } 35 | return response 36 | } 37 | 38 | module.exports = Topics 39 | -------------------------------------------------------------------------------- /lib/filters/options_processor/visibility.js: -------------------------------------------------------------------------------- 1 | const EventAware = require('../../eventAware') 2 | 3 | class Visibility { 4 | static process (context, filter, settings) { 5 | const payload = (new EventAware()).getPayload(context) 6 | let status = 'pass' 7 | if (settings.visibility === 'public' && payload.base.repo.private) { 8 | status = 'fail' 9 | } 10 | if (settings.visibility === 'private' && !payload.base.repo.private) { 11 | status = 'fail' 12 | } 13 | return { 14 | input: { private: payload.base.repo.private }, 15 | result: { status: status } 16 | } 17 | } 18 | } 19 | 20 | module.exports = Visibility 21 | -------------------------------------------------------------------------------- /lib/filters/or.js: -------------------------------------------------------------------------------- 1 | const { Filter } = require('./filter') 2 | const logicalConnectiveValidatorProcessor = require('./lib/logicalConnectiveValidatorProcessor') 3 | 4 | class Or extends Filter { 5 | constructor () { 6 | super('or') 7 | this.supportedEvents = [ 8 | '*' 9 | ] 10 | this.supportedOptions = [ 11 | 'filter' 12 | ] 13 | this.supportedSettings = {} 14 | } 15 | 16 | async filter (context, settings, registry) { 17 | return logicalConnectiveValidatorProcessor(context, settings.filter, registry, 'Or') 18 | } 19 | 20 | // skip validating settings 21 | validateSettings (supportedSettings, settingToCheck) {} 22 | } 23 | 24 | module.exports = Or 25 | -------------------------------------------------------------------------------- /lib/filters/payload.js: -------------------------------------------------------------------------------- 1 | const { Filter } = require('./filter') 2 | const consolidateResult = require('./options_processor/options/lib/consolidateResults') 3 | const constructError = require('./options_processor/options/lib/constructError') 4 | const _ = require('lodash') 5 | const { forEach } = require('p-iteration') 6 | 7 | const options = ['boolean', 'must_include', 'must_exclude', 'one_of', 'none_of'] 8 | 9 | async function recursveThruFields (filterObj, context, currentPath, output, payload, field) { 10 | await forEach(Object.keys(field), async key => { 11 | if (key === 'do') return 12 | 13 | if (options.includes(key)) { 14 | output.push(await filterObj.processOptions(context, payload, Object.assign(field, { do: currentPath }))) 15 | } else if (_.isUndefined(payload[key])) { 16 | output.push(constructError(filterObj, '', field, `${currentPath + '.' + key} does NOT exist`)) 17 | } else { 18 | await recursveThruFields(filterObj, context, `${currentPath + '.' + key}`, output, payload[key], field[key]) 19 | } 20 | }) 21 | } 22 | 23 | class Payload extends Filter { 24 | constructor () { 25 | super('payload') 26 | this.supportedEvents = [ 27 | 'pull_request.*', 28 | 'pull_request_review.*', 29 | 'issues.*', 30 | 'issue_comment.*' 31 | ] 32 | // no specific supported settings because it can vary by events 33 | this.supportedSettings = {} 34 | } 35 | 36 | async filter (context, settings) { 37 | const output = [] 38 | 39 | await recursveThruFields(this, context, 'payload', output, this.getPayload(context, true), settings) 40 | 41 | const filter = { 42 | name: settings.do, 43 | supportedOptions: this.supportedOptions 44 | } 45 | return consolidateResult(output, filter) 46 | } 47 | 48 | // skip validation because the number of possible fields to check vary by event 49 | validateSettings (supportedSettings, settingToCheck, nestings = []) { 50 | } 51 | } 52 | 53 | module.exports = Payload 54 | -------------------------------------------------------------------------------- /lib/filters/repository.js: -------------------------------------------------------------------------------- 1 | const Topics = require('./options_processor/topics') 2 | const Visibility = require('./options_processor/visibility') 3 | const Name = require('./options_processor/name') 4 | const { Filter } = require('./filter') 5 | const consolidateResult = require('./options_processor/options/lib/consolidateResults') 6 | const constructOutput = require('./options_processor/options/lib/constructOutput') 7 | 8 | class Repository extends Filter { 9 | constructor () { 10 | super('repository') 11 | this.supportedEvents = [ 12 | 'pull_request.*', 13 | 'pull_request_review.*' 14 | ] 15 | this.supportedSettings = { 16 | visibility: 'string', 17 | topics: { 18 | must_include: { 19 | regex: 'string', 20 | regex_flag: 'string', 21 | message: 'string' 22 | }, 23 | must_exclude: { 24 | regex: 'string', 25 | regex_flag: 'string', 26 | message: 'string' 27 | } 28 | }, 29 | name: { 30 | must_include: { 31 | regex: 'string', 32 | regex_flag: 'string', 33 | message: 'string' 34 | }, 35 | must_exclude: { 36 | regex: 'string', 37 | regex_flag: 'string', 38 | message: 'string' 39 | } 40 | } 41 | } 42 | } 43 | 44 | async filter (context, settings) { 45 | const output = [] 46 | 47 | const filter = { 48 | name: settings.do, 49 | supportedOptions: this.supportedOptions 50 | } 51 | 52 | if (settings.topics) { 53 | const processor = await Topics.process(context, filter, settings) 54 | output.push(constructOutput(filter, processor.input, { topics: settings.topics }, processor.result)) 55 | } 56 | 57 | if (settings.visibility) { 58 | const processor = Visibility.process(context, filter, settings) 59 | output.push(constructOutput(filter, processor.input, { visibility: settings.visibility }, processor.result)) 60 | } 61 | 62 | if (settings.name) { 63 | const processor = await Name.process(context, filter, settings) 64 | output.push(constructOutput(filter, processor.input, { name: settings.name }, processor.result)) 65 | } 66 | return consolidateResult(output, filter) 67 | } 68 | } 69 | 70 | module.exports = Repository 71 | -------------------------------------------------------------------------------- /lib/flex/flex.js: -------------------------------------------------------------------------------- 1 | const Configuration = require('../configuration/configuration') 2 | const Settings = require('../settings/settings') 3 | const logAndProcessConfigErrors = require('./lib/logAndProcessConfigErrors') 4 | const interceptors = require('../interceptors') 5 | const processWorkflow = require('./lib/processWorkflow') 6 | 7 | const logger = require('../logger') 8 | 9 | // Main logic Processor of mergeable 10 | const executeMergeable = async (context, registry) => { 11 | if (registry === undefined) { 12 | registry = { filters: new Map(), validators: new Map(), actions: new Map() } 13 | } 14 | 15 | // interceptors 16 | await interceptors(context) 17 | 18 | // first fetch the global settings 19 | context.globalSettings = await Settings.instanceWithContext(context) 20 | 21 | // then fetch the rule configuration 22 | const config = await Configuration.instanceWithContext(context) 23 | 24 | if (config.hasErrors()) { 25 | return logAndProcessConfigErrors(context, config) 26 | } 27 | 28 | if (process.env.LOG_CONFIG) { 29 | const log = logger.create('flex') 30 | const configLog = { 31 | logType: logger.logTypes.CONFIG, 32 | eventId: context.eventId, 33 | repo: context.payload.repository.full_name, 34 | settings: JSON.stringify(config.settings) 35 | } 36 | 37 | log.info(JSON.stringify(configLog)) 38 | } 39 | 40 | await processWorkflow(context, registry, config) 41 | } 42 | 43 | module.exports = executeMergeable 44 | -------------------------------------------------------------------------------- /lib/flex/lib/createPromises.js: -------------------------------------------------------------------------------- 1 | const createPromises = (arrayToIterate, registryName, funcCall, context, registry, name, result) => { 2 | const promises = [] 3 | arrayToIterate.forEach(element => { 4 | const key = element.do 5 | const klass = registry[registryName].get(key) 6 | const eventName = `${context.eventName}.${context.payload.action}` 7 | if (klass.isEventSupported(eventName)) { 8 | promises.push(funcCall(klass, context, element, name, result)) 9 | } 10 | }) 11 | return promises 12 | } 13 | 14 | module.exports = createPromises 15 | -------------------------------------------------------------------------------- /lib/flex/lib/getActionPromises.js: -------------------------------------------------------------------------------- 1 | const createPromises = require('./createPromises') 2 | 3 | const getActionPromises = (context, registry, rule, result) => { 4 | const actions = rule[result.validationStatus] 5 | if (actions) { 6 | const afterValidateFuncCall = (actionClass, context, action, name, result) => actionClass.processAfterValidate(context, action, name, result) 7 | 8 | return createPromises(actions, 'actions', afterValidateFuncCall, context, registry, rule.name, result) 9 | } 10 | } 11 | 12 | module.exports = getActionPromises 13 | -------------------------------------------------------------------------------- /lib/flex/lib/getFilterPromises.js: -------------------------------------------------------------------------------- 1 | const createPromises = require('./createPromises') 2 | 3 | const getFilterPromises = (context, registry, rule) => { 4 | const filters = [] 5 | if (rule.filter) { 6 | filters.push(...rule.filter) 7 | } 8 | const filterFuncCall = (filter, context, settings) => filter.processFilter(context, settings, registry) 9 | 10 | return createPromises(filters, 'filters', filterFuncCall, context, registry) 11 | } 12 | 13 | module.exports = getFilterPromises 14 | -------------------------------------------------------------------------------- /lib/flex/lib/getValidatorPromises.js: -------------------------------------------------------------------------------- 1 | const createPromises = require('./createPromises') 2 | 3 | const getValidatorPromises = (context, registry, rule) => { 4 | const validateFuncCall = (validator, context, validation) => validator.processValidate(context, validation, registry) 5 | 6 | return createPromises(rule.validate, 'validators', validateFuncCall, context, registry) 7 | } 8 | 9 | module.exports = getValidatorPromises 10 | -------------------------------------------------------------------------------- /lib/flex/lib/logAndProcessConfigErrors.js: -------------------------------------------------------------------------------- 1 | const Configuration = require('../../configuration/configuration') 2 | const Checks = require('../../actions/checks') 3 | 4 | const logger = require('../../logger') 5 | 6 | const logAndProcessConfigErrors = (context, config) => { 7 | const log = logger.create('flex') 8 | const event = `${context.eventName}.${context.payload.action}` 9 | const errors = config.errors 10 | 11 | const checks = new Checks() 12 | if (!checks.isEventSupported(event)) return 13 | 14 | const checkRunParam = { 15 | context: context, 16 | payload: { 17 | status: 'completed', 18 | conclusion: 'cancelled', 19 | output: { 20 | title: 'Invalid Configuration', 21 | summary: formatErrorSummary(errors) 22 | }, 23 | completed_at: new Date() 24 | } 25 | } 26 | 27 | const configErrorLog = { 28 | logType: logger.logTypes.CONFIG_INVALID_YML, 29 | eventId: context.eventId, 30 | errors, 31 | repo: context.payload.repository.full_name, 32 | event, 33 | settings: JSON.stringify(config.settings) 34 | } 35 | 36 | if (errors.has(Configuration.ERROR_CODES.NO_YML)) { 37 | checkRunParam.payload.conclusion = 'success' 38 | checkRunParam.payload.output = { 39 | title: 'No Config file found', 40 | summary: 'To enable Mergeable, please create a .github/mergeable.yml' + 41 | '\n\nSee the [documentation](https://github.com/mergeability/mergeable) for details on configuration.' 42 | } 43 | 44 | configErrorLog.log_type = logger.logTypes.CONFIG_NO_YML 45 | } 46 | 47 | log.info(JSON.stringify(configErrorLog)) 48 | 49 | return checks.run(checkRunParam) 50 | } 51 | 52 | const formatErrorSummary = (errors) => { 53 | const it = errors.values() 54 | let summary = `Errors were found in the configuration (${Configuration.FILE_NAME}):` 55 | let message = it.next() 56 | while (!message.done) { 57 | summary += '\n- ' + message.value 58 | message = it.next() 59 | } 60 | summary += '\n\nSee the [documentation](https://github.com/mergeability/mergeable) for details on configuration.' 61 | return summary 62 | } 63 | 64 | module.exports = logAndProcessConfigErrors 65 | -------------------------------------------------------------------------------- /lib/interceptors/checkReRun.js: -------------------------------------------------------------------------------- 1 | const Interceptor = require('./interceptor') 2 | const MetaData = require('../metaData') 3 | const Logger = require('../logger') 4 | 5 | /** 6 | * Checks the event for a re-requested check_run. This GH event is triggered when the user 7 | * clicks on "Re-run" or "Re-run failed checks" in the UI and expects conditions to be re-validated. Fetch the PR and it's stored condition from 8 | * the check run text. 9 | * 10 | * Set the context up with the appropriate PR payload, event and action for a validation and check run. 11 | * 12 | * NOTE: "Re-run all checks" generates a different event and is not taken care of in this interceptor. 13 | */ 14 | class CheckReRun extends Interceptor { 15 | async process (context) { 16 | if (!(context.eventName === 'check_run' && context.payload.action === 'rerequested')) return context 17 | 18 | const checkRun = context.payload.check_run 19 | // some checkRun doesn't have output field, skip if output field is not present 20 | if (!checkRun || !checkRun.output) return context 21 | 22 | const meta = MetaData.deserialize(checkRun.output.text) 23 | if (this.possibleInjection(context, checkRun, meta)) return context 24 | 25 | // sometimes the checkRun.pull_requests is empty, in those cases, just skip 26 | if (checkRun.pull_requests.length === 0) return context 27 | 28 | const pr = await context.octokit.pulls.get(context.repo({ pull_number: checkRun.pull_requests[0].number })) 29 | context.payload.action = meta.action 30 | context.eventName = meta.eventName 31 | context.payload.pull_request = pr.data 32 | return context 33 | } 34 | 35 | possibleInjection (context, checkRun, meta) { 36 | const isInjection = checkRun.id !== meta.id 37 | if (isInjection) { 38 | const log = Logger.create('interceptors/checkReRun') 39 | log.warn({ 40 | logType: Logger.logTypes.POTENTIAL_INJECTION, 41 | message: 'ids in payload do not match. Potential injection.', 42 | checkRun: checkRun, 43 | meta: meta 44 | }) 45 | } 46 | return isInjection 47 | } 48 | } 49 | 50 | module.exports = CheckReRun 51 | -------------------------------------------------------------------------------- /lib/interceptors/index.js: -------------------------------------------------------------------------------- 1 | const REGISTRY = [ 2 | new (require('./checkReRun'))(), 3 | new (require('./milestoned'))(), 4 | new (require('./push'))() 5 | ] // eslint-disable-line 6 | 7 | /** 8 | * Processes all the interceptors in the order of the registry array. 9 | */ 10 | module.exports = async (context) => { 11 | await Promise.all(REGISTRY.map(interceptor => interceptor.process(context))) 12 | } 13 | -------------------------------------------------------------------------------- /lib/interceptors/interceptor.js: -------------------------------------------------------------------------------- 1 | const GithubAPI = require('../github/api') 2 | /** 3 | * The Interceptor class defines the interface for all inheriting interceptors that 4 | * mutates the probot context with additional meta data or changes existing property Values 5 | * depending on certain criterias. 6 | * 7 | * This is used to filter by event and manipulate (add data or modify) the context such that the workflow engine is interacting 8 | * with the data in context depending on certain scenarios. 9 | * 10 | * Interceptors are cached instances and should be treated as singletons and is NOT thread safe. Instance variables should be treated as constants. 11 | */ 12 | class Interceptor { 13 | constructor () { 14 | this.githubAPI = GithubAPI 15 | } 16 | 17 | /** 18 | * All Interceptors should overwrite this method and mutate the context as needed. 19 | * By default returns the context unchanged. 20 | */ 21 | async process (context) { 22 | return context 23 | } 24 | } 25 | 26 | module.exports = Interceptor 27 | -------------------------------------------------------------------------------- /lib/interceptors/milestoned.js: -------------------------------------------------------------------------------- 1 | const Interceptor = require('./interceptor') 2 | 3 | /** 4 | * Handles milestoned and demilestoned for Pull Requests. 5 | */ 6 | class Milestoned extends Interceptor { 7 | async process (context) { 8 | if (this.valid(context)) { 9 | const res = await context.octokit.pulls.get(context.repo({ pull_number: context.payload.issue.number })) 10 | res.data.action = context.payload.action 11 | context.eventName = 'pull_request' 12 | context.payload.pull_request = res.data 13 | } 14 | return context 15 | } 16 | 17 | /** 18 | * @return true if issue has the action milestoned or demilestoned but is from a pull_request. 19 | */ 20 | valid (context) { 21 | // GH does not differentiate between issues and pulls for milestones. The only differentiator 22 | // is the payload for issues containing a pull_request property. 23 | return (context.eventName === 'issues' && 24 | (context.payload.action === 'milestoned' || 25 | context.payload.action === 'demilestoned')) && 26 | !!context.payload.issue.pull_request 27 | } 28 | } 29 | 30 | module.exports = Milestoned 31 | -------------------------------------------------------------------------------- /lib/interceptors/push.js: -------------------------------------------------------------------------------- 1 | const Interceptor = require('./interceptor') 2 | const processWorkflow = require('../flex/lib/processWorkflow') 3 | const Configuration = require('../configuration/configuration') 4 | const logAndProcessConfigErrors = require('../flex/lib/logAndProcessConfigErrors') 5 | const _ = require('lodash') 6 | 7 | /** 8 | * Checks the event for a push event. This GH event is triggered when the user push commits to any branch 9 | * 10 | * Re-run checks on all PR against the branch in which the commits have been pushed iff the config file has been changed 11 | */ 12 | class Push extends Interceptor { 13 | async process (context) { 14 | if (context.eventName !== 'push') return context 15 | 16 | // if there is no head_commit, just skip 17 | if (_.isUndefined(context.payload.head_commit) || !context.payload.head_commit) return context 18 | 19 | const addedFiles = context.payload.head_commit.added 20 | const modifiedFiles = context.payload.head_commit.modified 21 | 22 | const configPath = process.env.CONFIG_PATH ? process.env.CONFIG_PATH : 'mergeable.yml' 23 | if (!(addedFiles.includes(`.github/${configPath}`) || modifiedFiles.includes(`.github/${configPath}`))) return context 24 | const config = await Configuration.instanceWithContext(context) 25 | if (config.hasErrors()) { 26 | await logAndProcessConfigErrors(context, config) 27 | return context 28 | } 29 | 30 | const registry = { filters: new Map(), validators: new Map(), actions: new Map() } 31 | 32 | const res = await this.githubAPI.listPR(context) 33 | 34 | const pulls = res.data 35 | await Promise.all(pulls.map(pullRequest => { 36 | const newContext = _.cloneDeep(context) 37 | newContext.eventName = 'pull_request' 38 | newContext.payload.pull_request = pullRequest 39 | newContext.payload.action = 'push_synchronize' 40 | return processWorkflow(newContext, registry, config) 41 | })) 42 | 43 | return context 44 | } 45 | } 46 | 47 | module.exports = Push 48 | -------------------------------------------------------------------------------- /lib/logger.js: -------------------------------------------------------------------------------- 1 | // Our log object is provided by probot and we only have access to it during run-time 2 | // this module acts as a singleton for log object and needs to be initialized before using it 3 | let logger 4 | 5 | const logType = require('./utils/logTypes') 6 | 7 | class Logger { 8 | static get logTypes () { 9 | return logType 10 | } 11 | 12 | static create (name = 'mergeable') { 13 | if (logger === undefined) { 14 | throw Error('Logger has not been initialized') 15 | } 16 | 17 | return logger.child({ name }) 18 | } 19 | 20 | static init (log) { 21 | if (logger !== undefined) { 22 | throw Error('Logger has already been initialized, no need to initialize it again') 23 | } 24 | 25 | logger = log 26 | log.info('Logger Successfully initialized') 27 | } 28 | } 29 | 30 | module.exports = Logger 31 | -------------------------------------------------------------------------------- /lib/metaData.js: -------------------------------------------------------------------------------- 1 | 2 | const DATA_START = '' 4 | 5 | /** 6 | * Utility class to serialize/deserialuze a json/string to be appended to any text element in a 7 | * GH check_run, issue body, pull body, comments, etc. 8 | * i.e. 9 | * 10 | * This is primarily used to store meta-data to be retrieved later in a payload/webhook. 11 | * Since all of these elements in GH is markdown the text is in a HTML comment that will be hidden to the user. 12 | * 13 | */ 14 | class MetaData { 15 | /** 16 | * @return a string representation of the meta-data 17 | */ 18 | static serialize (json) { 19 | return `${DATA_START} ${JSON.stringify(json)} ${DATA_END}` 20 | } 21 | 22 | /** 23 | * @return true if meta data exists in a string. 24 | */ 25 | static exists (text) { 26 | return (text !== undefined && text.indexOf(DATA_START) !== -1 && text.indexOf(DATA_END) !== -1) 27 | } 28 | 29 | /** 30 | * @return the jsob object in a string that contains the serialized meta-data. 31 | */ 32 | static deserialize (text) { 33 | const begin = text.indexOf(DATA_START) + DATA_START.length 34 | const end = text.indexOf(DATA_END) 35 | const jsonString = text.substring(begin, end) 36 | return JSON.parse(jsonString.trim()) 37 | } 38 | } 39 | 40 | module.exports = MetaData 41 | -------------------------------------------------------------------------------- /lib/register.js: -------------------------------------------------------------------------------- 1 | class Register { 2 | static registerFilters (rule, registry) { 3 | if (!rule.filter) { 4 | return 5 | } 6 | rule.filter.forEach(filter => { 7 | const key = filter.do 8 | 9 | if (!registry.filters.has(key)) { 10 | const Filter = require(`./filters/${key}`) 11 | registry.filters.set(key, new Filter()) 12 | } 13 | }) 14 | } 15 | 16 | static registerValidators (rule, registry) { 17 | rule.validate.forEach(validation => { 18 | const key = validation.do 19 | 20 | if (!registry.validators.has(key)) { 21 | const Validator = require(`./validators/${key}`) 22 | registry.validators.set(key, new Validator()) 23 | } 24 | }) 25 | } 26 | 27 | static registerActions (rule, registry) { 28 | let possibleActions = [] 29 | const outcomesToCheck = [rule.pass, rule.fail, rule.error] 30 | 31 | outcomesToCheck.forEach(actions => { 32 | if (actions) { 33 | possibleActions = possibleActions.concat(actions) 34 | } 35 | }) 36 | 37 | possibleActions.forEach(action => { 38 | const key = action.do 39 | if (!registry.actions.has(key)) { 40 | const Action = require(`./actions/${key}`) 41 | registry.actions.set(key, new Action()) 42 | } 43 | }) 44 | } 45 | 46 | static registerAll (settings, registry) { 47 | settings.forEach(rule => { 48 | try { 49 | this.registerFilters(rule, registry) 50 | } catch (err) { 51 | throw new Error('Filters have thrown ' + err) 52 | } 53 | try { 54 | this.registerValidators(rule, registry) 55 | } catch (err) { 56 | throw new Error('Validators have thrown ' + err) 57 | } 58 | try { 59 | this.registerActions(rule, registry) 60 | } catch (err) { 61 | throw new Error('Actions have thrown ' + err) 62 | } 63 | }) 64 | } 65 | } 66 | 67 | module.exports = Register 68 | -------------------------------------------------------------------------------- /lib/settings/lib/consts.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | DEFAULT_USE_CONFIG_FROM_PULL_REQUEST: (process.env.USE_CONFIG_FROM_PULL_REQUEST === 'true') || true, 3 | DEFAULT_USE_CONFIG_CACHE: (process.env.USE_CONFIG_CACHE === 'true') || false, 4 | DEFAULT_USE_ORG_AS_DEFAULT_CONFIG: (process.env.USE_ORG_AS_DEFAULT_CONFIG === 'true') || false, 5 | DEFAULT_CONFIG_PATH: process.env.CONFIG_PATH || '' 6 | } 7 | -------------------------------------------------------------------------------- /lib/settings/transformers/v1Settings.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | const consts = require('../lib/consts') 3 | 4 | class V1Settings { 5 | static transform (Settings) { 6 | const transformedSettings = _.cloneDeep(Settings) 7 | setSettingsDefault(transformedSettings) 8 | return transformedSettings 9 | } 10 | } 11 | 12 | const checkAndSetDefault = (ruleSet, defaultValue) => { 13 | if (ruleSet === undefined) { 14 | return defaultValue 15 | } 16 | return ruleSet 17 | } 18 | 19 | const setSettingsDefault = (Settings) => { 20 | const mergeableSettings = Settings.mergeable 21 | 22 | mergeableSettings.use_config_from_pull_request = checkAndSetDefault(mergeableSettings.use_config_from_pull_request, consts.DEFAULT_USE_CONFIG_FROM_PULL_REQUEST) 23 | mergeableSettings.use_config_cache = checkAndSetDefault(mergeableSettings.use_config_cache, consts.DEFAULT_USE_CONFIG_CACHE) 24 | mergeableSettings.use_org_as_default_config = checkAndSetDefault(mergeableSettings.use_org_as_default_config, consts.DEFAULT_USE_ORG_AS_DEFAULT_CONFIG) 25 | mergeableSettings.config_path = checkAndSetDefault(mergeableSettings.config_path, consts.DEFAULT_CONFIG_PATH) 26 | } 27 | 28 | module.exports = V1Settings 29 | -------------------------------------------------------------------------------- /lib/stats/extractValidationStats.js: -------------------------------------------------------------------------------- 1 | /** 2 | * extract validation stats to be used in populating the output template using handlebars 3 | * 4 | * The following Values are extracted 5 | * 6 | * validationStatus OverAll status of the valiations 7 | * validationCount Num of Validations ran 8 | * passCount Num of validations passed 9 | * failureCount Num of validations failed 10 | * errorCount Num of validations errored 11 | * validations : [{ 12 | * validatorName: // Validator that was run 13 | * status: 'pass|fail|error' 14 | * description: 'Defaul or custom Message' 15 | * details { 16 | * input: // input the tests are run against 17 | * setting: rule 18 | * }] 19 | * } 20 | * 21 | */ 22 | module.exports = (results) => { 23 | const validationStatuses = results.map(result => result.status) 24 | const passCount = validationStatuses.filter(status => status === 'pass').length 25 | const failCount = validationStatuses.filter(status => status === 'fail').length 26 | const errorCount = validationStatuses.filter(status => status === 'error').length 27 | let validationStatus = 'pass' 28 | 29 | if (errorCount > 0) { 30 | validationStatus = 'error' 31 | } else if (failCount > 0) { 32 | validationStatus = 'fail' 33 | } 34 | 35 | const output = { 36 | validationStatus, 37 | validationCount: validationStatuses.length, 38 | passCount, 39 | failCount, 40 | errorCount, 41 | validationSuites: results, 42 | failures: searchByStatus('fail', results) 43 | } 44 | 45 | return output 46 | } 47 | 48 | function searchByStatus (nameKey, myArray) { 49 | const statusFiltered = [] 50 | for (let i = 0; i < myArray.length; i++) { 51 | if (myArray[i].status === nameKey) { 52 | statusFiltered.push(myArray[i]) 53 | } 54 | } 55 | 56 | return statusFiltered 57 | } 58 | -------------------------------------------------------------------------------- /lib/stats/githubPrometheusStats.js: -------------------------------------------------------------------------------- 1 | const Prometheus = require('prom-client') 2 | 3 | module.exports = { 4 | GithubRateLimitRemainingTotal: new Prometheus.Gauge({ 5 | name: 'github_ratelimit_remaining_total', 6 | help: 'The total amount of Rate Limit requests remaining', 7 | labelNames: ['installationId'] 8 | }), 9 | GithubRateLimitLimitTotal: new Prometheus.Gauge({ 10 | name: 'github_ratelimit_limit_total', 11 | help: 'The total amount of Rate Limit requests limit', 12 | labelNames: ['installationId'] 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils/githubRateLimitEndpoint.js: -------------------------------------------------------------------------------- 1 | module.exports = (probot) => { 2 | return async (req, res) => { 3 | let octokit 4 | 5 | if (req.params.installationId === '') { 6 | res.json('missing parameter `installationId`') 7 | return 8 | } 9 | 10 | try { 11 | octokit = await probot.auth(req.params.installationId) 12 | } catch (err) { 13 | res.json('invalid parameter `installationId`') 14 | return 15 | } 16 | 17 | octokit.rateLimit.get().then(result => { 18 | res.json(result.data.resources.core) 19 | }).catch(err => { 20 | res.json(`installation error: ${err.message}`) 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/utils/logTypes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | EVENT_RECEIVED: 'event_received', 3 | CONFIG_INVALID_YML: 'config_invalid_yml', 4 | CONFIG_NO_YML: 'config_no_yml', 5 | UNKNOWN_ERROR_FILTER: 'unknown_error_filter', 6 | UNKNOWN_ERROR_VALIDATOR: 'unknown_error_validator', 7 | UNKNOWN_ERROR_ACTION: 'unknown_error_action', 8 | FILTER_PROCESS: 'filter_process', 9 | VALIDATOR_PROCESS: 'validator_process', 10 | ACTION_BEFORE_VALIDATE_EXECUTE: 'action_before_validate_execute', 11 | ACTION_AFTER_VALIDATE_EXECUTE: 'action_after_validate_execute', 12 | POTENTIAL_INJECTION: 'potential_injection', 13 | CONFIG: 'config', 14 | MERGE_FAIL_ERROR: 'merge_fail_error', 15 | DELETE_COMMENT_FAIL_ERROR: 'delete_comment_fail_error', 16 | REQUEST_REVIEW_FAIL_ERROR: 'request_review_fail_error', 17 | ISSUE_GET_FAIL_ERROR: 'issue_get_fail_error', 18 | UNKNOWN_GITHUB_API_ERROR: 'unknown_github_api_error', 19 | GITHUB_SERVER_ERROR: 'github_server_error', 20 | HTTP_NOT_FOUND_ERROR: 'http_not_found_error', 21 | GITHUB_API_DEBUG: 'github_api_debug' 22 | } 23 | -------------------------------------------------------------------------------- /lib/validators/age.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | const moment = require('moment-timezone') 3 | const constructOutput = require('./options_processor/options/lib/constructOutput') 4 | const consolidateResult = require('./options_processor/options/lib/consolidateResults') 5 | 6 | const checkAge = (now, timeToCompare, settings) => { 7 | const validatorContext = { name: 'age' } 8 | const diff = now.diff(moment(timeToCompare)) 9 | const isMergeable = (moment.duration(diff).asDays() >= settings.days) 10 | 11 | const result = { 12 | status: isMergeable ? 'pass' : 'fail', 13 | description: isMergeable ? 'Your PR is old enough to merge' : settings.message || 'Your PR is not old enough to be merged yet' 14 | } 15 | 16 | return constructOutput(validatorContext, timeToCompare, settings, result) 17 | } 18 | 19 | class Age extends Validator { 20 | constructor () { 21 | super('time') 22 | this.supportedEvents = [ 23 | 'pull_request.*', 24 | 'pull_request_review.*', 25 | 'issue_comment.*' 26 | ] 27 | this.supportedSettings = { 28 | updated_at: { 29 | days: 'number', 30 | message: 'string' 31 | }, 32 | created_at: { 33 | days: 'number', 34 | message: 'string' 35 | } 36 | } 37 | } 38 | 39 | async validate (context, validationSettings) { 40 | const payload = this.getPayload(context) 41 | const now = moment().utc(false) 42 | const output = [] 43 | const validatorContext = { name: 'age' } 44 | 45 | if (validationSettings.created_at) { 46 | output.push(checkAge(now, payload.created_at, validationSettings.created_at)) 47 | } 48 | 49 | if (validationSettings.updated_at) { 50 | output.push(checkAge(now, payload.updated_at, validationSettings.updated_at)) 51 | } 52 | 53 | return consolidateResult(output, validatorContext) 54 | } 55 | } 56 | 57 | module.exports = Age 58 | -------------------------------------------------------------------------------- /lib/validators/and.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | const logicalConnectiveValidatorProcessor = require('./lib/logicalConnectiveValidatorProcessor') 3 | 4 | class And extends Validator { 5 | constructor () { 6 | super('and') 7 | this.supportedEvents = [ 8 | '*' 9 | ] 10 | this.supportedOptions = [ 11 | 'validate' 12 | ] 13 | this.supportedSettings = {} 14 | } 15 | 16 | async validate (context, validationSettings, registry) { 17 | return logicalConnectiveValidatorProcessor(context, validationSettings.validate, registry, 'And') 18 | } 19 | 20 | // skip validating settings 21 | validateSettings (supportedSettings, settingToCheck) {} 22 | } 23 | 24 | module.exports = And 25 | -------------------------------------------------------------------------------- /lib/validators/assignee.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | 3 | class Assignee extends Validator { 4 | constructor () { 5 | super('assignee') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*', 9 | 'issues.*', 10 | 'issue_comment.*' 11 | ] 12 | 13 | this.supportedSettings = { 14 | min: { 15 | count: 'number', 16 | message: 'string' 17 | }, 18 | max: { 19 | count: 'number', 20 | message: 'string' 21 | } 22 | } 23 | } 24 | 25 | async validate (context, validationSettings) { 26 | const assignees = this.getPayload(context).assignees 27 | 28 | return this.processOptions( 29 | validationSettings, 30 | assignees.map(assignee => assignee.login) 31 | ) 32 | } 33 | } 34 | 35 | module.exports = Assignee 36 | -------------------------------------------------------------------------------- /lib/validators/author.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | const Teams = require('./options_processor/teams') 3 | const ListProcessor = require('./options_processor/listProcessor') 4 | 5 | class Author extends Validator { 6 | constructor () { 7 | super('author') 8 | this.supportedEvents = [ 9 | 'pull_request.*', 10 | 'pull_request_review.*', 11 | 'issues.*', 12 | 'issue_comment.*' 13 | ] 14 | this.supportedSettings = { 15 | must_include: { 16 | regex: 'string', 17 | regex_flag: 'string', 18 | message: 'string' 19 | }, 20 | must_exclude: { 21 | regex: 'string', 22 | regex_flag: 'string', 23 | message: 'string' 24 | }, 25 | team: 'string', 26 | one_of: 'array', 27 | none_of: 'array' 28 | } 29 | } 30 | 31 | async validate (context, settings) { 32 | const payload = this.getPayload(context) 33 | 34 | if (settings.team) { 35 | const result = await Teams.processTeamOption(context, settings, payload) 36 | if (result.status !== 'pass') { 37 | return result 38 | } 39 | delete settings.team 40 | } 41 | 42 | if (settings.one_of) { 43 | settings.one_of = await ListProcessor.process(settings.one_of, context) 44 | } 45 | if (settings.none_of) { 46 | settings.none_of = await ListProcessor.process(settings.none_of, context) 47 | } 48 | 49 | return this.processOptions(settings, payload.user.login) 50 | } 51 | } 52 | 53 | module.exports = Author 54 | -------------------------------------------------------------------------------- /lib/validators/changeset.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | 3 | class Changeset extends Validator { 4 | constructor () { 5 | super('changeset') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*' 9 | ] 10 | this.supportedSettings = { 11 | no_empty: { 12 | enabled: 'boolean', 13 | message: 'string' 14 | }, 15 | must_include: { 16 | regex: ['string', 'array'], 17 | regex_flag: 'string', 18 | message: 'string', 19 | all: 'boolean' 20 | }, 21 | must_exclude: { 22 | regex: ['string', 'array'], 23 | regex_flag: 'string', 24 | message: 'string', 25 | all: 'boolean' 26 | }, 27 | begins_with: { 28 | match: ['string', 'array'], 29 | message: 'string' 30 | }, 31 | ends_with: { 32 | match: ['string', 'array'], 33 | message: 'string' 34 | }, 35 | min: { 36 | count: 'number', 37 | message: 'string' 38 | }, 39 | max: { 40 | count: 'number', 41 | message: 'string' 42 | }, 43 | files: { 44 | added: 'boolean', 45 | modified: 'boolean', 46 | removed: 'boolean' 47 | } 48 | } 49 | } 50 | 51 | async validate (context, validationSettings) { 52 | // fetch the file list 53 | let result = await this.githubAPI.listFiles(context, context.repo({ pull_number: this.getPayload(context).number })) 54 | 55 | if (validationSettings.files) { 56 | const fileStatusOptions = Object.keys(validationSettings.files).filter(fileStatus => validationSettings.files[fileStatus]) 57 | result = result.filter(file => fileStatusOptions.includes(file.status)) 58 | delete validationSettings.files 59 | } 60 | 61 | const changedFiles = result.map(file => file.filename) 62 | 63 | return this.processOptions(validationSettings, changedFiles) 64 | } 65 | } 66 | 67 | module.exports = Changeset 68 | -------------------------------------------------------------------------------- /lib/validators/description.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | 3 | class Description extends Validator { 4 | constructor () { 5 | super('description') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*', 9 | 'issues.*', 10 | 'issue_comment.*' 11 | ] 12 | this.supportedSettings = { 13 | no_empty: { 14 | enabled: 'boolean', 15 | message: 'string' 16 | }, 17 | jira: { 18 | regex: 'string', 19 | regex_flag: 'string', 20 | message: 'string' 21 | }, 22 | must_include: { 23 | regex: ['string', 'array'], 24 | regex_flag: 'string', 25 | message: 'string' 26 | }, 27 | must_exclude: { 28 | regex: ['string', 'array'], 29 | regex_flag: 'string', 30 | message: 'string' 31 | }, 32 | begins_with: { 33 | match: ['string', 'array'], 34 | message: 'string' 35 | }, 36 | ends_with: { 37 | match: ['string', 'array'], 38 | message: 'string' 39 | } 40 | } 41 | } 42 | 43 | async validate (context, validationSettings) { 44 | const description = this.getPayload(context).body || '' 45 | 46 | return this.processOptions( 47 | validationSettings, 48 | description 49 | ) 50 | } 51 | } 52 | 53 | module.exports = Description 54 | -------------------------------------------------------------------------------- /lib/validators/headRef.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | 3 | class HeadRef extends Validator { 4 | constructor () { 5 | super('headRef') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*' 9 | ] 10 | this.supportedSettings = { 11 | jira: { 12 | regex: 'string', 13 | regex_flag: 'string', 14 | message: 'string' 15 | }, 16 | must_include: { 17 | regex: ['string', 'array'], 18 | regex_flag: 'string', 19 | message: 'string' 20 | }, 21 | must_exclude: { 22 | regex: ['string', 'array'], 23 | regex_flag: 'string', 24 | message: 'string' 25 | } 26 | } 27 | } 28 | 29 | async validate (context, validationSettings) { 30 | return this.processOptions( 31 | validationSettings, 32 | this.getPayload(context).head.ref 33 | ) 34 | } 35 | } 36 | 37 | module.exports = HeadRef 38 | -------------------------------------------------------------------------------- /lib/validators/label.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | 3 | class Label extends Validator { 4 | constructor () { 5 | super('label') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*', 9 | 'issues.*', 10 | 'issue_comment.*' 11 | ] 12 | this.supportedSettings = { 13 | no_empty: { 14 | enabled: 'boolean', 15 | message: 'string' 16 | }, 17 | jira: { 18 | regex: 'string', 19 | regex_flag: 'string', 20 | message: 'string' 21 | }, 22 | must_include: { 23 | regex: ['string', 'array'], 24 | regex_flag: 'string', 25 | message: 'string' 26 | }, 27 | must_exclude: { 28 | regex: ['string', 'array'], 29 | regex_flag: 'string', 30 | message: 'string' 31 | }, 32 | begins_with: { 33 | match: ['string', 'array'], 34 | message: 'string' 35 | }, 36 | ends_with: { 37 | match: ['string', 'array'], 38 | message: 'string' 39 | }, 40 | min: { 41 | count: 'number', 42 | message: 'string' 43 | }, 44 | max: { 45 | count: 'number', 46 | message: 'string' 47 | } 48 | } 49 | } 50 | 51 | async validate (context, validationSettings) { 52 | const labels = await this.githubAPI.listLabelsOnIssue(context, this.getPayload(context).number) 53 | 54 | return this.processOptions( 55 | validationSettings, 56 | labels 57 | ) 58 | } 59 | } 60 | 61 | module.exports = Label 62 | -------------------------------------------------------------------------------- /lib/validators/milestone.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | const deepValidation = require('./options_processor/deepValidation') 3 | 4 | class Milestone extends Validator { 5 | constructor () { 6 | super('milestone') 7 | this.supportedEvents = [ 8 | 'pull_request.*', 9 | 'pull_request_review.*', 10 | 'issues.*' 11 | ] 12 | this.supportedSettings = { 13 | no_empty: { 14 | enabled: 'boolean', 15 | message: 'string' 16 | }, 17 | jira: { 18 | regex: 'string', 19 | regex_flag: 'string', 20 | message: 'string' 21 | }, 22 | must_include: { 23 | regex: ['string', 'array'], 24 | regex_flag: 'string', 25 | message: 'string' 26 | }, 27 | must_exclude: { 28 | regex: ['string', 'array'], 29 | regex_flag: 'string', 30 | message: 'string' 31 | }, 32 | begins_with: { 33 | match: ['string', 'array'], 34 | message: 'string' 35 | }, 36 | ends_with: { 37 | match: ['string', 'array'], 38 | message: 'string' 39 | } 40 | } 41 | } 42 | 43 | async validate (context, validationSettings) { 44 | const milestone = this.getPayload(context).milestone ? this.getPayload(context).milestone.title : '' 45 | let output = await this.processOptions( 46 | validationSettings, 47 | milestone 48 | ) 49 | 50 | // check PR body to see if closes an issue 51 | if (output.status === 'fail') { 52 | const res = deepValidation.checkIfClosesAnIssue(this.getPayload(context).body) 53 | if (res.length > 0) { 54 | const result = await deepValidation.checkIfIssueHaveProperty(context, res, 'milestone') 55 | result.forEach(issue => { 56 | const processed = this.processOptions(validationSettings, issue.title) 57 | output = processed 58 | }) 59 | } 60 | } 61 | 62 | return output 63 | } 64 | } 65 | 66 | module.exports = Milestone 67 | -------------------------------------------------------------------------------- /lib/validators/not.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | const logicalConnectiveValidatorProcessor = require('./lib/logicalConnectiveValidatorProcessor') 3 | 4 | class Not extends Validator { 5 | constructor () { 6 | super('not') 7 | this.supportedEvents = [ 8 | '*' 9 | ] 10 | this.supportedOptions = [ 11 | 'validate' 12 | ] 13 | this.supportedSettings = {} 14 | } 15 | 16 | async validate (context, validationSettings, registry) { 17 | return logicalConnectiveValidatorProcessor(context, validationSettings.validate, registry, 'Not') 18 | } 19 | 20 | // skip validating settings 21 | validateSettings (supportedSettings, settingToCheck) {} 22 | } 23 | 24 | module.exports = Not 25 | -------------------------------------------------------------------------------- /lib/validators/options_processor/assignees.js: -------------------------------------------------------------------------------- 1 | class Assignees { 2 | static async process (payload, context) { 3 | return payload.assignees.map(user => user.login) 4 | } 5 | } 6 | 7 | module.exports = Assignees 8 | -------------------------------------------------------------------------------- /lib/validators/options_processor/deepValidation.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line prefer-regex-literals 2 | const CLOSES_ISSUE_REGEX = new RegExp('\\b(closes?|closed|fix|fixes?|fixed|resolves?|resolved)\\b.#[0-9]*', 'ig') 3 | const GithubAPI = require('../../github/api') 4 | 5 | class DeepValidation { 6 | static checkIfClosesAnIssue (description) { 7 | let res 8 | const issues = [] 9 | 10 | do { 11 | res = CLOSES_ISSUE_REGEX.exec(description) 12 | if (res) { 13 | const match = res[0].indexOf('#') 14 | issues.push(res[0].substr(match + 1)) 15 | } 16 | } while (res) 17 | return issues 18 | } 19 | 20 | static async checkIfIssueHaveProperty (context, issues, property) { 21 | const output = [] 22 | for (let i = 0; i < issues.length; i++) { 23 | const issue = await GithubAPI.getIssues(context, issues[i]) 24 | 25 | if (issue.data[property]) { 26 | output.push(issue.data[property]) 27 | } 28 | } 29 | return output 30 | } 31 | } 32 | 33 | module.exports = DeepValidation 34 | -------------------------------------------------------------------------------- /lib/validators/options_processor/listProcessor.js: -------------------------------------------------------------------------------- 1 | const Teams = require('./teams') 2 | const TeamNotFoundError = require('../../errors/teamNotFoundError') 3 | const EventAware = require('../../eventAware') 4 | const searchAndReplaceSpecialAnnotations = require('../../actions/lib/searchAndReplaceSpecialAnnotation') 5 | const { forEach } = require('p-iteration') 6 | 7 | /** 8 | * ListProcessor replaces annotations in an array of strings. 9 | * Team slugs are exploded to the members they contain. 10 | * All elements are lowercased to be used for comparison with the one_of or none_of option processor. 11 | * @returns a new array containing the replacements 12 | */ 13 | class ListProcessor { 14 | static async process (list, context) { 15 | if (!Array.isArray(list)) { 16 | list = [list] 17 | } 18 | 19 | const candidates = [] 20 | const helper = new EventAware() 21 | const payload = helper.getPayload(context) 22 | const evt = helper.getEventAttributes(context) 23 | await forEach(list, async (element) => { 24 | if (element.match(/^@.+\/[^/]+$/)) { 25 | try { 26 | const teamMembers = await Teams.extractTeamMembers(context, [element]) 27 | candidates.push(...teamMembers.map((m) => m.toLowerCase())) 28 | } catch (err) { 29 | if (err instanceof TeamNotFoundError) { 30 | // uncritical, is just no candidate 31 | } else { 32 | throw err 33 | } 34 | } 35 | } else { 36 | const replacement = searchAndReplaceSpecialAnnotations(element, payload, evt) 37 | candidates.push(replacement.toLowerCase()) 38 | } 39 | }) 40 | 41 | return candidates 42 | } 43 | } 44 | 45 | module.exports = ListProcessor 46 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options.js: -------------------------------------------------------------------------------- 1 | const consolidateResult = require('./options/lib/consolidateResults') 2 | const constructError = require('./options/lib/constructErrorOutput') 3 | const constructOutput = require('./options/lib/constructOutput') 4 | const { forEach } = require('p-iteration') 5 | 6 | /** 7 | * Validation Processor 8 | * Process tests on the input based on the set of rules 9 | * 10 | * Params must be in the follow format 11 | * validatorContext: { 12 | * name: validatorName 13 | * } 14 | * Input: string or an Array to run test against 15 | * 16 | * Rules: [{ 17 | * option: either JSON object or Array of JSON objects 18 | * }] 19 | * 20 | * @param validatorContext 21 | * @param input 22 | * @param rules 23 | * @returns {{mergeable, description}} 24 | */ 25 | 26 | class Options { 27 | static async process (validatorContext, input, rules, returnRawOutput) { 28 | const output = [] 29 | 30 | if (!Array.isArray(rules)) { 31 | rules = [rules] 32 | } 33 | 34 | await forEach(rules, async (rule) => { 35 | await forEach(Object.keys(rule), async (key) => { 36 | if (key === 'do') return 37 | const setting = {} 38 | setting[key] = rule[key] 39 | try { 40 | if (validatorContext.supportedOptions && validatorContext.supportedOptions.indexOf(key) === -1) { 41 | output.push(constructError(validatorContext, input, setting, `The '${key}' option is not supported for '${validatorContext.name}' validator, please see README for all available options`)) 42 | } else { 43 | const result = await require(`./options/${key}`).process(validatorContext, input, rule) 44 | output.push(constructOutput(validatorContext, input, setting, result)) 45 | } 46 | } catch (err) { 47 | output.push(constructError(validatorContext, input, setting, err.message)) 48 | } 49 | }) 50 | }) 51 | return returnRawOutput ? output : consolidateResult(output, validatorContext) 52 | } 53 | } 54 | 55 | module.exports = Options 56 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/and.js: -------------------------------------------------------------------------------- 1 | const andOrProcessor = require('./lib/andOrProcessor') 2 | 3 | class AndProcessor { 4 | static async process (validatorContext, input, rule) { 5 | return andOrProcessor(validatorContext, input, rule, 'and') 6 | } 7 | } 8 | 9 | module.exports = AndProcessor 10 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/begins_with.js: -------------------------------------------------------------------------------- 1 | const MATCH_NOT_FOUND_ERROR = 'Failed to run the test because \'match\' is not provided for \'begins_with\' option. Please check README for more information about configuration' 2 | const UNKNOWN_MATCH_TYPE_ERROR = '\'match\' type invalid, expected string or Array type' 3 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected string or Array as input' 4 | 5 | class BeginsWith { 6 | static process (validatorContext, input, rule) { 7 | const filter = rule.begins_with 8 | 9 | const match = filter.match 10 | let description = filter.message 11 | if (!match) { 12 | throw new Error(MATCH_NOT_FOUND_ERROR) 13 | } 14 | 15 | const DEFAULT_SUCCESS_MESSAGE = `${validatorContext.name} does begins with '${match}'` 16 | if (!description) description = `${validatorContext.name} must begins with "${match}"` 17 | 18 | let isMergeable 19 | 20 | try { 21 | isMergeable = checkIfMergeable(input, match) 22 | } catch (err) { 23 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 24 | } 25 | 26 | return { 27 | status: isMergeable ? 'pass' : 'fail', 28 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 29 | } 30 | } 31 | } 32 | 33 | function checkIfMergeable (input, match) { 34 | if (typeof input !== 'string' && !Array.isArray(input)) { 35 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 36 | } 37 | 38 | if (typeof match !== 'string' && !Array.isArray(match)) { 39 | throw new Error(UNKNOWN_MATCH_TYPE_ERROR) 40 | } 41 | 42 | if (typeof input === 'string') { 43 | return checkIfInputMatches(match, (item) => input.indexOf(item) === 0) 44 | } else { 45 | return input.some(inputItem => 46 | checkIfInputMatches(match, (matchItem) => inputItem.indexOf(matchItem) === 0) 47 | ) 48 | } 49 | } 50 | 51 | function checkIfInputMatches (match, func) { 52 | if (typeof match === 'string') { 53 | return func(match) 54 | } else { 55 | return match.some(item => func(item)) 56 | } 57 | } 58 | 59 | module.exports = BeginsWith 60 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/ends_with.js: -------------------------------------------------------------------------------- 1 | const MATCH_NOT_FOUND_ERROR = 'Failed to run the test because \'match\' is not provided for \'ends_with\' option. Please check README for more information about configuration' 2 | const UNKNOWN_MATCH_TYPE_ERROR = '\'match\' type invalid, expected string or Array type' 3 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected string or Array as input' 4 | 5 | class EndsWith { 6 | static process (validatorContext, input, rule) { 7 | const filter = rule.ends_with 8 | 9 | const match = filter.match 10 | let description = filter.message 11 | if (!match) { 12 | throw new Error(MATCH_NOT_FOUND_ERROR) 13 | } 14 | 15 | const DEFAULT_SUCCESS_MESSAGE = `${validatorContext.name} does end with '${match}'` 16 | if (!description) description = `${validatorContext.name} must end with "${match}"` 17 | 18 | let isMergeable 19 | 20 | try { 21 | isMergeable = checkIfMergeable(input, match) 22 | } catch (err) { 23 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 24 | } 25 | 26 | return { 27 | status: isMergeable ? 'pass' : 'fail', 28 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 29 | } 30 | } 31 | } 32 | 33 | function checkIfMergeable (input, match) { 34 | if (typeof input !== 'string' && !Array.isArray(input)) { 35 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 36 | } 37 | 38 | if (typeof match !== 'string' && !Array.isArray(match)) { 39 | throw new Error(UNKNOWN_MATCH_TYPE_ERROR) 40 | } 41 | 42 | if (typeof input === 'string') { 43 | return checkIfInputMatches(match, (item) => input.indexOf(item) === (input.length - item.length)) 44 | } else { 45 | return input.some(inputItem => 46 | checkIfInputMatches(match, (matchItem) => inputItem.indexOf(matchItem) === (inputItem.length - matchItem.length)) 47 | ) 48 | } 49 | } 50 | 51 | function checkIfInputMatches (match, func) { 52 | if (typeof match === 'string') { 53 | return func(match) 54 | } else { 55 | return match.some(item => func(item)) 56 | } 57 | } 58 | 59 | module.exports = EndsWith 60 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/jira.js: -------------------------------------------------------------------------------- 1 | const JiraApi = require('jira-client') 2 | 3 | const REGEX_NOT_FOUND_ERROR = 'Failed to run the test because \'regex\' is not provided for \'jira\' option. Please check README for more information about configuration' 4 | 5 | class Jira { 6 | static async process (validatorContext, input, rule) { 7 | let isMergeable 8 | let regexObj 9 | 10 | const filter = rule.jira 11 | let description = filter.message 12 | const regex = filter.regex 13 | if (!regex) { 14 | throw new Error(REGEX_NOT_FOUND_ERROR) 15 | } 16 | 17 | const DEFAULT_SUCCESS_MESSAGE = 'The JIRA TICKET is valid' 18 | if (!description) description = 'The JIRA TICKET is not valid' 19 | 20 | // Parse the codes 21 | try { 22 | let regexFlag = 'i' 23 | if (filter.regex_flag) { 24 | regexFlag = filter.regex_flag === 'none' ? '' : filter.regex_flag 25 | } 26 | 27 | regexObj = new RegExp(regex, regexFlag) 28 | } catch (err) { 29 | throw new Error(`Failed to create a regex expression with the provided regex: ${regex}`) 30 | } 31 | 32 | const ticketID = regexObj.exec(input) 33 | 34 | if (ticketID != null && ticketID.length > 0) { 35 | try { 36 | isMergeable = await this.checkTicketStatus(ticketID[0]) 37 | } catch (err) { 38 | isMergeable = false 39 | } 40 | } else { 41 | isMergeable = false 42 | } 43 | 44 | return { 45 | status: isMergeable ? 'pass' : 'fail', 46 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 47 | } 48 | } 49 | 50 | static async checkTicketStatus (ticketID) { 51 | try { 52 | // Initialize the JIRA plugin 53 | const jira = new JiraApi({ 54 | protocol: process.env.JIRA_PROTOCOL || 'https', 55 | host: process.env.JIRA_HOST, 56 | username: process.env.JIRA_USERNAME, 57 | password: process.env.JIRA_PASSWORD, 58 | apiVersion: process.env.JIRA_VERSION || '2', 59 | strictSSL: process.env.JIRA_STRICT_SSL || true 60 | }) 61 | 62 | try { 63 | await jira.findIssue(ticketID) 64 | return true 65 | } catch (error) { 66 | return false 67 | } 68 | } catch (err) { 69 | throw new Error(`A problem occured with JIRA initilization: ${err}`) 70 | } 71 | } 72 | } 73 | 74 | module.exports = Jira 75 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/lib/andOrProcessor.js: -------------------------------------------------------------------------------- 1 | const options = require('../../options') 2 | const { map } = require('p-iteration') 3 | 4 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected array type as input' 5 | 6 | const andOrProcessor = async (validatorContext, input, rule, key) => { 7 | const filters = rule[key] 8 | 9 | if (!Array.isArray(filters)) { 10 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 11 | } 12 | 13 | const validated = map(filters, async filter => { 14 | if (filter.and) { 15 | return andOrProcessor(validatorContext, input, filter, 'and') 16 | } 17 | if (filter.or) { 18 | return andOrProcessor(validatorContext, input, filter, 'or') 19 | } 20 | 21 | // we are only passing in one item at a time, so this will only return one element array 22 | const data = await options.process(validatorContext, input, filter, true) 23 | return Promise.resolve(data).then(values => { 24 | return values[0] 25 | }) 26 | }) 27 | 28 | let isMergeable 29 | const DEFAULT_SUCCESS_MESSAGE = `All the requisite validations passed for '${key}' option` 30 | let descriptions = '' 31 | let doesErrorExists = false 32 | let errorMessage = 'Error occurred: \n' 33 | 34 | return Promise.resolve(validated).then(values => { 35 | values.forEach(result => { 36 | if (result.status === 'error') { 37 | doesErrorExists = true 38 | errorMessage += `- ${result.description} \n` 39 | } 40 | 41 | const resultSuccess = result.status === 'pass' 42 | if (isMergeable !== undefined) { 43 | isMergeable = key === 'and' ? isMergeable && resultSuccess : isMergeable || resultSuccess 44 | } else { 45 | isMergeable = resultSuccess 46 | } 47 | 48 | if (result.status === 'fail') { 49 | if (descriptions.length > 2) { 50 | descriptions += ` ${key === 'and' ? ' ***AND*** ' : ' ***OR*** '} ${result.description}` 51 | } else { 52 | descriptions += `${result.description}` 53 | } 54 | } 55 | }) 56 | 57 | let status = 'error' 58 | let description = errorMessage 59 | 60 | if (!doesErrorExists) { 61 | status = isMergeable ? 'pass' : 'fail' 62 | description = isMergeable ? DEFAULT_SUCCESS_MESSAGE : `(${descriptions})` 63 | } 64 | 65 | return { 66 | status, 67 | description 68 | } 69 | }) 70 | } 71 | 72 | module.exports = andOrProcessor 73 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/lib/consolidateResults.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Consolidate Results 3 | * Take all the result from individual tests and determine whether or not test suite passed 4 | * 5 | */ 6 | module.exports = (result, validatorContext) => { 7 | let status = 'pass' 8 | const tests = [] 9 | 10 | result.forEach(res => { 11 | if (res.status === 'fail' && status !== 'error') { 12 | status = 'fail' 13 | } 14 | if (res.status === 'error') { 15 | status = 'error' 16 | } 17 | 18 | tests.push(res) 19 | }) 20 | 21 | return { status: status, name: validatorContext.name, validations: tests } 22 | } 23 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/lib/constructErrorOutput.js: -------------------------------------------------------------------------------- 1 | const constructOuput = require('./constructOutput') 2 | 3 | module.exports = (validatorContext, input, rule, error, errorDetails) => { 4 | return constructOuput(validatorContext, input, rule, { 5 | status: 'error', 6 | description: error 7 | }, errorDetails) 8 | } 9 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/lib/constructOutput.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Contruct Output 3 | * Allows the processor Options module to create the uniform output type 4 | * 5 | * Expected Input: 6 | * validatorContext: { 7 | * name: validatorName 8 | * } 9 | * 10 | * input the rule was run against 11 | * rule: { 12 | * // rule used during the test 13 | * } 14 | * 15 | * 16 | * result: { 17 | * status: 'pass|fail|error' 18 | * description : 'Default or custom message' 19 | * } 20 | * 21 | * Output format: 22 | * output : { 23 | * validatorName: // Validator that was run 24 | * status: 'pass|fail|error' 25 | * description: 'Defaul or custom Message' 26 | * details { 27 | * input: // input the tests are run against 28 | * setting: rule 29 | * error: String // Optional, only should be sent when status == error 30 | * } 31 | * } 32 | * 33 | */ 34 | module.exports = (validatorContext, input, rule, result, error) => { 35 | return { 36 | validator: validatorContext, 37 | status: result.status, 38 | description: result.description, 39 | details: { 40 | input: input, 41 | settings: rule, 42 | error: error 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/max.js: -------------------------------------------------------------------------------- 1 | const COUNT_NOT_FOUND_ERROR = 'Failed to run the test because \'count\' is not provided for \'max\' option. Please check README for more information about configuration' 2 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected Array or Integer as input' 3 | 4 | class Max { 5 | static process (validatorContext, input, rule) { 6 | const filter = rule.max 7 | 8 | const count = filter.count ? filter.count : filter 9 | let description = filter.message 10 | if (typeof count !== 'number') { 11 | throw new Error(COUNT_NOT_FOUND_ERROR) 12 | } 13 | 14 | let isMergeable 15 | 16 | const DEFAULT_SUCCESS_MESSAGE = `${validatorContext.name} does have a maximum of '${count}'` 17 | if (!description) description = `${validatorContext.name} count is more than "${count}"` 18 | 19 | if (Array.isArray(input)) { 20 | isMergeable = input.length <= count 21 | } else if (Number.isInteger(input)) { 22 | isMergeable = input <= count 23 | } else { 24 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 25 | } 26 | 27 | return { 28 | status: isMergeable ? 'pass' : 'fail', 29 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 30 | } 31 | } 32 | } 33 | 34 | module.exports = Max 35 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/min.js: -------------------------------------------------------------------------------- 1 | const COUNT_NOT_FOUND_ERROR = 'Failed to run the test because \'count\' is not provided for \'min\' option. Please check README for more information about configuration' 2 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected Array as input' 3 | 4 | class Min { 5 | static process (validatorContext, input, rule) { 6 | const filter = rule.min 7 | 8 | const count = filter.count ? filter.count : filter 9 | let description = filter.message 10 | if (typeof count !== 'number') { 11 | throw new Error(COUNT_NOT_FOUND_ERROR) 12 | } 13 | 14 | let isMergeable 15 | 16 | const DEFAULT_SUCCESS_MESSAGE = `${validatorContext.name} does have a minimum of '${count}'` 17 | if (!description) description = `${validatorContext.name} count is less than "${count}"` 18 | 19 | if (Array.isArray(input)) { 20 | isMergeable = !(input.length < count) 21 | } else { 22 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 23 | } 24 | 25 | return { 26 | status: isMergeable ? 'pass' : 'fail', 27 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 28 | } 29 | } 30 | } 31 | 32 | module.exports = Min 33 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/must_include.js: -------------------------------------------------------------------------------- 1 | const REGEX_NOT_FOUND_ERROR = 'Failed to run the test because \'regex\' is not provided for \'must_include\' option. Please check README for more information about configuration' 2 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected either string or array of string as input' 3 | const KEY_NOT_FOUND_ERROR = 'Input type is an object and requires providing a \'key\' for the \'must_include\' option.' 4 | 5 | class MustInclude { 6 | static process (validatorContext, input, rule) { 7 | const filter = rule.must_include 8 | 9 | const regex = filter.regex 10 | const key = filter.key 11 | let description = filter.message 12 | if (!regex) { 13 | throw new Error(REGEX_NOT_FOUND_ERROR) 14 | } 15 | 16 | const regexList = [].concat(regex) 17 | 18 | const DEFAULT_SUCCESS_MESSAGE = `${validatorContext.name} ${filter.all ? 'all' : ''}must include '${regexList.join(', ')}'` 19 | if (!description) description = `${validatorContext.name} ${filter.all ? 'all' : ''}does not include "${regexList.join(', ')}"` 20 | 21 | const isMergeable = regexList.some((regex) => { 22 | let regexObj 23 | 24 | try { 25 | let regexFlag = 'i' 26 | if (filter.regex_flag) { 27 | regexFlag = filter.regex_flag === 'none' ? '' : filter.regex_flag 28 | } 29 | 30 | regexObj = new RegExp(regex, regexFlag) 31 | } catch (err) { 32 | throw new Error(`Failed to create a regex expression with the provided regex: ${regex}`) 33 | } 34 | 35 | if (typeof input === 'string') { 36 | return regexObj.test(input) 37 | } else if (Array.isArray(input)) { 38 | const arrayHandler = label => { 39 | if (typeof label === 'string') { 40 | return regexObj.test(label) 41 | } else if (key) { 42 | return regexObj.test(label[key]) 43 | } 44 | throw new Error(KEY_NOT_FOUND_ERROR) 45 | } 46 | if (filter.all) { 47 | return input.every(arrayHandler) 48 | } else { 49 | return input.some(arrayHandler) 50 | } 51 | } else { 52 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 53 | } 54 | }) 55 | 56 | return { 57 | status: isMergeable ? 'pass' : 'fail', 58 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 59 | } 60 | } 61 | } 62 | 63 | module.exports = MustInclude 64 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/no_empty.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash') 2 | 3 | const ENABLED_NOT_FOUND_ERROR = 'Failed to run the test because \'enabled\' is not provided for \'no_empty\' option. Please check README for more information about configuration' 4 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected string or Array as input' 5 | 6 | class NoEmpty { 7 | static process (validatorContext, input, rule) { 8 | const filter = rule.no_empty 9 | 10 | const enabled = filter.enabled 11 | let description = filter.message 12 | if (!enabled && enabled !== false) { 13 | throw new Error(ENABLED_NOT_FOUND_ERROR) 14 | } 15 | 16 | if (enabled === false) { 17 | return { 18 | status: 'pass', 19 | description: 'No_empty option is not enabled, as such this validator did not run' 20 | } 21 | } 22 | 23 | let isMergeable = false 24 | 25 | const DEFAULT_SUCCESS_MESSAGE = `The ${validatorContext.name} is not empty` 26 | if (!description) description = `The ${validatorContext.name} can't be empty` 27 | 28 | if (typeof input === 'string') { 29 | isMergeable = input.trim().length !== 0 30 | } else if (Array.isArray(input)) { 31 | isMergeable = input.length !== 0 32 | } else if (!(input == null || _.isUndefined(input))) { 33 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 34 | } 35 | 36 | return { 37 | status: isMergeable ? 'pass' : 'fail', 38 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 39 | } 40 | } 41 | } 42 | 43 | module.exports = NoEmpty 44 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/none_of.js: -------------------------------------------------------------------------------- 1 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected string as input' 2 | const LIST_NOT_FOUND_ERROR = 'Failed to run the test because \'none_of\' option is not present. Please check README for more information about configuration' 3 | 4 | class NoneOf { 5 | static process (validatorContext, input, rule) { 6 | const filter = rule.none_of 7 | if (typeof input !== 'string') { 8 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 9 | } 10 | if (!Array.isArray(filter)) { 11 | throw new Error(LIST_NOT_FOUND_ERROR) 12 | } 13 | 14 | const isExcluded = !filter.includes(input.toLowerCase()) 15 | 16 | const successMessage = `'${input}' is not in the none_of list'` 17 | const failureMessage = `'${input}' is in the none_of list'` 18 | 19 | return { 20 | status: isExcluded ? 'pass' : 'fail', 21 | description: isExcluded ? successMessage : failureMessage 22 | } 23 | } 24 | } 25 | 26 | module.exports = NoneOf 27 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/one_of.js: -------------------------------------------------------------------------------- 1 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected string as input' 2 | const LIST_NOT_FOUND_ERROR = 'Failed to run the test because \'one_of\' option is not present. Please check README for more information about configuration' 3 | 4 | class OneOf { 5 | static process (validatorContext, input, rule) { 6 | const filter = rule.one_of 7 | if (typeof input !== 'string') { 8 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 9 | } 10 | if (!Array.isArray(filter)) { 11 | throw new Error(LIST_NOT_FOUND_ERROR) 12 | } 13 | 14 | const isIncluded = filter.includes(input.toLowerCase()) 15 | 16 | const successMessage = `'${input}' is in the one_of list'` 17 | const failureMessage = `'${input}' is not in the one_of list'` 18 | 19 | return { 20 | status: isIncluded ? 'pass' : 'fail', 21 | description: isIncluded ? successMessage : failureMessage 22 | } 23 | } 24 | } 25 | 26 | module.exports = OneOf 27 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/or.js: -------------------------------------------------------------------------------- 1 | const andOrProcessor = require('./lib/andOrProcessor') 2 | 3 | class OrProcessor { 4 | static async process (validatorContext, input, rule) { 5 | return andOrProcessor(validatorContext, input, rule, 'or') 6 | } 7 | } 8 | 9 | module.exports = OrProcessor 10 | -------------------------------------------------------------------------------- /lib/validators/options_processor/options/required.js: -------------------------------------------------------------------------------- 1 | const UNKNOWN_INPUT_TYPE_ERROR = 'Input type invalid, expected array of string as input' 2 | 3 | class Required { 4 | static process (validatorContext, input, rule) { 5 | const filter = rule.required 6 | 7 | const reviewers = filter.reviewers ? filter.reviewers : [] 8 | const owners = filter.owners ? filter.owners : [] 9 | const assignees = filter.assignees ? filter.assignees : [] 10 | const requestedReviewers = filter.requested_reviewers ? filter.requested_reviewers : [] 11 | let description = filter.message 12 | 13 | if (!Array.isArray(input)) { 14 | throw new Error(UNKNOWN_INPUT_TYPE_ERROR) 15 | } 16 | 17 | // go thru the required list and check against inputs 18 | const remainingRequired = new Map(reviewers.map(user => [user.toLowerCase(), user])) 19 | input.forEach(user => remainingRequired.delete(user.toLowerCase())) 20 | 21 | const isMergeable = remainingRequired.size === 0 22 | 23 | const requiredReviewers = Array.from(remainingRequired.values()).map(user => { 24 | if (owners.includes(user)) { 25 | return user + '(Code Owner) ' 26 | } 27 | if (assignees.includes(user)) { 28 | return user + '(Assignee) ' 29 | } 30 | if (requestedReviewers.includes(user)) { 31 | return user + '(Requested Reviewer) ' 32 | } 33 | 34 | return user + ' ' 35 | }) 36 | 37 | const DEFAULT_SUCCESS_MESSAGE = `${validatorContext.name}: all required reviewers have approved` 38 | if (!description) description = `${validatorContext.name}: ${requiredReviewers}required` 39 | 40 | return { 41 | status: isMergeable ? 'pass' : 'fail', 42 | description: isMergeable ? DEFAULT_SUCCESS_MESSAGE : description 43 | } 44 | } 45 | } 46 | 47 | module.exports = Required 48 | -------------------------------------------------------------------------------- /lib/validators/options_processor/requestedReviewers.js: -------------------------------------------------------------------------------- 1 | class RequestedReviewers { 2 | static async process (payload, context) { 3 | return payload.requested_reviewers.map(user => user.login) 4 | } 5 | } 6 | 7 | module.exports = RequestedReviewers 8 | -------------------------------------------------------------------------------- /lib/validators/or.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | const logicalConnectiveValidatorProcessor = require('./lib/logicalConnectiveValidatorProcessor') 3 | 4 | class Or extends Validator { 5 | constructor () { 6 | super('or') 7 | this.supportedEvents = [ 8 | '*' 9 | ] 10 | this.supportedOptions = [ 11 | 'validate' 12 | ] 13 | this.supportedSettings = {} 14 | } 15 | 16 | async validate (context, validationSettings, registry) { 17 | return logicalConnectiveValidatorProcessor(context, validationSettings.validate, registry, 'Or') 18 | } 19 | 20 | // skip validating settings 21 | validateSettings (supportedSettings, settingToCheck) {} 22 | } 23 | 24 | module.exports = Or 25 | -------------------------------------------------------------------------------- /lib/validators/title.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require('./validator') 2 | 3 | class Title extends Validator { 4 | constructor () { 5 | super('title') 6 | this.supportedEvents = [ 7 | 'pull_request.*', 8 | 'pull_request_review.*', 9 | 'issues.*', 10 | 'issue_comment.*' 11 | ] 12 | this.supportedSettings = { 13 | no_empty: { 14 | enabled: 'boolean', 15 | message: 'string' 16 | }, 17 | jira: { 18 | regex: 'string', 19 | regex_flag: 'string', 20 | message: 'string' 21 | }, 22 | must_include: { 23 | regex: ['string', 'array'], 24 | regex_flag: 'string', 25 | message: 'string' 26 | }, 27 | must_exclude: { 28 | regex: ['string', 'array'], 29 | regex_flag: 'string', 30 | message: 'string' 31 | }, 32 | begins_with: { 33 | match: ['string', 'array'], 34 | message: 'string' 35 | }, 36 | ends_with: { 37 | match: ['string', 'array'], 38 | message: 'string' 39 | } 40 | } 41 | } 42 | 43 | async validate (context, validationSettings) { 44 | return this.processOptions( 45 | validationSettings, 46 | this.getPayload(context).title 47 | ) 48 | } 49 | } 50 | 51 | module.exports = Title 52 | -------------------------------------------------------------------------------- /m.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mergeability/mergeable/6f552642173530981daf7bd8a0ba8483c6f0f882/m.png -------------------------------------------------------------------------------- /mergeable-flex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mergeability/mergeable/6f552642173530981daf7bd8a0ba8483c6f0f882/mergeable-flex.png -------------------------------------------------------------------------------- /mergeable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mergeability/mergeable/6f552642173530981daf7bd8a0ba8483c6f0f882/mergeable.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mergeability/mergeable", 3 | "version": "2.17.3", 4 | "description": "", 5 | "author": "Justin Law (https://github.io/mergeability/mergeable), Shine Lee ", 6 | "license": "AGPL-3.0-only", 7 | "repository": "https://github.com/mergeability/mergeable.git", 8 | "scripts": { 9 | "dev": "cross-env NODE_ENV=development nodemon --exec 'npm start'", 10 | "start": "probot run ./index.js", 11 | "test": "cross-env NODE_ENV=test jest && standard", 12 | "test:unit": "cross-env LOG_LEVEL=warn NODE_ENV=test jest __tests__/unit/* && standard", 13 | "test:e2e": "cross-env LOG_LEVEL=warn NODE_ENV=test jest __tests__/e2e/* && standard", 14 | "test:scheduler": "cross-env LOG_LEVEL=warn NODE_ENV=test jest scheduler/test.js && standard", 15 | "test-watch": "cross-env NODE_ENV=test jest --watch", 16 | "test-coverage": "cross-env NODE_ENV=test standard && jest --collectCoverage && codecov", 17 | "lint": "standard --fix" 18 | }, 19 | "dependencies": { 20 | "cache-manager": "^3.4.0", 21 | "cache-manager-ioredis": "^2.1.0", 22 | "colors": "^1.3.2", 23 | "express-prometheus-middleware": "^1.1.0", 24 | "handlebars": "^4.7.6", 25 | "jira-client": "^6.21.1", 26 | "js-yaml": "^3.14.0", 27 | "lodash": "^4.17.20", 28 | "minimatch": "^3.0.4", 29 | "moment-timezone": "^0.5.31", 30 | "node-fetch": "^2.6.1", 31 | "p-iteration": "^1.1.8", 32 | "probot": "^13.0.2", 33 | "prom-client": "^13.1.0" 34 | }, 35 | "devDependencies": { 36 | "codecov": "^3.7.2", 37 | "cross-env": "^7.0.3", 38 | "jest": "^29.7.0", 39 | "nock": "14.0.0-beta.5", 40 | "nodemon": "^2.0.3", 41 | "object-dot": "^1.7.0", 42 | "smee-client": "^1.0.1", 43 | "standard": "^16.0.3" 44 | }, 45 | "engines": { 46 | "node": "^20" 47 | }, 48 | "standard": { 49 | "env": [ 50 | "jest" 51 | ] 52 | }, 53 | "jest": { 54 | "setupFiles": [ 55 | "./__fixtures__/setup/jestSetUp.js" 56 | ], 57 | "coverageDirectory": "./coverage/", 58 | "collectCoverageFrom": [ 59 | "lib/**/*.js" 60 | ] 61 | }, 62 | "publishConfig": { 63 | "access": "public" 64 | }, 65 | "private": false 66 | } 67 | -------------------------------------------------------------------------------- /scheduler/test.js: -------------------------------------------------------------------------------- 1 | process.env.PRIVATE_KEY = 'testkey' 2 | 3 | const nock = require('nock') 4 | const createScheduler = require('./') 5 | const { Probot, ProbotOctokit } = require('probot') 6 | 7 | const payload = require('./fixtures/installation-created.json') 8 | 9 | describe('Schedules intervals for a repository', () => { 10 | let probot 11 | 12 | beforeEach(() => { 13 | nock.disableNetConnect() 14 | probot = new Probot({ 15 | githubToken: 'test', 16 | // Disable throttling & retrying requests for easier testing 17 | Octokit: ProbotOctokit.defaults({ 18 | retry: { enabled: false }, 19 | throttle: { enabled: false } 20 | }) 21 | }) 22 | createScheduler(probot) 23 | }) 24 | 25 | test('gets a page of repositories', async () => { 26 | nock('https://api.github.com') 27 | .get('/app/installations') 28 | .query({ per_page: 1 }) 29 | .reply(200, [{ id: 1 }], { 30 | Link: '; rel="next"', 31 | 'X-GitHub-Media-Type': 'github.v3; format=json' 32 | }) 33 | .get('/installation/repositories') 34 | .query({ page: 2, per_page: 1 }) 35 | .reply(200, [{ id: 2 }]) 36 | .persist() 37 | 38 | nock('https://api.github.com') 39 | .get('/app/installations') 40 | .query({ per_page: 100 }) 41 | .reply(200, [{ account: { login: 'testUser' } }]) 42 | 43 | nock('https://api.github.com') 44 | .get('/installation/repositories') 45 | .query({ per_page: 100 }) 46 | .reply(200, [{ id: 2 }]) 47 | 48 | await probot.receive({ name: 'installation', payload }) 49 | }) 50 | 51 | afterEach(() => { 52 | nock.cleanAll() 53 | nock.enableNetConnect() 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mergeability/mergeable/6f552642173530981daf7bd8a0ba8483c6f0f882/screenshot.gif --------------------------------------------------------------------------------