-
Notifications
You must be signed in to change notification settings - Fork 435
Populate audit job steps in structured audit output #42222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"` | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/zoom-out] The two structs have the same three fields. 💡 Simpler alternative// In audit_report.go — eliminate the duplicate struct entirely
type JobStepData = JobStep // type alias, no conversion neededThen the mapping in Steps: slices.Clone(jobDetail.Steps),If the two types do need to diverge in the future (e.g., console tags, extra audit-only fields), switching back to a concrete struct is a one-line change. @copilot please address this. |
||
|
|
||
| // 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The direct struct conversion One subtle side-effect:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Brittle named-struct cast will surprise future maintainers: 💡 Suggested fixUse explicit field mapping instead: Steps: sliceutil.Map(jobDetail.Steps, func(step JobStep) JobStepData {
return JobStepData{Name: step.Name, Status: step.Status, Conclusion: step.Conclusion}
}),This makes the intent clear — only these three fields flow from the internal API model to the audit output model — and compiles cleanly even if either struct gains fields independently (e.g., |
||
| }), | ||
| } | ||
| if jobDetail.Duration > 0 { | ||
| job.Duration = timeutil.FormatDuration(jobDetail.Duration) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The assertion confirms the jq projection mentions @copilot please address this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 💡 What this meansThe test verifies Go-side parsing of step data — which is correct and valuable. But the actual jq expression in Consider documenting this known gap in a comment, or strengthening the assertion to check for a more specific substring of the jq expression (e.g., assert.Contains(t, string(argsLog), ".steps // []", "jq must use .steps with empty-array fallback") |
||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Missing edge-case test: job with null or absent The guard ensures API responses without a 💡 Suggested additionAdd a second sub-test (or table-driven variant) feeding output without a // Verify a job with no steps field round-trips without error
assert.Empty(t, jobs[0].Steps, "job with no steps field should produce empty Steps slice")This documents the contract of @copilot please address this. |
||
|
|
||
| // 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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/zoom-out]
Stepsfield has noconsolestruct tag — steps will be silently invisible in console (tabular) rendering.Every other field in
JobDatacarries aconsole:"header:..."tag. Omitting it fromStepsis probably intentional (nested slices are hard to render in a table), but leaving it undocumented is a subtle inconsistency.💡 Suggestion
Add an explicit opt-out tag so the intent is clear to future maintainers:
The
console:"-"pattern is already used in this file (seeAwContext) for fields that should be excluded from table output.@copilot please address this.