diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index ab41ec9728..c89debbc37 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -380,10 +380,16 @@ var handlerRegistry = map[string]handlerBuilder{ return nil } c := cfg.DispatchWorkflow - return newHandlerConfigBuilder(). + builder := newHandlerConfigBuilder(). AddIfPositive("max", c.Max). - AddStringSlice("workflows", c.Workflows). - Build() + AddStringSlice("workflows", c.Workflows) + + // Add workflow_files map if it has entries + if len(c.WorkflowFiles) > 0 { + builder.AddDefault("workflow_files", c.WorkflowFiles) + } + + return builder.Build() }, "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { if cfg.MissingTool == nil { diff --git a/pkg/workflow/dispatch_workflow_test.go b/pkg/workflow/dispatch_workflow_test.go new file mode 100644 index 0000000000..5edeefd795 --- /dev/null +++ b/pkg/workflow/dispatch_workflow_test.go @@ -0,0 +1,381 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDispatchWorkflowMultiDirectoryDiscovery tests that dispatch_workflow can find workflows +// in multiple directories (same directory and .github/workflows) +func TestDispatchWorkflowMultiDirectoryDiscovery(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + // Create a temporary directory structure + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err, "Failed to create aw directory") + err = os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a workflow in .github/workflows with workflow_dispatch + ciWorkflow := `name: CI +on: + push: + workflow_dispatch: + inputs: + test_mode: + description: 'Test mode' + type: choice + options: + - unit + - integration + required: false + default: 'unit' +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "Running tests" +` + ciFile := filepath.Join(workflowsDir, "ci.lock.yml") + err = os.WriteFile(ciFile, []byte(ciWorkflow), 0644) + require.NoError(t, err, "Failed to write ci workflow") + + // Create a dispatcher workflow in .github/aw that references ci + dispatcherWorkflow := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch-workflow: + workflows: + - ci + max: 1 +--- + +# Dispatcher Workflow + +This workflow dispatches to ci workflow. +` + dispatcherFile := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(dispatcherFile, []byte(dispatcherWorkflow), 0644) + require.NoError(t, err, "Failed to write dispatcher workflow") + + // Change to the aw directory for compilation + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(awDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + // Parse the dispatcher workflow + workflowData, err := compiler.ParseWorkflowFile("dispatcher.md") + require.NoError(t, err, "Failed to parse workflow") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + require.NotNil(t, workflowData.SafeOutputs.DispatchWorkflow, "DispatchWorkflow should not be nil") + + // Verify dispatch-workflow configuration + assert.Equal(t, 1, workflowData.SafeOutputs.DispatchWorkflow.Max) + assert.Equal(t, []string{"ci"}, workflowData.SafeOutputs.DispatchWorkflow.Workflows) + + // Validate the workflow - should find ci in .github/workflows + err = compiler.validateDispatchWorkflow(workflowData, dispatcherFile) + assert.NoError(t, err, "Validation should succeed - ci workflow should be found in .github/workflows") +} + +// TestDispatchWorkflowOnlySearchesGithubWorkflows tests that workflows are only +// searched in .github/workflows, not in the same directory as the current workflow +func TestDispatchWorkflowOnlySearchesGithubWorkflows(t *testing.T) { + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err, "Failed to create aw directory") + err = os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a workflow in .github/workflows with workflow_dispatch + workflowsTestWorkflow := `name: Test (workflows) +on: + workflow_dispatch: + inputs: + env: + description: 'Environment' + default: 'staging' +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "From workflows" +` + workflowsTestFile := filepath.Join(workflowsDir, "test.lock.yml") + err = os.WriteFile(workflowsTestFile, []byte(workflowsTestWorkflow), 0644) + require.NoError(t, err, "Failed to write workflows test workflow") + + // Create a workflow with the same name in .github/aw (should be ignored) + awTestWorkflow := `name: Test (aw) +on: + workflow_dispatch: + inputs: + mode: + description: 'Test mode' + default: 'fast' +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "From aw" +` + awTestFile := filepath.Join(awDir, "test.lock.yml") + err = os.WriteFile(awTestFile, []byte(awTestWorkflow), 0644) + require.NoError(t, err, "Failed to write aw test workflow") + + // Create a dispatcher workflow that references test + dispatcherWorkflow := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch-workflow: + workflows: + - test + max: 1 +--- + +# Dispatcher Workflow + +This workflow dispatches to test workflow. +` + dispatcherFile := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(dispatcherFile, []byte(dispatcherWorkflow), 0644) + require.NoError(t, err, "Failed to write dispatcher workflow") + + // Test that findWorkflowFile finds the one in .github/workflows only (not .github/aw) + fileResult, err := findWorkflowFile("test", dispatcherFile) + require.NoError(t, err, "findWorkflowFile should succeed") + assert.True(t, fileResult.lockExists, "Lock file should exist") + + // Verify it found the workflows version (not aw version) + assert.Contains(t, fileResult.lockPath, filepath.Join(".github", "workflows", "test.lock.yml"), + "Should find workflow in .github/workflows only") + assert.NotContains(t, fileResult.lockPath, filepath.Join(".github", "aw", "test.lock.yml"), + "Should NOT find workflow in same directory") +} + +// TestDispatchWorkflowNotFound tests error handling when workflow is not found +func TestDispatchWorkflowNotFound(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err, "Failed to create aw directory") + err = os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a dispatcher workflow that references a non-existent workflow + dispatcherWorkflow := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch-workflow: + workflows: + - nonexistent + max: 1 +--- + +# Dispatcher Workflow + +This workflow tries to dispatch to a non-existent workflow. +` + dispatcherFile := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(dispatcherFile, []byte(dispatcherWorkflow), 0644) + require.NoError(t, err, "Failed to write dispatcher workflow") + + // Change to the aw directory + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(awDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + // Parse the dispatcher workflow + workflowData, err := compiler.ParseWorkflowFile("dispatcher.md") + require.NoError(t, err, "Failed to parse workflow") + + // Validate the workflow - should fail because nonexistent workflow is not found + err = compiler.validateDispatchWorkflow(workflowData, dispatcherFile) + require.Error(t, err, "Validation should fail - workflow not found") + assert.Contains(t, err.Error(), "not found", "Error should mention workflow not found") + assert.Contains(t, err.Error(), "nonexistent", "Error should mention the workflow name") +} + +// TestDispatchWorkflowWithoutWorkflowDispatchTrigger tests error handling +// when referenced workflow doesn't support workflow_dispatch +func TestDispatchWorkflowWithoutWorkflowDispatchTrigger(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err, "Failed to create aw directory") + err = os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a workflow WITHOUT workflow_dispatch + ciWorkflow := `name: CI +on: + push: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "Running tests" +` + ciFile := filepath.Join(workflowsDir, "ci.lock.yml") + err = os.WriteFile(ciFile, []byte(ciWorkflow), 0644) + require.NoError(t, err, "Failed to write ci workflow") + + // Create a dispatcher workflow that references ci + dispatcherWorkflow := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch-workflow: + workflows: + - ci + max: 1 +--- + +# Dispatcher Workflow + +This workflow tries to dispatch to ci workflow. +` + dispatcherFile := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(dispatcherFile, []byte(dispatcherWorkflow), 0644) + require.NoError(t, err, "Failed to write dispatcher workflow") + + // Change to the aw directory + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(awDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + // Parse the dispatcher workflow + workflowData, err := compiler.ParseWorkflowFile("dispatcher.md") + require.NoError(t, err, "Failed to parse workflow") + + // Validate the workflow - should fail because ci doesn't support workflow_dispatch + err = compiler.validateDispatchWorkflow(workflowData, dispatcherFile) + require.Error(t, err, "Validation should fail - workflow doesn't support workflow_dispatch") + assert.Contains(t, err.Error(), "workflow_dispatch", "Error should mention workflow_dispatch") +} + +// TestDispatchWorkflowFileExtensionResolution tests that the correct file extension +// (.lock.yml or .yml) is stored in the WorkflowFiles map +func TestDispatchWorkflowFileExtensionResolution(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + tmpDir := t.TempDir() + awDir := filepath.Join(tmpDir, ".github", "aw") + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + + err := os.MkdirAll(awDir, 0755) + require.NoError(t, err, "Failed to create aw directory") + err = os.MkdirAll(workflowsDir, 0755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a .lock.yml workflow (agentic workflow) + lockWorkflow := `name: Lock Workflow +on: + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "Lock workflow" +` + lockFile := filepath.Join(workflowsDir, "lock-test.lock.yml") + err = os.WriteFile(lockFile, []byte(lockWorkflow), 0644) + require.NoError(t, err, "Failed to write lock workflow") + + // Create a .yml workflow (standard GitHub Actions) + ymlWorkflow := `name: YAML Workflow +on: + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "YAML workflow" +` + ymlFile := filepath.Join(workflowsDir, "yml-test.yml") + err = os.WriteFile(ymlFile, []byte(ymlWorkflow), 0644) + require.NoError(t, err, "Failed to write yml workflow") + + // Create a dispatcher workflow that references both + dispatcherWorkflow := `--- +on: issues +engine: copilot +permissions: + contents: read +safe-outputs: + dispatch-workflow: + workflows: + - lock-test + - yml-test + max: 2 +--- + +# Dispatcher Workflow + +This workflow dispatches to different workflow types. +` + dispatcherFile := filepath.Join(awDir, "dispatcher.md") + err = os.WriteFile(dispatcherFile, []byte(dispatcherWorkflow), 0644) + require.NoError(t, err, "Failed to write dispatcher workflow") + + // Change to the aw directory + oldDir, err := os.Getwd() + require.NoError(t, err, "Failed to get current directory") + err = os.Chdir(awDir) + require.NoError(t, err, "Failed to change directory") + defer func() { _ = os.Chdir(oldDir) }() + + // Parse and compile the dispatcher workflow + workflowData, err := compiler.ParseWorkflowFile("dispatcher.md") + require.NoError(t, err, "Failed to parse workflow") + + // Generate filtered tools JSON to populate WorkflowFiles + _, err = generateFilteredToolsJSON(workflowData, dispatcherFile) + require.NoError(t, err, "Failed to generate tools JSON") + + // Verify WorkflowFiles map has correct extensions + require.NotNil(t, workflowData.SafeOutputs.DispatchWorkflow.WorkflowFiles) + assert.Equal(t, ".lock.yml", workflowData.SafeOutputs.DispatchWorkflow.WorkflowFiles["lock-test"], + "lock-test should use .lock.yml extension") + assert.Equal(t, ".yml", workflowData.SafeOutputs.DispatchWorkflow.WorkflowFiles["yml-test"], + "yml-test should use .yml extension") +} diff --git a/pkg/workflow/dispatch_workflow_validation.go b/pkg/workflow/dispatch_workflow_validation.go index 7724bfbf94..b359b275b5 100644 --- a/pkg/workflow/dispatch_workflow_validation.go +++ b/pkg/workflow/dispatch_workflow_validation.go @@ -31,9 +31,6 @@ func (c *Compiler) validateDispatchWorkflow(data *WorkflowData, workflowPath str currentWorkflowName := getCurrentWorkflowName(workflowPath) dispatchWorkflowValidationLog.Printf("Current workflow name: %s", currentWorkflowName) - // Get the workflows directory - workflowsDir := filepath.Dir(workflowPath) - for _, workflowName := range config.Workflows { dispatchWorkflowValidationLog.Printf("Validating workflow: %s", workflowName) @@ -42,47 +39,45 @@ func (c *Compiler) validateDispatchWorkflow(data *WorkflowData, workflowPath str return fmt.Errorf("dispatch-workflow: self-reference not allowed (workflow '%s' cannot dispatch itself)", workflowName) } - // Check if the workflow file exists - support .md, .lock.yml, or .yml files - // Use filepath.Clean to sanitize paths and prevent path traversal attacks - workflowFilePath := filepath.Clean(filepath.Join(workflowsDir, workflowName+".md")) - lockFilePath := filepath.Clean(filepath.Join(workflowsDir, workflowName+".lock.yml")) - ymlFilePath := filepath.Clean(filepath.Join(workflowsDir, workflowName+".yml")) - - // Validate that all paths are within the workflows directory to prevent path traversal - if !isPathWithinDir(workflowFilePath, workflowsDir) || !isPathWithinDir(lockFilePath, workflowsDir) || !isPathWithinDir(ymlFilePath, workflowsDir) { - return fmt.Errorf("dispatch-workflow: invalid workflow name '%s' (path traversal not allowed)", workflowName) + // Find the workflow file in multiple locations + fileResult, err := findWorkflowFile(workflowName, workflowPath) + if err != nil { + return fmt.Errorf("dispatch-workflow: error finding workflow '%s': %w", workflowName, err) } // Check if any workflow file exists - mdExists := fileExists(workflowFilePath) - lockExists := fileExists(lockFilePath) - ymlExists := fileExists(ymlFilePath) - - if !mdExists && !lockExists && !ymlExists { - return fmt.Errorf("dispatch-workflow: workflow '%s' not found (expected %s, %s, or %s)", workflowName, workflowFilePath, lockFilePath, ymlFilePath) + if !fileResult.mdExists && !fileResult.lockExists && !fileResult.ymlExists { + // Provide helpful error message showing .github/workflows location + currentDir := filepath.Dir(workflowPath) + githubDir := filepath.Dir(currentDir) + repoRoot := filepath.Dir(githubDir) + workflowsDir := filepath.Join(repoRoot, ".github", "workflows") + + return fmt.Errorf("dispatch-workflow: workflow '%s' not found in %s (tried .md, .lock.yml, and .yml extensions)", + workflowName, workflowsDir) } // Validate that the workflow supports workflow_dispatch // Priority: .lock.yml (compiled agentic workflow) > .yml (standard GitHub Actions) > .md (needs compilation) var workflowContent []byte // #nosec G304 -- All file paths are validated via isPathWithinDir() before use - var err error var workflowFile string + var readErr error - if lockExists { - workflowFile = lockFilePath - workflowContent, err = os.ReadFile(lockFilePath) // #nosec G304 -- Path is validated above via isPathWithinDir - if err != nil { - return fmt.Errorf("dispatch-workflow: failed to read workflow file %s: %w", lockFilePath, err) + if fileResult.lockExists { + workflowFile = fileResult.lockPath + workflowContent, readErr = os.ReadFile(fileResult.lockPath) // #nosec G304 -- Path is validated via isPathWithinDir in findWorkflowFile + if readErr != nil { + return fmt.Errorf("dispatch-workflow: failed to read workflow file %s: %w", fileResult.lockPath, readErr) } - } else if ymlExists { - workflowFile = ymlFilePath - workflowContent, err = os.ReadFile(ymlFilePath) // #nosec G304 -- Path is validated above via isPathWithinDir - if err != nil { - return fmt.Errorf("dispatch-workflow: failed to read workflow file %s: %w", ymlFilePath, err) + } else if fileResult.ymlExists { + workflowFile = fileResult.ymlPath + workflowContent, readErr = os.ReadFile(fileResult.ymlPath) // #nosec G304 -- Path is validated via isPathWithinDir in findWorkflowFile + if readErr != nil { + return fmt.Errorf("dispatch-workflow: failed to read workflow file %s: %w", fileResult.ymlPath, readErr) } } else { // Only .md exists - needs to be compiled first - return fmt.Errorf("dispatch-workflow: workflow '%s' must be compiled first (run 'gh aw compile %s')", workflowName, workflowFilePath) + return fmt.Errorf("dispatch-workflow: workflow '%s' must be compiled first (run 'gh aw compile %s')", workflowName, fileResult.mdPath) } // Parse the workflow YAML to check for workflow_dispatch trigger @@ -213,3 +208,50 @@ func isPathWithinDir(path, dir string) bool { // If it starts with "..", it's trying to escape return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." } + +// findWorkflowFileResult holds the result of finding a workflow file +type findWorkflowFileResult struct { + mdPath string + lockPath string + ymlPath string + mdExists bool + lockExists bool + ymlExists bool +} + +// findWorkflowFile searches for a workflow file in .github/workflows directory only +// Returns paths and existence flags for .md, .lock.yml, and .yml files +func findWorkflowFile(workflowName string, currentWorkflowPath string) (*findWorkflowFileResult, error) { + result := &findWorkflowFileResult{} + + // Get the current workflow's directory + currentDir := filepath.Dir(currentWorkflowPath) + + // Get repo root by going up from current directory + // Assume structure: /.github/workflows/file.md or /.github/aw/file.md + githubDir := filepath.Dir(currentDir) // .github + repoRoot := filepath.Dir(githubDir) // repo root + + // Only search in .github/workflows (standard GitHub Actions location) + searchDir := filepath.Join(repoRoot, ".github", "workflows") + + // Build paths for the workflows directory + mdPath := filepath.Clean(filepath.Join(searchDir, workflowName+".md")) + lockPath := filepath.Clean(filepath.Join(searchDir, workflowName+".lock.yml")) + ymlPath := filepath.Clean(filepath.Join(searchDir, workflowName+".yml")) + + // Validate paths are within the search directory (prevent path traversal) + if !isPathWithinDir(mdPath, searchDir) || !isPathWithinDir(lockPath, searchDir) || !isPathWithinDir(ymlPath, searchDir) { + return result, fmt.Errorf("invalid workflow name '%s' (path traversal not allowed)", workflowName) + } + + // Check which files exist + result.mdPath = mdPath + result.lockPath = lockPath + result.ymlPath = ymlPath + result.mdExists = fileExists(mdPath) + result.lockExists = fileExists(lockPath) + result.ymlExists = fileExists(ymlPath) + + return result, nil +} diff --git a/pkg/workflow/safe_outputs_config_generation.go b/pkg/workflow/safe_outputs_config_generation.go index 8afe02019c..12f0ff8d1e 100644 --- a/pkg/workflow/safe_outputs_config_generation.go +++ b/pkg/workflow/safe_outputs_config_generation.go @@ -3,7 +3,6 @@ package workflow import ( "encoding/json" "fmt" - "path/filepath" "sort" "github.com/githubnext/gh-aw/pkg/stringutil" @@ -681,31 +680,33 @@ func generateFilteredToolsJSON(data *WorkflowData, markdownPath string) (string, if data.SafeOutputs.DispatchWorkflow != nil && len(data.SafeOutputs.DispatchWorkflow.Workflows) > 0 { safeOutputsConfigLog.Printf("Adding %d dispatch_workflow tools", len(data.SafeOutputs.DispatchWorkflow.Workflows)) - // Get workflows directory from markdownPath - workflowsDir := filepath.Dir(markdownPath) - // Initialize WorkflowFiles map if not already initialized if data.SafeOutputs.DispatchWorkflow.WorkflowFiles == nil { data.SafeOutputs.DispatchWorkflow.WorkflowFiles = make(map[string]string) } for _, workflowName := range data.SafeOutputs.DispatchWorkflow.Workflows { - // Try to find the workflow file - priority: .lock.yml > .yml - // .lock.yml is used for compiled agentic workflows - // .yml is used for standard GitHub Actions workflows - lockFilePath := filepath.Join(workflowsDir, workflowName+".lock.yml") - ymlFilePath := filepath.Join(workflowsDir, workflowName+".yml") + // Find the workflow file in multiple locations + fileResult, err := findWorkflowFile(workflowName, markdownPath) + if err != nil { + safeOutputsConfigLog.Printf("Warning: error finding workflow %s: %v", workflowName, err) + // Continue with empty inputs + tool := generateDispatchWorkflowTool(workflowName, make(map[string]any)) + filteredTools = append(filteredTools, tool) + continue + } + // Determine which file to use - priority: .lock.yml > .yml var workflowPath string var extension string - if fileExists(lockFilePath) { - workflowPath = lockFilePath + if fileResult.lockExists { + workflowPath = fileResult.lockPath extension = ".lock.yml" - } else if fileExists(ymlFilePath) { - workflowPath = ymlFilePath + } else if fileResult.ymlExists { + workflowPath = fileResult.ymlPath extension = ".yml" } else { - safeOutputsConfigLog.Printf("Warning: workflow file not found for %s (tried %s and %s)", workflowName, lockFilePath, ymlFilePath) + safeOutputsConfigLog.Printf("Warning: workflow file not found for %s (only .md exists, needs compilation)", workflowName) // Continue with empty inputs tool := generateDispatchWorkflowTool(workflowName, make(map[string]any)) filteredTools = append(filteredTools, tool) @@ -897,7 +898,7 @@ func generateDispatchWorkflowTool(workflowName string, workflowInputs map[string toolName := stringutil.NormalizeSafeOutputIdentifier(workflowName) // Build the description - description := fmt.Sprintf("Dispatch the '%s' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in the same repository.", workflowName) + description := fmt.Sprintf("Dispatch the '%s' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository.", workflowName) // Build input schema properties properties := make(map[string]any)