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
183 changes: 183 additions & 0 deletions actions/setup/js/dispatch_repository.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
*/

/** @type {string} Safe output type handled by this module */
const HANDLER_TYPE = "dispatch_repository";

const { getErrorMessage } = require("./error_helpers.cjs");
const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs");
const { parseRepoSlug, validateTargetRepo, parseAllowedRepos } = require("./repo_helpers.cjs");
const { logStagedPreviewInfo } = require("./staged_preview.cjs");
const { isStagedMode } = require("./safe_output_helpers.cjs");

/**
* Main handler factory for dispatch_repository
* Returns a message handler function that processes individual dispatch_repository messages
* @type {HandlerFactoryFunction}
*/
async function main(config = {}) {
const tools = config.tools || {};
const githubClient = await createAuthenticatedGitHubClient(config);
const isStaged = isStagedMode(config);

const contextRepoSlug = `${context.repo.owner}/${context.repo.repo}`;
core.info(`dispatch_repository handler initialized: tools=${Object.keys(tools).join(", ")}, context_repo=${contextRepoSlug}`);

// Per-tool dispatch counters for max enforcement
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no JS unit test coverage for the new dispatch_repository handler, while similar handlers (e.g. dispatch_workflow.cjs) have dedicated tests covering max enforcement, cross-repo allowlists, staged mode, and error paths. Given the security-sensitive nature of cross-repo dispatch, adding a dispatch_repository.test.cjs (and possibly MCP tool registration tests) would help prevent regressions.

This issue also appears in the following locations of the same file:

  • line 94
  • line 72
  • line 61
  • line 22
Suggested change
// Per-tool dispatch counters for max enforcement
// Per-tool dispatch counters for max enforcement; this state underpins security-sensitive
// rate/usage limits and is expected to be covered by dedicated unit tests (similar to
// dispatch_workflow handler tests).

Copilot uses AI. Check for mistakes.
/** @type {Record<string, number>} */
const dispatchCounts = {};

/**
* Message handler function that processes a single dispatch_repository message
* @param {Object} message - The dispatch_repository message to process
* @param {Object} resolvedTemporaryIds - Map of temporary IDs to resolved values
* @returns {Promise<Object>} Result with success/error status
*/
return async function handleDispatchRepository(message, resolvedTemporaryIds) {
const toolName = message.tool_name;

if (!toolName || toolName.trim() === "") {
core.warning("dispatch_repository: tool_name is empty, skipping");
return {
success: false,
error: "E001: tool_name is required",
};
}

// Look up the tool configuration
const toolConfig = tools[toolName];
if (!toolConfig) {
core.warning(`dispatch_repository: unknown tool "${toolName}", skipping`);
return {
success: false,
error: `E001: tool "${toolName}" is not configured in dispatch_repository`,
};
}

const maxCount = typeof toolConfig.max === "number" ? toolConfig.max : parseInt(String(toolConfig.max || "1"), 10) || 1;
const currentCount = dispatchCounts[toolName] || 0;

if (currentCount >= maxCount) {
core.warning(`dispatch_repository: max count of ${maxCount} reached for tool "${toolName}", skipping`);
return {
success: false,
error: `E002: Max count of ${maxCount} reached for tool "${toolName}"`,
};
}

// Resolve target repository
// Prefer message.repository > toolConfig.repository > first allowed_repository
const messageRepo = message.repository || "";
const configuredRepo = toolConfig.repository || "";
const allowedReposConfig = toolConfig.allowed_repositories || [];
const allowedRepos = parseAllowedRepos(allowedReposConfig);

let targetRepoSlug = messageRepo || configuredRepo;

if (!targetRepoSlug && allowedReposConfig.length > 0) {
// Default to first allowed repository if no specific target given
targetRepoSlug = allowedReposConfig[0];
}

if (!targetRepoSlug) {
core.warning(`dispatch_repository: no target repository for tool "${toolName}"`);
return {
success: false,
error: `E001: No target repository configured for tool "${toolName}"`,
};
}

// Validate cross-repo dispatch (SEC-005 pattern)
const isCrossRepo = targetRepoSlug !== contextRepoSlug;
if (isCrossRepo && allowedRepos.size > 0) {
const repoValidation = validateTargetRepo(targetRepoSlug, contextRepoSlug, allowedRepos);
if (!repoValidation.valid) {
core.warning(`dispatch_repository: cross-repo check failed for "${targetRepoSlug}": ${repoValidation.error}`);
return {
success: false,
error: `E004: ${repoValidation.error}`,
};
}
}

const parsedRepo = parseRepoSlug(targetRepoSlug);
if (!parsedRepo) {
core.warning(`dispatch_repository: invalid repository slug "${targetRepoSlug}"`);
return {
success: false,
error: `E001: Invalid repository slug "${targetRepoSlug}" (expected "owner/repo")`,
};
}

// Build client_payload from message inputs + workflow identifier
/** @type {Record<string, any>} */
const clientPayload = {
workflow: toolConfig.workflow || "",
};

if (message.inputs && typeof message.inputs === "object") {
for (const [key, value] of Object.entries(message.inputs)) {
clientPayload[key] = value;
}
}

const eventType = toolConfig.event_type || toolConfig.eventType || "";
if (!eventType) {
core.warning(`dispatch_repository: tool "${toolName}" has no event_type configured`);
return {
success: false,
error: `E001: event_type is required for tool "${toolName}"`,
};
}

core.info(`dispatch_repository: dispatching event_type="${eventType}" to ${targetRepoSlug} (workflow: ${toolConfig.workflow || "unspecified"})`);

// If in staged mode, preview without executing
if (isStaged || toolConfig.staged) {
logStagedPreviewInfo(`Would dispatch repository_dispatch event: event_type="${eventType}" to ${targetRepoSlug}, client_payload=${JSON.stringify(clientPayload)}`);
dispatchCounts[toolName] = currentCount + 1;
return {
success: true,
staged: true,
tool_name: toolName,
repository: targetRepoSlug,
event_type: eventType,
client_payload: clientPayload,
};
}

try {
await githubClient.rest.repos.createDispatchEvent({
owner: parsedRepo.owner,
repo: parsedRepo.repo,
event_type: eventType,
client_payload: clientPayload,
});

dispatchCounts[toolName] = currentCount + 1;
core.info(`✓ Successfully dispatched repository_dispatch: event_type="${eventType}" to ${targetRepoSlug}`);

return {
success: true,
tool_name: toolName,
repository: targetRepoSlug,
event_type: eventType,
client_payload: clientPayload,
};
} catch (error) {
const errorMessage = getErrorMessage(error);
core.error(`dispatch_repository: failed to dispatch event_type="${eventType}" to ${targetRepoSlug}: ${errorMessage}`);

return {
success: false,
error: `E099: Failed to dispatch repository_dispatch event "${eventType}" to ${targetRepoSlug}: ${errorMessage}`,
};
}
};
}

