diff --git a/docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md b/docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md new file mode 100644 index 00000000000..3bfe7e794c7 --- /dev/null +++ b/docs/adr/42150-make-dispatch-repository-canonical-safe-output-key.md @@ -0,0 +1,44 @@ +# ADR-42150: Make `dispatch-repository` the Canonical Safe-Output Key + +**Date**: 2026-06-29 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +The `dispatch_repository` safe-output type allowed agents to trigger `repository_dispatch` events in external repositories. When first introduced, both the underscore form (`dispatch_repository`) and the dashed form (`dispatch-repository`) were accepted at runtime as aliases of each other. This created a mismatch between the runtime behavior and the rest of the safe-output naming convention, where all other types use hyphen-case (e.g., `dispatch-workflow`, `call-workflow`, `create-check-run`). The drift between runtime behavior, the JSON schema, and the public documentation made the contract ambiguous for both users and tooling. A codemod-based migration path allows backward compatibility to be preserved while the canonical key is established. + +### Decision + +We will make `dispatch-repository` (hyphen) the canonical key for this safe-output type, demote `dispatch_repository` (underscore) to a deprecated backward-compatible alias in the JSON schema and runtime parser, and ship a `safe-output-dispatch-repository-key` codemod that automatically renames existing frontmatter. All documentation, warning messages, and schema definitions are updated to reference only the dashed form. The underscore alias is preserved in the schema as a `$ref` to avoid hard-breaking existing workflows while the codemod propagates. + +### Alternatives Considered + +#### Alternative 1: Keep `dispatch_repository` (underscore) as canonical + +The underscore key could remain primary and the dashed key could become the alias, matching Go struct field conventions. This was rejected because it contradicts the established hyphen-case naming pattern shared by every other safe-output type, and would make the public API feel inconsistent with its siblings. + +#### Alternative 2: Hard removal — drop the underscore key with no alias + +The underscore key could be removed entirely in a single release with no backward-compatible alias, requiring an immediate breaking migration. This was rejected because it would silently break all existing workflow files using `dispatch_repository` without any automated migration path, violating the project's convention of providing codemods for deprecations. + +### Consequences + +#### Positive +- Schema, runtime, documentation, and compiler warnings are now consistent: all references use `dispatch-repository`. +- Aligns with every other safe-output type (`dispatch-workflow`, `call-workflow`, `create-check-run`, etc.), reducing cognitive overhead for new users. +- The automated codemod (`safe-output-dispatch-repository-key`) lets existing users upgrade without manual edits. + +#### Negative +- The JSON schema retains a `dispatch_repository` `$ref` alias entry, adding a small amount of schema complexity that must be maintained until the alias can be removed. +- Any downstream tooling or documentation outside this repository that hard-codes the underscore key will require an update. + +#### Neutral +- The runtime parser lookup order is inverted: `dispatch-repository` is now checked first, then `dispatch_repository` as fallback — behavior is identical for users until the alias is eventually removed. +- Compiler warning text changes from `dispatch_repository` to `dispatch-repository`; any scripts that grep for the old warning string will need updating. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index c628c5cf271..1c31bb61541 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -305,9 +305,9 @@ A recognized "magic" repository secret name used as the default fallback token f An extension mechanism for safe outputs that enables integration with third-party services beyond built-in GitHub operations. Defined under `safe-outputs.jobs:`, custom safe outputs separate read and write operations: agents use read-only MCP tools for queries, while custom jobs execute write operations with secret access after agent completion. Supports services like Slack, Notion, Jira, or any external API. See [Custom Safe Outputs](/gh-aw/reference/custom-safe-outputs/). -### Dispatch Repository (`dispatch_repository`) +### Dispatch Repository (`dispatch-repository`) -An experimental safe output type that triggers `repository_dispatch` events in external repositories for cross-repository orchestration. Each key under `safe-outputs.dispatch_repository:` defines a named tool exposed to the agent. A tool requires a `workflow` identifier (forwarded in `client_payload` for routing), an `event_type`, and either a static `repository` slug or an `allowed_repositories` list. GitHub Actions expressions (`${{ ... }}`) are supported in repository fields and are passed through without format validation. At compile time the compiler emits a warning: `Using experimental feature: dispatch_repository`. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/#repository-dispatch-dispatch_repository). +An experimental safe output type that triggers `repository_dispatch` events in external repositories for cross-repository orchestration. Each key under `safe-outputs.dispatch-repository:` defines a named tool exposed to the agent. A tool requires a `workflow` identifier (forwarded in `client_payload` for routing), an `event_type`, and either a static `repository` slug or an `allowed_repositories` list. GitHub Actions expressions (`${{ ... }}`) are supported in repository fields and are passed through without format validation. At compile time the compiler emits a warning: `Using experimental feature: dispatch-repository`. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/#repository-dispatch-dispatch-repository). ### Safe Output Actions diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index eca08ca2c89..abf236ea133 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -80,7 +80,7 @@ The agent requests issue creation; a separate job with `issues: write` creates i |--------|-----|-------------| | [Dispatch Workflow](#workflow-dispatch-dispatch-workflow) | `dispatch-workflow` | Trigger other workflows with inputs (max: 3, same-repo only) | | [Call Workflow](#workflow-call-call-workflow) | `call-workflow` | Call reusable workflows via compile-time fan-out (max: 1, same-repo only) | -| [Dispatch Repository Event](#repository-dispatch-dispatch_repository) | `dispatch_repository` | Trigger `repository_dispatch` events in external repositories, experimental (cross-repo) | +| [Dispatch Repository Event](#repository-dispatch-dispatch-repository) | `dispatch-repository` | Trigger `repository_dispatch` events in external repositories, experimental (cross-repo) | | [Code Scanning Alerts](#code-scanning-alerts-create-code-scanning-alert) | `create-code-scanning-alert` | Generate SARIF security advisories (max: unlimited, same-repo only) | | [Autofix Code Scanning Alerts](#autofix-code-scanning-alerts-autofix-code-scanning-alert) | `autofix-code-scanning-alert` | Create automated fixes for code scanning alerts (max: 10, same-repo only) | | [Create Check Run](#check-run-creation-create-check-run) | `create-check-run` | Create GitHub Check Runs to surface analysis results in the PR checks UI (default max: 1, same-repo only) | @@ -1340,18 +1340,18 @@ Use `call-workflow` for deterministic fan-out where actor attribution and zero A **Security**: Same-repo only; only allowlisted workflows can be called; compile-time validation catches misconfiguration early. -### Repository Dispatch (`dispatch_repository`) +### Repository Dispatch (`dispatch-repository`) > [!CAUTION] -> This is an experimental feature. Compiling a workflow with `dispatch_repository` emits a warning: `Using experimental feature: dispatch_repository`. The API may change in future releases. +> This is an experimental feature. Compiling a workflow with `dispatch-repository` emits a warning: `Using experimental feature: dispatch-repository`. The API may change in future releases. -Triggers [`repository_dispatch`](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#repository_dispatch) events in external repositories. Unlike `dispatch-workflow` (same-repo only), `dispatch_repository` is designed for cross-repository orchestration. +Triggers [`repository_dispatch`](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#repository_dispatch) events in external repositories. Unlike `dispatch-workflow` (same-repo only), `dispatch-repository` is designed for cross-repository orchestration. -Each key under `dispatch_repository:` defines a named tool exposed to the agent: +Each key under `dispatch-repository:` defines a named tool exposed to the agent: ```yaml wrap safe-outputs: - dispatch_repository: + dispatch-repository: trigger_ci: description: Trigger CI in another repository workflow: ci.yml diff --git a/pkg/cli/codemod_safe_output_dispatch_repository_key.go b/pkg/cli/codemod_safe_output_dispatch_repository_key.go new file mode 100644 index 00000000000..80ca646666f --- /dev/null +++ b/pkg/cli/codemod_safe_output_dispatch_repository_key.go @@ -0,0 +1,109 @@ +package cli + +import ( + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var safeOutputDispatchRepositoryKeyCodemodLog = logger.New("cli:codemod_safe_output_dispatch_repository_key") + +func getSafeOutputDispatchRepositoryKeyCodemod() Codemod { + return Codemod{ + ID: "safe-output-dispatch-repository-key", + Name: "Rename safe-outputs.dispatch_repository to dispatch-repository", + Description: "Renames deprecated safe-outputs.dispatch_repository to safe-outputs.dispatch-repository.", + IntroducedIn: "1.0.65", + Apply: func(content string, frontmatter map[string]any) (string, bool, error) { + if safeOutputDispatchRepositoryKeyHasBothKeys(frontmatter) { + safeOutputDispatchRepositoryKeyCodemodLog.Print("WARN: safe-outputs has both dispatch_repository and dispatch-repository; manual review needed, skipping migration") + return content, false, nil + } + if !safeOutputDispatchRepositoryKeyNeedsMigration(frontmatter) { + return content, false, nil + } + + newContent, applied, err := applyFrontmatterLineTransform(content, renameSafeOutputDispatchRepositoryKey) + if applied { + safeOutputDispatchRepositoryKeyCodemodLog.Print("Renamed safe-outputs.dispatch_repository to safe-outputs.dispatch-repository") + } + return newContent, applied, err + }, + } +} + +func safeOutputDispatchRepositoryKeyHasBothKeys(frontmatter map[string]any) bool { + safeOutputsAny, ok := frontmatter["safe-outputs"] + if !ok { + return false + } + safeOutputsMap, ok := safeOutputsAny.(map[string]any) + if !ok { + return false + } + _, hasOld := safeOutputsMap["dispatch_repository"] + _, hasNew := safeOutputsMap["dispatch-repository"] + return hasOld && hasNew +} + +func safeOutputDispatchRepositoryKeyNeedsMigration(frontmatter map[string]any) bool { + safeOutputsAny, ok := frontmatter["safe-outputs"] + if !ok { + return false + } + safeOutputsMap, ok := safeOutputsAny.(map[string]any) + if !ok { + return false + } + _, hasOld := safeOutputsMap["dispatch_repository"] + _, hasNew := safeOutputsMap["dispatch-repository"] + return hasOld && !hasNew +} + +func renameSafeOutputDispatchRepositoryKey(lines []string) ([]string, bool) { + result := make([]string, 0, len(lines)) + modified := false + + inSafeOutputs := false + safeOutputsIndent := "" + safeOutputsChildIndent := "" + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + indent := getIndentation(line) + + if !strings.HasPrefix(trimmed, "#") { + if inSafeOutputs && hasExitedBlock(line, safeOutputsIndent) { + inSafeOutputs = false + safeOutputsChildIndent = "" + } + } + + if strings.HasPrefix(trimmed, "safe-outputs:") { + inSafeOutputs = true + safeOutputsIndent = indent + safeOutputsChildIndent = "" + result = append(result, line) + continue + } + + if inSafeOutputs && isDescendant(indent, safeOutputsIndent) && !strings.HasPrefix(trimmed, "#") { + if safeOutputsChildIndent == "" { + safeOutputsChildIndent = indent + } + if indent == safeOutputsChildIndent && strings.HasPrefix(trimmed, "dispatch_repository:") { + newLine, replaced := findAndReplaceInLine(line, "dispatch_repository", "dispatch-repository") + if replaced { + result = append(result, newLine) + modified = true + safeOutputDispatchRepositoryKeyCodemodLog.Printf("Renamed dispatch_repository to dispatch-repository in safe-outputs on line %d", i+1) + continue + } + } + } + + result = append(result, line) + } + + return result, modified +} diff --git a/pkg/cli/codemod_safe_output_dispatch_repository_key_test.go b/pkg/cli/codemod_safe_output_dispatch_repository_key_test.go new file mode 100644 index 00000000000..fa347de6082 --- /dev/null +++ b/pkg/cli/codemod_safe_output_dispatch_repository_key_test.go @@ -0,0 +1,166 @@ +//go:build !integration + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSafeOutputDispatchRepositoryKeyCodemod(t *testing.T) { + codemod := getSafeOutputDispatchRepositoryKeyCodemod() + + t.Run("metadata", func(t *testing.T) { + assert.Equal(t, "safe-output-dispatch-repository-key", codemod.ID) + assert.Equal(t, "Rename safe-outputs.dispatch_repository to dispatch-repository", codemod.Name) + assert.Equal(t, "Renames deprecated safe-outputs.dispatch_repository to safe-outputs.dispatch-repository.", codemod.Description) + assert.Equal(t, "1.0.65", codemod.IntroducedIn) + require.NotNil(t, codemod.Apply) + }) + + t.Run("renames safe-outputs dispatch_repository key", func(t *testing.T) { + content := `--- +on: workflow_dispatch +safe-outputs: + dispatch_repository: + relay: + workflow: router.yml + event_type: dispatch + repository: github/gh-aw +--- + +Body text. +` + frontmatter := map[string]any{ + "on": "workflow_dispatch", + "safe-outputs": map[string]any{ + "dispatch_repository": map[string]any{ + "relay": map[string]any{ + "workflow": "router.yml", + "event_type": "dispatch", + "repository": "github/gh-aw", + }, + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.True(t, applied) + assert.Contains(t, result, " dispatch-repository:") + assert.NotContains(t, result, " dispatch_repository:") + assert.Contains(t, result, "\n\nBody text.") + }) + + t.Run("preserves comments and indentation", func(t *testing.T) { + content := `--- +safe-outputs: + # relay config + dispatch_repository: # inline comment + relay: + workflow: router.yml + event_type: dispatch + repository: github/gh-aw +--- +` + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "dispatch_repository": map[string]any{ + "relay": map[string]any{ + "workflow": "router.yml", + "event_type": "dispatch", + "repository": "github/gh-aw", + }, + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.True(t, applied) + assert.Contains(t, result, " dispatch-repository: # inline comment") + assert.Contains(t, result, " # relay config") + }) + + t.Run("no-op when deprecated key absent", func(t *testing.T) { + content := `--- +safe-outputs: + dispatch-repository: + relay: + workflow: router.yml + event_type: dispatch + repository: github/gh-aw +--- +` + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "dispatch-repository": map[string]any{ + "relay": map[string]any{ + "workflow": "router.yml", + "event_type": "dispatch", + "repository": "github/gh-aw", + }, + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, result) + }) + + t.Run("no-op when both keys already exist", func(t *testing.T) { + content := `--- +safe-outputs: + dispatch-repository: + canonical: + workflow: router.yml + event_type: dispatch + repository: github/gh-aw + dispatch_repository: + alias: + workflow: router.yml + event_type: dispatch + repository: github/gh-aw +--- +` + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "dispatch-repository": map[string]any{}, + "dispatch_repository": map[string]any{}, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, result) + }) + + t.Run("no-op when dispatch_repository appears only in a description value", func(t *testing.T) { + content := `--- +safe-outputs: + some-tool: + description: "Triggers via dispatch_repository: mechanism" + workflow: router.yml + event_type: dispatch + repository: github/gh-aw +--- +` + frontmatter := map[string]any{ + "safe-outputs": map[string]any{ + "some-tool": map[string]any{ + "description": "Triggers via dispatch_repository: mechanism", + }, + }, + } + + result, applied, err := codemod.Apply(content, frontmatter) + require.NoError(t, err) + assert.False(t, applied) + assert.Equal(t, content, result) + }) +} diff --git a/pkg/cli/fix_codemods.go b/pkg/cli/fix_codemods.go index 9fb0a06cbba..7225d34c9e9 100644 --- a/pkg/cli/fix_codemods.go +++ b/pkg/cli/fix_codemods.go @@ -66,6 +66,7 @@ func GetAllCodemods() []Codemod { getSafeOutputRequireTitlePrefixCodemod(), // Rename deprecated safe-outputs title-prefix constraint fields getSafeOutputMergePRConstraintsCodemod(), // Rename deprecated merge-pull-request allowed-labels/allowed-branches getSafeOutputAddReviewerAllowlistsCodemod(), // Rename deprecated add-reviewer reviewers/team-reviewers + getSafeOutputDispatchRepositoryKeyCodemod(), // Rename deprecated safe-outputs.dispatch_repository key getSafeInputsToMCPScriptsCodemod(), // Rename safe-inputs to mcp-scripts getRateLimitToUserRateLimitCodemod(), // Rename rate-limit to user-rate-limit with max key migration getEffectiveTokensToAICreditsCodemod(), // Migrate obsolete effective-token budget keys to AI credits keys diff --git a/pkg/cli/fix_codemods_test.go b/pkg/cli/fix_codemods_test.go index 0fa61fe6829..43d214bb299 100644 --- a/pkg/cli/fix_codemods_test.go +++ b/pkg/cli/fix_codemods_test.go @@ -85,6 +85,7 @@ func TestGetAllCodemods_ContainsExpectedCodemods(t *testing.T) { "safe-output-title-prefix-to-required-title-prefix", "safe-output-merge-pr-constraints", "safe-output-add-reviewer-allowlists", + "safe-output-dispatch-repository-key", "safe-inputs-to-mcp-scripts", "rate-limit-to-user-rate-limit", "effective-tokens-to-ai-credits", @@ -200,6 +201,7 @@ func expectedCodemodOrder() []string { "safe-output-title-prefix-to-required-title-prefix", "safe-output-merge-pr-constraints", "safe-output-add-reviewer-allowlists", + "safe-output-dispatch-repository-key", "safe-inputs-to-mcp-scripts", "rate-limit-to-user-rate-limit", "effective-tokens-to-ai-credits", diff --git a/pkg/parser/schema_location_test.go b/pkg/parser/schema_location_test.go index 6399e641968..8acfddddccc 100644 --- a/pkg/parser/schema_location_test.go +++ b/pkg/parser/schema_location_test.go @@ -321,6 +321,23 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_AdditionalProperti wantErr: true, errContains: "requird", }, + { + name: "dispatch-repository key is accepted by schema", + frontmatter: map[string]any{ + "on": "workflow_dispatch", + "safe-outputs": map[string]any{ + "dispatch-repository": map[string]any{ + "relay": map[string]any{ + "workflow": "router.yml", + "event_type": "dispatch", + "repository": "github/gh-aw", + }, + }, + }, + }, + filePath: "/test/workflow.md", + wantErr: false, + }, { name: "valid workflow_call input still compiles", frontmatter: map[string]any{ diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index ef88f72c1f5..d76961a1abb 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -957,8 +957,8 @@ func TestMainWorkflowSchema_WorkflowCallAndDispatchInputDefsDisallowUnknownKeys( path: []any{"properties", "on", "oneOf", 1, "properties", "workflow_call", "oneOf", 1, "properties", "secrets", "additionalProperties"}, }, { - name: "safe-outputs.dispatch_repository..inputs.", - path: []any{"properties", "safe-outputs", "properties", "dispatch_repository", "additionalProperties", "properties", "inputs", "additionalProperties"}, + name: "safe-outputs.dispatch-repository..inputs.", + path: []any{"properties", "safe-outputs", "properties", "dispatch-repository", "additionalProperties", "properties", "inputs", "additionalProperties"}, }, } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index d4ff6273c59..f1a7d7fd3f6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9194,7 +9194,7 @@ ], "description": "Dispatch workflow_dispatch events to other workflows. Used by orchestrators to delegate work to worker workflows with controlled maximum dispatch count." }, - "dispatch_repository": { + "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": { @@ -9292,6 +9292,10 @@ "additionalProperties": false } }, + "dispatch_repository": { + "$ref": "#/properties/safe-outputs/properties/dispatch-repository", + "description": "Deprecated alias for dispatch-repository." + }, "call-workflow": { "oneOf": [ { diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go index 29fa8f21f84..a06ea9dd629 100644 --- a/pkg/workflow/compiler_validators.go +++ b/pkg/workflow/compiler_validators.go @@ -299,7 +299,7 @@ func (c *Compiler) emitExperimentalFeatureWarnings(workflowData *WorkflowData) { message string }{ {enabled: workflowData.RateLimit != nil, message: "Using experimental feature: rate limiting"}, - {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.DispatchRepository != nil, message: "Using experimental feature: dispatch_repository"}, + {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.DispatchRepository != nil, message: "Using experimental feature: dispatch-repository"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.MergePullRequest != nil, message: "Using experimental feature: merge-pull-request"}, {enabled: workflowData.SafeOutputs != nil && workflowData.SafeOutputs.ReplaceLabel != nil, message: "Using experimental feature: replace-label"}, {enabled: workflowData.EngineConfig != nil && workflowData.EngineConfig.CopilotSDK, message: "Using experimental feature: engine.copilot-sdk"}, diff --git a/pkg/workflow/dispatch_repository.go b/pkg/workflow/dispatch_repository.go index 60acd1d7008..73bc6d1ad63 100644 --- a/pkg/workflow/dispatch_repository.go +++ b/pkg/workflow/dispatch_repository.go @@ -26,28 +26,28 @@ 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). +// parseDispatchRepositoryConfig parses dispatch-repository configuration from the safe-outputs map. func (c *Compiler) parseDispatchRepositoryConfig(outputMap map[string]any) *DispatchRepositoryConfig { - dispatchRepositoryLog.Print("Parsing dispatch_repository configuration") + 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 { + // dispatch-repository is canonical; keep underscore form as a backward-compatible alias. + if configData, exists = outputMap["dispatch-repository"]; !exists { + if configData, exists = outputMap["dispatch_repository"]; !exists { return nil } + dispatchRepositoryLog.Print("WARNING: safe-outputs.dispatch_repository is deprecated; rename to dispatch-repository or run `gh aw fix`") } configMap, ok := configData.(map[string]any) if !ok { - dispatchRepositoryLog.Print("dispatch_repository value is not a map, skipping") + dispatchRepositoryLog.Print("dispatch-repository value is not a map, skipping") return nil } - dispatchRepositoryLog.Printf("Parsing dispatch_repository tools map with %d entries", len(configMap)) + dispatchRepositoryLog.Printf("Parsing dispatch-repository tools map with %d entries", len(configMap)) dispatchRepoConfig := &DispatchRepositoryConfig{ Tools: make(map[string]*DispatchRepositoryToolConfig), @@ -109,25 +109,25 @@ func (c *Compiler) parseDispatchRepositoryConfig(outputMap map[string]any) *Disp tool.Max = defaultIntStr(50) } - dispatchRepositoryLog.Printf("Parsed dispatch_repository tool %q: workflow=%s, event_type=%s, max=%v", + 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") + 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. +// 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) + dispatchRepositoryLog.Printf("Generating dispatch-repository tool: key=%s", toolKey) // Normalize tool key to use underscores toolName := stringutil.NormalizeSafeOutputIdentifier(toolKey) @@ -166,6 +166,6 @@ func generateDispatchRepositoryTool(toolKey string, toolConfig *DispatchReposito "inputSchema": inputSchema, } - dispatchRepositoryLog.Printf("Generated dispatch_repository tool: name=%s, properties=%d", toolName, len(properties)) + 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 index 665163e781f..11243f7d1e6 100644 --- a/pkg/workflow/dispatch_repository_experimental_warning_test.go +++ b/pkg/workflow/dispatch_repository_experimental_warning_test.go @@ -107,7 +107,7 @@ safe-outputs: return } - expectedMessage := "Using experimental feature: dispatch_repository" + expectedMessage := "Using experimental feature: dispatch-repository" if tt.expectWarning { if !strings.Contains(stderrOutput, expectedMessage) { diff --git a/pkg/workflow/dispatch_repository_test.go b/pkg/workflow/dispatch_repository_test.go index 299cb7ab4a7..6d91d289d3b 100644 --- a/pkg/workflow/dispatch_repository_test.go +++ b/pkg/workflow/dispatch_repository_test.go @@ -17,7 +17,7 @@ func TestParseDispatchRepositoryConfig_SingleTool(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ - "dispatch_repository": map[string]any{ + "dispatch-repository": map[string]any{ "trigger_ci": map[string]any{ "description": "Trigger CI in another repository", "workflow": "ci.yml", @@ -46,7 +46,7 @@ func TestParseDispatchRepositoryConfig_MultipleTools(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ - "dispatch_repository": map[string]any{ + "dispatch-repository": map[string]any{ "trigger_ci": map[string]any{ "workflow": "ci.yml", "event_type": "ci_trigger", @@ -90,12 +90,12 @@ func TestParseDispatchRepositoryConfig_MultipleTools(t *testing.T) { assert.Equal(t, strPtr("2"), notifyService.Max) } -// TestParseDispatchRepositoryConfig_DashAlias tests that "dispatch-repository" (dash) also works -func TestParseDispatchRepositoryConfig_DashAlias(t *testing.T) { +// TestParseDispatchRepositoryConfig_UnderscoreAlias tests that "dispatch_repository" (underscore) remains supported. +func TestParseDispatchRepositoryConfig_UnderscoreAlias(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0")) outputMap := map[string]any{ - "dispatch-repository": map[string]any{ + "dispatch_repository": map[string]any{ "trigger_ci": map[string]any{ "workflow": "ci.yml", "event_type": "ci_trigger", @@ -105,7 +105,7 @@ func TestParseDispatchRepositoryConfig_DashAlias(t *testing.T) { } config := compiler.parseDispatchRepositoryConfig(outputMap) - require.NotNil(t, config, "Config should be parsed from dash form") + require.NotNil(t, config, "Config should be parsed from underscore alias") require.Len(t, config.Tools, 1, "Should have 1 tool") } @@ -121,6 +121,36 @@ func TestParseDispatchRepositoryConfig_Absent(t *testing.T) { assert.Nil(t, config, "Config should be nil when dispatch_repository is absent") } +// TestParseDispatchRepositoryConfig_DashPrecedenceOverUnderscore tests that dispatch-repository (dashed) +// takes precedence over dispatch_repository (underscore) when both keys are present. +func TestParseDispatchRepositoryConfig_DashPrecedenceOverUnderscore(t *testing.T) { + compiler := NewCompiler(WithVersion("1.0.0")) + + outputMap := map[string]any{ + "dispatch-repository": map[string]any{ + "dash_tool": map[string]any{ + "workflow": "dash.yml", + "event_type": "dash_event", + "repository": "github/canonical", + }, + }, + "dispatch_repository": map[string]any{ + "underscore_tool": map[string]any{ + "workflow": "underscore.yml", + "event_type": "underscore_event", + "repository": "github/alias", + }, + }, + } + + config := compiler.parseDispatchRepositoryConfig(outputMap) + require.NotNil(t, config) + _, hasDashTool := config.Tools["dash_tool"] + assert.True(t, hasDashTool, "dashed form should take precedence") + _, hasUnderscoreTool := config.Tools["underscore_tool"] + assert.False(t, hasUnderscoreTool, "underscore form should be shadowed by dashed form") +} + // TestParseDispatchRepositoryConfig_MaxCap tests that max is capped at 50 func TestParseDispatchRepositoryConfig_MaxCap(t *testing.T) { compiler := NewCompiler(WithVersion("1.0.0"))