diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 995e0a670cc..742a125b032 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -16,6 +16,7 @@ Repository overlay (optional): Read only the files you need: Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/action-container-substitutions.md` +- `.github/aw/agent-runtime-instructions.md` - `.github/aw/agentic-chat.md` - `.github/aw/agentic-workflows-mcp.md` - `.github/aw/asciicharts.md` diff --git a/pkg/cli/README.md b/pkg/cli/README.md index 66767ba5ab9..332bad5b3c5 100644 --- a/pkg/cli/README.md +++ b/pkg/cli/README.md @@ -137,8 +137,8 @@ The `cli` package is intentionally large and command-oriented. The tables below | `RunUpdateWorkflows` | `func RunUpdateWorkflows(ctx context.Context, opts UpdateWorkflowsOptions) error` | CLI wrapper for updating sourced workflows. | | `UpdateWorkflows` | `func UpdateWorkflows(ctx context.Context, opts UpdateWorkflowsOptions) error` | Updates workflows with `source:` frontmatter from upstream definitions. | | `NewStatusCommand` | `func NewStatusCommand() *cobra.Command` | Constructs the `gh aw status` command. | -| `GetWorkflowStatuses` | `func GetWorkflowStatuses(pattern string, ref string, labelFilter string, repoOverride string) ([]WorkflowStatus, error)` | Returns workflow status data for programmatic callers. | -| `StatusWorkflows` | `func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string, repoOverride string) error` | Renders workflow status to terminal or JSON. | +| `GetWorkflowStatuses` | `func GetWorkflowStatuses(ctx context.Context, pattern string, ref string, labelFilter string, repoOverride string) ([]WorkflowStatus, error)` | Returns workflow status data for programmatic callers. | +| `StatusWorkflows` | `func StatusWorkflows(ctx context.Context, pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string, repoOverride string) error` | Renders workflow status to terminal or JSON. | | `InitRepository` | `func InitRepository(opts InitOptions) error` | Initializes repository-local gh-aw support files. | | `CreateWorkflowMarkdownFile` | `func CreateWorkflowMarkdownFile(workflowName string, verbose bool, force bool, engine string) error` | Creates a new workflow markdown file. | | `ResolveWorkflowPath` | `func ResolveWorkflowPath(workflowFile string) (string, error)` | Resolves a workflow identifier to a local file path. | diff --git a/pkg/cli/commands_test.go b/pkg/cli/commands_test.go index aee4f586bb5..96056bcd8f4 100644 --- a/pkg/cli/commands_test.go +++ b/pkg/cli/commands_test.go @@ -222,7 +222,7 @@ func TestRemoveWorkflows(t *testing.T) { } func TestStatusWorkflows(t *testing.T) { - err := StatusWorkflows("test-pattern", false, false, "", "", "") + err := StatusWorkflows(t.Context(), "test-pattern", false, false, "", "", "") // Should not error since it's a stub implementation if err != nil { @@ -385,8 +385,8 @@ Test workflow for command existence.` _, err := CompileWorkflows(context.Background(), config) return err }, false, "CompileWorkflows"}, - {func() error { return RemoveWorkflows("nonexistent", false, "") }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully - {func() error { return StatusWorkflows("nonexistent", false, false, "", "", "") }, false, "StatusWorkflows"}, // Should handle missing directory gracefully + {func() error { return RemoveWorkflows("nonexistent", false, "") }, false, "RemoveWorkflows"}, // Should handle missing directory gracefully + {func() error { return StatusWorkflows(t.Context(), "nonexistent", false, false, "", "", "") }, false, "StatusWorkflows"}, // Should handle missing directory gracefully {func() error { return RunWorkflowOnGitHub(context.Background(), "", RunOptions{}) }, true, "RunWorkflowOnGitHub"}, // Should error with empty workflow name @@ -985,13 +985,13 @@ func TestRunWorkflowOnGitHubWithEnable(t *testing.T) { func TestGetWorkflowStatus(t *testing.T) { // Test with non-existent workflow - _, err := getWorkflowStatus("nonexistent-workflow", "", false) + _, err := getWorkflowStatus(t.Context(), "nonexistent-workflow", "", false) if err == nil { t.Error("getWorkflowStatus should return error for non-existent workflow") } // Test with empty workflow name - _, err = getWorkflowStatus("", "", false) + _, err = getWorkflowStatus(t.Context(), "", "", false) if err == nil { t.Error("getWorkflowStatus should return error for empty workflow name") } diff --git a/pkg/cli/enable.go b/pkg/cli/enable.go index d069901034b..a880d576081 100644 --- a/pkg/cli/enable.go +++ b/pkg/cli/enable.go @@ -81,7 +81,7 @@ func toggleWorkflowsByNames(ctx context.Context, workflowNames []string, enable // Get GitHub workflows status for comparison; warn but continue if unavailable enableLog.Print("Fetching GitHub workflows status for comparison") - githubWorkflows, err := fetchGitHubWorkflows(repoOverride, false) + githubWorkflows, err := fetchGitHubWorkflows(ctx, repoOverride, false) if err != nil { enableLog.Printf("Failed to fetch GitHub workflows: %v", err) fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Unable to fetch GitHub workflows (gh CLI may not be authenticated): %v", err))) diff --git a/pkg/cli/forecast_montecarlo_test.go b/pkg/cli/forecast_montecarlo_test.go index 5a4cb3c638d..99f335aad31 100644 --- a/pkg/cli/forecast_montecarlo_test.go +++ b/pkg/cli/forecast_montecarlo_test.go @@ -412,7 +412,7 @@ func TestResolveForecastWorkflowsFromRemote_RateLimitFallsBackToPartialResults(t attempts := 0 var backoffs []time.Duration - forecastFetchGitHubWorkflows = func(repoOverride string, verbose bool) (map[string]*GitHubWorkflow, error) { + forecastFetchGitHubWorkflows = func(_ context.Context, repoOverride string, verbose bool) (map[string]*GitHubWorkflow, error) { attempts++ return nil, errors.New("API rate limit exceeded") } diff --git a/pkg/cli/forecast_resolution.go b/pkg/cli/forecast_resolution.go index ce2afcfa5b5..4fb8e84cb8f 100644 --- a/pkg/cli/forecast_resolution.go +++ b/pkg/cli/forecast_resolution.go @@ -22,7 +22,9 @@ const ( ) var ( - forecastFetchGitHubWorkflows = fetchGitHubWorkflows + forecastFetchGitHubWorkflows = func(ctx context.Context, repoOverride string, verbose bool) (map[string]*GitHubWorkflow, error) { + return fetchGitHubWorkflows(ctx, repoOverride, verbose) + } forecastListWorkflowRunsPaginated = listWorkflowRunsWithPagination forecastRateLimitSleep = func(ctx context.Context, delay time.Duration) error { timer := time.NewTimer(delay) @@ -110,7 +112,7 @@ func fetchWorkflowsWithBackoff(ctx context.Context, ids []string, repoOverride s var lastErr error for attempt := 1; attempt <= forecastRateLimitMaxAttempts; attempt++ { - githubWorkflows, err := forecastFetchGitHubWorkflows(repoOverride, verbose) + githubWorkflows, err := forecastFetchGitHubWorkflows(ctx, repoOverride, verbose) if err == nil { return githubWorkflows, nil } diff --git a/pkg/cli/mcp_tools_readonly.go b/pkg/cli/mcp_tools_readonly.go index 718c5626ebc..412f4df01a9 100644 --- a/pkg/cli/mcp_tools_readonly.go +++ b/pkg/cli/mcp_tools_readonly.go @@ -47,7 +47,7 @@ Returns a JSON array where each element has the following structure: mcpLog.Printf("Executing status tool: pattern=%s", args.Pattern) // Call GetWorkflowStatuses directly instead of spawning subprocess - statuses, err := GetWorkflowStatuses(args.Pattern, "", "", "") + statuses, err := GetWorkflowStatuses(ctx, args.Pattern, "", "", "") if err != nil { return nil, nil, newMCPError(jsonrpc.CodeInternalError, "failed to get workflow statuses", map[string]any{"error": err.Error()}) } diff --git a/pkg/cli/run_workflow_execution.go b/pkg/cli/run_workflow_execution.go index ff7fb98800f..0df799b80cd 100644 --- a/pkg/cli/run_workflow_execution.go +++ b/pkg/cli/run_workflow_execution.go @@ -133,7 +133,7 @@ func prepareWorkflowRun(ctx context.Context, workflowIdOrName string, opts RunOp if err := validateWorkflowForRun(workflowIdOrName, opts); err != nil { return nil, err } - enableState, err := handleWorkflowEnablement(workflowIdOrName, opts) + enableState, err := handleWorkflowEnablement(ctx, workflowIdOrName, opts) if err != nil { return nil, err } @@ -214,11 +214,11 @@ func warnLocalWorkflowStatus(workflowFile string) { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Consider pushing your changes before running the workflow")) } -func handleWorkflowEnablement(workflowIdOrName string, opts RunOptions) (workflowEnableState, error) { +func handleWorkflowEnablement(ctx context.Context, workflowIdOrName string, opts RunOptions) (workflowEnableState, error) { if !opts.Enable { return workflowEnableState{}, nil } - wf, err := getWorkflowStatus(workflowIdOrName, opts.RepoOverride, opts.Verbose) + wf, err := getWorkflowStatus(ctx, workflowIdOrName, opts.RepoOverride, opts.Verbose) if err != nil { if opts.Verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not check workflow status: %v", err))) diff --git a/pkg/cli/status.go b/pkg/cli/status.go index ad8f902534e..23284fbd025 100644 --- a/pkg/cli/status.go +++ b/pkg/cli/status.go @@ -34,7 +34,7 @@ It accepts workflow IDs (basename without .md) or full filenames.`, labelFilter, _ := cmd.Flags().GetString("label") repoOverride, _ := cmd.Flags().GetString("repo") statusLog.Printf("Status command invoked: pattern=%q, json=%v, ref=%q, label=%q, repo=%q", pattern, jsonFlag, ref, labelFilter, repoOverride) - return StatusWorkflows(pattern, verbose, jsonFlag, ref, labelFilter, repoOverride) + return StatusWorkflows(cmd.Context(), pattern, verbose, jsonFlag, ref, labelFilter, repoOverride) }, } diff --git a/pkg/cli/status_command.go b/pkg/cli/status_command.go index 67055d5cec9..1abbd46b2de 100644 --- a/pkg/cli/status_command.go +++ b/pkg/cli/status_command.go @@ -1,6 +1,7 @@ package cli import ( + "context" "encoding/json" "errors" "fmt" @@ -40,14 +41,17 @@ type WorkflowStatus struct { // GetWorkflowStatuses retrieves workflow status information and returns it as a slice. // This function is designed for programmatic access (e.g., from MCP server). // For CLI usage, use StatusWorkflows which handles output formatting. -func GetWorkflowStatuses(pattern string, ref string, labelFilter string, repoOverride string) ([]WorkflowStatus, error) { +func GetWorkflowStatuses(ctx context.Context, pattern string, ref string, labelFilter string, repoOverride string) ([]WorkflowStatus, error) { statusLog.Printf("Getting workflow statuses: pattern=%s, ref=%s, labelFilter=%s, repo=%s", pattern, ref, labelFilter, repoOverride) // Get GitHub workflows data statusLog.Print("Fetching GitHub workflow status") - githubWorkflows, err := fetchGitHubWorkflows(repoOverride, false) + githubWorkflows, err := fetchGitHubWorkflows(ctx, repoOverride, false) if err != nil { statusLog.Printf("Failed to fetch GitHub workflows: %v", err) + if ctx.Err() != nil { + return nil, ctx.Err() + } githubWorkflows = make(map[string]*GitHubWorkflow) } else { statusLog.Printf("Successfully fetched %d GitHub workflows", len(githubWorkflows)) @@ -56,9 +60,12 @@ func GetWorkflowStatuses(pattern string, ref string, labelFilter string, repoOve // Fetch latest workflow runs for ref if specified var latestRunsByWorkflow map[string]*WorkflowRun if ref != "" { - latestRunsByWorkflow, err = fetchLatestRunsByRef(ref, repoOverride, false) + latestRunsByWorkflow, err = fetchLatestRunsByRef(ctx, ref, repoOverride, false) if err != nil { statusLog.Printf("Failed to fetch workflow runs for ref %s: %v", ref, err) + if ctx.Err() != nil { + return nil, ctx.Err() + } latestRunsByWorkflow = make(map[string]*WorkflowRun) } else { statusLog.Printf("Successfully fetched %d workflow runs for ref %s", len(latestRunsByWorkflow), ref) @@ -240,7 +247,7 @@ func buildRemoteWorkflowStatuses(pattern string, githubWorkflows map[string]*Git return statuses } -func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string, repoOverride string) error { +func StatusWorkflows(ctx context.Context, pattern string, verbose bool, jsonOutput bool, ref string, labelFilter string, repoOverride string) error { statusLog.Printf("Checking workflow status: pattern=%s, jsonOutput=%v, ref=%s, labelFilter=%s, repo=%s", pattern, jsonOutput, ref, labelFilter, repoOverride) if verbose && !jsonOutput { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Checking status of workflow files")) @@ -255,11 +262,11 @@ func StatusWorkflows(pattern string, verbose bool, jsonOutput bool, ref string, } // Get workflow statuses - statuses, err := GetWorkflowStatuses(pattern, ref, labelFilter, repoOverride) + statuses, err := GetWorkflowStatuses(ctx, pattern, ref, labelFilter, repoOverride) if err != nil { statusLog.Printf("Failed to get workflow statuses: %v", err) fmt.Fprintln(os.Stderr, console.FormatErrorMessage(err.Error())) - return nil + return err } // Additional verbose output after successful fetch @@ -482,7 +489,7 @@ func isCompiledUpToDateWithCache(workflowPath, lockFilePath string, cache *parse } // fetchLatestRunsByRef fetches the latest workflow run for each workflow from a specific ref (branch or tag) -func fetchLatestRunsByRef(ref string, repoOverride string, verbose bool) (map[string]*WorkflowRun, error) { +func fetchLatestRunsByRef(ctx context.Context, ref string, repoOverride string, verbose bool) (map[string]*WorkflowRun, error) { statusLog.Printf("Fetching latest workflow runs for ref: %s, repo: %s", ref, repoOverride) // Start spinner for network operation (only if not in verbose mode) @@ -496,7 +503,7 @@ func fetchLatestRunsByRef(ref string, repoOverride string, verbose bool) (map[st if repoOverride != "" { args = append(args, "--repo", repoOverride) } - cmd := workflow.ExecGH(args...) + cmd := workflow.ExecGHContext(ctx, args...) output, err := cmd.Output() if err != nil { diff --git a/pkg/cli/status_command_test.go b/pkg/cli/status_command_test.go index 7a04e549ae6..1c7c49bcf32 100644 --- a/pkg/cli/status_command_test.go +++ b/pkg/cli/status_command_test.go @@ -31,7 +31,7 @@ func TestStatusWorkflows_JSONOutput(t *testing.T) { // Test JSON output without pattern t.Run("JSON output without pattern", func(t *testing.T) { - err := StatusWorkflows("", false, true, "", "", "") + err := StatusWorkflows(t.Context(), "", false, true, "", "", "") if err != nil { t.Errorf("StatusWorkflows with JSON flag failed: %v", err) } @@ -41,7 +41,7 @@ func TestStatusWorkflows_JSONOutput(t *testing.T) { // Test JSON output with pattern t.Run("JSON output with pattern", func(t *testing.T) { - err := StatusWorkflows("smoke", false, true, "", "", "") + err := StatusWorkflows(t.Context(), "smoke", false, true, "", "", "") if err != nil { t.Errorf("StatusWorkflows with JSON flag and pattern failed: %v", err) } @@ -547,14 +547,14 @@ func TestWorkflowStatus_ConsoleRenderingWithRunStatus(t *testing.T) { func TestStatusWorkflows_WithRepoOverride(t *testing.T) { // This test verifies that the function accepts the repoOverride parameter // and doesn't error out. It should work in the current repository context. - err := StatusWorkflows("", false, true, "", "", "") + err := StatusWorkflows(t.Context(), "", false, true, "", "", "") if err != nil { t.Errorf("StatusWorkflows with empty repoOverride should not error: %v", err) } // Test with a non-empty repo override (will fail gracefully if repo doesn't exist) // We expect this to either succeed or fail gracefully without panicking - _ = StatusWorkflows("", false, true, "", "", "nonexistent/repo") + _ = StatusWorkflows(t.Context(), "", false, true, "", "", "nonexistent/repo") // Note: We don't check error here because it's expected to fail for a nonexistent repo // The important part is that the parameter is accepted and used } diff --git a/pkg/cli/status_mcp_integration_test.go b/pkg/cli/status_mcp_integration_test.go index dfc328607f5..ec7379f0ce9 100644 --- a/pkg/cli/status_mcp_integration_test.go +++ b/pkg/cli/status_mcp_integration_test.go @@ -15,7 +15,7 @@ import ( func TestGetWorkflowStatuses_MCPIntegration(t *testing.T) { // This test requires being run from the repository root // since it needs .github/workflows directory - statuses, err := GetWorkflowStatuses("", "", "", "") + statuses, err := GetWorkflowStatuses(t.Context(), "", "", "", "") // We expect either: // - No error and a valid (possibly empty) slice @@ -45,7 +45,7 @@ func TestGetWorkflowStatuses_MCPIntegration(t *testing.T) { // TestGetWorkflowStatuses_WithPattern tests filtering by pattern func TestGetWorkflowStatuses_WithPattern(t *testing.T) { // Get all statuses first - allStatuses, err := GetWorkflowStatuses("", "", "", "") + allStatuses, err := GetWorkflowStatuses(t.Context(), "", "", "", "") if err != nil { t.Skipf("Skipping test: not in a repository with workflows: %v", err) return @@ -61,7 +61,7 @@ func TestGetWorkflowStatuses_WithPattern(t *testing.T) { pattern := firstWorkflowName[:min(3, len(firstWorkflowName))] // Use first 3 chars as pattern // Get filtered statuses - filteredStatuses, err := GetWorkflowStatuses(pattern, "", "", "") + filteredStatuses, err := GetWorkflowStatuses(t.Context(), pattern, "", "", "") require.NoError(t, err, "GetWorkflowStatuses with pattern should not error") // Verify that filtered results are a subset @@ -79,7 +79,7 @@ func TestGetWorkflowStatuses_WithPattern(t *testing.T) { // TestGetWorkflowStatuses_MCPJSONStructure verifies the JSON structure func TestGetWorkflowStatuses_MCPJSONStructure(t *testing.T) { - statuses, err := GetWorkflowStatuses("", "", "", "") + statuses, err := GetWorkflowStatuses(t.Context(), "", "", "", "") if err != nil { t.Skipf("Skipping test: not in a repository with workflows: %v", err) return diff --git a/pkg/cli/status_remote_test.go b/pkg/cli/status_remote_test.go index 42cc59faec3..1f64b2fc48c 100644 --- a/pkg/cli/status_remote_test.go +++ b/pkg/cli/status_remote_test.go @@ -97,7 +97,7 @@ func TestGetWorkflowStatuses_WithRepoFlag_SkipsLocalFiles(t *testing.T) { // there is no local .github/workflows directory. The GitHub API call will // fail (no real token in tests) but that is swallowed and an empty result // is returned rather than a "no .github/workflows directory found" error. - statuses, err := GetWorkflowStatuses("", "", "", "owner/repo") + statuses, err := GetWorkflowStatuses(t.Context(), "", "", "", "owner/repo") require.NoError(t, err, "GetWorkflowStatuses with --repo should not propagate a 'missing local dir' error") // statuses may be nil (no API mock) or an empty slice; either is acceptable. _ = statuses @@ -107,7 +107,7 @@ func TestGetWorkflowStatuses_WithRepoFlag_SkipsLocalFiles(t *testing.T) { // with --repo returns a clear error, since label information is not exposed by // the GitHub Actions workflow API. func TestGetWorkflowStatuses_LabelFilterWithRepo(t *testing.T) { - _, err := GetWorkflowStatuses("", "", "my-label", "owner/repo") + _, err := GetWorkflowStatuses(t.Context(), "", "", "my-label", "owner/repo") require.Error(t, err) require.ErrorContains(t, err, "--label filter is not supported with --repo") } diff --git a/pkg/cli/workflows.go b/pkg/cli/workflows.go index ffa7af59a91..4e4f45a6c22 100644 --- a/pkg/cli/workflows.go +++ b/pkg/cli/workflows.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "bytes" + "context" "encoding/json" "errors" "fmt" @@ -68,7 +69,7 @@ type GitHubWorkflow struct { } // fetchGitHubWorkflows fetches workflow information from GitHub -func fetchGitHubWorkflows(repoOverride string, verbose bool) (map[string]*GitHubWorkflow, error) { +func fetchGitHubWorkflows(ctx context.Context, repoOverride string, verbose bool) (map[string]*GitHubWorkflow, error) { workflowsLog.Printf("Fetching GitHub workflows: repoOverride=%s", repoOverride) // Start spinner for network operation (only if not in verbose mode) @@ -81,7 +82,7 @@ func fetchGitHubWorkflows(repoOverride string, verbose bool) (map[string]*GitHub if repoOverride != "" { args = append(args, "--repo", repoOverride) } - cmd := workflow.ExecGH(args...) + cmd := workflow.ExecGHContext(ctx, args...) output, err := cmd.Output() if err != nil { @@ -176,14 +177,14 @@ func extractWorkflowNameFromPath(path string) string { } // getWorkflowStatus gets the status of a single workflow by name -func getWorkflowStatus(workflowIdOrName string, repoOverride string, verbose bool) (*GitHubWorkflow, error) { +func getWorkflowStatus(ctx context.Context, workflowIdOrName string, repoOverride string, verbose bool) (*GitHubWorkflow, error) { workflowsLog.Printf("Getting workflow status: workflow=%s", workflowIdOrName) // Extract workflow name for lookup filename := normalizeWorkflowID(workflowIdOrName) // Get all GitHub workflows - githubWorkflows, err := fetchGitHubWorkflows(repoOverride, verbose) + githubWorkflows, err := fetchGitHubWorkflows(ctx, repoOverride, verbose) if err != nil { return nil, fmt.Errorf("failed to fetch GitHub workflows: %w", err) } diff --git a/pkg/cli/workflows_test.go b/pkg/cli/workflows_test.go index b96e611dded..3372371ac71 100644 --- a/pkg/cli/workflows_test.go +++ b/pkg/cli/workflows_test.go @@ -3,14 +3,113 @@ package cli import ( + "context" + "errors" + "fmt" "os" + "os/exec" "path/filepath" + "runtime" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// TestHelperProcess is the subprocess entry point for fake-gh tests. +// It is invoked by the test binary itself (via os.Executable) when +// GO_TEST_HELPER_PROCESS=1 is set, so we can provide a blocking "gh" +// that hangs indefinitely until killed by context cancellation. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_TEST_HELPER_PROCESS") != "1" { + return + } + // Block forever — the parent will kill us via context cancellation. + select {} +} + +// makeFakeGH writes a tiny fake "gh" executable into dir that just +// re-invokes the current test binary in TestHelperProcess mode. +func makeFakeGH(t *testing.T, dir string) { + t.Helper() + self, err := os.Executable() + require.NoError(t, err) + + if runtime.GOOS == "windows" { + // .bat wrapper that re-runs the test binary in helper mode + script := fmt.Sprintf("@echo off\r\n\"%s\" -test.run=TestHelperProcess -test.v 2>nul\r\n", self) + err = os.WriteFile(filepath.Join(dir, "gh.bat"), []byte(script), 0o755) + } else { + script := fmt.Sprintf("#!/bin/sh\nGO_TEST_HELPER_PROCESS=1 exec \"%s\" -test.run=TestHelperProcess -test.v \"$@\"\n", self) + err = os.WriteFile(filepath.Join(dir, "gh"), []byte(script), 0o755) + } + require.NoError(t, err) +} + +// withFakeBlockingGH prepends dir to PATH so that workflow.ExecGHContext +// picks up the blocking fake gh instead of the real one. +func withFakeBlockingGH(t *testing.T) { + t.Helper() + dir := t.TempDir() + makeFakeGH(t, dir) + + orig := os.Getenv("PATH") + t.Setenv("PATH", dir+string(os.PathListSeparator)+orig) + + // Also clear GH_TOKEN / GITHUB_TOKEN so ExecGHContext doesn't try to + // set up authentication (which requires the real gh binary). + t.Setenv("GH_TOKEN", "") + t.Setenv("GITHUB_TOKEN", "") +} + +// TestFetchGitHubWorkflows_ContextCancellation verifies that cancelling the +// context while the gh subprocess is blocked causes fetchGitHubWorkflows to +// return promptly with a context error. +func TestFetchGitHubWorkflows_ContextCancellation(t *testing.T) { + if _, err := exec.LookPath("sh"); runtime.GOOS != "windows" && err != nil { + t.Skip("sh not available") + } + + withFakeBlockingGH(t) + + ctx, cancel := context.WithCancel(context.Background()) + // Cancel immediately so the subprocess is killed as soon as it starts. + cancel() + + start := time.Now() + _, err := fetchGitHubWorkflows(ctx, "", false) + elapsed := time.Since(start) + + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), + "expected context error, got: %v", err) + assert.Less(t, elapsed, 5*time.Second, "fetchGitHubWorkflows should return promptly on cancellation") +} + +// TestFetchLatestRunsByRef_ContextCancellation verifies that cancelling the +// context while the gh subprocess (run list) is blocked causes +// fetchLatestRunsByRef to return promptly with a context error. +func TestFetchLatestRunsByRef_ContextCancellation(t *testing.T) { + if _, err := exec.LookPath("sh"); runtime.GOOS != "windows" && err != nil { + t.Skip("sh not available") + } + + withFakeBlockingGH(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + _, err := fetchLatestRunsByRef(ctx, "main", "", false) + elapsed := time.Since(start) + + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded), + "expected context error, got: %v", err) + assert.Less(t, elapsed, 5*time.Second, "fetchLatestRunsByRef should return promptly on cancellation") +} + func TestIsWorkflowFile(t *testing.T) { tests := []struct { name string