diff --git a/.eslintrc.json b/.eslintrc.json index 799df88..01d8ff9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -14,6 +14,7 @@ ], "plugins": ["@typescript-eslint"], "rules": { - "@typescript-eslint/camelcase": "off" + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/ban-types": "warn" } } diff --git a/.github/slash-command-dispatch.json b/.github/slash-command-dispatch.json index 33cfede..5ba6dad 100644 --- a/.github/slash-command-dispatch.json +++ b/.github/slash-command-dispatch.json @@ -15,6 +15,12 @@ "foo=bar" ] }, + { + "command": "update", + "permission": "write", + "issue_type": "issue", + "dispatch_type": "workflow" + }, { "command": "do-something-remotely", "permission": "write", diff --git a/README.md b/README.md index 802ff3b..5a9187e 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ [![CI](https://github.com/peter-evans/slash-command-dispatch/workflows/CI/badge.svg)](https://github.com/peter-evans/slash-command-dispatch/actions?query=workflow%3ACI) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Slash%20Command%20Dispatch-blue.svg?colorA=24292e&colorB=0366d6&style=flat&longCache=true&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAM6wAADOsB5dZE0gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAERSURBVCiRhZG/SsMxFEZPfsVJ61jbxaF0cRQRcRJ9hlYn30IHN/+9iquDCOIsblIrOjqKgy5aKoJQj4O3EEtbPwhJbr6Te28CmdSKeqzeqr0YbfVIrTBKakvtOl5dtTkK+v4HfA9PEyBFCY9AGVgCBLaBp1jPAyfAJ/AAdIEG0dNAiyP7+K1qIfMdonZic6+WJoBJvQlvuwDqcXadUuqPA1NKAlexbRTAIMvMOCjTbMwl1LtI/6KWJ5Q6rT6Ht1MA58AX8Apcqqt5r2qhrgAXQC3CZ6i1+KMd9TRu3MvA3aH/fFPnBodb6oe6HM8+lYHrGdRXW8M9bMZtPXUji69lmf5Cmamq7quNLFZXD9Rq7v0Bpc1o/tp0fisAAAAASUVORK5CYII=)](https://github.com/marketplace/actions/slash-command-dispatch) -A GitHub action that facilitates ["ChatOps"](https://www.pagerduty.com/blog/what-is-chatops/) by creating repository dispatch events for slash commands. +A GitHub action that facilitates ["ChatOps"](https://www.pagerduty.com/blog/what-is-chatops/) by creating dispatch events for slash commands. ### How does it work? The action runs in `issue_comment` event workflows and checks the first line of comments for slash commands. -When a valid command is found it creates a repository dispatch event that includes a payload containing full details of the command and its context. +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. +It also supports creating [workflow dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events with defined input parameters. -### Why repository dispatch? +### Why create dispatch events? "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 trying to handle new `issue_comment` events *and* process the commands themselves. @@ -32,6 +33,7 @@ See it in action with the following live demos. - [Examples](docs/examples.md) - [Standard configuration](#standard-configuration) - [Advanced configuration](docs/advanced-configuration.md) +- [Workflow dispatch](docs/workflow-dispatch.md) - [Updating to v2](docs/updating.md) ## Dispatching commands @@ -59,7 +61,7 @@ jobs: build-docs ``` -Note that not specifying the `repository` input will mean that `repository_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 seperate "processor" repository. +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 seperate "processor" repository. 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. @@ -77,13 +79,14 @@ This action also features [advanced configuration](docs/advanced-configuration.m | `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` | | `static-args` | A comma or newline separated list of arguments that will be dispatched with every command. | | +| `dispatch-type` | The dispatch type; `repository` or `workflow`. See [dispatch-type](#dispatch-type) for further details. | `repository` | | `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) | | #### `token` -This action creates [`repository_dispatch`](https://developer.github.com/v3/repos/#create-a-repository-dispatch-event) events. -The default `GITHUB_TOKEN` does not have scopes to do this so a `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line) is required. +This action creates [`repository_dispatch`](https://developer.github.com/v3/repos/#create-a-repository-dispatch-event) and [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. +The default `GITHUB_TOKEN` does not have scopes to create these events, so a `repo` scoped [PAT](https://help.github.com/en/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line) is required. If you will be dispatching commands to public repositories *only* then you can use the more limited `public_repo` scope. #### `reaction-token` @@ -104,6 +107,13 @@ You can use a [PAT](https://help.github.com/en/github/authenticating-to-github/c build-docs ``` +#### `dispatch-type` + +By default, the action creates [`repository_dispatch`](https://developer.github.com/v3/repos/#create-a-repository-dispatch-event) events. +Setting `dispatch-type` to `workflow` will instead create [workflow_dispatch](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) events. + +There are significant differences in the action's behaviour when using `workflow` dispatch. See [workflow dispatch](docs/workflow-dispatch.md) for usage details. + ### How comments are parsed for slash commands Slash commands must be placed in the first line of the comment to be interpreted as a command. @@ -116,6 +126,9 @@ Slash commands must be placed in the first line of the comment to be interpreted ## Handling dispatched commands +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. +For `workflow` dispatch documentation, see [workflow dispatch](docs/workflow-dispatch.md). + ### Event types 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`. diff --git a/__test__/command-helper.test.ts b/__test__/command-helper.test.ts index e09f79a..952833e 100644 --- a/__test__/command-helper.test.ts +++ b/__test__/command-helper.test.ts @@ -25,6 +25,7 @@ describe('command-helper tests', () => { repository: '', eventTypeSuffix: '-command', staticArgs: [], + dispatchType: 'repository', config: '', configFromFile: '' } @@ -40,6 +41,7 @@ describe('command-helper tests', () => { commandDefaults.event_type_suffix ) expect(config[i].static_args).toEqual(commandDefaults.static_args) + expect(config[i].dispatch_type).toEqual(commandDefaults.dispatch_type) } }) @@ -56,6 +58,7 @@ describe('command-helper tests', () => { repository: 'owner/repo', eventTypeSuffix: '-cmd', staticArgs: ['production', 'region=us-east-1'], + dispatchType: 'workflow', config: '', configFromFile: '' } @@ -69,6 +72,7 @@ describe('command-helper tests', () => { expect(config[i].repository).toEqual(inputs.repository) expect(config[i].event_type_suffix).toEqual(inputs.eventTypeSuffix) expect(config[i].static_args).toEqual(inputs.staticArgs) + expect(config[i].dispatch_type).toEqual(inputs.dispatchType) } }) @@ -93,7 +97,8 @@ describe('command-helper tests', () => { expect(config[i].event_type_suffix).toEqual( commandDefaults.event_type_suffix ) - expect(config[i].static_args).toEqual(commandDefaults.static_args) + expect(config[i].static_args).toEqual(commandDefaults.static_args), + expect(config[i].dispatch_type).toEqual(commandDefaults.dispatch_type) } }) @@ -113,7 +118,8 @@ describe('command-helper tests', () => { "static_args": [ "production", "region=us-east-1" - ] + ], + "dispatch_type": "workflow" } ]` const commands = ['do-stuff', 'test-all-the-things'] @@ -125,10 +131,13 @@ describe('command-helper tests', () => { expect(config[0].allow_edits).toBeTruthy() expect(config[0].repository).toEqual('owner/repo') expect(config[0].event_type_suffix).toEqual('-cmd') + expect(config[0].static_args).toEqual([]) + expect(config[0].dispatch_type).toEqual('repository') expect(config[1].command).toEqual(commands[1]) expect(config[1].permission).toEqual('read') expect(config[1].issue_type).toEqual(commandDefaults.issue_type) expect(config[1].static_args).toEqual(['production', 'region=us-east-1']) + expect(config[1].dispatch_type).toEqual('workflow') }) test('valid config', async () => { @@ -140,7 +149,8 @@ describe('command-helper tests', () => { allow_edits: false, repository: 'peter-evans/slash-command-dispatch', event_type_suffix: '-command', - static_args: [] + static_args: [], + dispatch_type: 'repository' } ] expect(configIsValid(config)).toBeTruthy() @@ -155,7 +165,8 @@ describe('command-helper tests', () => { allow_edits: false, repository: 'peter-evans/slash-command-dispatch', event_type_suffix: '-command', - static_args: [] + static_args: [], + dispatch_type: 'repository' } ] expect(configIsValid(config)).toBeFalsy() @@ -170,7 +181,24 @@ describe('command-helper tests', () => { allow_edits: false, repository: 'peter-evans/slash-command-dispatch', event_type_suffix: '-command', - static_args: [] + static_args: [], + dispatch_type: 'repository' + } + ] + expect(configIsValid(config)).toBeFalsy() + }) + + test('invalid dispatch type in config', async () => { + const config: Command[] = [ + { + command: 'test', + permission: 'write', + issue_type: 'test-case-invalid-issue-type', + allow_edits: false, + repository: 'peter-evans/slash-command-dispatch', + event_type_suffix: '-command', + static_args: [], + dispatch_type: 'test-case-invalid-dispatch-type' } ] expect(configIsValid(config)).toBeFalsy() diff --git a/action.yml b/action.yml index 7cc4503..a6b8dda 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'Slash Command Dispatch' -description: 'Facilitates "ChatOps" by creating repository dispatch events for slash commands' +description: 'Facilitates "ChatOps" by creating dispatch events for slash commands' inputs: token: description: 'A repo scoped GitHub Personal Access Token.' @@ -30,6 +30,9 @@ inputs: default: -command static-args: description: 'A comma or newline separated list of arguments that will be dispatched with every command.' + dispatch-type: + description: 'The dispatch type; `repository` or `workflow`.' + default: repository config: description: 'JSON configuration for commands.' config-from-file: diff --git a/dist/index.js b/dist/index.js index 09ad8b7..b2440c3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -520,7 +520,7 @@ const core = __importStar(__webpack_require__(470)); const github = __importStar(__webpack_require__(469)); const util_1 = __webpack_require__(669); const command_helper_1 = __webpack_require__(372); -const octokit_client_1 = __webpack_require__(921); +const github_helper_1 = __webpack_require__(718); function run() { return __awaiter(this, void 0, void 0, function* () { try { @@ -595,15 +595,15 @@ function run() { return; } } - // Set octokit clients - const octokit = new octokit_client_1.Octokit({ auth: `${inputs.token}` }); - const reactionOctokit = new octokit_client_1.Octokit({ auth: `${inputs.reactionToken}` }); + // Create github clients + const githubHelper = new github_helper_1.GitHubHelper(inputs.token); + const githubHelperReaction = new github_helper_1.GitHubHelper(inputs.reactionToken); // At this point we know the command is registered // Add the "eyes" reaction to the comment if (inputs.reactions) - yield command_helper_1.addReaction(reactionOctokit, github.context.repo, commentId, 'eyes'); + yield githubHelperReaction.tryAddReaction(github.context.repo, commentId, 'eyes'); // Get the actor permission - const actorPermission = yield command_helper_1.getActorPermission(octokit, github.context.repo, github.context.actor); + const actorPermission = yield githubHelper.getActorPermission(github.context.repo, github.context.actor); core.debug(`Actor permission: ${actorPermission}`); // Filter matching commands by the user's permission level configMatches = configMatches.filter(function (cmd) { @@ -618,7 +618,6 @@ function run() { core.info(`Command '${commandWords[0]}' to be dispatched.`); // Define payload const clientPayload = { - slash_command: {}, github: github.context }; // Truncate the body to keep the size of the payload under the max @@ -628,7 +627,7 @@ function run() { } // Get the pull request context for the dispatch payload if (isPullRequest) { - const { data: pullRequest } = yield octokit.pulls.get(Object.assign(Object.assign({}, github.context.repo), { pull_number: github.context.payload.issue.number })); + const pullRequest = yield githubHelper.getPull(github.context.repo, github.context.payload.issue.number); // Truncate the body to keep the size of the payload under the max pullRequest.body = pullRequest.body.slice(0, 1000); clientPayload['pull_request'] = pullRequest; @@ -639,20 +638,11 @@ function run() { clientPayload.slash_command = command_helper_1.getSlashCommandPayload(commandWords, cmd.static_args); core.debug(`Slash command payload: ${util_1.inspect(clientPayload.slash_command)}`); // Dispatch the command - const dispatchRepo = cmd.repository.split('/'); - const eventType = cmd.command + cmd.event_type_suffix; - yield octokit.repos.createDispatchEvent({ - owner: dispatchRepo[0], - repo: dispatchRepo[1], - event_type: eventType, - client_payload: clientPayload - }); - core.info(`Command '${cmd.command}' dispatched to '${cmd.repository}' ` + - `with event type '${eventType}'.`); + yield githubHelper.createDispatch(cmd, clientPayload); } // Add the "rocket" reaction to the comment if (inputs.reactions) - yield command_helper_1.addReaction(reactionOctokit, github.context.repo, commentId, 'rocket'); + yield githubHelperReaction.tryAddReaction(github.context.repo, commentId, 'rocket'); } catch (error) { core.debug(util_1.inspect(error)); @@ -933,17 +923,8 @@ var __importStar = (this && this.__importStar) || function (mod) { __setModuleDefault(result, mod); return result; }; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getSlashCommandPayload = exports.addReaction = exports.getActorPermission = exports.actorHasPermission = exports.configIsValid = exports.getCommandsConfigFromJson = exports.getCommandsConfigFromInputs = exports.getCommandsConfig = exports.getInputs = exports.toBool = exports.commandDefaults = exports.MAX_ARGS = void 0; +exports.getSlashCommandPayload = exports.actorHasPermission = exports.configIsValid = exports.getCommandsConfigFromJson = exports.getCommandsConfigFromInputs = exports.getCommandsConfig = exports.getInputs = exports.toBool = exports.commandDefaults = exports.MAX_ARGS = void 0; const core = __importStar(__webpack_require__(470)); const fs = __importStar(__webpack_require__(747)); const util_1 = __webpack_require__(669); @@ -956,7 +937,8 @@ exports.commandDefaults = Object.freeze({ allow_edits: false, repository: process.env.GITHUB_REPOSITORY || '', event_type_suffix: '-command', - static_args: [] + static_args: [], + dispatch_type: 'repository' }); function toBool(input, defaultVal) { if (typeof input === 'boolean') { @@ -983,6 +965,7 @@ function getInputs() { repository: core.getInput('repository'), eventTypeSuffix: core.getInput('event-type-suffix'), staticArgs: utils.getInputAsArray('static-args'), + dispatchType: core.getInput('dispatch-type'), config: core.getInput('config'), configFromFile: core.getInput('config-from-file') }; @@ -1018,7 +1001,8 @@ function getCommandsConfigFromInputs(inputs) { allow_edits: inputs.allowEdits, repository: inputs.repository, event_type_suffix: inputs.eventTypeSuffix, - static_args: inputs.staticArgs + static_args: inputs.staticArgs, + dispatch_type: inputs.dispatchType }; config.push(cmd); } @@ -1039,7 +1023,12 @@ function getCommandsConfigFromJson(json) { event_type_suffix: jc.event_type_suffix ? jc.event_type_suffix : exports.commandDefaults.event_type_suffix, - static_args: jc.static_args ? jc.static_args : exports.commandDefaults.static_args + static_args: jc.static_args + ? jc.static_args + : exports.commandDefaults.static_args, + dispatch_type: jc.dispatch_type + ? jc.dispatch_type + : exports.commandDefaults.dispatch_type }; config.push(cmd); } @@ -1056,6 +1045,10 @@ function configIsValid(config) { core.setFailed(`'${command.issue_type}' is not a valid 'issue-type'.`); return false; } + if (!['repository', 'workflow'].includes(command.dispatch_type)) { + core.setFailed(`'${command.dispatch_type}' is not a valid 'dispatch-type'.`); + return false; + } } return true; } @@ -1072,25 +1065,6 @@ function actorHasPermission(actorPermission, commandPermission) { return (permissionLevels[actorPermission] >= permissionLevels[commandPermission]); } exports.actorHasPermission = actorHasPermission; -function getActorPermission(octokit, repo, actor) { - return __awaiter(this, void 0, void 0, function* () { - const { data: { permission } } = yield octokit.repos.getCollaboratorPermissionLevel(Object.assign(Object.assign({}, repo), { username: actor })); - return permission; - }); -} -exports.getActorPermission = getActorPermission; -function addReaction(octokit, repo, commentId, reaction) { - return __awaiter(this, void 0, void 0, function* () { - try { - yield octokit.reactions.createForIssueComment(Object.assign(Object.assign({}, repo), { comment_id: commentId, content: reaction })); - } - catch (error) { - core.debug(util_1.inspect(error)); - core.warning(`Failed to set reaction on comment ID ${commentId}.`); - } - }); -} -exports.addReaction = addReaction; function getSlashCommandPayload(commandWords, staticArgs) { const payload = { command: commandWords[0], @@ -4676,6 +4650,133 @@ function isPlainObject(o) { module.exports = isPlainObject; +/***/ }), + +/***/ 718: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.GitHubHelper = void 0; +const core = __importStar(__webpack_require__(470)); +const octokit_client_1 = __webpack_require__(921); +class GitHubHelper { + constructor(token) { + const options = {}; + if (token) { + options.auth = `${token}`; + } + this.octokit = new octokit_client_1.Octokit(options); + } + parseRepository(repository) { + const [owner, repo] = repository.split('/'); + return { + owner: owner, + repo: repo + }; + } + getActorPermission(repo, actor) { + return __awaiter(this, void 0, void 0, function* () { + const { data: { permission } } = yield this.octokit.repos.getCollaboratorPermissionLevel(Object.assign(Object.assign({}, repo), { username: actor })); + return permission; + }); + } + tryAddReaction(repo, commentId, reaction) { + return __awaiter(this, void 0, void 0, function* () { + try { + yield this.octokit.reactions.createForIssueComment(Object.assign(Object.assign({}, repo), { comment_id: commentId, content: reaction })); + } + catch (error) { + core.debug(error); + core.warning(`Failed to set reaction on comment ID ${commentId}.`); + } + }); + } + getPull(repo, pullNumber) { + return __awaiter(this, void 0, void 0, function* () { + const { data: pullRequest } = yield this.octokit.pulls.get(Object.assign(Object.assign({}, repo), { pull_number: pullNumber })); + return pullRequest; + }); + } + createDispatch(cmd, clientPayload) { + return __awaiter(this, void 0, void 0, function* () { + if (cmd.dispatch_type == 'repository') { + yield this.createRepositoryDispatch(cmd, clientPayload); + } + else { + yield this.createWorkflowDispatch(cmd, clientPayload); + } + }); + } + createRepositoryDispatch(cmd, clientPayload) { + return __awaiter(this, void 0, void 0, function* () { + const eventType = `${cmd.command}${cmd.event_type_suffix}`; + yield this.octokit.repos.createDispatchEvent(Object.assign(Object.assign({}, this.parseRepository(cmd.repository)), { event_type: `${cmd.command}${cmd.event_type_suffix}`, client_payload: clientPayload })); + core.info(`Command '${cmd.command}' dispatched to '${cmd.repository}' ` + + `with event type '${eventType}'.`); + }); + } + createWorkflowDispatch(cmd, clientPayload) { + return __awaiter(this, void 0, void 0, function* () { + const workflow = `${cmd.command}${cmd.event_type_suffix}.yml`; + const slashCommand = clientPayload.slash_command; + const ref = slashCommand.args.named.ref + ? slashCommand.args.named.ref + : yield this.getDefaultBranch(cmd.repository); + // Take max 10 named arguments, excluding 'ref'. + const inputs = {}; + let count = 0; + for (const key in slashCommand.args.named) { + if (key != 'ref') { + inputs[key] = slashCommand.args.named[key]; + count++; + } + if (count == 10) + break; + } + yield this.octokit.request('POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', Object.assign(Object.assign({}, this.parseRepository(cmd.repository)), { workflow_id: workflow, ref: ref, inputs: inputs })); + core.info(`Command '${cmd.command}' dispatched to workflow '${workflow}' in '${cmd.repository}'`); + }); + } + getDefaultBranch(repository) { + return __awaiter(this, void 0, void 0, function* () { + const { data: repo } = yield this.octokit.repos.get(Object.assign({}, this.parseRepository(repository))); + return repo.default_branch; + }); + } +} +exports.GitHubHelper = GitHubHelper; + + /***/ }), /***/ 747: diff --git a/docs/advanced-configuration.md b/docs/advanced-configuration.md index 3e1dd3a..01c1ece 100644 --- a/docs/advanced-configuration.md +++ b/docs/advanced-configuration.md @@ -64,7 +64,8 @@ jobs: "permission": "write", "issue_type": "issue", "allow_edits": true, - "event_type_suffix": "-cmd" + "event_type_suffix": "-cmd", + "dispatch_type": "workflow" } ] ``` @@ -105,5 +106,6 @@ Advanced configuration requires a combination of yaml based inputs and JSON conf | | `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 command. | `-command` | | | `static_args` | A string array of arguments that will be dispatched with the command. | `[]` | +| | `dispatch_type` | The dispatch type; `repository` or `workflow`. | `repository` | | `config` | | JSON configuration for commands. | | | `config-from-file` | | JSON configuration from a file for commands. | | diff --git a/docs/updating.md b/docs/updating.md index 383e2f6..6a05b09 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -50,9 +50,12 @@ region=us-east-1 ``` Advanced configuration: - ```yml + ```json "static_args": [ "production", "region=us-east-1" ] ``` + + - 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`. + There are significant differences in the action's behaviour when using `workflow` dispatch. See [workflow dispatch](workflow-dispatch.md) for usage details. diff --git a/docs/workflow-dispatch.md b/docs/workflow-dispatch.md new file mode 100644 index 0000000..2d83235 --- /dev/null +++ b/docs/workflow-dispatch.md @@ -0,0 +1,92 @@ +# Workflow dispatch + +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. + +To learn about the `workflow_dispatch` event, see GitHub's documentation on [manually running a workflow](https://docs.github.com/en/actions/configuring-and-managing-workflows/configuring-a-workflow#manually-running-a-workflow). + +## Action behaviour + +There are significant differences in the action's behaviour when using `workflow` dispatch. + +- When issuing a slash command with arguments, *only named arguments are accepted*. Unnamed arguments will be ignored. +- A maximum of 10 named arguments are accepted. Additional named arguments after the first 10 will be ignored. +- `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. +- 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 the named arguments supplied with the slash command. + +## Handling dispatched commands + +### Workflow name + +It is important to name the `workflow_dispatch` event workflow correctly since the action targets the workflow based on its filename. The target filename is a combination of the command name and the `event-type-suffix`. + +For the following example configuration, the target workflows are: +- `deploy-command.yml` +- `integration-test-command.yml` +- `build-docs-command.yml` + +```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@v2 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + commands: | + deploy + integration-test + build-docs + dispatch-type: workflow +``` + +### Responding to the comment on command completion + +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. + +Using basic configuration: +```yml + static-args: | + repository=${{ github.repository }} + comment-id=${{ github.event.issue.comment-id }} +``` + +Using advanced configuration: +```json + "static_args": [ + "repository=${{ github.repository }}", + "comment-id=${{ github.event.issue.comment-id }}" + ] +``` + +The target workflow must define these arguments as inputs. + +```yml +on: + workflow_dispatch: + inputs: + repository: + description: 'The repository from which the slash command was dispatched' + required: true + comment-id: + description: 'The comment-id of the slash command' + required: true +``` + +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. + +The simplest response is to add a :tada: reaction to the comment. + +```yml + - name: Add reaction + uses: peter-evans/create-or-update-comment@v1 + with: + token: ${{ secrets.REPO_ACCESS_TOKEN }} + repository: ${{ github.event.inputs.repository }} + comment-id: ${{ github.event.inputs.comment-id }} + reaction-type: hooray +``` diff --git a/package-lock.json b/package-lock.json index 8c610fb..5f33216 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "slash-command-dispatch", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index f11f69c..99f4f6d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "slash-command-dispatch", "version": "2.0.0", "private": true, - "description": "Create repository dispatch events for slash commands", + "description": "Facilitates 'ChatOps' by creating dispatch events for slash commands", "main": "lib/main.js", "scripts": { "build": "tsc && ncc build", @@ -31,7 +31,8 @@ "@actions/github": "4.0.0", "@octokit/core": "3.1.1", "@octokit/plugin-paginate-rest": "2.2.3", - "@octokit/plugin-rest-endpoint-methods": "4.1.0" + "@octokit/plugin-rest-endpoint-methods": "4.1.0", + "@octokit/types": "5.1.0" }, "devDependencies": { "@types/jest": "26.0.5", diff --git a/src/command-helper.ts b/src/command-helper.ts index 85c4846..72cc1f7 100644 --- a/src/command-helper.ts +++ b/src/command-helper.ts @@ -1,7 +1,6 @@ import * as core from '@actions/core' import * as fs from 'fs' import {inspect} from 'util' -import {Octokit} from './octokit-client' import * as utils from './utils' const NAMED_ARG_PATTERN = /^(?[a-zA-Z0-9_]+)=(?[^\s]+)$/ @@ -19,6 +18,7 @@ export interface Inputs { repository: string eventTypeSuffix: string staticArgs: string[] + dispatchType: string config: string configFromFile: string } @@ -31,11 +31,7 @@ export interface Command { repository: string event_type_suffix: string static_args: string[] -} - -interface Repo { - owner: string - repo: string + dispatch_type: string } export interface SlashCommandPayload { @@ -58,7 +54,8 @@ export const commandDefaults = Object.freeze({ allow_edits: false, repository: process.env.GITHUB_REPOSITORY || '', event_type_suffix: '-command', - static_args: [] + static_args: [], + dispatch_type: 'repository' }) export function toBool(input: string, defaultVal: boolean): boolean { @@ -84,6 +81,7 @@ export function getInputs(): Inputs { repository: core.getInput('repository'), eventTypeSuffix: core.getInput('event-type-suffix'), staticArgs: utils.getInputAsArray('static-args'), + dispatchType: core.getInput('dispatch-type'), config: core.getInput('config'), configFromFile: core.getInput('config-from-file') } @@ -118,7 +116,8 @@ export function getCommandsConfigFromInputs(inputs: Inputs): Command[] { allow_edits: inputs.allowEdits, repository: inputs.repository, event_type_suffix: inputs.eventTypeSuffix, - static_args: inputs.staticArgs + static_args: inputs.staticArgs, + dispatch_type: inputs.dispatchType } config.push(cmd) } @@ -140,7 +139,12 @@ export function getCommandsConfigFromJson(json: string): Command[] { event_type_suffix: jc.event_type_suffix ? jc.event_type_suffix : commandDefaults.event_type_suffix, - static_args: jc.static_args ? jc.static_args : commandDefaults.static_args + static_args: jc.static_args + ? jc.static_args + : commandDefaults.static_args, + dispatch_type: jc.dispatch_type + ? jc.dispatch_type + : commandDefaults.dispatch_type } config.push(cmd) } @@ -157,6 +161,12 @@ export function configIsValid(config: Command[]): boolean { core.setFailed(`'${command.issue_type}' is not a valid 'issue-type'.`) return false } + if (!['repository', 'workflow'].includes(command.dispatch_type)) { + core.setFailed( + `'${command.dispatch_type}' is not a valid 'dispatch-type'.` + ) + return false + } } return true } @@ -178,46 +188,6 @@ export function actorHasPermission( ) } -export async function getActorPermission( - octokit: InstanceType, - repo: Repo, - actor: string -): Promise { - const { - data: {permission} - } = await octokit.repos.getCollaboratorPermissionLevel({ - ...repo, - username: actor - }) - return permission -} - -export async function addReaction( - octokit: InstanceType, - repo: Repo, - commentId: number, - reaction: - | '+1' - | '-1' - | 'laugh' - | 'confused' - | 'heart' - | 'hooray' - | 'rocket' - | 'eyes' -): Promise { - try { - await octokit.reactions.createForIssueComment({ - ...repo, - comment_id: commentId, - content: reaction - }) - } catch (error) { - core.debug(inspect(error)) - core.warning(`Failed to set reaction on comment ID ${commentId}.`) - } -} - export function getSlashCommandPayload( commandWords: string[], staticArgs: string[] diff --git a/src/github-helper.ts b/src/github-helper.ts new file mode 100644 index 0000000..830bb0c --- /dev/null +++ b/src/github-helper.ts @@ -0,0 +1,285 @@ +import * as core from '@actions/core' +import {Octokit, OctokitOptions, PullsGetResponseData} from './octokit-client' +import {Command, SlashCommandPayload} from './command-helper' + +type ReposCreateDispatchEventParamsClientPayload = { + [key: string]: ReposCreateDispatchEventParamsClientPayloadKeyString +} +type ReposCreateDispatchEventParamsClientPayloadKeyString = {} + +export interface ClientPayload + extends ReposCreateDispatchEventParamsClientPayload { + github: any + pull_request?: any + slash_command?: SlashCommandPayload | any +} + +interface Repository { + owner: string + repo: string +} + +export class GitHubHelper { + private octokit: InstanceType + + constructor(token: string) { + const options: OctokitOptions = {} + if (token) { + options.auth = `${token}` + } + this.octokit = new Octokit(options) + } + + private parseRepository(repository: string): Repository { + const [owner, repo] = repository.split('/') + return { + owner: owner, + repo: repo + } + } + + async getActorPermission(repo: Repository, actor: string): Promise { + const { + data: {permission} + } = await this.octokit.repos.getCollaboratorPermissionLevel({ + ...repo, + username: actor + }) + return permission + } + + async tryAddReaction( + repo: Repository, + commentId: number, + reaction: + | '+1' + | '-1' + | 'laugh' + | 'confused' + | 'heart' + | 'hooray' + | 'rocket' + | 'eyes' + ): Promise { + try { + await this.octokit.reactions.createForIssueComment({ + ...repo, + comment_id: commentId, + content: reaction + }) + } catch (error) { + core.debug(error) + core.warning(`Failed to set reaction on comment ID ${commentId}.`) + } + } + + async getPull( + repo: Repository, + pullNumber: number + ): Promise { + const {data: pullRequest} = await this.octokit.pulls.get({ + ...repo, + pull_number: pullNumber + }) + return pullRequest + } + + async createDispatch( + cmd: Command, + clientPayload: ClientPayload + ): Promise { + if (cmd.dispatch_type == 'repository') { + await this.createRepositoryDispatch(cmd, clientPayload) + } else { + await this.createWorkflowDispatch(cmd, clientPayload) + } + } + + private async createRepositoryDispatch( + cmd: Command, + clientPayload: ClientPayload + ): Promise { + const eventType = `${cmd.command}${cmd.event_type_suffix}` + await this.octokit.repos.createDispatchEvent({ + ...this.parseRepository(cmd.repository), + event_type: `${cmd.command}${cmd.event_type_suffix}`, + client_payload: clientPayload + }) + core.info( + `Command '${cmd.command}' dispatched to '${cmd.repository}' ` + + `with event type '${eventType}'.` + ) + } + + async createWorkflowDispatch( + cmd: Command, + clientPayload: ClientPayload + ): Promise { + const workflow = `${cmd.command}${cmd.event_type_suffix}.yml` + const slashCommand: SlashCommandPayload = clientPayload.slash_command + const ref = slashCommand.args.named.ref + ? slashCommand.args.named.ref + : await this.getDefaultBranch(cmd.repository) + + // Take max 10 named arguments, excluding 'ref'. + const inputs = {} + let count = 0 + for (const key in slashCommand.args.named) { + if (key != 'ref') { + inputs[key] = slashCommand.args.named[key] + count++ + } + if (count == 10) break + } + + await this.octokit.request( + 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', + { + ...this.parseRepository(cmd.repository), + workflow_id: workflow, + ref: ref, + inputs: inputs + } + ) + core.info( + `Command '${cmd.command}' dispatched to workflow '${workflow}' in '${cmd.repository}'` + ) + } + + private async getDefaultBranch(repository: string): Promise { + const {data: repo} = await this.octokit.repos.get({ + ...this.parseRepository(repository) + }) + return repo.default_branch + } + + // private async createOrUpdate( + // inputs: Inputs, + // baseRepository: string, + // headBranch: string + // ): Promise { + // // Try to create the pull request + // try { + // const {data: pull} = await this.octokit.pulls.create({ + // ...this.parseRepository(baseRepository), + // title: inputs.title, + // head: headBranch, + // base: inputs.base, + // body: inputs.body, + // draft: inputs.draft + // }) + // core.info( + // `Created pull request #${pull.number} (${headBranch} => ${inputs.base})` + // ) + // return pull.number + // } catch (e) { + // if ( + // !e.message || + // !e.message.includes(`A pull request already exists for ${headBranch}`) + // ) { + // throw e + // } + // } + + // // Update the pull request that exists for this branch and base + // const {data: pulls} = await this.octokit.pulls.list({ + // ...this.parseRepository(baseRepository), + // state: 'open', + // head: headBranch, + // base: inputs.base + // }) + // const {data: pull} = await this.octokit.pulls.update({ + // ...this.parseRepository(baseRepository), + // pull_number: pulls[0].number, + // title: inputs.title, + // body: inputs.body, + // draft: inputs.draft + // }) + // core.info( + // `Updated pull request #${pull.number} (${headBranch} => ${inputs.base})` + // ) + // return pull.number + // } + + // async getRepositoryParent(headRepository: string): Promise { + // const {data: headRepo} = await this.octokit.repos.get({ + // ...this.parseRepository(headRepository) + // }) + // if (!headRepo.parent) { + // throw new Error( + // `Repository '${headRepository}' is not a fork. Unable to continue.` + // ) + // } + // return headRepo.parent.full_name + // } + + // async createOrUpdatePullRequest( + // inputs: Inputs, + // baseRepository: string, + // headRepository: string + // ): Promise { + // const [headOwner] = headRepository.split('/') + // const headBranch = `${headOwner}:${inputs.branch}` + + // // Create or update the pull request + // const pullNumber = await this.createOrUpdate( + // inputs, + // baseRepository, + // headBranch + // ) + + // // Set outputs + // core.startGroup('Setting outputs') + // core.setOutput('pull-request-number', pullNumber) + // core.exportVariable('PULL_REQUEST_NUMBER', pullNumber) + // core.endGroup() + + // // Set milestone, labels and assignees + // const updateIssueParams = {} + // if (inputs.milestone) { + // updateIssueParams['milestone'] = inputs.milestone + // core.info(`Applying milestone '${inputs.milestone}'`) + // } + // if (inputs.labels.length > 0) { + // updateIssueParams['labels'] = inputs.labels + // core.info(`Applying labels '${inputs.labels}'`) + // } + // if (inputs.assignees.length > 0) { + // updateIssueParams['assignees'] = inputs.assignees + // core.info(`Applying assignees '${inputs.assignees}'`) + // } + // if (Object.keys(updateIssueParams).length > 0) { + // await this.octokit.issues.update({ + // ...this.parseRepository(baseRepository), + // issue_number: pullNumber, + // ...updateIssueParams + // }) + // } + + // // Request reviewers and team reviewers + // const requestReviewersParams = {} + // if (inputs.reviewers.length > 0) { + // requestReviewersParams['reviewers'] = inputs.reviewers + // core.info(`Requesting reviewers '${inputs.reviewers}'`) + // } + // if (inputs.teamReviewers.length > 0) { + // requestReviewersParams['team_reviewers'] = inputs.teamReviewers + // core.info(`Requesting team reviewers '${inputs.teamReviewers}'`) + // } + // if (Object.keys(requestReviewersParams).length > 0) { + // try { + // await this.octokit.pulls.requestReviewers({ + // ...this.parseRepository(baseRepository), + // pull_number: pullNumber, + // ...requestReviewersParams + // }) + // } catch (e) { + // if (e.message && e.message.includes(ERROR_PR_REVIEW_FROM_AUTHOR)) { + // core.warning(ERROR_PR_REVIEW_FROM_AUTHOR) + // } else { + // throw e + // } + // } + // } + // } +} diff --git a/src/main.ts b/src/main.ts index 04399df..cfcf373 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,11 +6,9 @@ import { getCommandsConfig, configIsValid, actorHasPermission, - getActorPermission, - addReaction, getSlashCommandPayload } from './command-helper' -import {Octokit} from './octokit-client' +import {GitHubHelper, ClientPayload} from './github-helper' async function run(): Promise { try { @@ -108,18 +106,21 @@ async function run(): Promise { } } - // Set octokit clients - const octokit = new Octokit({auth: `${inputs.token}`}) - const reactionOctokit = new Octokit({auth: `${inputs.reactionToken}`}) + // Create github clients + const githubHelper = new GitHubHelper(inputs.token) + const githubHelperReaction = new GitHubHelper(inputs.reactionToken) // At this point we know the command is registered // Add the "eyes" reaction to the comment if (inputs.reactions) - await addReaction(reactionOctokit, github.context.repo, commentId, 'eyes') + await githubHelperReaction.tryAddReaction( + github.context.repo, + commentId, + 'eyes' + ) // Get the actor permission - const actorPermission = await getActorPermission( - octokit, + const actorPermission = await githubHelper.getActorPermission( github.context.repo, github.context.actor ) @@ -141,8 +142,7 @@ async function run(): Promise { core.info(`Command '${commandWords[0]}' to be dispatched.`) // Define payload - const clientPayload = { - slash_command: {}, + const clientPayload: ClientPayload = { github: github.context } // Truncate the body to keep the size of the payload under the max @@ -158,10 +158,10 @@ async function run(): Promise { // Get the pull request context for the dispatch payload if (isPullRequest) { - const {data: pullRequest} = await octokit.pulls.get({ - ...github.context.repo, - pull_number: github.context.payload.issue.number - }) + const pullRequest = await githubHelper.getPull( + github.context.repo, + github.context.payload.issue.number + ) // Truncate the body to keep the size of the payload under the max pullRequest.body = pullRequest.body.slice(0, 1000) clientPayload['pull_request'] = pullRequest @@ -178,24 +178,12 @@ async function run(): Promise { `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({ - owner: dispatchRepo[0], - repo: dispatchRepo[1], - event_type: eventType, - client_payload: clientPayload - }) - core.info( - `Command '${cmd.command}' dispatched to '${cmd.repository}' ` + - `with event type '${eventType}'.` - ) + await githubHelper.createDispatch(cmd, clientPayload) } // Add the "rocket" reaction to the comment if (inputs.reactions) - await addReaction( - reactionOctokit, + await githubHelperReaction.tryAddReaction( github.context.repo, commentId, 'rocket' diff --git a/src/octokit-client.ts b/src/octokit-client.ts index 5e82c5d..a13b1a6 100644 --- a/src/octokit-client.ts +++ b/src/octokit-client.ts @@ -5,3 +5,5 @@ export {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods' export {OctokitOptions} from '@octokit/core/dist-types/types' export const Octokit = Core.plugin(paginateRest, restEndpointMethods) + +export {PullsGetResponseData} from '@octokit/types'