diff --git a/cmd/qa/cmd_watch.go b/cmd/qa/cmd_watch.go index 7a5577f..0aeb0e3 100644 --- a/cmd/qa/cmd_watch.go +++ b/cmd/qa/cmd_watch.go @@ -9,10 +9,12 @@ package qa import ( + "cmp" "context" "encoding/json" "fmt" "os/exec" + "slices" "strings" "time" @@ -43,11 +45,12 @@ type WorkflowRun struct { // WorkflowJob represents a job within a workflow run type WorkflowJob struct { - ID int64 `json:"databaseId"` - Name string `json:"name"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - URL string `json:"url"` + ID int64 `json:"databaseId"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` + Steps []JobStep `json:"steps"` } // JobStep represents a step within a job @@ -110,6 +113,7 @@ func runWatch() error { // Poll for workflow runs pollInterval := 3 * time.Second var lastStatus string + waitingStatus := dimStyle.Render(i18n.T("cmd.qa.watch.waiting_for_workflows")) for { // Check if context deadline exceeded @@ -125,7 +129,10 @@ func runWatch() error { if len(runs) == 0 { // No workflows triggered yet, keep waiting - cli.Print("\033[2K\r%s", dimStyle.Render(i18n.T("cmd.qa.watch.waiting_for_workflows"))) + if waitingStatus != lastStatus { + cli.Print("%s\n", waitingStatus) + lastStatus = waitingStatus + } time.Sleep(pollInterval) continue } @@ -169,12 +176,11 @@ func runWatch() error { // Only print if status changed if status != lastStatus { - cli.Print("\033[2K\r%s", status) + cli.Print("%s\n", status) lastStatus = status } if allComplete { - cli.Blank() cli.Blank() return printResults(ctx, repoFullName, runs) } @@ -308,14 +314,17 @@ func printResults(ctx context.Context, repoFullName string, runs []WorkflowRun) } } + slices.SortFunc(successes, compareWorkflowRun) + slices.SortFunc(failures, compareWorkflowRun) + // Print successes briefly for _, run := range successes { - cli.Print("%s %s\n", successStyle.Render(cli.Glyph(":check:")), run.Name) + cli.Print("%s %s\n", successStyle.Render(i18n.T("common.label.success")), run.Name) } // Print failures with details for _, run := range failures { - cli.Print("%s %s\n", errorStyle.Render(cli.Glyph(":cross:")), run.Name) + cli.Print("%s %s\n", errorStyle.Render(i18n.T("common.label.error")), run.Name) // Fetch failed job details failedJob, failedStep, errorLine := fetchFailureDetails(ctx, repoFullName, run.ID) @@ -359,25 +368,20 @@ func fetchFailureDetails(ctx context.Context, repoFullName string, runID int64) } var result struct { - Jobs []struct { - Name string `json:"name"` - Conclusion string `json:"conclusion"` - Steps []struct { - Name string `json:"name"` - Conclusion string `json:"conclusion"` - Number int `json:"number"` - } `json:"steps"` - } `json:"jobs"` + Jobs []WorkflowJob `json:"jobs"` } if err := json.Unmarshal(output, &result); err != nil { return "", "", "" } + slices.SortFunc(result.Jobs, compareWorkflowJob) + // Find the failed job and step for _, job := range result.Jobs { if job.Conclusion == "failure" { jobName = job.Name + slices.SortFunc(job.Steps, compareJobStep) for _, step := range job.Steps { if step.Conclusion == "failure" { stepName = fmt.Sprintf("%d: %s", step.Number, step.Name) @@ -442,3 +446,33 @@ func fetchErrorFromLogs(ctx context.Context, repoFullName string, runID int64) s return "" } + +func compareWorkflowRun(a, b WorkflowRun) int { + return cmp.Or( + cmp.Compare(a.Name, b.Name), + cmp.Compare(a.DisplayTitle, b.DisplayTitle), + a.CreatedAt.Compare(b.CreatedAt), + a.UpdatedAt.Compare(b.UpdatedAt), + cmp.Compare(a.ID, b.ID), + cmp.Compare(a.URL, b.URL), + ) +} + +func compareWorkflowJob(a, b WorkflowJob) int { + return cmp.Or( + cmp.Compare(a.Name, b.Name), + cmp.Compare(a.Conclusion, b.Conclusion), + cmp.Compare(a.Status, b.Status), + cmp.Compare(a.ID, b.ID), + cmp.Compare(a.URL, b.URL), + ) +} + +func compareJobStep(a, b JobStep) int { + return cmp.Or( + cmp.Compare(a.Number, b.Number), + cmp.Compare(a.Name, b.Name), + cmp.Compare(a.Conclusion, b.Conclusion), + cmp.Compare(a.Status, b.Status), + ) +} diff --git a/cmd/qa/cmd_watch_test.go b/cmd/qa/cmd_watch_test.go new file mode 100644 index 0000000..28a01bf --- /dev/null +++ b/cmd/qa/cmd_watch_test.go @@ -0,0 +1,103 @@ +package qa + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPrintResults_SortsRunsAndUsesDeterministicDetails(t *testing.T) { + dir := t.TempDir() + writeExecutable(t, filepath.Join(dir, "gh"), `#!/bin/sh +case "$*" in + *"run view 2 --repo forge/alpha --json jobs"*) + cat <<'JSON' +{"jobs":[ + { + "databaseId": 20, + "name": "Zulu Job", + "status": "completed", + "conclusion": "failure", + "steps": [ + {"name": "Zulu Step", "status": "completed", "conclusion": "failure", "number": 2} + ] + }, + { + "databaseId": 10, + "name": "Alpha Job", + "status": "completed", + "conclusion": "failure", + "steps": [ + {"name": "Zulu Step", "status": "completed", "conclusion": "failure", "number": 2}, + {"name": "Alpha Step", "status": "completed", "conclusion": "failure", "number": 1} + ] + } +]} +JSON + ;; + *"run view 2 --repo forge/alpha --log-failed"*) + cat <<'EOF' +Alpha error detail +EOF + ;; + *"run view 4 --repo forge/alpha --json jobs"*) + cat <<'JSON' +{"jobs":[ + { + "databaseId": 40, + "name": "Omega Job", + "status": "completed", + "conclusion": "failure", + "steps": [ + {"name": "Omega Step", "status": "completed", "conclusion": "failure", "number": 1} + ] + } +]} +JSON + ;; + *"run view 4 --repo forge/alpha --log-failed"*) + cat <<'EOF' +Omega error detail +EOF + ;; + *) + printf '%s\n' "unexpected gh invocation: $*" >&2 + exit 1 + ;; +esac +`) + + prependPath(t, dir) + + runs := []WorkflowRun{ + {ID: 3, Name: "Zulu Build", Conclusion: "success", URL: "https://example.com/zulu"}, + {ID: 1, Name: "Alpha Build", Conclusion: "success", URL: "https://example.com/alpha"}, + {ID: 4, Name: "Omega Failure", Conclusion: "failure", URL: "https://example.com/omega"}, + {ID: 2, Name: "Beta Failure", Conclusion: "failure", URL: "https://example.com/beta"}, + } + + output := captureStdout(t, func() { + err := printResults(context.Background(), "forge/alpha", runs) + require.Error(t, err) + }) + + assert.NotContains(t, output, "\033[2K\r") + alphaBuild := strings.Index(output, "Alpha Build") + require.NotEqual(t, -1, alphaBuild) + zuluBuild := strings.Index(output, "Zulu Build") + require.NotEqual(t, -1, zuluBuild) + assert.Less(t, alphaBuild, zuluBuild) + + betaFailure := strings.Index(output, "Beta Failure") + require.NotEqual(t, -1, betaFailure) + omegaFailure := strings.Index(output, "Omega Failure") + require.NotEqual(t, -1, omegaFailure) + assert.Less(t, betaFailure, omegaFailure) + assert.Contains(t, output, "Job: Alpha Job (step: 1: Alpha Step)") + assert.Contains(t, output, "Error: Alpha error detail") + assert.NotContains(t, output, "Job: Zulu Job") +}