@@ -16,12 +16,14 @@ import (
1616 "fmt"
1717 "os"
1818 "os/exec"
19+ "path/filepath"
1920 "slices"
2021 "strconv"
2122 "strings"
2223 "time"
2324
2425 "github.com/github/gh-aw/pkg/console"
26+ "github.com/github/gh-aw/pkg/constants"
2527 "github.com/github/gh-aw/pkg/logger"
2628 "github.com/github/gh-aw/pkg/workflow"
2729)
@@ -76,63 +78,121 @@ func buildCreatedFilter(startDate, endDate, beforeDate string) string {
7678// call and returns the full detail slice together with the count of failed jobs.
7779// It is the single source of truth for the jobs endpoint; fetchJobDetails and
7880// fetchJobStatuses are thin wrappers that each return only the value they need.
79- func fetchJobDetailsWithCounts (ctx context.Context , runID int64 , verbose bool ) ([]JobInfoWithDuration , int , error ) {
81+ func fetchJobDetailsWithCounts (ctx context.Context , runID int64 , outputDir string , verbose bool ) ([]JobInfoWithDuration , int , error ) {
8082 logsGitHubAPILog .Printf ("Fetching job details: runID=%d" , runID )
8183 if verbose {
8284 fmt .Fprintln (os .Stderr , console .FormatVerboseMessage (fmt .Sprintf ("Fetching job details for run %d" , runID )))
8385 }
8486
8587 output , err := workflow .RunGHCombinedContext (ctx , "Fetching job details..." , "api" ,
86- fmt .Sprintf ("repos/{owner}/{repo}/actions/runs/%d/jobs" , runID ),
87- "--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 // \" \" )}))} " )
88+ fmt .Sprintf ("repos/{owner}/{repo}/actions/runs/%d/jobs?per_page=100 " , runID ),
89+ "--paginate " , "--slurp " )
8890 if err != nil {
8991 if verbose {
9092 fmt .Fprintln (os .Stderr , console .FormatVerboseMessage (fmt .Sprintf ("Failed to fetch job details for run %d: %v" , runID , err )))
9193 }
9294 return nil , 0 , err
9395 }
9496
95- var jobs []JobInfoWithDuration
96- failedJobs := 0
97- lines := strings .SplitSeq (strings .TrimSpace (string (output )), "\n " )
98- for line := range lines {
99- if strings .TrimSpace (line ) == "" {
100- continue
101- }
97+ var responses []struct {
98+ Jobs []json.RawMessage `json:"jobs"`
99+ }
100+ if err := json .Unmarshal (output , & responses ); err != nil {
101+ return nil , 0 , fmt .Errorf ("failed to parse jobs API response: %w" , err )
102+ }
102103
103- var job JobInfo
104- if err := json .Unmarshal ([]byte (line ), & job ); err != nil {
105- if verbose {
106- fmt .Fprintln (os .Stderr , console .FormatVerboseMessage ("Failed to parse job info: " + line ))
104+ jobs := []JobInfoWithDuration {}
105+ failedJobs := 0
106+ for _ , response := range responses {
107+ for _ , rawJob := range response .Jobs {
108+ var job JobInfo
109+ if err := json .Unmarshal (rawJob , & job ); err != nil {
110+ logsGitHubAPILog .Printf ("Skipping malformed job in run %d: %v" , runID , err )
111+ continue
112+ }
113+ jobWithDuration := JobInfoWithDuration {JobInfo : job }
114+ if ! job .StartedAt .IsZero () && ! job .CompletedAt .IsZero () {
115+ jobWithDuration .Duration = job .CompletedAt .Sub (job .StartedAt )
116+ }
117+ jobs = append (jobs , jobWithDuration )
118+
119+ if isFailureConclusion (job .Conclusion ) {
120+ failedJobs ++
121+ logsGitHubAPILog .Printf ("Found failed job: name=%s, conclusion=%s" , job .Name , job .Conclusion )
122+ if verbose {
123+ fmt .Fprintln (os .Stderr , console .FormatVerboseMessage (fmt .Sprintf ("Found failed job '%s' with conclusion '%s'" , job .Name , job .Conclusion )))
124+ }
107125 }
108- continue
109- }
110-
111- jobWithDuration := JobInfoWithDuration {JobInfo : job }
112- if ! job .StartedAt .IsZero () && ! job .CompletedAt .IsZero () {
113- jobWithDuration .Duration = job .CompletedAt .Sub (job .StartedAt )
114126 }
115- jobs = append ( jobs , jobWithDuration )
127+ }
116128
117- if isFailureConclusion (job .Conclusion ) {
118- failedJobs ++
119- logsGitHubAPILog .Printf ("Found failed job: name=%s, conclusion=%s" , job .Name , job .Conclusion )
120- if verbose {
121- fmt .Fprintln (os .Stderr , console .FormatVerboseMessage (fmt .Sprintf ("Found failed job '%s' with conclusion '%s'" , job .Name , job .Conclusion )))
122- }
129+ if outputDir != "" {
130+ responsePath := filepath .Join (outputDir , jobsAPIResponseFileName )
131+ if err := writeSensitiveFile (responsePath , output ); err != nil {
132+ return jobs , failedJobs , & jobDetailsCacheError {err : fmt .Errorf ("failed to cache jobs API response: %w" , err )}
123133 }
134+ logsGitHubAPILog .Printf ("Cached jobs API response: path=%s" , responsePath )
124135 }
125136
126137 logsGitHubAPILog .Printf ("Job fetch complete: total=%d failed=%d" , len (jobs ), failedJobs )
127138 return jobs , failedJobs , nil
128139}
129140
141+ type jobDetailsCacheError struct {
142+ err error
143+ }
144+
145+ func (e * jobDetailsCacheError ) Error () string {
146+ return e .err .Error ()
147+ }
148+
149+ func (e * jobDetailsCacheError ) Unwrap () error {
150+ return e .err
151+ }
152+
153+ func writeSensitiveFile (path string , data []byte ) (err error ) {
154+ file , err := os .CreateTemp (filepath .Dir (path ), "." + filepath .Base (path )+ ".tmp-*" )
155+ if err != nil {
156+ return err
157+ }
158+ tempPath := file .Name ()
159+ closed := false
160+ defer func () {
161+ if ! closed {
162+ if closeErr := file .Close (); err == nil {
163+ err = closeErr
164+ }
165+ }
166+ if removeErr := os .Remove (tempPath ); err == nil && removeErr != nil && ! errors .Is (removeErr , os .ErrNotExist ) {
167+ err = removeErr
168+ }
169+ }()
170+ if err := file .Chmod (constants .FilePermSensitive ); err != nil {
171+ return err
172+ }
173+ if _ , err := file .Write (data ); err != nil {
174+ return err
175+ }
176+ if err := file .Close (); err != nil {
177+ return err
178+ }
179+ closed = true
180+ if err := os .Rename (tempPath , path ); err != nil {
181+ return err
182+ }
183+ return nil
184+ }
185+
130186// fetchJobDetails gets detailed job information including durations for a workflow run.
131187// Errors from the underlying API call are suppressed so that callers can continue
132188// processing even when job data is unavailable (e.g. missing permissions).
133- func fetchJobDetails (ctx context.Context , runID int64 , verbose bool ) ([]JobInfoWithDuration , error ) {
134- jobs , _ , err := fetchJobDetailsWithCounts (ctx , runID , verbose )
189+ func fetchJobDetails (ctx context.Context , runID int64 , outputDir string , verbose bool ) ([]JobInfoWithDuration , error ) {
190+ jobs , _ , err := fetchJobDetailsWithCounts (ctx , runID , outputDir , verbose )
135191 if err != nil {
192+ var cacheErr * jobDetailsCacheError
193+ if errors .As (err , & cacheErr ) {
194+ return jobs , err
195+ }
136196 // Don't fail the entire operation if we can't get job info
137197 return nil , nil
138198 }
@@ -143,7 +203,7 @@ func fetchJobDetails(ctx context.Context, runID int64, verbose bool) ([]JobInfoW
143203// Errors from the underlying API call are suppressed so that callers can continue
144204// processing even when job data is unavailable (e.g. missing permissions).
145205func fetchJobStatuses (ctx context.Context , runID int64 , verbose bool ) (int , error ) {
146- _ , failedJobs , err := fetchJobDetailsWithCounts (ctx , runID , verbose )
206+ _ , failedJobs , err := fetchJobDetailsWithCounts (ctx , runID , "" , verbose )
147207 if err != nil {
148208 // Don't fail the entire operation if we can't get job info
149209 return 0 , nil
@@ -189,7 +249,7 @@ type ListWorkflowRunsOptions struct {
189249// not the total number of matching runs the user wants to find.
190250//
191251// The processedCount and targetCount parameters are used to display progress in the spinner message.
192- func listWorkflowRunsWithPagination (opts ListWorkflowRunsOptions ) ([]WorkflowRun , int , error ) {
252+ func listWorkflowRunsWithPagination (opts ListWorkflowRunsOptions ) ([]WorkflowRun , int , error ) { //nolint:largefunc // Existing run listing keeps pagination, error classification, and filtering together.
193253 logsGitHubAPILog .Printf ("Listing workflow runs: workflow=%s, limit=%d, startDate=%s, endDate=%s, ref=%s" , opts .WorkflowName , opts .Limit , opts .StartDate , opts .EndDate , opts .Ref )
194254 args := []string {"run" , "list" , "--json" , "databaseId,number,url,status,conclusion,workflowName,createdAt,startedAt,updatedAt,event,headBranch,headSha,displayTitle" }
195255
0 commit comments