Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions actions/setup/js/check_command_position.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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");
Expand Down
28 changes: 28 additions & 0 deletions actions/setup/js/check_command_position.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
44 changes: 30 additions & 14 deletions actions/setup/js/route_slash_command.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -336,6 +324,34 @@ function isDisabledWorkflowDispatchError(error) {
return message.includes("workflow is disabled") || message.includes("workflow was disabled") || message.includes("disabled workflow");
}

/**
* @param {Record<string, Array<{workflow?: unknown, events?: unknown, ai_reaction?: unknown}>>} 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}'.`);
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 22 additions & 4 deletions actions/setup/js/route_slash_command.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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("");
});
});

Expand Down Expand Up @@ -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"] }],
Expand Down
6 changes: 6 additions & 0 deletions actions/setup/js/sanitize_content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
22 changes: 16 additions & 6 deletions actions/setup/js/sanitize_content_core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand Down
76 changes: 76 additions & 0 deletions actions/setup/js/slash_command_matcher.cjs
Original file line number Diff line number Diff line change
@@ -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,
};
1 change: 1 addition & 0 deletions actions/setup/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading