diff --git a/.github/workflows/integration-agentics.yml b/.github/workflows/integration-agentics.yml index 1ce87199d8..0092ba915e 100644 --- a/.github/workflows/integration-agentics.yml +++ b/.github/workflows/integration-agentics.yml @@ -28,6 +28,8 @@ jobs: - name: Install development dependencies run: make deps-dev + env: + GH_TOKEN: ${{ github.token }} - name: Build gh-aw binary run: make build diff --git a/Makefile b/Makefile index cb1b307b4f..521cfefc9e 100644 --- a/Makefile +++ b/Makefile @@ -68,9 +68,14 @@ deps: go install golang.org/x/tools/gopls@latest go install github.com/rhysd/actionlint/cmd/actionlint@latest +# Install act tool for local workflow testing +.PHONY: install-act +install-act: + gh extension install https://github.com/nektos/gh-act + # Install development tools (including linter) .PHONY: deps-dev -deps-dev: deps copy-copilot-to-claude download-github-actions-schema +deps-dev: deps copy-copilot-to-claude download-github-actions-schema install-act go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest npm ci @@ -245,6 +250,8 @@ help: @echo " test-coverage - Run tests with coverage report" @echo " clean - Clean build artifacts" @echo " deps - Install dependencies" + @echo " deps-dev - Install development dependencies (includes act)" + @echo " install-act - Install act tool for local workflow testing" @echo " lint - Run linter" @echo " fmt - Format code" @echo " fmt-cjs - Format JavaScript (.cjs) files" diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 9f9f6e5710..8165646d1b 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -368,6 +368,7 @@ func init() { rootCmd.AddCommand(uninstallCmd) rootCmd.AddCommand(compileCmd) rootCmd.AddCommand(runCmd) + rootCmd.AddCommand(cli.NewTestCommand()) rootCmd.AddCommand(removeCmd) rootCmd.AddCommand(statusCmd) rootCmd.AddCommand(enableCmd) diff --git a/pkg/cli/commands_test_local_test.go b/pkg/cli/commands_test_local_test.go new file mode 100644 index 0000000000..4a81d117e3 --- /dev/null +++ b/pkg/cli/commands_test_local_test.go @@ -0,0 +1,171 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckActInstalled(t *testing.T) { + tests := []struct { + name string + verbose bool + wantErr bool + }{ + { + name: "verbose mode", + verbose: true, + wantErr: true, // Should error if act is not in PATH (as expected in test environment) + }, + { + name: "quiet mode", + verbose: false, + wantErr: true, // Should error if act is not in PATH (as expected in test environment) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkActInstalled(tt.verbose) + if (err != nil) != tt.wantErr { + t.Errorf("checkActInstalled() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTestWorkflowLocallyValidation(t *testing.T) { + tests := []struct { + name string + workflowName string + event string + verbose bool + wantErr bool + }{ + { + name: "empty workflow name", + workflowName: "", + event: "workflow_dispatch", + verbose: false, + wantErr: true, + }, + { + name: "single workflow test", + workflowName: "test-workflow", + event: "workflow_dispatch", + verbose: false, + wantErr: true, // Will error on missing workflow file + }, + { + name: "custom event type", + workflowName: "test-workflow", + event: "push", + verbose: true, + wantErr: true, // Will error on missing workflow file + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := TestWorkflowLocally(tt.workflowName, tt.event, tt.verbose) + if (err != nil) != tt.wantErr { + t.Errorf("TestWorkflowLocally() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestTestSingleWorkflowLocally(t *testing.T) { + // Create a temporary test environment + tempDir := t.TempDir() + + // Create a mock .github/workflows directory + workflowsDir := filepath.Join(tempDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Create a mock markdown file first + mdFile := filepath.Join(workflowsDir, "test-workflow.md") + mdContent := `--- +on: + workflow_dispatch: +--- + +# Test Workflow + +This is a test workflow. +` + if err := os.WriteFile(mdFile, []byte(mdContent), 0644); err != nil { + t.Fatalf("Failed to create mock markdown file: %v", err) + } + + // Create a corresponding mock lock file + lockFile := filepath.Join(workflowsDir, "test-workflow.lock.yml") + lockContent := `name: Test Workflow +on: + workflow_dispatch: +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo "test" +` + if err := os.WriteFile(lockFile, []byte(lockContent), 0644); err != nil { + t.Fatalf("Failed to create mock lock file: %v", err) + } + + // Change to temp directory + oldDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get current directory: %v", err) + } + defer func() { + if err := os.Chdir(oldDir); err != nil { + t.Errorf("Failed to restore directory: %v", err) + } + }() + + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change to temp directory: %v", err) + } + + tests := []struct { + name string + workflowName string + event string + verbose bool + wantErr bool + }{ + { + name: "valid workflow test", + workflowName: "test-workflow", + event: "workflow_dispatch", + verbose: false, + wantErr: true, // Will still error due to missing act installation in test environment + }, + { + name: "valid workflow test verbose", + workflowName: "test-workflow", + event: "push", + verbose: true, + wantErr: true, // Will still error due to missing act installation in test environment + }, + { + name: "non-existent workflow", + workflowName: "missing-workflow", + event: "workflow_dispatch", + verbose: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := testSingleWorkflowLocally(tt.workflowName, tt.event, tt.verbose) + if (err != nil) != tt.wantErr { + t.Errorf("testSingleWorkflowLocally() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/cli/test.go b/pkg/cli/test.go new file mode 100644 index 0000000000..06d08eda7d --- /dev/null +++ b/pkg/cli/test.go @@ -0,0 +1,156 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/workflow" + "github.com/spf13/cobra" +) + +// NewTestCommand creates the test command for local workflow execution with act +func NewTestCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "test ", + Short: "Test an agentic workflow locally using Docker and act", + Long: `Test an agentic workflow locally using Docker and the nektos/act tool. + +This command compiles the workflow and runs it locally in Docker containers instead of GitHub Actions. +It automatically detects and installs the 'act' tool if not available. + +The workflow must have been added as an action and compiled. +This command works with workflows that have workflow_dispatch, push, pull_request, or other triggers. + +Examples: + gh aw test weekly-research + gh aw test weekly-research --event workflow_dispatch`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + event, _ := cmd.Flags().GetString("event") + verbose, _ := cmd.Flags().GetBool("verbose") + + if err := TestWorkflowLocally(args[0], event, verbose); err != nil { + fmt.Fprintln(os.Stderr, console.FormatError(console.CompilerError{ + Type: "error", + Message: fmt.Sprintf("testing workflow locally: %v", err), + })) + os.Exit(1) + } + }, + } + + // Add flags to test command + cmd.Flags().StringP("event", "e", "workflow_dispatch", "Event type to simulate (workflow_dispatch, push, pull_request, etc.)") + + return cmd +} + +// TestWorkflowLocally runs a single agentic workflow locally using Docker and act +func TestWorkflowLocally(workflowName, event string, verbose bool) error { + if workflowName == "" { + return fmt.Errorf("workflow name or ID is required") + } + + // Compile workflow first to ensure it's up to date (with safe-outputs staged if any) + fmt.Println(console.FormatProgressMessage("Compiling workflow before local testing...")) + if err := CompileWorkflowForTesting(workflowName, verbose); err != nil { + return fmt.Errorf("failed to compile workflow: %w", err) + } + + fmt.Println(console.FormatSuccessMessage("Workflow compiled successfully")) + + if err := testSingleWorkflowLocally(workflowName, event, verbose); err != nil { + return fmt.Errorf("failed to test workflow '%s': %w", workflowName, err) + } + + fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Successfully tested workflow: %s", workflowName))) + return nil +} + +// testSingleWorkflowLocally tests a single workflow locally using act +func testSingleWorkflowLocally(workflowName, event string, verbose bool) error { + // Resolve workflow file + workflowFile, err := resolveWorkflowFile(workflowName, verbose) + if err != nil { + return fmt.Errorf("failed to resolve workflow file: %w", err) + } + + // Check if it's a lock file or markdown file + lockFile := workflowFile + if !strings.HasSuffix(workflowFile, ".lock.yml") { + // Find corresponding lock file + baseName := strings.TrimSuffix(filepath.Base(workflowFile), ".md") + lockFile = filepath.Join(getWorkflowsDir(), baseName+".lock.yml") + + // Check if lock file exists + if _, err := os.Stat(lockFile); os.IsNotExist(err) { + return fmt.Errorf("compiled workflow file not found: %s. Run 'gh aw compile %s' first", lockFile, workflowName) + } + } + + if verbose { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Testing workflow file: %s", lockFile))) + } + + // Build act command + args := []string{"act"} + + // Add event type + if event != "" { + args = append(args, event) + } + + // Add workflow file + args = append(args, "--workflows", lockFile) + + // Add verbose flag if requested + if verbose { + args = append(args, "--verbose") + } + + if verbose { + fmt.Println(console.FormatCommandMessage(fmt.Sprintf("gh %s", strings.Join(args, " ")))) + } + + // Execute act command via GitHub CLI + cmd := exec.Command("gh", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("act execution failed: %w", err) + } + + return nil +} + +// CompileWorkflowForTesting compiles a single workflow with safe-outputs forced to staged mode +func CompileWorkflowForTesting(workflowName string, verbose bool) error { + // Create compiler with forced staged mode for testing + compiler := workflow.NewCompiler(verbose, "", GetVersion()) + compiler.SetSkipValidation(false) // Enable validation for testing + compiler.SetForceStaged(true) // Force safe-outputs to be staged for local testing + + // Resolve workflow file + workflowFile, err := resolveWorkflowFile(workflowName, verbose) + if err != nil { + return fmt.Errorf("failed to resolve workflow file: %w", err) + } + + if verbose { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Compiling workflow: %s", workflowFile))) + } + + // Compile the workflow + err = compiler.CompileWorkflow(workflowFile) + if err != nil { + return fmt.Errorf("failed to compile workflow '%s': %w", workflowFile, err) + } + + return nil +} diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index 5011a48f6a..69c7843daf 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -39,6 +39,7 @@ type Compiler struct { version string // Version of the extension skipValidation bool // If true, skip schema validation noEmit bool // If true, validate without generating lock files + forceStaged bool // If true, force safe-outputs to be staged for testing jobManager *JobManager // Manages jobs and dependencies engineRegistry *EngineRegistry // Registry of available agentic engines fileTracker FileTracker // Optional file tracker for tracking created files @@ -105,6 +106,11 @@ func (c *Compiler) SetFileTracker(tracker FileTracker) { c.fileTracker = tracker } +// SetForceStaged configures whether to force safe-outputs to be staged (for testing) +func (c *Compiler) SetForceStaged(forceStaged bool) { + c.forceStaged = forceStaged +} + // NewCompilerWithCustomOutput creates a new workflow compiler with custom output path func NewCompilerWithCustomOutput(verbose bool, engineOverride string, customOutput string, version string) *Compiler { c := &Compiler{ @@ -3734,6 +3740,12 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } } + // Force staged to true if forceStaged is set and we have safe-outputs config + if c.forceStaged { + stageBool := true + config.Staged = &stageBool + } + // Handle env configuration if env, exists := outputMap["env"]; exists { if envMap, ok := env.(map[string]any); ok {