diff --git a/actions/setup/js/check_command_position.cjs b/actions/setup/js/check_command_position.cjs index 39419df758c..e945c7bbb44 100644 --- a/actions/setup/js/check_command_position.cjs +++ b/actions/setup/js/check_command_position.cjs @@ -3,6 +3,7 @@ const { ERR_API, ERR_CONFIG, ERR_VALIDATION } = require("./error_codes.cjs"); const { writeDenialSummary } = require("./pre_activation_summary.cjs"); +const { matchesCommandName, resolveMatchedCommand } = require("./slash_command_matcher.cjs"); /** * Check if command is the first word in the triggering text @@ -90,7 +91,7 @@ async function main() { } if (inboundCommandName) { - if (commands.includes(inboundCommandName)) { + if (commands.some(command => matchesCommandName(command, inboundCommandName))) { core.info(`✓ command_name '${inboundCommandName}' resolved from workflow_dispatch aw_context`); core.setOutput("command_position_ok", "true"); core.setOutput("matched_command", inboundCommandName); @@ -116,24 +117,14 @@ async function main() { return; } - // Normalize whitespace and get the first word + // Normalize whitespace and resolve the matched slash command at the start of the text. const trimmedText = text.trim(); + const matchedCommand = resolveMatchedCommand(trimmedText, commands); const firstWord = trimmedText.split(/\s+/)[0]; core.info(`Checking command position. First word in text: ${firstWord}`); core.info(`Looking for commands: ${commands.map(c => `/${c}`).join(", ")}`); - // Check if any of the commands match - let matchedCommand = null; - for (const command of commands) { - const expectedCommand = `/${command}`; - - if (firstWord === expectedCommand) { - matchedCommand = command; - break; - } - } - if (matchedCommand) { core.info(`✓ Command '/${matchedCommand}' matched at the start of the text`); core.setOutput("command_position_ok", "true"); diff --git a/actions/setup/js/check_command_position.test.cjs b/actions/setup/js/check_command_position.test.cjs index a5bf82e852a..067daf18f2e 100644 --- a/actions/setup/js/check_command_position.test.cjs +++ b/actions/setup/js/check_command_position.test.cjs @@ -90,6 +90,18 @@ const mockCore = { expect(mockCore.setOutput).toHaveBeenCalledWith("command_position_ok", "true"); expect(mockCore.setOutput).toHaveBeenCalledWith("matched_command", "test-bot"); }), + it("should resolve wildcard command from workflow_dispatch aw_context", async () => { + process.env.GH_AW_COMMANDS = JSON.stringify(["smoke*"]); + mockContext.eventName = "workflow_dispatch"; + mockContext.payload = { + inputs: { + aw_context: JSON.stringify({ command_name: "smoke-copilot-sdk" }), + }, + }; + await eval(`(async () => { ${checkCommandPositionScript}; await main(); })()`); + expect(mockCore.setOutput).toHaveBeenCalledWith("command_position_ok", "true"); + expect(mockCore.setOutput).toHaveBeenCalledWith("matched_command", "smoke-copilot-sdk"); + }), it("should handle pull_request event with command at start", async () => { ((process.env.GH_AW_COMMANDS = JSON.stringify(["review-bot"])), (mockContext.eventName = "pull_request"), @@ -127,6 +139,22 @@ const mockCore = { await eval(`(async () => { ${checkCommandPositionScript}; await main(); })()`), expect(mockCore.setOutput).toHaveBeenCalledWith("command_position_ok", "true")); }), + it("should match wildcard command at the start of text", async () => { + ((process.env.GH_AW_COMMANDS = JSON.stringify(["smoke*"])), + (mockContext.eventName = "issue_comment"), + (mockContext.payload = { comment: { body: "/smoke-copilot-sdk run tests" } }), + await eval(`(async () => { ${checkCommandPositionScript}; await main(); })()`), + expect(mockCore.setOutput).toHaveBeenCalledWith("command_position_ok", "true"), + expect(mockCore.setOutput).toHaveBeenCalledWith("matched_command", "smoke-copilot-sdk")); + }), + it("should reject command followed by punctuation", async () => { + ((process.env.GH_AW_COMMANDS = JSON.stringify(["review"])), + (mockContext.eventName = "issue_comment"), + (mockContext.payload = { comment: { body: "/review:" } }), + await eval(`(async () => { ${checkCommandPositionScript}; await main(); })()`), + expect(mockCore.setOutput).toHaveBeenCalledWith("command_position_ok", "false"), + expect(mockCore.setOutput).toHaveBeenCalledWith("matched_command", "")); + }), it("should pass for bot comment with attribution metadata after newline", async () => { ((process.env.GH_AW_COMMANDS = JSON.stringify(["deploy"])), (mockContext.eventName = "issue_comment"), diff --git a/actions/setup/js/route_slash_command.cjs b/actions/setup/js/route_slash_command.cjs index ab9704f1287..41c4ef267a0 100644 --- a/actions/setup/js/route_slash_command.cjs +++ b/actions/setup/js/route_slash_command.cjs @@ -3,6 +3,7 @@ const { REACTION_MAP } = require("./add_reaction.cjs"); const nodePath = require("node:path"); +const { matchesCommandName, parseSlashCommand } = require("./slash_command_matcher.cjs"); // Keep this aligned with the current default stable GitHub REST API version used by workflows. // Update when GitHub advances the recommended version to avoid sunset/deprecation warnings. const GITHUB_API_VERSION = "2022-11-28"; @@ -38,19 +39,6 @@ async function appendRoutingSummary(existingCommands, selectedCommand) { } } -/** - * Extracts the slash command name from the start of the given body text. - * Returns an empty string if the text does not begin with a valid slash command. - * A valid slash command starts with '/' followed by a name of one or more characters - * from [a-zA-Z0-9], [-], and [_]. - * @param {string} text - * @returns {string} - */ -function parseSlashCommand(text) { - const match = /^\/([a-zA-Z0-9][a-zA-Z0-9\-_]*)\b/.exec(String(text).trim()); - return match ? match[1] : ""; -} - function eventIdentifier() { if (context.eventName !== "issue_comment") { return context.eventName; @@ -336,6 +324,34 @@ function isDisabledWorkflowDispatchError(error) { return message.includes("workflow is disabled") || message.includes("workflow was disabled") || message.includes("disabled workflow"); } +/** + * @param {Record>} slashRouteMap + * @param {string} actualCommand + * @returns {Array<{workflow?: unknown, events?: unknown, ai_reaction?: unknown}>} + */ +function resolveMatchingSlashRoutes(slashRouteMap, actualCommand) { + /** @type {Array<{workflow?: unknown, events?: unknown, ai_reaction?: unknown}>} */ + const matchedRoutes = []; + const seen = new Set(); + + for (const [configuredCommand, configuredRoutes] of Object.entries(slashRouteMap)) { + if (!matchesCommandName(configuredCommand, actualCommand) || !Array.isArray(configuredRoutes)) { + continue; + } + + for (const route of configuredRoutes) { + const key = JSON.stringify([route?.workflow ?? "", route?.ai_reaction ?? "", Array.isArray(route?.events) ? route.events : []]); + if (seen.has(key)) { + continue; + } + seen.add(key); + matchedRoutes.push(route); + } + } + + return matchedRoutes; +} + async function main() { core.info("Starting centralized command routing."); core.info(`Incoming event name: '${context.eventName}'.`); @@ -408,7 +424,7 @@ async function main() { const commandName = selectedCommand; core.info(`Resolved command '/${commandName}' for event identifier '${identifier}'.`); - const configuredRoutes = slashRouteMap[commandName] ?? []; + const configuredRoutes = resolveMatchingSlashRoutes(slashRouteMap, commandName); core.info(`Configured routes for '/${commandName}': ${configuredRoutes.length}.`); const routes = configuredRoutes.filter(route => Array.isArray(route.events) && route.events.includes(identifier)); if (routes.length === 0) { diff --git a/actions/setup/js/route_slash_command.test.cjs b/actions/setup/js/route_slash_command.test.cjs index 625b5981be4..0b11fcadf81 100644 --- a/actions/setup/js/route_slash_command.test.cjs +++ b/actions/setup/js/route_slash_command.test.cjs @@ -37,8 +37,8 @@ describe("parseSlashCommand", () => { expect(parseSlashCommand(" /smoke-copilot-sdk")).toBe("smoke-copilot-sdk"); }); - it("does not include trailing punctuation in the command name", () => { - expect(parseSlashCommand("/smoke-copilot-sdk!")).toBe("smoke-copilot-sdk"); + it("does not match when command is followed by punctuation", () => { + expect(parseSlashCommand("/smoke-copilot-sdk!")).toBe(""); }); it("does not match a slash command in the middle of text", () => { @@ -49,12 +49,16 @@ describe("parseSlashCommand", () => { expect(parseSlashCommand("/code_review")).toBe("code_review"); }); + it("extracts a command name with dots", () => { + expect(parseSlashCommand("/cmd.add")).toBe("cmd.add"); + }); + it("does not match a command starting with a dash", () => { expect(parseSlashCommand("/-command")).toBe(""); }); - it("enforces word boundary: command followed by a colon", () => { - expect(parseSlashCommand("/archie:more")).toBe("archie"); + it("does not match command followed by a colon", () => { + expect(parseSlashCommand("/archie:more")).toBe(""); }); }); @@ -462,6 +466,20 @@ describe("route_slash_command", () => { expect(awContext.command_name).toBe("smoke-copilot-sdk"); }); + it("dispatches wildcard slash routes using the actual matched command name", async () => { + process.env.GH_AW_SLASH_ROUTING = JSON.stringify({ + "smoke*": [{ workflow: "smoke-family", events: ["issue_comment"] }], + }); + globals.context.payload.comment.body = "/smoke-copilot-sdk"; + + await main(); + + expect(dispatchCalls).toHaveLength(1); + expect(dispatchCalls[0].workflow_id).toBe("smoke-family.lock.yml"); + const awContext = JSON.parse(dispatchCalls[0].inputs.aw_context); + expect(awContext.command_name).toBe("smoke-copilot-sdk"); + }); + it("does not dispatch smoke-copilot-sdk when command is smoke-copilot", async () => { process.env.GH_AW_SLASH_ROUTING = JSON.stringify({ "smoke-copilot": [{ workflow: "smoke-copilot", events: ["issue_comment"] }], diff --git a/actions/setup/js/sanitize_content.test.cjs b/actions/setup/js/sanitize_content.test.cjs index ed2fecab3b0..09fc29a0be1 100644 --- a/actions/setup/js/sanitize_content.test.cjs +++ b/actions/setup/js/sanitize_content.test.cjs @@ -95,6 +95,12 @@ describe("sanitize_content.cjs", () => { const result = sanitizeContent("/review check"); expect(result).toBe("`/review` check"); }); + + it("should neutralize wildcard-matched command names from GH_AW_COMMANDS", () => { + process.env.GH_AW_COMMANDS = JSON.stringify(["smoke*"]); + const result = sanitizeContent("/smoke-copilot-sdk run tests"); + expect(result).toBe("`/smoke-copilot-sdk` run tests"); + }); }); describe("@mention neutralization", () => { diff --git a/actions/setup/js/sanitize_content_core.cjs b/actions/setup/js/sanitize_content_core.cjs index 2e29f05aa7c..bcb3256ac00 100644 --- a/actions/setup/js/sanitize_content_core.cjs +++ b/actions/setup/js/sanitize_content_core.cjs @@ -7,6 +7,7 @@ */ const { isRepoAllowed } = require("./repo_helpers.cjs"); +const { resolveMatchedCommand } = require("./slash_command_matcher.cjs"); const SAFE_OUTPUTS_URLS_ENV = "GH_AW_SAFE_OUTPUTS_URLS"; const SAFE_OUTPUTS_URLS_ALLOWED_ONLY = "allowed-only"; @@ -359,17 +360,26 @@ function neutralizeCommands(s) { return s; } - // Neutralize each command name at the start of text (with optional leading whitespace) - let result = s; + const leadingWhitespace = s.match(/^\s*/)?.[0] ?? ""; + const remainder = s.slice(leadingWhitespace.length); + const matchedCommand = resolveMatchedCommand(remainder, commandNames); + if (matchedCommand) { + const escapedCommand = matchedCommand.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); + } + for (const name of commandNames) { + if (name.endsWith("*")) { + continue; + } const escapedCommand = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - result = result.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); - // Stop after first substitution (only one command can be at position 0) + const result = s.replace(new RegExp(`^(\\s*)/(${escapedCommand})\\b`, "i"), "$1`/$2`"); if (result !== s) { - break; + return result; } } - return result; + + return s; } /** diff --git a/actions/setup/js/slash_command_matcher.cjs b/actions/setup/js/slash_command_matcher.cjs new file mode 100644 index 00000000000..294e28c9ae9 --- /dev/null +++ b/actions/setup/js/slash_command_matcher.cjs @@ -0,0 +1,76 @@ +// @ts-check + +/** + * Extracts the slash command name from the start of the given body text. + * Returns an empty string if the text does not begin with a valid slash command. + * A valid slash command starts with '/' followed by a name of one or more characters + * from [a-zA-Z0-9], [-], [_], and [.]. + * @param {string} text + * @returns {string} + */ +function parseSlashCommand(text) { + const match = /^\/([a-zA-Z0-9][a-zA-Z0-9._-]*)(?=$|\s)/.exec(String(text).trim()); + return match ? match[1] : ""; +} + +/** + * @param {string} commandName + * @returns {boolean} + */ +function isWildcardCommandName(commandName) { + return typeof commandName === "string" && commandName.length > 1 && commandName.endsWith("*"); +} + +/** + * @param {string} configuredCommand + * @returns {string} + */ +function wildcardCommandPrefix(configuredCommand) { + return isWildcardCommandName(configuredCommand) ? configuredCommand.slice(0, -1) : ""; +} + +/** + * @param {string} configuredCommand + * @param {string} actualCommand + * @returns {boolean} + */ +function matchesCommandName(configuredCommand, actualCommand) { + if (typeof configuredCommand !== "string" || typeof actualCommand !== "string") { + return false; + } + + if (isWildcardCommandName(configuredCommand)) { + const prefix = wildcardCommandPrefix(configuredCommand); + return prefix !== "" && actualCommand.startsWith(prefix); + } + + return configuredCommand === actualCommand; +} + +/** + * @param {string} text + * @param {string[]} configuredCommands + * @returns {string} + */ +function resolveMatchedCommand(text, configuredCommands) { + const actualCommand = parseSlashCommand(text); + if (!actualCommand) { + return ""; + } + + for (const configuredCommand of configuredCommands) { + if (matchesCommandName(configuredCommand, actualCommand)) { + return actualCommand; + } + } + + return ""; +} + +module.exports = { + isWildcardCommandName, + matchesCommandName, + parseSlashCommand, + resolveMatchedCommand, + wildcardCommandPrefix, +}; diff --git a/actions/setup/setup.sh b/actions/setup/setup.sh index 514d15b3bb9..8f2135608a3 100755 --- a/actions/setup/setup.sh +++ b/actions/setup/setup.sh @@ -331,6 +331,7 @@ SAFE_OUTPUTS_FILES=( "missing_info_formatter.cjs" "sanitize_content.cjs" "sanitize_content_core.cjs" + "slash_command_matcher.cjs" "sanitize_title.cjs" "issue_title_dedup.cjs" "levenshtein_distance.cjs" diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index e74b5748256..0b31130805d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -414,8 +414,8 @@ { "type": "string", "minLength": 1, - "pattern": "^[^/]", - "description": "Command name as a string (shorthand format, e.g., 'customname' for '/customname' triggers). Command names must not start with '/' as the slash is automatically added when matching commands." + "pattern": "^[^/*][^/*]*\\*?$", + "description": "Command name as a string (shorthand format, e.g., 'customname' for '/customname' triggers). Command names must not start with '/'. Append '*' as a suffix to enable wildcard prefix matching (for example, 'smoke*' matches '/smoke-copilot')." }, { "type": "object", @@ -426,8 +426,8 @@ { "type": "string", "minLength": 1, - "pattern": "^[^/]", - "description": "Single command name for slash commands (e.g., 'helper-bot' for '/helper-bot' triggers). Command names must not start with '/' as the slash is automatically added when matching commands. Defaults to workflow filename without .md extension if not specified." + "pattern": "^[^/*][^/*]*\\*?$", + "description": "Single command name for slash commands (e.g., 'helper-bot' for '/helper-bot' triggers). Command names must not start with '/'. Append '*' as a suffix to enable wildcard prefix matching. Defaults to workflow filename without .md extension if not specified." }, { "type": "array", @@ -436,8 +436,8 @@ "items": { "type": "string", "minLength": 1, - "pattern": "^[^/]", - "description": "Command name without leading slash" + "pattern": "^[^/*][^/*]*\\*?$", + "description": "Command name without leading slash. Append '*' as a suffix to enable wildcard prefix matching." }, "maxItems": 25 } @@ -485,8 +485,8 @@ { "type": "string", "minLength": 1, - "pattern": "^[^/]", - "description": "Command name as a string (shorthand format, e.g., 'customname' for '/customname' triggers). Command names must not start with '/' as the slash is automatically added when matching commands." + "pattern": "^[^/*][^/*]*\\*?$", + "description": "Command name as a string (shorthand format, e.g., 'customname' for '/customname' triggers). Command names must not start with '/'. Append '*' as a suffix to enable wildcard prefix matching (for example, 'smoke*' matches '/smoke-copilot')." }, { "type": "object", @@ -497,8 +497,8 @@ { "type": "string", "minLength": 1, - "pattern": "^[^/]", - "description": "Custom command name for slash commands (e.g., 'helper-bot' for '/helper-bot' triggers). Command names must not start with '/' as the slash is automatically added when matching commands. Defaults to workflow filename without .md extension if not specified." + "pattern": "^[^/*][^/*]*\\*?$", + "description": "Custom command name for slash commands (e.g., 'helper-bot' for '/helper-bot' triggers). Command names must not start with '/'. Append '*' as a suffix to enable wildcard prefix matching. Defaults to workflow filename without .md extension if not specified." }, { "type": "array", @@ -507,8 +507,8 @@ "items": { "type": "string", "minLength": 1, - "pattern": "^[^/]", - "description": "Command name without leading slash" + "pattern": "^[^/*][^/*]*\\*?$", + "description": "Command name without leading slash. Append '*' as a suffix to enable wildcard prefix matching." }, "maxItems": 25 } diff --git a/pkg/workflow/command.go b/pkg/workflow/command.go index 5313331a384..1898e99a3b7 100644 --- a/pkg/workflow/command.go +++ b/pkg/workflow/command.go @@ -4,12 +4,17 @@ import ( "errors" "fmt" "slices" + "strings" "github.com/github/gh-aw/pkg/logger" ) var commandLog = logger.New("workflow:command") +func isWildcardCommandName(commandName string) bool { + return len(commandName) > 1 && strings.HasSuffix(commandName, "*") +} + // buildEventAwareCommandCondition creates a condition that only applies command checks to comment-related events // commandNames: list of command names that can trigger this workflow // commandEvents: list of event identifiers where command should be active (nil = all events) @@ -43,6 +48,15 @@ func buildEventAwareCommandCondition(commandNames []string, commandEvents []stri buildMultiCommandCheck := func(bodyAccessor string) ConditionNode { var commandOrChecks []ConditionNode for _, commandName := range commandNames { + if isWildcardCommandName(commandName) { + commandPrefix := "/" + strings.TrimSuffix(commandName, "*") + commandOrChecks = append(commandOrChecks, BuildFunctionCall("startsWith", + BuildPropertyAccess(bodyAccessor), + BuildStringLiteral(commandPrefix), + )) + continue + } + commandText := "/" + commandName commandWithSpace := fmt.Sprintf("/%s ", commandName) commandWithNewline := fmt.Sprintf("/%s\n", commandName) diff --git a/pkg/workflow/command_precision_test.go b/pkg/workflow/command_precision_test.go index 91c312f5cd5..90450af7bb0 100644 --- a/pkg/workflow/command_precision_test.go +++ b/pkg/workflow/command_precision_test.go @@ -221,6 +221,27 @@ tools: "github.event.comment.body == '/test-bot'", }, }, + { + name: "slash_command wildcard should use prefix matching", + frontmatter: `--- +on: + slash_command: + name: smoke* + events: [issue_comment] +tools: + github: + allowed: [list_issues] +---`, + filename: "command-wildcard-prefix.md", + shouldContain: []string{ + "startsWith(github.event.comment.body, '/smoke')", + "github.event_name == 'issue_comment'", + }, + shouldNotContain: []string{ + "github.event.comment.body == '/smoke*'", + "startsWith(github.event.comment.body, '/smoke* ')", + }, + }, } for _, tt := range tests {