diff --git a/pkg/cli/audit_report.go b/pkg/cli/audit_report.go index 284a4912699..ed14d79e7e5 100644 --- a/pkg/cli/audit_report.go +++ b/pkg/cli/audit_report.go @@ -115,10 +115,18 @@ type MetricsData struct { // JobData contains information about individual jobs type JobData struct { - Name string `json:"name" console:"header:Name"` - Status string `json:"status" console:"header:Status"` - Conclusion string `json:"conclusion,omitempty" console:"header:Conclusion,omitempty"` - Duration string `json:"duration,omitempty" console:"header:Duration,omitempty"` + Name string `json:"name" console:"header:Name"` + Status string `json:"status" console:"header:Status"` + Conclusion string `json:"conclusion,omitempty" console:"header:Conclusion,omitempty"` + Duration string `json:"duration,omitempty" console:"header:Duration,omitempty"` + Steps []JobStepData `json:"steps,omitempty"` +} + +// JobStepData contains information about an individual workflow job step. +type JobStepData struct { + Name string `json:"name"` + Status string `json:"status,omitempty"` + Conclusion string `json:"conclusion,omitempty"` } // FileInfo contains information about downloaded artifact files @@ -351,6 +359,9 @@ func buildAuditData(processedRun ProcessedRun, metrics LogMetrics, mcpToolUsage Name: jobDetail.Name, Status: jobDetail.Status, Conclusion: jobDetail.Conclusion, + Steps: sliceutil.Map(jobDetail.Steps, func(step JobStep) JobStepData { + return JobStepData(step) + }), } if jobDetail.Duration > 0 { job.Duration = timeutil.FormatDuration(jobDetail.Duration) diff --git a/pkg/cli/audit_report_test.go b/pkg/cli/audit_report_test.go index 31516dd97d3..6a76cbf85c2 100644 --- a/pkg/cli/audit_report_test.go +++ b/pkg/cli/audit_report_test.go @@ -686,8 +686,30 @@ func TestBuildAuditDataComplete(t *testing.T) { LogsPath: tmpDir, }, JobDetails: []JobInfoWithDuration{ - {JobInfo: JobInfo{Name: "build", Status: "completed", Conclusion: "success"}, Duration: 2 * time.Minute}, - {JobInfo: JobInfo{Name: "test", Status: "completed", Conclusion: "failure"}, Duration: 5 * time.Minute}, + { + JobInfo: JobInfo{ + Name: "build", + Status: "completed", + Conclusion: "success", + Steps: []JobStep{ + {Name: "Set up job", Status: "completed", Conclusion: "success"}, + {Name: "Compile", Status: "completed", Conclusion: "success"}, + }, + }, + Duration: 2 * time.Minute, + }, + { + JobInfo: JobInfo{ + Name: "test", + Status: "completed", + Conclusion: "failure", + Steps: []JobStep{ + {Name: "Set up job", Status: "completed", Conclusion: "success"}, + {Name: "Run tests", Status: "completed", Conclusion: "failure"}, + }, + }, + Duration: 5 * time.Minute, + }, }, MissingTools: []MissingToolReport{ {Tool: "special_tool", Reason: "Not configured"}, @@ -752,6 +774,12 @@ func TestBuildAuditDataComplete(t *testing.T) { t.Run("Jobs", func(t *testing.T) { assert.Len(t, auditData.Jobs, 2, "Should have 2 jobs") + assert.Len(t, auditData.Jobs[1].Steps, 2, + "Should preserve step details for each job") + assert.Equal(t, "Run tests", auditData.Jobs[1].Steps[1].Name, + "Should preserve step names") + assert.Equal(t, "failure", auditData.Jobs[1].Steps[1].Conclusion, + "Should preserve step conclusions") }) // Verify tool usage @@ -895,7 +923,16 @@ func TestRenderJSONComplete(t *testing.T) { {Priority: "low", Action: "Monitor", Reason: "Test reason"}, }, Jobs: []JobData{ - {Name: "test-job", Status: "completed", Conclusion: "success", Duration: "1m30s"}, + { + Name: "test-job", + Status: "completed", + Conclusion: "success", + Duration: "1m30s", + Steps: []JobStepData{ + {Name: "Set up job", Status: "completed", Conclusion: "success"}, + {Name: "Run agent", Status: "completed", Conclusion: "success"}, + }, + }, }, DownloadedFiles: []FileInfo{ {Path: "test.log", Size: 1024, Description: "Test log"}, @@ -943,6 +980,10 @@ func TestRenderJSONComplete(t *testing.T) { "Recommendations should be preserved in JSON") assert.Len(t, parsed.Jobs, 1, "Jobs should be preserved in JSON") + assert.Len(t, parsed.Jobs[0].Steps, 2, + "Job steps should be preserved in JSON") + assert.Equal(t, "Run agent", parsed.Jobs[0].Steps[1].Name, + "Job step names should be preserved in JSON") assert.Len(t, parsed.Errors, 1, "Errors should be preserved in JSON") assert.Len(t, parsed.Warnings, 2, diff --git a/pkg/cli/logs_github_api.go b/pkg/cli/logs_github_api.go index d7a6079e74b..7994475e50a 100644 --- a/pkg/cli/logs_github_api.go +++ b/pkg/cli/logs_github_api.go @@ -84,7 +84,7 @@ func fetchJobDetailsWithCounts(runID int64, verbose bool) ([]JobInfoWithDuration output, err := workflow.RunGHCombined("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}") + "--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 { if verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Failed to fetch job details for run %d: %v", runID, err))) diff --git a/pkg/cli/logs_github_api_test.go b/pkg/cli/logs_github_api_test.go index 5fe6784cfd5..8fc68bed793 100644 --- a/pkg/cli/logs_github_api_test.go +++ b/pkg/cli/logs_github_api_test.go @@ -4,9 +4,13 @@ package cli import ( "encoding/json" + "os" + "path/filepath" "strings" "testing" + "time" + "github.com/github/gh-aw/pkg/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -222,6 +226,60 @@ func TestListWorkflowRunsErrorHandling(t *testing.T) { } } +func TestFetchJobDetailsWithCountsIncludesSteps(t *testing.T) { + fakeBinDir := testutil.TempDir(t, "fake-gh-*") + fakeGH := filepath.Join(fakeBinDir, "gh") + argsLogPath := filepath.Join(fakeBinDir, "gh-args.log") + fakeGHScript := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + argsLogPath + "\"\n" + + "cat <<'EOF'\n" + + "{\"name\":\"agent\",\"status\":\"completed\",\"conclusion\":\"failure\",\"started_at\":\"2026-06-28T01:31:00Z\",\"completed_at\":\"2026-06-28T01:33:00Z\",\"steps\":[{\"name\":\"Set up job\",\"status\":\"completed\",\"conclusion\":\"success\"},{\"name\":\"Run agent\",\"status\":\"completed\",\"conclusion\":\"failure\"}]}\n" + + "EOF\n" + require.NoError(t, os.WriteFile(fakeGH, []byte(fakeGHScript), 0o755)) + + t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + jobs, failedJobs, err := fetchJobDetailsWithCounts(28307653871, false) + require.NoError(t, err) + require.Len(t, jobs, 1) + assert.Equal(t, 1, failedJobs, "failed job count should include failed jobs") + assert.Equal(t, 2*time.Minute, jobs[0].Duration, "job duration should still be derived from timestamps") + require.Len(t, jobs[0].Steps, 2) + assert.Equal(t, "Run agent", jobs[0].Steps[1].Name, "step names should be parsed from gh api output") + assert.Equal(t, "failure", jobs[0].Steps[1].Conclusion, "step conclusions should be parsed from gh api output") + + argsLog, err := os.ReadFile(argsLogPath) + require.NoError(t, err) + assert.Contains(t, string(argsLog), "repos/{owner}/{repo}/actions/runs/28307653871/jobs", "should query the run jobs API") + assert.Contains(t, string(argsLog), "steps:", "gh jq projection should request step data") +} + +// TestFetchJobDetailsWithCountsNullConclusion verifies that jobs and steps with null conclusions +// (e.g. in-progress or queued jobs) are still parsed and not silently dropped. The jq projection +// uses (.conclusion // "") to coerce null to empty string before JSON decoding. +func TestFetchJobDetailsWithCountsNullConclusion(t *testing.T) { + fakeBinDir := testutil.TempDir(t, "fake-gh-*") + fakeGH := filepath.Join(fakeBinDir, "gh") + // Simulate jq coercing null -> "" before output (as (.conclusion // "") does). + // A job still in progress has conclusion="" for itself and for any pending steps. + fakeGHScript := "#!/bin/sh\n" + + "cat <<'EOF'\n" + + "{\"name\":\"agent\",\"status\":\"in_progress\",\"conclusion\":\"\",\"started_at\":\"2026-06-28T01:31:00Z\",\"completed_at\":\"0001-01-01T00:00:00Z\",\"steps\":[{\"name\":\"Set up job\",\"status\":\"completed\",\"conclusion\":\"success\"},{\"name\":\"Run agent\",\"status\":\"in_progress\",\"conclusion\":\"\"}]}\n" + + "EOF\n" + require.NoError(t, os.WriteFile(fakeGH, []byte(fakeGHScript), 0o755)) + + t.Setenv("PATH", fakeBinDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + jobs, failedJobs, err := fetchJobDetailsWithCounts(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") + assert.Equal(t, "in_progress", jobs[0].Status) + assert.Empty(t, jobs[0].Conclusion, "null conclusion should be coerced to empty string") + require.Len(t, jobs[0].Steps, 2) + assert.Empty(t, jobs[0].Steps[1].Conclusion, "null step conclusion should be coerced to empty string") +} + func TestWorkflowRunsSpinnerMessage(t *testing.T) { tests := []struct { name string diff --git a/pkg/cli/logs_models.go b/pkg/cli/logs_models.go index 8a959b3769e..f19bc1a2a4e 100644 --- a/pkg/cli/logs_models.go +++ b/pkg/cli/logs_models.go @@ -261,6 +261,14 @@ type JobInfo struct { Conclusion string `json:"conclusion"` StartedAt time.Time `json:"started_at,omitzero"` CompletedAt time.Time `json:"completed_at,omitzero"` + Steps []JobStep `json:"steps,omitempty"` +} + +// JobStep represents basic information about an individual workflow job step. +type JobStep struct { + Name string `json:"name"` + Status string `json:"status,omitempty"` + Conclusion string `json:"conclusion,omitempty"` } // JobInfoWithDuration extends JobInfo with calculated duration