├── .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 | [](https://github.com/peter-evans/slash-command-dispatch/actions?query=workflow%3ACI)
3 | [](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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------