diff --git a/actions/setup/js/dispatch_repository.cjs b/actions/setup/js/dispatch_repository.cjs new file mode 100644 index 00000000000..8848d11ecb6 --- /dev/null +++ b/actions/setup/js/dispatch_repository.cjs @@ -0,0 +1,183 @@ +// @ts-check +/// + +/** + * @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 + /** @type {Record} */ + 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} 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} */ + 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 }; diff --git a/actions/setup/js/safe_output_handler_manager.cjs b/actions/setup/js/safe_output_handler_manager.cjs index 16684f2b42e..683a22c5df4 100644 --- a/actions/setup/js/safe_output_handler_manager.cjs +++ b/actions/setup/js/safe_output_handler_manager.cjs @@ -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", diff --git a/actions/setup/js/safe_outputs_mcp_server_http.cjs b/actions/setup/js/safe_outputs_mcp_server_http.cjs index da386a37b28..3257ebf33b5 100644 --- a/actions/setup/js/safe_outputs_mcp_server_http.cjs +++ b/actions/setup/js/safe_outputs_mcp_server_http.cjs @@ -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 @@ -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) { @@ -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; } diff --git a/actions/setup/js/safe_outputs_tools_loader.cjs b/actions/setup/js/safe_outputs_tools_loader.cjs index 12219531249..9a1b93b5bd3 100644 --- a/actions/setup/js/safe_outputs_tools_loader.cjs +++ b/actions/setup/js/safe_outputs_tools_loader.cjs @@ -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) { @@ -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 @@ -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) { diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go index 2d447c8fb18..43e6738edda 100644 --- a/pkg/cli/compile_integration_test.go +++ b/pkg/cli/compile_integration_test.go @@ -1378,6 +1378,295 @@ When done, call add_label with the appropriate label. } } +// TestCompileDispatchRepository verifies that a workflow with a dispatch_repository +// safe-output compiles successfully and produces the expected lock file content: +// - dispatch_repository appears in the Tools prompt hint +// - Each tool's _dispatch_repository_tool metadata is present in the tools JSON +// - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG contains the dispatch_repository config +// - The handler config includes workflow, event_type, and repository fields +func TestCompileDispatchRepository(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Test Dispatch Repository +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + dispatch_repository: + trigger_ci: + description: Trigger CI in another repository + workflow: ci.yml + event_type: ci_trigger + repository: org/target-repo + max: 1 +--- + +# Test Dispatch Repository + +Call trigger_ci to trigger CI in the target repository. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "test-dispatch-repo.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "test-dispatch-repo.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Verify dispatch_repository appears in the Tools prompt hint + if !strings.Contains(lockContentStr, "dispatch_repository") { + t.Errorf("Lock file should contain 'dispatch_repository'\nLock file content:\n%s", lockContentStr) + } + + // Verify the tool definition has _dispatch_repository_tool metadata + if !strings.Contains(lockContentStr, `"_dispatch_repository_tool": "trigger_ci"`) { + t.Errorf("Lock file should contain _dispatch_repository_tool metadata for trigger_ci\nLock file content:\n%s", lockContentStr) + } + + // Verify the tool name is normalized correctly + if !strings.Contains(lockContentStr, `"name": "trigger_ci"`) { + t.Errorf("Lock file should contain the trigger_ci tool definition\nLock file content:\n%s", lockContentStr) + } + + // Verify GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG contains dispatch_repository + if !strings.Contains(lockContentStr, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + t.Errorf("Lock file should contain GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"dispatch_repository"`) { + t.Errorf("GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG should contain 'dispatch_repository'\nLock file content:\n%s", lockContentStr) + } + + // Verify required fields are in the handler config + if !strings.Contains(lockContentStr, `"workflow":"ci.yml"`) { + t.Errorf("Handler config should contain 'workflow':'ci.yml'\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"event_type":"ci_trigger"`) { + t.Errorf("Handler config should contain 'event_type':'ci_trigger'\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"repository":"org/target-repo"`) { + t.Errorf("Handler config should contain 'repository':'org/target-repo'\nLock file content:\n%s", lockContentStr) + } +} + +// TestCompileDispatchRepositoryMultipleTools verifies that dispatch_repository with +// multiple tools compiles correctly and all tools appear in the output with their +// correct configurations (repository and allowed_repositories). +func TestCompileDispatchRepositoryMultipleTools(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Test Dispatch Repository Multiple Tools +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + dispatch_repository: + trigger_ci: + description: Trigger CI pipeline + workflow: ci.yml + event_type: ci_trigger + repository: org/target-repo + inputs: + environment: + type: choice + options: + - staging + - production + default: staging + max: 1 + notify_service: + description: Notify external service + workflow: notify.yml + event_type: notify_event + allowed_repositories: + - org/service-repo + - org/backup-repo + inputs: + message: + type: string + description: Notification message + max: 2 +--- + +# Test Dispatch Repository Multiple Tools + +Dispatch trigger_ci and notify_service as appropriate. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-multi.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-multi.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Both tools must have _dispatch_repository_tool metadata + if !strings.Contains(lockContentStr, `"_dispatch_repository_tool": "trigger_ci"`) { + t.Errorf("Lock file should contain _dispatch_repository_tool metadata for trigger_ci\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"_dispatch_repository_tool": "notify_service"`) { + t.Errorf("Lock file should contain _dispatch_repository_tool metadata for notify_service\nLock file content:\n%s", lockContentStr) + } + + // Both tool names must appear in tool definitions + if !strings.Contains(lockContentStr, `"name": "trigger_ci"`) { + t.Errorf("Lock file should contain trigger_ci tool\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"name": "notify_service"`) { + t.Errorf("Lock file should contain notify_service tool\nLock file content:\n%s", lockContentStr) + } + + // Handler config must include both tools with their settings + if !strings.Contains(lockContentStr, `"workflow":"ci.yml"`) { + t.Errorf("Handler config should contain trigger_ci workflow\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"workflow":"notify.yml"`) { + t.Errorf("Handler config should contain notify_service workflow\nLock file content:\n%s", lockContentStr) + } + + // allowed_repositories must be serialized in the handler config + if !strings.Contains(lockContentStr, "org/service-repo") { + t.Errorf("Handler config should contain allowed_repositories entry org/service-repo\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, "org/backup-repo") { + t.Errorf("Handler config should contain allowed_repositories entry org/backup-repo\nLock file content:\n%s", lockContentStr) + } + + // Input schemas must be reflected in tool properties + if !strings.Contains(lockContentStr, `"environment"`) { + t.Errorf("Lock file should contain the environment input property for trigger_ci\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"message"`) { + t.Errorf("Lock file should contain the message input property for notify_service\nLock file content:\n%s", lockContentStr) + } +} + +// TestCompileDispatchRepositoryValidationFailure verifies that a workflow with +// an invalid dispatch_repository configuration (missing required 'workflow' field) +// fails compilation with a descriptive error message. +func TestCompileDispatchRepositoryValidationFailure(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Test Dispatch Repository Validation Failure +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + dispatch_repository: + trigger_ci: + event_type: ci_trigger + repository: org/target-repo +--- + +# Test Dispatch Repository Validation Failure + +This workflow is intentionally missing the required 'workflow' field. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-fail.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + outputStr := string(output) + + // Compilation must fail + if err == nil { + t.Fatalf("Compilation should have failed due to missing 'workflow' field, but succeeded\nOutput: %s", outputStr) + } + + // Error message must mention dispatch_repository and the missing field + if !strings.Contains(outputStr, "dispatch_repository") { + t.Errorf("Error output should mention 'dispatch_repository'\nOutput: %s", outputStr) + } + if !strings.Contains(outputStr, "workflow") { + t.Errorf("Error output should mention the missing 'workflow' field\nOutput: %s", outputStr) + } +} + +// TestCompileDispatchRepositoryWorkflowFile compiles the canonical test workflow +// from pkg/cli/workflows/test-copilot-dispatch-repository.md and verifies that it +// produces a valid lock file with the expected dispatch_repository configuration. +func TestCompileDispatchRepositoryWorkflowFile(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Copy the canonical workflow file into the test's .github/workflows dir + srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-copilot-dispatch-repository.md") + dstPath := filepath.Join(setup.workflowsDir, "test-copilot-dispatch-repository.md") + + srcContent, err := os.ReadFile(srcPath) + if err != nil { + t.Fatalf("Failed to read source workflow file %s: %v", srcPath, err) + } + if err := os.WriteFile(dstPath, srcContent, 0644); err != nil { + t.Fatalf("Failed to write workflow to test dir: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", dstPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed for canonical workflow: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "test-copilot-dispatch-repository.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Verify both tools are present in the compiled output + if !strings.Contains(lockContentStr, `"_dispatch_repository_tool": "trigger_ci"`) { + t.Errorf("Lock file should contain trigger_ci tool metadata\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"_dispatch_repository_tool": "notify_service"`) { + t.Errorf("Lock file should contain notify_service tool metadata\nLock file content:\n%s", lockContentStr) + } + + // Verify handler config is correctly serialized + if !strings.Contains(lockContentStr, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + t.Errorf("Lock file should contain GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, `"dispatch_repository"`) { + t.Errorf("GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG should contain dispatch_repository config\nLock file content:\n%s", lockContentStr) + } + + t.Logf("Canonical dispatch_repository workflow compiled successfully to %s", lockFilePath) +} + // TestCompileSafeOutputsActionsMultiple verifies that multiple actions in safe-outputs.actions // all generate separate action steps and all appear in GH_AW_SAFE_OUTPUT_ACTIONS. func TestCompileSafeOutputsActionsMultiple(t *testing.T) { @@ -1571,3 +1860,123 @@ Call pin_pr to pin the pull request. t.Errorf("Lock file should contain step 'id: action_pin_pr'\nLock file content:\n%s", lockContentStr) } } + +// TestCompileDispatchRepositoryGitHubActionsExpression verifies that GitHub Actions +// expressions are accepted without format validation errors in the 'repository' field. +// Expressions like "${{ inputs.target_repo }}" or "${{ vars.CI_REPO }}" must compile +// successfully because their values are only known at workflow runtime. +func TestCompileDispatchRepositoryGitHubActionsExpression(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Test Dispatch Repository GitHub Expression +on: + workflow_dispatch: + inputs: + target_repo: + description: Target repository for dispatch + required: true + type: string +permissions: + contents: read +engine: copilot +safe-outputs: + dispatch_repository: + trigger_ci: + description: Trigger CI using a runtime-resolved repository + workflow: ci.yml + event_type: ci_trigger + repository: ${{ inputs.target_repo }} + max: 1 +--- + +# Test Dispatch Repository GitHub Expression + +Call trigger_ci to dispatch a repository_dispatch event. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-expr.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed for workflow with GitHub Actions expression in 'repository': %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-expr.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // The expression must be preserved verbatim in the handler config + if !strings.Contains(lockContentStr, `inputs.target_repo`) { + t.Errorf("Lock file should preserve the GitHub Actions expression in handler config\nLock file content:\n%s", lockContentStr) + } + + // GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG must be present + if !strings.Contains(lockContentStr, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + t.Errorf("Lock file should contain GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG\nLock file content:\n%s", lockContentStr) + } +} + +// TestCompileDispatchRepositoryGitHubActionsExpressionAllowedRepos verifies that +// GitHub Actions expressions are accepted in 'allowed_repositories' entries. +// Mixed lists (static slugs alongside expressions) must also compile successfully. +func TestCompileDispatchRepositoryGitHubActionsExpressionAllowedRepos(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +name: Test Dispatch Repository Expression in AllowedRepos +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + dispatch_repository: + notify_dynamic: + description: Notify a dynamically-resolved set of repositories + workflow: notify.yml + event_type: notify_event + allowed_repositories: + - org/static-repo + - ${{ vars.DYNAMIC_REPO }} + max: 2 +--- + +# Test Dispatch Repository Expression in AllowedRepos + +Call notify_dynamic to send notifications. +` + testWorkflowPath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-expr-allowed.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testWorkflowPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed for workflow with GitHub Actions expression in 'allowed_repositories': %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "test-dispatch-repo-expr-allowed.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lockContentStr := string(lockContent) + + // Both the static slug and the expression must appear in the handler config + if !strings.Contains(lockContentStr, "org/static-repo") { + t.Errorf("Lock file should contain the static allowed_repositories entry\nLock file content:\n%s", lockContentStr) + } + if !strings.Contains(lockContentStr, "vars.DYNAMIC_REPO") { + t.Errorf("Lock file should preserve the GitHub Actions expression in allowed_repositories\nLock file content:\n%s", lockContentStr) + } +} diff --git a/pkg/cli/workflows/test-copilot-dispatch-repository.md b/pkg/cli/workflows/test-copilot-dispatch-repository.md new file mode 100644 index 00000000000..b3bc3fb0d92 --- /dev/null +++ b/pkg/cli/workflows/test-copilot-dispatch-repository.md @@ -0,0 +1,45 @@ +--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +safe-outputs: + dispatch_repository: + trigger_ci: + description: Trigger CI pipeline in a target repository + workflow: ci.yml + event_type: ci_trigger + repository: org/target-repo + inputs: + environment: + type: choice + options: + - staging + - production + default: staging + description: Target deployment environment + max: 1 + notify_service: + description: Notify external service workflow + workflow: notify.yml + event_type: notify_event + allowed_repositories: + - org/service-repo + - org/backup-repo + inputs: + message: + type: string + description: Notification message + max: 2 +--- + +# Test Copilot Dispatch Repository + +Test the `dispatch_repository` safe output type with multiple tools using the Copilot engine. + +## Task + +Dispatch the `trigger_ci` tool to trigger CI in the target repository with `environment: staging`. + +Optionally, call `notify_service` with a status message after the dispatch. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 663fc6b7e35..ba3891234d2 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -6891,6 +6891,103 @@ ], "description": "Dispatch workflow_dispatch events to other workflows. Used by orchestrators to delegate work to worker workflows with controlled maximum dispatch count." }, + "dispatch_repository": { + "type": "object", + "description": "Dispatch repository_dispatch events to external repositories. Each sub-key defines a named dispatch tool with its own event_type, target repository, input schema, and execution limits.", + "additionalProperties": { + "type": "object", + "description": "Configuration for a single repository dispatch tool", + "properties": { + "description": { + "type": "string", + "description": "Human-readable description of what this dispatch tool does" + }, + "workflow": { + "type": "string", + "description": "Target workflow name (for traceability and inclusion in client_payload)", + "minLength": 1 + }, + "event_type": { + "type": "string", + "description": "The repository_dispatch event_type string sent to the target repository", + "minLength": 1 + }, + "repository": { + "type": "string", + "description": "Target repository in 'owner/repo' format. Dispatches to this repository when no 'allowed_repositories' list is given.", + "pattern": "^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$|^\\$\\{\\{.*\\}\\}$" + }, + "allowed_repositories": { + "type": "array", + "description": "List of allowed target repositories (owner/repo). Supports glob patterns like 'org/*'.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "inputs": { + "type": "object", + "description": "Input schema for the dispatch tool. Inputs are validated and compiled into client_payload.", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "choice", "environment"], + "description": "Input type" + }, + "description": { + "type": "string", + "description": "Input description" + }, + "required": { + "type": "boolean", + "description": "Whether this input is required" + }, + "default": { + "description": "Default value for this input" + }, + "options": { + "type": "array", + "description": "Allowed options for 'choice' type inputs", + "items": { + "type": "string" + } + } + } + } + }, + "max": { + "description": "Maximum number of dispatch executions for this tool per run (default: 1, max: 50). Supports integer or GitHub Actions expression.", + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 1 + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression that resolves to an integer at runtime" + } + ] + }, + "github-token": { + "$ref": "#/$defs/github_token", + "description": "GitHub token to use for dispatching. Overrides global github-token if specified." + }, + "staged": { + "type": "boolean", + "description": "If true, emit step summary messages instead of making GitHub API calls (preview mode)", + "examples": [true, false] + } + }, + "required": ["workflow", "event_type"], + "additionalProperties": false + } + }, "call-workflow": { "oneOf": [ { diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index d65f62cb1d6..7604c0ad311 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -261,6 +261,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath c.IncrementWarningCount() } + // Emit experimental warning for dispatch_repository feature + if workflowData.SafeOutputs != nil && workflowData.SafeOutputs.DispatchRepository != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: dispatch_repository")) + c.IncrementWarningCount() + } + // Validate workflow_run triggers have branch restrictions log.Printf("Validating workflow_run triggers for branch restrictions") if err := c.validateWorkflowRunBranches(workflowData, markdownPath); err != nil { @@ -382,6 +388,12 @@ Ensure proper audience validation and trust policies are configured.` return formatCompilerError(markdownPath, "error", fmt.Sprintf("dispatch-workflow validation failed: %v", err), err) } + // Validate dispatch_repository configuration (independent of agentic-workflows tool) + log.Print("Validating dispatch_repository configuration") + if err := c.validateDispatchRepository(workflowData, markdownPath); err != nil { + return formatCompilerError(markdownPath, "error", fmt.Sprintf("dispatch_repository validation failed: %v", err), err) + } + // Validate call-workflow configuration (independent of agentic-workflows tool) log.Print("Validating call-workflow configuration") if err := c.validateCallWorkflow(workflowData, markdownPath); err != nil { diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index 2d9edc6fed9..66ab52c0c95 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -610,6 +610,26 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddIfTrue("staged", c.Staged) return builder.Build() }, + "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.DispatchRepository == nil || len(cfg.DispatchRepository.Tools) == 0 { + return nil + } + // Serialize each tool as a sub-map + tools := make(map[string]any, len(cfg.DispatchRepository.Tools)) + for toolKey, tool := range cfg.DispatchRepository.Tools { + toolConfig := newHandlerConfigBuilder(). + AddIfNotEmpty("workflow", tool.Workflow). + AddIfNotEmpty("event_type", tool.EventType). + AddIfNotEmpty("repository", tool.Repository). + AddStringSlice("allowed_repositories", tool.AllowedRepositories). + AddTemplatableInt("max", tool.Max). + AddIfNotEmpty("github-token", tool.GitHubToken). + AddIfTrue("staged", tool.Staged). + Build() + tools[toolKey] = toolConfig + } + return map[string]any{"tools": tools} + }, "call_workflow": func(cfg *SafeOutputsConfig) map[string]any { if cfg.CallWorkflow == nil { return nil diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 294b0e0aca8..7c78ee095fc 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -466,6 +466,7 @@ type SafeOutputsConfig struct { HideComment *HideCommentConfig `yaml:"hide-comment,omitempty"` // Hide comments SetIssueType *SetIssueTypeConfig `yaml:"set-issue-type,omitempty"` // Set the type of an issue (empty string clears the type) DispatchWorkflow *DispatchWorkflowConfig `yaml:"dispatch-workflow,omitempty"` // Dispatch workflow_dispatch events to other workflows + DispatchRepository *DispatchRepositoryConfig `yaml:"dispatch_repository,omitempty"` // Dispatch repository_dispatch events to external repositories CallWorkflow *CallWorkflowConfig `yaml:"call-workflow,omitempty"` // Call reusable workflows via workflow_call fan-out MissingTool *MissingToolConfig `yaml:"missing-tool,omitempty"` // Optional for reporting missing functionality MissingData *MissingDataConfig `yaml:"missing-data,omitempty"` // Optional for reporting missing data required to achieve goals diff --git a/pkg/workflow/dispatch_repository.go b/pkg/workflow/dispatch_repository.go new file mode 100644 index 00000000000..3663fd10582 --- /dev/null +++ b/pkg/workflow/dispatch_repository.go @@ -0,0 +1,228 @@ +package workflow + +import ( + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/stringutil" +) + +var dispatchRepositoryLog = logger.New("workflow:dispatch_repository") + +// DispatchRepositoryToolConfig defines a single repository dispatch tool within dispatch_repository +type DispatchRepositoryToolConfig struct { + Description string `yaml:"description,omitempty"` // Human-readable description + Workflow string `yaml:"workflow"` // Target workflow name (for traceability and payload) + EventType string `yaml:"event_type"` // repository_dispatch event_type + Repository string `yaml:"repository,omitempty"` // Single target repository (owner/repo) + AllowedRepositories []string `yaml:"allowed_repositories,omitempty"` // Multiple allowed target repositories + Inputs map[string]any `yaml:"inputs,omitempty"` // Input schema (similar to workflow_dispatch inputs) + Max *string `yaml:"max,omitempty"` // Max dispatch executions (templatable int) + GitHubToken string `yaml:"github-token,omitempty"` // Optional override token + Staged bool `yaml:"staged,omitempty"` // If true, preview-only mode +} + +// DispatchRepositoryConfig holds configuration for dispatching repository_dispatch events +// Uses a map-of-tools pattern where each key defines a named dispatch tool +type DispatchRepositoryConfig struct { + Tools map[string]*DispatchRepositoryToolConfig // Map of tool name to tool config +} + +// parseDispatchRepositoryConfig parses dispatch_repository configuration from the safe-outputs map. +// Accepts both "dispatch_repository" (underscore, preferred) and "dispatch-repository" (dash, alias). +func (c *Compiler) parseDispatchRepositoryConfig(outputMap map[string]any) *DispatchRepositoryConfig { + dispatchRepositoryLog.Print("Parsing dispatch_repository configuration") + + var configData any + var exists bool + + // Support both underscore and dash variants + if configData, exists = outputMap["dispatch_repository"]; !exists { + if configData, exists = outputMap["dispatch-repository"]; !exists { + return nil + } + } + + configMap, ok := configData.(map[string]any) + if !ok { + dispatchRepositoryLog.Print("dispatch_repository value is not a map, skipping") + return nil + } + + dispatchRepositoryLog.Printf("Parsing dispatch_repository tools map with %d entries", len(configMap)) + + dispatchRepoConfig := &DispatchRepositoryConfig{ + Tools: make(map[string]*DispatchRepositoryToolConfig), + } + + for toolKey, toolValue := range configMap { + toolMap, ok := toolValue.(map[string]any) + if !ok { + dispatchRepositoryLog.Printf("Skipping tool %q: value is not a map", toolKey) + continue + } + + tool := &DispatchRepositoryToolConfig{} + + if desc, ok := toolMap["description"].(string); ok { + tool.Description = desc + } + + if workflow, ok := toolMap["workflow"].(string); ok { + tool.Workflow = workflow + } + + if eventType, ok := toolMap["event_type"].(string); ok { + tool.EventType = eventType + } + + if repo, ok := toolMap["repository"].(string); ok { + tool.Repository = repo + } + + // Parse allowed_repositories (list of repos) + if allowedReposRaw, exists := toolMap["allowed_repositories"]; exists { + if allowedReposList, ok := allowedReposRaw.([]any); ok { + for _, r := range allowedReposList { + if rStr, ok := r.(string); ok { + tool.AllowedRepositories = append(tool.AllowedRepositories, rStr) + } + } + } + } + + // Parse inputs (map of input definitions) + if inputsRaw, exists := toolMap["inputs"]; exists { + if inputsMap, ok := inputsRaw.(map[string]any); ok { + tool.Inputs = inputsMap + } + } + + // Parse max (templatable int, default 1) + var baseCfg BaseSafeOutputConfig + c.parseBaseSafeOutputConfig(toolMap, &baseCfg, 1) + tool.Max = baseCfg.Max + tool.GitHubToken = baseCfg.GitHubToken + tool.Staged = baseCfg.Staged + + // Cap max at 50 + if maxVal := templatableIntValue(tool.Max); maxVal > 50 { + dispatchRepositoryLog.Printf("Tool %q: max value %d exceeds limit, capping at 50", toolKey, maxVal) + tool.Max = defaultIntStr(50) + } + + dispatchRepositoryLog.Printf("Parsed dispatch_repository tool %q: workflow=%s, event_type=%s, max=%v", + toolKey, tool.Workflow, tool.EventType, tool.Max) + + dispatchRepoConfig.Tools[toolKey] = tool + } + + if len(dispatchRepoConfig.Tools) == 0 { + dispatchRepositoryLog.Print("No valid tools found in dispatch_repository config") + return nil + } + + return dispatchRepoConfig +} + +// generateDispatchRepositoryTool generates an MCP tool definition for a specific dispatch_repository tool. +// The tool will be named after the tool key (normalized to underscores) and accept +// the tool's declared inputs as parameters. +func generateDispatchRepositoryTool(toolKey string, toolConfig *DispatchRepositoryToolConfig) map[string]any { + dispatchRepositoryLog.Printf("Generating dispatch_repository tool: key=%s", toolKey) + + // Normalize tool key to use underscores + toolName := stringutil.NormalizeSafeOutputIdentifier(toolKey) + + description := toolConfig.Description + if description == "" { + description = "Dispatch a repository_dispatch event" + if toolConfig.EventType != "" { + description += " with event_type: " + toolConfig.EventType + } + } + + if toolConfig.Workflow != "" { + description += " (targets workflow: " + toolConfig.Workflow + ")" + } + + // Build input schema from the tool's inputs definition + properties := make(map[string]any) + required := []string{} + + for inputName, inputDef := range toolConfig.Inputs { + inputDefMap, ok := inputDef.(map[string]any) + if !ok { + continue + } + + inputType := "string" + inputDescription := "Input parameter '" + inputName + "'" + inputRequired := false + + if desc, ok := inputDefMap["description"].(string); ok && desc != "" { + inputDescription = desc + } + if req, ok := inputDefMap["required"].(bool); ok { + inputRequired = req + } + + // Map input types to JSON Schema types + if typeStr, ok := inputDefMap["type"].(string); ok { + switch typeStr { + case "number": + inputType = "number" + case "boolean": + inputType = "boolean" + case "choice": + inputType = "string" + if options, ok := inputDefMap["options"].([]any); ok && len(options) > 0 { + prop := map[string]any{ + "type": inputType, + "description": inputDescription, + "enum": options, + } + if defaultVal, ok := inputDefMap["default"]; ok { + prop["default"] = defaultVal + } + properties[inputName] = prop + if inputRequired { + required = append(required, inputName) + } + continue + } + case "environment": + inputType = "string" + } + } + + prop := map[string]any{ + "type": inputType, + "description": inputDescription, + } + if defaultVal, ok := inputDefMap["default"]; ok { + prop["default"] = defaultVal + } + properties[inputName] = prop + + if inputRequired { + required = append(required, inputName) + } + } + + tool := map[string]any{ + "name": toolName, + "description": description, + "_dispatch_repository_tool": toolKey, // Internal metadata for handler routing + "inputSchema": map[string]any{ + "type": "object", + "properties": properties, + "additionalProperties": false, + }, + } + + if len(required) > 0 { + tool["inputSchema"].(map[string]any)["required"] = required + } + + dispatchRepositoryLog.Printf("Generated dispatch_repository tool: name=%s, properties=%d", toolName, len(properties)) + return tool +} diff --git a/pkg/workflow/dispatch_repository_experimental_warning_test.go b/pkg/workflow/dispatch_repository_experimental_warning_test.go new file mode 100644 index 00000000000..665163e781f --- /dev/null +++ b/pkg/workflow/dispatch_repository_experimental_warning_test.go @@ -0,0 +1,131 @@ +//go:build integration + +package workflow + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/testutil" +) + +// TestDispatchRepositoryExperimentalWarning tests that the dispatch_repository feature +// emits an experimental warning when enabled. +func TestDispatchRepositoryExperimentalWarning(t *testing.T) { + tests := []struct { + name string + content string + expectWarning bool + }{ + { + name: "dispatch_repository enabled produces experimental warning", + content: `--- +on: workflow_dispatch +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch_repository: + trigger_ci: + description: Trigger CI + workflow: ci.yml + event_type: ci_trigger + repository: org/target-repo +--- + +# Test Workflow +`, + expectWarning: true, + }, + { + name: "no dispatch_repository does not produce experimental warning", + content: `--- +on: workflow_dispatch +engine: copilot +permissions: + contents: read +--- + +# Test Workflow +`, + expectWarning: false, + }, + { + name: "dispatch_repository with allowed_repositories produces experimental warning", + content: `--- +on: workflow_dispatch +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch_repository: + notify_service: + workflow: notify.yml + event_type: notify_event + allowed_repositories: + - org/service-repo + - org/backup-repo +--- + +# Test Workflow +`, + expectWarning: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := testutil.TempDir(t, "dispatch-repository-experimental-warning-test") + + testFile := filepath.Join(tmpDir, "test-workflow.md") + if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil { + t.Fatal(err) + } + + // Capture stderr to check for warnings + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + compiler := NewCompiler() + compiler.SetStrictMode(false) + err := compiler.CompileWorkflow(testFile) + + // Restore stderr + w.Close() + os.Stderr = oldStderr + var buf bytes.Buffer + io.Copy(&buf, r) + stderrOutput := buf.String() + + if err != nil { + t.Errorf("Expected compilation to succeed but it failed: %v", err) + return + } + + expectedMessage := "Using experimental feature: dispatch_repository" + + if tt.expectWarning { + if !strings.Contains(stderrOutput, expectedMessage) { + t.Errorf("Expected warning containing '%s', got stderr:\n%s", expectedMessage, stderrOutput) + } + } else { + if strings.Contains(stderrOutput, expectedMessage) { + t.Errorf("Did not expect warning '%s', but got stderr:\n%s", expectedMessage, stderrOutput) + } + } + + // Verify warning count includes dispatch_repository warning + if tt.expectWarning { + warningCount := compiler.GetWarningCount() + if warningCount == 0 { + t.Error("Expected warning count > 0 but got 0") + } + } + }) + } +} diff --git a/pkg/workflow/dispatch_repository_test.go b/pkg/workflow/dispatch_repository_test.go new file mode 100644 index 00000000000..6ac742b44ef --- /dev/null +++ b/pkg/workflow/dispatch_repository_test.go @@ -0,0 +1,602 @@ +//go:build !integration + +package workflow + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestParseDispatchRepositoryConfig_SingleTool tests parsing a single dispatch_repository tool +func TestParseDispatchRepositoryConfig_SingleTool(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + outputMap := map[string]any{ + "dispatch_repository": map[string]any{ + "trigger_ci": map[string]any{ + "description": "Trigger CI in another repository", + "workflow": "ci.yml", + "event_type": "ci_trigger", + "repository": "org/target-repo", + "max": 1, + }, + }, + } + + config := compiler.parseDispatchRepositoryConfig(outputMap) + require.NotNil(t, config, "Config should be parsed") + require.Len(t, config.Tools, 1, "Should have 1 tool") + + tool := config.Tools["trigger_ci"] + require.NotNil(t, tool, "trigger_ci tool should be present") + assert.Equal(t, "Trigger CI in another repository", tool.Description) + assert.Equal(t, "ci.yml", tool.Workflow) + assert.Equal(t, "ci_trigger", tool.EventType) + assert.Equal(t, "org/target-repo", tool.Repository) + assert.Equal(t, strPtr("1"), tool.Max) +} + +// TestParseDispatchRepositoryConfig_MultipleTools tests parsing multiple tools +func TestParseDispatchRepositoryConfig_MultipleTools(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + outputMap := map[string]any{ + "dispatch_repository": map[string]any{ + "trigger_ci": map[string]any{ + "workflow": "ci.yml", + "event_type": "ci_trigger", + "repository": "org/target-repo", + }, + "notify_service": map[string]any{ + "description": "Notify external service", + "workflow": "notify.yml", + "event_type": "notify_event", + "allowed_repositories": []any{ + "org/service-repo", + "org/backup-repo", + }, + "inputs": map[string]any{ + "message": map[string]any{ + "type": "string", + "description": "Notification message", + }, + }, + "max": 2, + }, + }, + } + + config := compiler.parseDispatchRepositoryConfig(outputMap) + require.NotNil(t, config, "Config should be parsed") + require.Len(t, config.Tools, 2, "Should have 2 tools") + + triggerCI := config.Tools["trigger_ci"] + require.NotNil(t, triggerCI, "trigger_ci should be present") + assert.Equal(t, "ci.yml", triggerCI.Workflow) + assert.Equal(t, "ci_trigger", triggerCI.EventType) + assert.Equal(t, "org/target-repo", triggerCI.Repository) + + notifyService := config.Tools["notify_service"] + require.NotNil(t, notifyService, "notify_service should be present") + assert.Equal(t, "notify.yml", notifyService.Workflow) + assert.Equal(t, "notify_event", notifyService.EventType) + assert.Equal(t, []string{"org/service-repo", "org/backup-repo"}, notifyService.AllowedRepositories) + assert.NotNil(t, notifyService.Inputs, "Inputs should be present") + assert.Equal(t, strPtr("2"), notifyService.Max) +} + +// TestParseDispatchRepositoryConfig_DashAlias tests that "dispatch-repository" (dash) also works +func TestParseDispatchRepositoryConfig_DashAlias(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + outputMap := map[string]any{ + "dispatch-repository": map[string]any{ + "trigger_ci": map[string]any{ + "workflow": "ci.yml", + "event_type": "ci_trigger", + "repository": "org/target-repo", + }, + }, + } + + config := compiler.parseDispatchRepositoryConfig(outputMap) + require.NotNil(t, config, "Config should be parsed from dash form") + require.Len(t, config.Tools, 1, "Should have 1 tool") +} + +// TestParseDispatchRepositoryConfig_Absent tests that nil is returned when key is absent +func TestParseDispatchRepositoryConfig_Absent(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + outputMap := map[string]any{ + "create_issue": map[string]any{}, + } + + config := compiler.parseDispatchRepositoryConfig(outputMap) + assert.Nil(t, config, "Config should be nil when dispatch_repository is absent") +} + +// TestParseDispatchRepositoryConfig_MaxCap tests that max is capped at 50 +func TestParseDispatchRepositoryConfig_MaxCap(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + outputMap := map[string]any{ + "dispatch_repository": map[string]any{ + "trigger_ci": map[string]any{ + "workflow": "ci.yml", + "event_type": "ci_trigger", + "repository": "org/target-repo", + "max": 100, + }, + }, + } + + config := compiler.parseDispatchRepositoryConfig(outputMap) + require.NotNil(t, config) + assert.Equal(t, strPtr("50"), config.Tools["trigger_ci"].Max, "Max should be capped at 50") +} + +// TestValidateDispatchRepository_Valid tests that valid config passes validation +func TestValidateDispatchRepository_Valid(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + Workflow: "ci.yml", + EventType: "ci_trigger", + Repository: "org/target-repo", + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + assert.NoError(t, err, "Validation should succeed for valid config") +} + +// TestValidateDispatchRepository_MissingWorkflow tests error when workflow field is missing +func TestValidateDispatchRepository_MissingWorkflow(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + // workflow field is missing + EventType: "ci_trigger", + Repository: "org/target-repo", + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + require.Error(t, err, "Validation should fail when workflow is missing") + assert.Contains(t, err.Error(), "workflow", "Error should mention workflow field") +} + +// TestValidateDispatchRepository_MissingEventType tests error when event_type field is missing +func TestValidateDispatchRepository_MissingEventType(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + Workflow: "ci.yml", + // event_type is missing + Repository: "org/target-repo", + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + require.Error(t, err, "Validation should fail when event_type is missing") + assert.Contains(t, err.Error(), "event_type", "Error should mention event_type field") +} + +// TestValidateDispatchRepository_MissingRepository tests error when no repository is specified +func TestValidateDispatchRepository_MissingRepository(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + Workflow: "ci.yml", + EventType: "ci_trigger", + // no repository or allowed_repositories + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + require.Error(t, err, "Validation should fail when no repository is specified") + assert.Contains(t, err.Error(), "repository", "Error should mention repository") +} + +// TestValidateDispatchRepository_AllowedRepositories tests valid config with allowed_repositories +func TestValidateDispatchRepository_AllowedRepositories(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "notify_service": { + Workflow: "notify.yml", + EventType: "notify_event", + AllowedRepositories: []string{"org/service-repo", "org/backup-repo"}, + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + assert.NoError(t, err, "Validation should succeed with allowed_repositories") +} + +// TestValidateDispatchRepository_InvalidRepoFormat tests error for malformed repository slug +func TestValidateDispatchRepository_InvalidRepoFormat(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + Workflow: "ci.yml", + EventType: "ci_trigger", + Repository: "not-a-valid-repo-format", // missing slash + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + require.Error(t, err, "Validation should fail for invalid repository format") + assert.Contains(t, err.Error(), "invalid", "Error should mention invalid format") +} + +// TestValidateDispatchRepository_GitHubExpression tests that GitHub Actions expressions are accepted +func TestValidateDispatchRepository_GitHubExpression(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowPath := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + Workflow: "ci.yml", + EventType: "ci_trigger", + Repository: "${{ github.repository }}", // expression + }, + }, + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + assert.NoError(t, err, "GitHub Actions expressions should be accepted without format validation") +} + +// TestValidateDispatchRepository_EmptyTools tests error when no tools are defined +func TestValidateDispatchRepository_EmptyTools(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + workflowPath := filepath.Join(tmpDir, "dispatcher.md") + err := os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644) + require.NoError(t, err) + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{}, // empty + }, + }, + } + + err = compiler.validateDispatchRepository(workflowData, workflowPath) + require.Error(t, err, "Validation should fail with empty tools map") + assert.Contains(t, err.Error(), "at least one dispatch tool", "Error should mention tools requirement") +} + +// TestValidateDispatchRepository_NilConfig tests that nil config is OK (no-op) +func TestValidateDispatchRepository_NilConfig(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + // DispatchRepository is nil - not configured + }, + } + + err := compiler.validateDispatchRepository(workflowData, "/tmp/test.md") + assert.NoError(t, err, "Nil config should not cause an error") +} + +// TestGenerateDispatchRepositoryTool tests that tool definitions are generated correctly +func TestGenerateDispatchRepositoryTool(t *testing.T) { + toolConfig := &DispatchRepositoryToolConfig{ + Description: "Trigger CI in another repository", + Workflow: "ci.yml", + EventType: "ci_trigger", + Repository: "org/target-repo", + Inputs: map[string]any{ + "environment": map[string]any{ + "type": "choice", + "description": "Target environment", + "options": []any{"staging", "production"}, + "default": "staging", + }, + "message": map[string]any{ + "type": "string", + "description": "Optional message", + }, + }, + } + + tool := generateDispatchRepositoryTool("trigger_ci", toolConfig) + + assert.Equal(t, "trigger_ci", tool["name"], "Tool name should match key") + assert.NotEmpty(t, tool["description"], "Tool should have a description") + assert.Equal(t, "trigger_ci", tool["_dispatch_repository_tool"], "Should have routing metadata") + + inputSchema, ok := tool["inputSchema"].(map[string]any) + require.True(t, ok, "Tool should have inputSchema") + + properties, ok := inputSchema["properties"].(map[string]any) + require.True(t, ok, "inputSchema should have properties") + + assert.Contains(t, properties, "environment", "Should have environment property") + assert.Contains(t, properties, "message", "Should have message property") + + envProp, ok := properties["environment"].(map[string]any) + require.True(t, ok, "environment property should be a map") + assert.Equal(t, "string", envProp["type"]) + assert.Contains(t, envProp, "enum", "choice type should have enum") + assert.Equal(t, "staging", envProp["default"]) +} + +// TestGenerateDispatchRepositoryTool_NameNormalization tests underscore normalization +func TestGenerateDispatchRepositoryTool_NameNormalization(t *testing.T) { + toolConfig := &DispatchRepositoryToolConfig{ + Workflow: "ci.yml", + EventType: "ci_trigger", + Repository: "org/target-repo", + } + + tool := generateDispatchRepositoryTool("trigger-ci-workflow", toolConfig) + assert.Equal(t, "trigger_ci_workflow", tool["name"], "Dashes should be normalized to underscores") +} + +// TestDispatchRepositoryConfigSerialization tests that config serializes to JSON correctly +func TestDispatchRepositoryConfigSerialization(t *testing.T) { + max1 := "1" + workflowData := &WorkflowData{ + SafeOutputs: &SafeOutputsConfig{ + DispatchRepository: &DispatchRepositoryConfig{ + Tools: map[string]*DispatchRepositoryToolConfig{ + "trigger_ci": { + Description: "Trigger CI", + Workflow: "ci.yml", + EventType: "ci_trigger", + Repository: "org/target-repo", + Max: &max1, + }, + "notify_service": { + Workflow: "notify.yml", + EventType: "notify_event", + AllowedRepositories: []string{"org/service-repo"}, + Max: &max1, + }, + }, + }, + }, + } + + configJSON := generateSafeOutputsConfig(workflowData) + require.NotEmpty(t, configJSON, "Config JSON should not be empty") + + var config map[string]any + err := json.Unmarshal([]byte(configJSON), &config) + require.NoError(t, err, "Config JSON should be valid") + + dispatchRepo, ok := config["dispatch_repository"].(map[string]any) + require.True(t, ok, "dispatch_repository should be in config") + + tools, ok := dispatchRepo["tools"].(map[string]any) + require.True(t, ok, "tools should be in dispatch_repository config") + + assert.Contains(t, tools, "trigger_ci", "trigger_ci tool should be in config") + assert.Contains(t, tools, "notify_service", "notify_service tool should be in config") + + triggerCIConfig, ok := tools["trigger_ci"].(map[string]any) + require.True(t, ok, "trigger_ci config should be a map") + assert.Equal(t, "ci.yml", triggerCIConfig["workflow"]) + assert.Equal(t, "ci_trigger", triggerCIConfig["event_type"]) + assert.Equal(t, "org/target-repo", triggerCIConfig["repository"]) + + notifyConfig, ok := tools["notify_service"].(map[string]any) + require.True(t, ok, "notify_service config should be a map") + assert.Equal(t, "notify.yml", notifyConfig["workflow"]) + allowedRepos, ok := notifyConfig["allowed_repositories"].([]any) + require.True(t, ok, "allowed_repositories should be present") + assert.Contains(t, allowedRepos, "org/service-repo") +} + +// TestDispatchRepositoryInWorkflowCompilation tests end-to-end compilation +func TestDispatchRepositoryInWorkflowCompilation(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + workflowContent := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch_repository: + trigger_ci: + description: Trigger CI in another repository + workflow: ci.yml + event_type: ci_trigger + repository: org/target-repo + max: 1 +--- + +# Dispatch Repository Workflow + +This workflow dispatches repository events. +` + workflowFile := filepath.Join(awDir, "my-workflow.md") + err = os.WriteFile(workflowFile, []byte(workflowContent), 0644) + require.NoError(t, err) + + oldDir, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(awDir) + require.NoError(t, err) + defer func() { _ = os.Chdir(oldDir) }() + + workflowData, err := compiler.ParseWorkflowFile("my-workflow.md") + require.NoError(t, err, "Should parse workflow successfully") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + require.NotNil(t, workflowData.SafeOutputs.DispatchRepository, "DispatchRepository should not be nil") + + assert.Len(t, workflowData.SafeOutputs.DispatchRepository.Tools, 1) + + tool := workflowData.SafeOutputs.DispatchRepository.Tools["trigger_ci"] + require.NotNil(t, tool) + assert.Equal(t, "Trigger CI in another repository", tool.Description) + assert.Equal(t, "ci.yml", tool.Workflow) + assert.Equal(t, "ci_trigger", tool.EventType) + assert.Equal(t, "org/target-repo", tool.Repository) +} + +// TestDispatchRepositoryValidation_InCompiler tests validation runs during compilation +func TestDispatchRepositoryValidation_InCompiler(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err) + + // Missing workflow field should fail compilation + workflowContent := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch_repository: + trigger_ci: + event_type: ci_trigger + repository: org/target-repo +--- + +# Invalid Dispatch Repository Workflow +` + workflowFile := filepath.Join(awDir, "invalid-workflow.md") + err = os.WriteFile(workflowFile, []byte(workflowContent), 0644) + require.NoError(t, err) + + oldDir, err := os.Getwd() + require.NoError(t, err) + err = os.Chdir(tmpDir) + require.NoError(t, err) + defer func() { _ = os.Chdir(oldDir) }() + + err = compiler.CompileWorkflow(workflowFile) + require.Error(t, err, "Compilation should fail due to missing workflow field") + assert.Contains(t, err.Error(), "dispatch_repository", "Error should mention dispatch_repository") + assert.Contains(t, err.Error(), "workflow", "Error should mention workflow field") +} diff --git a/pkg/workflow/dispatch_repository_validation.go b/pkg/workflow/dispatch_repository_validation.go new file mode 100644 index 00000000000..49a4678ab31 --- /dev/null +++ b/pkg/workflow/dispatch_repository_validation.go @@ -0,0 +1,107 @@ +package workflow + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +var dispatchRepositoryValidationLog = newValidationLogger("dispatch_repository") + +// repoSlugPattern matches a valid owner/repo GitHub repository slug. +// Owner names: alphanumerics and hyphens (no dots - GitHub usernames/org names cannot have dots). +// Repository names: alphanumerics, hyphens, dots and underscores. +var repoSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+$`) + +// validateDispatchRepository validates that the dispatch_repository configuration is correct. +func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath string) error { + dispatchRepositoryValidationLog.Print("Starting dispatch_repository validation") + + if data.SafeOutputs == nil || data.SafeOutputs.DispatchRepository == nil { + dispatchRepositoryValidationLog.Print("No dispatch_repository configuration found") + return nil + } + + config := data.SafeOutputs.DispatchRepository + + if len(config.Tools) == 0 { + return errors.New("dispatch_repository: must specify at least one dispatch tool\n\nExample configuration in workflow frontmatter:\nsafe-outputs:\n dispatch_repository:\n trigger_ci:\n description: Trigger CI in another repository\n workflow: ci.yml\n event_type: ci_trigger\n repository: org/target-repo") + } + + collector := NewErrorCollector(c.failFast) + + for toolKey, tool := range config.Tools { + dispatchRepositoryValidationLog.Printf("Validating dispatch_repository tool: %s", toolKey) + + // Validate workflow field is present + if strings.TrimSpace(tool.Workflow) == "" { + workflowErr := fmt.Errorf("dispatch_repository: tool %q must specify a 'workflow' field (target workflow name for traceability)\n\nExample:\n dispatch_repository:\n %s:\n workflow: ci.yml\n event_type: ci_trigger\n repository: org/target-repo", toolKey, toolKey) + if returnErr := collector.Add(workflowErr); returnErr != nil { + return returnErr + } + continue + } + + // Validate event_type field is present + if strings.TrimSpace(tool.EventType) == "" { + eventTypeErr := fmt.Errorf("dispatch_repository: tool %q must specify an 'event_type' field\n\nExample:\n dispatch_repository:\n %s:\n workflow: %s\n event_type: my_event\n repository: org/target-repo", toolKey, toolKey, tool.Workflow) + if returnErr := collector.Add(eventTypeErr); returnErr != nil { + return returnErr + } + continue + } + + // Validate that at least one repository target is specified + hasRepository := strings.TrimSpace(tool.Repository) != "" + hasAllowedRepos := len(tool.AllowedRepositories) > 0 + + if !hasRepository && !hasAllowedRepos { + repoErr := fmt.Errorf("dispatch_repository: tool %q must specify either 'repository' or 'allowed_repositories'\n\nExample with single repository:\n dispatch_repository:\n %s:\n workflow: %s\n event_type: %s\n repository: org/target-repo\n\nExample with multiple repositories:\n dispatch_repository:\n %s:\n workflow: %s\n event_type: %s\n allowed_repositories:\n - org/repo1\n - org/repo2", toolKey, toolKey, tool.Workflow, tool.EventType, toolKey, tool.Workflow, tool.EventType) + if returnErr := collector.Add(repoErr); returnErr != nil { + return returnErr + } + continue + } + + // Validate single repository format (skip if it looks like a GitHub Actions expression) + if hasRepository && !isGitHubActionsExpression(tool.Repository) { + if !repoSlugPattern.MatchString(tool.Repository) { + repoFmtErr := fmt.Errorf("dispatch_repository: tool %q has invalid 'repository' format %q (expected 'owner/repo')", toolKey, tool.Repository) + if returnErr := collector.Add(repoFmtErr); returnErr != nil { + return returnErr + } + } + } + + // Validate allowed_repositories format + for _, repo := range tool.AllowedRepositories { + if isGitHubActionsExpression(repo) { + continue + } + // Allow glob patterns like "org/*" + if strings.Contains(repo, "*") { + continue + } + if !repoSlugPattern.MatchString(repo) { + allowedRepoErr := fmt.Errorf("dispatch_repository: tool %q has invalid repository %q in 'allowed_repositories' (expected 'owner/repo' format)", toolKey, repo) + if returnErr := collector.Add(allowedRepoErr); returnErr != nil { + return returnErr + } + } + } + + dispatchRepositoryValidationLog.Printf("Tool %q validation passed", toolKey) + } + + dispatchRepositoryValidationLog.Printf("dispatch_repository validation completed: error_count=%d, total_tools=%d", + collector.Count(), len(config.Tools)) + + return collector.FormattedError("dispatch_repository") +} + +// isGitHubActionsExpression returns true if the string contains a GitHub Actions +// expression syntax (${{ }}), which should not be validated as a static value. +func isGitHubActionsExpression(value string) bool { + return strings.Contains(value, "${{") +} diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 70146ca9fc3..5ada55f2220 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -299,6 +299,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut config.DispatchWorkflow = dispatchWorkflowConfig } + // Handle dispatch_repository + dispatchRepositoryConfig := c.parseDispatchRepositoryConfig(outputMap) + if dispatchRepositoryConfig != nil { + config.DispatchRepository = dispatchRepositoryConfig + } + // Handle call-workflow callWorkflowConfig := c.parseCallWorkflowConfig(outputMap) if callWorkflowConfig != nil { diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index e6d555ccfb7..f087e09d4f3 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -536,6 +536,35 @@ func generateSafeOutputsConfig(data *WorkflowData) string { } } + // Add dispatch_repository configuration + if data.SafeOutputs.DispatchRepository != nil && len(data.SafeOutputs.DispatchRepository.Tools) > 0 { + tools := make(map[string]any, len(data.SafeOutputs.DispatchRepository.Tools)) + for toolKey, tool := range data.SafeOutputs.DispatchRepository.Tools { + toolCfg := map[string]any{ + "workflow": tool.Workflow, + "event_type": tool.EventType, + "max": resolveMaxForConfig(tool.Max, 1), + } + if tool.Repository != "" { + toolCfg["repository"] = tool.Repository + } + if len(tool.AllowedRepositories) > 0 { + toolCfg["allowed_repositories"] = tool.AllowedRepositories + } + if tool.GitHubToken != "" { + toolCfg["github-token"] = tool.GitHubToken + } + if tool.Staged { + toolCfg["staged"] = true + } + if tool.Description != "" { + toolCfg["description"] = tool.Description + } + tools[toolKey] = toolCfg + } + safeOutputsConfig["dispatch_repository"] = map[string]any{"tools": tools} + } + // Add call-workflow configuration if data.SafeOutputs.CallWorkflow != nil { callWorkflowConfig := map[string]any{} diff --git a/pkg/workflow/safe_outputs_state.go b/pkg/workflow/safe_outputs_state.go index a8efdd51e9d..8b371a081ac 100644 --- a/pkg/workflow/safe_outputs_state.go +++ b/pkg/workflow/safe_outputs_state.go @@ -55,6 +55,7 @@ var safeOutputFieldMapping = map[string]string{ "LinkSubIssue": "link_sub_issue", "HideComment": "hide_comment", "DispatchWorkflow": "dispatch_workflow", + "DispatchRepository": "dispatch_repository", "CallWorkflow": "call_workflow", "MissingTool": "missing_tool", "MissingData": "missing_data", diff --git a/pkg/workflow/safe_outputs_tools_filtering.go b/pkg/workflow/safe_outputs_tools_filtering.go index b8e9a531559..efa27ffc1e3 100644 --- a/pkg/workflow/safe_outputs_tools_filtering.go +++ b/pkg/workflow/safe_outputs_tools_filtering.go @@ -339,6 +339,24 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, } } + // Add dynamic dispatch_repository tools + if data.SafeOutputs.DispatchRepository != nil && len(data.SafeOutputs.DispatchRepository.Tools) > 0 { + safeOutputsConfigLog.Printf("Adding %d dispatch_repository tools", len(data.SafeOutputs.DispatchRepository.Tools)) + + // Sort tool keys for deterministic output + toolKeys := make([]string, 0, len(data.SafeOutputs.DispatchRepository.Tools)) + for toolKey := range data.SafeOutputs.DispatchRepository.Tools { + toolKeys = append(toolKeys, toolKey) + } + sort.Strings(toolKeys) + + for _, toolKey := range toolKeys { + toolConfig := data.SafeOutputs.DispatchRepository.Tools[toolKey] + tool := generateDispatchRepositoryTool(toolKey, toolConfig) + filteredTools = append(filteredTools, tool) + } + } + // Add dynamic call_workflow tools if data.SafeOutputs.CallWorkflow != nil && len(data.SafeOutputs.CallWorkflow.Workflows) > 0 { safeOutputsConfigLog.Printf("Adding %d call_workflow tools", len(data.SafeOutputs.CallWorkflow.Workflows)) @@ -856,6 +874,23 @@ func generateDynamicTools(data *WorkflowData, markdownPath string) ([]map[string } } + // Add dynamic dispatch_repository tools + if data.SafeOutputs.DispatchRepository != nil && len(data.SafeOutputs.DispatchRepository.Tools) > 0 { + safeOutputsConfigLog.Printf("Adding %d dispatch_repository tools to dynamic tools", len(data.SafeOutputs.DispatchRepository.Tools)) + + // Sort tool keys for deterministic output + toolKeys := make([]string, 0, len(data.SafeOutputs.DispatchRepository.Tools)) + for toolKey := range data.SafeOutputs.DispatchRepository.Tools { + toolKeys = append(toolKeys, toolKey) + } + sort.Strings(toolKeys) + + for _, toolKey := range toolKeys { + toolConfig := data.SafeOutputs.DispatchRepository.Tools[toolKey] + dynamicTools = append(dynamicTools, generateDispatchRepositoryTool(toolKey, toolConfig)) + } + } + // Add dynamic call_workflow tools if data.SafeOutputs.CallWorkflow != nil && len(data.SafeOutputs.CallWorkflow.Workflows) > 0 { safeOutputsConfigLog.Printf("Adding %d call_workflow tools", len(data.SafeOutputs.CallWorkflow.Workflows)) diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go index 96a4ff50d31..8075960a4ee 100644 --- a/pkg/workflow/unified_prompt_step.go +++ b/pkg/workflow/unified_prompt_step.go @@ -621,6 +621,9 @@ func buildSafeOutputsSections(safeOutputs *SafeOutputsConfig) []PromptSection { if safeOutputs.DispatchWorkflow != nil { tools = append(tools, "dispatch_workflow") } + if safeOutputs.DispatchRepository != nil { + tools = append(tools, "dispatch_repository") + } if safeOutputs.CallWorkflow != nil { tools = append(tools, "call_workflow") }