fix(ax): stabilise watch output

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-30 10:52:49 +00:00
parent d5bc922325
commit e05d7cf070
2 changed files with 156 additions and 19 deletions

View file

@ -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),
)
}

103
cmd/qa/cmd_watch_test.go Normal file
View file

@ -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")
}