Add dispatch-type and support for workflow dispatch

This commit is contained in:
Peter Evans
2020-07-22 11:17:07 +09:00
parent 27e527b62e
commit d560454706
15 changed files with 642 additions and 147 deletions
+2 -1
View File
@@ -14,6 +14,7 @@
],
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/camelcase": "off"
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-types": "warn"
}
}
+6
View File
@@ -15,6 +15,12 @@
"foo=bar"
]
},
{
"command": "update",
"permission": "write",
"issue_type": "issue",
"dispatch_type": "workflow"
},
{
"command": "do-something-remotely",
"permission": "write",
+19 -6
View File
@@ -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`.
+33 -5
View File
@@ -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()
+4 -1
View File
@@ -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:
+152 -51
View File
@@ -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:
+3 -1
View File
@@ -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. | |
+4 -1
View File
@@ -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.
+92
View File
@@ -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
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "slash-command-dispatch",
"version": "1.0.0",
"version": "2.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+3 -2
View File
@@ -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",
+19 -49
View File
@@ -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 = /^(?<name>[a-zA-Z0-9_]+)=(?<value>[^\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<typeof Octokit>,
repo: Repo,
actor: string
): Promise<string> {
const {
data: {permission}
} = await octokit.repos.getCollaboratorPermissionLevel({
...repo,
username: actor
})
return permission
}
export async function addReaction(
octokit: InstanceType<typeof Octokit>,
repo: Repo,
commentId: number,
reaction:
| '+1'
| '-1'
| 'laugh'
| 'confused'
| 'heart'
| 'hooray'
| 'rocket'
| 'eyes'
): Promise<void> {
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[]
+285
View File
@@ -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<typeof Octokit>
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<string> {
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<void> {
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<PullsGetResponseData> {
const {data: pullRequest} = await this.octokit.pulls.get({
...repo,
pull_number: pullNumber
})
return pullRequest
}
async createDispatch(
cmd: Command,
clientPayload: ClientPayload
): Promise<void> {
if (cmd.dispatch_type == 'repository') {
await this.createRepositoryDispatch(cmd, clientPayload)
} else {
await this.createWorkflowDispatch(cmd, clientPayload)
}
}
private async createRepositoryDispatch(
cmd: Command,
clientPayload: ClientPayload
): Promise<void> {
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<void> {
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<string> {
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<number> {
// // 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<string> {
// 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<void> {
// 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
// }
// }
// }
// }
}
+17 -29
View File
@@ -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<void> {
try {
@@ -108,18 +106,21 @@ async function run(): Promise<void> {
}
}
// 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<void> {
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<void> {
// 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<void> {
`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'
+2
View File
@@ -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'