├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── dependabot.yml ├── slash-command-dispatch.json └── workflows │ ├── automerge-dependabot.yml │ ├── ci.yml │ ├── hello-world-command.yml │ ├── ping-command.yml │ ├── slash-command-dispatch.yml │ └── update-major-version.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── __test__ ├── command-helper.unit.test.ts └── github-helper.int.test.ts ├── action.yml ├── dist └── index.js ├── docs ├── advanced-configuration.md ├── assets │ ├── comment-parsing.png │ ├── error-message-output.png │ ├── example-command.png │ └── slash-command-dispatch.png ├── examples.md ├── getting-started.md ├── updating.md └── workflow-dispatch.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── command-helper.ts ├── github-helper.ts ├── main.ts ├── octokit-client.ts └── utils.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "node": true, "jest": true }, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { "ecmaVersion": 9, "sourceType": "module" }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:import/errors", 10 | "plugin:import/warnings", 11 | "plugin:import/typescript", 12 | "plugin:prettier/recommended" 13 | ], 14 | "plugins": ["@typescript-eslint"], 15 | "rules": { 16 | "@typescript-eslint/camelcase": "off" 17 | }, 18 | "settings": { 19 | "import/resolver": { 20 | "typescript": {} 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: peter-evans -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "monday" 8 | labels: 9 | - "dependencies" 10 | 11 | - package-ecosystem: "npm" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | day: "monday" 16 | ignore: 17 | - dependency-name: "*" 18 | update-types: ["version-update:semver-major"] 19 | labels: 20 | - "dependencies" 21 | 22 | -------------------------------------------------------------------------------- /.github/slash-command-dispatch.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "command": "create", 4 | "permission": "write", 5 | "issue_type": "issue", 6 | "event_type_suffix": "-cmd" 7 | }, 8 | { 9 | "command": "delete", 10 | "permission": "write", 11 | "issue_type": "both", 12 | "allow_edits": true, 13 | "static_args": [ 14 | "some-unnamed-arg", 15 | "foo=bar" 16 | ] 17 | }, 18 | { 19 | "command": "update", 20 | "permission": "write", 21 | "issue_type": "issue", 22 | "dispatch_type": "workflow" 23 | }, 24 | { 25 | "command": "do-something-remotely", 26 | "permission": "write", 27 | "issue_type": "both", 28 | "repository": "peter-evans/slash-command-dispatch-processor", 29 | "event_type_suffix": "-cmd" 30 | }, 31 | { 32 | "command": "send-to-multiple-repos", 33 | "repository": "peter-evans/slash-command-dispatch-processor" 34 | }, 35 | { 36 | "command": "send-to-multiple-repos", 37 | "repository": "peter-evans/slash-command-dispatch" 38 | }, 39 | { 40 | "command": "analyze", 41 | "permission": "admin", 42 | "issue_type": "pull-request", 43 | "allow_edits": true, 44 | "event_type_suffix": "-cmd" 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /.github/workflows/automerge-dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Auto-merge Dependabot 2 | on: pull_request 3 | 4 | jobs: 5 | automerge: 6 | runs-on: ubuntu-latest 7 | if: github.actor == 'dependabot[bot]' 8 | steps: 9 | - uses: peter-evans/enable-pull-request-automerge@v3 10 | with: 11 | token: ${{ secrets.ACTIONS_BOT_TOKEN }} 12 | pull-request-number: ${{ github.event.pull_request.number }} 13 | merge-method: squash 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | paths-ignore: 6 | - 'README.md' 7 | - 'docs/**' 8 | pull_request: 9 | branches: [main] 10 | paths-ignore: 11 | - 'README.md' 12 | - 'docs/**' 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20.x 21 | cache: npm 22 | - run: npm ci 23 | - run: npm run build 24 | - run: npm run format-check 25 | - run: npm run lint 26 | - run: npm run test 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | name: dist 30 | path: dist 31 | 32 | package: 33 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 34 | needs: [build] 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: actions/download-artifact@v4 39 | with: 40 | name: dist 41 | path: dist 42 | - name: Create Pull Request 43 | uses: peter-evans/create-pull-request@v7 44 | with: 45 | token: ${{ secrets.ACTIONS_BOT_TOKEN }} 46 | commit-message: 'build: update distribution' 47 | title: Update distribution 48 | body: | 49 | - Updates the distribution for changes on `main` 50 | 51 | Auto-generated by [create-pull-request][1] 52 | 53 | [1]: https://github.com/peter-evans/create-pull-request 54 | branch: update-distribution 55 | -------------------------------------------------------------------------------- /.github/workflows/hello-world-command.yml: -------------------------------------------------------------------------------- 1 | name: Hello World Command 2 | on: 3 | repository_dispatch: 4 | types: [hello-world-local-command] 5 | jobs: 6 | helloWorld: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Add reaction 10 | uses: peter-evans/create-or-update-comment@v4 11 | with: 12 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 13 | reactions: hooray 14 | 15 | - name: Create URL to the run output 16 | id: vars 17 | run: echo "run-url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_OUTPUT 18 | 19 | - name: Create comment 20 | uses: peter-evans/create-or-update-comment@v4 21 | with: 22 | issue-number: ${{ github.event.client_payload.github.payload.issue.number }} 23 | body: | 24 | Hello @${{ github.event.client_payload.github.actor }}! 25 | 26 | [Click here to see the command run output][1] 27 | 28 | [1]: ${{ steps.vars.outputs.run-url }} 29 | -------------------------------------------------------------------------------- /.github/workflows/ping-command.yml: -------------------------------------------------------------------------------- 1 | name: Ping Command 2 | on: 3 | repository_dispatch: 4 | types: [ping-local-command] 5 | jobs: 6 | helloWorld: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Update comment 10 | uses: peter-evans/create-or-update-comment@v4 11 | with: 12 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 13 | body: | 14 | >pong ${{ github.event.client_payload.slash_command.args.all }} 15 | reactions: hooray 16 | -------------------------------------------------------------------------------- /.github/workflows/slash-command-dispatch.yml: -------------------------------------------------------------------------------- 1 | name: Slash Command Dispatch 2 | on: 3 | issue_comment: 4 | # Type "edited" added here for test purposes. Where possible, avoid 5 | # using to prevent processing unnecessary events. 6 | types: [created, edited] 7 | jobs: 8 | slashCommandDispatch: 9 | runs-on: ubuntu-latest 10 | steps: 11 | # Checkout is necessary here due to referencing a local action. 12 | # It's also necessary when using the 'config-from-file' option. 13 | # Otherwise, avoid using checkout to keep this workflow fast. 14 | - uses: actions/checkout@v4 15 | 16 | # Basic configuration 17 | - name: Slash Command Dispatch 18 | uses: ./ 19 | with: 20 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 21 | commands: | 22 | hello-world-local 23 | ping-local 24 | permission: none 25 | issue-type: issue 26 | 27 | # Advanced JSON configuration 28 | - name: Slash Command Dispatch (JSON) 29 | id: scd 30 | uses: ./ 31 | with: 32 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 33 | config: > 34 | [ 35 | { 36 | "command": "rebase", 37 | "permission": "admin", 38 | "repository": "peter-evans/slash-command-dispatch-processor", 39 | "issue_type": "pull-request" 40 | }, 41 | { 42 | "command": "help", 43 | "permission": "none", 44 | "issue_type": "issue", 45 | "repository": "peter-evans/slash-command-dispatch-processor" 46 | }, 47 | { 48 | "command": "help", 49 | "permission": "none", 50 | "issue_type": "pull-request", 51 | "repository": "peter-evans/slash-command-dispatch-processor", 52 | "event_type_suffix": "-pr-command" 53 | }, 54 | { 55 | "command": "example", 56 | "permission": "none", 57 | "issue_type": "issue", 58 | "repository": "peter-evans/slash-command-dispatch-processor" 59 | }, 60 | { 61 | "command": "hello-world", 62 | "permission": "none", 63 | "issue_type": "issue", 64 | "repository": "peter-evans/slash-command-dispatch-processor" 65 | }, 66 | { 67 | "command": "hello-world", 68 | "permission": "none", 69 | "issue_type": "pull-request", 70 | "repository": "peter-evans/slash-command-dispatch-processor", 71 | "event_type_suffix": "-pr-command" 72 | }, 73 | { 74 | "command": "hello-workflow", 75 | "permission": "none", 76 | "issue_type": "issue", 77 | "repository": "peter-evans/slash-command-dispatch-processor", 78 | "static_args": [ 79 | "repository=${{ github.repository }}", 80 | "comment-id=${{ github.event.comment.id }}", 81 | "issue-number=${{ github.event.issue.number }}", 82 | "actor=${{ github.actor }}" 83 | ], 84 | "dispatch_type": "workflow" 85 | }, 86 | { 87 | "command": "ping", 88 | "permission": "none", 89 | "issue_type": "issue", 90 | "repository": "peter-evans/slash-command-dispatch-processor" 91 | }, 92 | { 93 | "command": "black", 94 | "permission": "none", 95 | "issue_type": "pull-request", 96 | "repository": "peter-evans/slash-command-dispatch-processor" 97 | }, 98 | { 99 | "command": "reset-demo", 100 | "permission": "none", 101 | "issue_type": "pull-request", 102 | "repository": "peter-evans/slash-command-dispatch-processor" 103 | } 104 | ] 105 | 106 | - name: Edit comment with error message 107 | if: steps.scd.outputs.error-message 108 | uses: peter-evans/create-or-update-comment@v4 109 | with: 110 | comment-id: ${{ github.event.comment.id }} 111 | body: | 112 | > ${{ steps.scd.outputs.error-message }} 113 | 114 | # Advanced JSON configuration from file 115 | # (These commands do not do anything and are just a reference example) 116 | - name: Slash Command Dispatch (JSON file) 117 | uses: ./ 118 | with: 119 | token: ${{ secrets.REPO_ACCESS_TOKEN }} 120 | reactions: false 121 | config-from-file: .github/slash-command-dispatch.json 122 | -------------------------------------------------------------------------------- /.github/workflows/update-major-version.yml: -------------------------------------------------------------------------------- 1 | name: Update Major Version 2 | run-name: Update ${{ github.event.inputs.main_version }} to ${{ github.event.inputs.target }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | target: 8 | description: The target tag or reference 9 | required: true 10 | main_version: 11 | type: choice 12 | description: The major version tag to update 13 | options: 14 | - v4 15 | 16 | jobs: 17 | tag: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | token: ${{ secrets.ACTIONS_BOT_TOKEN }} 23 | fetch-depth: 0 24 | - name: Git config 25 | run: | 26 | git config user.name actions-bot 27 | git config user.email actions-bot@users.noreply.github.com 28 | - name: Tag new target 29 | run: git tag -f ${{ github.event.inputs.main_version }} ${{ github.event.inputs.target }} 30 | - name: Push new tag 31 | run: git push origin ${{ github.event.inputs.main_version }} --force 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.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 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Peter Evans 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slash Command Dispatch 2 | [![CI](https://github.com/peter-evans/slash-command-dispatch/workflows/CI/badge.svg)](https://github.com/peter-evans/slash-command-dispatch/actions?query=workflow%3ACI) 3 | [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Slash%20Command%20Dispatch-blue.svg?colorA=24292e&colorB=0366d6&style=flat&longCache=true&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAM6wAADOsB5dZE0gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAERSURBVCiRhZG/SsMxFEZPfsVJ61jbxaF0cRQRcRJ9hlYn30IHN/+9iquDCOIsblIrOjqKgy5aKoJQj4O3EEtbPwhJbr6Te28CmdSKeqzeqr0YbfVIrTBKakvtOl5dtTkK+v4HfA9PEyBFCY9AGVgCBLaBp1jPAyfAJ/AAdIEG0dNAiyP7+K1qIfMdonZic6+WJoBJvQlvuwDqcXadUuqPA1NKAlexbRTAIMvMOCjTbMwl1LtI/6KWJ5Q6rT6Ht1MA58AX8Apcqqt5r2qhrgAXQC3CZ6i1+KMd9TRu3MvA3aH/fFPnBodb6oe6HM8+lYHrGdRXW8M9bMZtPXUji69lmf5Cmamq7quNLFZXD9Rq7v0Bpc1o/tp0fisAAAAASUVORK5CYII=)](https://github.com/marketplace/actions/slash-command-dispatch) 4 | 5 | A GitHub action that facilitates ["ChatOps"](https://www.pagerduty.com/blog/what-is-chatops/) by creating dispatch events for slash commands. 6 | 7 | ### How does it work? 8 | 9 | The action runs in `issue_comment` event workflows and checks the first line of comments for slash commands. 10 | When a valid command is found it creates a [repository dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#repository_dispatch) event that includes a payload containing full details of the command and its context. 11 | It also supports creating [workflow dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events with defined input parameters. 12 | 13 | ### Why create dispatch events? 14 | 15 | "ChatOps" with slash commands can work in a basic way by parsing the commands during `issue_comment` events and immediately processing the command. 16 | In repositories with a lot of activity, the workflow queue will get backed up very quickly trying to handle new `issue_comment` events *and* process the commands themselves. 17 | 18 | Dispatching commands to be processed elsewhere keeps the workflow queue moving quickly. It essentially enables parallel processing of workflows. 19 | 20 | An additional benefit of dispatching is that it allows non-sensitive workloads to be run in public repositories to save using private repository GitHub Action minutes. 21 | 22 |
23 | 24 | ## Demos 25 | 26 | See it in action with the following live demos. 27 | 28 | - [ChatOps Demo in Issues](https://github.com/peter-evans/slash-command-dispatch/issues/3) 29 | - [ChatOps Demo in Pull Requests](https://github.com/peter-evans/slash-command-dispatch/pull/8) 30 | - [Slash command code formatting - Python](https://github.com/peter-evans/slash-command-dispatch/pull/28) 31 | 32 | ## Documentation 33 | 34 | - [Getting started](docs/getting-started.md) 35 | - [Examples](docs/examples.md) 36 | - [Standard configuration](#standard-configuration) 37 | - [Advanced configuration](docs/advanced-configuration.md) 38 | - [Workflow dispatch](docs/workflow-dispatch.md) 39 | - [Updating to v4](docs/updating.md) 40 | 41 | ## Dispatching commands 42 | 43 | ### Standard configuration 44 | 45 | The following workflow should be configured in the repository where commands will be dispatched from. This example will respond to comments containing the slash commands `/deploy`, `/integration-test` and `/build-docs`. 46 | 47 | ```yml 48 | name: Slash Command Dispatch 49 | on: 50 | issue_comment: 51 | types: [created] 52 | jobs: 53 | slashCommandDispatch: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Slash Command Dispatch 57 | uses: peter-evans/slash-command-dispatch@v4 58 | with: 59 | token: ${{ secrets.PAT }} 60 | commands: | 61 | deploy 62 | integration-test 63 | build-docs 64 | ``` 65 | 66 | Note that not specifying the `repository` input will mean that dispatch events are created in the *current* repository by default. It's perfectly fine to use the current repository and not dispatch events to a separate "processor" repository. 67 | 68 | This action also features [advanced configuration](docs/advanced-configuration.md) that allows each command to be configured individually if necessary. Use the standard configuration shown above unless you require advanced features. 69 | 70 | ### Action inputs 71 | 72 | | Input | Description | Default | 73 | | --- | --- | --- | 74 | | `token` | (**required**) A `repo` scoped [Personal Access Token (PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). Note: `GITHUB_TOKEN` *does not* work here. See [token](#token) for further details. | | 75 | | `reaction-token` | `GITHUB_TOKEN` or a `repo` scoped [Personal Access Token (PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). See [reaction-token](#reaction-token) for further details. | `GITHUB_TOKEN` | 76 | | `reactions` | Add reactions. :eyes: = seen, :rocket: = dispatched | `true` | 77 | | `commands` | (**required**) A comma or newline separated list of commands. | | 78 | | `permission` | The repository permission level required by the user to dispatch commands. See [permission](#permission) for further details. (`none`, `read`, `triage`, `write`, `maintain`, `admin`) | `write` | 79 | | `issue-type` | The issue type required for commands. (`issue`, `pull-request`, `both`) | `both` | 80 | | `allow-edits` | Allow edited comments to trigger command dispatches. | `false` | 81 | | `repository` | The full name of the repository to send the dispatch events. | Current repository | 82 | | `event-type-suffix` | The repository dispatch event type suffix for the commands. | `-command` | 83 | | `static-args` | A comma or newline separated list of arguments that will be dispatched with every command. | | 84 | | `dispatch-type` | The dispatch type; `repository` or `workflow`. See [dispatch-type](#dispatch-type) for further details. | `repository` | 85 | | `config` | | JSON configuration for commands. See [Advanced configuration](docs/advanced-configuration.md) | | 86 | | `config-from-file` | | JSON configuration from a file for commands. See [Advanced configuration](docs/advanced-configuration.md) | | 87 | 88 | #### `token` 89 | 90 | This action creates [repository_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#repository_dispatch) and [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. 91 | The default `GITHUB_TOKEN` does not have scopes to create these events, so a `repo` scoped [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) is required. 92 | If you will be dispatching commands to public repositories *only* then you can use the more limited `public_repo` scope. 93 | 94 | When using the action in a GitHub organization, the user the PAT is created on must be a member of the organization. 95 | Additionally, the PAT should be given the `org:read` scope. 96 | 97 | #### `reaction-token` 98 | 99 | If you don't specify a token for `reaction-token` it will use the default `GITHUB_TOKEN`. 100 | Reactions to comments will then be made by the @github-actions bot user. 101 | You can use a [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) if you would prefer reactions to be made by the user account associated with the PAT. 102 | 103 | ```yml 104 | - name: Slash Command Dispatch 105 | uses: peter-evans/slash-command-dispatch@v4 106 | with: 107 | token: ${{ secrets.PAT }} 108 | reaction-token: ${{ secrets.PAT }} 109 | commands: | 110 | deploy 111 | integration-test 112 | build-docs 113 | ``` 114 | 115 | #### `permission` 116 | 117 | This input sets the repository permission level required by the user to dispatch commands. 118 | It expects one of the [five repository permission levels](https://docs.github.com/en/github/setting-up-and-managing-organizations-and-teams/repository-permission-levels-for-an-organization#permission-levels-for-repositories-owned-by-an-organization), or `none`. 119 | From the least to greatest permission level they are `none`, `read`, `triage`, `write`, `maintain` and `admin`. 120 | 121 | Setting `write` as the required permission level means that any user with `write`, `maintain` or `admin` permission level will be able to execute commands. 122 | 123 | Note that `read`, `triage` and `maintain` are only applicable to organization repositories. 124 | For repositories owned by a user account there are only two permission levels, the repository owner (`admin`) and collaborators (`write`). 125 | 126 | There is a known issue with permissions when using [nested teams](https://docs.github.com/en/organizations/organizing-members-into-teams/about-teams#nested-teams) in a GitHub organization. See [here](https://github.com/peter-evans/slash-command-dispatch/issues/120) for further details. 127 | 128 | #### `dispatch-type` 129 | 130 | By default, the action creates [repository_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#repository_dispatch) events. 131 | Setting `dispatch-type` to `workflow` will instead create [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. 132 | There are significant differences in the action's behaviour when using `workflow` dispatch. See [workflow dispatch](docs/workflow-dispatch.md) for usage details. 133 | 134 | For the majority of use cases, the default `repository` dispatch will likely be the most suitable for new workflows. 135 | If you already have `workflow_dispatch` workflows, you can execute them with slash commands using this action. 136 | 137 | | Repository Dispatch (default) | Workflow Dispatch | 138 | | --- | --- | 139 | | Events are created with a `client_payload` giving the target workflow access to a wealth of useful [context properties](#accessing-contexts). | A `client_payload` cannot be sent with [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. The target workflow can only make use of up to 10 pre-defined inputs, the names of which must match named arguments supplied with the slash command. | 140 | | Slash commands can only execute workflows in the target repository's default branch. | Slash commands can execute workflows in any branch using the `ref` named argument. The reference can be a branch, tag, or a commit SHA. This can be useful to test workflows in PR branches before merging. | 141 | | Immediate command validation feedback is unavailable when creating the dispatch event. | Immediate command [validation feedback](docs/workflow-dispatch.md#validation-errors) is available as an action output. | 142 | 143 | ### How comments are parsed for slash commands 144 | 145 | Slash commands must be placed in the first line of the comment to be interpreted as a command. 146 | 147 | - The command must start with a `/` 148 | - The slash command extends to the last non-whitespace character on the first line 149 | - Anything after the first line is ignored and can be freely used for comments 150 | 151 | ![Comment Parsing](docs/assets/comment-parsing.png) 152 | 153 | ## Handling dispatched commands 154 | 155 | The following documentation applies to the `dispatch-type` default, `repository`, which creates [repository_dispatch](https://developer.github.com/v3/repos/#create-a-repository-dispatch-event) events. 156 | For `workflow` dispatch documentation, see [workflow dispatch](docs/workflow-dispatch.md). 157 | 158 | ### Event types 159 | 160 | Repository dispatch events have a `type` to distinguish between events. The `type` set by the action is a combination of the slash command and `event-type-suffix`. The `event-type-suffix` input defaults to `-command`. 161 | 162 | For example, if your slash command is `integration-test`, the event type will be `integration-test-command`. 163 | 164 | ```yml 165 | on: 166 | repository_dispatch: 167 | types: [integration-test-command] 168 | ``` 169 | 170 | ### Accessing contexts 171 | 172 | Commands are dispatched with a payload containing a number of contexts. 173 | 174 | #### `slash_command` context 175 | 176 | The slash command context contains the command and any arguments that were supplied by the user. 177 | It will also contain any static arguments if configured. 178 | 179 | To demonstrate, take the following configuration as an example. 180 | ```yml 181 | - uses: peter-evans/slash-command-dispatch@v4 182 | with: 183 | token: ${{ secrets.PAT }} 184 | commands: | 185 | deploy 186 | static-args: | 187 | production 188 | region=us-east-1 189 | ``` 190 | 191 | For the above example configuration, the slash command `/deploy branch=main dry-run reason="new feature"` will be converted to a JSON payload as follows. 192 | 193 | ```json 194 | "slash_command": { 195 | "command": "deploy", 196 | "args": { 197 | "all": "production region=us-east-1 branch=main dry-run reason=\"new feature\"", 198 | "unnamed": { 199 | "all": "production dry-run", 200 | "arg1": "production", 201 | "arg2": "dry-run" 202 | }, 203 | "named": { 204 | "region": "us-east-1", 205 | "branch": "main", 206 | "reason": "new feature" 207 | }, 208 | } 209 | } 210 | ``` 211 | 212 | The properties in the `slash_command` context from the above example can be used in a workflow as follows. 213 | 214 | ```yml 215 | - name: Output command and arguments 216 | run: | 217 | echo ${{ github.event.client_payload.slash_command.command }} 218 | echo ${{ github.event.client_payload.slash_command.args.all }} 219 | echo ${{ github.event.client_payload.slash_command.args.unnamed.all }} 220 | echo ${{ github.event.client_payload.slash_command.args.unnamed.arg1 }} 221 | echo ${{ github.event.client_payload.slash_command.args.unnamed.arg2 }} 222 | echo ${{ github.event.client_payload.slash_command.args.named.region }} 223 | echo ${{ github.event.client_payload.slash_command.args.named.branch }} 224 | echo ${{ github.event.client_payload.slash_command.args.named.reason }} 225 | # etc. 226 | ``` 227 | 228 | #### `github` and `pull_request` contexts 229 | 230 | The payload contains the `github` context of the `issue_comment` event at path `github.event.client_payload.github`. 231 | Additionally, if the comment was made in a pull request, the action calls the [GitHub API to fetch the pull request detail](https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request) and attaches it to the payload at path `github.event.client_payload.pull_request`. 232 | 233 | You can inspect the payload with the following step. 234 | ```yml 235 | - name: Dump the client payload context 236 | env: 237 | PAYLOAD_CONTEXT: ${{ toJson(github.event.client_payload) }} 238 | run: echo "$PAYLOAD_CONTEXT" 239 | ``` 240 | 241 | Note that the `client_payload.github.payload.issue.body` and `client_payload.pull_request.body` context properties will be truncated if they exceed 1000 characters. 242 | 243 | ### Responding to the comment on command completion 244 | 245 | Using [create-or-update-comment](https://github.com/peter-evans/create-or-update-comment) action there are a number of ways you can respond to the comment once the command has completed. 246 | 247 | The simplest response is to add a :tada: reaction to the comment. 248 | 249 | ```yml 250 | - name: Add reaction 251 | uses: peter-evans/create-or-update-comment@v4 252 | with: 253 | token: ${{ secrets.PAT }} 254 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 255 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 256 | reactions: hooray 257 | ``` 258 | 259 | Another option is to reply with a new comment containing a link to the run output. 260 | 261 | ```yml 262 | - name: Create URL to the run output 263 | id: vars 264 | run: echo "run-url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_OUTPUT 265 | 266 | - name: Create comment 267 | uses: peter-evans/create-or-update-comment@v4 268 | with: 269 | token: ${{ secrets.PAT }} 270 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 271 | issue-number: ${{ github.event.client_payload.github.payload.issue.number }} 272 | body: | 273 | [Command run output][1] 274 | 275 | [1]: ${{ steps.vars.outputs.run-url }} 276 | ``` 277 | 278 | ## License 279 | 280 | [MIT](LICENSE) 281 | -------------------------------------------------------------------------------- /__test__/command-helper.unit.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Inputs, 3 | Command, 4 | SlashCommandPayload, 5 | commandDefaults, 6 | getCommandsConfigFromInputs, 7 | getCommandsConfigFromJson, 8 | actorHasPermission, 9 | configIsValid, 10 | tokeniseCommand, 11 | getSlashCommandPayload 12 | } from '../lib/command-helper' 13 | 14 | describe('command-helper tests', () => { 15 | test('building config with required inputs only', async () => { 16 | const commands = ['list', 'of', 'slash', 'commands'] 17 | const inputs: Inputs = { 18 | token: '', 19 | reactionToken: '', 20 | reactions: true, 21 | commands: commands, 22 | permission: 'write', 23 | issueType: 'both', 24 | allowEdits: false, 25 | repository: 'peter-evans/slash-command-dispatch', 26 | eventTypeSuffix: '-command', 27 | staticArgs: [], 28 | dispatchType: 'repository', 29 | config: '', 30 | configFromFile: '' 31 | } 32 | const config = getCommandsConfigFromInputs(inputs) 33 | expect(config.length).toEqual(4) 34 | for (var i = 0; i < config.length; i++) { 35 | expect(config[i].command).toEqual(commands[i]) 36 | expect(config[i].permission).toEqual(commandDefaults.permission) 37 | expect(config[i].issue_type).toEqual(commandDefaults.issue_type) 38 | expect(config[i].allow_edits).toEqual(commandDefaults.allow_edits) 39 | expect(config[i].repository).toEqual(commandDefaults.repository) 40 | expect(config[i].event_type_suffix).toEqual( 41 | commandDefaults.event_type_suffix 42 | ) 43 | expect(config[i].static_args).toEqual(commandDefaults.static_args) 44 | expect(config[i].dispatch_type).toEqual(commandDefaults.dispatch_type) 45 | } 46 | }) 47 | 48 | test('building config with optional inputs', async () => { 49 | const commands = ['list', 'of', 'slash', 'commands'] 50 | const inputs: Inputs = { 51 | token: '', 52 | reactionToken: '', 53 | reactions: true, 54 | commands: commands, 55 | permission: 'admin', 56 | issueType: 'pull-request', 57 | allowEdits: true, 58 | repository: 'owner/repo', 59 | eventTypeSuffix: '-cmd', 60 | staticArgs: ['production', 'region=us-east-1'], 61 | dispatchType: 'workflow', 62 | config: '', 63 | configFromFile: '' 64 | } 65 | const config = getCommandsConfigFromInputs(inputs) 66 | expect(config.length).toEqual(4) 67 | for (var i = 0; i < config.length; i++) { 68 | expect(config[i].command).toEqual(commands[i]) 69 | expect(config[i].permission).toEqual(inputs.permission) 70 | expect(config[i].issue_type).toEqual(inputs.issueType) 71 | expect(config[i].allow_edits).toEqual(inputs.allowEdits) 72 | expect(config[i].repository).toEqual(inputs.repository) 73 | expect(config[i].event_type_suffix).toEqual(inputs.eventTypeSuffix) 74 | expect(config[i].static_args).toEqual(inputs.staticArgs) 75 | expect(config[i].dispatch_type).toEqual(inputs.dispatchType) 76 | } 77 | }) 78 | 79 | test('building config with required JSON only', async () => { 80 | const json = `[ 81 | { 82 | "command": "do-stuff" 83 | }, 84 | { 85 | "command": "test-all-the-things" 86 | } 87 | ]` 88 | const commands = ['do-stuff', 'test-all-the-things'] 89 | const config = getCommandsConfigFromJson(json) 90 | expect(config.length).toEqual(2) 91 | for (var i = 0; i < config.length; i++) { 92 | expect(config[i].command).toEqual(commands[i]) 93 | expect(config[i].permission).toEqual(commandDefaults.permission) 94 | expect(config[i].issue_type).toEqual(commandDefaults.issue_type) 95 | expect(config[i].allow_edits).toEqual(commandDefaults.allow_edits) 96 | expect(config[i].repository).toEqual(commandDefaults.repository) 97 | expect(config[i].event_type_suffix).toEqual( 98 | commandDefaults.event_type_suffix 99 | ) 100 | expect(config[i].static_args).toEqual(commandDefaults.static_args), 101 | expect(config[i].dispatch_type).toEqual(commandDefaults.dispatch_type) 102 | } 103 | }) 104 | 105 | test('building config with optional JSON properties', async () => { 106 | const json = `[ 107 | { 108 | "command": "do-stuff", 109 | "permission": "admin", 110 | "issue_type": "pull-request", 111 | "allow_edits": true, 112 | "repository": "owner/repo", 113 | "event_type_suffix": "-cmd" 114 | }, 115 | { 116 | "command": "test-all-the-things", 117 | "permission": "read", 118 | "static_args": [ 119 | "production", 120 | "region=us-east-1" 121 | ], 122 | "dispatch_type": "workflow" 123 | } 124 | ]` 125 | const commands = ['do-stuff', 'test-all-the-things'] 126 | const config = getCommandsConfigFromJson(json) 127 | expect(config.length).toEqual(2) 128 | expect(config[0].command).toEqual(commands[0]) 129 | expect(config[0].permission).toEqual('admin') 130 | expect(config[0].issue_type).toEqual('pull-request') 131 | expect(config[0].allow_edits).toBeTruthy() 132 | expect(config[0].repository).toEqual('owner/repo') 133 | expect(config[0].event_type_suffix).toEqual('-cmd') 134 | expect(config[0].static_args).toEqual([]) 135 | expect(config[0].dispatch_type).toEqual('repository') 136 | expect(config[1].command).toEqual(commands[1]) 137 | expect(config[1].permission).toEqual('read') 138 | expect(config[1].issue_type).toEqual(commandDefaults.issue_type) 139 | expect(config[1].static_args).toEqual(['production', 'region=us-east-1']) 140 | expect(config[1].dispatch_type).toEqual('workflow') 141 | }) 142 | 143 | test('valid config', async () => { 144 | const config: Command[] = [ 145 | { 146 | command: 'test', 147 | permission: 'write', 148 | issue_type: 'both', 149 | allow_edits: false, 150 | repository: 'peter-evans/slash-command-dispatch', 151 | event_type_suffix: '-command', 152 | static_args: [], 153 | dispatch_type: 'repository' 154 | } 155 | ] 156 | expect(configIsValid(config)).toEqual(null) 157 | }) 158 | 159 | test('invalid permission level in config', async () => { 160 | const config: Command[] = [ 161 | { 162 | command: 'test', 163 | permission: 'test-case-invalid-permission', 164 | issue_type: 'both', 165 | allow_edits: false, 166 | repository: 'peter-evans/slash-command-dispatch', 167 | event_type_suffix: '-command', 168 | static_args: [], 169 | dispatch_type: 'repository' 170 | } 171 | ] 172 | expect(configIsValid(config)).toEqual( 173 | `'test-case-invalid-permission' is not a valid 'permission'.` 174 | ) 175 | }) 176 | 177 | test('invalid issue type in config', async () => { 178 | const config: Command[] = [ 179 | { 180 | command: 'test', 181 | permission: 'write', 182 | issue_type: 'test-case-invalid-issue-type', 183 | allow_edits: false, 184 | repository: 'peter-evans/slash-command-dispatch', 185 | event_type_suffix: '-command', 186 | static_args: [], 187 | dispatch_type: 'repository' 188 | } 189 | ] 190 | expect(configIsValid(config)).toEqual( 191 | `'test-case-invalid-issue-type' is not a valid 'issue-type'.` 192 | ) 193 | }) 194 | 195 | test('invalid dispatch type in config', async () => { 196 | const config: Command[] = [ 197 | { 198 | command: 'test', 199 | permission: 'write', 200 | issue_type: 'both', 201 | allow_edits: false, 202 | repository: 'peter-evans/slash-command-dispatch', 203 | event_type_suffix: '-command', 204 | static_args: [], 205 | dispatch_type: 'test-case-invalid-dispatch-type' 206 | } 207 | ] 208 | expect(configIsValid(config)).toEqual( 209 | `'test-case-invalid-dispatch-type' is not a valid 'dispatch-type'.` 210 | ) 211 | }) 212 | 213 | test('actor does not have permission', async () => { 214 | expect(actorHasPermission('none', 'read')).toBeFalsy() 215 | expect(actorHasPermission('read', 'triage')).toBeFalsy() 216 | expect(actorHasPermission('triage', 'write')).toBeFalsy() 217 | expect(actorHasPermission('write', 'maintain')).toBeFalsy() 218 | expect(actorHasPermission('maintain', 'admin')).toBeFalsy() 219 | }) 220 | 221 | test('actor has permission', async () => { 222 | expect(actorHasPermission('read', 'none')).toBeTruthy() 223 | expect(actorHasPermission('triage', 'read')).toBeTruthy() 224 | expect(actorHasPermission('write', 'triage')).toBeTruthy() 225 | expect(actorHasPermission('admin', 'write')).toBeTruthy() 226 | expect(actorHasPermission('write', 'write')).toBeTruthy() 227 | }) 228 | 229 | test('command arguments are correctly tokenised', async () => { 230 | const command = `a b=c "d e" f-g="h i" "j \\"k\\"" l="m \\"n\\" o"` 231 | const commandTokens = [ 232 | `a`, 233 | `b=c`, 234 | `"d e"`, 235 | `f-g="h i"`, 236 | `"j \\"k\\""`, 237 | `l="m \\"n\\" o"` 238 | ] 239 | expect(tokeniseCommand(command)).toEqual(commandTokens) 240 | }) 241 | 242 | test('tokenisation of malformed command arguments', async () => { 243 | const command = `test arg named= quoted arg" named-arg="with \\"quoted value` 244 | const commandTokens = [ 245 | 'test', 246 | 'arg', 247 | 'named=', 248 | 'quoted', 249 | `arg"`, 250 | `named-arg="with`, 251 | '\\"quoted', 252 | 'value' 253 | ] 254 | expect(tokeniseCommand(command)).toEqual(commandTokens) 255 | }) 256 | 257 | test('slash command payload with unnamed args', async () => { 258 | const commandTokens = ['test', 'arg1', 'arg2', 'arg3'] 259 | const staticArgs = [] 260 | const payload: SlashCommandPayload = { 261 | command: 'test', 262 | args: { 263 | all: 'arg1 arg2 arg3', 264 | unnamed: { 265 | all: 'arg1 arg2 arg3', 266 | arg1: 'arg1', 267 | arg2: 'arg2', 268 | arg3: 'arg3' 269 | }, 270 | named: {} 271 | } 272 | } 273 | expect(getSlashCommandPayload(commandTokens, staticArgs)).toEqual(payload) 274 | }) 275 | 276 | test('slash command payload with named args', async () => { 277 | const commandTokens = [ 278 | 'test', 279 | 'branch_name=main', 280 | 'arg1', 281 | 'test-id=123', 282 | 'arg2' 283 | ] 284 | const staticArgs = [] 285 | const payload: SlashCommandPayload = { 286 | command: 'test', 287 | args: { 288 | all: 'branch_name=main arg1 test-id=123 arg2', 289 | unnamed: { 290 | all: 'arg1 arg2', 291 | arg1: 'arg1', 292 | arg2: 'arg2' 293 | }, 294 | named: { 295 | branch_name: 'main', 296 | 'test-id': '123' 297 | } 298 | } 299 | } 300 | expect(getSlashCommandPayload(commandTokens, staticArgs)).toEqual(payload) 301 | }) 302 | 303 | test('slash command payload with named args and static args', async () => { 304 | const commandTokens = ['test', 'branch=main', 'arg1', 'dry-run'] 305 | const staticArgs = ['production', 'region=us-east-1'] 306 | const payload: SlashCommandPayload = { 307 | command: 'test', 308 | args: { 309 | all: 'production region=us-east-1 branch=main arg1 dry-run', 310 | unnamed: { 311 | all: 'production arg1 dry-run', 312 | arg1: 'production', 313 | arg2: 'arg1', 314 | arg3: 'dry-run' 315 | }, 316 | named: { 317 | region: 'us-east-1', 318 | branch: 'main' 319 | } 320 | } 321 | } 322 | expect(getSlashCommandPayload(commandTokens, staticArgs)).toEqual(payload) 323 | }) 324 | 325 | test('slash command payload with quoted args', async () => { 326 | const commandTokens = [ 327 | `test`, 328 | `a`, 329 | `b=c`, 330 | `"d e"`, 331 | `f-g="h i"`, 332 | `"j \\"k\\""`, 333 | `l="m \\"n\\" o"` 334 | ] 335 | const staticArgs = [`msg="x y z"`] 336 | const payload: SlashCommandPayload = { 337 | command: `test`, 338 | args: { 339 | all: `msg="x y z" a b=c "d e" f-g="h i" "j \\"k\\"" l="m \\"n\\" o"`, 340 | unnamed: { 341 | all: `a "d e" "j \\"k\\""`, 342 | arg1: `a`, 343 | arg2: `d e`, 344 | arg3: `j \\"k\\"` 345 | }, 346 | named: { 347 | msg: `x y z`, 348 | b: `c`, 349 | 'f-g': `h i`, 350 | l: `m \\"n\\" o` 351 | } 352 | } 353 | } 354 | expect(getSlashCommandPayload(commandTokens, staticArgs)).toEqual(payload) 355 | }) 356 | 357 | test('slash command payload with malformed named args', async () => { 358 | const commandTokens = ['test', 'branch=', 'arg1', 'e.nv=prod', 'arg2'] 359 | const staticArgs = [] 360 | const payload: SlashCommandPayload = { 361 | command: 'test', 362 | args: { 363 | all: 'branch= arg1 e.nv=prod arg2', 364 | unnamed: { 365 | all: 'branch= arg1 e.nv=prod arg2', 366 | arg1: 'branch=', 367 | arg2: 'arg1', 368 | arg3: 'e.nv=prod', 369 | arg4: 'arg2' 370 | }, 371 | named: {} 372 | } 373 | } 374 | expect(getSlashCommandPayload(commandTokens, staticArgs)).toEqual(payload) 375 | }) 376 | 377 | test('slash command payload with malformed quoted args', async () => { 378 | const commandTokens = [ 379 | 'test', 380 | 'arg', 381 | 'named=', 382 | 'quoted', 383 | `arg"`, 384 | `named-arg="with`, 385 | `\\"quoted`, 386 | 'value' 387 | ] 388 | const staticArgs = [] 389 | const payload: SlashCommandPayload = { 390 | command: 'test', 391 | args: { 392 | all: `arg named= quoted arg" named-arg="with \\"quoted value`, 393 | unnamed: { 394 | all: `arg named= quoted arg" \\"quoted value`, 395 | arg1: 'arg', 396 | arg2: 'named=', 397 | arg3: 'quoted', 398 | arg4: `arg"`, 399 | arg5: `\\"quoted`, 400 | arg6: 'value' 401 | }, 402 | named: { 403 | 'named-arg': `"with` 404 | } 405 | } 406 | } 407 | expect(getSlashCommandPayload(commandTokens, staticArgs)).toEqual(payload) 408 | }) 409 | }) 410 | -------------------------------------------------------------------------------- /__test__/github-helper.int.test.ts: -------------------------------------------------------------------------------- 1 | import {GitHubHelper} from '../lib/github-helper' 2 | 3 | const token: string = process.env['REPO_SCOPED_PAT'] || 'not set' 4 | 5 | describe('github-helper tests', () => { 6 | it('tests getActorPermission returns "none" for non-existent collaborators', async () => { 7 | const githubHelper = new GitHubHelper(token) 8 | const actorPermission = await githubHelper.getActorPermission( 9 | {owner: 'peter-evans', repo: 'slash-command-dispatch'}, 10 | 'collaborator-does-not-exist' 11 | ) 12 | expect(actorPermission).toEqual('none') 13 | }) 14 | 15 | it('tests getActorPermission returns "admin"', async () => { 16 | const githubHelper = new GitHubHelper(token) 17 | const actorPermission = await githubHelper.getActorPermission( 18 | {owner: 'peter-evans', repo: 'slash-command-dispatch'}, 19 | 'peter-evans' 20 | ) 21 | expect(actorPermission).toEqual('admin') 22 | }) 23 | 24 | it('tests getActorPermission returns "write"', async () => { 25 | const githubHelper = new GitHubHelper(token) 26 | const actorPermission = await githubHelper.getActorPermission( 27 | {owner: 'peter-evans', repo: 'slash-command-dispatch'}, 28 | 'actions-bot' 29 | ) 30 | expect(actorPermission).toEqual('write') 31 | }) 32 | 33 | it('tests getActorPermission returns "triage" for an org repository collaborator', async () => { 34 | const githubHelper = new GitHubHelper(token) 35 | const actorPermission = await githubHelper.getActorPermission( 36 | {owner: 'slash-command-dispatch', repo: 'integration-test-fixture'}, 37 | 'test-case-machine-user' 38 | ) 39 | expect(actorPermission).toEqual('triage') 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Slash Command Dispatch' 2 | description: 'Facilitates "ChatOps" by creating dispatch events for slash commands' 3 | inputs: 4 | token: 5 | description: 'A repo scoped GitHub Personal Access Token.' 6 | required: true 7 | reaction-token: 8 | description: 'An optional GitHub token to use for reactions.' 9 | default: ${{ github.token }} 10 | reactions: 11 | description: 'Add reactions to comments containing commands.' 12 | default: true 13 | commands: 14 | description: 'A comma or newline separated list of commands.' 15 | required: true 16 | permission: 17 | description: 'The repository permission level required by the user to dispatch commands.' 18 | default: write 19 | issue-type: 20 | description: 'The issue type required for commands.' 21 | default: both 22 | allow-edits: 23 | description: 'Allow edited comments to trigger command dispatches.' 24 | default: false 25 | repository: 26 | description: 'The full name of the repository to send the dispatch events.' 27 | default: ${{ github.repository }} 28 | event-type-suffix: 29 | description: 'The repository dispatch event type suffix for the commands.' 30 | default: -command 31 | static-args: 32 | description: 'A comma or newline separated list of arguments that will be dispatched with every command.' 33 | dispatch-type: 34 | description: 'The dispatch type; `repository` or `workflow`.' 35 | default: repository 36 | config: 37 | description: 'JSON configuration for commands.' 38 | config-from-file: 39 | description: 'JSON configuration from a file for commands.' 40 | outputs: 41 | error-message: 42 | description: 'Validation errors when using `workflow` dispatch.' 43 | runs: 44 | using: 'node20' 45 | main: 'dist/index.js' 46 | branding: 47 | icon: 'target' 48 | color: 'gray-dark' 49 | -------------------------------------------------------------------------------- /docs/advanced-configuration.md: -------------------------------------------------------------------------------- 1 | # Advanced configuration 2 | 3 | ## What is advanced configuration? 4 | 5 | Due to the limitations of YAML based action inputs, basic configuration is not adequate to support unique configuration *per command*. 6 | 7 | For example, the following basic configuration means that all commands must have the same `admin` permission. 8 | 9 | ```yml 10 | - name: Slash Command Dispatch 11 | uses: peter-evans/slash-command-dispatch@v4 12 | with: 13 | token: ${{ secrets.PAT }} 14 | commands: | 15 | deploy 16 | integration-test 17 | build-docs 18 | permission: admin 19 | ``` 20 | 21 | To solve this issue, advanced JSON configuration allows each command to be configured individually. 22 | 23 | ## Dispatching commands 24 | 25 | There are two ways to specify JSON configuration for command dispatch. Directly in the workflow via the `config` input, OR, specifying a JSON config file via the `config-from-file` input. 26 | 27 | **Note**: It is recommended to write the JSON configuration directly in the workflow rather than use a file. Using the `config-from-file` input will be slightly slower due to requiring the repository to be checked out with `actions/checkout` so the file can be accessed. 28 | 29 | Here is a reference example workflow. Take care to use the correct [JSON property names](#advanced-action-inputs). 30 | 31 | ```yml 32 | name: Slash Command Dispatch 33 | on: 34 | issue_comment: 35 | types: [created] 36 | jobs: 37 | slashCommandDispatch: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Slash Command Dispatch 41 | uses: peter-evans/slash-command-dispatch@v4 42 | with: 43 | token: ${{ secrets.PAT }} 44 | config: > 45 | [ 46 | { 47 | "command": "rebase", 48 | "permission": "admin", 49 | "issue_type": "pull-request", 50 | "repository": "peter-evans/slash-command-dispatch-processor" 51 | }, 52 | { 53 | "command": "integration-test", 54 | "permission": "write", 55 | "issue_type": "both", 56 | "repository": "peter-evans/slash-command-dispatch-processor", 57 | "static_args": [ 58 | "production", 59 | "region=us-east-1" 60 | ] 61 | }, 62 | { 63 | "command": "create-ticket", 64 | "permission": "write", 65 | "issue_type": "issue", 66 | "allow_edits": true, 67 | "event_type_suffix": "-cmd", 68 | "dispatch_type": "workflow" 69 | } 70 | ] 71 | ``` 72 | 73 | The following workflow is an example using the `config-from-file` input to set JSON configuration. 74 | Note that `actions/checkout` is required to access the file. 75 | 76 | ```yml 77 | name: Slash Command Dispatch 78 | on: 79 | issue_comment: 80 | types: [created] 81 | jobs: 82 | slashCommandDispatch: 83 | runs-on: ubuntu-latest 84 | steps: 85 | - uses: actions/checkout@v3 86 | - name: Slash Command Dispatch 87 | uses: peter-evans/slash-command-dispatch@v4 88 | with: 89 | token: ${{ secrets.PAT }} 90 | config-from-file: .github/slash-command-dispatch.json 91 | ``` 92 | 93 | ## Advanced action inputs 94 | 95 | Advanced configuration requires a combination of YAML based inputs and JSON configuration. 96 | 97 | | Input | JSON Property | Description | Default | 98 | | --- | --- | --- | --- | 99 | | `token` | | (**required**) A `repo` scoped [Personal Access Token (PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). Note: `GITHUB_TOKEN` *does not* work here. See [token](https://github.com/peter-evans/slash-command-dispatch#token) for further details. | | 100 | | `reaction-token` | | `GITHUB_TOKEN` or a `repo` scoped [Personal Access Token (PAT)](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). See [reaction-token](https://github.com/peter-evans/slash-command-dispatch#reaction-token) for further details. | `GITHUB_TOKEN` | 101 | | `reactions` | | Add reactions. :eyes: = seen, :rocket: = dispatched | `true` | 102 | | | `command` | (**required**) The slash command. | | 103 | | | `permission` | The repository permission level required by the user to dispatch the command. (`none`, `read`, `triage`, `write`, `maintain`, `admin`) | `write` | 104 | | | `issue_type` | The issue type required for the command. (`issue`, `pull-request`, `both`) | `both` | 105 | | | `allow_edits` | Allow edited comments to trigger command dispatches. | `false` | 106 | | | `repository` | The full name of the repository to send the dispatch events. | Current repository | 107 | | | `event_type_suffix` | The repository dispatch event type suffix for the command. | `-command` | 108 | | | `static_args` | A string array of arguments that will be dispatched with the command. | `[]` | 109 | | | `dispatch_type` | The dispatch type; `repository` or `workflow`. | `repository` | 110 | | `config` | | JSON configuration for commands. | | 111 | | `config-from-file` | | JSON configuration from a file for commands. | | 112 | -------------------------------------------------------------------------------- /docs/assets/comment-parsing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peter-evans/slash-command-dispatch/a8d45245f13fda351d64a5ab0df097701f016a2a/docs/assets/comment-parsing.png -------------------------------------------------------------------------------- /docs/assets/error-message-output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peter-evans/slash-command-dispatch/a8d45245f13fda351d64a5ab0df097701f016a2a/docs/assets/error-message-output.png -------------------------------------------------------------------------------- /docs/assets/example-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peter-evans/slash-command-dispatch/a8d45245f13fda351d64a5ab0df097701f016a2a/docs/assets/example-command.png -------------------------------------------------------------------------------- /docs/assets/slash-command-dispatch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peter-evans/slash-command-dispatch/a8d45245f13fda351d64a5ab0df097701f016a2a/docs/assets/slash-command-dispatch.png -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [Use case: Execute command using a specific repository branch](#use-case-execute-command-using-a-specific-repository-branch) 4 | - [pytest](#pytest) 5 | - [Use case: Execute command to modify a pull request branch](#use-case-execute-command-to-modify-a-pull-request-branch) 6 | - [rebase](#rebase) 7 | - [black](#black) 8 | - [Help command](#help-command) 9 | 10 | ## Use case: Execute command using a specific repository branch 11 | 12 | This is a pattern for a slash command where a named argument specifies the branch to checkout. If the named argument is missing it defaults to `main`. For example, the following command will cause the command workflow to checkout the `develop` branch of the repository where the command was dispatched from. After the branch has been checked out in the command workflow, scripts, tools or actions may be executed against it. 13 | 14 | ``` 15 | /do-something branch=develop 16 | ``` 17 | 18 | In the following command workflow, `PAT` is a `repo` scoped [Personal Access Token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 19 | 20 | ```yml 21 | name: do-something-command 22 | on: 23 | repository_dispatch: 24 | types: [do-something-command] 25 | jobs: 26 | doSomething: 27 | runs-on: ubuntu-latest 28 | steps: 29 | # Get the branch name 30 | - name: Get the target branch name 31 | id: vars 32 | run: | 33 | branch=${{ github.event.client_payload.slash_command.args.named.branch }} 34 | if [[ -z "$branch" ]]; then branch="main"; fi 35 | echo "branch=$branch" >> $GITHUB_OUTPUT 36 | 37 | # Checkout the branch to test 38 | - uses: actions/checkout@v3 39 | with: 40 | token: ${{ secrets.PAT }} 41 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 42 | ref: ${{ steps.vars.outputs.branch }} 43 | 44 | # Execute scripts, tools or actions 45 | - name: Do something 46 | run: | 47 | # Execute a script, tool or action here 48 | # 49 | echo "Do something" 50 | 51 | # Add reaction to the comment 52 | - name: Add reaction 53 | uses: peter-evans/create-or-update-comment@v4 54 | with: 55 | token: ${{ secrets.PAT }} 56 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 57 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 58 | reactions: hooray 59 | ``` 60 | 61 | ### pytest 62 | 63 | This is a real example that uses this pattern to execute the Python test tool [pytest](https://github.com/pytest-dev/pytest/) against a specific branch. 64 | 65 | ``` 66 | /pytest branch=develop -v -s 67 | ``` 68 | 69 | In the following command workflow, note how the unnamed arguments are passed to the `pytest` tool with the property `args.unnamed.all`. 70 | 71 | ```yml 72 | name: pytest 73 | on: 74 | repository_dispatch: 75 | types: [pytest-command] 76 | jobs: 77 | pytest: 78 | runs-on: ubuntu-latest 79 | steps: 80 | # Get the branch name 81 | - name: Get the target branch name 82 | id: vars 83 | run: | 84 | branch=${{ github.event.client_payload.slash_command.args.named.branch }} 85 | if [[ -z "$branch" ]]; then branch="main"; fi 86 | echo "branch=$branch" >> $GITHUB_OUTPUT 87 | 88 | # Checkout the branch to test 89 | - uses: actions/checkout@v3 90 | with: 91 | token: ${{ secrets.PAT }} 92 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 93 | ref: ${{ steps.vars.outputs.branch }} 94 | 95 | # Setup Python environment 96 | - uses: actions/setup-python@v3 97 | 98 | # Install pytest 99 | - name: Install pytest 100 | run: | 101 | pip install -U pytest 102 | pytest --version 103 | 104 | # Install requirements 105 | - name: Install requirements 106 | run: pip install -r requirements.txt 107 | 108 | # Execute pytest 109 | - name: Execute pytest 110 | run: pytest ${{ github.event.client_payload.slash_command.args.unnamed.all }} 111 | 112 | # Add reaction to the comment 113 | - name: Add reaction 114 | uses: peter-evans/create-or-update-comment@v4 115 | with: 116 | token: ${{ secrets.PAT }} 117 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 118 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 119 | reactions: hooray 120 | ``` 121 | 122 | ## Use case: Execute command to modify a pull request branch 123 | 124 | This is a pattern for a slash command used in pull request comments. It checks out the pull request branch and allows further scripts, tools and action steps to modify it. 125 | 126 | ``` 127 | /fix-pr 128 | ``` 129 | 130 | In the dispatch configuration for this command pattern, `issue-type` should be set to `pull-request`. This will prevent it from being dispatched from regular issue comments where it will fail. 131 | 132 | In the following command workflow, `PAT` is a `repo` scoped [Personal Access Token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 133 | 134 | ```yml 135 | name: fix-pr-command 136 | on: 137 | repository_dispatch: 138 | types: [fix-pr-command] 139 | jobs: 140 | fixPr: 141 | runs-on: ubuntu-latest 142 | steps: 143 | # Checkout the pull request branch 144 | - uses: actions/checkout@v3 145 | with: 146 | token: ${{ secrets.PAT }} 147 | repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} 148 | ref: ${{ github.event.client_payload.pull_request.head.ref }} 149 | 150 | # Commit changes to the PR branch 151 | - name: Commit changes to the PR branch 152 | run: | 153 | # Make changes to commit here 154 | # 155 | git config --global user.name 'actions-bot' 156 | git config --global user.email '58130806+actions-bot@users.noreply.github.com' 157 | git commit -am "[fix-pr-command] fixes" 158 | git push 159 | 160 | - name: Add reaction 161 | uses: peter-evans/create-or-update-comment@v4 162 | with: 163 | token: ${{ secrets.PAT }} 164 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 165 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 166 | reactions: hooray 167 | ``` 168 | 169 | ### rebase 170 | 171 | This rebase command is a working example and demonstrates a using slash command to modify a pull request. 172 | 173 | ``` 174 | /rebase 175 | ``` 176 | 177 | In the following command workflow, note how the pull request `rebaseable` context property is checked to see whether or not GitHub has determined a safe rebase is possible. 178 | 179 | ```yml 180 | name: rebase-command 181 | on: 182 | repository_dispatch: 183 | types: [rebase-command] 184 | jobs: 185 | rebase: 186 | if: github.event.client_payload.pull_request.rebaseable == true 187 | runs-on: ubuntu-latest 188 | steps: 189 | - name: Checkout pull request 190 | uses: actions/checkout@v3 191 | with: 192 | token: ${{ secrets.PAT }} 193 | repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} 194 | ref: ${{ github.event.client_payload.pull_request.head.ref }} 195 | fetch-depth: 0 196 | 197 | - name: Rebase 198 | run: | 199 | git config --global user.name '${{ github.event.client_payload.github.actor }}' 200 | git config --global user.email '${{ github.event.client_payload.github.actor }}@users.noreply.github.com' 201 | git remote add base https://x-access-token:${{ secrets.PAT }}@github.com/${{ github.event.client_payload.pull_request.base.repo.full_name }}.git 202 | git fetch base ${{ github.event.client_payload.pull_request.base.ref }} 203 | git rebase base/${{ github.event.client_payload.pull_request.base.ref }} 204 | git push --force-with-lease 205 | 206 | - name: Update comment 207 | uses: peter-evans/create-or-update-comment@v4 208 | with: 209 | token: ${{ secrets.PAT }} 210 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 211 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 212 | body: | 213 | >Pull request successfully rebased 214 | reactions: hooray 215 | 216 | notRebaseable: 217 | if: github.event.client_payload.pull_request.rebaseable != true 218 | runs-on: ubuntu-latest 219 | steps: 220 | - name: Update comment 221 | uses: peter-evans/create-or-update-comment@v4 222 | with: 223 | token: ${{ secrets.PAT }} 224 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 225 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 226 | body: | 227 | >Pull request is not rebaseable 228 | reactions: hooray 229 | ``` 230 | 231 | ### black 232 | 233 | In this real example, a pull request is modified by formatting Python code using [Black](https://github.com/psf/black). 234 | 235 | ``` 236 | /black 237 | ``` 238 | 239 | In the following command workflow, note how a step `if` condition checks to see if anything should be committed. 240 | 241 | ```yml 242 | name: black-command 243 | on: 244 | repository_dispatch: 245 | types: [black-command] 246 | jobs: 247 | black: 248 | runs-on: ubuntu-latest 249 | steps: 250 | # Checkout the pull request branch 251 | - uses: actions/checkout@v3 252 | with: 253 | token: ${{ secrets.PAT }} 254 | repository: ${{ github.event.client_payload.pull_request.head.repo.full_name }} 255 | ref: ${{ github.event.client_payload.pull_request.head.ref }} 256 | 257 | # Setup Python environment 258 | - uses: actions/setup-python@v3 259 | 260 | # Install black 261 | - name: Install black 262 | run: pip install black 263 | 264 | # Execute black in check mode 265 | - name: Black 266 | id: black 267 | run: | 268 | format=$(black --check --quiet . || echo "true") 269 | echo "format=$format" >> $GITHUB_OUTPUT 270 | 271 | # Execute black and commit the change to the PR branch 272 | - name: Commit to the PR branch 273 | if: steps.black.outputs.format == 'true' 274 | run: | 275 | black . 276 | git config --global user.name 'actions-bot' 277 | git config --global user.email '58130806+actions-bot@users.noreply.github.com' 278 | git commit -am "[black-command] fixes" 279 | git push 280 | 281 | - name: Add reaction 282 | uses: peter-evans/create-or-update-comment@v4 283 | with: 284 | token: ${{ secrets.PAT }} 285 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 286 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 287 | reactions: hooray 288 | ``` 289 | 290 | ## Help command 291 | 292 | The following is an example command workflow to return details of available commands when a user issues the `/help` command. 293 | 294 | ```yml 295 | name: help-command 296 | on: 297 | repository_dispatch: 298 | types: [help-command] 299 | jobs: 300 | help: 301 | runs-on: ubuntu-latest 302 | steps: 303 | - name: Update comment 304 | uses: peter-evans/create-or-update-comment@v4 305 | with: 306 | token: ${{ secrets.ACTIONS_BOT_TOKEN }} 307 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 308 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 309 | body: | 310 | > Command | Description 311 | > --- | --- 312 | > /hello-world | Receive a greeting from the world 313 | > /ping [\ ...] | Echos back a list of arguments 314 | > /hello-world-local | Receive a greeting from the world (local execution) 315 | > /ping-local [\ ...] | Echos back a list of arguments (local execution) 316 | reactions: hooray 317 | ``` 318 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Follow this guide to get started with a working `/example` command. 4 | 5 | ## Command processing setup 6 | 7 | 1. Create a new repository called, for example, `slash-command-processor`. 8 | This will be the repository that commands are dispatched to for processing. 9 | 10 | 2. In your new repository, create the following workflow at `.github/workflows/example-command.yml`. 11 | 12 | ```yml 13 | name: example-command 14 | on: 15 | repository_dispatch: 16 | types: [example-command] 17 | jobs: 18 | example: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Add reaction 22 | uses: peter-evans/create-or-update-comment@v4 23 | with: 24 | token: ${{ secrets.PAT }} 25 | repository: ${{ github.event.client_payload.github.payload.repository.full_name }} 26 | comment-id: ${{ github.event.client_payload.github.payload.comment.id }} 27 | reactions: hooray 28 | ``` 29 | 30 | 3. Create a `repo` scoped Personal Access Token (PAT) by following [this guide](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token). 31 | 32 | 4. Go to your repository `Settings` -> `Secrets and variables` -> `Actions` and `New repository secret`. 33 | 34 | **Name**: `PAT` 35 | 36 | **Value**: (The PAT created in step 3) 37 | 38 | Command processing setup is complete! Now we need to setup command dispatch for our `/example` command. 39 | 40 | ## Command dispatch setup 41 | 42 | 1. Choose a repository or create a new repository to dispatch commands from. 43 | This will be the repository where issue and pull request comments will be monitored for slash commands. 44 | 45 | In the repository, create the following workflow at `.github/workflows/slash-command-dispatch.yml`. 46 | 47 | **Note**: Change `your-github-username/slash-command-processor` to reference your command processor repository created in the [previous section](#command-processing-setup). 48 | 49 | ```yml 50 | name: Slash Command Dispatch 51 | on: 52 | issue_comment: 53 | types: [created] 54 | jobs: 55 | slashCommandDispatch: 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Slash Command Dispatch 59 | uses: peter-evans/slash-command-dispatch@v4 60 | with: 61 | token: ${{ secrets.PAT }} 62 | commands: example 63 | repository: your-github-username/slash-command-processor 64 | ``` 65 | 66 | 2. Create a new `repo` scoped [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token), OR, use the one created at step 3 of the [previous section](#command-processing-setup). 67 | 68 | 3. Go to your repository `Settings` -> `Secrets` and `Add a new secret`. 69 | 70 | **Name**: `PAT` 71 | 72 | **Value**: (The PAT created in step 2) 73 | 74 | Command dispatch setup is complete! Now let's test our `/example` command. 75 | 76 | ## Testing the command 77 | 78 | 1. Create a new GitHub Issue in the repository you chose to dispatch commands from. 79 | 80 | 2. Add a new comment with the text `/example`. 81 | 82 | Once the command completes you should see all three reactions on your comment. 83 | 84 | ![Example Command](assets/example-command.png) 85 | 86 | Now you can start to tweak the command and make it do something useful! 87 | -------------------------------------------------------------------------------- /docs/updating.md: -------------------------------------------------------------------------------- 1 | ## Updating from `v3` to `v4` 2 | 3 | ### Breaking changes 4 | 5 | - If using self-hosted runners or GitHub Enterprise Server, there are minimum requirements for `v4` to run. See "What's new" below for details. 6 | 7 | ### What's new 8 | 9 | - Updated runtime to Node.js 20 10 | - The action now requires a minimum version of [v2.308.0](https://github.com/actions/runner/releases/tag/v2.308.0) for the Actions runner. Update self-hosted runners to v2.308.0 or later to ensure compatibility. 11 | 12 | ## Updating from `v2` to `v3` 13 | 14 | ### Breaking changes 15 | 16 | - If using self-hosted runners or GitHub Enterprise Server, there are minimum requirements for `v3` to run. See "What's new" below for details. 17 | 18 | ### What's new 19 | 20 | - Updated runtime to Node.js 16 21 | - The action now requires a minimum version of v2.285.0 for the [Actions Runner](https://github.com/actions/runner/releases/tag/v2.285.0). 22 | - If using GitHub Enterprise Server, the action requires [GHES 3.4](https://docs.github.com/en/enterprise-server@3.4/admin/release-notes) or later. 23 | 24 | ## Updating from `v1` to `v2` 25 | 26 | ### Breaking changes 27 | 28 | - The format of the `slash_command` context has been changed to prevent an issue where named arguments can overwrite other properties of the payload. 29 | 30 | The following diff shows how the `slash_command` context has changed for the example command `/deploy branch=main smoke-test dry-run reason="new feature"`. 31 | 32 | ```diff 33 | "slash_command": { 34 | "command": "deploy", 35 | - "args": "branch=main smoke-test dry-run reason=\"new feature\"", 36 | - "unnamed_args": "smoke-test dry-run", 37 | - "arg1": "smoke-test", 38 | - "arg2": "dry-run" 39 | - "branch": "main", 40 | - "reason": "new feature" 41 | + "args": { 42 | + "all": "branch=main smoke-test dry-run reason=\"new feature\"", 43 | + "unnamed": { 44 | + "all": "smoke-test dry-run", 45 | + "arg1": "smoke-test", 46 | + "arg2": "dry-run" 47 | + }, 48 | + "named": { 49 | + "branch": "main", 50 | + "reason": "new feature" 51 | + }, 52 | + } 53 | } 54 | ``` 55 | 56 | - The `named-args` input (standard configuration) and `named_args` JSON property (advanced configuration) have been removed. Named arguments will now always be parsed and added to the `slash_command` context. 57 | 58 | - The `client_payload.github.payload.issue.body` and `client_payload.pull_request.body` context properties will now be truncated if they exceed 1000 characters. 59 | 60 | ### New features 61 | 62 | - Commands can now be dispatched via the new [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event. For standard configuration, set the new `dispatch-type` input to `workflow`. For advanced configuration, set the `dispatch_type` JSON property of a command to `workflow`. 63 | There are significant differences in the action's behaviour when using `workflow` dispatch. See [workflow dispatch](workflow-dispatch.md) for usage details. 64 | 65 | - Added a new input `static-args` (standard configuration), and a new JSON property `static_args` (advanced configuration). This is a list of arguments that will always be dispatched with commands. 66 | 67 | Standard configuration: 68 | ```yml 69 | static-args: | 70 | production 71 | region=us-east-1 72 | ``` 73 | Advanced configuration: 74 | ```json 75 | "static_args": [ 76 | "production", 77 | "region=us-east-1" 78 | ] 79 | ``` 80 | 81 | - Slash command arguments can now be double-quoted to allow for argument values containing spaces. 82 | 83 | e.g. 84 | ``` 85 | /deploy branch=main dry-run reason="new feature" 86 | ``` 87 | ``` 88 | /send "hello world!" 89 | ``` 90 | 91 | - The `commands` input can now be newline separated, or comma-separated. 92 | 93 | e.g. 94 | ```yml 95 | commands: | 96 | deploy 97 | integration-test 98 | build-docs 99 | ``` 100 | -------------------------------------------------------------------------------- /docs/workflow-dispatch.md: -------------------------------------------------------------------------------- 1 | # Workflow dispatch 2 | 3 | This documentation applies when the `dispatch-type` input is set to `workflow`, which instructs the action to create [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. 4 | 5 | To learn about the `workflow_dispatch` event, see GitHub's documentation on [manually running a workflow](https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow). 6 | 7 | ## Action behaviour 8 | 9 | There are significant differences in the action's behaviour when using `workflow` dispatch. 10 | 11 | - When issuing a slash command with arguments, *only named arguments are accepted*. Unnamed arguments will be ignored. 12 | - A maximum of 10 named arguments are accepted. Additional named arguments after the first 10 will be ignored. 13 | - `ref` is a reserved named argument that does not count towards the maximum of 10. This is used to specify the target reference of the command. The reference can be a branch, tag, or a commit SHA. If you omit the `ref` argument, the target repository's default branch will be used. 14 | - A `client_payload` context cannot be sent with [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. The target workflow can only make use of up to 10 pre-defined inputs, the names of which must match named arguments supplied with the slash command. 15 | 16 | ## Handling dispatched commands 17 | 18 | ### Workflow name 19 | 20 | It is important to name the `workflow_dispatch` event workflow correctly since the action targets the workflow based on its filename. 21 | The target filename is a combination of the command name and the `event-type-suffix`. 22 | Additionally, the file extension must be `.yml`. 23 | 24 | For the following example configuration, the target workflows are: 25 | - `deploy-command.yml` 26 | - `integration-test-command.yml` 27 | - `build-docs-command.yml` 28 | 29 | ```yml 30 | name: Slash Command Dispatch 31 | on: 32 | issue_comment: 33 | types: [created] 34 | jobs: 35 | slashCommandDispatch: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Slash Command Dispatch 39 | uses: peter-evans/slash-command-dispatch@v4 40 | with: 41 | token: ${{ secrets.PAT }} 42 | commands: | 43 | deploy 44 | integration-test 45 | build-docs 46 | dispatch-type: workflow 47 | ``` 48 | 49 | ### Responding to the comment on command completion 50 | 51 | In order to respond to the comment where the slash command was made we need to pass the `comment-id` and `repository` (if the target workflow is not in the current repository). Set static arguments as follows. Note that these static arguments will count towards the maximum of 10 named arguments. 52 | 53 | Using basic configuration: 54 | ```yml 55 | static-args: | 56 | repository=${{ github.repository }} 57 | comment-id=${{ github.event.comment.id }} 58 | ``` 59 | 60 | Using advanced configuration: 61 | ```json 62 | "static_args": [ 63 | "repository=${{ github.repository }}", 64 | "comment-id=${{ github.event.comment.id }}" 65 | ] 66 | ``` 67 | 68 | The target workflow must define these arguments as inputs. 69 | 70 | ```yml 71 | on: 72 | workflow_dispatch: 73 | inputs: 74 | repository: 75 | description: 'The repository from which the slash command was dispatched' 76 | required: true 77 | comment-id: 78 | description: 'The comment-id of the slash command' 79 | required: true 80 | ``` 81 | 82 | Using [create-or-update-comment](https://github.com/peter-evans/create-or-update-comment) action there are a number of ways you can respond to the comment once the command has completed. 83 | 84 | The simplest response is to add a :tada: reaction to the comment. 85 | 86 | ```yml 87 | - name: Add reaction 88 | uses: peter-evans/create-or-update-comment@v4 89 | with: 90 | token: ${{ secrets.PAT }} 91 | repository: ${{ github.event.inputs.repository }} 92 | comment-id: ${{ github.event.inputs.comment-id }} 93 | reactions: hooray 94 | ``` 95 | 96 | ## Validation errors 97 | 98 | When creating the [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event, the GitHub API will return validation errors. In the following cases the action will issue a warning (visible in the Actions log), and set the action output `error-message`. 99 | 100 | - `Required input '...' not provided` - A required input for the workflow was not supplied as a named argument. 101 | - `Unexpected inputs provided` - Named arguments were supplied that are not defined as workflow inputs. 102 | - `No ref found for: ...` - The supplied `ref` does not exist in the target repository. 103 | - `Workflow does not have 'workflow_dispatch' trigger` - The target workflow doesn't define `on: workflow_dispatch`, OR, the supplied `ref` doesn't contain the target workflow. 104 | 105 | The `error-message` output can be used to provide feedback to the user as follows. Note that the action step needs an `id` to access outputs. 106 | 107 | ```yml 108 | - name: Slash Command Dispatch 109 | id: scd 110 | uses: peter-evans/slash-command-dispatch@v4 111 | with: 112 | token: ${{ secrets.PAT }} 113 | commands: | 114 | deploy 115 | integration-test 116 | build-docs 117 | dispatch-type: workflow 118 | 119 | - name: Edit comment with error message 120 | if: steps.scd.outputs.error-message 121 | uses: peter-evans/create-or-update-comment@v4 122 | with: 123 | comment-id: ${{ github.event.comment.id }} 124 | body: | 125 | > ${{ steps.scd.outputs.error-message }} 126 | ``` 127 | 128 | ![Comment Parsing](assets/error-message-output.png) 129 | -------------------------------------------------------------------------------- /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 | process.env = Object.assign(process.env, { 13 | GITHUB_REPOSITORY: "peter-evans/slash-command-dispatch" 14 | }) 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slash-command-dispatch", 3 | "version": "4.0.0", 4 | "private": true, 5 | "description": "Facilitates 'ChatOps' by creating dispatch events for slash commands", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc && ncc build", 9 | "format": "prettier --write '**/*.ts'", 10 | "format-check": "prettier --check '**/*.ts'", 11 | "lint": "eslint src/**/*.ts", 12 | "test": "jest unit", 13 | "test:int": "jest int" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/peter-evans/slash-command-dispatch.git" 18 | }, 19 | "keywords": [ 20 | "slash", 21 | "command", 22 | "dispatch" 23 | ], 24 | "author": "Peter Evans", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/peter-evans/slash-command-dispatch/issues" 28 | }, 29 | "homepage": "https://github.com/peter-evans/slash-command-dispatch#readme", 30 | "dependencies": { 31 | "@actions/core": "^1.11.1", 32 | "@actions/github": "^6.0.1", 33 | "@octokit/core": "^6.1.5", 34 | "@octokit/plugin-paginate-rest": "^11.6.0", 35 | "@octokit/plugin-rest-endpoint-methods": "^6.8.1", 36 | "http-proxy-agent": "^5.0.0", 37 | "https-proxy-agent": "^5.0.1" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^27.0.3", 41 | "@types/node": "^16.18.126", 42 | "@typescript-eslint/parser": "^5.62.0", 43 | "@vercel/ncc": "^0.38.3", 44 | "eslint": "^8.57.1", 45 | "eslint-import-resolver-typescript": "^3.10.1", 46 | "eslint-plugin-github": "^4.10.2", 47 | "eslint-plugin-jest": "^25.7.0", 48 | "eslint-plugin-prettier": "^5.4.1", 49 | "jest": "^27.5.1", 50 | "jest-circus": "^27.5.1", 51 | "js-yaml": "^4.1.0", 52 | "prettier": "^3.5.3", 53 | "ts-jest": "^27.1.5", 54 | "typescript": "^4.9.5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/command-helper.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as fs from 'fs' 3 | import {inspect} from 'util' 4 | import * as utils from './utils' 5 | 6 | // Tokenise command and arguments 7 | // Support escaped quotes within quotes. https://stackoverflow.com/a/5696141/11934042 8 | const TOKENISE_REGEX = 9 | /\S+="[^"\\]*(?:\\.[^"\\]*)*"|"[^"\\]*(?:\\.[^"\\]*)*"|\S+/g 10 | const NAMED_ARG_REGEX = /^(?[a-zA-Z0-9_-]+)=(?.+)$/ 11 | 12 | export const MAX_ARGS = 50 13 | 14 | export interface Inputs { 15 | token: string 16 | reactionToken: string 17 | reactions: boolean 18 | commands: string[] 19 | permission: string 20 | issueType: string 21 | allowEdits: boolean 22 | repository: string 23 | eventTypeSuffix: string 24 | staticArgs: string[] 25 | dispatchType: string 26 | config: string 27 | configFromFile: string 28 | } 29 | 30 | export interface Command { 31 | command: string 32 | permission: string 33 | issue_type: string 34 | allow_edits: boolean 35 | repository: string 36 | event_type_suffix: string 37 | static_args: string[] 38 | dispatch_type: string 39 | } 40 | 41 | export interface SlashCommandPayload { 42 | command: string 43 | args: { 44 | all: string 45 | unnamed: { 46 | all: string 47 | [k: string]: string 48 | } 49 | named: { 50 | [k: string]: string 51 | } 52 | } 53 | } 54 | 55 | export const commandDefaults = Object.freeze({ 56 | permission: 'write', 57 | issue_type: 'both', 58 | allow_edits: false, 59 | repository: process.env.GITHUB_REPOSITORY || '', 60 | event_type_suffix: '-command', 61 | static_args: [], 62 | dispatch_type: 'repository' 63 | }) 64 | 65 | export function toBool(input: string, defaultVal: boolean): boolean { 66 | if (typeof input === 'boolean') { 67 | return input 68 | } else if (typeof input === 'undefined' || input.length == 0) { 69 | return defaultVal 70 | } else { 71 | return input === 'true' 72 | } 73 | } 74 | 75 | export function getInputs(): Inputs { 76 | // Defaults set in action.yml 77 | return { 78 | token: core.getInput('token'), 79 | reactionToken: core.getInput('reaction-token'), 80 | reactions: core.getInput('reactions') === 'true', 81 | commands: utils.getInputAsArray('commands'), 82 | permission: core.getInput('permission'), 83 | issueType: core.getInput('issue-type'), 84 | allowEdits: core.getInput('allow-edits') === 'true', 85 | repository: core.getInput('repository'), 86 | eventTypeSuffix: core.getInput('event-type-suffix'), 87 | staticArgs: utils.getInputAsArray('static-args'), 88 | dispatchType: core.getInput('dispatch-type'), 89 | config: core.getInput('config'), 90 | configFromFile: core.getInput('config-from-file') 91 | } 92 | } 93 | 94 | export function getCommandsConfig(inputs: Inputs): Command[] { 95 | if (inputs.configFromFile) { 96 | core.info(`Using JSON configuration from file '${inputs.configFromFile}'.`) 97 | const json = fs.readFileSync(inputs.configFromFile, { 98 | encoding: 'utf8' 99 | }) 100 | return getCommandsConfigFromJson(json) 101 | } else if (inputs.config) { 102 | core.info(`Using JSON configuration from 'config' input.`) 103 | return getCommandsConfigFromJson(inputs.config) 104 | } else { 105 | core.info(`Using configuration from yaml inputs.`) 106 | return getCommandsConfigFromInputs(inputs) 107 | } 108 | } 109 | 110 | export function getCommandsConfigFromInputs(inputs: Inputs): Command[] { 111 | core.debug(`Commands: ${inspect(inputs.commands)}`) 112 | 113 | // Build config 114 | const config: Command[] = [] 115 | for (const c of inputs.commands) { 116 | const cmd: Command = { 117 | command: c, 118 | permission: inputs.permission, 119 | issue_type: inputs.issueType, 120 | allow_edits: inputs.allowEdits, 121 | repository: inputs.repository, 122 | event_type_suffix: inputs.eventTypeSuffix, 123 | static_args: inputs.staticArgs, 124 | dispatch_type: inputs.dispatchType 125 | } 126 | config.push(cmd) 127 | } 128 | return config 129 | } 130 | 131 | export function getCommandsConfigFromJson(json: string): Command[] { 132 | const jsonConfig = JSON.parse(json) 133 | core.debug(`JSON config: ${inspect(jsonConfig)}`) 134 | 135 | const config: Command[] = [] 136 | for (const jc of jsonConfig) { 137 | const cmd: Command = { 138 | command: jc.command, 139 | permission: jc.permission ? jc.permission : commandDefaults.permission, 140 | issue_type: jc.issue_type ? jc.issue_type : commandDefaults.issue_type, 141 | allow_edits: toBool(jc.allow_edits, commandDefaults.allow_edits), 142 | repository: jc.repository ? jc.repository : commandDefaults.repository, 143 | event_type_suffix: jc.event_type_suffix 144 | ? jc.event_type_suffix 145 | : commandDefaults.event_type_suffix, 146 | static_args: jc.static_args 147 | ? jc.static_args 148 | : commandDefaults.static_args, 149 | dispatch_type: jc.dispatch_type 150 | ? jc.dispatch_type 151 | : commandDefaults.dispatch_type 152 | } 153 | config.push(cmd) 154 | } 155 | return config 156 | } 157 | 158 | export function configIsValid(config: Command[]): string | null { 159 | for (const command of config) { 160 | if ( 161 | !['none', 'read', 'triage', 'write', 'maintain', 'admin'].includes( 162 | command.permission 163 | ) 164 | ) { 165 | return `'${command.permission}' is not a valid 'permission'.` 166 | } 167 | if (!['issue', 'pull-request', 'both'].includes(command.issue_type)) { 168 | return `'${command.issue_type}' is not a valid 'issue-type'.` 169 | } 170 | if (!['repository', 'workflow'].includes(command.dispatch_type)) { 171 | return `'${command.dispatch_type}' is not a valid 'dispatch-type'.` 172 | } 173 | } 174 | return null 175 | } 176 | 177 | export function actorHasPermission( 178 | actorPermission: string, 179 | commandPermission: string 180 | ): boolean { 181 | const permissionLevels = Object.freeze({ 182 | none: 1, 183 | read: 2, 184 | triage: 3, 185 | write: 4, 186 | maintain: 5, 187 | admin: 6 188 | }) 189 | core.debug(`Actor permission level: ${permissionLevels[actorPermission]}`) 190 | core.debug(`Command permission level: ${permissionLevels[commandPermission]}`) 191 | return ( 192 | permissionLevels[actorPermission] >= permissionLevels[commandPermission] 193 | ) 194 | } 195 | 196 | export function tokeniseCommand(command: string): string[] { 197 | let matches 198 | const output: string[] = [] 199 | while ((matches = TOKENISE_REGEX.exec(command))) { 200 | output.push(matches[0]) 201 | } 202 | return output 203 | } 204 | 205 | function stripQuotes(input: string): string { 206 | if (input.startsWith(`"`) && input.endsWith(`"`)) { 207 | return input.slice(1, input.length - 1) 208 | } else { 209 | return input 210 | } 211 | } 212 | 213 | export function getSlashCommandPayload( 214 | commandTokens: string[], 215 | staticArgs: string[] 216 | ): SlashCommandPayload { 217 | const payload: SlashCommandPayload = { 218 | command: commandTokens[0], 219 | args: { 220 | all: '', 221 | unnamed: { 222 | all: '' 223 | }, 224 | named: {} 225 | } 226 | } 227 | // Get arguments if they exist 228 | const argWords = 229 | commandTokens.length > 1 ? commandTokens.slice(1, MAX_ARGS + 1) : [] 230 | // Add static arguments if they exist 231 | argWords.unshift(...staticArgs) 232 | if (argWords.length > 0) { 233 | payload.args.all = argWords.join(' ') 234 | // Parse named and unnamed args 235 | let unnamedCount = 1 236 | const unnamedArgs: string[] = [] 237 | for (const argWord of argWords) { 238 | if (NAMED_ARG_REGEX.test(argWord)) { 239 | const result = NAMED_ARG_REGEX.exec(argWord) 240 | if (result && result.groups) { 241 | payload.args.named[`${result.groups['name']}`] = stripQuotes( 242 | result.groups['value'] 243 | ) 244 | } 245 | } else { 246 | unnamedArgs.push(argWord) 247 | payload.args.unnamed[`arg${unnamedCount}`] = stripQuotes(argWord) 248 | unnamedCount += 1 249 | } 250 | } 251 | // Add a string of only the unnamed args 252 | if (unnamedArgs.length > 0) { 253 | payload.args.unnamed.all = unnamedArgs.join(' ') 254 | } 255 | } 256 | return payload 257 | } 258 | -------------------------------------------------------------------------------- /src/github-helper.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {Octokit, PullsGetResponseData} from './octokit-client' 3 | import {Command, SlashCommandPayload} from './command-helper' 4 | import {inspect} from 'util' 5 | import * as utils from './utils' 6 | 7 | type ReposCreateDispatchEventParamsClientPayload = { 8 | [key: string]: ReposCreateDispatchEventParamsClientPayloadKeyString 9 | } 10 | // eslint-disable-next-line 11 | type ReposCreateDispatchEventParamsClientPayloadKeyString = {} 12 | 13 | export interface ClientPayload 14 | extends ReposCreateDispatchEventParamsClientPayload { 15 | // eslint-disable-next-line 16 | github: any 17 | // eslint-disable-next-line 18 | pull_request?: any 19 | // eslint-disable-next-line 20 | slash_command?: SlashCommandPayload | any 21 | } 22 | 23 | interface Repository { 24 | owner: string 25 | repo: string 26 | } 27 | 28 | type CollaboratorPermission = { 29 | repository: { 30 | collaborators: { 31 | edges: [ 32 | { 33 | permission: string 34 | } 35 | ] 36 | } 37 | } 38 | } 39 | 40 | export class GitHubHelper { 41 | private octokit: InstanceType 42 | 43 | constructor(token: string) { 44 | this.octokit = new Octokit({ 45 | auth: token, 46 | baseUrl: process.env['GITHUB_API_URL'] || 'https://api.github.com' 47 | }) 48 | } 49 | 50 | private parseRepository(repository: string): Repository { 51 | const [owner, repo] = repository.split('/') 52 | return { 53 | owner: owner, 54 | repo: repo 55 | } 56 | } 57 | 58 | async getActorPermission(repo: Repository, actor: string): Promise { 59 | // https://docs.github.com/en/graphql/reference/enums#repositorypermission 60 | // https://docs.github.com/en/graphql/reference/objects#repositorycollaboratoredge 61 | // Returns 'READ', 'TRIAGE', 'WRITE', 'MAINTAIN', 'ADMIN' 62 | const query = `query CollaboratorPermission($owner: String!, $repo: String!, $collaborator: String) { 63 | repository(owner:$owner, name:$repo) { 64 | collaborators(login: $collaborator) { 65 | edges { 66 | permission 67 | } 68 | } 69 | } 70 | }` 71 | const collaboratorPermission = 72 | await this.octokit.graphql(query, { 73 | ...repo, 74 | collaborator: actor 75 | }) 76 | core.debug( 77 | `CollaboratorPermission: ${inspect( 78 | collaboratorPermission.repository.collaborators.edges 79 | )}` 80 | ) 81 | return collaboratorPermission.repository.collaborators.edges.length > 0 82 | ? collaboratorPermission.repository.collaborators.edges[0].permission.toLowerCase() 83 | : 'none' 84 | } 85 | 86 | async tryAddReaction( 87 | repo: Repository, 88 | commentId: number, 89 | reaction: 90 | | '+1' 91 | | '-1' 92 | | 'laugh' 93 | | 'confused' 94 | | 'heart' 95 | | 'hooray' 96 | | 'rocket' 97 | | 'eyes' 98 | ): Promise { 99 | try { 100 | await this.octokit.rest.reactions.createForIssueComment({ 101 | ...repo, 102 | comment_id: commentId, 103 | content: reaction 104 | }) 105 | } catch (error) { 106 | core.debug(utils.getErrorMessage(error)) 107 | core.warning(`Failed to set reaction on comment ID ${commentId}.`) 108 | } 109 | } 110 | 111 | async getPull( 112 | repo: Repository, 113 | pullNumber: number 114 | ): Promise { 115 | const {data: pullRequest} = await this.octokit.rest.pulls.get({ 116 | ...repo, 117 | pull_number: pullNumber 118 | }) 119 | return pullRequest 120 | } 121 | 122 | async createDispatch( 123 | cmd: Command, 124 | clientPayload: ClientPayload 125 | ): Promise { 126 | if (cmd.dispatch_type == 'repository') { 127 | await this.createRepositoryDispatch(cmd, clientPayload) 128 | } else { 129 | await this.createWorkflowDispatch(cmd, clientPayload) 130 | } 131 | } 132 | 133 | private async createRepositoryDispatch( 134 | cmd: Command, 135 | clientPayload: ClientPayload 136 | ): Promise { 137 | const eventType = `${cmd.command}${cmd.event_type_suffix}` 138 | await this.octokit.rest.repos.createDispatchEvent({ 139 | ...this.parseRepository(cmd.repository), 140 | event_type: `${cmd.command}${cmd.event_type_suffix}`, 141 | client_payload: clientPayload 142 | }) 143 | core.info( 144 | `Command '${cmd.command}' dispatched to '${cmd.repository}' ` + 145 | `with event type '${eventType}'.` 146 | ) 147 | } 148 | 149 | async createWorkflowDispatch( 150 | cmd: Command, 151 | clientPayload: ClientPayload 152 | ): Promise { 153 | const workflow = `${cmd.command}${cmd.event_type_suffix}.yml` 154 | const slashCommand: SlashCommandPayload = clientPayload.slash_command 155 | const ref = slashCommand.args.named.ref 156 | ? slashCommand.args.named.ref 157 | : await this.getDefaultBranch(cmd.repository) 158 | 159 | // Take max 10 named arguments, excluding 'ref'. 160 | const inputs = {} 161 | let count = 0 162 | for (const key in slashCommand.args.named) { 163 | if (key != 'ref') { 164 | inputs[key] = slashCommand.args.named[key] 165 | count++ 166 | } 167 | if (count == 10) break 168 | } 169 | 170 | await this.octokit.request( 171 | 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', 172 | { 173 | ...this.parseRepository(cmd.repository), 174 | workflow_id: workflow, 175 | ref: ref, 176 | inputs: inputs 177 | } 178 | ) 179 | core.info( 180 | `Command '${cmd.command}' dispatched to workflow '${workflow}' in '${cmd.repository}'` 181 | ) 182 | } 183 | 184 | private async getDefaultBranch(repository: string): Promise { 185 | const {data: repo} = await this.octokit.rest.repos.get({ 186 | ...this.parseRepository(repository) 187 | }) 188 | return repo.default_branch 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import {inspect} from 'util' 4 | import { 5 | getInputs, 6 | tokeniseCommand, 7 | getCommandsConfig, 8 | configIsValid, 9 | actorHasPermission, 10 | getSlashCommandPayload 11 | } from './command-helper' 12 | import {GitHubHelper, ClientPayload} from './github-helper' 13 | import * as utils from './utils' 14 | 15 | async function run(): Promise { 16 | try { 17 | // Check required context properties exist (satisfy type checking) 18 | if ( 19 | !github.context.payload.action || 20 | !github.context.payload.issue || 21 | !github.context.payload.comment 22 | ) { 23 | throw new Error('Required context properties are missing.') 24 | } 25 | 26 | // Only handle 'created' and 'edited' event types 27 | if (!['created', 'edited'].includes(github.context.payload.action)) { 28 | core.warning( 29 | `Event type '${github.context.payload.action}' not supported.` 30 | ) 31 | return 32 | } 33 | 34 | // Get action inputs 35 | const inputs = getInputs() 36 | core.debug(`Inputs: ${inspect(inputs)}`) 37 | 38 | // Check required inputs 39 | if (!inputs.token) { 40 | throw new Error(`Missing required input 'token'.`) 41 | } 42 | 43 | // Get configuration for registered commands 44 | const config = getCommandsConfig(inputs) 45 | core.debug(`Commands config: ${inspect(config)}`) 46 | 47 | // Check the config is valid 48 | const configError = configIsValid(config) 49 | if (configError) { 50 | throw new Error(configError) 51 | } 52 | 53 | // Get the comment body and id 54 | const commentBody: string = github.context.payload.comment.body 55 | const commentId: number = github.context.payload.comment.id 56 | core.debug(`Comment body: ${commentBody}`) 57 | core.debug(`Comment id: ${commentId}`) 58 | 59 | // Check if the first line of the comment is a slash command 60 | const firstLine = commentBody.split(/\r?\n/)[0].trim() 61 | if (firstLine.length < 2 || firstLine.charAt(0) != '/') { 62 | console.debug( 63 | 'The first line of the comment is not a valid slash command.' 64 | ) 65 | return 66 | } 67 | 68 | // Tokenise the first line (minus the leading slash) 69 | const commandTokens = tokeniseCommand(firstLine.slice(1)) 70 | core.debug(`Command tokens: ${inspect(commandTokens)}`) 71 | 72 | // Check if the command is registered for dispatch 73 | let configMatches = config.filter(function (cmd) { 74 | return cmd.command == commandTokens[0] 75 | }) 76 | core.debug(`Config matches on 'command': ${inspect(configMatches)}`) 77 | if (configMatches.length == 0) { 78 | core.info(`Command '${commandTokens[0]}' is not registered for dispatch.`) 79 | return 80 | } 81 | 82 | // Filter matching commands by issue type 83 | const isPullRequest = 'pull_request' in github.context.payload.issue 84 | configMatches = configMatches.filter(function (cmd) { 85 | return ( 86 | cmd.issue_type == 'both' || 87 | (cmd.issue_type == 'issue' && !isPullRequest) || 88 | (cmd.issue_type == 'pull-request' && isPullRequest) 89 | ) 90 | }) 91 | core.debug(`Config matches on 'issue_type': ${inspect(configMatches)}`) 92 | if (configMatches.length == 0) { 93 | const issueType = isPullRequest ? 'pull request' : 'issue' 94 | core.info( 95 | `Command '${commandTokens[0]}' is not configured for the issue type '${issueType}'.` 96 | ) 97 | return 98 | } 99 | 100 | // Filter matching commands by whether or not to allow edits 101 | if (github.context.payload.action == 'edited') { 102 | configMatches = configMatches.filter(function (cmd) { 103 | return cmd.allow_edits 104 | }) 105 | core.debug(`Config matches on 'allow_edits': ${inspect(configMatches)}`) 106 | if (configMatches.length == 0) { 107 | core.info( 108 | `Command '${commandTokens[0]}' is not configured to allow edits.` 109 | ) 110 | return 111 | } 112 | } 113 | 114 | // Create github clients 115 | const githubHelper = new GitHubHelper(inputs.token) 116 | const githubHelperReaction = new GitHubHelper(inputs.reactionToken) 117 | 118 | // At this point we know the command is registered 119 | // Add the "eyes" reaction to the comment 120 | if (inputs.reactions) 121 | await githubHelperReaction.tryAddReaction( 122 | github.context.repo, 123 | commentId, 124 | 'eyes' 125 | ) 126 | 127 | // Get the actor permission 128 | const actorPermission = await githubHelper.getActorPermission( 129 | github.context.repo, 130 | github.context.actor 131 | ) 132 | core.debug(`Actor permission: ${actorPermission}`) 133 | 134 | // Filter matching commands by the user's permission level 135 | configMatches = configMatches.filter(function (cmd) { 136 | return actorHasPermission(actorPermission, cmd.permission) 137 | }) 138 | core.debug(`Config matches on 'permission': ${inspect(configMatches)}`) 139 | if (configMatches.length == 0) { 140 | core.info( 141 | `Command '${commandTokens[0]}' is not configured for the user's permission level '${actorPermission}'.` 142 | ) 143 | return 144 | } 145 | 146 | // Determined that the command should be dispatched 147 | core.info(`Command '${commandTokens[0]}' to be dispatched.`) 148 | 149 | // Define payload 150 | const clientPayload: ClientPayload = { 151 | github: github.context 152 | } 153 | // Truncate the body to keep the size of the payload under the max 154 | if ( 155 | clientPayload.github.payload.issue && 156 | clientPayload.github.payload.issue.body 157 | ) { 158 | clientPayload.github.payload.issue.body = 159 | clientPayload.github.payload.issue.body.slice(0, 1000) 160 | } 161 | 162 | // Get the pull request context for the dispatch payload 163 | if (isPullRequest) { 164 | const pullRequest = await githubHelper.getPull( 165 | github.context.repo, 166 | github.context.payload.issue.number 167 | ) 168 | // Truncate the body to keep the size of the payload under the max 169 | if (pullRequest.body) { 170 | pullRequest.body = pullRequest.body.slice(0, 1000) 171 | } 172 | clientPayload['pull_request'] = pullRequest 173 | } 174 | 175 | // Dispatch for each matching configuration 176 | for (const cmd of configMatches) { 177 | // Generate slash command payload 178 | clientPayload.slash_command = getSlashCommandPayload( 179 | commandTokens, 180 | cmd.static_args 181 | ) 182 | core.debug( 183 | `Slash command payload: ${inspect(clientPayload.slash_command)}` 184 | ) 185 | // Dispatch the command 186 | await githubHelper.createDispatch(cmd, clientPayload) 187 | } 188 | 189 | // Add the "rocket" reaction to the comment 190 | if (inputs.reactions) 191 | await githubHelperReaction.tryAddReaction( 192 | github.context.repo, 193 | commentId, 194 | 'rocket' 195 | ) 196 | } catch (error) { 197 | core.debug(inspect(error)) 198 | const message: string = utils.getErrorMessage(error) 199 | // Handle validation errors from workflow dispatch 200 | if ( 201 | message.startsWith('Unexpected inputs provided') || 202 | (message.startsWith('Required input') && 203 | message.endsWith('not provided')) || 204 | message.startsWith('No ref found for:') || 205 | message == `Workflow does not have 'workflow_dispatch' trigger` 206 | ) { 207 | core.setOutput('error-message', message) 208 | core.warning(message) 209 | } else { 210 | core.setFailed(message) 211 | } 212 | } 213 | } 214 | 215 | run() 216 | -------------------------------------------------------------------------------- /src/octokit-client.ts: -------------------------------------------------------------------------------- 1 | import {Octokit as Core} from '@octokit/core' 2 | import {paginateRest} from '@octokit/plugin-paginate-rest' 3 | import { 4 | restEndpointMethods, 5 | RestEndpointMethodTypes 6 | } from '@octokit/plugin-rest-endpoint-methods' 7 | import {HttpProxyAgent} from 'http-proxy-agent' 8 | import {HttpsProxyAgent} from 'https-proxy-agent' 9 | 10 | export const Octokit = Core.plugin( 11 | paginateRest, 12 | restEndpointMethods, 13 | autoProxyAgent 14 | ) 15 | 16 | export type PullsGetResponseData = 17 | RestEndpointMethodTypes['pulls']['get']['response']['data'] 18 | 19 | // Octokit plugin to support the http_proxy and https_proxy environment variable 20 | function autoProxyAgent(octokit: Core) { 21 | const http_proxy_address = 22 | process.env['http_proxy'] || process.env['HTTP_PROXY'] 23 | const https_proxy_address = 24 | process.env['https_proxy'] || process.env['HTTPS_PROXY'] 25 | 26 | octokit.hook.before('request', options => { 27 | if (options.baseUrl.startsWith('http://') && http_proxy_address) { 28 | options.request.agent = new HttpProxyAgent(http_proxy_address) 29 | } else if (options.baseUrl.startsWith('https://') && https_proxy_address) { 30 | options.request.agent = new HttpsProxyAgent(https_proxy_address) 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | 3 | export function getInputAsArray( 4 | name: string, 5 | options?: core.InputOptions 6 | ): string[] { 7 | return getStringAsArray(core.getInput(name, options)) 8 | } 9 | 10 | export function getStringAsArray(str: string): string[] { 11 | return str 12 | .split(/[\n,]+/) 13 | .map(s => s.trim()) 14 | .filter(x => x !== '') 15 | } 16 | 17 | export function getErrorMessage(error: unknown) { 18 | if (error instanceof Error) return error.message 19 | return String(error) 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es6" 7 | ], 8 | "outDir": "./lib", 9 | "rootDir": "./src", 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": false, 13 | "esModuleInterop": true 14 | }, 15 | "exclude": ["__test__", "lib", "node_modules"] 16 | } 17 | --------------------------------------------------------------------------------