blob: d8ca1f06619b5394640af9396989fa3becd0be49 [file] [log] [blame]
/*
* Copyright (C) 2020 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR
* CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
* OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import {stat} from "fs";
import path from "path";
import util from "util";
import {execFile, spawn} from "child_process";
import SlackRTMAPI from "@slack/rtm-api";
import AsyncTaskQueue from "./AsyncTaskQueue.mjs";
import {dataLogLn, escapeForSlackText, isASCII, rootDirectoryOfWebKit} from "./Utility.mjs";
const defaultTaskLimit = 10;
const defaultPullPeriod = 60 * 1000 * 60;
const defaultTimeoutForRevert = 60 * 1000 * 10;
const execFileAsync = util.promisify(execFile);
const statAsync = util.promisify(stat);
function parseBugId(string)
{
if (!string)
return null;
let match = string.match(/^https?:\/\/webkit\.org\/b\/(\d+)$/m);
if (match)
return match[1];
match = string.match(/^https?:\/\/bugs\.webkit\.org\/show_bug\.cgi\?id=(\d+)(?:&ctype=xml|&excludefield=attachmentdata)*$/m);
if (match)
return match[1];
return null;
}
function extractRevision(text)
{
let revisions = [];
for (let candidate of text.split(",")) {
candidate = candidate.trim();
if (!candidate)
continue;
let match = candidate.match(/^r?(\d+):?$/);
if (!match)
return null;
revisions.push(match[1]);
}
return revisions;
}
export function extractRevisionsAndReason(args)
{
let revisions = [];
let reason = "";
for (let i = 0; i < args.length; ++i) {
let arg = args[i];
let extracted = extractRevision(arg);
if (!extracted) {
let reasons = [];
for (; i < args.length; ++i)
reasons.push(args[i]);
reason = reasons.join(" ").trim();
break;
}
revisions.push(...extracted);
}
// If reason starts with quote and ends with the same quote, remove them once.
if (reason.length >= 2) {
let firstCharacterOfReason = reason.charAt(0);
if (firstCharacterOfReason === "'" || firstCharacterOfReason === "\"" || firstCharacterOfReason === "`") {
if (reason.charAt(reason.length - 1) === firstCharacterOfReason)
reason = reason.slice(1, reason.length - 1);
}
}
return {revisions, reason};
}
export function extractCommandAndArgs(text)
{
let args = text.trim().split(/\s+/);
let command = args.shift().toLowerCase();
return {command, args};
}
export function extractTextIfMentioned(text, id)
{
let regexp = new RegExp(`<@${id}>`);
let globalRegexp = new RegExp(`<@${id}>`, "g");
let matched = text.match(regexp);
if (!matched)
return null;
text = text.replace(globalRegexp, "");
// Preprocessing for the text.
// 1. Convert smart quotes to normal ASCII quotes because webkit-patch cannot accept non-ASCII text and slack may convert normal quotes to smart quotes.
text = text.replace(/[\u2018\u2019]/g, "'");
text = text.replace(/[\u201C\u201D]/g, "\"");
// 2. Convert line-terminators to spaces. It is unlikely that we want to have line-terminators in webkitbot commands.
text = text.replace(/(\r\n|\n|\r|\u2028|\u2029)/g, " ");
return text;
}
export default class WebKitBot {
constructor(webClient, auth)
{
this._taskQueue = new AsyncTaskQueue(defaultTaskLimit);
this._web = webClient;
this._auth = auth;
this._commands = new Map;
let revertCommand = {
description: "Opens a bug to revert the specified revision, CCing author + reviewer, and attaching the reverse-diff of the given revisions marked as commit-queue=?.",
usage: `\`revert SVN_REVISION [SVN_REVISIONS] REASON\`
e.g. \`revert 260220 Ensure it is working after refactoring\`
\`revert 260220,260221 Ensure it is working after refactoring\``,
operation: this.revertCommand.bind(this),
};
this._commands.set("rollout", revertCommand);
this._commands.set("revert", revertCommand);
this._commands.set("dry-revert", {
description: "Parse revert message, but do not revert actually.",
usage: `\`dry-revert SVN_REVISION [SVN_REVISIONS] REASON\`
e.g. \`dry-revert 260220 Ensure it is working after refactoring\`
\`dry-revert 260220,260221 Ensure it is working after refactoring\``,
operation: this.dryRevertCommand.bind(this),
});
this._commands.set("ping", {
description: "Responds with pong to check if WebKitBot is alive/working",
usage: "`ping`",
operation: this.pingCommand.bind(this),
});
this._commands.set("pull", {
description: "Pulls the latest checkout of WebKit checkout for reverting queue.",
usage: "`pull`",
operation: this.pullCommand.bind(this),
});
this._commands.set("help", {
description: "Provides help on my individual commands.",
usage: "`help [COMMAND]`",
operation: this.helpCommand.bind(this),
});
this._commands.set("status", {
description: "Shows current reverting queue status.",
usage: "`status`",
operation: this.statusCommand.bind(this),
});
this._commands.set("hi", {
description: "Responds with hi.",
usage: "`hi`",
operation: this.hiCommand.bind(this),
});
this._commands.set("yt?", {
description: "Responds with yes.",
usage: "`yt?`",
operation: this.youThereCommand.bind(this),
});
this._rtm = new SlackRTMAPI.RTMClient(process.env.SLACK_TOKEN);
this._rtm.on("message", async (event) => {
if (event.type !== "message")
return;
// If message has subtype, this is not an usual message.
if (event.subtype)
return;
let text = extractTextIfMentioned(event.text, this._auth.user_id);
if (text) {
let {command, args} = extractCommandAndArgs(text);
let operation = this._commands.get(command);
if (operation)
await operation.operation(event, command, args);
else
await this.unknownCommand(event, command, args);
}
});
setInterval(() => {
this._taskQueue.postOrFailWhenExceedingLimit({
command: "pull",
});
}, defaultPullPeriod);
}
async revertCommand(event, command, args)
{
let {revisions, reason} = extractRevisionsAndReason(args);
if (!isASCII(reason)) {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> webkit-patch only accepts an ASCII string for reason: \`${escapeForSlackText(reason)}\``,
});
return;
}
dataLogLn(revisions, reason);
if (revisions.length) {
try {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Preparing revert for ${revisions.map((revision) => `<${escapeForSlackText(`https://trac.webkit.org/r${revision}|r${revision}`)}>`).join(" ")} ...`,
});
let bugId = await this._taskQueue.postOrFailWhenExceedingLimit({
command: "revert",
revisions,
reason,
});
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Created a revert patch https://webkit.org/b/${escapeForSlackText(bugId)}`,
});
} catch (error) {
console.error(error);
let stderr = error.stderr;
console.error("STDERR ", stderr);
if (typeof stderr === "string") {
{
let index = stderr.indexOf("Failed to apply reverse diff for revision");
if (index !== -1) {
let lines = stderr.slice(index).split("\n");
lines.shift();
let files = [];
for (let line of lines) {
try {
await statAsync(path.join(process.env.webkitWorkingDirectory, line));
files.push(line);
} catch {
// THis is not a file.
break;
}
}
if (files.length !== 0) {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Failed to create revert patch because of the following conflicts:
\`\`\`
${escapeForSlackText(files.join("\n"))}\`\`\``,
});
return;
}
}
}
{
let index = stderr.indexOf("You are not authorized to access bug");
if (index !== -1) {
let line = stderr.slice(index).split("\n")[0].trim();
let matched = /#(\d+)/.match(line);
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Failed to create revert patch. Please ensure commit-queue@webkit.org is authorized to access ${matched ? ("bug " + escapeForSlackText(matched[1])) : "the bug"}.`
});
return;
}
}
}
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Failed to create revert patch.` + (stderr ? `
\`\`\`
${escapeForSlackText(stderr)}\`\`\`` : ""),
});
}
return;
}
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Failed to parse revision and reason`,
});
}
async dryRevertCommand(event, command, args)
{
let {revisions, reason} = extractRevisionsAndReason(args);
if (!isASCII(reason)) {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> webkit-patch only accepts an ASCII string for reason: \`${escapeForSlackText(reason)}\``,
});
return;
}
if (!revisions.length) {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> No revision is found: reason = \`${escapeForSlackText(reason)}\``,
});
return;
}
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> revisions = \`${escapeForSlackText(revisions.join(","))}\`, reason = \`${escapeForSlackText(reason)}\``,
});
}
async pullCommand(event, command, args)
{
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Preparing pulling the latest WebKit checkout.`,
});
await this._taskQueue.postOrFailWhenExceedingLimit({
command: "pull",
});
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Pulled the latest checkout.`,
});
}
async pingCommand(event, command, args)
{
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> pong`,
});
}
async helpCommand(event, command, args)
{
if (args.length) {
let commandName = args[0];
let operation = this._commands.get(commandName);
if (operation) {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> \`${escapeForSlackText(commandName)}\`: ${escapeForSlackText(operation.description)}
Usage: ${escapeForSlackText(operation.usage)}`,
});
} else {
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Unknown command \`${escapeForSlackText(commandName)}\``,
});
}
} else {
let commandNames = [];
for (let key of this._commands.keys())
commandNames.push("`" + key + "`");
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Available commands: ${escapeForSlackText(commandNames.join(", "))}
Type \`help COMMAND\` for help on my individual commands.`,
});
}
}
async statusCommand(event, command, args)
{
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> ${escapeForSlackText(String(this._taskQueue.length))} requests in queue.`,
});
}
async hiCommand(event, command, args)
{
await this._web.chat.postMessage({
channel: event.channel,
text: `Hi <@${event.user}>!`,
});
}
async youThereCommand(event, command, args)
{
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> yes`,
});
}
async unknownCommand(event, command, args)
{
dataLogLn("Unknown command: ", command);
await this._web.chat.postMessage({
channel: event.channel,
text: `<@${event.user}> Unknown command \`${escapeForSlackText(command)}\``,
});
}
execInWebKitDirectorySimple(command, args)
{
return new Promise((resolve, reject) => {
let task = spawn(command, args, {
cwd: process.env.webkitWorkingDirectory,
env: {},
stdio: "inherit",
});
task.on("close", (code) => {
if (!code)
resolve(code);
else
reject(code);
});
});
}
async cleanUpWorkingCopy()
{
dataLogLn("1. Resetting");
await this.execInWebKitDirectorySimple("git", ["reset", "--hard"]);
dataLogLn("2. Cleaning");
await this.execInWebKitDirectorySimple("git", ["clean", "-df"]);
dataLogLn("3. Pulling");
await this.execInWebKitDirectorySimple("git", ["pull"]);
dataLogLn("4. Fetching");
await this.execInWebKitDirectorySimple("git", ["svn", "fetch"]);
}
async generateRevertingPatch(revisions, reason)
{
dataLogLn("Reverting ", revisions, reason);
let revisionsArgument = revisions.map((revision) => {
let number = Number.parseInt(revision, 10);
if (!Number.isFinite(number))
throw new Error(`Invalid svn revision number "${String(revision)}"`);
return number;
}).join(" ");
if (reason.startsWith("-"))
throw new Error(`The revert reason may not begin with - ("${reason}")`);
await this.cleanUpWorkingCopy();
dataLogLn("5. Creating revert patch ", revisions, reason);
let results;
try {
const webkitPatchPath = path.resolve("BotWebKit", "Tools", "Scripts", "webkit-patch");
results = await execFileAsync(webkitPatchPath, [
"create-revert",
"--force-clean",
// In principle, we should pass --non-interactive here, but it
// turns out that create-revert doesn't need it yet. We can't
// pass it prophylactically because we reject unrecognized command
// line switches.
"--parent-command=sheriff-bot",
revisionsArgument,
reason,
], {
cwd: process.env.webkitWorkingDirectory,
env: {
CHANGE_LOG_NAME: "Commit Queue",
CHANGE_LOG_EMAIL_ADDRESS: "commit-queue@webkit.org",
webkit_bugzilla_username: process.env.webkitBugzillaUsername,
webkit_bugzilla_password: process.env.webkitBugzillaPassword,
},
timeout: defaultTimeoutForRevert,
maxBuffer: 1024 * 1024 * 50,
});
} catch (error) {
dataLogLn(error);
let newError = new Error("Revert command failed");
newError.stderr = error.stderr;
throw newError;
}
let {stdout, stderr} = results;
dataLogLn(stdout);
dataLogLn(stderr);
{
let bugId = parseBugId(stdout);
if (bugId !== null)
return bugId;
}
{
let bugId = parseBugId(stderr);
if (bugId !== null)
return bugId;
}
throw new Error("bug-id cannot be found");
}
async action(task)
{
dataLogLn(task);
switch (task.command) {
case "revert": {
let {revisions, reason} = task;
return this.generateRevertingPatch(revisions, reason);
}
case "pull":
return this.cleanUpWorkingCopy();
}
throw new Error(`${task.command} is undefined action`);
}
static async create(webClient, auth)
{
let bot = new WebKitBot(webClient, auth);
await bot._rtm.start();
return bot;
}
static async main(webClient, auth)
{
let bot = await WebKitBot.create(webClient, auth);
while (true) {
let {task, resolve, reject} = await bot._taskQueue.take();
try {
let result = await bot.action(task);
resolve(result);
} catch (error) {
reject(error);
}
}
}
}