├── __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 |
4 |
5 |
6 |
7 |
8 |
9 |
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 | [](https://codecov.io/gh/BirthdayResearch/oss-governance-bot)
6 | 
7 | [](https://github.com/BirthdayResearch/oss-governance-bot/releases)
8 | [](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 | [](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 |
--------------------------------------------------------------------------------