module.exports = { main };
1 change: 1 addition & 0 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const HANDLER_MAP = {
create_code_scanning_alert: "./create_code_scanning_alert.cjs",
autofix_code_scanning_alert: "./autofix_code_scanning_alert.cjs",
dispatch_workflow: "./dispatch_workflow.cjs",
dispatch_repository: "./dispatch_repository.cjs",
call_workflow: "./call_workflow.cjs",
create_missing_tool_issue: "./create_missing_tool_issue.cjs",
missing_tool: "./missing_tool.cjs",
Expand Down
22 changes: 21 additions & 1 deletion actions/setup/js/safe_outputs_mcp_server_http.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ function createMCPServer(options = {}) {
// The _workflow_name should be a non-empty string
const isDispatchWorkflowTool = tool._workflow_name && typeof tool._workflow_name === "string" && tool._workflow_name.length > 0;

// Check if this is a dispatch_repository tool (has _dispatch_repository_tool metadata)
// These tools are dynamically generated with tool-specific names
const isDispatchRepositoryTool = tool._dispatch_repository_tool && typeof tool._dispatch_repository_tool === "string" && tool._dispatch_repository_tool.length > 0;

// Check if this is a call_workflow tool (has _call_workflow_name metadata)
// These tools are dynamically generated with workflow-specific names
// The _call_workflow_name should be a non-empty string
Expand All @@ -127,6 +131,15 @@ function createMCPServer(options = {}) {
continue;
}
logger.debug(` dispatch_workflow config exists, registering tool`);
} else if (isDispatchRepositoryTool) {
logger.debug(`Found dispatch_repository tool: ${tool.name} (_dispatch_repository_tool: ${tool._dispatch_repository_tool})`);
if (!safeOutputsConfig.dispatch_repository) {
logger.debug(` WARNING: dispatch_repository config is missing or falsy - tool will NOT be registered`);
logger.debug(` Config keys: ${Object.keys(safeOutputsConfig).join(", ")}`);
logger.debug(` config.dispatch_repository value: ${JSON.stringify(safeOutputsConfig.dispatch_repository)}`);
continue;
}
logger.debug(` dispatch_repository config exists, registering tool`);
} else if (isCallWorkflowTool) {
logger.debug(`Found call_workflow tool: ${tool.name} (_call_workflow_name: ${tool._call_workflow_name})`);
if (!safeOutputsConfig.call_workflow) {
Expand All @@ -140,7 +153,14 @@ function createMCPServer(options = {}) {
// Check if regular tool is enabled in configuration
if (!enabledTools.has(tool.name)) {
// Log tool metadata to help diagnose registration issues
const toolMeta = tool._workflow_name !== undefined ? ` (_workflow_name: ${JSON.stringify(tool._workflow_name)})` : tool._call_workflow_name !== undefined ? ` (_call_workflow_name: ${JSON.stringify(tool._call_workflow_name)})` : "";
let toolMeta = "";
if (tool._workflow_name !== undefined) {
toolMeta = ` (_workflow_name: ${JSON.stringify(tool._workflow_name)})`;
} else if (tool._dispatch_repository_tool !== undefined) {
toolMeta = ` (_dispatch_repository_tool: ${JSON.stringify(tool._dispatch_repository_tool)})`;
} else if (tool._call_workflow_name !== undefined) {
toolMeta = ` (_call_workflow_name: ${JSON.stringify(tool._call_workflow_name)})`;
}
logger.debug(`Skipping tool ${tool.name}${toolMeta} - not enabled in config (tool has ${Object.keys(tool).length} properties: ${Object.keys(tool).join(", ")})`);
continue;
}
Expand Down
35 changes: 35 additions & 0 deletions actions/setup/js/safe_outputs_tools_loader.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ function loadTools(server) {
});
}

// Log details about dispatch_repository tools for debugging
const dispatchRepositoryTools = tools.filter(t => t._dispatch_repository_tool);
if (dispatchRepositoryTools.length > 0) {
server.debug(` Found ${dispatchRepositoryTools.length} dispatch_repository tools:`);
dispatchRepositoryTools.forEach(t => {
server.debug(` - ${t.name} (tool: ${t._dispatch_repository_tool})`);
});
}

// Log details about call_workflow tools for debugging
const callWorkflowTools = tools.filter(t => t._call_workflow_name);
if (callWorkflowTools.length > 0) {
Expand Down Expand Up @@ -90,6 +99,17 @@ function attachHandlers(tools, handlers) {
};
}

// Check if this is a dispatch_repository tool (dynamic tool with dispatch_repository metadata)
if (tool._dispatch_repository_tool) {
const toolKey = tool._dispatch_repository_tool;
tool.handler = args => {
return handlers.defaultHandler("dispatch_repository")({
inputs: args,
tool_name: toolKey,
});
};
}

// Check if this is a call_workflow tool (dynamic tool with call workflow metadata)
if (tool._call_workflow_name) {
// Create a custom handler that wraps args in inputs and adds workflow_name
Expand Down Expand Up @@ -161,6 +181,21 @@ function registerPredefinedTools(server, tools, config, registerTool, normalizeT
}
}

// Check if this is a dispatch_repository tool (has _dispatch_repository_tool metadata)
// These tools are dynamically generated with tool-specific names
if (tool._dispatch_repository_tool) {
server.debug(`Found dispatch_repository tool: ${tool.name} (_dispatch_repository_tool: ${tool._dispatch_repository_tool})`);
if (config.dispatch_repository) {
server.debug(` dispatch_repository config exists, registering tool`);
registerTool(server, tool);
return;
} else {
server.debug(` WARNING: dispatch_repository config is missing or falsy - tool will NOT be registered`);
server.debug(` Config keys: ${Object.keys(config).join(", ")}`);
server.debug(` config.dispatch_repository value: ${JSON.stringify(config.dispatch_repository)}`);
}
}

// Check if this is a call_workflow tool (has _call_workflow_name metadata)
// These tools are dynamically generated with workflow-specific names
if (tool._call_workflow_name) {
Expand Down
Loading
Loading