-
Notifications
You must be signed in to change notification settings - Fork 476
fix(dispatch-workflow): expose ref parameter in per-workflow tool schema when allowed-refs is configured
#49754
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fe1f3f7
f33f4a1
b0a8d37
15d949b
30362bd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| package workflow | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/github/gh-aw/pkg/logger" | ||
| ) | ||
|
|
||
|
|
@@ -94,15 +97,38 @@ func workflowHasAwContextInput(fileResult *findWorkflowFileResult, workflowName | |
| // generateDispatchWorkflowTool generates an MCP tool definition for a specific workflow. | ||
| // The tool will be named after the workflow (normalized to underscores) and accept | ||
| // the workflow's defined workflow_dispatch inputs as parameters. | ||
| func generateDispatchWorkflowTool(workflowName string, workflowInputs map[string]any) map[string]any { | ||
| safeOutputsDispatchWorkflowLog.Printf("Generating dispatch-workflow tool: workflow=%s, inputs=%d", workflowName, len(workflowInputs)) | ||
| // When allowedRefs is non-empty, a 'ref' parameter is added to let the agent | ||
| // specify which branch/tag/SHA to dispatch to, validated against the configured globs. | ||
| func generateDispatchWorkflowTool(workflowName string, workflowInputs map[string]any, allowedRefs []string) map[string]any { | ||
| safeOutputsDispatchWorkflowLog.Printf("Generating dispatch-workflow tool: workflow=%s, inputs=%d, allowedRefs=%d", workflowName, len(workflowInputs), len(allowedRefs)) | ||
|
|
||
| descriptionFormat := "Dispatch the '%s' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository." | ||
|
|
||
| tool := generateWorkflowToolDefinition(workflowToolDefinitionOptions{ | ||
| workflowName: workflowName, | ||
| workflowInputs: workflowInputs, | ||
| descriptionFormat: "Dispatch the '%s' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository.", | ||
| descriptionFormat: descriptionFormat, | ||
| metadataKey: "_workflow_name", | ||
| }) | ||
|
|
||
| // When allowed-refs is configured, inject a 'ref' property so the agent can | ||
| // specify the target branch/tag/SHA. The runtime handler validates the value | ||
| // against the configured glob patterns before dispatching. | ||
| if len(allowedRefs) > 0 { | ||
| inputSchema, _ := tool["inputSchema"].(map[string]any) | ||
| properties, _ := inputSchema["properties"].(map[string]any) | ||
| allowedRefsDesc := strings.Join(allowedRefs, ", ") | ||
|
|
||
| refDesc := fmt.Sprintf("The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: %s. If omitted, the dispatching workflow's ref is used.", allowedRefsDesc) | ||
| properties["ref"] = map[string]any{ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] Using 💡 Suggested fixReplace both refDesc := fmt.Sprintf(
"The git ref ... allowed ref patterns: %s. If omitted ...",
strings.Join(allowedRefs, ", "),
)
tool["description"] = desc + fmt.Sprintf(
" Use the 'ref' parameter to target a specific branch or tag (allowed patterns: %s).",
strings.Join(allowedRefs, ", "),
)This also means the test assertions ( @copilot please address this. |
||
| "type": "string", | ||
| "description": refDesc, | ||
| } | ||
|
Comment on lines
+123
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Details
Suggested fix: format with |
||
|
|
||
| desc, _ := tool["description"].(string) | ||
| tool["description"] = desc + fmt.Sprintf(" Use the 'ref' parameter to target a specific branch or tag (allowed patterns: %s).", allowedRefsDesc) | ||
| } | ||
|
|
||
| inputSchema, _ := tool["inputSchema"].(map[string]any) | ||
| properties, _ := inputSchema["properties"].(map[string]any) | ||
| requiredCount := 0 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -258,7 +258,7 @@ func TestGenerateDispatchWorkflowToolBasic(t *testing.T) { | |
| }, | ||
| } | ||
|
|
||
| tool := generateDispatchWorkflowTool("deploy-app", workflowInputs) | ||
| tool := generateDispatchWorkflowTool("deploy-app", workflowInputs, nil) | ||
|
|
||
| assert.Equal(t, "deploy_app", tool["name"], "Tool name should be normalized") | ||
| assert.Equal(t, "deploy-app", tool["_workflow_name"], "Internal workflow name should be preserved") | ||
|
|
@@ -278,7 +278,7 @@ func TestGenerateDispatchWorkflowToolBasic(t *testing.T) { | |
|
|
||
| // TestGenerateDispatchWorkflowToolEmptyInputs tests dispatch workflow tool with no inputs. | ||
| func TestGenerateDispatchWorkflowToolEmptyInputs(t *testing.T) { | ||
| tool := generateDispatchWorkflowTool("simple-workflow", make(map[string]any)) | ||
| tool := generateDispatchWorkflowTool("simple-workflow", make(map[string]any), nil) | ||
|
|
||
| assert.Equal(t, "simple_workflow", tool["name"], "Name should be normalized") | ||
|
|
||
|
|
@@ -313,7 +313,7 @@ func TestGenerateDispatchWorkflowToolRequiredSorted(t *testing.T) { | |
|
|
||
| // Run multiple times to catch non-determinism from map iteration | ||
| for i := range 10 { | ||
| tool := generateDispatchWorkflowTool("cleanup-worker", workflowInputs) | ||
| tool := generateDispatchWorkflowTool("cleanup-worker", workflowInputs, nil) | ||
|
|
||
| inputSchema, ok := tool["inputSchema"].(map[string]any) | ||
| require.True(t, ok, "inputSchema should be present (iteration %d)", i) | ||
|
|
@@ -326,8 +326,75 @@ func TestGenerateDispatchWorkflowToolRequiredSorted(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| // TestGenerateFilteredToolsJSONWithStandardOutputs tests that standard safe outputs produce | ||
| // the expected tools in the filtered output (regression test for the completeness check). | ||
| // TestGenerateDispatchWorkflowToolWithAllowedRefs tests that a 'ref' parameter is injected | ||
| // into the tool schema when allowed-refs is configured. This ensures the agent can supply | ||
| // a target ref that is validated against the configured glob patterns by the runtime handler. | ||
| func TestGenerateDispatchWorkflowToolWithAllowedRefs(t *testing.T) { | ||
| workflowInputs := map[string]any{ | ||
| "model": map[string]any{ | ||
| "description": "Model to run", | ||
| "type": "string", | ||
| "required": true, | ||
| }, | ||
| } | ||
| allowedRefs := []string{"silencer/*", "refs/heads/main"} | ||
|
|
||
| tool := generateDispatchWorkflowTool("t3000-unit-tests", workflowInputs, allowedRefs) | ||
|
|
||
| assert.Equal(t, "t3000_unit_tests", tool["name"], "Tool name should be normalized") | ||
|
|
||
| inputSchema, ok := tool["inputSchema"].(map[string]any) | ||
| require.True(t, ok, "inputSchema should be present") | ||
|
|
||
| properties, ok := inputSchema["properties"].(map[string]any) | ||
| require.True(t, ok, "properties should be present") | ||
|
|
||
| // ref property should be injected | ||
| refProp, ok := properties["ref"].(map[string]any) | ||
| require.True(t, ok, "ref property should exist when allowed-refs is configured") | ||
| assert.Equal(t, "string", refProp["type"], "ref property should be a string") | ||
| assert.Contains(t, refProp["description"].(string), "silencer/*", "ref description should mention allowed patterns") | ||
| assert.Contains(t, refProp["description"].(string), "refs/heads/main", "ref description should mention all allowed patterns") | ||
|
|
||
| // ref should not be in required (it is optional) | ||
| required, hasRequired := inputSchema["required"].([]string) | ||
| if hasRequired { | ||
| assert.NotContains(t, required, "ref", "ref should not be required") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The 💡 Suggested fixHandle both possible slice types: requiredRaw := inputSchema["required"]
if requiredRaw != nil {
switch r := requiredRaw.(type) {
case []string:
assert.NotContains(t, r, "ref")
case []any:
for _, v := range r {
assert.NotEqual(t, "ref", v)
}
}
}Alternatively, introduce a small @copilot please address this. |
||
| } | ||
|
|
||
| // description should mention the allowed patterns | ||
| desc := tool["description"].(string) | ||
| assert.Contains(t, desc, "silencer/*", "tool description should mention allowed ref patterns") | ||
| } | ||
|
|
||
| // TestGenerateDispatchWorkflowToolNoRefWithoutAllowedRefs tests that no 'ref' property | ||
| // is added when allowed-refs is not configured (nil or empty). | ||
| func TestGenerateDispatchWorkflowToolNoRefWithoutAllowedRefs(t *testing.T) { | ||
| workflowInputs := map[string]any{ | ||
| "platform": map[string]any{ | ||
| "description": "Target platform", | ||
| "type": "string", | ||
| "required": true, | ||
| }, | ||
| } | ||
|
|
||
| for _, allowedRefs := range [][]string{nil, {}} { | ||
| tool := generateDispatchWorkflowTool("build-workflow", workflowInputs, allowedRefs) | ||
|
|
||
| inputSchema, ok := tool["inputSchema"].(map[string]any) | ||
| require.True(t, ok, "inputSchema should be present") | ||
|
|
||
| properties, ok := inputSchema["properties"].(map[string]any) | ||
| require.True(t, ok, "properties should be present") | ||
|
|
||
| _, hasRef := properties["ref"] | ||
| assert.False(t, hasRef, "ref property should not be present when allowed-refs is not configured") | ||
|
|
||
| required, ok := inputSchema["required"].([]string) | ||
| require.True(t, ok, "required should be present for required workflow input") | ||
| assert.Equal(t, []string{"platform"}, required, "required should only include workflow inputs, not ref") | ||
| } | ||
| } | ||
|
|
||
| // TestComputeRequiredFieldRemovalsCloseDiscussion verifies that allow-body: false for | ||
| // close-discussion produces a required field removal for the body field. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type assertions on
tool["inputSchema"]andinputSchema["properties"]ignore theokbool, so a future shape change ingenerateWorkflowToolDefinitionwould turn into a nil-map write panic here instead of a clear error.💡 Details
properties, _ := inputSchema["properties"].(map[string]any)discards the failure case. TodaygenerateWorkflowToolDefinitionalways returns a non-nilpropertiesmap (viabuildInputSchema), so this is currently safe, but the coupling is implicit and undocumented — nothing enforces that invariant at this call site. IfgenerateWorkflowToolDefinitionis refactored later (e.g. to omitpropertieswhen there are no inputs, which would be a reasonable change),properties["ref"] = ...panics on a nil map write with an unhelpful stack trace.Suggested hardening: