Add action

This commit is contained in:
Peter Evans
2019-11-24 13:21:38 +09:00
parent 4776d95a05
commit d3083b5256
10 changed files with 17920 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"env": {
"commonjs": true,
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2018
},
"rules": {
}
}
+1
View File
@@ -0,0 +1 @@
node_modules
+33
View File
@@ -0,0 +1,33 @@
name: 'Slash Commmand Dispatch'
description: 'Facilitates "ChatOps" by creating repository dispatch events for slash commands'
inputs:
token:
description: 'A repo scoped GitHub Personal Access Token.'
required: true
reaction-token:
description: 'An optional GitHub token to use for reactions.'
reactions:
description: 'Add reactions to comments containing commands.'
commands:
description: 'A comma separated list of commands to dispatch.'
required: true
permission:
description: 'The permission level required by the user to dispatch commands.'
issue-type:
description: 'The issue type required for commands.'
allow-edits:
description: 'Allow edited comments to trigger command dispatches.'
repository:
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.'
config:
description: 'JSON configuration for the action.'
config-from-file:
description: 'JSON configuration from a file for the action.'
runs:
using: 'node12'
main: 'dist/index.js'
branding:
icon: 'target'
color: 'gray-dark'
+11571
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
process.env = Object.assign(process.env, {
GITHUB_REPOSITORY: "peter-evans/slash-command-dispatch"
});
+5728
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "slash-command-dispatch",
"version": "1.0.0",
"description": "Create repository dispatch events for slash commands",
"main": "src/index.js",
"scripts": {
"lint": "eslint src/index.js",
"package": "ncc build src/index.js -o dist",
"test": "eslint src/index.js && jest"
},
"repository": {
"type": "git",
"url": "git+https://github.com/peter-evans/slash-command-dispatch.git"
},
"keywords": [],
"author": "Peter Evans",
"license": "MIT",
"bugs": {
"url": "https://github.com/peter-evans/slash-command-dispatch/issues"
},
"homepage": "https://github.com/peter-evans/slash-command-dispatch#readme",
"dependencies": {
"@actions/core": "^1.1.1",
"@actions/github": "^1.1.0"
},
"devDependencies": {
"@zeit/ncc": "^0.20.5",
"eslint": "^6.3.0",
"jest": "^24.9.0"
}
}
+185
View File
@@ -0,0 +1,185 @@
const { inspect } = require("util");
var fs = require("fs");
const core = require("@actions/core");
const MAX_ARGS = 50;
const commandDefaults = Object.freeze({
permission: "write",
issue_type: "both",
allow_edits: false,
repository: process.env.GITHUB_REPOSITORY,
event_type_suffix: "-command"
});
function toBool(input, defaultVal) {
if (typeof input === 'boolean') {
return input;
} else if (typeof input === 'undefined' || input.length == 0) {
return defaultVal;
} else {
return input === "true";
}
}
function getInputs() {
return {
token: core.getInput("token"),
reactionToken: core.getInput("reaction-token"),
reactions: toBool(core.getInput("reactions"), true),
commands: core.getInput("commands"),
permission: core.getInput("permission"),
issueType: core.getInput("issue-type"),
allowEdits: core.getInput("allow-edits"),
repository: core.getInput("repository"),
eventTypeSuffix: core.getInput("event-type-suffix"),
config: core.getInput("config"),
configFromFile: core.getInput("config-from-file")
};
}
function getCommandsConfig(inputs) {
if (inputs.configFromFile) {
core.info(`Using JSON configuration from file '${inputs.configFromFile}'.`);
return getCommandsConfigFromJson(fs.readFileSync(inputs.configFromFile));
} else if (inputs.config) {
core.info(`Using JSON configuration from 'config' input.`);
return getCommandsConfigFromJson(inputs.config);
} else {
core.info(`Using configuration from yaml inputs.`);
return getCommandsConfigFromInputs(inputs);
}
}
function extend(obj1, obj2) {
var result = {};
for (val in obj1) result[val] = obj1[val];
for (val in obj2) result[val] = obj2[val];
return result;
}
function getCommandsConfigFromInputs(inputs) {
// Get commands
const commands = inputs.commands.replace(/\s+/g, "").split(",");
core.debug(`Commands: ${inspect(commands)}`);
// Build config
var config = [];
for (const c of commands) {
var cmd = {};
cmd.command = c;
cmd = extend(commandDefaults, cmd);
cmd.permission = inputs.permission ? inputs.permission : cmd.permission;
cmd.issue_type = inputs.issueType ? inputs.issueType : cmd.issue_type;
cmd.allow_edits = toBool(inputs.allowEdits, cmd.allow_edits);
cmd.repository = inputs.repository ? inputs.repository : cmd.repository;
cmd.event_type_suffix = inputs.eventTypeSuffix
? inputs.eventTypeSuffix
: cmd.event_type_suffix;
config.push(cmd);
}
return config;
}
function getCommandsConfigFromJson(json) {
const jsonConfig = JSON.parse(json);
core.debug(`JSON config: ${inspect(jsonConfig)}`);
var config = [];
for (const jc of jsonConfig) {
var cmd = {};
cmd.command = jc.command;
cmd = extend(commandDefaults, cmd);
cmd.permission = jc.permission ? jc.permission : cmd.permission;
cmd.issue_type = jc.issue_type ? jc.issue_type : cmd.issue_type;
cmd.allow_edits = toBool(jc.allow_edits, cmd.allow_edits);
cmd.repository = jc.repository ? jc.repository : cmd.repository;
cmd.event_type_suffix = jc.event_type_suffix
? jc.event_type_suffix
: cmd.event_type_suffix;
config.push(cmd);
}
return config;
}
function configIsValid(config) {
for (const command of config) {
if (!["none", "read", "write", "admin"].includes(command.permission)) {
core.setFailed(`'${command.permission}' is not a valid 'permission'.`);
return false;
}
if (!["issue", "pull-request", "both"].includes(command.issue_type)) {
core.setFailed(`'${command.issue_type}' is not a valid 'issue-type'.`);
return false;
}
}
return true;
}
function actorHasPermission(actorPermission, commandPermission) {
const permissionLevels = Object.freeze({
none: 1,
read: 2,
write: 3,
admin: 4
});
core.debug(`Actor permission level: ${permissionLevels[actorPermission]}`);
core.debug(
`Command permission level: ${permissionLevels[commandPermission]}`
);
return (
permissionLevels[actorPermission] >= permissionLevels[commandPermission]
);
}
async function getActorPermission(octokit, repo, actor) {
const {
data: { permission }
} = await octokit.repos.getCollaboratorPermissionLevel({
...repo,
username: actor
});
return permission;
}
async function addReaction(octokit, repo, commentId, reaction) {
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}.`);
}
}
function getSlashCommandPayload(commentWords) {
var payload = {
command: commentWords[0],
args: ""
};
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];
}
}
return payload;
}
module.exports = {
MAX_ARGS,
commandDefaults,
getInputs,
getCommandsConfig,
getCommandsConfigFromInputs,
getCommandsConfigFromJson,
configIsValid,
actorHasPermission,
getActorPermission,
addReaction,
getSlashCommandPayload
};
+166
View File
@@ -0,0 +1,166 @@
const {
commandDefaults,
getCommandsConfigFromInputs,
getCommandsConfigFromJson,
actorHasPermission,
configIsValid,
getSlashCommandPayload
} = require("./func");
test("building config with required inputs only", async () => {
const inputs = {
commands: "list, of, slash, commands"
};
const commands = inputs.commands.replace(/\s+/g, "").split(",");
const config = getCommandsConfigFromInputs(inputs);
expect(config.length).toEqual(4);
for (var i = 0; i < config.length; i++) {
expect(config[i].command).toEqual(commands[i]);
expect(config[i].permission).toEqual(commandDefaults.permission);
expect(config[i].issue_type).toEqual(commandDefaults.issue_type);
expect(config[i].allow_edits).toEqual(commandDefaults.allow_edits);
expect(config[i].repository).toEqual(commandDefaults.repository);
expect(config[i].event_type_suffix).toEqual(
commandDefaults.event_type_suffix
);
}
});
test("building config with optional inputs", async () => {
const inputs = {
commands: "list, of, slash, commands",
permission: "admin",
issueType: "pull-request",
allowEdits: true,
repository: "owner/repo",
eventTypeSuffix: "-cmd"
};
const commands = inputs.commands.replace(/\s+/g, "").split(",");
const config = getCommandsConfigFromInputs(inputs);
expect(config.length).toEqual(4);
for (var i = 0; i < config.length; i++) {
expect(config[i].command).toEqual(commands[i]);
expect(config[i].permission).toEqual(inputs.permission);
expect(config[i].issue_type).toEqual(inputs.issueType);
expect(config[i].allow_edits).toEqual(inputs.allowEdits);
expect(config[i].repository).toEqual(inputs.repository);
expect(config[i].event_type_suffix).toEqual(inputs.eventTypeSuffix);
}
});
test("building config with required JSON only", async () => {
const json = `[
{
"command": "do-stuff"
},
{
"command": "test-all-the-things"
}
]`;
const commands = ["do-stuff", "test-all-the-things"];
const config = getCommandsConfigFromJson(json);
expect(config.length).toEqual(2);
for (var i = 0; i < config.length; i++) {
expect(config[i].command).toEqual(commands[i]);
expect(config[i].permission).toEqual(commandDefaults.permission);
expect(config[i].issue_type).toEqual(commandDefaults.issue_type);
expect(config[i].allow_edits).toEqual(commandDefaults.allow_edits);
expect(config[i].repository).toEqual(commandDefaults.repository);
expect(config[i].event_type_suffix).toEqual(
commandDefaults.event_type_suffix
);
}
});
test("building config with optional JSON properties", async () => {
const json = `[
{
"command": "do-stuff",
"permission": "admin",
"issue_type": "pull-request",
"allow_edits": true,
"repository": "owner/repo",
"event_type_suffix": "-cmd"
},
{
"command": "test-all-the-things",
"permission": "read"
}
]`;
const commands = ["do-stuff", "test-all-the-things"];
const config = getCommandsConfigFromJson(json);
expect(config.length).toEqual(2);
expect(config[0].command).toEqual(commands[0]);
expect(config[0].permission).toEqual("admin");
expect(config[0].issue_type).toEqual("pull-request");
expect(config[0].allow_edits).toBeTruthy();
expect(config[0].repository).toEqual("owner/repo");
expect(config[0].event_type_suffix).toEqual("-cmd");
expect(config[1].command).toEqual(commands[1]);
expect(config[1].permission).toEqual("read");
expect(config[1].issue_type).toEqual(commandDefaults.issue_type);
});
test("valid config", async () => {
const config = [
{
command: "test",
permission: "write",
issue_type: "both",
repository: "peter-evans/slash-command-dispatch",
event_type_suffix: "-command"
}
];
expect(configIsValid(config)).toBeTruthy();
});
test("invalid permission level in config", async () => {
const config = [
{
command: "test",
permission: "super-admin",
issue_type: "both",
repository: "peter-evans/slash-command-dispatch",
event_type_suffix: "-command"
}
];
expect(configIsValid(config)).toBeFalsy();
});
test("invalid issue type in config", async () => {
const config = [
{
command: "test",
permission: "write",
issue_type: "pr",
repository: "peter-evans/slash-command-dispatch",
event_type_suffix: "-command"
}
];
expect(configIsValid(config)).toBeFalsy();
});
test("actor does not have permission", async () => {
expect(actorHasPermission("none", "read")).toBeFalsy();
expect(actorHasPermission("read", "write")).toBeFalsy();
expect(actorHasPermission("write", "admin")).toBeFalsy();
});
test("actor has permission", async () => {
expect(actorHasPermission("read", "none")).toBeTruthy();
expect(actorHasPermission("write", "read")).toBeTruthy();
expect(actorHasPermission("admin", "write")).toBeTruthy();
expect(actorHasPermission("write", "write")).toBeTruthy();
});
test("slash command payload", async () => {
commandWords = ["test", "arg1", "arg2", "arg3"];
payload = {
command: "test",
args: "arg1 arg2 arg3",
arg1: "arg1",
arg2: "arg2",
arg3: "arg3"
};
expect(getSlashCommandPayload(commandWords)).toEqual(payload);
});
+185
View File
@@ -0,0 +1,185 @@
const { inspect } = require("util");
const core = require("@actions/core");
const github = require("@actions/github");
const {
getInputs,
getCommandsConfig,
configIsValid,
actorHasPermission,
getActorPermission,
addReaction,
getSlashCommandPayload
} = require("./func");
async function run() {
try {
// Only handle 'created' and 'edited' event types
if (!["created", "edited"].includes(github.context.payload.action)) {
core.warning(
`Event type '${github.context.payload.action}' not supported.`
);
return;
}
// Get action inputs
const inputs = getInputs();
core.debug(`Inputs: ${inspect(inputs)}`);
// Check required inputs
if (!inputs.token) {
core.setFailed("Missing required input 'token'.");
return false;
}
// Get configuration for registered commands
const config = getCommandsConfig(inputs);
core.debug(`Commands config: ${inspect(config)}`);
// Check the config is valid
if (!configIsValid(config)) return;
// Get the comment body and id
const commentBody = github.context.payload["comment"]["body"];
const commentId = github.context.payload["comment"]["id"];
core.debug(`Comment body: ${commentBody}`);
core.debug(`Comment id: ${commentId}`);
// Check if the comment is a slash command
if (commentBody.charAt(0) != "/" || commentBody.length < 2) {
core.info("Comment is not a valid slash command.");
return;
}
// Split the comment into "words"
const commentWords = commentBody.slice(1).split(" ");
core.debug(`Comment words: ${inspect(commentWords)}`);
// Check if the command is registered for dispatch
var configMatches = config.filter(function(cmd) {
return cmd.command == commentWords[0];
});
core.debug(`Config matches on 'command': ${inspect(configMatches)}`);
if (configMatches.length == 0) {
core.info(`Command '${commentWords[0]}' is not registered for dispatch.`);
return;
}
// Filter matching commands by issue type
const isPullRequest = "pull_request" in github.context.payload.issue;
configMatches = configMatches.filter(function(cmd) {
return (
cmd.issue_type == "both" ||
(cmd.issue_type == "issue" && !isPullRequest) ||
(cmd.issue_type == "pull-request" && isPullRequest)
);
});
core.debug(`Config matches on 'issue_type': ${inspect(configMatches)}`);
if (configMatches.length == 0) {
const issueType = isPullRequest ? "pull request" : "issue";
core.info(
`Command '${commentWords[0]}' is not configured for the issue type '${issueType}'.`
);
return;
}
// Filter matching commands by whether or not to allow edits
if (github.context.payload.action == "edited") {
configMatches = configMatches.filter(function(cmd) {
return cmd.allow_edits;
});
core.debug(`Config matches on 'allow_edits': ${inspect(configMatches)}`);
if (configMatches.length == 0) {
core.info(
`Command '${commentWords[0]}' is not configured to allow edits.`
);
return;
}
}
// Set octokit clients
const octokit = new github.GitHub(inputs.token);
const reactionOctokit = inputs.reactionToken
? new github.GitHub(inputs.reactionToken)
: new github.GitHub(inputs.token);
// 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"
);
// Get the actor permission
const actorPermission = await getActorPermission(
octokit,
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) {
return actorHasPermission(actorPermission, cmd.permission);
});
core.debug(`Config matches on 'permission': ${inspect(configMatches)}`);
if (configMatches.length == 0) {
core.info(
`Command '${commentWords[0]}' is not configured for the user's permission level '${actorPermission}'.`
);
return;
}
// Determined that the command should be dispatched
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,
github: github.context
};
// 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
});
clientPayload["pull_request"] = pullRequest;
}
// Dispatch for each matching configuration
for (const cmd of configMatches) {
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}'.`
);
}
// Add the "rocket" reaction to the comment
if (inputs.reactions)
await addReaction(
reactionOctokit,
github.context.repo,
commentId,
"rocket"
);
} catch (error) {
core.debug(inspect(error));
core.setFailed(error.message);
}
}
run();