diff --git a/pkg/cli/audit.go b/pkg/cli/audit.go index 20691c759f7..0fd05c29e32 100644 --- a/pkg/cli/audit.go +++ b/pkg/cli/audit.go @@ -377,7 +377,7 @@ func AuditWorkflowRun(ctx context.Context, runID int64, opts AuditOptions) error if err != nil { return err } - results := collectAuditAnalysisResults(run, cfg.outputDir, cfg.verbose, artifactMatchesFilter(constants.AgentArtifactName, cfg.artifactFilter)) + results := collectAuditAnalysisResults(ctx, run, cfg.outputDir, cfg.verbose, artifactMatchesFilter(constants.AgentArtifactName, cfg.artifactFilter)) run = applyAuditMetrics(run, results) processedRun := buildProcessedAuditRun(run, results) saveAuditRunSummary(cfg.outputDir, run, processedRun, results, cfg.verbose) @@ -660,10 +660,10 @@ func prepareRunForAnalysis(run WorkflowRun, cfg auditRunConfig, useLocalCache bo return run } -func collectAuditAnalysisResults(run WorkflowRun, runOutputDir string, verbose bool, hasFirewallArtifact bool) auditAnalysisResults { +func collectAuditAnalysisResults(ctx context.Context, run WorkflowRun, runOutputDir string, verbose bool, hasFirewallArtifact bool) auditAnalysisResults { results := auditAnalysisResults{} var wg sync.WaitGroup - launchCoreAuditAnalyses(&wg, &results, run, runOutputDir, verbose) + launchCoreAuditAnalyses(ctx, &wg, &results, run, runOutputDir, verbose) if hasFirewallArtifact { launchFirewallAuditAnalyses(&wg, &results, runOutputDir, verbose) } @@ -673,13 +673,13 @@ func collectAuditAnalysisResults(run WorkflowRun, runOutputDir string, verbose b } // launchCoreAuditAnalyses exclusively writes missingTools, missingData, noops, mcpFailures, and accessAnalysis. -func launchCoreAuditAnalyses(wg *sync.WaitGroup, results *auditAnalysisResults, run WorkflowRun, runOutputDir string, verbose bool) { +func launchCoreAuditAnalyses(ctx context.Context, wg *sync.WaitGroup, results *auditAnalysisResults, run WorkflowRun, runOutputDir string, verbose bool) { // Resolve experiment assignment once so all goroutines reuse the same values // rather than each reading state.json independently. expName, expVariant, _ := firstExperimentAssignment(extractExperimentData(runOutputDir)) launchMetricsAnalysis(wg, results, runOutputDir, verbose, run.WorkflowPath) - launchJobDetailsAnalysis(wg, results, run.DatabaseID, verbose) + launchJobDetailsAnalysis(ctx, wg, results, run.DatabaseID, verbose) runAuditAnalysis(wg, verbose, "extractMissingToolsFromRun", "Failed to extract missing tools", func(v []MissingToolReport) { results.missingTools = v }, func() ([]MissingToolReport, error) { @@ -723,9 +723,9 @@ func launchMetricsAnalysis(wg *sync.WaitGroup, results *auditAnalysisResults, ru } // launchJobDetailsAnalysis exclusively writes results.jobDetails and results.failedJobCount. -func launchJobDetailsAnalysis(wg *sync.WaitGroup, results *auditAnalysisResults, runID int64, verbose bool) { +func launchJobDetailsAnalysis(ctx context.Context, wg *sync.WaitGroup, results *auditAnalysisResults, runID int64, verbose bool) { wg.Go(func() { - jobDetails, failedJobCount, err := fetchJobDetailsWithCounts(runID, verbose) + jobDetails, failedJobCount, err := fetchJobDetailsWithCounts(ctx, runID, verbose) if err != nil { auditLog.Printf("fetchJobDetailsWithCounts failed: %v", err) if verbose { diff --git a/pkg/cli/logs_github_api.go b/pkg/cli/logs_github_api.go index 582d2be9576..d9aa161f01a 100644 --- a/pkg/cli/logs_github_api.go +++ b/pkg/cli/logs_github_api.go @@ -76,13 +76,13 @@ func buildCreatedFilter(startDate, endDate, beforeDate string) string { // call and returns the full detail slice together with the count of failed jobs. // It is the single source of truth for the jobs endpoint; fetchJobDetails and // fetchJobStatuses are thin wrappers that each return only the value they need. -func fetchJobDetailsWithCounts(runID int64, verbose bool) ([]JobInfoWithDuration, int, error) { +func fetchJobDetailsWithCounts(ctx context.Context, runID int64, verbose bool) ([]JobInfoWithDuration, int, error) { logsGitHubAPILog.Printf("Fetching job details: runID=%d", runID) if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching job details for run %d", runID))) } - output, err := workflow.RunGHCombined("Fetching job details...", "api", + output, err := workflow.RunGHCombinedContext(ctx, "Fetching job details...", "api", fmt.Sprintf("repos/{owner}/{repo}/actions/runs/%d/jobs", runID), "--jq", ".jobs[] | {name: .name, status: .status, conclusion: (.conclusion // \"\"), started_at: .started_at, completed_at: .completed_at, steps: ((.steps // []) | map({name: .name, status: .status, conclusion: (.conclusion // \"\")}))}") if err != nil { @@ -130,8 +130,8 @@ func fetchJobDetailsWithCounts(runID int64, verbose bool) ([]JobInfoWithDuration // fetchJobDetails gets detailed job information including durations for a workflow run. // Errors from the underlying API call are suppressed so that callers can continue // processing even when job data is unavailable (e.g. missing permissions). -func fetchJobDetails(runID int64, verbose bool) ([]JobInfoWithDuration, error) { - jobs, _, err := fetchJobDetailsWithCounts(runID, verbose) +func fetchJobDetails(ctx context.Context, runID int64, verbose bool) ([]JobInfoWithDuration, error) { + jobs, _, err := fetchJobDetailsWithCounts(ctx, runID, verbose) if err != nil { // Don't fail the entire operation if we can't get job info return nil, nil @@ -142,8 +142,8 @@ func fetchJobDetails(runID int64, verbose bool) ([]JobInfoWithDuration, error) { // fetchJobStatuses gets the count of failed jobs for a workflow run. // Errors from the underlying API call are suppressed so that callers can continue // processing even when job data is unavailable (e.g. missing permissions). -func fetchJobStatuses(runID int64, verbose bool) (int, error) { - _, failedJobs, err := fetchJobDetailsWithCounts(runID, verbose) +func fetchJobStatuses(ctx context.Context, runID int64, verbose bool) (int, error) { + _, failedJobs, err := fetchJobDetailsWithCounts(ctx, runID, verbose) if err != nil { // Don't fail the entire operation if we can't get job info return 0, nil diff --git a/pkg/cli/logs_github_api_test.go b/pkg/cli/logs_github_api_test.go index 8fc68bed793..8dd40b00767 100644 --- a/pkg/cli/logs_github_api_test.go +++ b/pkg/cli/logs_github_api_test.go @@ -3,6 +3,7 @@ package cli import ( + "context" "encoding/json" "os" "path/filepath" @@ -239,7 +240,7 @@ func TestFetchJobDetailsWithCountsIncludesSteps(t *testing.T) { t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH")) - jobs, failedJobs, err := fetchJobDetailsWithCounts(28307653871, false) + jobs, failedJobs, err := fetchJobDetailsWithCounts(context.Background(), 28307653871, false) require.NoError(t, err) require.Len(t, jobs, 1) assert.Equal(t, 1, failedJobs, "failed job count should include failed jobs") @@ -270,7 +271,7 @@ func TestFetchJobDetailsWithCountsNullConclusion(t *testing.T) { t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH")) - jobs, failedJobs, err := fetchJobDetailsWithCounts(28307653871, false) + jobs, failedJobs, err := fetchJobDetailsWithCounts(context.Background(), 28307653871, false) require.NoError(t, err) require.Len(t, jobs, 1, "in-progress jobs with null conclusion should not be dropped") assert.Equal(t, 0, failedJobs, "in-progress job should not count as failed") diff --git a/pkg/cli/logs_orchestrator_filters.go b/pkg/cli/logs_orchestrator_filters.go index 195d3dac165..62c285cf030 100644 --- a/pkg/cli/logs_orchestrator_filters.go +++ b/pkg/cli/logs_orchestrator_filters.go @@ -164,7 +164,7 @@ func buildProcessedRun(result DownloadResult, verbose, logFailedJobs bool) Proce } // Add failed jobs to error count. - if failedJobCount, err := fetchJobStatusesForProcessedRun(run.DatabaseID, verbose); err == nil { + if failedJobCount, err := fetchJobStatusesForProcessedRun(context.Background(), run.DatabaseID, verbose); err == nil { run.ErrorCount += failedJobCount if verbose && logFailedJobs && failedJobCount > 0 { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Added %d failed jobs to error count for run %d", failedJobCount, run.DatabaseID))) diff --git a/pkg/cli/logs_orchestrator_filters_test.go b/pkg/cli/logs_orchestrator_filters_test.go index 11ea4dba877..285eee2e652 100644 --- a/pkg/cli/logs_orchestrator_filters_test.go +++ b/pkg/cli/logs_orchestrator_filters_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func stubFetchJobStatusesForProcessedRun(t *testing.T, fn func(int64, bool) (int, error)) { +func stubFetchJobStatusesForProcessedRun(t *testing.T, fn func(context.Context, int64, bool) (int, error)) { t.Helper() previous := fetchJobStatusesForProcessedRun fetchJobStatusesForProcessedRun = fn @@ -211,7 +211,7 @@ func TestApplyRunFilters_FilteredIntegrity(t *testing.T) { // the ProcessedRun fields from a DownloadResult. func TestBuildProcessedRun(t *testing.T) { t.Run("basic fields are propagated", func(t *testing.T) { - stubFetchJobStatusesForProcessedRun(t, func(int64, bool) (int, error) { return 0, nil }) + stubFetchJobStatusesForProcessedRun(t, func(context.Context, int64, bool) (int, error) { return 0, nil }) now := time.Now() tmpDir := t.TempDir() awCtx := &AwContext{Repo: "owner/repo"} @@ -235,7 +235,7 @@ func TestBuildProcessedRun(t *testing.T) { }) t.Run("duration and action minutes are computed", func(t *testing.T) { - stubFetchJobStatusesForProcessedRun(t, func(int64, bool) (int, error) { return 0, nil }) + stubFetchJobStatusesForProcessedRun(t, func(context.Context, int64, bool) (int, error) { return 0, nil }) base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) result := DownloadResult{ Run: WorkflowRun{ @@ -253,7 +253,7 @@ func TestBuildProcessedRun(t *testing.T) { }) t.Run("zero timestamps leave duration unset", func(t *testing.T) { - stubFetchJobStatusesForProcessedRun(t, func(int64, bool) (int, error) { return 0, nil }) + stubFetchJobStatusesForProcessedRun(t, func(context.Context, int64, bool) (int, error) { return 0, nil }) result := DownloadResult{ Run: WorkflowRun{DatabaseID: 7}, LogsPath: t.TempDir(), @@ -264,7 +264,7 @@ func TestBuildProcessedRun(t *testing.T) { }) t.Run("effective tokens are propagated", func(t *testing.T) { - stubFetchJobStatusesForProcessedRun(t, func(int64, bool) (int, error) { return 0, nil }) + stubFetchJobStatusesForProcessedRun(t, func(context.Context, int64, bool) (int, error) { return 0, nil }) usage := &TokenUsageSummary{TotalEffectiveTokens: 5000} result := DownloadResult{ Run: WorkflowRun{DatabaseID: 3}, @@ -276,7 +276,7 @@ func TestBuildProcessedRun(t *testing.T) { }) t.Run("zero effective tokens not propagated", func(t *testing.T) { - stubFetchJobStatusesForProcessedRun(t, func(int64, bool) (int, error) { return 0, nil }) + stubFetchJobStatusesForProcessedRun(t, func(context.Context, int64, bool) (int, error) { return 0, nil }) usage := &TokenUsageSummary{TotalEffectiveTokens: 0} result := DownloadResult{ Run: WorkflowRun{DatabaseID: 4}, @@ -288,7 +288,7 @@ func TestBuildProcessedRun(t *testing.T) { }) t.Run("failed job count is added via test seam", func(t *testing.T) { - stubFetchJobStatusesForProcessedRun(t, func(runID int64, verbose bool) (int, error) { + stubFetchJobStatusesForProcessedRun(t, func(_ context.Context, runID int64, verbose bool) (int, error) { assert.Equal(t, int64(88), runID) assert.False(t, verbose) return 2, nil diff --git a/pkg/cli/logs_run_processor.go b/pkg/cli/logs_run_processor.go index ac3a18b1c2a..960e3e9e088 100644 --- a/pkg/cli/logs_run_processor.go +++ b/pkg/cli/logs_run_processor.go @@ -231,7 +231,7 @@ func processSingleRunDownload( if params.evalsArtifactRequested && !runHasEvals(runOutputDir, params.verbose) { tryDownloadEvalsArtifactFallback(ctx, run.DatabaseID, runOutputDir, perRunParams) } - analyzeRunArtifacts(result, runOutputDir, params.verbose, params.artifactFilter) + analyzeRunArtifacts(ctx, result, runOutputDir, params.verbose, params.artifactFilter) } } else { logsOrchestratorLog.Printf("Cache hit for run %d, using cached summary", run.DatabaseID) @@ -329,7 +329,7 @@ func tryLoadCachedRunResult( // analyzeRunArtifacts populates a DownloadResult with all analysis data derived from // freshly-downloaded artifacts in runOutputDir. Called only when the download succeeded // and no valid cached summary was found. -func analyzeRunArtifacts(result *DownloadResult, runOutputDir string, verbose bool, artifactFilter []string) { +func analyzeRunArtifacts(ctx context.Context, result *DownloadResult, runOutputDir string, verbose bool, artifactFilter []string) { metrics := extractRunMetricsAndMetadata(result, runOutputDir, verbose) usageActivitySummary, usageActivityErr := loadUsageActivitySummary(runOutputDir) @@ -350,7 +350,7 @@ func analyzeRunArtifacts(result *DownloadResult, runOutputDir string, verbose bo applyRunUsageMetrics(result, &metrics, runOutputDir, verbose, usageActivitySummary, hasFirewallArtifact) - finalizeAndSaveRunSummary(result, runOutputDir, metrics, verbose) + finalizeAndSaveRunSummary(ctx, result, runOutputDir, metrics, verbose) } // extractRunMetricsAndMetadata extracts log metrics, infers missing workflow path, and @@ -488,8 +488,8 @@ func applyRunUsageMetrics(result *DownloadResult, metrics *LogMetrics, runOutput // finalizeAndSaveRunSummary fetches job details, derives the agentic analysis, builds the // RunSummary struct, and writes it to disk. It also sets the agentic-analysis fields on // result directly so they are available to the caller. -func finalizeAndSaveRunSummary(result *DownloadResult, runOutputDir string, metrics LogMetrics, verbose bool) { - jobDetails, jobErr := fetchJobDetails(result.Run.DatabaseID, verbose) +func finalizeAndSaveRunSummary(ctx context.Context, result *DownloadResult, runOutputDir string, metrics LogMetrics, verbose bool) { + jobDetails, jobErr := fetchJobDetails(ctx, result.Run.DatabaseID, verbose) if jobErr != nil && verbose { fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", result.Run.DatabaseID, jobErr))) } else {