├── __tests__ ├── fixtures │ ├── config-invalid │ │ ├── empty.yml │ │ ├── version.yml │ │ ├── empty-array-issue-labels.yml │ │ ├── empty-array-issue-chat-ops.yml │ │ ├── empty-array-pr-labels.yml │ │ ├── empty-array-pr-chat-ops.yml │ │ ├── issue-label-list.yml │ │ ├── pr-label-list.yml │ │ ├── issue-label-prefix.yml │ │ ├── captures-empty.yml │ │ ├── issue-chat-ops-cmd.yml │ │ ├── issue-chat-ops-comment.yml │ │ ├── issue-chat-ops-label.yml │ │ ├── issue-chat-ops-type.yml │ │ ├── pr-chat-ops-cmd.yml │ │ ├── pr-chat-ops-comment.yml │ │ ├── pr-chat-ops-label.yml │ │ ├── pr-chat-ops-type.yml │ │ ├── pr-label-prefix.yml │ │ ├── empty-array.yml │ │ ├── issue-label-needs.yml │ │ ├── pr-label-needs.yml │ │ ├── issue-label-multiple.yml │ │ ├── chat-ops-author-association.yml │ │ ├── pr-label-multiple.yml │ │ ├── pr-label-status-empty.yml │ │ ├── captures-label.yml │ │ ├── captures-github-release.yml │ │ ├── captures-ignore-case.yml │ │ ├── issue-label-needs-comment.yml │ │ ├── label-author-association.yml │ │ ├── pr-label-needs-comment.yml │ │ ├── chat-ops-author-association-author.yml │ │ ├── chat-ops-author-association-member.yml │ │ ├── issue-chat-ops-label-object.yml │ │ ├── captures-regex.yml │ │ ├── pr-chat-ops-label-object.yml │ │ ├── chat-ops-author-association-contributor.yml │ │ ├── pr-label-status-context.yml │ │ ├── label-author-association-author.yml │ │ ├── label-author-association-member.yml │ │ ├── label-author-association-contributor.yml │ │ └── pr-label-status-url.yml │ └── config-valid │ │ ├── version.yml │ │ ├── chat-ops-none.yml │ │ ├── chat-ops-close.yml │ │ ├── chat-ops-assign.yml │ │ ├── chat-ops-review.yml │ │ ├── captures-minimal.yml │ │ ├── label-multiple-true.yml │ │ ├── chat-ops-comment.yml │ │ ├── label-multiple-false.yml │ │ ├── chat-ops-label-add.yml │ │ ├── captures-version.yml │ │ ├── captures-ignore-case.yml │ │ ├── chat-ops-label-remove.yml │ │ ├── chat-ops-author-association-author.yml │ │ ├── chat-ops-author-association-member.yml │ │ ├── chat-ops-label-add-array.yml │ │ ├── chat-ops-author-association-contributor.yml │ │ ├── chat-ops-label-remove-array.yml │ │ ├── label-status-context.yml │ │ ├── chat-ops-label.yml │ │ ├── label-author-association-author.yml │ │ ├── label-author-association-member.yml │ │ ├── label-author-association-contributor.yml │ │ ├── label-status-url.yml │ │ ├── label-prefix-list.yml │ │ ├── label-status-description-string.yml │ │ ├── label-status-description-failure.yml │ │ ├── label-status-description-pending.yml │ │ ├── label-status-description-success.yml │ │ ├── label-needs.yml │ │ ├── label-status-description-success-failure.yml │ │ ├── label-status-description-success-pending.yml │ │ ├── label-status.yml │ │ ├── chat-ops-author-association.yml │ │ ├── label-author-association.yml │ │ ├── captures-all.yml │ │ └── label-triage.yml ├── operators │ ├── chat-ops │ │ ├── close.test.ts │ │ ├── comment.test.ts │ │ ├── assign.test.ts │ │ ├── review.test.ts │ │ └── label.test.ts │ ├── index.test.ts │ ├── capture.test.ts │ └── label.test.ts ├── github.test.ts ├── main.test.ts ├── rules │ ├── author-association.test.ts │ └── ignore.test.ts ├── command.test.ts └── config.test.ts ├── .eslintignore ├── .prettierignore ├── .gitattributes ├── icon.png ├── preview.png ├── .prettierrc.json ├── .editorconfig ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── general.md │ ├── enhancement.md │ └── bug-report.md ├── workflows │ ├── draft.yml │ ├── release.yml │ ├── sync-labels.yml │ ├── ci-use.yml │ └── ci.yml ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json ├── release-drafter.yml ├── CODEOWNERS ├── labels.yml └── governance.yml ├── jest.config.js ├── .idea ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml ├── oss-governance-bot.iml └── vcs.xml ├── tsconfig.json ├── src ├── operators │ ├── chat-ops │ │ ├── close.ts │ │ ├── comment.ts │ │ ├── assign.ts │ │ ├── review.ts │ │ └── label.ts │ ├── capture.ts │ ├── index.ts │ └── label.ts ├── rules │ ├── author-association.ts │ └── ignore.ts ├── command.ts ├── main.ts ├── config.ts └── github.ts ├── .eslintrc.json ├── action.yml ├── LICENSE ├── package.json ├── .gitignore └── README.md /__tests__/fixtures/config-invalid/empty.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/version.yml: -------------------------------------------------------------------------------- 1 | version: v2 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/version.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BirthdayResearch/oss-governance-bot/HEAD/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BirthdayResearch/oss-governance-bot/HEAD/preview.png -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/empty-array-issue-labels.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/empty-array-issue-chat-ops.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | chat_ops: 5 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/empty-array-pr-labels.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/empty-array-pr-chat-ops.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-label-list.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-list.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: kind 6 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-label-prefix.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - list: [ "accepted" ] 6 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/captures-empty.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Version: *(.+)" 6 | 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-none.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/none" 6 | type: none 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-close.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/close" 6 | type: close 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-chat-ops-cmd.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | chat_ops: 5 | - cmd: [ "/invalid" ] 6 | type: close 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-chat-ops-comment.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | chat_ops: 5 | - cmd: "/comment" 6 | type: comment 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-chat-ops-label.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-chat-ops-type.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | chat_ops: 5 | - cmd: "/something" 6 | type: invalid 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-chat-ops-cmd.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: ["/invalid"] 6 | type: close 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-assign.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/assign" 6 | type: assign 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-review.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/review" 6 | type: review 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-chat-ops-comment.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/comment" 6 | type: comment 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-chat-ops-label.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-chat-ops-type.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/something" 6 | type: invalid 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-prefix.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - list: [ "feature", "fix", "chore", "docs", "refactor" ] 6 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/captures-minimal.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Version: *(.+)" 6 | label: 'version/$CAPTURED' 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/empty-array.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | chat_ops: 6 | 7 | pull_request: 8 | labels: 9 | chat_ops: 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-multiple-true.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: true 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-label-needs.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: "invalid" 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-needs.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: "invalid" 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-comment.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/comment" 6 | type: comment 7 | comment: "comment" 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-multiple-false.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-label-multiple.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: "invalid" 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/chat-ops-author-association.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-multiple.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: "invalid" 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-label-add.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | add: kind/me 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-status-empty.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/captures-version.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Version: *(.+)" 6 | github_release: true 7 | label: 'version/$CAPTURED' 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/captures-label.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Operating System: *(macos|mac) *" 6 | ignore_case: true 7 | label: [ 'maybe' ] 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/captures-ignore-case.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Operating System: *(linux) *" 6 | ignore_case: true 7 | label: 'os/linux' 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-label-remove.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | remove: label/remove 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/captures-github-release.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Version: *(.+)" 6 | github_release: missing 7 | label: 'version/$CAPTURED' 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/captures-ignore-case.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Operating System: *(macos|mac) *" 6 | ignore_case: many 7 | label: 'os/mac' 8 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-label-needs-comment.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | comment: [ "invalid" ] 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/label-author-association.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-needs-comment.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | comment: [ "invalid" ] 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-author-association-author.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | author: true 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-author-association-member.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | member: true 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-label-add-array.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | add: [ 'kind/me', 'kind/this' ] 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/chat-ops-author-association-author.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | author: 'invalid' 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/chat-ops-author-association-member.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | member: 'invalid' 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/issue-chat-ops-label-object.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | add: 9 | remove: 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/captures-regex.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: [ "- Operating System: *(macos|mac) *", "- Operating System: *(windows|window|win|win) *" ] 6 | label: 'os/mac' 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-chat-ops-label-object.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | add: 9 | remove: 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-author-association-contributor.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | contributor: true 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/chat-ops-author-association-contributor.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | contributor: 'invalid' 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-status-context.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: [] 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-label-remove-array.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | remove: [ 'label/remove', 'label/that' ] 9 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-context.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-label.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/label this" 6 | type: label 7 | label: 8 | add: kind/me 9 | remove: [ 'label/remove', 'label/that' ] 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-author-association-author.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | author: true 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-author-association-member.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | member: false 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/label-author-association-author.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | author: 'invalid' 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/label-author-association-member.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | member: 'invalid' 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-author-association-contributor.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | contributor: true 10 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/label-author-association-contributor.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | contributor: 'invalid' 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-url.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | url: "https://github.com/google" 11 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-prefix-list.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | 8 | pull_request: 9 | labels: 10 | - prefix: kind 11 | list: [ "feature", "fix", "chore", "docs", "refactor" ] 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | labels: 8 | - 'kind/dependencies' 9 | commit-message: 10 | include: scope 11 | prefix: bump 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-description-string.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | description: Description 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General Question 3 | about: General question about using oss-governance-bot for your project/organisation 4 | labels: kind/question 5 | --- 6 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-invalid/pr-label-status-url.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | url: 11 | url: "https://github.com/google" 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-description-failure.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | description: 11 | failure: Failure description 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-description-pending.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | description: 11 | pending: Pending description 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-description-success.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | description: 11 | success: Success description 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement Request 3 | about: Suggest an enhancement to the oss-governance-bot project 4 | labels: kind/feature 5 | --- 6 | 7 | 8 | 9 | #### What would you like to be added: 10 | 11 | #### Why is this needed: 12 | -------------------------------------------------------------------------------- /.github/workflows/draft.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | draft-release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: release-drafter/release-drafter@cfc5540ebc9d65a8731f02032e3d44db5e449fb6 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Tags 2 | 3 | on: 4 | release: 5 | types: [ released ] 6 | 7 | jobs: 8 | release-tags: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 12 | 13 | - uses: vweevers/additional-tags-action@3bab55b44e81186dcfef7db9f2cbca01a78eb710 14 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-needs.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | comment: | 9 | @$AUTHOR: This issue is currently awaiting triage. 10 | 11 | The triage/accepted label can be added by org members by writing /triage accepted in a comment. 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-description-success-failure.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | description: 11 | success: Success description 12 | failure: Failure description 13 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status-description-success-pending.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | description: 11 | success: Success description 12 | pending: Pending description 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "es2019" 6 | ], 7 | "module": "commonjs", 8 | "outDir": "./lib", 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "esModuleInterop": true 13 | }, 14 | "exclude": [ 15 | "node_modules", 16 | "__tests__/**/*.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-status.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | needs: 8 | status: 9 | context: Status Context 10 | url: "https://github.com/google" 11 | description: 12 | success: Success description 13 | pending: Pending description 14 | failure: Failure description 15 | -------------------------------------------------------------------------------- /src/operators/chat-ops/close.ts: -------------------------------------------------------------------------------- 1 | import {ChatOps} from '../../config' 2 | import {Commands} from '../../command' 3 | import {patchIssue} from '../../github' 4 | 5 | export default async function ( 6 | chatOps: ChatOps, 7 | commands: Commands 8 | ): Promise { 9 | const matched = commands.prefix(chatOps.cmd) 10 | if (!matched.length) { 11 | return 12 | } 13 | 14 | await patchIssue({ 15 | state: 'closed' 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /src/operators/chat-ops/comment.ts: -------------------------------------------------------------------------------- 1 | import {CommentChatOps} from '../../config' 2 | import {Commands} from '../../command' 3 | import {postComment} from '../../github' 4 | 5 | export default async function ( 6 | chatOps: CommentChatOps, 7 | commands: Commands 8 | ): Promise { 9 | const matched = commands.prefix(chatOps.cmd) 10 | if (!matched.length) { 11 | return 12 | } 13 | 14 | await postComment(chatOps.comment) 15 | } 16 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/chat-ops-author-association.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | pull_request: 4 | chat_ops: 5 | - cmd: "/author" 6 | type: none 7 | author_association: 8 | author: false 9 | collaborator: false 10 | contributor: false 11 | first_timer: false 12 | first_time_contributor: false 13 | mannequin: false 14 | member: false 15 | none: false 16 | owner: false 17 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync labels 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - .github/labels.yml 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 14 | 15 | - uses: micnncim/action-label-syncer@3abd5ab72fda571e69fffd97bd4e0033dd5f495c 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.idea/oss-governance-bot.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-author-association.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | author: false 10 | collaborator: false 11 | contributor: false 12 | first_timer: false 13 | first_time_contributor: false 14 | mannequin: false 15 | member: false 16 | none: false 17 | owner: false 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug encountered with using oss-governance-bot 4 | labels: kind/bug 5 | --- 6 | 7 | 12 | 13 | #### What happened: 14 | 15 | #### What you expected to happen: 16 | 17 | #### How to reproduce it (as minimally and precisely as possible): 18 | 19 | #### Anything else we need to know?: 20 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/captures-all.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: "- Version: *(.+)" 6 | github_release: true 7 | ignore_case: true 8 | label: 'version/$CAPTURED' 9 | 10 | - regex: "- Operating System: *(macos|mac) *" 11 | ignore_case: true 12 | label: 'os/mac' 13 | 14 | - regex: "- Operating System: *(windows|window|win|win) *" 15 | ignore_case: true 16 | label: 'os/win' 17 | 18 | - regex: "- Operating System: *(linux) *" 19 | ignore_case: true 20 | label: 'os/linux' 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | #### What kind of PR is this?: 4 | 5 | 13 | 14 | /kind 15 | 16 | #### What this PR does / why we need it: 17 | 18 | #### Which issue(s) does this PR fixes?: 19 | 20 | 24 | Fixes # 25 | 26 | #### Additional comments?: 27 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "jest", 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "plugin:github/recommended" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "parserOptions": { 11 | "ecmaVersion": 9, 12 | "sourceType": "module", 13 | "project": "./tsconfig.json" 14 | }, 15 | "rules": { 16 | "import/no-namespace": "off", 17 | "import/no-anonymous-default-export": "off", 18 | "no-redeclare": "off" 19 | }, 20 | "env": { 21 | "node": true, 22 | "es6": true, 23 | "jest/globals": true 24 | }, 25 | "ignorePatterns": [ 26 | "__tests__/**.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'OSS Governance Bot' 2 | description: 'Speed up issue triaging with automated chat-bot and chat-ops operations with quality control hierarchy' 3 | author: 'BirthdayResearch' 4 | branding: 5 | icon: zap 6 | color: white 7 | 8 | inputs: 9 | config-path: 10 | description: 'The path for the governance configuration' 11 | default: '.github/governance.yml' 12 | required: false 13 | github-token: 14 | description: 'GITHUB_TOKEN or a `repo` scoped Personal Access Token (PAT) for everything else' 15 | required: false 16 | default: ${{ github.token }} 17 | 18 | runs: 19 | using: 'node20' 20 | main: 'dist/index.js' 21 | -------------------------------------------------------------------------------- /src/operators/chat-ops/assign.ts: -------------------------------------------------------------------------------- 1 | import {ChatOps} from '../../config' 2 | import {Commands} from '../../command' 3 | import {assign} from '../../github' 4 | 5 | export default async function ( 6 | chatOps: ChatOps, 7 | commands: Commands 8 | ): Promise { 9 | const matched = commands.prefix(chatOps.cmd) 10 | if (!matched.length) { 11 | return 12 | } 13 | 14 | const assignees: string[] = matched 15 | .flatMap(value => value.args) 16 | .map(value => { 17 | value = value.trim() 18 | if (value.startsWith('@')) { 19 | return value.replace(/^@/, '') 20 | } 21 | }) 22 | .filter(value => value) as string[] 23 | 24 | await assign(assignees) 25 | } 26 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":semanticCommitTypeAll(bump)"], 4 | "labels": ["kind/dependencies"], 5 | "dependencyDashboard": true, 6 | "dependencyDashboardAutoclose": false, 7 | "major": { 8 | "dependencyDashboardApproval": true 9 | }, 10 | "rangeStrategy": "bump", 11 | "packageRules": [ 12 | { 13 | "matchManagers": ["github-actions"], 14 | "enabled": false 15 | }, 16 | { 17 | "matchPackagePatterns": ["eslint"], 18 | "groupName": "eslint" 19 | }, 20 | { 21 | "matchPackagePatterns": ["jest"], 22 | "groupName": "jest" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/operators/chat-ops/review.ts: -------------------------------------------------------------------------------- 1 | import {ChatOps} from '../../config' 2 | import {Commands} from '../../command' 3 | import {requestReviewers} from '../../github' 4 | 5 | export default async function ( 6 | chatOps: ChatOps, 7 | commands: Commands 8 | ): Promise { 9 | const matched = commands.prefix(chatOps.cmd) 10 | if (!matched.length) { 11 | return 12 | } 13 | 14 | const reviewers: string[] = matched 15 | .flatMap(value => value.args) 16 | .map(value => { 17 | value = value.trim() 18 | if (value.startsWith('@')) { 19 | return value.replace(/^@/, '') 20 | } 21 | }) 22 | .filter(value => value) as string[] 23 | 24 | await requestReviewers(reviewers) 25 | } 26 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'kind/feature' 7 | - title: '🐛 Bug Fixes' 8 | labels: 9 | - 'kind/fix' 10 | - title: '🧰 Maintenance' 11 | labels: 12 | - 'kind/refactor' 13 | - 'kind/chore' 14 | - 'kind/docs' 15 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)' 16 | change-title-escapes: '\<*_&' 17 | version-resolver: 18 | minor: 19 | labels: 20 | - 'kind/feature' 21 | patch: 22 | labels: 23 | - 'kind/refactor' 24 | - 'kind/chore' 25 | - 'kind/docs' 26 | - 'kind/fix' 27 | default: patch 28 | template: | 29 | $CHANGES 30 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Define individuals that are responsible for code in a repository. 2 | # More details are here: https://help.github.com/articles/about-codeowners/ 3 | 4 | /.github/ @fuxingloh 5 | /.idea/ @fuxingloh 6 | /.github/workflows/ci-use.yml @fuxingloh 7 | /.github/governance.yml @fuxingloh 8 | 9 | tsconfig.json @fuxingloh 10 | jest.config.js @fuxingloh 11 | package.json @fuxingloh 12 | package-lock.json @fuxingloh 13 | 14 | README.md @fuxingloh 15 | LICENSE @fuxingloh 16 | -------------------------------------------------------------------------------- /src/operators/chat-ops/label.ts: -------------------------------------------------------------------------------- 1 | import {LabelChatOps} from '../../config' 2 | import {Commands} from '../../command' 3 | import {addLabels, removeLabels} from '../../github' 4 | 5 | export default async function ( 6 | chatOps: LabelChatOps, 7 | commands: Commands 8 | ): Promise { 9 | const matched = commands.prefix(chatOps.cmd) 10 | if (!matched.length) { 11 | return 12 | } 13 | 14 | const add = chatOps.label?.add 15 | 16 | if (typeof add === 'string' && add) { 17 | await addLabels([add]) 18 | } else if (Array.isArray(add)) { 19 | await addLabels(add) 20 | } 21 | 22 | const remove = chatOps.label?.remove 23 | 24 | if (typeof remove === 'string' && remove) { 25 | await removeLabels([remove]) 26 | } else if (Array.isArray(remove)) { 27 | await removeLabels(remove) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-valid/label-triage.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | collaborator: true 10 | member: true 11 | owner: true 12 | needs: 13 | comment: | 14 | @$AUTHOR: Thanks for opening an issue, it is currently awaiting triage. 15 | 16 | The triage/accepted label can be added by foundation members by writing `/triage accepted` in a comment. 17 | 18 | In the meantime, you can: 19 | 20 | 1. Checkout [DeFiChain’s Github issue page](https://github.com/DeFiCh/app/issues) to see if your issue has already been reported 21 | 2. Checkout [how to an submit issue for DeFi app](https://github.com/DeFiCh/app/wiki/How-to-submit-issues-for-DeFi-app) 22 | 3. Submit any logs if you have them, this will greatly expedite the process for us. 23 | -------------------------------------------------------------------------------- /.github/workflows/ci-use.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request_target: 5 | types: [ synchronize, opened, labeled, unlabeled ] 6 | issues: 7 | types: [ opened, labeled, unlabeled ] 8 | issue_comment: 9 | types: [ created ] 10 | 11 | # You can use permissions to modify the default permissions granted to the GITHUB_TOKEN, 12 | # adding or removing access as required, so that you only allow the minimum required access. 13 | permissions: 14 | contents: read 15 | issues: write 16 | pull-requests: write 17 | statuses: write 18 | checks: write 19 | 20 | jobs: 21 | uses: 22 | name: Uses 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 26 | 27 | - uses: ./ 28 | with: 29 | # You can use a PAT to post a comment/label so that it shows up as a user instead of github-actions 30 | # Set the user to Triage, full repo scope. 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /src/operators/capture.ts: -------------------------------------------------------------------------------- 1 | import {Capture} from '../config' 2 | import {getBody} from '../command' 3 | import {addLabels, hasReleaseByTag} from '../github' 4 | 5 | async function parseLabel( 6 | capture: Capture, 7 | array: RegExpExecArray 8 | ): Promise { 9 | let capturedText = (array[1] || '').trim() 10 | if (capture.github_release) { 11 | // Automatically parse semantic release 12 | capturedText = capturedText.replace(/^v/, '') 13 | 14 | if ( 15 | !(await hasReleaseByTag(`v${capturedText}`)) && 16 | !(await hasReleaseByTag(capturedText)) 17 | ) { 18 | return 19 | } 20 | } 21 | 22 | return capture.label.replace('$CAPTURED', capturedText) 23 | } 24 | 25 | export default async function (capture: Capture): Promise { 26 | const regex = new RegExp(capture.regex, `${capture.ignore_case ? 'i' : ''}`) 27 | 28 | for (const line of getBody().split('\n')) { 29 | const array = regex.exec(line) 30 | if (!array) { 31 | continue 32 | } 33 | 34 | const label = await parseLabel(capture, array) 35 | if (label) { 36 | await addLabels([label]) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Fuxing Loh 4 | Copyright (c) BirthdayResearch OSS Governance Bot Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/rules/author-association.ts: -------------------------------------------------------------------------------- 1 | import {AuthorAssociation} from '../config' 2 | import * as github from '@actions/github' 3 | 4 | function getAuthorAssociation(): string | undefined { 5 | const payload = github.context.payload 6 | const current = payload.comment || payload.pull_request || payload.issue 7 | return current?.author_association 8 | } 9 | 10 | function isCommentUserIssueAuthor(): boolean { 11 | const payload = github.context.payload 12 | return payload.comment?.user?.login === payload.issue?.user?.login 13 | } 14 | 15 | export function isAuthorAssociationAllowed( 16 | authorAssociation: AuthorAssociation | undefined 17 | ): boolean { 18 | if (!authorAssociation) { 19 | return true 20 | } 21 | 22 | if (authorAssociation.author && isCommentUserIssueAuthor()) { 23 | return true 24 | } 25 | 26 | switch (getAuthorAssociation()) { 27 | case 'COLLABORATOR': 28 | return !!authorAssociation.collaborator 29 | case 'CONTRIBUTOR': 30 | return !!authorAssociation.contributor 31 | case 'FIRST_TIMER': 32 | return !!authorAssociation.first_timer 33 | case 'FIRST_TIME_CONTRIBUTOR': 34 | return !!authorAssociation.first_time_contributor 35 | case 'MANNEQUIN': 36 | return !!authorAssociation.mannequin 37 | case 'MEMBER': 38 | return !!authorAssociation.member 39 | case 'NONE': 40 | return !!authorAssociation.none 41 | case 'OWNER': 42 | return !!authorAssociation.owner 43 | default: 44 | return false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # GitHub generic useful labels 2 | - color: c4d471 3 | name: duplicate 4 | description: This issue or pull request already exists 5 | - color: 7057ff 6 | name: good first issue 7 | description: Good for newcomers 8 | 9 | # Needs 10 | - color: cc40d8 11 | name: needs/kind 12 | description: Needs kind label 13 | - color: cc40d8 14 | name: needs/priority 15 | description: Needs a priority label 16 | - color: cc40d8 17 | name: needs/triage 18 | description: Waiting for triage to be accepted 19 | 20 | # Kind 21 | - color: e7e9eb 22 | name: kind/bug 23 | description: Something isn't working 24 | - color: e7e9eb 25 | name: kind/feature 26 | description: New feature request 27 | - color: e7e9eb 28 | name: kind/question 29 | description: Generic question 30 | - color: e7e9eb 31 | name: kind/chore 32 | description: Non feature change 33 | - color: e7e9eb 34 | name: kind/fix 35 | description: Fix a bug 36 | - color: e7e9eb 37 | name: kind/docs 38 | description: Non feature documentation change 39 | - color: e7e9eb 40 | name: kind/refactor 41 | description: Non feature refactor change 42 | - color: e7e9eb 43 | name: kind/dependencies 44 | description: Pull requests that update a dependency file 45 | 46 | # Priority 47 | - color: d93f0b 48 | name: priority/urgent-now 49 | description: Urgent fix, as fast as possible 50 | - color: d93f0b 51 | name: priority/important-soon 52 | description: Will be important soon 53 | 54 | # Triage 55 | - color: b1f488 56 | name: triage/accepted 57 | description: Triage has been accepted 58 | -------------------------------------------------------------------------------- /__tests__/operators/chat-ops/close.test.ts: -------------------------------------------------------------------------------- 1 | import close from '../../../src/operators/chat-ops/close' 2 | import {Command, Commands} from "../../../src/command"; 3 | import * as github from "@actions/github"; 4 | import * as core from "@actions/core"; 5 | import nock from "nock"; 6 | 7 | const patchIssue = jest.fn() 8 | 9 | beforeAll(() => { 10 | jest.spyOn(core, 'getInput').mockImplementation(name => { 11 | return 'token' 12 | }) 13 | 14 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 15 | return { 16 | owner: 'owner', 17 | repo: 'repo' 18 | } 19 | }) 20 | 21 | github.context.payload = { 22 | issue: { 23 | number: 1 24 | } 25 | } 26 | 27 | nock('https://api.github.com') 28 | .patch('/repos/owner/repo/issues/1') 29 | .reply(200, function (_, body) { 30 | patchIssue(body) 31 | return {} 32 | }).persist() 33 | }) 34 | 35 | afterAll(() => { 36 | jest.clearAllMocks() 37 | }) 38 | 39 | async function runOp(cmd: string, list: string[] = [], others: any = {}) { 40 | const commands = new Commands(list.map(t => new Command((t)))) 41 | 42 | await close({ 43 | cmd: cmd, 44 | type: 'close', 45 | ...others, 46 | }, commands) 47 | } 48 | 49 | it('should close with /close', async () => { 50 | await runOp('/close', ['/close']) 51 | await expect(patchIssue).toHaveBeenCalledWith({"state": "closed"}) 52 | }); 53 | 54 | it('should close with /close-it', async () => { 55 | await runOp('/close-it', ['/close-it']) 56 | await expect(patchIssue).toHaveBeenCalledWith({"state": "closed"}) 57 | }); 58 | 59 | it('should not close with /nah', async () => { 60 | await runOp('/nah', ['/close']) 61 | await expect(patchIssue).not.toHaveBeenCalled() 62 | }); 63 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | 3 | export class Command { 4 | public readonly text: string 5 | public readonly args: string[] = [] 6 | 7 | constructor(text: string) { 8 | this.text = text 9 | } 10 | } 11 | 12 | export class ArgsCommand extends Command { 13 | public readonly args: string[] = [] 14 | 15 | constructor(text: string, prefix: string) { 16 | super(text) 17 | 18 | const postfix = this.text.split(prefix)[1] 19 | if (postfix) { 20 | this.args = postfix.trim().split(' ') 21 | } 22 | } 23 | } 24 | 25 | export class Commands { 26 | public readonly commands: Command[] 27 | 28 | constructor(commands: Command[]) { 29 | this.commands = commands 30 | } 31 | 32 | prefix(start: string): ArgsCommand[] { 33 | return this.commands 34 | .filter(command => { 35 | return command.text.startsWith(start) 36 | }) 37 | .map(value => { 38 | return new ArgsCommand(value.text, start + ' ') 39 | }) 40 | } 41 | } 42 | 43 | export function getBody(): string { 44 | const payload = github.context.payload 45 | const content = payload.comment || payload.pull_request || payload.issue 46 | 47 | let body: string = content?.body || '' 48 | // Replace comments so that it's not processed 49 | body = body.replace('\r', '\n') 50 | body = body.replace('\r\n', '\n') 51 | body = body.replace(//g, '') 52 | return body 53 | } 54 | 55 | export function getCommands(): Command[] { 56 | return getBody() 57 | .split('\n') 58 | .map(text => /^\/(.+)/.exec(text)?.[0]) 59 | .filter((cmd): cmd is string => !!cmd) 60 | .map(value => new Command(value)) 61 | } 62 | 63 | export default async function (): Promise { 64 | const commands = getCommands() 65 | return new Commands(commands) 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oss-governance-bot", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Speed up issue triaging with automated chat-bot and chat-ops operations with quality control hierarchy", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write **/*.ts", 10 | "lint": "eslint --fix src/**/*.ts", 11 | "pack": "ncc build --source-map --license licenses.txt", 12 | "test": "jest", 13 | "test:coverage": "jest --ci --coverage && codecov", 14 | "all": "npm run build && npm run format && npm run lint && npm run pack && npm run test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/BirthdayResearch/oss-governance-bot.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "open source", 23 | "project management", 24 | "goverenance" 25 | ], 26 | "author": { 27 | "name": "Birthday Research", 28 | "email": "oss@birthday.dev" 29 | }, 30 | "license": "MIT", 31 | "dependencies": { 32 | "@actions/core": "^1.2.6", 33 | "@actions/github": "^4.0.0", 34 | "fp-ts": "^2.11.4", 35 | "io-ts": "^2.2.16", 36 | "io-ts-reporters": "^1.2.2", 37 | "js-yaml": "^4.1.0" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^26.0.24", 41 | "@types/js-yaml": "^4.0.9", 42 | "@types/lodash": "^4.14.202", 43 | "@types/node": "^16.18.80", 44 | "@typescript-eslint/parser": "^5.32.0", 45 | "@vercel/ncc": "^0.31.1", 46 | "codecov": "^3.8.2", 47 | "eslint": "^7.32.0", 48 | "eslint-plugin-github": "^4.1.1", 49 | "eslint-plugin-jest": "^26.9.0", 50 | "jest": "^26.6.3", 51 | "jest-circus": "^26.6.3", 52 | "nock": "^13.5.4", 53 | "prettier": "2.2.1", 54 | "ts-jest": "^26.5.6", 55 | "typescript": "^4.1.5", 56 | "wait-for-expect": "^3.0.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | Build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 14 | 15 | - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c 16 | with: 17 | node-version: '16' 18 | cache: 'npm' 19 | 20 | - run: npm ci 21 | - run: npm run build 22 | - run: npm run pack 23 | 24 | - uses: EndBug/add-and-commit@61a88be553afe4206585b31aa72387c64295d08b 25 | with: 26 | message: 'Diff from format, lint and pack' 27 | 28 | lint_prettier: 29 | name: "Lint (prettier)" 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 33 | 34 | - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c 35 | with: 36 | node-version: '16' 37 | cache: 'npm' 38 | - run: npm ci 39 | - run: npx --no-install prettier --check **/*.ts 40 | 41 | lint_eslint: 42 | name: "Lint (eslint)" 43 | runs-on: ubuntu-latest 44 | steps: 45 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 46 | 47 | - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c 48 | with: 49 | node-version: '16' 50 | cache: 'npm' 51 | 52 | - run: npm ci 53 | - run: npx --no-install eslint src/**/*.ts 54 | 55 | Test: 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c 59 | 60 | - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c 61 | with: 62 | node-version: '16' 63 | cache: 'npm' 64 | 65 | - run: npm ci 66 | - run: npm run test:coverage 67 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import {Config, getConfig, Governance} from './config' 4 | import ignore from './rules/ignore' 5 | import command from './command' 6 | import operations from './operators' 7 | import {initClient} from './github' 8 | 9 | /** 10 | * @return the current governance config based on the context, it could be 'pull_request' or 'issue'. 11 | */ 12 | export async function getGovernance(): Promise { 13 | const configPath = core.getInput('config-path', {required: true}) 14 | const config: Config = await getConfig(initClient(), configPath) 15 | 16 | if (github.context.payload.comment) { 17 | if (github.context.payload.issue?.pull_request) { 18 | return config.pull_request 19 | } 20 | 21 | if (github.context.payload.issue) { 22 | return config.issue 23 | } 24 | } 25 | 26 | if (github.context.payload.issue) { 27 | return config.issue 28 | } 29 | 30 | if (github.context.payload.pull_request) { 31 | return config.pull_request 32 | } 33 | 34 | throw new Error('Could not get pull_request or issue from context') 35 | } 36 | 37 | /** 38 | * Get governance config, parse and run commands from context. 39 | */ 40 | export async function runGovernance(): Promise { 41 | const governance = await getGovernance() 42 | core.info('main: fetched governance.yml') 43 | 44 | if (!governance) { 45 | return 46 | } 47 | 48 | core.info('main: parsing commands') 49 | const commands = await command() 50 | 51 | core.info('main: running operations') 52 | await operations(governance, commands) 53 | core.info('main: completed operations') 54 | } 55 | 56 | /* eslint github/no-then: off */ 57 | ignore() 58 | .then(async toIgnore => { 59 | if (toIgnore) return 60 | 61 | await runGovernance() 62 | }) 63 | .catch(error => { 64 | core.error(error) 65 | core.setFailed(error) 66 | }) 67 | -------------------------------------------------------------------------------- /__tests__/operators/chat-ops/comment.test.ts: -------------------------------------------------------------------------------- 1 | import comment from '../../../src/operators/chat-ops/comment' 2 | import {Command, Commands} from "../../../src/command"; 3 | import * as github from "@actions/github"; 4 | import * as core from "@actions/core"; 5 | import nock from "nock"; 6 | 7 | const postComments = jest.fn() 8 | 9 | 10 | beforeAll(() => { 11 | jest.spyOn(core, 'getInput').mockImplementation(name => { 12 | return 'token' 13 | }) 14 | 15 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 16 | return { 17 | owner: 'owner', 18 | repo: 'repo' 19 | } 20 | }) 21 | 22 | github.context.payload = { 23 | issue: { 24 | number: 1 25 | } 26 | } 27 | 28 | nock('https://api.github.com') 29 | .post('/repos/owner/repo/issues/1/comments') 30 | .reply(200, function (_, body) { 31 | postComments(body) 32 | return {} 33 | }).persist() 34 | }) 35 | 36 | afterAll(() => { 37 | jest.clearAllMocks() 38 | }) 39 | 40 | async function runOp(cmd: string, list: string[] = [], others: any = {}) { 41 | const commands = new Commands(list.map(t => new Command((t)))) 42 | 43 | await comment({ 44 | cmd: cmd, 45 | type: 'comment', 46 | ...others, 47 | }, commands) 48 | } 49 | 50 | it('should comment with /comment', async () => { 51 | await runOp('/comment', ['/comment'], { 52 | comment: '@$AUTHOR: Hey this is comment example.' 53 | }) 54 | await expect(postComments).toHaveBeenCalled() 55 | }); 56 | 57 | it('should comment with /comment-it', async () => { 58 | await runOp('/comment-it', ['/comment-it'], { 59 | comment: '@$AUTHOR: Hey this is comment example.' 60 | }) 61 | await expect(postComments).toHaveBeenCalled() 62 | }); 63 | 64 | it('should not comment with /nah', async () => { 65 | await runOp('/nah', ['/comment'], { 66 | comment: '@$AUTHOR: Hey this is comment example.' 67 | }) 68 | await expect(postComments).not.toHaveBeenCalled() 69 | }); 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib/**/* 100 | 101 | # IntelliJ 102 | .idea/* 103 | !.idea/inspectionProfiles/ 104 | !.idea/dictionaries/ 105 | !.idea/vcs.xml 106 | !.idea/modules.xml 107 | !.idea/*.iml 108 | -------------------------------------------------------------------------------- /.github/governance.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | labels: 5 | - prefix: triage 6 | list: [ "accepted" ] 7 | multiple: false 8 | author_association: 9 | collaborator: true 10 | member: true 11 | owner: true 12 | needs: 13 | comment: | 14 | @$AUTHOR: This issue is currently awaiting triage. 15 | 16 | The triage/accepted label can be added by org members by writing /triage accepted in a comment. 17 | 18 | - prefix: kind 19 | list: [ "feature", "bug", "question" ] 20 | multiple: false 21 | needs: true 22 | 23 | chat_ops: 24 | - cmd: /close 25 | type: close 26 | author_association: 27 | author: true 28 | collaborator: true 29 | member: true 30 | owner: true 31 | 32 | - cmd: /cc 33 | type: none 34 | 35 | - cmd: /assign 36 | type: assign 37 | author_association: 38 | collaborator: true 39 | member: true 40 | owner: true 41 | 42 | pull_request: 43 | labels: 44 | - prefix: kind 45 | multiple: false 46 | list: [ "feature", "fix", "chore", "docs", "refactor", "dependencies" ] 47 | needs: 48 | comment: | 49 | @$AUTHOR: There are no 'kind' label on this PR. You need a 'kind' label to generate the release automatically. 50 | 51 | * `/kind feature` 52 | * `/kind fix` 53 | * `/kind chore` 54 | * `/kind docs` 55 | * `/kind refactor` 56 | * `/kind dependencies` 57 | status: 58 | context: "Governance / Kind Label" 59 | description: 60 | success: Ready for review & merge. 61 | failure: Missing kind label to generate release automatically. 62 | 63 | - prefix: priority 64 | multiple: false 65 | list: [ "urgent-now", "important-soon" ] 66 | author_association: 67 | collaborator: true 68 | member: true 69 | owner: true 70 | 71 | chat_ops: 72 | - cmd: /close 73 | type: close 74 | author_association: 75 | author: true 76 | collaborator: true 77 | member: true 78 | owner: true 79 | 80 | - cmd: /cc 81 | type: none 82 | 83 | - cmd: /request 84 | type: review 85 | author_association: 86 | collaborator: true 87 | member: true 88 | owner: true 89 | -------------------------------------------------------------------------------- /src/operators/index.ts: -------------------------------------------------------------------------------- 1 | import {Capture, ChatOps, Governance, Label} from '../config' 2 | import {Commands} from '../command' 3 | import {isAuthorAssociationAllowed} from '../rules/author-association' 4 | import label from './label' 5 | import capture from './capture' 6 | import chatOpsClose from './chat-ops/close' 7 | import chatOpsComment from './chat-ops/comment' 8 | import chatOpsAssign from './chat-ops/assign' 9 | import chatOpsReview from './chat-ops/review' 10 | import chatOpsLabel from './chat-ops/label' 11 | import {isCreatedOpened} from '../rules/ignore' 12 | import * as core from '@actions/core' 13 | 14 | async function processLabels( 15 | labels: Label[], 16 | commands: Commands 17 | ): Promise { 18 | for (const labelOp of labels) { 19 | await label(labelOp, commands) 20 | } 21 | } 22 | 23 | async function processCaptures(captures: Capture[]): Promise { 24 | if (!isCreatedOpened()) { 25 | return 26 | } 27 | 28 | for (const captureOp of captures) { 29 | if (isAuthorAssociationAllowed(captureOp.author_association)) { 30 | await capture(captureOp) 31 | } 32 | } 33 | } 34 | 35 | async function processChatOps( 36 | chatOps: ChatOps[], 37 | commands: Commands 38 | ): Promise { 39 | if (!isCreatedOpened()) { 40 | return 41 | } 42 | 43 | for (const chatOp of chatOps) { 44 | if (!isAuthorAssociationAllowed(chatOp.author_association)) { 45 | continue 46 | } 47 | switch (chatOp.type) { 48 | case 'close': 49 | await chatOpsClose(chatOp, commands) 50 | break 51 | case 'assign': 52 | await chatOpsAssign(chatOp, commands) 53 | break 54 | case 'review': 55 | await chatOpsReview(chatOp, commands) 56 | break 57 | case 'comment': 58 | await chatOpsComment(chatOp, commands) 59 | break 60 | case 'label': 61 | await chatOpsLabel(chatOp, commands) 62 | break 63 | } 64 | } 65 | } 66 | 67 | export default async function ( 68 | governance: Governance, 69 | commands: Commands 70 | ): Promise { 71 | if (governance.captures?.length) { 72 | core.info('operations: processing captures') 73 | await processCaptures(governance.captures) 74 | } 75 | 76 | if (governance.chat_ops?.length) { 77 | core.info('operations: processing chatops') 78 | await processChatOps(governance.chat_ops, commands) 79 | } 80 | 81 | if (governance.labels?.length) { 82 | core.info('operations: processing labels') 83 | await processLabels(governance.labels, commands) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /__tests__/operators/chat-ops/assign.test.ts: -------------------------------------------------------------------------------- 1 | import assign from '../../../src/operators/chat-ops/assign' 2 | import {Command, Commands} from "../../../src/command"; 3 | import * as github from "@actions/github"; 4 | import * as core from "@actions/core"; 5 | import nock from "nock"; 6 | 7 | const postAssignees = jest.fn() 8 | 9 | beforeAll(() => { 10 | jest.spyOn(core, 'getInput').mockImplementation(name => { 11 | return 'token' 12 | }) 13 | 14 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 15 | return { 16 | owner: 'owner', 17 | repo: 'repo' 18 | } 19 | }) 20 | 21 | github.context.payload = { 22 | issue: { 23 | number: 1 24 | } 25 | } 26 | 27 | nock('https://api.github.com') 28 | .post('/repos/owner/repo/issues/1/assignees') 29 | .reply(200, function (_, body) { 30 | postAssignees(body) 31 | return {} 32 | }).persist() 33 | }) 34 | 35 | afterAll(() => { 36 | jest.clearAllMocks() 37 | }) 38 | 39 | async function runOp(cmd: string, list: string[] = [], others: any = {}) { 40 | const commands = new Commands(list.map(t => new Command((t)))) 41 | 42 | await assign({ 43 | cmd: cmd, 44 | type: 'assign', 45 | ...others, 46 | }, commands) 47 | } 48 | 49 | it('should assign with /assign @jenny', async () => { 50 | await runOp('/assign', ['/assign @jenny']) 51 | await expect(postAssignees).toHaveBeenCalledWith({"assignees": ["jenny"]}) 52 | }); 53 | 54 | it('should assign with /assign @jenny @clarie', async () => { 55 | await runOp('/assign', ['/assign @jenny @clarie']) 56 | await expect(postAssignees).toHaveBeenCalledWith({"assignees": ["jenny", "clarie"]}) 57 | }); 58 | 59 | it('should not assign with /assign jenny', async () => { 60 | await runOp('/assign', ['/assign jenny']) 61 | await expect(postAssignees).not.toHaveBeenCalled() 62 | }); 63 | 64 | it('should not assign with /assign jessica abc', async () => { 65 | await runOp('/assign', ['/assign jessica abc']) 66 | await expect(postAssignees).not.toHaveBeenCalled() 67 | }); 68 | 69 | it('should not assign with /assign to @jessica', async () => { 70 | await runOp('/assign', ['/assign to @jessica']) 71 | await expect(postAssignees).toHaveBeenCalledWith({"assignees": ["jessica"]}) 72 | }); 73 | 74 | it('should assign with /forward', async () => { 75 | await runOp('/forward', ['/forward @jessica']) 76 | await expect(postAssignees).toHaveBeenCalledWith({"assignees": ["jessica"]}) 77 | }); 78 | 79 | it('should not close with /nah', async () => { 80 | await runOp('/nah', ['/assign @jessica']) 81 | await expect(postAssignees).not.toHaveBeenCalled() 82 | }); 83 | -------------------------------------------------------------------------------- /__tests__/operators/chat-ops/review.test.ts: -------------------------------------------------------------------------------- 1 | import review from '../../../src/operators/chat-ops/review' 2 | import {Command, Commands} from "../../../src/command"; 3 | import * as github from "@actions/github"; 4 | import * as core from "@actions/core"; 5 | import nock from "nock"; 6 | 7 | const postRequestedReviewers = jest.fn() 8 | 9 | beforeAll(() => { 10 | jest.spyOn(core, 'getInput').mockImplementation(name => { 11 | return 'token' 12 | }) 13 | 14 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 15 | return { 16 | owner: 'owner', 17 | repo: 'repo' 18 | } 19 | }) 20 | 21 | github.context.payload = { 22 | issue: { 23 | number: 1 24 | } 25 | } 26 | 27 | nock('https://api.github.com') 28 | .post('/repos/owner/repo/pulls/1/requested_reviewers') 29 | .reply(200, function (_, body) { 30 | postRequestedReviewers(body) 31 | return {} 32 | }).persist() 33 | }) 34 | 35 | afterAll(() => { 36 | jest.clearAllMocks() 37 | }) 38 | 39 | async function runOp(cmd: string, list: string[] = [], others: any = {}) { 40 | const commands = new Commands(list.map(t => new Command((t)))) 41 | 42 | await review({ 43 | cmd: cmd, 44 | type: 'review', 45 | ...others, 46 | }, commands) 47 | } 48 | 49 | it('should assign with /review @jess', async () => { 50 | await runOp('/review', ['/review @jess']) 51 | await expect(postRequestedReviewers).toHaveBeenCalledWith({"reviewers": ["jess"]}) 52 | }); 53 | 54 | it('should assign with /review @thunder @clarie', async () => { 55 | await runOp('/review', ['/review @thunder @clarie']) 56 | await expect(postRequestedReviewers).toHaveBeenCalledWith({"reviewers": ["thunder", "clarie"]}) 57 | }); 58 | 59 | it('should assign with /review @DeFiChDeveloper @thunder @john', async () => { 60 | await runOp('/review', ['/review @DeFiChDeveloper @thunder @john']) 61 | await expect(postRequestedReviewers).toHaveBeenCalledWith({"reviewers": ["DeFiChDeveloper", "thunder", "john"]}) 62 | }); 63 | 64 | 65 | it('should not assign with /review jenny', async () => { 66 | await runOp('/review', ['/review jenny']) 67 | await expect(postRequestedReviewers).not.toHaveBeenCalled() 68 | }); 69 | 70 | it('should not assign with /review jenny abc', async () => { 71 | await runOp('/review', ['/review jenny abc']) 72 | await expect(postRequestedReviewers).not.toHaveBeenCalled() 73 | }); 74 | 75 | it('should not assign with /review to @jenny', async () => { 76 | await runOp('/review', ['/review to @jenny']) 77 | await expect(postRequestedReviewers).toHaveBeenCalledWith({"reviewers": ["jenny"]}) 78 | }); 79 | 80 | it('should assign with /ask', async () => { 81 | await runOp('/ask', ['/ask @jenny']) 82 | await expect(postRequestedReviewers).toHaveBeenCalledWith({"reviewers": ["jenny"]}) 83 | }); 84 | 85 | it('should not close with /nah', async () => { 86 | await runOp('/nah', ['/review @jenny']) 87 | await expect(postRequestedReviewers).not.toHaveBeenCalled() 88 | }); 89 | -------------------------------------------------------------------------------- /__tests__/operators/chat-ops/label.test.ts: -------------------------------------------------------------------------------- 1 | import labelChatOps from '../../../src/operators/chat-ops/label' 2 | import {Command, Commands} from "../../../src/command"; 3 | import * as github from "@actions/github"; 4 | import * as core from "@actions/core"; 5 | import nock from "nock"; 6 | import {addLabels} from "../../../src/github"; 7 | 8 | const postLabels = jest.fn() 9 | const deleteLabels = jest.fn() 10 | 11 | beforeAll(() => { 12 | jest.spyOn(core, 'getInput').mockImplementation(name => { 13 | return 'token' 14 | }) 15 | 16 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 17 | return { 18 | owner: 'owner', 19 | repo: 'repo' 20 | } 21 | }) 22 | 23 | github.context.payload = { 24 | issue: { 25 | number: 1 26 | } 27 | } 28 | 29 | nock('https://api.github.com') 30 | .post('/repos/owner/repo/issues/1/labels') 31 | .reply(200, function (_, body) { 32 | postLabels(body) 33 | return {} 34 | }).persist() 35 | 36 | nock('https://api.github.com') 37 | .delete(/\/repos\/owner\/repo\/issues\/1\/labels\/.+/) 38 | .reply(200, function (_, body) { 39 | const paths = this.req.path.split('/') 40 | deleteLabels(decodeURIComponent(paths[paths.length - 1])) 41 | return {} 42 | }).persist() 43 | }) 44 | 45 | afterAll(() => { 46 | jest.clearAllMocks() 47 | }) 48 | 49 | async function runOp(cmd: string, list: string[] = [], others: any = {}) { 50 | const commands = new Commands(list.map(t => new Command((t)))) 51 | 52 | await labelChatOps({ 53 | cmd: cmd, 54 | type: 'label', 55 | ...others, 56 | }, commands) 57 | } 58 | 59 | it('should have added label with /label me', async () => { 60 | await runOp('/label me', ['/label me'], { 61 | label: { 62 | add: 'me-2' 63 | } 64 | }) 65 | await expect(postLabels).toHaveBeenCalledWith({"labels": ['me-2']}) 66 | }); 67 | 68 | it('should have added 2 labels with /label me', async () => { 69 | await runOp('/label me', ['/label me'], { 70 | label: { 71 | add: ['me-1', 'me-2'] 72 | } 73 | }) 74 | await expect(postLabels).toHaveBeenCalledWith({"labels": ['me-1', 'me-2']}) 75 | }); 76 | 77 | it('should have removed label with /label me', async () => { 78 | await runOp('/label me', ['/label me'], { 79 | label: { 80 | remove: 'me-2' 81 | } 82 | }) 83 | await expect(deleteLabels).toHaveBeenCalledWith('me-2') 84 | }); 85 | 86 | it('should have removed 2 labels with /label me', async () => { 87 | await runOp('/label me', ['/label me'], { 88 | label: { 89 | remove: ['me-1', 'me-2'] 90 | } 91 | }) 92 | await expect(deleteLabels).toHaveBeenCalledWith('me-1') 93 | await expect(deleteLabels).toHaveBeenCalledWith('me-2') 94 | await expect(deleteLabels).toHaveBeenCalledTimes(2) 95 | }); 96 | 97 | it('should have add 3 removed 2 labels with /label abc', async () => { 98 | await runOp('/label abc', ['/label abc'], { 99 | label: { 100 | add: ['add-1', 'add-2', 'add-3'], 101 | remove: ['remove-1', 'remove-2'], 102 | } 103 | }) 104 | 105 | await expect(postLabels).toHaveBeenCalledWith({"labels": ['add-1', 'add-2', 'add-3']}) 106 | await expect(deleteLabels).toHaveBeenCalledWith('remove-1') 107 | await expect(deleteLabels).toHaveBeenCalledWith('remove-2') 108 | await expect(deleteLabels).toHaveBeenCalledTimes(2) 109 | }); 110 | 111 | it('should have add 1 removed 2 labels with /label abc', async () => { 112 | await runOp('/label abc', ['/label abc'], { 113 | label: { 114 | add: 'add-5', 115 | remove: ['remove-1', 'remove-2'], 116 | } 117 | }) 118 | 119 | await expect(postLabels).toHaveBeenCalledWith({"labels": ['add-5']}) 120 | await expect(deleteLabels).toHaveBeenCalledWith('remove-1') 121 | await expect(deleteLabels).toHaveBeenCalledWith('remove-2') 122 | await expect(deleteLabels).toHaveBeenCalledTimes(2) 123 | }); 124 | -------------------------------------------------------------------------------- /src/rules/ignore.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import * as core from '@actions/core' 3 | import {getBotUserId} from '../github' 4 | 5 | function is(eventName: string, actions: string[]): boolean { 6 | return ( 7 | github.context.eventName === eventName && 8 | actions.includes(github.context.payload.action!) 9 | ) 10 | } 11 | 12 | /** 13 | * Ignore labeled race condition where it get created before needs labels. 14 | * Not sure what is a better way to do this. 15 | */ 16 | function ignoreLabeledRaceCondition(): boolean { 17 | const payload = github.context.payload 18 | 19 | if ( 20 | payload.sender?.type !== 'User' && 21 | github.context.payload.action === 'labeled' 22 | ) { 23 | return false 24 | } 25 | 26 | if (is('issues', ['labeled'])) { 27 | return ( 28 | Date.parse(payload.issue?.created_at) + 5000 >= 29 | Date.parse(payload.issue?.updated_at) 30 | ) 31 | } 32 | 33 | if (is('pull_request', ['labeled'])) { 34 | return ( 35 | Date.parse(payload.pull_request?.created_at) + 5000 >= 36 | Date.parse(payload.pull_request?.updated_at) 37 | ) 38 | } 39 | 40 | if (is('pull_request_target', ['labeled'])) { 41 | return ( 42 | Date.parse(payload.pull_request?.created_at) + 5000 >= 43 | Date.parse(payload.pull_request?.updated_at) 44 | ) 45 | } 46 | 47 | return false 48 | } 49 | 50 | function isDependabot(): boolean { 51 | const payload = github.context.payload 52 | return payload.sender?.login === 'dependabot[bot]' 53 | } 54 | 55 | /** 56 | * Ignore non 'User' to prevent infinite loop. 57 | */ 58 | function ignoreBot(): boolean { 59 | const payload = github.context.payload 60 | core.info( 61 | `ignore: ignore bot - type:${payload.sender?.type} - login:${payload.sender?.login}` 62 | ) 63 | if (isDependabot()) { 64 | return false 65 | } 66 | return payload.sender?.type !== 'User' 67 | } 68 | 69 | /** 70 | * Ignores if sender is self 71 | */ 72 | async function ignoreSelf(): Promise { 73 | const payload = github.context.payload 74 | // allow fail because with 'github-token' > 'resource not accessible by integration' 75 | try { 76 | return payload.sender?.id === (await getBotUserId()) 77 | } catch (e) { 78 | return false 79 | } 80 | } 81 | 82 | /** 83 | * Closed issue and pull_request should not trigger governance 84 | */ 85 | function ignoreClosed(): boolean { 86 | const payload = github.context.payload 87 | if (payload?.pull_request?.state === 'closed') { 88 | return true 89 | } 90 | 91 | if (payload?.issue?.state === 'closed') { 92 | return true 93 | } 94 | 95 | return false 96 | } 97 | 98 | /** 99 | * To prevent mistakes, this will ignore invalid workflow trigger 100 | */ 101 | export default async function (): Promise { 102 | if (ignoreClosed()) { 103 | core.info('ignore: closed') 104 | return true 105 | } 106 | 107 | if (isDependabot()) { 108 | if (is('pull_request', ['opened'])) { 109 | return true 110 | } 111 | if (is('pull_request_target', ['opened'])) { 112 | return true 113 | } 114 | } 115 | 116 | if (ignoreLabeledRaceCondition()) { 117 | core.info('ignore: labeled race condition') 118 | return true 119 | } 120 | 121 | if (await ignoreSelf()) { 122 | if (is('pull_request_target', ['synchronize', 'opened'])) { 123 | return false 124 | } 125 | core.info('ignore: ignore self') 126 | return true 127 | } 128 | 129 | if (is('issue_comment', ['created'])) { 130 | return ignoreBot() 131 | } 132 | 133 | if (is('pull_request', ['synchronize', 'opened'])) { 134 | return ignoreBot() 135 | } 136 | 137 | if (is('pull_request', ['labeled', 'unlabeled'])) { 138 | return false 139 | } 140 | 141 | if (is('pull_request_target', ['synchronize', 'opened'])) { 142 | return ignoreBot() 143 | } 144 | 145 | if (is('pull_request_target', ['labeled', 'unlabeled'])) { 146 | return false 147 | } 148 | 149 | if (is('issues', ['opened'])) { 150 | return ignoreBot() 151 | } 152 | 153 | if (is('issues', ['labeled', 'unlabeled'])) { 154 | return false 155 | } 156 | 157 | core.info('ignore: catch all') 158 | return true 159 | } 160 | 161 | export function isCreatedOpened(): boolean { 162 | if (is('issue_comment', ['created'])) { 163 | return true 164 | } 165 | 166 | if (is('pull_request', ['opened'])) { 167 | return true 168 | } 169 | 170 | if (is('pull_request_target', ['opened'])) { 171 | return true 172 | } 173 | 174 | if (is('issues', ['opened'])) { 175 | return true 176 | } 177 | 178 | return false 179 | } 180 | -------------------------------------------------------------------------------- /__tests__/operators/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import nock from "nock"; 4 | import operations from "../../src/operators"; 5 | import {Command, Commands} from "../../src/command"; 6 | 7 | const intercepted = jest.fn() 8 | 9 | beforeEach(() => { 10 | jest.spyOn(core, 'getInput').mockImplementation(name => { 11 | return 'token' 12 | }) 13 | 14 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 15 | return { 16 | owner: 'owner', 17 | repo: 'repo' 18 | } 19 | }) 20 | 21 | github.context.eventName = 'issues' 22 | github.context.payload = { 23 | action: 'opened', 24 | issue: { 25 | number: 1, 26 | labels: [], 27 | body: 'Hello friends\n- Operating System: linux \r\n- Version: v1.5.0', 28 | author_association: 'CONTRIBUTOR' 29 | } 30 | } 31 | 32 | nock('https://api.github.com').get(/.+/).reply(200, () => { 33 | intercepted() 34 | return {} 35 | }).persist() 36 | nock('https://api.github.com').post(/.+/).reply(200, () => { 37 | intercepted() 38 | return {} 39 | }).persist() 40 | nock('https://api.github.com').delete(/.+/).reply(200, () => { 41 | intercepted() 42 | return {} 43 | }).persist() 44 | nock('https://api.github.com').patch(/.+/).reply(200, () => { 45 | intercepted() 46 | return {} 47 | }).persist() 48 | }) 49 | 50 | afterAll(() => { 51 | jest.clearAllMocks() 52 | }) 53 | 54 | function getCommands(list: string[] = []): Commands { 55 | return new Commands(list.map(t => new Command((t)))) 56 | } 57 | 58 | describe('all', () => { 59 | it('should not have error', async () => { 60 | await operations({ 61 | labels: [ 62 | { 63 | prefix: 'prefix', 64 | list: ['a', 'b'], 65 | needs: true, 66 | author_association: { 67 | owner: true 68 | } 69 | }, 70 | { 71 | prefix: 'another', 72 | list: ['a', 'b'], 73 | needs: true, 74 | } 75 | ], 76 | captures: [ 77 | { 78 | regex: "- Operating System: *(linux) *", 79 | label: 'os/linux' 80 | }, 81 | { 82 | regex: "- Version: *(.+) *", 83 | label: 'v/$CAPTURED', 84 | github_release: true 85 | } 86 | ], 87 | chat_ops: [ 88 | { 89 | cmd: '/close', 90 | type: 'close' 91 | }, 92 | { 93 | cmd: '/no run', 94 | type: 'none', 95 | author_association: { 96 | owner: true 97 | } 98 | } 99 | ] 100 | }, getCommands(['/close', '/prefix a', '/another a'])) 101 | await expect(intercepted).toHaveBeenCalledTimes(6) 102 | }); 103 | }) 104 | 105 | describe('labels', () => { 106 | it('should not have error', async () => { 107 | await operations({ 108 | labels: [ 109 | { 110 | prefix: 'prefix', 111 | list: ['a', 'b'], 112 | needs: true, 113 | } 114 | ] 115 | }, getCommands()) 116 | await expect(intercepted).toHaveBeenCalledTimes(1) 117 | }) 118 | }) 119 | 120 | describe('captures', () => { 121 | it('should not have error', async () => { 122 | await operations({ 123 | captures: [ 124 | { 125 | regex: "- Operating System: *(linux) *", 126 | label: 'os/linux' 127 | }, 128 | { 129 | regex: "- Version: *(.+) *", 130 | label: 'v/$CAPTURED', 131 | github_release: true 132 | } 133 | ] 134 | }, getCommands()) 135 | await expect(intercepted).toHaveBeenCalledTimes(3) 136 | }) 137 | }) 138 | 139 | describe('chat-ops', () => { 140 | it('should not have error', async () => { 141 | await operations({ 142 | chat_ops: [ 143 | { 144 | cmd: '/close', 145 | type: 'close' 146 | }, 147 | { 148 | cmd: '/cc', 149 | type: 'none' 150 | }, 151 | { 152 | cmd: '/request', 153 | type: 'review' 154 | }, 155 | { 156 | cmd: '/assign', 157 | type: 'assign' 158 | }, 159 | { 160 | cmd: '/comment me', 161 | type: 'comment', 162 | comment: 'abc' 163 | }, 164 | { 165 | cmd: '/label me', 166 | type: 'label', 167 | label: { 168 | add: 'me' 169 | } 170 | } 171 | ] 172 | }, getCommands(['/close', '/cc', '/request'])) 173 | await expect(intercepted).toHaveBeenCalledTimes(1) 174 | }); 175 | }) 176 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import {load} from 'js-yaml' 2 | import * as t from 'io-ts' 3 | import reporter from 'io-ts-reporters' 4 | import {isRight} from 'fp-ts/Either' 5 | import {GitHub} from '@actions/github/lib/utils' 6 | import * as github from '@actions/github' 7 | 8 | const AuthorAssociation = t.partial({ 9 | // Author of issue or pull_request 10 | author: t.boolean, 11 | // Author has been invited to collaborate on the repository. 12 | collaborator: t.boolean, 13 | // Author has previously committed to the repository. 14 | contributor: t.boolean, 15 | // Author has not previously committed to GitHub. 16 | first_timer: t.boolean, 17 | // Author has not previously committed to the repository. 18 | first_time_contributor: t.boolean, 19 | // Author is a placeholder for an unclaimed user. 20 | mannequin: t.boolean, 21 | // Author is a member of the organization that owns the repository. 22 | member: t.boolean, 23 | // Author has no association with the repository. 24 | none: t.boolean, 25 | // Author is the owner of the repository. 26 | owner: t.boolean 27 | }) 28 | 29 | const Label = t.intersection([ 30 | t.type({ 31 | prefix: t.string, 32 | list: t.array(t.string) 33 | }), 34 | t.partial({ 35 | multiple: t.boolean, 36 | author_association: AuthorAssociation, 37 | needs: t.union([ 38 | t.boolean, 39 | t.partial({ 40 | comment: t.string, 41 | status: t.intersection([ 42 | t.type({ 43 | context: t.string 44 | }), 45 | t.partial({ 46 | url: t.string, 47 | description: t.union([ 48 | t.string, 49 | t.partial({ 50 | success: t.string, 51 | failure: t.string 52 | }) 53 | ]) 54 | }) 55 | ]) 56 | }) 57 | ]) 58 | }) 59 | ]) 60 | 61 | const Capture = t.intersection([ 62 | t.type({ 63 | regex: t.string, 64 | label: t.string 65 | }), 66 | t.partial({ 67 | author_association: AuthorAssociation, 68 | ignore_case: t.boolean, 69 | github_release: t.boolean 70 | }) 71 | ]) 72 | 73 | const CommentChatOps = t.intersection([ 74 | t.type({ 75 | cmd: t.string, 76 | type: t.literal('comment'), 77 | comment: t.string 78 | }), 79 | t.partial({ 80 | author_association: AuthorAssociation 81 | }) 82 | ]) 83 | 84 | const LabelChatOps = t.intersection([ 85 | t.type({ 86 | cmd: t.string, 87 | type: t.literal('label'), 88 | label: t.partial({ 89 | add: t.union([t.string, t.array(t.string)]), 90 | remove: t.union([t.string, t.array(t.string)]) 91 | }) 92 | }), 93 | t.partial({ 94 | author_association: AuthorAssociation 95 | }) 96 | ]) 97 | 98 | const GenericChatOps = t.intersection([ 99 | t.type({ 100 | cmd: t.string, 101 | type: t.keyof({ 102 | close: null, 103 | none: null, 104 | assign: null, 105 | review: null 106 | }) 107 | }), 108 | t.partial({ 109 | author_association: AuthorAssociation 110 | }) 111 | ]) 112 | 113 | const ChatOps = t.union([GenericChatOps, LabelChatOps, CommentChatOps]) 114 | 115 | const Governance = t.partial({ 116 | labels: t.array(Label), 117 | captures: t.array(Capture), 118 | chat_ops: t.array(ChatOps) 119 | }) 120 | 121 | const Config = t.intersection([ 122 | t.type({ 123 | version: t.literal('v1') 124 | }), 125 | t.partial({ 126 | issue: Governance, 127 | pull_request: Governance 128 | }) 129 | ]) 130 | 131 | /* eslint no-redeclare: off */ 132 | export type Label = t.TypeOf 133 | export type Capture = t.TypeOf 134 | export type AuthorAssociation = t.TypeOf 135 | export type CommentChatOps = t.TypeOf 136 | export type LabelChatOps = t.TypeOf 137 | export type GenericChatOps = t.TypeOf 138 | export type ChatOps = t.TypeOf 139 | export type Governance = t.TypeOf 140 | export type Config = t.TypeOf 141 | 142 | function parse(content: string): Config { 143 | const config = load(content) 144 | 145 | const decoded = Config.decode(config) 146 | if (isRight(decoded)) { 147 | return decoded.right 148 | } else { 149 | throw new Error( 150 | `Config parse error:\\n${reporter.report(decoded).join('\\n')}` 151 | ) 152 | } 153 | } 154 | 155 | /** 156 | * @param client used to get governance config from 157 | * @param configPath location of the config file 158 | */ 159 | export async function getConfig( 160 | client: InstanceType, 161 | configPath: string 162 | ): Promise { 163 | const response: any = await client.repos.getContent({ 164 | owner: github.context.repo.owner, 165 | repo: github.context.repo.repo, 166 | ref: github.context.sha, 167 | path: configPath 168 | }) 169 | 170 | const content: string = Buffer.from( 171 | response.data.content, 172 | response.data.encoding 173 | ).toString() 174 | return parse(content) 175 | } 176 | -------------------------------------------------------------------------------- /src/operators/label.ts: -------------------------------------------------------------------------------- 1 | import {Label} from '../config' 2 | import {Commands} from '../command' 3 | import { 4 | addLabels, 5 | commitStatus, 6 | getLabels, 7 | postComment, 8 | removeLabels 9 | } from '../github' 10 | import * as github from '@actions/github' 11 | import {isCreatedOpened} from '../rules/ignore' 12 | import {isAuthorAssociationAllowed} from '../rules/author-association' 13 | 14 | class PrefixLabelSet { 15 | public prefix: string 16 | public needs: boolean = false 17 | public labels: Set = new Set() 18 | public last: string | undefined 19 | private existing: string[] = [] 20 | 21 | constructor(prefix: string) { 22 | this.prefix = prefix 23 | 24 | for (const label of getLabels()) { 25 | if (label === `needs/${prefix}`) { 26 | this.existing.push(label) 27 | this.needs = true 28 | } else if (label.startsWith(`${prefix}/`)) { 29 | this.existing.push(label) 30 | this.add(label) 31 | } 32 | } 33 | } 34 | 35 | remove(label: string) { 36 | this.labels.delete(label) 37 | } 38 | 39 | add(label: string) { 40 | this.labels.add(label) 41 | this.last = label 42 | } 43 | 44 | setMultiple(bool: boolean) { 45 | if (bool) { 46 | return 47 | } 48 | 49 | this.labels.clear() 50 | if (this.last) { 51 | this.labels.add(this.last) 52 | } 53 | } 54 | 55 | setNeeds(bool: boolean) { 56 | this.needs = bool && this.labels.size === 0 57 | } 58 | 59 | async persist() { 60 | const removes = [] 61 | const adds = [] 62 | 63 | for (const string of this.existing) { 64 | if (!this.labels.has(string)) { 65 | removes.push(string) 66 | } 67 | } 68 | 69 | for (const label of this.labels) { 70 | if (!this.existing.includes(label)) { 71 | adds.push(label) 72 | } 73 | } 74 | 75 | if (this.needs) { 76 | if (this.existing.includes(`needs/${this.prefix}`)) { 77 | // don't remove 78 | const index = removes.indexOf(`needs/${this.prefix}`) 79 | if (index > -1) { 80 | removes.splice(index, 1) 81 | } 82 | } else { 83 | // add missing 84 | adds.push(`needs/${this.prefix}`) 85 | } 86 | } 87 | 88 | await removeLabels(removes) 89 | await addLabels(adds) 90 | } 91 | } 92 | 93 | export default async function ( 94 | label: Label, 95 | commands: Commands 96 | ): Promise { 97 | const labelSet = new PrefixLabelSet(label.prefix) 98 | 99 | /** 100 | * Check if labeled is required 101 | */ 102 | function needs() { 103 | const needCommands = [ 104 | ...commands.prefix(`/needs ${label.prefix}`), 105 | ...commands.prefix(`/need ${label.prefix}`) 106 | ] 107 | 108 | if (needCommands.length) { 109 | return true 110 | } 111 | 112 | if (labelSet.needs) { 113 | return true 114 | } 115 | 116 | return !!label.needs 117 | } 118 | 119 | /** 120 | * Compute labels to add and remove 121 | * @return whether any prefixed label is present 122 | */ 123 | function computeLabels() { 124 | const removing = commands 125 | .prefix(`/${label.prefix}-remove`) 126 | .flatMap(add => add.args.map(value => `${label.prefix}/${value}`)) 127 | 128 | for (const value of removing) { 129 | labelSet.remove(value) 130 | } 131 | 132 | const adding = commands 133 | .prefix(`/${label.prefix}`) 134 | .flatMap(add => 135 | add.args 136 | .filter(value => label.list.includes(value)) 137 | .map(value => `${label.prefix}/${value}`) 138 | ) 139 | 140 | for (const value of adding) { 141 | labelSet.add(value) 142 | } 143 | } 144 | 145 | if ( 146 | isCreatedOpened() && 147 | isAuthorAssociationAllowed(label.author_association) 148 | ) { 149 | computeLabels() 150 | } 151 | 152 | labelSet.setMultiple(label.multiple === undefined || label.multiple) 153 | labelSet.setNeeds(needs()) 154 | await labelSet.persist() 155 | 156 | if (labelSet.needs) { 157 | await sendComment(label) 158 | } 159 | 160 | await sendStatus(label, !labelSet.needs) 161 | } 162 | 163 | /** 164 | * This only run on opened action so that it's not duplicated everytime a user comment. 165 | * 166 | * @param label to send comment to 167 | */ 168 | async function sendComment(label: Label) { 169 | if (github.context.payload.action !== 'opened') { 170 | return 171 | } 172 | 173 | if (typeof label.needs === 'boolean') { 174 | return 175 | } 176 | 177 | // Post comment if needs.comment is available 178 | const comment = label.needs?.comment 179 | if (comment) { 180 | await postComment(comment) 181 | } 182 | } 183 | 184 | async function sendStatus(label: Label, success: boolean) { 185 | if (typeof label.needs === 'boolean') { 186 | return 187 | } 188 | 189 | const status = label.needs?.status 190 | if (!status) { 191 | return 192 | } 193 | 194 | function description(): string | undefined { 195 | if (typeof status?.description === 'string') { 196 | return status?.description 197 | } 198 | 199 | if (success) { 200 | return status?.description?.success 201 | } 202 | 203 | return status?.description?.failure 204 | } 205 | 206 | function state(): 'success' | 'failure' | 'pending' { 207 | if (success) { 208 | return 'success' 209 | } 210 | 211 | if (typeof status?.description === 'string') { 212 | return 'failure' 213 | } 214 | 215 | if (typeof status?.description?.failure === 'string') { 216 | return 'failure' 217 | } 218 | 219 | return 'pending' 220 | } 221 | 222 | await commitStatus(status.context, state(), description(), status.url) 223 | } 224 | -------------------------------------------------------------------------------- /__tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | import {commitStatus, postComment} from '../src/github' 2 | import * as core from '@actions/core' 3 | import * as github from '@actions/github' 4 | import nock from 'nock' 5 | 6 | const postComments = jest.fn() 7 | const getPulls = jest.fn() 8 | const postStatus = jest.fn() 9 | 10 | beforeAll(() => { 11 | jest.spyOn(core, 'getInput').mockImplementation(name => { 12 | return 'config-path/location.yml' 13 | }) 14 | 15 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 16 | return { 17 | owner: 'owner', 18 | repo: 'repo' 19 | } 20 | }) 21 | 22 | nock('https://api.github.com') 23 | .post('/repos/owner/repo/issues/1/comments') 24 | .reply(200, function (_, body) { 25 | postComments(body) 26 | return {} 27 | }) 28 | .persist() 29 | 30 | nock('https://api.github.com') 31 | .get('/repos/owner/repo/pulls/1') 32 | .reply(200, function (_, body) { 33 | getPulls() 34 | return { 35 | head: { 36 | sha: 'abc' 37 | } 38 | } 39 | }) 40 | .persist() 41 | 42 | nock('https://api.github.com') 43 | .post(/\/repos\/owner\/repo\/statuses\/.+/) 44 | .reply(200, function (_, body) { 45 | postStatus(body) 46 | return {} 47 | }) 48 | .persist() 49 | }) 50 | 51 | afterAll(() => { 52 | jest.clearAllMocks() 53 | }) 54 | 55 | it('pull_request should comment as expected', async () => { 56 | github.context.payload = { 57 | pull_request: { 58 | number: 1, 59 | labels: [] 60 | } 61 | } 62 | 63 | await postComment('a') 64 | await expect(postComments).toBeCalled() 65 | }) 66 | 67 | it('issue should comment as expected', async () => { 68 | github.context.payload = { 69 | issue: { 70 | number: 1, 71 | labels: [] 72 | } 73 | } 74 | 75 | await postComment('a') 76 | await expect(postComments).toBeCalled() 77 | }) 78 | 79 | it('Organization: should format details as expected', async () => { 80 | github.context.payload = { 81 | issue: { 82 | number: 1, 83 | labels: [] 84 | }, 85 | repository: { 86 | name: 'Hello-World', 87 | owner: { 88 | login: 'Codertocat', 89 | html_url: 'https://github.com/Codertocat/', 90 | type: 'Organization' 91 | }, 92 | default_branch: 'main', 93 | html_url: 'https://github.com/Codertocat/Hello-World' 94 | } 95 | } 96 | 97 | await postComment('a') 98 | await expect(postComments).toHaveBeenCalledWith({ 99 | body: 100 | 'a' + 101 | '\n' + 102 | '
Details' + 103 | '\n\n' + 104 | 'I am a bot created to help the [Codertocat](https://github.com/Codertocat/) developers manage community feedback and contributions. You can check out my [manifest file](https://github.com/Codertocat/Hello-World/blob/main/config-path/location.yml) to understand my behavior and what I can do. If you want to use this for your project, you can check out the [BirthdayResearch/oss-governance-bot](https://github.com/BirthdayResearch/oss-governance-bot) repository.' + 105 | '\n\n' + 106 | '
' 107 | }) 108 | }) 109 | 110 | it('User: should format details as expected', async () => { 111 | github.context.payload = { 112 | issue: { 113 | number: 1, 114 | labels: [] 115 | }, 116 | repository: { 117 | name: 'Hello-World', 118 | owner: { 119 | login: 'Codertocat', 120 | html_url: 'https://github.com/Codertocat/', 121 | type: 'User' 122 | }, 123 | default_branch: 'main', 124 | html_url: 'https://github.com/Codertocat/Hello-World' 125 | } 126 | } 127 | 128 | await postComment('b') 129 | await expect(postComments).toHaveBeenCalledWith({ 130 | body: 131 | 'b' + 132 | '\n' + 133 | '
Details' + 134 | '\n\n' + 135 | 'I am a bot created to help [Codertocat](https://github.com/Codertocat/) manage community feedback and contributions. You can check out my [manifest file](https://github.com/Codertocat/Hello-World/blob/main/config-path/location.yml) to understand my behavior and what I can do. If you want to use this for your project, you can check out the [BirthdayResearch/oss-governance-bot](https://github.com/BirthdayResearch/oss-governance-bot) repository.' + 136 | '\n\n' + 137 | '
' 138 | }) 139 | }) 140 | 141 | describe('commit status', () => { 142 | it('should resolve head.sha if not available', async () => { 143 | github.context.payload = { 144 | comment: { 145 | id: 1 146 | }, 147 | issue: { 148 | number: 1, 149 | pull_request: { 150 | diff_url: 151 | 'https://github.com/BirthdayResearch/oss-governance-bot/pull/9.diff', 152 | html_url: 153 | 'https://github.com/BirthdayResearch/oss-governance-bot/pull/9', 154 | patch_url: 155 | 'https://github.com/BirthdayResearch/oss-governance-bot/pull/9.patch', 156 | url: 157 | 'https://api.github.com/repos/BirthdayResearch/oss-governance-bot/pulls/9' 158 | } 159 | } 160 | } 161 | 162 | await commitStatus('Hey', 'pending', 'descriptions') 163 | await expect(getPulls).toHaveBeenCalledTimes(1) 164 | await expect(postStatus).toHaveBeenCalledWith({ 165 | context: 'Hey', 166 | description: 'descriptions', 167 | state: 'pending' 168 | }) 169 | }) 170 | 171 | it('open pull_request will not have head.sha ', async () => { 172 | github.context.payload = { 173 | pull_request: { 174 | number: 1, 175 | head: { 176 | sha: '123' 177 | } 178 | } 179 | } 180 | 181 | await commitStatus('Hey', 'pending', 'descriptions') 182 | await expect(getPulls).not.toHaveBeenCalled() 183 | await expect(postStatus).toHaveBeenCalledWith({ 184 | context: 'Hey', 185 | description: 'descriptions', 186 | state: 'pending' 187 | }) 188 | }) 189 | }) 190 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import nock from 'nock' 4 | import fs from 'fs' 5 | 6 | const info = jest.fn() 7 | const error = jest.fn() 8 | const intercepted = jest.fn() 9 | 10 | beforeEach(() => { 11 | github.context.eventName = 'issue_comment' 12 | github.context.payload = { 13 | action: 'created', 14 | issue: { 15 | number: 1 16 | }, 17 | sender: { 18 | type: 'User' 19 | } 20 | } 21 | 22 | jest.spyOn(core, 'info').mockImplementation(info) 23 | jest.spyOn(core, 'error').mockImplementation(error) 24 | jest.spyOn(core, 'warning').mockImplementation(jest.fn()) 25 | jest.spyOn(core, 'debug').mockImplementation(jest.fn()) 26 | jest.spyOn(core, 'setFailed').mockImplementation(jest.fn()) 27 | 28 | jest.spyOn(core, 'getInput').mockImplementation(name => { 29 | switch (name) { 30 | case 'github-token': 31 | return 'token' 32 | case 'config-path': 33 | return '.github/governance.yml' 34 | default: 35 | return '' 36 | } 37 | }) 38 | 39 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 40 | return { 41 | owner: 'owner', 42 | repo: 'repo' 43 | } 44 | }) 45 | 46 | const contentsRegex = /\/repos\/owner\/repo\/contents\/([^?]+).*/ 47 | nock('https://api.github.com') 48 | .get(contentsRegex) 49 | .reply(200, function () { 50 | const path = contentsRegex.exec(this.req.path)?.[1] || '' 51 | return { 52 | content: fs.readFileSync(decodeURIComponent(path), 'utf8'), 53 | encoding: 'utf-8' 54 | } 55 | }) 56 | .persist() 57 | 58 | nock('https://api.github.com') 59 | .get(/.+/) 60 | .reply(200, () => { 61 | intercepted() 62 | return {} 63 | }) 64 | .persist() 65 | nock('https://api.github.com') 66 | .post(/.+/) 67 | .reply(200, () => { 68 | intercepted() 69 | return {} 70 | }) 71 | .persist() 72 | nock('https://api.github.com') 73 | .delete(/.+/) 74 | .reply(200, () => { 75 | intercepted() 76 | return {} 77 | }) 78 | .persist() 79 | nock('https://api.github.com') 80 | .patch(/.+/) 81 | .reply(200, () => { 82 | intercepted() 83 | return {} 84 | }) 85 | .persist() 86 | }) 87 | 88 | describe('getGovernance', () => { 89 | it('should error not get from context', async function () { 90 | jest.setTimeout(10000) 91 | github.context.payload = {} 92 | 93 | const {getGovernance} = require('../src/main') 94 | 95 | await expect(getGovernance()).rejects.toThrow( 96 | 'Could not get pull_request or issue from context' 97 | ) 98 | }) 99 | 100 | it('should be issue', async function () { 101 | jest.setTimeout(10000) 102 | github.context.payload = { 103 | issue: { 104 | number: 1, 105 | state: 'open' 106 | } 107 | } 108 | 109 | const {getGovernance} = require('../src/main') 110 | const governance = await getGovernance() 111 | 112 | expect(governance?.labels?.length).toBe(2) 113 | }) 114 | 115 | it('should be pull request', async function () { 116 | jest.setTimeout(10000) 117 | github.context.payload = { 118 | pull_request: { 119 | number: 1 120 | } 121 | } 122 | 123 | const {getGovernance} = require('../src/main') 124 | const governance = await getGovernance() 125 | expect(governance?.labels?.length).toBe(2) 126 | }) 127 | 128 | describe('comment', () => { 129 | it('should be issue', async function () { 130 | jest.setTimeout(10000) 131 | github.context.payload = { 132 | comment: { 133 | id: 1 134 | }, 135 | issue: { 136 | number: 1, 137 | state: 'open' 138 | } 139 | } 140 | 141 | const {getGovernance} = require('../src/main') 142 | const governance = await getGovernance() 143 | 144 | expect(governance?.labels?.length).toBe(2) 145 | }) 146 | 147 | it('should be pull request', async function () { 148 | jest.setTimeout(10000) 149 | github.context.payload = { 150 | comment: { 151 | id: 1 152 | }, 153 | issue: { 154 | number: 1, 155 | pull_request: { 156 | diff_url: 157 | 'https://github.com/BirthdayResearch/oss-governance-bot/pull/9.diff', 158 | html_url: 159 | 'https://github.com/BirthdayResearch/oss-governance-bot/pull/9', 160 | patch_url: 161 | 'https://github.com/BirthdayResearch/oss-governance-bot/pull/9.patch', 162 | url: 163 | 'https://api.github.com/repos/BirthdayResearch/oss-governance-bot/pulls/9' 164 | } 165 | } 166 | } 167 | 168 | const {getGovernance} = require('../src/main') 169 | const governance = await getGovernance() 170 | expect(governance?.labels?.length).toBe(2) 171 | }) 172 | }) 173 | }) 174 | 175 | describe('runGovernance', () => { 176 | it('should be issue', async function () { 177 | jest.setTimeout(10000) 178 | github.context.payload = { 179 | issue: { 180 | number: 1 181 | } 182 | } 183 | 184 | const {runGovernance} = require('../src/main') 185 | await runGovernance() 186 | await expect(info).toHaveBeenCalledWith('main: completed operations') 187 | await expect(intercepted).toHaveBeenCalled() 188 | }) 189 | 190 | it('should be pull request', async function () { 191 | jest.setTimeout(10000) 192 | github.context.payload = { 193 | pull_request: { 194 | number: 1, 195 | head: { 196 | sha: '123' 197 | } 198 | } 199 | } 200 | 201 | const {runGovernance} = require('../src/main') 202 | await runGovernance() 203 | await expect(info).toHaveBeenCalledWith('main: completed operations') 204 | await expect(intercepted).toHaveBeenCalled() 205 | }) 206 | 207 | it('should return no governance', async function () { 208 | jest.setTimeout(10000) 209 | github.context.payload = { 210 | pull_request: { 211 | number: 1, 212 | head: { 213 | sha: '123' 214 | } 215 | } 216 | } 217 | jest.spyOn(core, 'getInput').mockImplementation(name => { 218 | switch (name) { 219 | case 'github-token': 220 | return 'token' 221 | case 'config-path': 222 | return '__tests__/fixtures/config-valid/version.yml' 223 | default: 224 | return '' 225 | } 226 | }) 227 | 228 | const {runGovernance} = require('../src/main') 229 | await runGovernance() 230 | await expect(info).toHaveBeenCalledTimes(1) 231 | await expect(intercepted).not.toHaveBeenCalled() 232 | }) 233 | }) 234 | -------------------------------------------------------------------------------- /src/github.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import * as core from '@actions/core' 3 | import {GitHub} from '@actions/github/lib/utils' 4 | 5 | export function initClient( 6 | token: string = core.getInput('github-token') 7 | ): InstanceType { 8 | return github.getOctokit(token) 9 | } 10 | 11 | export async function getBotUserId(): Promise { 12 | core.info('github-client: getBotUserId') 13 | const client = initClient() 14 | const user = await client.users.getAuthenticated() 15 | return user.data.id 16 | } 17 | 18 | function getNumber(): number | undefined { 19 | return ( 20 | github.context.payload.pull_request?.number || 21 | github.context.payload.issue?.number 22 | ) 23 | } 24 | 25 | export function getLabels(): string[] { 26 | const contents = 27 | github.context.payload.pull_request || github.context.payload.issue 28 | return contents?.labels?.map(({name}: {name: string}) => name) || [] 29 | } 30 | 31 | export async function addLabels(labels: string[]): Promise { 32 | if (!labels.length) return 33 | 34 | core.info('github-client: addLabels') 35 | const client = initClient() 36 | 37 | await client.issues.addLabels({ 38 | owner: github.context.repo.owner, 39 | repo: github.context.repo.repo, 40 | issue_number: getNumber()!, 41 | labels: labels 42 | }) 43 | } 44 | 45 | export async function removeLabels(labels: string[]): Promise { 46 | if (!labels.length) return 47 | 48 | core.info('github-client: removeLabels') 49 | const client = initClient() 50 | 51 | await Promise.all( 52 | labels.map(name => 53 | client.issues.removeLabel({ 54 | owner: github.context.repo.owner, 55 | repo: github.context.repo.repo, 56 | issue_number: getNumber()!, 57 | name: name 58 | }) 59 | ) 60 | ) 61 | } 62 | 63 | /** 64 | * Comment details. 65 | */ 66 | function getDetails(): string { 67 | const repository = github.context.payload.repository 68 | 69 | const configPath = core.getInput('config-path', {required: true}) 70 | const repoUrl = repository?.html_url 71 | const owner = repository?.owner 72 | const branch = repository?.default_branch 73 | 74 | let details = '' 75 | details += '\n' 76 | details += '
Details' 77 | details += '\n\n' 78 | 79 | if (owner?.type === 'Organization') { 80 | details += `I am a bot created to help the [${owner?.login}](${owner?.html_url}) developers manage community feedback and contributions.` 81 | } else { 82 | details += `I am a bot created to help [${owner?.login}](${owner?.html_url}) manage community feedback and contributions.` 83 | } 84 | 85 | details += ' ' 86 | details += `You can check out my [manifest file](${repoUrl}/blob/${branch}/${configPath}) to understand my behavior and what I can do.` 87 | details += ' ' 88 | details += 89 | 'If you want to use this for your project, you can check out the [BirthdayResearch/oss-governance-bot](https://github.com/BirthdayResearch/oss-governance-bot) repository.' 90 | details += '\n\n' 91 | details += '
' 92 | return details 93 | } 94 | 95 | function getIssueUserLogin(): string | undefined { 96 | if (github.context.payload.issue) { 97 | return github.context.payload.issue.user?.login 98 | } 99 | if (github.context.payload.pull_request) { 100 | return github.context.payload.pull_request.user?.login 101 | } 102 | } 103 | 104 | /** 105 | * Comment to post with added details. 106 | * 107 | * @param body comment 108 | */ 109 | export async function postComment(body: string) { 110 | core.info('github-client: postComment') 111 | const client = initClient() 112 | 113 | body = body.replace('$AUTHOR', github.context.payload.sender?.login) 114 | body = body.replace('$ISSUE_AUTHOR', getIssueUserLogin()!) 115 | body += getDetails() 116 | 117 | await client.issues.createComment({ 118 | owner: github.context.repo.owner, 119 | repo: github.context.repo.repo, 120 | issue_number: getNumber()!, 121 | body: body 122 | }) 123 | } 124 | 125 | export async function patchIssue(changes: any) { 126 | core.info('github-client: patchIssue') 127 | const client = initClient() 128 | 129 | await client.issues.update({ 130 | owner: github.context.repo.owner, 131 | repo: github.context.repo.repo, 132 | issue_number: getNumber()!, 133 | ...changes 134 | }) 135 | } 136 | 137 | export async function assign(assignees: string[]) { 138 | if (!assignees.length) return 139 | 140 | core.info('github-client: assign') 141 | const client = initClient() 142 | 143 | await client.issues.addAssignees({ 144 | owner: github.context.repo.owner, 145 | repo: github.context.repo.repo, 146 | issue_number: getNumber()!, 147 | assignees: assignees 148 | }) 149 | } 150 | 151 | export async function requestReviewers(reviewers: string[]) { 152 | if (!reviewers.length) return 153 | 154 | core.info('github-client: requestReviewers') 155 | const client = initClient() 156 | 157 | await client.pulls.requestReviewers({ 158 | owner: github.context.repo.owner, 159 | repo: github.context.repo.repo, 160 | pull_number: getNumber()!, 161 | reviewers: reviewers 162 | }) 163 | } 164 | 165 | export async function commitStatus( 166 | context: string, 167 | state: 'success' | 'failure' | 'pending', 168 | description?: string, 169 | url?: string 170 | ): Promise { 171 | core.info('github-client: commitStatus') 172 | const client = initClient() 173 | 174 | async function sendStatus(sha: string) { 175 | await client.repos.createCommitStatus({ 176 | owner: github.context.repo.owner, 177 | repo: github.context.repo.repo, 178 | sha: sha, 179 | context: context, 180 | state: state, 181 | description: description, 182 | target_url: url 183 | }) 184 | } 185 | 186 | if (github.context.payload.pull_request) { 187 | await sendStatus(github.context.payload.pull_request?.head.sha as string) 188 | return 189 | } 190 | 191 | if ( 192 | github.context.payload.comment && 193 | github.context.payload.issue?.pull_request 194 | ) { 195 | const response = await client.pulls.get({ 196 | owner: github.context.repo.owner, 197 | repo: github.context.repo.repo, 198 | pull_number: getNumber()! 199 | }) 200 | 201 | await sendStatus(response.data.head.sha) 202 | } 203 | } 204 | 205 | export async function hasReleaseByTag(tag: string): Promise { 206 | core.info('github-client: getReleaseByTag') 207 | const client = initClient() 208 | 209 | const release = client.repos.getReleaseByTag({ 210 | owner: github.context.repo.owner, 211 | repo: github.context.repo.repo, 212 | tag: tag 213 | }) 214 | 215 | return release.then(() => true).catch(() => false) 216 | } 217 | -------------------------------------------------------------------------------- /__tests__/rules/author-association.test.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import {AuthorAssociation} from '../../src/config' 3 | import {isAuthorAssociationAllowed} from '../../src/rules/author-association' 4 | 5 | function expectAssociation( 6 | association: AuthorAssociation | undefined, 7 | type: string, 8 | target: string = 'comment' 9 | ) { 10 | github.context.payload = {} 11 | github.context.payload[target] = { 12 | id: 1, 13 | author_association: type 14 | } 15 | 16 | return expect(isAuthorAssociationAllowed(association)) 17 | } 18 | 19 | describe('all association', function () { 20 | it('should empty true', function () { 21 | expectAssociation(undefined, 'OWNER').toBe(true) 22 | }) 23 | 24 | it('invalid type should be false', function () { 25 | expectAssociation({}, 'INVALID').toBe(false) 26 | }) 27 | 28 | it('should fail invalid match', function () { 29 | expectAssociation({collaborator: false}, 'OWNER').toBe(false) 30 | }) 31 | 32 | describe('COLLABORATOR', () => { 33 | it('should collaborator false', function () { 34 | expectAssociation({collaborator: false}, 'COLLABORATOR').toBe(false) 35 | }) 36 | 37 | it('should collaborator true', function () { 38 | expectAssociation({collaborator: true}, 'COLLABORATOR').toBe(true) 39 | }) 40 | }) 41 | 42 | describe('CONTRIBUTOR', () => { 43 | it('should contributor false', function () { 44 | expectAssociation({contributor: false}, 'CONTRIBUTOR').toBe(false) 45 | }) 46 | 47 | it('should contributor true', function () { 48 | expectAssociation({contributor: true}, 'CONTRIBUTOR').toBe(true) 49 | }) 50 | }) 51 | 52 | describe('FIRST_TIMER', () => { 53 | it('should first_timer false', function () { 54 | expectAssociation({first_timer: false}, 'FIRST_TIMER').toBe(false) 55 | }) 56 | 57 | it('should first_timer true', function () { 58 | expectAssociation({first_timer: true}, 'FIRST_TIMER').toBe(true) 59 | }) 60 | }) 61 | 62 | describe('FIRST_TIME_CONTRIBUTOR', () => { 63 | it('should first_time_contributor false', function () { 64 | expectAssociation( 65 | {first_time_contributor: false}, 66 | 'FIRST_TIME_CONTRIBUTOR' 67 | ).toBe(false) 68 | }) 69 | 70 | it('should first_time_contributor true', function () { 71 | expectAssociation( 72 | {first_time_contributor: true}, 73 | 'FIRST_TIME_CONTRIBUTOR' 74 | ).toBe(true) 75 | }) 76 | }) 77 | 78 | describe('MANNEQUIN', () => { 79 | it('should mannequin false', function () { 80 | expectAssociation({mannequin: false}, 'MANNEQUIN').toBe(false) 81 | }) 82 | 83 | it('should mannequin true', function () { 84 | expectAssociation({mannequin: true}, 'MANNEQUIN').toBe(true) 85 | }) 86 | }) 87 | 88 | describe('MEMBER', () => { 89 | it('should member false', function () { 90 | expectAssociation({member: false}, 'MEMBER').toBe(false) 91 | }) 92 | 93 | it('should member true', function () { 94 | expectAssociation({member: true}, 'MEMBER').toBe(true) 95 | }) 96 | }) 97 | 98 | describe('NONE', () => { 99 | it('should none false', function () { 100 | expectAssociation({none: false}, 'NONE').toBe(false) 101 | }) 102 | 103 | it('should none true', function () { 104 | expectAssociation({none: true}, 'NONE').toBe(true) 105 | }) 106 | }) 107 | 108 | describe('OWNER', () => { 109 | it('should owner false', function () { 110 | expectAssociation({owner: false}, 'OWNER').toBe(false) 111 | }) 112 | 113 | it('should owner true', function () { 114 | expectAssociation({owner: true}, 'OWNER').toBe(true) 115 | }) 116 | }) 117 | }) 118 | 119 | describe('pull_request', function () { 120 | it('should owner true', function () { 121 | expectAssociation({owner: true}, 'OWNER', 'pull_request').toBe(true) 122 | }) 123 | }) 124 | 125 | describe('issue', function () { 126 | it('should owner true', function () { 127 | expectAssociation({owner: true}, 'OWNER', 'issue').toBe(true) 128 | }) 129 | }) 130 | 131 | describe('comment', function () { 132 | it('should owner true', function () { 133 | expectAssociation({owner: true}, 'OWNER', 'comment').toBe(true) 134 | }) 135 | 136 | it('should take comment priority owner', function () { 137 | github.context.payload = { 138 | comment: { 139 | id: 1, 140 | author_association: 'OWNER' 141 | }, 142 | issue: { 143 | number: 1, 144 | author_association: 'MEMBER' 145 | } 146 | } 147 | 148 | expect(isAuthorAssociationAllowed({owner: true})).toBe(true) 149 | }) 150 | 151 | it('should take comment priority member', function () { 152 | github.context.payload = { 153 | comment: { 154 | id: 1, 155 | author_association: 'MEMBER' 156 | }, 157 | issue: { 158 | number: 1, 159 | author_association: 'MEMBER' 160 | } 161 | } 162 | 163 | expect(isAuthorAssociationAllowed({owner: true})).toBe(false) 164 | }) 165 | }) 166 | 167 | describe('comment/issue author', function () { 168 | it('comment author is issue author', function () { 169 | github.context.payload = { 170 | comment: { 171 | id: 1, 172 | author_association: 'OWNER', 173 | user: { 174 | login: 'DeFiCh', 175 | type: 'ORGANIZATION' 176 | } 177 | }, 178 | issue: { 179 | number: 1, 180 | author_association: 'OWNER', 181 | user: { 182 | login: 'DeFiCh' 183 | } 184 | } 185 | } 186 | 187 | expect(isAuthorAssociationAllowed({author: true})).toBe(true) 188 | }) 189 | 190 | it('comment author is not issue author', function () { 191 | github.context.payload = { 192 | comment: { 193 | id: 1, 194 | author_association: 'OWNER', 195 | user: { 196 | login: 'DeFiCh', 197 | type: 'ORGANIZATION' 198 | } 199 | }, 200 | issue: { 201 | number: 1, 202 | author_association: 'MEMBER', 203 | user: { 204 | login: 'DeFiChMember' 205 | } 206 | } 207 | } 208 | 209 | expect(isAuthorAssociationAllowed({author: true})).toBe(false) 210 | }) 211 | 212 | it('comment author is not issue author but is member', function () { 213 | github.context.payload = { 214 | comment: { 215 | id: 1, 216 | author_association: 'MEMBER', 217 | user: { 218 | login: 'DeFiChMember', 219 | type: 'USER' 220 | } 221 | }, 222 | issue: { 223 | number: 1, 224 | author_association: 'OWNER', 225 | user: { 226 | login: 'DeFiCh', 227 | type: 'ORGANIZATION' 228 | } 229 | } 230 | } 231 | 232 | expect(isAuthorAssociationAllowed({author: true, member: true})).toBe(true) 233 | }) 234 | }) 235 | -------------------------------------------------------------------------------- /__tests__/command.test.ts: -------------------------------------------------------------------------------- 1 | import command, {Commands, getCommands} from '../src/command' 2 | import * as github from '@actions/github' 3 | 4 | describe('getCommands', () => { 5 | function expectCommands(body: string, commands: string[]) { 6 | github.context.payload = { 7 | issue: { 8 | number: 1, 9 | body: body 10 | } 11 | } 12 | 13 | expect(getCommands().map(cmd => cmd.text)).toStrictEqual(commands) 14 | } 15 | 16 | it('multi line', () => { 17 | expectCommands('/area ui/ux\n/needs label', ['/area ui/ux', '/needs label']) 18 | }) 19 | 20 | it('ignore text', () => { 21 | expectCommands('just some text', []) 22 | }) 23 | 24 | it('ignore after start of line', () => { 25 | expectCommands('just some text /close', []) 26 | }) 27 | 28 | it('authors 2nd line', () => { 29 | expectCommands('just some text\n/review @me @you', ['/review @me @you']) 30 | }) 31 | 32 | it('authors 1st line', () => { 33 | expectCommands('/review @me @you\njust some text', ['/review @me @you']) 34 | }) 35 | 36 | it('/middle', function () { 37 | expectCommands('first\n/middle\nthird', ['/middle']) 38 | }) 39 | 40 | it('/second /third', function () { 41 | expectCommands('first\n/second\n/third', ['/second', '/third']) 42 | }) 43 | 44 | it('brbr /cmd', function () { 45 | expectCommands('\n\n/second', ['/second']) 46 | }) 47 | 48 | describe('issue', () => { 49 | function expectCommands(body: string, commands: string[]) { 50 | github.context.payload = { 51 | issue: { 52 | number: 1, 53 | body: body 54 | } 55 | } 56 | 57 | expect(getCommands().map(value => value.text)).toStrictEqual(commands) 58 | } 59 | 60 | it('/issue basic', () => { 61 | expectCommands('/issue basic', ['/issue basic']) 62 | }) 63 | }) 64 | 65 | describe('pull_request', () => { 66 | function expectCommands(body: string, commands: string[]) { 67 | github.context.payload = { 68 | pull_request: { 69 | number: 1, 70 | body: body 71 | } 72 | } 73 | 74 | expect(getCommands().map(value => value.text)).toStrictEqual(commands) 75 | } 76 | 77 | it('/pull request', () => { 78 | expectCommands('/pull request', ['/pull request']) 79 | }) 80 | }) 81 | 82 | describe('issue_comment', () => { 83 | function expectCommands(body: string, commands: string[]) { 84 | github.context.payload = { 85 | comment: { 86 | id: 1, 87 | body: body 88 | } 89 | } 90 | 91 | expect(getCommands().map(value => value.text)).toStrictEqual(commands) 92 | } 93 | 94 | it('/issue', () => { 95 | expectCommands('/issue 123', ['/issue 123']) 96 | }) 97 | }) 98 | }) 99 | 100 | describe('commands', () => { 101 | async function getCommands(body: string): Promise { 102 | github.context.payload = { 103 | issue: { 104 | number: 1, 105 | body: body 106 | } 107 | } 108 | 109 | return await command() 110 | } 111 | 112 | it('should multi line', async () => { 113 | const commands = await getCommands('/area ui/ux\n/needs label') 114 | 115 | expect(commands.prefix('/area').length).toBeTruthy() 116 | expect(commands.prefix('/area')[0].args[0]).toBe('ui/ux') 117 | expect(commands.prefix('/needs').length).toBeTruthy() 118 | expect(commands.prefix('/needs')[0].args[0]).toBe('label') 119 | }) 120 | 121 | it('should authors', async () => { 122 | const commands = await getCommands('/review @jess @tommy') 123 | 124 | expect(commands.prefix('@jess').length).toBeFalsy() 125 | expect(commands.prefix('@tommy').length).toBeFalsy() 126 | expect(commands.prefix('/review').length).toBeTruthy() 127 | expect(commands.prefix('/review @jess').length).toBeTruthy() 128 | expect(commands.prefix('/review @jess')[0].args).toStrictEqual(['@tommy']) 129 | expect(commands.prefix('/review')[0].args).toStrictEqual([ 130 | '@jess', 131 | '@tommy' 132 | ]) 133 | }) 134 | 135 | it('should args', async () => { 136 | const commands = await getCommands('no\n/why a b c\nnah') 137 | 138 | expect(commands.prefix('no').length).toBeFalsy() 139 | expect(commands.prefix('nah').length).toBeFalsy() 140 | expect(commands.prefix('/why').length).toBeTruthy() 141 | expect(commands.prefix('/why a').length).toBeTruthy() 142 | expect(commands.prefix('/why a b').length).toBeTruthy() 143 | expect(commands.prefix('/why a b c').length).toBeTruthy() 144 | expect(commands.prefix('/why')[0].args).toStrictEqual(['a', 'b', 'c']) 145 | expect(commands.prefix('/why a')[0].args).toStrictEqual(['b', 'c']) 146 | }) 147 | 148 | describe('issue', () => { 149 | it('should /issue abc', async function () { 150 | github.context.payload = { 151 | issue: { 152 | number: 1, 153 | body: '/issue abc' 154 | } 155 | } 156 | 157 | const commands = await command() 158 | expect(commands.prefix('/not abc').length).toBeFalsy() 159 | expect(commands.prefix('/issue abc').length).toBeTruthy() 160 | expect(commands.prefix('/issue').length).toBeTruthy() 161 | expect(commands.prefix('/ussue abc 123').length).toBeFalsy() 162 | }) 163 | }) 164 | 165 | describe('pull_request', () => { 166 | it('should /pull request', async function () { 167 | github.context.payload = { 168 | pull_request: { 169 | number: 1, 170 | body: '/pull request' 171 | } 172 | } 173 | 174 | const commands = await command() 175 | expect(commands.prefix('/not request').length).toBeFalsy() 176 | expect(commands.prefix('/pull request').length).toBeTruthy() 177 | expect(commands.prefix('/pull').length).toBeTruthy() 178 | expect(commands.prefix('/pull request 123').length).toBeFalsy() 179 | }) 180 | }) 181 | 182 | describe('issue_comment', () => { 183 | it('should /issue 123', async () => { 184 | github.context.payload = { 185 | comment: { 186 | id: 1, 187 | body: '/issue 123' 188 | } 189 | } 190 | 191 | const commands = await command() 192 | expect(commands.prefix('/issue not').length).toBeFalsy() 193 | expect(commands.prefix('/issue').length).toBeTruthy() 194 | expect(commands.prefix('/issue 123').length).toBeTruthy() 195 | expect(commands.prefix('/issue 1234').length).toBeFalsy() 196 | }) 197 | }) 198 | }) 199 | 200 | describe('ignore comments', () => { 201 | async function getCommands(body: string): Promise { 202 | github.context.payload = { 203 | issue: { 204 | number: 1, 205 | body: body 206 | } 207 | } 208 | 209 | return await command() 210 | } 211 | 212 | it('should ignore comments for CR', async () => { 213 | const commands = await getCommands('' + '') 214 | 215 | expect(commands.prefix('/needs').length).toBeFalsy() 216 | }) 217 | 218 | it('should ignore comments for LF', async () => { 219 | const commands = await getCommands('' + '') 220 | 221 | expect(commands.prefix('/needs').length).toBeFalsy() 222 | }) 223 | 224 | it('should ignore comments for CR + LF', async () => { 225 | const commands = await getCommands( 226 | '' + '' 227 | ) 228 | 229 | expect(commands.prefix('/needs').length).toBeFalsy() 230 | }) 231 | 232 | it('should ignore comments longer', async () => { 233 | const body = 234 | '\r\n' + 235 | '\r\n' + 236 | '#### What kind of PR is this?:\r\n' + 237 | '\r\n' + 245 | '\r\n' + 246 | '/kind \r\n' + 247 | '\r\n' + 248 | '#### What this PR does / why we need it:\r\n' + 249 | '\r\n' + 250 | '#### Which issue(s) does this PR fixes?:\r\n' + 251 | '\r\n' + 255 | 'Fixes #\r\n' + 256 | '\r\n' + 257 | '#### Additional comments?:\r\n' 258 | 259 | const commands = await getCommands(body) 260 | expect(commands.commands.length).toBe(1) 261 | }) 262 | }) 263 | -------------------------------------------------------------------------------- /__tests__/operators/capture.test.ts: -------------------------------------------------------------------------------- 1 | import * as core from "@actions/core"; 2 | import * as github from "@actions/github"; 3 | import nock from "nock"; 4 | import capture from "../../src/operators/capture"; 5 | 6 | const postLabels = jest.fn() 7 | 8 | beforeAll(() => { 9 | jest.spyOn(core, 'getInput').mockImplementation(name => { 10 | return 'token' 11 | }) 12 | 13 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 14 | return { 15 | owner: 'owner', 16 | repo: 'repo' 17 | } 18 | }) 19 | 20 | nock('https://api.github.com') 21 | .post('/repos/owner/repo/issues/1/labels') 22 | .reply(200, function (_, body) { 23 | postLabels(body) 24 | return {} 25 | }).persist() 26 | }) 27 | 28 | afterAll(() => { 29 | jest.clearAllMocks() 30 | }) 31 | 32 | describe('pr/issue', () => { 33 | it('should capture os/mac regex', async () => { 34 | github.context.eventName = 'issues' 35 | github.context.payload = { 36 | action: 'opened', 37 | issue: { 38 | number: 1, 39 | labels: [], 40 | body: '- Version: 1.0.0\r\n- Operating System:mac' 41 | } 42 | } 43 | await capture({ 44 | regex: "- Operating System: *(macos|mac) *", 45 | label: 'os/mac' 46 | }) 47 | await expect(postLabels).toHaveBeenCalledWith({labels: ['os/mac']}) 48 | }); 49 | 50 | it('should capture os/win regex', async () => { 51 | github.context.eventName = 'pull_request' 52 | github.context.payload = { 53 | action: 'opened', 54 | pull_request: { 55 | number: 1, 56 | labels: [], 57 | body: '- Operating System: win\n- Version: 1.0.0' 58 | } 59 | } 60 | await capture({ 61 | regex: "- Operating System: *(windows|window|win|win) *", 62 | label: 'os/win' 63 | }) 64 | await expect(postLabels).toHaveBeenCalledWith({labels: ['os/win']}) 65 | }); 66 | 67 | it('should capture windows regex', async () => { 68 | github.context.eventName = 'pull_request' 69 | github.context.payload = { 70 | action: 'opened', 71 | pull_request: { 72 | number: 1, 73 | labels: [], 74 | body: '- Operating System: windows \n- Version: 1.0.0' 75 | } 76 | } 77 | await capture({ 78 | regex: "- Operating System: *(windows|window|win|win) *", 79 | label: 'os/win' 80 | }) 81 | await expect(postLabels).toHaveBeenCalledWith({labels: ['os/win']}) 82 | }); 83 | 84 | it('should fail capture due to uppercase', async () => { 85 | github.context.eventName = 'issue' 86 | github.context.payload = { 87 | action: 'opened', 88 | issue: { 89 | number: 1, 90 | labels: [], 91 | body: '- Operating System: LINUX \n- Version: 1.0.0' 92 | } 93 | } 94 | await capture({ 95 | regex: "- Operating System: *(linux) *", 96 | label: 'os/linux' 97 | }) 98 | await expect(postLabels).not.toHaveBeenCalled() 99 | }); 100 | 101 | it('should fail capture due to uppercase', async () => { 102 | github.context.eventName = 'issue' 103 | github.context.payload = { 104 | action: 'opened', 105 | issue: { 106 | number: 1, 107 | labels: [], 108 | body: '- Operating System: LINUX \n- Version: 1.0.0' 109 | } 110 | } 111 | await capture({ 112 | regex: "- Operating System: *(linux) *", 113 | label: 'os/linux', 114 | ignore_case: true 115 | }) 116 | await expect(postLabels).toHaveBeenCalledWith({labels: ['os/linux']}) 117 | }); 118 | 119 | it('should not capture nothing regex', async () => { 120 | github.context.eventName = 'pull_request' 121 | github.context.payload = { 122 | action: 'opened', 123 | pull_request: { 124 | number: 1, 125 | labels: [], 126 | body: '- Operating System: windows \n- Version: 1.0.0' 127 | } 128 | } 129 | await capture({ 130 | regex: "- Operating System: *(nothing) *", 131 | label: 'os/nothing' 132 | }) 133 | await expect(postLabels).not.toHaveBeenCalled() 134 | }); 135 | }) 136 | 137 | describe('comment', () => { 138 | it('should capture windows regex', async () => { 139 | github.context.eventName = 'issue_comment' 140 | github.context.payload = { 141 | action: 'created', 142 | comment: { 143 | id: 1, 144 | body: '- Operating System: windows \n- Version: 1.0.0' 145 | }, 146 | issue: { 147 | number: 1 148 | } 149 | } 150 | await capture({ 151 | regex: "- Operating System: *(windows|window|win|win) *", 152 | label: 'os/win' 153 | }) 154 | await expect(postLabels).toHaveBeenCalledWith({labels: ['os/win']}) 155 | }); 156 | 157 | it('should fail capture due to uppercase', async () => { 158 | github.context.eventName = 'issue_comment' 159 | github.context.payload = { 160 | action: 'created', 161 | comment: { 162 | id: 1, 163 | body: '- Operating System: LINUX \n- Version: 1.0.0' 164 | }, 165 | issue: { 166 | number: 1 167 | } 168 | } 169 | await capture({ 170 | regex: "- Operating System: *(linux) *", 171 | label: 'os/linux' 172 | }) 173 | await expect(postLabels).not.toHaveBeenCalled() 174 | }); 175 | }); 176 | 177 | describe('version', () => { 178 | it('should capture replace', async () => { 179 | github.context.eventName = 'issues' 180 | github.context.payload = { 181 | action: 'opened', 182 | issue: { 183 | number: 1, 184 | labels: [], 185 | body: '- Version: 1.0.0 \r\n- Operating System:mac' 186 | } 187 | } 188 | await capture({ 189 | regex: "- Version: *(.+) *", 190 | label: 'version/$CAPTURED' 191 | }) 192 | await expect(postLabels).toHaveBeenCalledWith({labels: ['version/1.0.0']}) 193 | }); 194 | 195 | it('should capture replace with v', async () => { 196 | github.context.eventName = 'issues' 197 | github.context.payload = { 198 | action: 'opened', 199 | issue: { 200 | number: 1, 201 | labels: [], 202 | body: '- Version: v1.5.0\r\n- Operating System:mac' 203 | } 204 | } 205 | await capture({ 206 | regex: "- Version: *(.+) *", 207 | label: 'version/$CAPTURED' 208 | }) 209 | await expect(postLabels).toHaveBeenCalledWith({labels: ['version/v1.5.0']}) 210 | }); 211 | 212 | it('should capture replace uppercase', async () => { 213 | github.context.eventName = 'issues' 214 | github.context.payload = { 215 | action: 'opened', 216 | issue: { 217 | number: 1, 218 | labels: [], 219 | body: '- Version: V1.5.0\r\n- Operating System:mac' 220 | } 221 | } 222 | await capture({ 223 | regex: "- Version: *(.+) *", 224 | label: 'version/$CAPTURED', 225 | ignore_case: true 226 | }) 227 | await expect(postLabels).toHaveBeenCalledWith({labels: ['version/V1.5.0']}) 228 | }); 229 | 230 | it('should have v1.5.0 captured and validated and not missing', async () => { 231 | github.context.eventName = 'issues' 232 | github.context.payload = { 233 | action: 'opened', 234 | issue: { 235 | number: 1, 236 | labels: [], 237 | body: '- Version: v1.5.0\r\n- Operating System:mac' 238 | } 239 | } 240 | 241 | nock('https://api.github.com') 242 | .get(/\/repos\/owner\/repo\/releases\/tags\/.+/) 243 | .reply(200, function (_, body) { 244 | const paths = this.req.path.split('/') 245 | return { 246 | tag_name: decodeURIComponent(paths[paths.length - 1]) 247 | } 248 | }) 249 | 250 | await capture({ 251 | regex: "- Version: *(.+) *", 252 | label: 'v/$CAPTURED', 253 | github_release: true 254 | }) 255 | await expect(postLabels).toHaveBeenCalledWith({labels: ['v/1.5.0']}) 256 | }); 257 | 258 | it('should have 1.5.0 captured and validated', async () => { 259 | github.context.eventName = 'issues' 260 | github.context.payload = { 261 | action: 'opened', 262 | issue: { 263 | number: 1, 264 | labels: [], 265 | body: '- Version: 1.5.0\r\n- Operating System:mac' 266 | } 267 | } 268 | 269 | nock('https://api.github.com') 270 | .get(/\/repos\/owner\/repo\/releases\/tags\/.+/) 271 | .reply(200, function (_, body) { 272 | const paths = this.req.path.split('/') 273 | return { 274 | tag_name: decodeURIComponent(paths[paths.length - 1]) 275 | } 276 | }) 277 | 278 | await capture({ 279 | regex: "- Version: *(.+) *", 280 | label: 'v/$CAPTURED', 281 | github_release: true 282 | }) 283 | await expect(postLabels).toHaveBeenCalledWith({labels: ['v/1.5.0']}) 284 | }); 285 | 286 | it('should have 1.5.0 captured and validated but missing and then non prefixed v is valid', async () => { 287 | github.context.eventName = 'issues' 288 | github.context.payload = { 289 | action: 'opened', 290 | issue: { 291 | number: 1, 292 | labels: [], 293 | body: '- Version: 1.5.0\r\n- Operating System:mac' 294 | } 295 | } 296 | 297 | nock('https://api.github.com') 298 | .get('/repos/owner/repo/releases/tags/v1.5.0') 299 | .reply(404, function (_, body) { 300 | return {} 301 | }) 302 | 303 | nock('https://api.github.com') 304 | .get('/repos/owner/repo/releases/tags/1.5.0') 305 | .reply(200, function (_, body) { 306 | return { 307 | tag_name: '1.5.0' 308 | } 309 | }) 310 | 311 | await capture({ 312 | regex: "- Version: *(.+) *", 313 | label: 'v/$CAPTURED', 314 | github_release: true 315 | }) 316 | await expect(postLabels).toHaveBeenCalledWith({labels: ['v/1.5.0']}) 317 | }); 318 | 319 | it('both version does not exist', async () => { 320 | github.context.eventName = 'issues' 321 | github.context.payload = { 322 | action: 'opened', 323 | issue: { 324 | number: 1, 325 | labels: [], 326 | body: '- Version: 1.5.0\r\n- Operating System:mac' 327 | } 328 | } 329 | 330 | nock('https://api.github.com') 331 | .get('/repos/owner/repo/releases/tags/v1.5.0') 332 | .reply(404, function (_, body) { 333 | return {} 334 | }) 335 | 336 | nock('https://api.github.com') 337 | .get('/repos/owner/repo/releases/tags/1.5.0') 338 | .reply(404, function (_, body) { 339 | return {} 340 | }) 341 | 342 | await capture({ 343 | regex: "- Version: *(.+) *", 344 | label: 'v/$CAPTURED', 345 | github_release: true 346 | }) 347 | await expect(postLabels).not.toHaveBeenCalled() 348 | }); 349 | }) 350 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # OSS Governance Bot 4 | 5 | [![codecov](https://codecov.io/gh/BirthdayResearch/oss-governance-bot/branch/main/graph/badge.svg?token=RJI65RZH2F)](https://codecov.io/gh/BirthdayResearch/oss-governance-bot) 6 | ![Codacy Badge](https://app.codacy.com/project/badge/Grade/a43f22b8c3c74fe9b6b58935a01fff4e) 7 | [![Release](https://img.shields.io/github/v/release/BirthdayResearch/oss-governance-bot)](https://github.com/BirthdayResearch/oss-governance-bot/releases) 8 | [![License MIT](https://img.shields.io/github/license/BirthdayResearch/oss-governance-bot)](https://github.com/BirthdayResearch/oss-governance-bot/blob/main/LICENSE) 9 | 10 | 11 | 12 | Although putting our project on GitHub makes it transparent and visible for public contributions, it's far from making 13 | it maintainable. For new contributors, creating an issue or pull request and successfully sending it is a mountainous 14 | journey in itself. Quality control hierarchy such as 'Triage', 'Conventional Commits', 'CI steps' and 'Code review' 15 | often deter contributors from contributing due to the complex nature of each OSS governance policy. 16 | 17 | A healthy open source projects must be able to scale to thousands of contributors. This project is an attempt to bring 18 | efficacy to the process by lowering the barrier of entry for community participation. The onus should be on the 19 | reviewers or ChatBot/ChatOp to guide the contributor through a series of education (governance/triage requirements) or 20 | adjustment (code review changes). 21 | 22 | This project is created to fully utilize the GitHub generous open source policy. It runs on GitHub Actions workflow 23 | hooks and deeply integrate with many GitHub offerings. In addition to providing a chat-bot experience when contributor 24 | interact with your project, `oss-governance-bot` also provide automation in the form of policy enforcement. Community 25 | contributors can trigger chat-ops via /slash style commands. 26 | 27 | ## What can OSS Governance Bot do for you? 28 | 29 | * Speed up issue triaging with automated chat-bot and chat-ops operations. 30 | * Increased code review agility by moving quality control hierarchy from requirements to educational steps. 31 | * Scale to thousands of contributors without alienating community participation with complex quality control hierarchy. 32 | * A GitHub Action that lives natively and integrate well with the GitHub action/workflow product offering. You can view 33 | the source directly and modify it to your needs. 34 | * See it in action at [DeFiCh/jellyfish](https://github.com/DeFiCh/jellyfish/issues) 35 | or [DeFiCh/scan](https://github.com/DeFiCh/scan/issues). 36 | 37 | [![preview](preview.png)](https://github.com/DeFiCh/scan/issues/1034) 38 | 39 | ## Usage 40 | 41 | #### `.github/workflow/governance.yml` 42 | 43 | ```yml 44 | # .github/workflow/governance.yml 45 | 46 | on: 47 | pull_request_target: 48 | types: [ synchronize, opened, labeled, unlabeled ] 49 | issues: 50 | types: [ opened, labeled, unlabeled ] 51 | issue_comment: 52 | types: [ created ] 53 | 54 | # You can use permissions to modify the default permissions granted to the GITHUB_TOKEN, 55 | # adding or removing access as required, so that you only allow the minimum required access. 56 | permissions: 57 | contents: read 58 | issues: write 59 | pull-requests: write 60 | statuses: write 61 | checks: write 62 | 63 | jobs: 64 | governance: 65 | name: Governance 66 | runs-on: ubuntu-latest 67 | steps: 68 | # Semantic versioning, lock to different version: v2, v2.0 or a commit hash. 69 | - uses: BirthdayResearch/oss-governance-bot@v2 70 | with: 71 | # You can use a PAT to post a comment/label/status so that it shows up as a user instead of github-actions 72 | github-token: ${{secrets.GITHUB_TOKEN}} # optional, default to '${{ github.token }}' 73 | config-path: .github/governance.yml # optional, default to '.github/governance.yml' 74 | ``` 75 | 76 | #### `.github/governance.yml` 77 | 78 | ```yml 79 | # .github/governance.yml 80 | 81 | version: v1 82 | 83 | issue: 84 | labels: 85 | - prefix: triage 86 | list: [ "accepted" ] 87 | multiple: false 88 | author_association: 89 | collaborator: true 90 | member: true 91 | owner: true 92 | needs: 93 | comment: | 94 | @$AUTHOR: This issue is currently awaiting triage. 95 | 96 | The triage/accepted label can be added by org members by writing /triage accepted in a comment. 97 | 98 | - prefix: kind 99 | list: [ "feature", "bug", "question" ] 100 | multiple: false 101 | needs: 102 | comment: | 103 | @$AUTHOR: There are no 'kind' label on this PR. You need a 'kind' label to generate the release note automatically. 104 | 105 | * `/kind feature` 106 | * `/kind bug` 107 | * `/kind question` 108 | 109 | - prefix: area 110 | list: [ "ui-ux", "semantics", "translation", "security" ] 111 | multiple: true 112 | needs: 113 | comment: | 114 | @$AUTHOR: There are no area labels on this issue. Adding an appropriate label will greatly expedite the process for us. You can add as many area as you see fit. **If you are unsure what to do you can ignore this!** 115 | 116 | * `/area ui-ux` 117 | * `/area semantics` 118 | * `/area translation` 119 | * `/area security` 120 | 121 | - prefix: os 122 | list: [ "mac", "win", "linux" ] 123 | multiple: true 124 | 125 | - prefix: priority 126 | multiple: false 127 | list: [ "urgent-now", "important-soon" ] 128 | author_association: 129 | collaborator: true 130 | member: true 131 | owner: true 132 | 133 | chat_ops: 134 | - cmd: /close 135 | type: close 136 | author_association: 137 | author: true 138 | collaborator: true 139 | member: true 140 | owner: true 141 | 142 | - cmd: /cc 143 | type: none 144 | 145 | - cmd: /assign 146 | type: assign 147 | author_association: 148 | collaborator: true 149 | member: true 150 | owner: true 151 | 152 | - cmd: /comment issue 153 | type: comment 154 | comment: | 155 | @$ISSUE_AUTHOR: Hey this is comment issue example for issue/pr author. 156 | @$AUTHOR: Hey this is comment issue example for sender author. 157 | 158 | pull_request: 159 | labels: 160 | - prefix: kind 161 | multiple: false 162 | list: [ "feature", "fix", "chore", "docs", "refactor", "dependencies" ] 163 | needs: 164 | comment: | 165 | @$AUTHOR: There are no 'kind' label on this PR. You need a 'kind' label to generate the release automatically. 166 | 167 | * `/kind feature` 168 | * `/kind fix` 169 | * `/kind chore` 170 | * `/kind docs` 171 | * `/kind refactor` 172 | * `/kind dependencies` 173 | status: 174 | context: "Kind Label" 175 | description: 176 | success: Ready for review & merge. 177 | failure: Missing kind label to generate release automatically. 178 | 179 | - prefix: priority 180 | multiple: false 181 | list: [ "urgent-now", "important-soon" ] 182 | author_association: 183 | collaborator: true 184 | member: true 185 | owner: true 186 | 187 | chat_ops: 188 | - cmd: /close 189 | type: close 190 | author_association: 191 | author: true 192 | collaborator: true 193 | member: true 194 | owner: true 195 | 196 | - cmd: /cc 197 | type: none # does not trigger anything 198 | 199 | - cmd: /request 200 | type: review 201 | author_association: 202 | collaborator: true 203 | member: true 204 | owner: true 205 | 206 | - cmd: /comment pr 207 | type: comment 208 | comment: | 209 | @$AUTHOR: Hey this is comment pr example. 210 | ``` 211 | 212 | ## Configuration 213 | 214 | You can target `pull_request` or `issue` with `labels` and/or `chat_ops`. 215 | 216 | ```yml 217 | version: v1 218 | 219 | issue: 220 | labels: 221 | chat_ops: 222 | 223 | pull_request: 224 | labels: 225 | chat_ops: 226 | ``` 227 | 228 | ### Author Association 229 | 230 | Author association to restrict who can trigger the operation. You can use this for both `labels` and `chat_ops` 231 | in `issue` or `pull_request`. 232 | 233 | ```yml 234 | version: v1 235 | 236 | issue: 237 | labels: 238 | - prefix: triage 239 | list: [ "accepted" ] 240 | author_association: 241 | author: false 242 | collaborator: true 243 | contributor: true 244 | first_timer: false 245 | first_time_contributor: false 246 | mannequin: false 247 | member: true 248 | none: false 249 | owner: true 250 | ``` 251 | 252 | ### Labels 253 | 254 | ```yml 255 | version: v1 256 | 257 | pull_request: 258 | labels: 259 | - prefix: kind 260 | multiple: false 261 | list: [ "feature", "fix", "chore", "docs", "refactor", "dependencies" ] 262 | needs: 263 | comment: | 264 | @$AUTHOR: There are no 'kind' label on this PR. You need a 'kind' label to generate the release automatically. 265 | 266 | * `/kind feature` 267 | * `/kind ...` 268 | status: 269 | context: "Governance/Kind" 270 | description: 271 | success: Ready for review & merge. 272 | failure: Missing kind label to generate release automatically. 273 | ``` 274 | 275 | ### ChatOps: close 276 | 277 | ```yml 278 | version: v1 279 | 280 | issue: 281 | chat_ops: 282 | - cmd: /close 283 | type: close 284 | ``` 285 | 286 | ### ChatOps: review 287 | 288 | Review is only available for pull_request. 289 | 290 | ```yml 291 | version: v1 292 | 293 | pull_request: 294 | chat_ops: 295 | # /request-review @john @ben @more 296 | - cmd: /request-review 297 | type: review 298 | ``` 299 | 300 | ### ChatOps: assign 301 | 302 | ```yml 303 | version: v1 304 | 305 | issue: 306 | chat_ops: 307 | # /assign @john @ben @more 308 | - cmd: /assign 309 | type: assign 310 | ``` 311 | 312 | ### ChatOps: none 313 | 314 | Does nothing, might be useful to show it in governance. 315 | 316 | ```yml 317 | version: v1 318 | 319 | issue: 320 | chat_ops: 321 | - cmd: /cc 322 | type: none 323 | ``` 324 | 325 | ### ChatOps: comment 326 | 327 | * `$AUTHOR` is the user that send the /chat-ops, comment/issue/pull_request. 328 | * `$ISSUE_AUTHOR` is the user that owns the current issue/pull_request. 329 | 330 | ```yml 331 | version: v1 332 | 333 | issue: 334 | chat_ops: 335 | - cmd: /comment me 336 | type: comment 337 | comment: | 338 | @$ISSUE_AUTHOR: Hey this is comment issue example for issue/pr author. 339 | @$AUTHOR: Hey this is comment issue example for sender author. 340 | ``` 341 | 342 | ### ChatOps: label 343 | 344 | Add or remove labels via chat ops. 345 | 346 | ```yml 347 | version: v1 348 | 349 | issue: 350 | chat_ops: 351 | - cmd: /label me 352 | type: label 353 | label: 354 | add: kind/me 355 | remove: [ 'label/remove', 'label/that' ] 356 | ``` 357 | 358 | ### Captures: Regex 359 | 360 | Capture labels based on regex, optionally validate them against github_release. 361 | 362 | ```yml 363 | version: v1 364 | 365 | issue: 366 | captures: 367 | - regex: "- Version: *(.+)" 368 | github_release: true # validate against github_release 369 | label: 'version/$CAPTURED' 370 | ``` 371 | 372 | ## Development & Contribution 373 | 374 | > IntelliJ IDEA is the IDE of choice for writing and maintaining this code library. IntelliJ's files are included for 375 | > convenience with toolchain setup but usage of IntelliJ is optional. 376 | 377 | ```shell 378 | npm i # npm 7 is used 379 | npm run all # to build/check/lint/package 380 | ``` 381 | 382 | * For any question please feel free to create an issue. 383 | * Pull request for non-breaking features are welcomed too! 384 | * Although all features were created specifically for [Birthday Research](https://github.com/BirthdayResearch) needs; 385 | you should not limit yourself to our offering. Feel free to fork the project. Appreciate if you mention us! 386 | 387 | ## Prior art 388 | 389 | * [Open Source Governance Models](https://gist.github.com/calebamiles/c578f88403b2fcb203deb5c9ef941d98) 390 | * [Kubernetes Prow](https://github.com/kubernetes/test-infra) 391 | * [jpmcb/prow-github-actions](https://github.com/jpmcb/prow-github-actions) 392 | -------------------------------------------------------------------------------- /__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import nock from 'nock' 3 | import * as github from '@actions/github' 4 | import {getConfig} from '../src/config' 5 | 6 | function expectConfig(path: string) { 7 | const client = github.getOctokit('token') 8 | return expect(getConfig(client, path)) 9 | } 10 | 11 | function expectInvalid(path: string) { 12 | return expectConfig( 13 | `__tests__/fixtures/config-invalid/${path}` 14 | ).rejects.toThrow(/Config parse error:.+/) 15 | } 16 | 17 | function expectValid(path: string) { 18 | return expectConfig( 19 | `__tests__/fixtures/config-valid/${path}` 20 | ).resolves.toBeTruthy() 21 | } 22 | 23 | beforeEach(() => { 24 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 25 | return { 26 | owner: 'owner', 27 | repo: 'repo' 28 | } 29 | }) 30 | 31 | const contentsRegex = /\/repos\/owner\/repo\/contents\/([^?]+).*/ 32 | nock('https://api.github.com') 33 | .get(contentsRegex) 34 | .reply(200, function () { 35 | const path = contentsRegex.exec(this.req.path)?.[1] || '' 36 | return { 37 | content: fs.readFileSync(decodeURIComponent(path), 'utf8'), 38 | encoding: 'utf-8' 39 | } 40 | }) 41 | }) 42 | 43 | afterAll(() => { 44 | jest.clearAllMocks() 45 | }) 46 | 47 | it('.github/governance.yml is valid', () => { 48 | return expectConfig('.github/governance.yml').resolves.toBeTruthy() 49 | }) 50 | 51 | describe('invalid config', () => { 52 | it('version.yml is invalid', () => { 53 | return expectInvalid('version.yml') 54 | }) 55 | 56 | describe('empty', () => { 57 | it('empty.yml is invalid', () => { 58 | return expectInvalid('empty.yml') 59 | }) 60 | 61 | it('empty-array.yml is invalid', () => { 62 | return expectInvalid('empty-array.yml') 63 | }) 64 | it('empty-array-issue-chat-ops.yml is invalid', () => { 65 | return expectInvalid('empty-array-issue-chat-ops.yml') 66 | }) 67 | it('empty-array-issue-labels.yml is invalid', () => { 68 | return expectInvalid('empty-array-issue-labels.yml') 69 | }) 70 | it('empty-array-pr-chat-ops.yml is invalid', () => { 71 | return expectInvalid('empty-array-pr-chat-ops.yml') 72 | }) 73 | it('empty-array-pr-labels.yml is invalid', () => { 74 | return expectInvalid('empty-array-pr-labels.yml') 75 | }) 76 | }) 77 | 78 | describe('issue', () => { 79 | describe('chat-ops', () => { 80 | it('issue-chat-ops-cmd.yml is invalid', () => { 81 | return expectInvalid('issue-chat-ops-cmd.yml') 82 | }) 83 | it('issue-chat-ops-comment.yml is invalid', () => { 84 | return expectInvalid('issue-chat-ops-comment.yml') 85 | }) 86 | it('issue-chat-ops-label.yml is invalid', () => { 87 | return expectInvalid('issue-chat-ops-label.yml') 88 | }) 89 | it('issue-chat-ops-label-object.yml is invalid', () => { 90 | return expectInvalid('issue-chat-ops-label-object.yml') 91 | }) 92 | it('issue-chat-ops-type.yml is invalid', () => { 93 | return expectInvalid('issue-chat-ops-type.yml') 94 | }) 95 | }) 96 | 97 | describe('label', () => { 98 | it('issue-label-list.yml is invalid', () => { 99 | return expectInvalid('issue-label-list.yml') 100 | }) 101 | it('issue-label-multiple.yml is invalid', () => { 102 | return expectInvalid('issue-label-multiple.yml') 103 | }) 104 | it('issue-label-needs.yml is invalid', () => { 105 | return expectInvalid('issue-label-needs.yml') 106 | }) 107 | it('issue-label-needs-comment.yml is invalid', () => { 108 | return expectInvalid('issue-label-needs-comment.yml') 109 | }) 110 | it('issue-label-prefix.yml is invalid', () => { 111 | return expectInvalid('issue-label-prefix.yml') 112 | }) 113 | }) 114 | }) 115 | 116 | describe('pull_request', () => { 117 | describe('chat_ops', () => { 118 | it('pr-chat-ops-cmd.yml is invalid', () => { 119 | return expectInvalid('pr-chat-ops-cmd.yml') 120 | }) 121 | it('pr-chat-ops-comment.yml is invalid', () => { 122 | return expectInvalid('pr-chat-ops-comment.yml') 123 | }) 124 | it('pr-chat-ops-label.yml is invalid', () => { 125 | return expectInvalid('pr-chat-ops-label.yml') 126 | }) 127 | it('pr-chat-ops-label-object.yml is invalid', () => { 128 | return expectInvalid('pr-chat-ops-label-object.yml') 129 | }) 130 | it('pr-chat-ops-type.yml is invalid', () => { 131 | return expectInvalid('pr-chat-ops-type.yml') 132 | }) 133 | }) 134 | 135 | describe('label', () => { 136 | it('pr-label-list.yml is invalid', () => { 137 | return expectInvalid('pr-label-list.yml') 138 | }) 139 | it('pr-label-multiple.yml is invalid', () => { 140 | return expectInvalid('pr-label-multiple.yml') 141 | }) 142 | it('pr-label-needs.yml is invalid', () => { 143 | return expectInvalid('pr-label-needs.yml') 144 | }) 145 | it('pr-label-needs-comment.yml is invalid', () => { 146 | return expectInvalid('pr-label-needs-comment.yml') 147 | }) 148 | it('pr-label-prefix.yml is invalid', () => { 149 | return expectInvalid('pr-label-prefix.yml') 150 | }) 151 | 152 | describe('status', () => { 153 | it('pr-label-status-context.yml is invalid', () => { 154 | return expectInvalid('pr-label-status-context.yml') 155 | }) 156 | it('pr-label-status-empty.yml is invalid', () => { 157 | return expectInvalid('pr-label-status-empty.yml') 158 | }) 159 | it('pr-label-status-url.yml is invalid', () => { 160 | return expectInvalid('pr-label-status-url.yml') 161 | }) 162 | }) 163 | }) 164 | }) 165 | 166 | describe('author_association', () => { 167 | describe('chat_ops', () => { 168 | it('chat-ops-author-association.yml is invalid', () => { 169 | return expectInvalid('chat-ops-author-association.yml') 170 | }) 171 | it('chat-ops-author-association-author.yml is invalid', () => { 172 | return expectInvalid('chat-ops-author-association-author.yml') 173 | }) 174 | it('chat-ops-author-association-contributor.yml is invalid', () => { 175 | return expectInvalid('chat-ops-author-association-contributor.yml') 176 | }) 177 | it('chat-ops-author-association-member.yml is invalid', () => { 178 | return expectInvalid('chat-ops-author-association-member.yml') 179 | }) 180 | }) 181 | 182 | describe('label', () => { 183 | it('label-author-association.yml is invalid', () => { 184 | return expectInvalid('label-author-association.yml') 185 | }) 186 | it('label-author-association-author.yml is invalid', () => { 187 | return expectInvalid('label-author-association-author.yml') 188 | }) 189 | it('label-author-association-contributor.yml is invalid', () => { 190 | return expectInvalid('label-author-association-contributor.yml') 191 | }) 192 | it('label-author-association-member.yml is invalid', () => { 193 | return expectInvalid('label-author-association-member.yml') 194 | }) 195 | }) 196 | }) 197 | 198 | describe('captures', () => { 199 | it('captures-empty.yml is invalid', () => { 200 | return expectInvalid('captures-empty.yml') 201 | }) 202 | it('captures-github-release.yml is invalid', () => { 203 | return expectInvalid('captures-github-release.yml') 204 | }) 205 | it('captures-ignore-case.yml is invalid', () => { 206 | return expectInvalid('captures-ignore-case.yml') 207 | }) 208 | it('captures-label.yml is invalid', () => { 209 | return expectInvalid('captures-label.yml') 210 | }) 211 | it('captures-regex.yml is invalid', () => { 212 | return expectInvalid('captures-regex.yml') 213 | }) 214 | }) 215 | }) 216 | 217 | describe('valid config', () => { 218 | it('version.yml is valid', () => { 219 | return expectValid('version.yml') 220 | }) 221 | 222 | it('label-triage.yml is valid', () => { 223 | return expectValid('label-triage.yml') 224 | }) 225 | 226 | describe('captures', () => { 227 | it('captures-all.yml is valid', () => { 228 | return expectValid('captures-all.yml') 229 | }) 230 | it('captures-ignore-case.yml is valid', () => { 231 | return expectValid('captures-ignore-case.yml') 232 | }) 233 | it('captures-minimal.yml is valid', () => { 234 | return expectValid('captures-minimal.yml') 235 | }) 236 | it('captures-version.yml is valid', () => { 237 | return expectValid('captures-version.yml') 238 | }) 239 | }) 240 | 241 | describe('chat-ops', () => { 242 | it('chat-ops-author-association.yml is valid', () => { 243 | return expectValid('chat-ops-author-association.yml') 244 | }) 245 | it('chat-ops-author-association-author.yml is valid', () => { 246 | return expectValid('chat-ops-author-association-author.yml') 247 | }) 248 | it('chat-ops-author-association-contributor.yml is valid', () => { 249 | return expectValid('chat-ops-author-association-contributor.yml') 250 | }) 251 | it('chat-ops-author-association-member.yml is valid', () => { 252 | return expectValid('chat-ops-author-association-member.yml') 253 | }) 254 | 255 | it('chat-ops-assign.yml is valid', () => { 256 | return expectValid('chat-ops-assign.yml') 257 | }) 258 | it('chat-ops-close.yml is valid', () => { 259 | return expectValid('chat-ops-close.yml') 260 | }) 261 | it('chat-ops-comment.yml is valid', () => { 262 | return expectValid('chat-ops-comment.yml') 263 | }) 264 | it('chat-ops-none.yml is valid', () => { 265 | return expectValid('chat-ops-none.yml') 266 | }) 267 | it('chat-ops-label.yml is valid', () => { 268 | return expectValid('chat-ops-label.yml') 269 | }) 270 | it('chat-ops-label-add.yml is valid', () => { 271 | return expectValid('chat-ops-label-add.yml') 272 | }) 273 | it('chat-ops-label-add-array.yml is valid', () => { 274 | return expectValid('chat-ops-label-add-array.yml') 275 | }) 276 | it('chat-ops-label-remove.yml is valid', () => { 277 | return expectValid('chat-ops-label-remove.yml') 278 | }) 279 | it('chat-ops-label-remove-array.yml is valid', () => { 280 | return expectValid('chat-ops-label-remove-array.yml') 281 | }) 282 | it('chat-ops-review.yml is valid', () => { 283 | return expectValid('chat-ops-review.yml') 284 | }) 285 | }) 286 | 287 | describe('label', () => { 288 | it('label-author-association.yml is valid', () => { 289 | return expectValid('label-author-association.yml') 290 | }) 291 | it('label-author-association-author.yml is valid', () => { 292 | return expectValid('label-author-association-author.yml') 293 | }) 294 | it('label-author-association-contributor.yml is valid', () => { 295 | return expectValid('label-author-association-contributor.yml') 296 | }) 297 | it('label-author-association-member.yml is valid', () => { 298 | return expectValid('label-author-association-member.yml') 299 | }) 300 | 301 | it('label-multiple-false.yml is valid', () => { 302 | return expectValid('label-multiple-false.yml') 303 | }) 304 | it('label-multiple-true.yml is valid', () => { 305 | return expectValid('label-multiple-true.yml') 306 | }) 307 | it('label-needs.yml is valid', () => { 308 | return expectValid('label-needs.yml') 309 | }) 310 | it('label-prefix-list.yml is valid', () => { 311 | return expectValid('label-prefix-list.yml') 312 | }) 313 | 314 | describe('label-status', () => { 315 | it('label-status.yml is valid', () => { 316 | return expectValid('label-status.yml') 317 | }) 318 | it('label-status-context.yml is valid', () => { 319 | return expectValid('label-status-context.yml') 320 | }) 321 | it('label-status-description-failure.yml is valid', () => { 322 | return expectValid('label-status-description-failure.yml') 323 | }) 324 | it('label-status-description-pending.yml is valid', () => { 325 | return expectValid('label-status-description-pending.yml') 326 | }) 327 | it('label-status-description-string.yml is valid', () => { 328 | return expectValid('label-status-description-string.yml') 329 | }) 330 | it('label-status-description-success.yml is valid', () => { 331 | return expectValid('label-status-description-success.yml') 332 | }) 333 | it('label-status-description-success-failure.yml is valid', () => { 334 | return expectValid('label-status-description-success-failure.yml') 335 | }) 336 | it('label-status-description-success-pending.yml is valid', () => { 337 | return expectValid('label-status-description-success-pending.yml') 338 | }) 339 | it('label-status-url.yml is valid', () => { 340 | return expectValid('label-status-url.yml') 341 | }) 342 | }) 343 | }) 344 | }) 345 | -------------------------------------------------------------------------------- /__tests__/rules/ignore.test.ts: -------------------------------------------------------------------------------- 1 | import ignore, {isCreatedOpened} from '../../src/rules/ignore' 2 | import * as github from '@actions/github' 3 | import nock from 'nock' 4 | import * as core from '@actions/core' 5 | 6 | function set( 7 | eventName: string, 8 | action: string, 9 | userType: string, 10 | options: any = {} 11 | ) { 12 | github.context.eventName = eventName 13 | github.context.payload = { 14 | action: action, 15 | sender: { 16 | type: userType, 17 | login: 'birthday-bot', 18 | id: 100000 19 | }, 20 | ...options 21 | } 22 | } 23 | 24 | async function expectIgnore(expected: boolean): Promise { 25 | await expect.assertions(1) 26 | await expect(ignore()).resolves.toBe(expected) 27 | } 28 | 29 | beforeEach(() => { 30 | set('issue_comment', 'created', 'User') 31 | 32 | jest.spyOn(core, 'getInput').mockImplementation(name => { 33 | return 'eg-bot-token' 34 | }) 35 | 36 | nock('https://api.github.com') 37 | .get('/user') 38 | .reply(200, function () { 39 | return { 40 | login: 'real-human', 41 | id: 9999 42 | } 43 | }) 44 | }) 45 | 46 | afterAll(() => { 47 | jest.clearAllMocks() 48 | }) 49 | 50 | it('default should not ignore', async () => { 51 | await expectIgnore(false) 52 | }) 53 | 54 | describe('sender', () => { 55 | it('should not ignore User', async () => { 56 | set('issue_comment', 'created', 'User') 57 | await expectIgnore(false) 58 | }) 59 | 60 | it('should not ignore Bot dependabot', async () => { 61 | set('issue_comment', 'created', 'Bot', { 62 | sender: { 63 | type: 'Bot', 64 | login: 'dependabot[bot]', 65 | id: 100000 66 | } 67 | }) 68 | await expectIgnore(false) 69 | }) 70 | 71 | it('should ignore Bot', async () => { 72 | set('issue_comment', 'created', 'Bot') 73 | await expectIgnore(true) 74 | }) 75 | 76 | it('should ignore token from bot', async () => { 77 | github.context.eventName = 'issue_comment' 78 | github.context.payload = { 79 | action: 'created', 80 | sender: { 81 | type: 'User', 82 | login: 'real-human', 83 | id: 9999 84 | } 85 | } 86 | await expectIgnore(true) 87 | }) 88 | }) 89 | 90 | describe('issue_comment', () => { 91 | it('should not ignore created', async () => { 92 | set('issue_comment', 'created', 'User') 93 | await expectIgnore(false) 94 | }) 95 | 96 | it('should ignore created if Bot', async () => { 97 | set('issue_comment', 'created', 'Bot') 98 | await expectIgnore(true) 99 | }) 100 | 101 | it('should ignore edited', async () => { 102 | set('issue_comment', 'edited', 'User') 103 | await expectIgnore(true) 104 | }) 105 | 106 | it('should ignore deleted', async () => { 107 | set('issue_comment', 'deleted', 'User') 108 | await expectIgnore(true) 109 | }) 110 | 111 | it('should ignore created but closed', async () => { 112 | set('issue_comment', 'created', 'User', { 113 | pull_request: { 114 | number: 1, 115 | state: 'closed' 116 | } 117 | }) 118 | await expectIgnore(true) 119 | }) 120 | 121 | it('should ignore created but closed', async () => { 122 | set('issue_comment', 'created', 'User', { 123 | issue: { 124 | number: 1, 125 | state: 'closed' 126 | } 127 | }) 128 | await expectIgnore(true) 129 | }) 130 | }) 131 | 132 | describe('pull_request', () => { 133 | it('should not ignore opened', async () => { 134 | set('pull_request', 'opened', 'User') 135 | await expectIgnore(false) 136 | }) 137 | 138 | it('should not ignore unlabeled', async () => { 139 | set('pull_request', 'labeled', 'User') 140 | await expectIgnore(false) 141 | }) 142 | 143 | it('should not ignore unlabeled', async () => { 144 | set('pull_request', 'unlabeled', 'User') 145 | await expectIgnore(false) 146 | }) 147 | 148 | it('should not ignore synchronize', async () => { 149 | set('pull_request', 'synchronize', 'User') 150 | await expectIgnore(false) 151 | }) 152 | 153 | it('should ignore opened if Bot', async () => { 154 | set('pull_request', 'opened', 'Bot') 155 | await expectIgnore(true) 156 | }) 157 | 158 | it('should ignore locked', async () => { 159 | set('pull_request', 'locked', 'User') 160 | await expectIgnore(true) 161 | }) 162 | 163 | it('should ignore edited', async () => { 164 | set('pull_request', 'edited', 'User') 165 | await expectIgnore(true) 166 | }) 167 | 168 | it('should ignore if updated only 1s has passed', async () => { 169 | set('pull_request', 'labeled', 'User', { 170 | pull_request: { 171 | number: 1, 172 | created_at: '2021-02-01T07:03:58Z', 173 | updated_at: '2021-02-01T07:03:59Z' 174 | } 175 | }) 176 | await expectIgnore(true) 177 | }) 178 | 179 | it('should ignore if updated only 2s has passed', async () => { 180 | set('pull_request', 'labeled', 'User', { 181 | pull_request: { 182 | number: 1, 183 | created_at: '2021-02-01T07:03:58Z', 184 | updated_at: '2021-02-01T07:04:00Z' 185 | } 186 | }) 187 | await expectIgnore(true) 188 | }) 189 | 190 | it('should ignore if updated only 3s has passed', async () => { 191 | set('pull_request', 'labeled', 'User', { 192 | pull_request: { 193 | number: 1, 194 | created_at: '2021-02-01T07:03:58Z', 195 | updated_at: '2021-02-01T07:04:01Z' 196 | } 197 | }) 198 | await expectIgnore(true) 199 | }) 200 | 201 | it('should ignore if updated only 4s has passed', async () => { 202 | set('pull_request', 'labeled', 'User', { 203 | pull_request: { 204 | number: 1, 205 | created_at: '2021-02-01T07:03:58Z', 206 | updated_at: '2021-02-01T07:04:02Z' 207 | } 208 | }) 209 | await expectIgnore(true) 210 | }) 211 | 212 | it('should ignore if updated only 5s has passed', async () => { 213 | set('pull_request', 'labeled', 'User', { 214 | pull_request: { 215 | number: 1, 216 | created_at: '2021-02-01T07:03:58Z', 217 | updated_at: '2021-02-01T07:04:03Z' 218 | } 219 | }) 220 | await expectIgnore(true) 221 | }) 222 | 223 | it('should not ignore if updated 10s later', async () => { 224 | set('pull_request', 'labeled', 'User', { 225 | pull_request: { 226 | number: 1, 227 | created_at: '2021-02-01T07:03:58Z', 228 | updated_at: '2021-02-01T07:04:08Z' 229 | } 230 | }) 231 | await expectIgnore(false) 232 | }) 233 | 234 | it('should not ignore if updated more than 1min later', async () => { 235 | set('pull_request', 'labeled', 'User', { 236 | pull_request: { 237 | number: 1, 238 | created_at: '2021-02-01T07:03:51Z', 239 | updated_at: '2021-02-01T09:04:59Z' 240 | } 241 | }) 242 | await expectIgnore(false) 243 | }) 244 | 245 | it('should not ignore if 3s but user is Bot', async () => { 246 | set('pull_request', 'labeled', 'Bot', { 247 | pull_request: { 248 | number: 1, 249 | created_at: '2021-02-01T07:03:58Z', 250 | updated_at: '2021-02-01T07:04:01Z' 251 | } 252 | }) 253 | await expectIgnore(false) 254 | }) 255 | }) 256 | 257 | describe('pull_request_target', () => { 258 | it('should not ignore opened', async () => { 259 | set('pull_request_target', 'opened', 'User') 260 | await expectIgnore(false) 261 | }) 262 | 263 | it('should not ignore unlabeled', async () => { 264 | set('pull_request_target', 'labeled', 'User') 265 | await expectIgnore(false) 266 | }) 267 | 268 | it('should not ignore unlabeled', async () => { 269 | set('pull_request_target', 'unlabeled', 'User') 270 | await expectIgnore(false) 271 | }) 272 | 273 | it('should not ignore synchronize', async () => { 274 | set('pull_request_target', 'synchronize', 'User') 275 | await expectIgnore(false) 276 | }) 277 | 278 | it('should ignore opened if Bot', async () => { 279 | set('pull_request_target', 'opened', 'Bot') 280 | await expectIgnore(true) 281 | }) 282 | 283 | it('should ignore locked', async () => { 284 | set('pull_request_target', 'locked', 'User') 285 | await expectIgnore(true) 286 | }) 287 | 288 | it('should ignore edited', async () => { 289 | set('pull_request_target', 'edited', 'User') 290 | await expectIgnore(true) 291 | }) 292 | 293 | it('should ignore if updated only 1s has passed', async () => { 294 | set('pull_request_target', 'labeled', 'User', { 295 | pull_request: { 296 | number: 1, 297 | created_at: '2021-02-01T07:03:58Z', 298 | updated_at: '2021-02-01T07:03:59Z' 299 | } 300 | }) 301 | await expectIgnore(true) 302 | }) 303 | 304 | it('should ignore if updated only 2s has passed', async () => { 305 | set('pull_request_target', 'labeled', 'User', { 306 | pull_request: { 307 | number: 1, 308 | created_at: '2021-02-01T07:03:58Z', 309 | updated_at: '2021-02-01T07:04:00Z' 310 | } 311 | }) 312 | await expectIgnore(true) 313 | }) 314 | 315 | it('should ignore if updated only 3s has passed', async () => { 316 | set('pull_request_target', 'labeled', 'User', { 317 | pull_request: { 318 | number: 1, 319 | created_at: '2021-02-01T07:03:58Z', 320 | updated_at: '2021-02-01T07:04:01Z' 321 | } 322 | }) 323 | await expectIgnore(true) 324 | }) 325 | 326 | it('should ignore if updated only 4s has passed', async () => { 327 | set('pull_request_target', 'labeled', 'User', { 328 | pull_request: { 329 | number: 1, 330 | created_at: '2021-02-01T07:03:58Z', 331 | updated_at: '2021-02-01T07:04:02Z' 332 | } 333 | }) 334 | await expectIgnore(true) 335 | }) 336 | 337 | it('should ignore if updated only 5s has passed', async () => { 338 | set('pull_request_target', 'labeled', 'User', { 339 | pull_request: { 340 | number: 1, 341 | created_at: '2021-02-01T07:03:58Z', 342 | updated_at: '2021-02-01T07:04:03Z' 343 | } 344 | }) 345 | await expectIgnore(true) 346 | }) 347 | 348 | it('should not ignore if updated 10s later', async () => { 349 | set('pull_request_target', 'labeled', 'User', { 350 | pull_request: { 351 | number: 1, 352 | created_at: '2021-02-01T07:03:58Z', 353 | updated_at: '2021-02-01T07:04:08Z' 354 | } 355 | }) 356 | await expectIgnore(false) 357 | }) 358 | 359 | it('should not ignore if updated more than 1min later', async () => { 360 | set('pull_request_target', 'labeled', 'User', { 361 | pull_request: { 362 | number: 1, 363 | created_at: '2021-02-01T07:03:51Z', 364 | updated_at: '2021-02-01T09:04:59Z' 365 | } 366 | }) 367 | await expectIgnore(false) 368 | }) 369 | }) 370 | 371 | describe('issues', () => { 372 | it('should not ignore opened', async () => { 373 | set('issues', 'opened', 'User') 374 | await expectIgnore(false) 375 | }) 376 | 377 | it('should not ignore labeled', async () => { 378 | set('pull_request', 'labeled', 'User') 379 | await expectIgnore(false) 380 | }) 381 | 382 | it('should not ignore labeled', async () => { 383 | set('pull_request_target', 'labeled', 'User') 384 | await expectIgnore(false) 385 | }) 386 | 387 | it('should not ignore unlabeled', async () => { 388 | set('issues', 'unlabeled', 'User') 389 | await expectIgnore(false) 390 | }) 391 | 392 | it('should ignore opened if Bot', async () => { 393 | set('issues', 'opened', 'Bot') 394 | await expectIgnore(true) 395 | }) 396 | 397 | it('should ignore assigned', async () => { 398 | set('issues', 'assigned', 'User') 399 | await expectIgnore(true) 400 | }) 401 | 402 | it('should ignore edited', async () => { 403 | set('issues', 'edited', 'User') 404 | await expectIgnore(true) 405 | }) 406 | 407 | it('should ignore if updated only 1s has passed', async () => { 408 | set('issues', 'labeled', 'User', { 409 | issue: { 410 | number: 1, 411 | created_at: '2021-02-01T07:03:58Z', 412 | updated_at: '2021-02-01T07:03:59Z' 413 | } 414 | }) 415 | await expectIgnore(true) 416 | }) 417 | 418 | it('should ignore if updated only 2s has passed', async () => { 419 | set('issues', 'labeled', 'User', { 420 | issue: { 421 | number: 1, 422 | created_at: '2021-02-01T07:03:58Z', 423 | updated_at: '2021-02-01T07:04:00Z' 424 | } 425 | }) 426 | await expectIgnore(true) 427 | }) 428 | 429 | it('should ignore if updated only 3s has passed', async () => { 430 | set('issues', 'labeled', 'User', { 431 | issue: { 432 | number: 1, 433 | created_at: '2021-02-01T07:03:58Z', 434 | updated_at: '2021-02-01T07:04:01Z' 435 | } 436 | }) 437 | await expectIgnore(true) 438 | }) 439 | 440 | it('should ignore if updated only 4s has passed', async () => { 441 | set('issues', 'labeled', 'User', { 442 | issue: { 443 | number: 1, 444 | created_at: '2021-02-01T07:03:58Z', 445 | updated_at: '2021-02-01T07:04:02Z' 446 | } 447 | }) 448 | await expectIgnore(true) 449 | }) 450 | 451 | it('should ignore if updated only 5s has passed', async () => { 452 | set('issues', 'labeled', 'User', { 453 | issue: { 454 | number: 1, 455 | created_at: '2021-02-01T07:03:58Z', 456 | updated_at: '2021-02-01T07:04:03Z' 457 | } 458 | }) 459 | await expectIgnore(true) 460 | }) 461 | 462 | it('should not ignore if updated 10s later', async () => { 463 | set('issues', 'labeled', 'User', { 464 | issue: { 465 | number: 1, 466 | created_at: '2021-02-01T07:03:58Z', 467 | updated_at: '2021-02-01T07:04:08Z' 468 | } 469 | }) 470 | await expectIgnore(false) 471 | }) 472 | 473 | it('should not ignore if updated more than 1min later', async () => { 474 | set('issues', 'labeled', 'User', { 475 | issue: { 476 | number: 1, 477 | created_at: '2021-02-01T07:03:51Z', 478 | updated_at: '2021-02-01T09:04:59Z' 479 | } 480 | }) 481 | await expectIgnore(false) 482 | }) 483 | }) 484 | 485 | describe('isCreatedOpened', () => { 486 | async function expectCreatedOpened(expected: boolean): Promise { 487 | await expect.assertions(1) 488 | await expect(isCreatedOpened()).toBe(expected) 489 | } 490 | 491 | it('issue_comment created should be true', async () => { 492 | set('issue_comment', 'created', 'User') 493 | await expectCreatedOpened(true) 494 | }) 495 | 496 | it('pull_request opened should be true', async () => { 497 | set('pull_request', 'opened', 'User') 498 | await expectCreatedOpened(true) 499 | }) 500 | 501 | it('pull_request_target opened should be true', async () => { 502 | set('pull_request_target', 'opened', 'User') 503 | await expectCreatedOpened(true) 504 | }) 505 | 506 | it('issues opened should be true', async () => { 507 | set('issues', 'opened', 'User') 508 | await expectCreatedOpened(true) 509 | }) 510 | 511 | it('issue_comment edited should be true', async () => { 512 | set('issue_comment', 'edited', 'User') 513 | await expectCreatedOpened(false) 514 | }) 515 | 516 | it('pull_request labeled should be true', async () => { 517 | set('pull_request', 'labeled', 'User') 518 | await expectCreatedOpened(false) 519 | }) 520 | 521 | it('pull_request_target labeled should be true', async () => { 522 | set('pull_request_target', 'labeled', 'User') 523 | await expectCreatedOpened(false) 524 | }) 525 | 526 | it('issues labeled should be true', async () => { 527 | set('issues', 'labeled', 'User') 528 | await expectCreatedOpened(false) 529 | }) 530 | }) 531 | -------------------------------------------------------------------------------- /__tests__/operators/label.test.ts: -------------------------------------------------------------------------------- 1 | import label from '../../src/operators/label' 2 | import {Command, Commands} from "../../src/command"; 3 | import * as github from "@actions/github"; 4 | import * as core from "@actions/core"; 5 | import nock from "nock"; 6 | 7 | const postComments = jest.fn() 8 | const postLabels = jest.fn() 9 | const postStatus = jest.fn() 10 | const deleteLabels = jest.fn() 11 | 12 | beforeAll(() => { 13 | jest.spyOn(core, 'getInput').mockImplementation(name => { 14 | return 'token' 15 | }) 16 | 17 | jest.spyOn(github.context, 'repo', 'get').mockImplementation(() => { 18 | return { 19 | owner: 'owner', 20 | repo: 'repo' 21 | } 22 | }) 23 | 24 | nock('https://api.github.com') 25 | .post('/repos/owner/repo/issues/1/comments') 26 | .reply(200, function (_, body) { 27 | postComments(body) 28 | return {} 29 | }).persist() 30 | 31 | nock('https://api.github.com') 32 | .post('/repos/owner/repo/issues/1/labels') 33 | .reply(200, function (_, body) { 34 | postLabels(body) 35 | return {} 36 | }).persist() 37 | 38 | nock('https://api.github.com') 39 | .delete(/\/repos\/owner\/repo\/issues\/1\/labels\/.+/) 40 | .reply(200, function (_, body) { 41 | const paths = this.req.path.split('/') 42 | deleteLabels(decodeURIComponent(paths[paths.length - 1])) 43 | return {} 44 | }).persist() 45 | 46 | nock('https://api.github.com') 47 | .post(/\/repos\/owner\/repo\/statuses\/.+/) 48 | .reply(200, function (_, body) { 49 | postStatus(body) 50 | return {} 51 | }).persist() 52 | }) 53 | 54 | afterAll(() => { 55 | jest.clearAllMocks() 56 | }) 57 | 58 | function getCommands(list: string[] = []): Commands { 59 | return new Commands(list.map(t => new Command((t)))) 60 | } 61 | 62 | describe('needs', () => { 63 | it('should have needs/triage when needs is true', async function () { 64 | github.context.eventName = 'issues' 65 | github.context.payload = { 66 | action: 'opened', 67 | issue: { 68 | number: 1, 69 | labels: [] 70 | } 71 | } 72 | 73 | await label({ 74 | prefix: 'triage', 75 | list: ['accepted'], 76 | needs: true 77 | }, getCommands()) 78 | 79 | return expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 80 | }); 81 | 82 | it('should have needs/triage when needs.comment is present', async function () { 83 | github.context.eventName = 'issues' 84 | github.context.payload = { 85 | action: 'opened', 86 | issue: { 87 | number: 1, 88 | labels: [] 89 | } 90 | } 91 | 92 | await label({ 93 | prefix: 'triage', 94 | list: ['accepted'], 95 | needs: { 96 | comment: 'Hello you!' 97 | } 98 | }, getCommands()) 99 | 100 | return expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 101 | }); 102 | 103 | it('should have needs/triage when /needs triage is commented', async function () { 104 | github.context.eventName = 'issues' 105 | github.context.payload = { 106 | action: 'opened', 107 | issue: { 108 | number: 1, 109 | labels: [] 110 | } 111 | } 112 | 113 | await label({ 114 | prefix: 'triage', 115 | list: ['accepted'], 116 | }, getCommands(['/needs triage'])) 117 | 118 | return expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 119 | }); 120 | 121 | it('should not have needs/triage when /needs triage is commented because its already present', async function () { 122 | github.context.eventName = 'issues' 123 | github.context.payload = { 124 | action: 'opened', 125 | issue: { 126 | number: 1, 127 | labels: [{name: 'triage/accepted'}] 128 | } 129 | } 130 | 131 | await label({ 132 | prefix: 'triage', 133 | list: ['accepted'], 134 | }, getCommands(['/need triage'])) 135 | 136 | await expect(postLabels).not.toHaveBeenCalled() 137 | await expect(postComments).not.toHaveBeenCalled() 138 | await expect(deleteLabels).not.toHaveBeenCalled() 139 | }); 140 | 141 | it('should have needs/triage removed when labeled', async function () { 142 | github.context.eventName = 'issues' 143 | github.context.payload = { 144 | action: 'opened', 145 | issue: { 146 | number: 1, 147 | labels: [{name: 'needs/triage'}, {name: 'triage/accepted'}] 148 | } 149 | } 150 | 151 | await label({ 152 | prefix: 'triage', 153 | list: ['accepted', 'rejected'], 154 | }, getCommands()) 155 | 156 | await expect(postLabels).not.toHaveBeenCalled() 157 | await expect(postComments).not.toHaveBeenCalled() 158 | await expect(deleteLabels).toHaveBeenCalledWith("needs/triage") 159 | }); 160 | 161 | it('should have needs/triage removed when /triage accepted', async function () { 162 | github.context.eventName = 'issues' 163 | github.context.payload = { 164 | action: 'opened', 165 | issue: { 166 | number: 1, 167 | labels: [{name: 'needs/triage'}] 168 | } 169 | } 170 | 171 | await label({ 172 | prefix: 'triage', 173 | list: ['accepted', 'rejected'], 174 | }, getCommands(['/triage accepted'])) 175 | 176 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/accepted']}) 177 | await expect(deleteLabels).toHaveBeenCalledWith("needs/triage") 178 | }); 179 | 180 | it('should have needs/kind removed when /kind fix is commented', async function () { 181 | github.context.eventName = 'issue_comment' 182 | github.context.payload = { 183 | action: 'created', 184 | comment: { 185 | id: 1, 186 | }, 187 | pull_request: { 188 | number: 1, 189 | labels: [{name: 'needs/kind'}] 190 | } 191 | } 192 | 193 | await label({ 194 | prefix: 'kind', 195 | multiple: false, 196 | list: ["feature", "fix", "chore", "docs", "refactor", "dependencies"], 197 | needs: { 198 | comment: 'TEST' 199 | } 200 | }, getCommands(['/kind fix'])) 201 | 202 | await expect(postLabels).toHaveBeenCalledWith({labels: ['kind/fix']}) 203 | await expect(deleteLabels).toHaveBeenCalledWith("needs/kind") 204 | }); 205 | 206 | it('should have needs/triage removed when /triage accepted when needs:true', async function () { 207 | github.context.eventName = 'issues' 208 | github.context.payload = { 209 | action: 'opened', 210 | issue: { 211 | number: 1, 212 | labels: [{name: 'needs/triage'}] 213 | } 214 | } 215 | 216 | await label({ 217 | prefix: 'triage', 218 | list: ['accepted', 'rejected'], 219 | needs: true, 220 | }, getCommands(['/triage accepted'])) 221 | 222 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/accepted']}) 223 | await expect(deleteLabels).toHaveBeenCalledWith("needs/triage") 224 | }); 225 | 226 | it('should have needs/triage removed when /triage accepted when needs comments is available', async function () { 227 | github.context.eventName = 'issues' 228 | github.context.payload = { 229 | action: 'opened', 230 | issue: { 231 | number: 1, 232 | labels: [{name: 'needs/triage'}] 233 | } 234 | } 235 | 236 | await label({ 237 | prefix: 'triage', 238 | list: ['accepted', 'rejected'], 239 | needs: { 240 | comment: 'available' 241 | }, 242 | }, getCommands(['/triage accepted'])) 243 | 244 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/accepted']}) 245 | await expect(postComments).not.toHaveBeenCalled() 246 | await expect(deleteLabels).toHaveBeenCalledWith("needs/triage") 247 | }); 248 | 249 | it('should have needs/triage removed when /triage rejected', async function () { 250 | github.context.eventName = 'issues' 251 | github.context.payload = { 252 | action: 'opened', 253 | issue: { 254 | number: 1, 255 | labels: [{name: 'needs/triage'}] 256 | } 257 | } 258 | 259 | await label({ 260 | prefix: 'triage', 261 | list: ['accepted', 'rejected'], 262 | }, getCommands(['/triage rejected'])) 263 | 264 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/rejected']}) 265 | await expect(postComments).not.toHaveBeenCalled() 266 | await expect(deleteLabels).toHaveBeenCalledWith('needs/triage') 267 | }); 268 | 269 | it('should have comment when needs/triage is present and opened', async function () { 270 | github.context.eventName = 'issues' 271 | github.context.payload = { 272 | action: 'opened', 273 | issue: { 274 | number: 1, 275 | labels: [] 276 | } 277 | } 278 | 279 | await label({ 280 | prefix: 'triage', 281 | list: ['accepted'], 282 | needs: { 283 | comment: 'hello you' 284 | } 285 | }, getCommands()) 286 | 287 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 288 | await expect(postComments).toHaveBeenCalledTimes(1) 289 | await expect(deleteLabels).not.toHaveBeenCalled() 290 | }); 291 | 292 | it('should not have comment when needs/triage is present and opened', async function () { 293 | github.context.eventName = 'issues' 294 | github.context.payload = { 295 | action: 'opened', 296 | issue: { 297 | number: 1, 298 | labels: [] 299 | } 300 | } 301 | 302 | await label({ 303 | prefix: 'triage', 304 | list: ['accepted'], 305 | needs: true 306 | }, getCommands()) 307 | 308 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 309 | await expect(postComments).not.toHaveBeenCalled() 310 | await expect(deleteLabels).not.toHaveBeenCalled() 311 | }); 312 | 313 | it('should not have comment when needs/triage is present as its edited', async function () { 314 | github.context.eventName = 'issues' 315 | github.context.payload = { 316 | action: 'edited', 317 | issue: { 318 | number: 1, 319 | labels: [] 320 | } 321 | } 322 | 323 | await label({ 324 | prefix: 'triage', 325 | list: ['accepted'], 326 | needs: { 327 | comment: 'hello you' 328 | } 329 | }, getCommands()) 330 | 331 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 332 | await expect(postComments).not.toHaveBeenCalled() 333 | await expect(deleteLabels).not.toHaveBeenCalled() 334 | }); 335 | 336 | it('should not have commented', async function () { 337 | github.context.eventName = 'issues' 338 | github.context.payload = { 339 | action: 'opened', 340 | issue: { 341 | number: 1, 342 | labels: [] 343 | } 344 | } 345 | 346 | await label({ 347 | prefix: 'triage', 348 | list: ['accepted'], 349 | }, getCommands()) 350 | 351 | return expect(postComments).not.toHaveBeenCalled() 352 | }); 353 | 354 | describe('is not created or opened', () => { 355 | it('should have called because pull_request synchronize', async function () { 356 | github.context.eventName = 'pull_request' 357 | github.context.payload = { 358 | action: 'synchronize', 359 | pull_request: { 360 | number: 1, 361 | labels: [] 362 | } 363 | } 364 | 365 | await label({ 366 | prefix: 'triage', 367 | list: ['accepted'], 368 | needs: true 369 | }, getCommands()) 370 | 371 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 372 | await expect(postStatus).not.toHaveBeenCalled() 373 | await expect(postComments).not.toHaveBeenCalled() 374 | await expect(deleteLabels).not.toHaveBeenCalled() 375 | }); 376 | 377 | it('should have called with status because pull_request synchronize', async function () { 378 | github.context.eventName = 'pull_request' 379 | github.context.payload = { 380 | action: 'synchronize', 381 | pull_request: { 382 | number: 1, 383 | labels: [], 384 | head: { 385 | sha: '123' 386 | } 387 | } 388 | } 389 | 390 | await label({ 391 | prefix: 'triage', 392 | list: ['accepted'], 393 | needs: { 394 | status: { 395 | context: 'Context' 396 | } 397 | } 398 | }, getCommands()) 399 | 400 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 401 | await expect(postStatus).toHaveBeenCalled() 402 | await expect(postComments).not.toHaveBeenCalled() 403 | await expect(deleteLabels).not.toHaveBeenCalled() 404 | }); 405 | 406 | it('should have called with status because pull_request unlabeled', async function () { 407 | github.context.eventName = 'pull_request' 408 | github.context.payload = { 409 | action: 'unlabeled', 410 | pull_request: { 411 | number: 1, 412 | labels: [], 413 | head: { 414 | sha: '123' 415 | } 416 | } 417 | } 418 | 419 | await label({ 420 | prefix: 'triage', 421 | list: ['accepted'], 422 | needs: { 423 | status: { 424 | context: 'Context' 425 | } 426 | } 427 | }, getCommands(['/triage accepted'])) 428 | 429 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 430 | await expect(postStatus).toHaveBeenCalled() 431 | await expect(postComments).not.toHaveBeenCalled() 432 | await expect(deleteLabels).not.toHaveBeenCalled() 433 | }); 434 | 435 | it('should have called because pull_request edited', async function () { 436 | github.context.eventName = 'pull_request' 437 | github.context.payload = { 438 | action: 'edited', 439 | pull_request: { 440 | number: 1, 441 | labels: [] 442 | } 443 | } 444 | 445 | await label({ 446 | prefix: 'triage', 447 | list: ['accepted'], 448 | needs: true 449 | }, getCommands()) 450 | 451 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 452 | await expect(postStatus).not.toHaveBeenCalled() 453 | await expect(postComments).not.toHaveBeenCalled() 454 | await expect(deleteLabels).not.toHaveBeenCalled() 455 | }); 456 | 457 | it('should not have called because issues labeled', async function () { 458 | github.context.eventName = 'issues' 459 | github.context.payload = { 460 | action: 'labeled', 461 | issue: { 462 | number: 1, 463 | labels: [{name: 'needs/triage'}] 464 | } 465 | } 466 | 467 | await label({ 468 | prefix: 'triage', 469 | list: ['accepted'], 470 | needs: true 471 | }, getCommands(['/triage accepted'])) 472 | 473 | await expect(postLabels).not.toHaveBeenCalled() 474 | await expect(postComments).not.toHaveBeenCalled() 475 | await expect(deleteLabels).not.toHaveBeenCalled() 476 | }); 477 | 478 | it('should have needs/triage called because issues labeled', async function () { 479 | github.context.eventName = 'issues' 480 | github.context.payload = { 481 | action: 'labeled', 482 | issue: { 483 | number: 1, 484 | labels: [] 485 | } 486 | } 487 | 488 | await label({ 489 | prefix: 'triage', 490 | list: ['accepted'], 491 | needs: true 492 | }, getCommands(['/triage accepted'])) 493 | 494 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 495 | await expect(postComments).not.toHaveBeenCalled() 496 | await expect(deleteLabels).not.toHaveBeenCalled() 497 | }); 498 | 499 | it('should have needs/triage called because pull_request labeled', async function () { 500 | github.context.eventName = 'pull_request' 501 | github.context.payload = { 502 | action: 'labeled', 503 | pull_request: { 504 | number: 1, 505 | labels: [] 506 | } 507 | } 508 | 509 | await label({ 510 | prefix: 'triage', 511 | list: ['accepted'], 512 | needs: true 513 | }, getCommands(['/triage accepted'])) 514 | 515 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 516 | await expect(postComments).not.toHaveBeenCalled() 517 | await expect(deleteLabels).not.toHaveBeenCalled() 518 | }); 519 | }) 520 | }) 521 | 522 | describe('labels', () => { 523 | it('should only add approved labels with command', async function () { 524 | github.context.eventName = 'issues' 525 | github.context.payload = { 526 | action: 'opened', 527 | issue: { 528 | number: 1, 529 | labels: [] 530 | } 531 | } 532 | 533 | await label({ 534 | prefix: 'triage', 535 | list: ['accepted'], 536 | }, getCommands(['/triage random'])) 537 | 538 | await expect(postLabels).not.toHaveBeenCalled() 539 | await expect(deleteLabels).not.toHaveBeenCalled() 540 | }); 541 | 542 | it('should have removed labels with command', async function () { 543 | github.context.eventName = 'issues' 544 | github.context.payload = { 545 | action: 'opened', 546 | issue: { 547 | number: 1, 548 | labels: [{name: 'triage/accepted'}] 549 | } 550 | } 551 | 552 | await label({ 553 | prefix: 'triage', 554 | list: ['accepted'], 555 | }, getCommands(['/triage-remove accepted'])) 556 | 557 | await expect(postLabels).not.toHaveBeenCalled() 558 | await expect(deleteLabels).toHaveBeenCalledWith('triage/accepted') 559 | }); 560 | 561 | it('should have added labels with command', async function () { 562 | github.context.eventName = 'issues' 563 | github.context.payload = { 564 | action: 'opened', 565 | issue: { 566 | number: 1, 567 | labels: [{name: 'triage/accepted'}] 568 | } 569 | } 570 | 571 | await label({ 572 | prefix: 'triage', 573 | list: ['accepted', 'a', 'b'], 574 | }, getCommands(['/triage a'])) 575 | 576 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/a']}) 577 | await expect(postComments).not.toHaveBeenCalled() 578 | await expect(deleteLabels).not.toHaveBeenCalled() 579 | }); 580 | 581 | it('should have added multiple labels with command', async function () { 582 | github.context.eventName = 'issues' 583 | github.context.payload = { 584 | action: 'opened', 585 | issue: { 586 | number: 1, 587 | labels: [] 588 | } 589 | } 590 | 591 | await label({ 592 | prefix: 'triage', 593 | list: ['a', 'b', 'c'], 594 | }, getCommands(['/triage a c'])) 595 | 596 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/a', "triage/c"]}) 597 | await expect(deleteLabels).not.toHaveBeenCalled() 598 | }); 599 | 600 | describe('multiples', () => { 601 | it('false: should have one label', async function () { 602 | github.context.eventName = 'issues' 603 | github.context.payload = { 604 | action: 'opened', 605 | issue: { 606 | number: 1, 607 | labels: [{name: 'triage/b'}] 608 | } 609 | } 610 | 611 | await label({ 612 | prefix: 'triage', 613 | list: ['a', 'b', 'c'], 614 | multiple: false 615 | }, getCommands(['/triage a c'])) 616 | 617 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/c']}) 618 | await expect(deleteLabels).toHaveBeenCalledTimes(1) 619 | await expect(deleteLabels).toHaveBeenCalledWith('triage/b') 620 | await expect(postComments).not.toHaveBeenCalled() 621 | }) 622 | 623 | it('true: should have many label', async function () { 624 | github.context.eventName = 'issues' 625 | github.context.payload = { 626 | action: 'opened', 627 | issue: { 628 | number: 1, 629 | labels: [{name: 'triage/b'}] 630 | } 631 | } 632 | 633 | await label({ 634 | prefix: 'triage', 635 | list: ['a', 'b', 'c'], 636 | multiple: true 637 | }, getCommands(['/triage a c'])) 638 | 639 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/a', 'triage/c']}) 640 | await expect(deleteLabels).not.toHaveBeenCalled() 641 | await expect(postComments).not.toHaveBeenCalled() 642 | }) 643 | 644 | it('should have needs/triage removed when /triage accepted', async function () { 645 | github.context.eventName = 'issues' 646 | github.context.payload = { 647 | action: 'opened', 648 | issue: { 649 | number: 1, 650 | labels: [{name: 'needs/triage'}] 651 | } 652 | } 653 | 654 | await label({ 655 | prefix: 'triage', 656 | list: ['accepted', 'rejected'], 657 | multiple: false, 658 | }, getCommands(['/triage accepted'])) 659 | 660 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/accepted']}) 661 | await expect(deleteLabels).toHaveBeenCalledWith("needs/triage") 662 | }); 663 | 664 | it('should have needs/triage removed when /triage accepted when needs:true', async function () { 665 | github.context.eventName = 'issues' 666 | github.context.payload = { 667 | action: 'opened', 668 | issue: { 669 | number: 1, 670 | labels: [{name: 'needs/triage'}] 671 | } 672 | } 673 | 674 | await label({ 675 | prefix: 'triage', 676 | list: ['accepted', 'rejected'], 677 | needs: true, 678 | multiple: false, 679 | }, getCommands(['/triage accepted'])) 680 | 681 | await expect(postLabels).toHaveBeenCalledWith({labels: ['triage/accepted']}) 682 | await expect(deleteLabels).toHaveBeenCalledWith("needs/triage") 683 | }); 684 | }) 685 | }) 686 | 687 | describe('status', () => { 688 | it('should have pending status', async () => { 689 | github.context.eventName = 'pull_request' 690 | github.context.payload = { 691 | action: 'opened', 692 | pull_request: { 693 | number: 1, 694 | labels: [], 695 | head: { 696 | sha: 'abc' 697 | } 698 | } 699 | } 700 | 701 | await label({ 702 | prefix: 'triage', 703 | list: ['accepted'], 704 | needs: { 705 | status: { 706 | context: 'Triage' 707 | } 708 | } 709 | }, getCommands()) 710 | 711 | return expect(postStatus).toHaveBeenCalledWith({ 712 | "context": "Triage", 713 | "state": "pending" 714 | }) 715 | }) 716 | 717 | it('should have success status', async () => { 718 | github.context.eventName = 'pull_request' 719 | github.context.payload = { 720 | action: 'opened', 721 | pull_request: { 722 | number: 1, 723 | labels: [], 724 | head: { 725 | sha: 'abc' 726 | } 727 | } 728 | } 729 | 730 | await label({ 731 | prefix: 'triage', 732 | list: ['accepted'], 733 | needs: { 734 | status: { 735 | context: 'Triage' 736 | } 737 | } 738 | }, getCommands(['/triage accepted'])) 739 | 740 | return expect(postStatus).toHaveBeenCalledWith({ 741 | "context": "Triage", 742 | "state": "success" 743 | }) 744 | }) 745 | 746 | it('should have failure status with description', async () => { 747 | github.context.eventName = 'pull_request' 748 | github.context.payload = { 749 | action: 'opened', 750 | pull_request: { 751 | number: 1, 752 | labels: [], 753 | head: { 754 | sha: 'abc' 755 | } 756 | } 757 | } 758 | 759 | await label({ 760 | prefix: 'triage', 761 | list: ['accepted'], 762 | needs: { 763 | status: { 764 | context: 'Triage', 765 | description: "Fail Message" 766 | } 767 | } 768 | }, getCommands()) 769 | 770 | return expect(postStatus).toHaveBeenCalledWith({ 771 | "context": "Triage", 772 | "state": "failure", 773 | "description": "Fail Message", 774 | }) 775 | }) 776 | 777 | it('should have failure status with description', async () => { 778 | github.context.eventName = 'pull_request' 779 | github.context.payload = { 780 | action: 'opened', 781 | pull_request: { 782 | number: 1, 783 | labels: [], 784 | head: { 785 | sha: 'abc' 786 | } 787 | } 788 | } 789 | 790 | await label({ 791 | prefix: 'triage', 792 | list: ['accepted'], 793 | needs: { 794 | status: { 795 | context: 'Triage', 796 | description: { 797 | failure: "Fail Message" 798 | } 799 | } 800 | } 801 | }, getCommands()) 802 | 803 | return expect(postStatus).toHaveBeenCalledWith({ 804 | "context": "Triage", 805 | "state": "failure", 806 | "description": "Fail Message", 807 | }) 808 | }) 809 | 810 | it('should have success status with description', async () => { 811 | github.context.eventName = 'pull_request' 812 | github.context.payload = { 813 | action: 'opened', 814 | pull_request: { 815 | number: 1, 816 | labels: [], 817 | head: { 818 | sha: 'abc' 819 | } 820 | } 821 | } 822 | 823 | await label({ 824 | prefix: 'triage', 825 | list: ['accepted'], 826 | needs: { 827 | status: { 828 | context: 'Triage', 829 | description: { 830 | failure: 'No', 831 | success: "Success Message" 832 | } 833 | } 834 | } 835 | }, getCommands(['/triage accepted'])) 836 | 837 | return expect(postStatus).toHaveBeenCalledWith({ 838 | "context": "Triage", 839 | "state": "success", 840 | "description": "Success Message", 841 | }) 842 | }) 843 | 844 | it('should not have status', async () => { 845 | github.context.eventName = 'pull_request' 846 | github.context.payload = { 847 | action: 'opened', 848 | pull_request: { 849 | number: 1, 850 | labels: [], 851 | head: { 852 | sha: 'abc' 853 | } 854 | } 855 | } 856 | 857 | await label({ 858 | prefix: 'triage', 859 | list: ['accepted'], 860 | needs: true 861 | }, getCommands()) 862 | 863 | return expect(postStatus).not.toHaveBeenCalled() 864 | }) 865 | 866 | it('should have url', async () => { 867 | github.context.eventName = 'pull_request' 868 | github.context.payload = { 869 | action: 'opened', 870 | pull_request: { 871 | number: 1, 872 | labels: [], 873 | head: { 874 | sha: 'abc' 875 | } 876 | } 877 | } 878 | 879 | await label({ 880 | prefix: 'triage', 881 | list: ['accepted'], 882 | needs: { 883 | status: { 884 | context: 'Triage', 885 | url: 'https://google.com' 886 | } 887 | } 888 | }, getCommands()) 889 | 890 | return expect(postStatus).toHaveBeenCalledWith({ 891 | "context": "Triage", 892 | "state": "pending", 893 | "target_url": "https://google.com", 894 | }) 895 | }) 896 | 897 | it('should not have status because it is not a pull_request', async () => { 898 | github.context.eventName = 'issues' 899 | github.context.payload = { 900 | action: 'opened', 901 | issue: { 902 | number: 1, 903 | labels: [], 904 | } 905 | } 906 | 907 | await label({ 908 | prefix: 'triage', 909 | list: ['accepted'], 910 | needs: { 911 | status: { 912 | context: 'Triage', 913 | url: 'https://google.com' 914 | } 915 | } 916 | }, getCommands()) 917 | 918 | return expect(postStatus).not.toHaveBeenCalled() 919 | }); 920 | }) 921 | 922 | describe('scenario', () => { 923 | it('should have needs/kind removed when /kind fix is commented', async () => { 924 | github.context.eventName = 'issue_comment' 925 | github.context.payload = { 926 | action: 'created', 927 | comment: { 928 | id: 1, 929 | }, 930 | pull_request: { 931 | number: 1, 932 | labels: [{name: 'needs/kind'}, {name: 'kind/fix'}] 933 | } 934 | } 935 | 936 | await label({ 937 | prefix: 'kind', 938 | multiple: false, 939 | list: ["feature", "fix", "chore", "docs", "refactor", "dependencies"], 940 | needs: { 941 | comment: 'TEST' 942 | } 943 | }, getCommands(['/kind fix'])) 944 | 945 | await expect(deleteLabels).toHaveBeenCalledWith("needs/kind") 946 | await expect(deleteLabels).toHaveBeenCalledTimes(1) 947 | await expect(postLabels).not.toHaveBeenCalled() 948 | await expect(postComments).not.toHaveBeenCalled() 949 | }); 950 | 951 | it('needs/kind should not be called', async () => { 952 | github.context.eventName = 'issue_comment' 953 | github.context.payload = { 954 | action: 'created', 955 | comment: { 956 | id: 1, 957 | }, 958 | issue: { 959 | number: 1, 960 | labels: [{name: 'needs/kind'}] 961 | } 962 | } 963 | 964 | await label({ 965 | prefix: 'kind', 966 | multiple: false, 967 | list: ["feature"], 968 | needs: { 969 | comment: 'TEST' 970 | } 971 | }, getCommands(['/something else'])) 972 | 973 | await expect(postComments).not.toHaveBeenCalled() 974 | await expect(postLabels).not.toHaveBeenCalled() 975 | await expect(deleteLabels).not.toHaveBeenCalled() 976 | }); 977 | 978 | it('should have removed labels with command', async () => { 979 | github.context.eventName = 'issues' 980 | github.context.payload = { 981 | action: 'opened', 982 | issue: { 983 | number: 1, 984 | labels: [{name: 'kind/bug'}] 985 | } 986 | } 987 | 988 | await label({ 989 | prefix: 'triage', 990 | list: ['accepted'], 991 | needs: { 992 | comment: 'Required triage' 993 | } 994 | }, getCommands(['/area ui-ux'])) 995 | 996 | await label({ 997 | prefix: 'area', 998 | list: ['ui-ux', 'semantics', 'translation', 'security'], 999 | multiple: true, 1000 | needs: { 1001 | comment: 'Required area' 1002 | } 1003 | }, getCommands(['/area ui-ux'])) 1004 | 1005 | await expect(postComments).toHaveBeenCalledTimes(1) 1006 | await expect(postLabels).toHaveBeenCalledTimes(2) 1007 | await expect(deleteLabels).not.toHaveBeenCalled() 1008 | }); 1009 | 1010 | it('should have needs/triage added when sender is not whitelisted', async () => { 1011 | github.context.eventName = 'issue' 1012 | github.context.payload = { 1013 | action: 'opened', 1014 | issue: { 1015 | number: 1, 1016 | author_association: 'NONE' 1017 | } 1018 | } 1019 | 1020 | await label({ 1021 | prefix: 'triage', 1022 | multiple: false, 1023 | list: ["accepted"], 1024 | needs: { 1025 | comment: 'TEST' 1026 | }, 1027 | author_association: { 1028 | contributor: true 1029 | } 1030 | }, getCommands()) 1031 | 1032 | await expect(postLabels).toHaveBeenCalledWith({labels: ['needs/triage']}) 1033 | await expect(deleteLabels).not.toHaveBeenCalled() 1034 | await expect(postComments).toHaveBeenCalled() 1035 | }); 1036 | }) 1037 | --------------------------------------------------------------------------------