diff --git a/app/commands.js b/app/commands.js index d3562b7..18f8116 100644 --- a/app/commands.js +++ b/app/commands.js @@ -4,15 +4,19 @@ import { ReopenCommand } from "./commands/reopen-command.js"; import { LabelCommand } from "./commands/label-command.js"; import { RemoveLabelCommand } from "./commands/remove-label-command.js"; import { ReviewerCommand } from "./commands/reviewer-command.js"; +import { HelpCommand } from "./commands/help-command.js"; + +export const ALL_COMMANDS = [ + TransferCommand, + CloseCommand, + ReopenCommand, + LabelCommand, + RemoveLabelCommand, + ReviewerCommand, + HelpCommand, +]; export function getCommands(id, payload) { - const commands = [ - new TransferCommand(id, payload), - new CloseCommand(id, payload), - new ReopenCommand(id, payload), - new LabelCommand(id, payload), - new RemoveLabelCommand(id, payload), - new ReviewerCommand(id, payload), - ]; + const commands = ALL_COMMANDS.map((Cls) => new Cls(id, payload)); return commands.filter((command) => command.matches()); } diff --git a/app/commands.test.js b/app/commands.test.js index 3e9ef17..675d640 100644 --- a/app/commands.test.js +++ b/app/commands.test.js @@ -29,4 +29,14 @@ describe("commands", () => { expect(commands).toHaveLength(2); }); + + test("getCommands matches /help command", () => { + const commands = getCommands("any", { + comment: { + body: "/help", + }, + }); + + expect(commands).toHaveLength(1); + }); }); diff --git a/app/commands/close-command.js b/app/commands/close-command.js index d9d0c4a..35aea9b 100644 --- a/app/commands/close-command.js +++ b/app/commands/close-command.js @@ -12,10 +12,20 @@ import { const classLogger = getLogger("commands/close-command"); export class CloseCommand extends Command { + static configKey = "close"; + constructor(id, payload) { super(id, payload); } + get usage() { + return "/close [not-planned]"; + } + + get description() { + return "Close this issue"; + } + matches() { return closeMatcher(extractBody(this.payload)); } diff --git a/app/commands/command.js b/app/commands/command.js index 988b480..05fb99b 100644 --- a/app/commands/command.js +++ b/app/commands/command.js @@ -4,6 +4,14 @@ export class Command { this.payload = payload; } + get usage() { + throw new Error("usage must be implemented"); + } + + get description() { + throw new Error("description must be implemented"); + } + matches() { throw new Error("matches() must be implemented"); } diff --git a/app/commands/help-command.js b/app/commands/help-command.js new file mode 100644 index 0000000..bce61fe --- /dev/null +++ b/app/commands/help-command.js @@ -0,0 +1,68 @@ +import { helpMatcher } from "../matchers.js"; +import { reportError } from "../github.js"; +import { Command } from "./command.js"; +import { getLogger } from "../logger.js"; +import { + extractBody, + extractHtmlUrl, + extractLabelableId, +} from "../comment-extractor.js"; +import { ALL_COMMANDS } from "../commands.js"; + +const classLogger = getLogger("commands/help-command"); + +function buildHelpText(config) { + const rows = ALL_COMMANDS.filter( + (Cls) => !Cls.configKey || config.commands[Cls.configKey]?.enabled, + ) + .map( + (Cls) => `| \`${Cls.prototype.usage}\` | ${Cls.prototype.description} |`, + ) + .join("\n"); + + return `### Available Commands\n\n| Command | Description |\n| ------- | ----------- |\n${rows}`; +} + +export class HelpCommand extends Command { + get usage() { + return "/help"; + } + + get description() { + return "Show available commands"; + } + + matches() { + return helpMatcher(extractBody(this.payload)); + } + + enabled(config) { + this._config = config; + return { + enabled: true, + }; + } + + async run(authToken) { + const logger = classLogger.child({ + user: this.payload.sender.login, + id: this.id, + }); + + logger.info(`Showing help for ${extractHtmlUrl(this.payload)}`); + try { + await reportError( + authToken, + extractLabelableId(this.payload), + buildHelpText(this._config), + ); + } catch (error) { + logger.error( + `Failed to post help comment ${ + error.errors ? JSON.stringify(error.errors) : "" + }`, + error, + ); + } + } +} diff --git a/app/commands/label-command.js b/app/commands/label-command.js index 3737eb5..c1ad4ab 100644 --- a/app/commands/label-command.js +++ b/app/commands/label-command.js @@ -14,10 +14,20 @@ import { const classLogger = getLogger("commands/label-command"); export class LabelCommand extends Command { + static configKey = "label"; + constructor(id, payload) { super(id, payload); } + get usage() { + return "/label "; + } + + get description() { + return "Add labels to this issue or pull request"; + } + matches() { return labelMatcher(extractBody(this.payload)); } diff --git a/app/commands/remove-label-command.js b/app/commands/remove-label-command.js index 9b09517..ea39d4c 100644 --- a/app/commands/remove-label-command.js +++ b/app/commands/remove-label-command.js @@ -13,10 +13,20 @@ import { const classLogger = getLogger("commands/remove-label-command"); export class RemoveLabelCommand extends Command { + static configKey = "removeLabel"; + constructor(id, payload) { super(id, payload); } + get usage() { + return "/remove-label "; + } + + get description() { + return "Remove labels from this issue or pull request"; + } + matches() { return removeLabelMatcher(extractBody(this.payload)); } diff --git a/app/commands/reopen-command.js b/app/commands/reopen-command.js index 728a19e..965abbd 100644 --- a/app/commands/reopen-command.js +++ b/app/commands/reopen-command.js @@ -12,10 +12,20 @@ import { const classLogger = getLogger("commands/reopen-command"); export class ReopenCommand extends Command { + static configKey = "reopen"; + constructor(id, payload) { super(id, payload); } + get usage() { + return "/reopen"; + } + + get description() { + return "Reopen this issue"; + } + matches() { return reopenMatcher(extractBody(this.payload)); } diff --git a/app/commands/reviewer-command.js b/app/commands/reviewer-command.js index 952a119..49d2cbf 100644 --- a/app/commands/reviewer-command.js +++ b/app/commands/reviewer-command.js @@ -13,10 +13,20 @@ import { const classLogger = getLogger("commands/reviewer-command"); export class ReviewerCommand extends Command { + static configKey = "reviewer"; + constructor(id, payload) { super(id, payload); } + get usage() { + return "/reviewer "; + } + + get description() { + return "Request reviewers for this pull request"; + } + matches() { return reviewerMatcher(extractBody(this.payload)); } diff --git a/app/commands/transfer-command.js b/app/commands/transfer-command.js index 18cd95f..e74c0f5 100644 --- a/app/commands/transfer-command.js +++ b/app/commands/transfer-command.js @@ -12,10 +12,20 @@ import { const classLogger = getLogger("commands/transfer-command"); export class TransferCommand extends Command { + static configKey = "transfer"; + constructor(id, payload) { super(id, payload); } + get usage() { + return "/transfer "; + } + + get description() { + return "Transfer this issue to another repository"; + } + matches() { return transferMatcher(extractBody(this.payload)); } diff --git a/app/matchers.js b/app/matchers.js index 2c9fab7..d88945c 100644 --- a/app/matchers.js +++ b/app/matchers.js @@ -24,3 +24,7 @@ export function removeLabelMatcher(text) { export function reviewerMatcher(text) { return text.match(/(?:^| | \r\n|\n)\/reviewers? ([ /@A-Za-z\d-,]+)/); } + +export function helpMatcher(text) { + return text.match(/(?:^| | \r\n|\n)\/help/); +} diff --git a/app/matchers.test.js b/app/matchers.test.js index d0a9031..22fa19c 100644 --- a/app/matchers.test.js +++ b/app/matchers.test.js @@ -1,5 +1,6 @@ import { closeMatcher, + helpMatcher, labelMatcher, removeLabelMatcher, reopenMatcher, @@ -240,4 +241,27 @@ describe("matchers", () => { expect(result).toBeFalsy(); }); }); + + describe("help", () => { + test("matches /help", () => { + const result = helpMatcher("/help"); + + expect(result).toBeTruthy(); + }); + test("does not match input without /help", () => { + const result = helpMatcher("help"); + + expect(result).toBeFalsy(); + }); + test("does not match in the middle of a string", () => { + const result = helpMatcher("something/help"); + + expect(result).toBeFalsy(); + }); + test("matches /help at the start of a new line", () => { + const result = helpMatcher("some text\n/help"); + + expect(result).toBeTruthy(); + }); + }); });