diff --git a/.github/slash-command-dispatch.json b/.github/slash-command-dispatch.json index cc18ef604..a419b1cdf 100644 --- a/.github/slash-command-dispatch.json +++ b/.github/slash-command-dispatch.json @@ -3,7 +3,8 @@ "command": "create", "permission": "write", "issue_type": "issue", - "event_type_suffix": "-cmd" + "event_type_suffix": "-cmd", + "named_args": true }, { "command": "delete", diff --git a/README.md b/README.md index dcd1e0904..3e7a1550d 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,7 @@ When a valid command is found it creates a repository dispatch event that includ "ChatOps" with slash commands can work in a basic way by parsing the commands during `issue_comment` events and immediately processing the command. In repositories with a lot of activity, the workflow queue will get backed up very quickly if it is trying to handle new comments for commands *and* process the commands themselves. -Dispatching commands to be processed elsewhere keeps the workflow queue moving quickly. It essentially allows you to run multiple workflow queues in parallel. - -### Key features - -- Easy configuration of "ChatOps" slash commands -- Enables separating the queue of `issue_comment` events from the queue of dispatched commands to keep it fast moving -- Users receive faster feedback that commands have been seen and are waiting to be processed -- The ability to handle processing commands in multiple repositories in parallel -- Long running workloads can be processed in a repository workflow queue of their own -- Even if commands are dispatched and processed in the same repository, separation of comment parsing and command processing makes workflows more maintainable, and with less duplication +Dispatching commands to be processed elsewhere keeps the workflow queue moving quickly. It essentially enables parallel processing of workflows. ### Demo and examples @@ -33,11 +24,13 @@ Check out the following demos. - [ChatOps Demo in Pull Requests](https://github.com/peter-evans/slash-command-dispatch/pull/8) - [Slash command code formatting - Python](https://github.com/peter-evans/slash-command-dispatch/pull/11) -See [examples](examples.md) for command patterns and example workflows. +See [examples](docs/examples.md) for command patterns and example workflows. ## Dispatching commands -### Basic configuration +### Configuration + +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 `/rebase`, `/integration-test` and `/create-ticket`. ```yml name: Slash Command Dispatch @@ -55,24 +48,24 @@ jobs: commands: rebase, integration-test, create-ticket ``` +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. + ### Action inputs -For basic configuration, use the inputs in the leftmost column. -Use the JSON properties for [Advanced configuration](#advanced-configuration). - -| Input | JSON Property | Description | Default | -| --- | --- | --- | --- | -| `token` | | (**required**) A `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). | | -| `reaction-token` | | `GITHUB_TOKEN` or a `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). | | -| `reactions` | | Add reactions. :eyes: = seen, :rocket: = dispatched | `true` | -| `commands` | `command` | (**required**) Input: A comma separated list of commands to dispatch. JSON property: A single command. | | -| `permission` | `permission` | The repository permission level required by the user to dispatch commands. (`none`, `read`, `write`, `admin`) | `write` | -| `issue-type` | `issue_type` | The issue type required for commands. (`issue`, `pull-request`, `both`) | `both` | -| `allow-edits` | `allow_edits` | Allow edited comments to trigger command dispatches. | `false` | -| `repository` | `repository` | The full name of the repository to send the dispatch events. | Current repository | -| `event-type-suffix` | `event_type_suffix` | The repository dispatch event type suffix for the commands. | `-command` | -| `config` | | JSON configuration for commands. See [Advanced configuration](#advanced-configuration) | | -| `config-from-file` | | JSON configuration from a file for commands. See [Advanced configuration](#advanced-configuration) | | +| Input | Description | Default | +| --- | --- | --- | +| `token` | (**required**) A `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). | | +| `reaction-token` | `GITHUB_TOKEN` or a `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). | | +| `reactions` | Add reactions. :eyes: = seen, :rocket: = dispatched | `true` | +| `commands` | (**required**) A comma separated list of commands. | | +| `permission` | The repository permission level required by the user to dispatch commands. (`none`, `read`, `write`, `admin`) | `write` | +| `issue-type` | The issue type required for commands. (`issue`, `pull-request`, `both`) | `both` | +| `allow-edits` | Allow edited comments to trigger command dispatches. | `false` | +| `repository` | The full name of the repository to send the dispatch events. | Current repository | +| `event-type-suffix` | The repository dispatch event type suffix for the commands. | `-command` | +| `named-args` | Parse named arguments and add them to the command payload. | `false` | +| `config` | | JSON configuration for commands. See [Advanced configuration](docs/advanced-configuration.md) | | +| `config-from-file` | | JSON configuration from a file for commands. See [Advanced configuration](docs/advanced-configuration.md) | | ### What is the reaction-token? @@ -88,72 +81,6 @@ This means that reactions to comments will appear to be made by the user account commands: rebase, integration-test, create-ticket ``` -### Advanced configuration - -Using JSON configuration allows the options for each command to be specified individually. - -Note that it's 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. - -Here is an example workflow. Take care to use the correct JSON property names. - -```yml -name: Slash Command Dispatch -on: - issue_comment: - types: [created] -jobs: - slashCommandDispatch: - runs-on: ubuntu-latest - steps: - - name: Slash Command Dispatch - uses: peter-evans/slash-command-dispatch@v1 - with: - token: ${{ secrets.REPO_ACCESS_TOKEN }} - reaction-token: ${{ secrets.GITHUB_TOKEN }} - config: > - [ - { - "command": "rebase", - "permission": "admin", - "issue_type": "pull-request", - "repository": "peter-evans/slash-command-dispatch-processor" - }, - { - "command": "integration-test", - "permission": "write", - "issue_type": "both", - "repository": "peter-evans/slash-command-dispatch-processor" - }, - { - "command": "create-ticket", - "permission": "write", - "issue_type": "issue", - "allow_edits": true, - "event_type_suffix": "-cmd" - } - ] -``` - -The following workflow is an example using the `config-from-file` input to set JSON configuration. -Note that `actions/checkout` is required to access the file. - -```yml -name: Slash Command Dispatch -on: - issue_comment: - types: [created] -jobs: - slashCommandDispatch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Slash Command Dispatch - uses: peter-evans/slash-command-dispatch@v1 - with: - token: ${{ secrets.REPO_ACCESS_TOKEN }} - config-from-file: .github/slash-command-dispatch.json -``` - ## Handling dispatched commands ### Event types @@ -168,10 +95,12 @@ on: types: [integration-test-command] ``` -### Accessing command contexts +### Accessing contexts Commands are dispatched with a payload containing a number of contexts. +#### `slash_command` context + The slash command context can be accessed as follows. `args` is a space separated string of all the supplied arguments. Each argument is also supplied in a numbered property, i.e. `arg1`, `arg2`, `arg3`, etc. @@ -187,6 +116,36 @@ Each argument is also supplied in a numbered property, i.e. `arg1`, `arg2`, `arg # etc. ``` +If the `named-args` input is set to `true`, any arguments that are prefixed in the format `name=argument` will be parsed and added to the payload. + +For example, the slash command `/deploy branch=master env=prod some other args` will be set in the JSON payload as follows. + +```json + "slash_command": { + "command": "deploy", + "args": "branch=master env=prod some other args", + "unnamed_args": "some other args", + "branch": "master", + "env": "prod", + "arg1": "some", + "arg2": "other", + "arg3": "args" + } +``` + +These named arguments can be accessed in a workflow as follows. + +```yml + - name: Output command and named arguments + run: | + echo ${{ github.event.client_payload.slash_command.command }} + echo ${{ github.event.client_payload.slash_command.branch }} + echo ${{ github.event.client_payload.slash_command.env }} + echo ${{ github.event.client_payload.slash_command.unnamed_args }} +``` + +#### `github` and `pull_request` contexts + The payload contains the complete `github` context of the `issue_comment` event at path `github.event.client_payload.github`. Additionally, if the comment was made in a pull request, the action calls the [GitHub API to fetch the pull request detail](https://developer.github.com/v3/pulls/#get-a-single-pull-request) and attach it to the payload at path `github.event.client_payload.pull_request`. diff --git a/action.yml b/action.yml index 7cf1c9f3e..838443ed5 100644 --- a/action.yml +++ b/action.yml @@ -9,7 +9,7 @@ inputs: reactions: description: 'Add reactions to comments containing commands.' commands: - description: 'A comma separated list of commands to dispatch.' + description: 'A comma separated list of commands.' required: true permission: description: 'The repository permission level required by the user to dispatch commands.' @@ -21,6 +21,8 @@ inputs: description: 'The full name of the repository to send the dispatch events.' event-type-suffix: description: 'The repository dispatch event type suffix for the commands.' + named-args: + description: 'Parse named arguments and add them to the command payload.' config: description: 'JSON configuration for commands.' config-from-file: diff --git a/dist/index.js b/dist/index.js index b58794c37..7f08f3919 100644 --- a/dist/index.js +++ b/dist/index.js @@ -4665,13 +4665,15 @@ var fs = __webpack_require__(747); const core = __webpack_require__(470); const MAX_ARGS = 50; +const namedArgPattern = /^(?[a-zA-Z0-9_]+)=(?[^\s]+)$/; const commandDefaults = Object.freeze({ permission: "write", issue_type: "both", allow_edits: false, repository: process.env.GITHUB_REPOSITORY, - event_type_suffix: "-command" + event_type_suffix: "-command", + named_args: false }); function toBool(input, defaultVal) { @@ -4695,6 +4697,7 @@ function getInputs() { allowEdits: core.getInput("allow-edits"), repository: core.getInput("repository"), eventTypeSuffix: core.getInput("event-type-suffix"), + namedArgs: core.getInput("named-args"), config: core.getInput("config"), configFromFile: core.getInput("config-from-file") }; @@ -4738,6 +4741,7 @@ function getCommandsConfigFromInputs(inputs) { cmd.event_type_suffix = inputs.eventTypeSuffix ? inputs.eventTypeSuffix : cmd.event_type_suffix; + cmd.named_args = toBool(inputs.namedArgs, cmd.named_args); config.push(cmd); } return config; @@ -4759,6 +4763,7 @@ function getCommandsConfigFromJson(json) { cmd.event_type_suffix = jc.event_type_suffix ? jc.event_type_suffix : cmd.event_type_suffix; + cmd.named_args = toBool(jc.named_args, cmd.named_args); config.push(cmd); } return config; @@ -4817,7 +4822,7 @@ async function addReaction(octokit, repo, commentId, reaction) { } } -function getSlashCommandPayload(commentWords) { +function getSlashCommandPayload(commentWords, namedArgs) { var payload = { command: commentWords[0], args: "" @@ -4825,8 +4830,22 @@ function getSlashCommandPayload(commentWords) { if (commentWords.length > 1) { const argWords = commentWords.slice(1, MAX_ARGS + 1); payload.args = argWords.join(" "); - for (var i = 0; i < argWords.length; i++) { - payload[`arg${i + 1}`] = argWords[i]; + // Parse named and unnamed args + var unnamedCount = 1; + var unnamedArgs = []; + for (var argWord of argWords) { + if (namedArgs && namedArgPattern.test(argWord)) { + const { groups: { name, value } } = namedArgPattern.exec(argWord); + payload[`${name}`] = value; + } else { + unnamedArgs.push(argWord) + payload[`arg${unnamedCount}`] = argWord; + unnamedCount += 1; + } + } + // Add a string of only the unnamed args + if (namedArgs && unnamedArgs.length > 0) { + payload["unnamed_args"] = unnamedArgs.join(" "); } } return payload; @@ -8049,10 +8068,8 @@ async function run() { core.info(`Command '${commentWords[0]}' to be dispatched.`); // Define payload - const slashCommandPayload = getSlashCommandPayload(commentWords); - core.debug(`Slash command payload: ${inspect(slashCommandPayload)}`); var clientPayload = { - slash_command: slashCommandPayload, + slash_command: {}, github: github.context }; @@ -8067,6 +8084,15 @@ async function run() { // Dispatch for each matching configuration for (const cmd of configMatches) { + // Generate slash command payload + clientPayload.slash_command = getSlashCommandPayload( + commentWords, + cmd.named_args + ); + core.debug( + `Slash command payload: ${inspect(clientPayload.slash_command)}` + ); + // Dispatch the command const dispatchRepo = cmd.repository.split("/"); const eventType = cmd.command + cmd.event_type_suffix; await octokit.repos.createDispatchEvent({ diff --git a/docs/advanced-configuration.md b/docs/advanced-configuration.md new file mode 100644 index 000000000..4f9ffd03a --- /dev/null +++ b/docs/advanced-configuration.md @@ -0,0 +1,104 @@ +# Advanced configuration + +## What is advanced configuration? + +Due to the limitations of yaml based action inputs, basic configuration is not adequate enough to support unique configuration *per command*. + +For example, the following basic configuration means that all commands must have the same `admin` permission. + +```yml + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@v1 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + commands: rebase, integration-test, create-ticket + permission: admin +``` + +To solve this issue, advanced JSON configuration allows each command to be configured individually. + +## Dispatching commands + +There are two ways to specify JSON configuration for command dispatch. Directly in the workflow via the `config` input, OR, specifing a JSON config file via the `config-from-file` input. + +**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. + +Here is a reference example workflow. Take care to use the correct [JSON property names](#advanced-action-inputs). + +```yml +name: Slash Command Dispatch +on: + issue_comment: + types: [created] +jobs: + slashCommandDispatch: + runs-on: ubuntu-latest + steps: + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@v1 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + reaction-token: ${{ secrets.GITHUB_TOKEN }} + config: > + [ + { + "command": "rebase", + "permission": "admin", + "issue_type": "pull-request", + "repository": "peter-evans/slash-command-dispatch-processor" + }, + { + "command": "integration-test", + "permission": "write", + "issue_type": "both", + "repository": "peter-evans/slash-command-dispatch-processor", + "named_args": true + }, + { + "command": "create-ticket", + "permission": "write", + "issue_type": "issue", + "allow_edits": true, + "event_type_suffix": "-cmd" + } + ] +``` + +The following workflow is an example using the `config-from-file` input to set JSON configuration. +Note that `actions/checkout` is required to access the file. + +```yml +name: Slash Command Dispatch +on: + issue_comment: + types: [created] +jobs: + slashCommandDispatch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@v1 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + config-from-file: .github/slash-command-dispatch.json +``` + +## Advanced action inputs + +Advanced configuration requires a combination of yaml based inputs and JSON configuration. + +| Input | JSON Property | Description | Default | +| --- | --- | --- | --- | +| `token` | | (**required**) A `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). | | +| `reaction-token` | | `GITHUB_TOKEN` or a `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). | | +| `reactions` | | Add reactions. :eyes: = seen, :rocket: = dispatched | `true` | +| | `command` | (**required**) The slash command. | | +| | `permission` | The repository permission level required by the user to dispatch commands. (`none`, `read`, `write`, `admin`) | `write` | +| | `issue_type` | The issue type required for commands. (`issue`, `pull-request`, `both`) | `both` | +| | `allow_edits` | Allow edited comments to trigger command dispatches. | `false` | +| | `repository` | The full name of the repository to send the dispatch events. | Current repository | +| | `event_type_suffix` | The repository dispatch event type suffix for the commands. | `-command` | +| | `named_args` | Parse named arguments and add them to the command payload. | `false` | +| `config` | | JSON configuration for commands. See [Advanced configuration](#advanced-configuration) | | +| `config-from-file` | | JSON configuration from a file for commands. See [Advanced configuration](#advanced-configuration) | | diff --git a/examples.md b/docs/examples.md similarity index 100% rename from examples.md rename to docs/examples.md diff --git a/src/func.js b/src/func.js index 9671a4edc..a5b153431 100644 --- a/src/func.js +++ b/src/func.js @@ -3,13 +3,15 @@ var fs = require("fs"); const core = require("@actions/core"); const MAX_ARGS = 50; +const namedArgPattern = /^(?[a-zA-Z0-9_]+)=(?[^\s]+)$/; const commandDefaults = Object.freeze({ permission: "write", issue_type: "both", allow_edits: false, repository: process.env.GITHUB_REPOSITORY, - event_type_suffix: "-command" + event_type_suffix: "-command", + named_args: false }); function toBool(input, defaultVal) { @@ -33,6 +35,7 @@ function getInputs() { allowEdits: core.getInput("allow-edits"), repository: core.getInput("repository"), eventTypeSuffix: core.getInput("event-type-suffix"), + namedArgs: core.getInput("named-args"), config: core.getInput("config"), configFromFile: core.getInput("config-from-file") }; @@ -76,6 +79,7 @@ function getCommandsConfigFromInputs(inputs) { cmd.event_type_suffix = inputs.eventTypeSuffix ? inputs.eventTypeSuffix : cmd.event_type_suffix; + cmd.named_args = toBool(inputs.namedArgs, cmd.named_args); config.push(cmd); } return config; @@ -97,6 +101,7 @@ function getCommandsConfigFromJson(json) { cmd.event_type_suffix = jc.event_type_suffix ? jc.event_type_suffix : cmd.event_type_suffix; + cmd.named_args = toBool(jc.named_args, cmd.named_args); config.push(cmd); } return config; @@ -155,7 +160,7 @@ async function addReaction(octokit, repo, commentId, reaction) { } } -function getSlashCommandPayload(commentWords) { +function getSlashCommandPayload(commentWords, namedArgs) { var payload = { command: commentWords[0], args: "" @@ -163,8 +168,22 @@ function getSlashCommandPayload(commentWords) { if (commentWords.length > 1) { const argWords = commentWords.slice(1, MAX_ARGS + 1); payload.args = argWords.join(" "); - for (var i = 0; i < argWords.length; i++) { - payload[`arg${i + 1}`] = argWords[i]; + // Parse named and unnamed args + var unnamedCount = 1; + var unnamedArgs = []; + for (var argWord of argWords) { + if (namedArgs && namedArgPattern.test(argWord)) { + const { groups: { name, value } } = namedArgPattern.exec(argWord); + payload[`${name}`] = value; + } else { + unnamedArgs.push(argWord) + payload[`arg${unnamedCount}`] = argWord; + unnamedCount += 1; + } + } + // Add a string of only the unnamed args + if (namedArgs && unnamedArgs.length > 0) { + payload["unnamed_args"] = unnamedArgs.join(" "); } } return payload; diff --git a/src/func.test.js b/src/func.test.js index 8f156de39..b1769e587 100644 --- a/src/func.test.js +++ b/src/func.test.js @@ -23,6 +23,7 @@ test("building config with required inputs only", async () => { expect(config[i].event_type_suffix).toEqual( commandDefaults.event_type_suffix ); + expect(config[i].named_args).toEqual(commandDefaults.named_args); } }); @@ -33,7 +34,8 @@ test("building config with optional inputs", async () => { issueType: "pull-request", allowEdits: true, repository: "owner/repo", - eventTypeSuffix: "-cmd" + eventTypeSuffix: "-cmd", + namedArgs: true }; const commands = inputs.commands.replace(/\s+/g, "").split(","); const config = getCommandsConfigFromInputs(inputs); @@ -45,6 +47,7 @@ test("building config with optional inputs", async () => { expect(config[i].allow_edits).toEqual(inputs.allowEdits); expect(config[i].repository).toEqual(inputs.repository); expect(config[i].event_type_suffix).toEqual(inputs.eventTypeSuffix); + expect(config[i].named_args).toEqual(inputs.namedArgs); } }); @@ -69,6 +72,7 @@ test("building config with required JSON only", async () => { expect(config[i].event_type_suffix).toEqual( commandDefaults.event_type_suffix ); + expect(config[i].named_args).toEqual(commandDefaults.named_args); } }); @@ -80,7 +84,8 @@ test("building config with optional JSON properties", async () => { "issue_type": "pull-request", "allow_edits": true, "repository": "owner/repo", - "event_type_suffix": "-cmd" + "event_type_suffix": "-cmd", + "named_args": true }, { "command": "test-all-the-things", @@ -96,6 +101,7 @@ test("building config with optional JSON properties", async () => { expect(config[0].allow_edits).toBeTruthy(); expect(config[0].repository).toEqual("owner/repo"); expect(config[0].event_type_suffix).toEqual("-cmd"); + expect(config[0].named_args).toBeTruthy(); expect(config[1].command).toEqual(commands[1]); expect(config[1].permission).toEqual("read"); expect(config[1].issue_type).toEqual(commandDefaults.issue_type); @@ -164,3 +170,33 @@ test("slash command payload", async () => { }; expect(getSlashCommandPayload(commandWords)).toEqual(payload); }); + +test("slash command payload with named args", async () => { + commandWords = ["test", "branch=master", "arg1", "env=prod", "arg2"]; + namedArgs = true; + payload = { + command: "test", + args: "branch=master arg1 env=prod arg2", + unnamed_args: "arg1 arg2", + branch: "master", + env: "prod", + arg1: "arg1", + arg2: "arg2" + }; + expect(getSlashCommandPayload(commandWords, namedArgs)).toEqual(payload); +}); + +test("slash command payload with malformed named args", async () => { + commandWords = ["test", "branch=", "arg1", "e-nv=prod", "arg2"]; + namedArgs = true; + payload = { + command: "test", + args: "branch= arg1 e-nv=prod arg2", + unnamed_args: "branch= arg1 e-nv=prod arg2", + arg1: "branch=", + arg2: "arg1", + arg3: "e-nv=prod", + arg4: "arg2" + }; + expect(getSlashCommandPayload(commandWords, namedArgs)).toEqual(payload); +}); diff --git a/src/index.js b/src/index.js index 96223f50e..df5735179 100644 --- a/src/index.js +++ b/src/index.js @@ -136,10 +136,8 @@ async function run() { core.info(`Command '${commentWords[0]}' to be dispatched.`); // Define payload - const slashCommandPayload = getSlashCommandPayload(commentWords); - core.debug(`Slash command payload: ${inspect(slashCommandPayload)}`); var clientPayload = { - slash_command: slashCommandPayload, + slash_command: {}, github: github.context }; @@ -154,6 +152,15 @@ async function run() { // Dispatch for each matching configuration for (const cmd of configMatches) { + // Generate slash command payload + clientPayload.slash_command = getSlashCommandPayload( + commentWords, + cmd.named_args + ); + core.debug( + `Slash command payload: ${inspect(clientPayload.slash_command)}` + ); + // Dispatch the command const dispatchRepo = cmd.repository.split("/"); const eventType = cmd.command + cmd.event_type_suffix; await octokit.repos.createDispatchEvent({