fix(ax): make health and issues machine-friendly

This commit is contained in:
Virgil 2026-03-30 10:24:38 +00:00
parent 95c32c21ca
commit 1f34ead44f
5 changed files with 319 additions and 30 deletions

View file

@ -40,23 +40,24 @@ type HealthWorkflowRun struct {
// RepoHealth represents the CI health of a single repo.
type RepoHealth struct {
Name string `json:"name"`
Status string `json:"status"` // passing, failing, pending, no_ci, disabled
Status string `json:"status"` // passing, failing, error, pending, no_ci, disabled
Message string `json:"message"`
URL string `json:"url"`
FailingSince string `json:"failing_since"`
FailingSince string `json:"failing_since,omitempty"`
}
// HealthSummary captures aggregate health counts.
type HealthSummary struct {
TotalRepos int `json:"total_repos"`
FilteredRepos int `json:"filtered_repos"`
Passing int `json:"passing"`
Failing int `json:"failing"`
Pending int `json:"pending"`
Disabled int `json:"disabled"`
NotConfigured int `json:"not_configured"`
PassingRate int `json:"passing_rate"`
ProblemsOnly bool `json:"problems_only"`
TotalRepos int `json:"total_repos"`
FilteredRepos int `json:"filtered_repos"`
Passing int `json:"passing"`
Failing int `json:"failing"`
Errors int `json:"errors"`
Pending int `json:"pending"`
Disabled int `json:"disabled"`
NotConfigured int `json:"not_configured"`
PassingRate int `json:"passing_rate"`
ProblemsOnly bool `json:"problems_only"`
ByStatus map[string]int `json:"by_status"`
}
@ -150,6 +151,7 @@ func runHealth() error {
}
printHealthGroup("failing", grouped["failing"], errorStyle)
printHealthGroup("error", grouped["error"], errorStyle)
printHealthGroup("pending", grouped["pending"], warningStyle)
printHealthGroup("no_ci", grouped["no_ci"], dimStyle)
printHealthGroup("disabled", grouped["disabled"], dimStyle)
@ -185,7 +187,7 @@ func fetchRepoHealth(org, repoName string) RepoHealth {
}
return RepoHealth{
Name: repoName,
Status: "no_ci",
Status: "error",
Message: i18n.T("cmd.qa.health.fetch_error"),
}
}
@ -194,7 +196,7 @@ func fetchRepoHealth(org, repoName string) RepoHealth {
if err := json.Unmarshal(output, &runs); err != nil {
return RepoHealth{
Name: repoName,
Status: "no_ci",
Status: "error",
Message: i18n.T("cmd.qa.health.parse_error"),
}
}
@ -247,16 +249,18 @@ func healthPriority(status string) int {
switch status {
case "failing":
return 0
case "pending":
case "error":
return 1
case "no_ci":
case "pending":
return 2
case "disabled":
case "no_ci":
return 3
case "passing":
case "disabled":
return 4
default:
case "passing":
return 5
default:
return 6
}
}
@ -275,6 +279,8 @@ func summariseHealthResults(totalRepos int, results []RepoHealth, problemsOnly b
summary.Passing++
case "failing":
summary.Failing++
case "error":
summary.Errors++
case "pending":
summary.Pending++
case "disabled":
@ -316,6 +322,8 @@ func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) {
switch status {
case "failing":
label = i18n.T("cmd.qa.health.count_failing")
case "error":
label = i18n.T("cmd.qa.health.count_error")
case "pending":
label = i18n.T("cmd.qa.health.count_pending")
case "no_ci":

162
cmd/qa/cmd_health_test.go Normal file
View file

@ -0,0 +1,162 @@
package qa
import (
"encoding/json"
"path/filepath"
"testing"
"forge.lthn.ai/core/cli/pkg/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunHealthJSONOutput_UsesMachineFriendlyKeysAndKeepsFetchErrors(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "repos.yaml"), `version: 1
org: forge
base_path: .
repos:
alpha:
type: module
beta:
type: module
`)
writeExecutable(t, filepath.Join(dir, "gh"), `#!/bin/sh
case "$*" in
*"--repo forge/alpha"*)
cat <<'JSON'
[
{
"status": "completed",
"conclusion": "success",
"name": "CI",
"headSha": "abc123",
"updatedAt": "2026-03-30T00:00:00Z",
"url": "https://example.com/alpha/run/1"
}
]
JSON
;;
*"--repo forge/beta"*)
printf '%s\n' 'simulated workflow lookup failure' >&2
exit 1
;;
*)
printf '%s\n' "unexpected gh invocation: $*" >&2
exit 1
;;
esac
`)
restoreWorkingDir(t, dir)
prependPath(t, dir)
resetHealthFlags(t)
t.Cleanup(func() {
healthRegistry = ""
})
parent := &cli.Command{Use: "qa"}
addHealthCommand(parent)
command := findSubcommand(t, parent, "health")
require.NoError(t, command.Flags().Set("registry", filepath.Join(dir, "repos.yaml")))
require.NoError(t, command.Flags().Set("json", "true"))
output := captureStdout(t, func() {
require.NoError(t, command.RunE(command, nil))
})
var payload HealthOutput
require.NoError(t, json.Unmarshal([]byte(output), &payload))
assert.Equal(t, 2, payload.Summary.TotalRepos)
assert.Equal(t, 1, payload.Summary.Passing)
assert.Equal(t, 1, payload.Summary.Errors)
assert.Equal(t, 2, payload.Summary.FilteredRepos)
assert.Equal(t, 1, payload.Summary.ByStatus["passing"])
assert.Equal(t, 1, payload.Summary.ByStatus["error"])
require.Len(t, payload.Repos, 2)
assert.Equal(t, "error", payload.Repos[0].Status)
assert.Equal(t, "beta", payload.Repos[0].Name)
assert.Equal(t, "passing", payload.Repos[1].Status)
assert.Equal(t, "alpha", payload.Repos[1].Name)
assert.Contains(t, output, `"status"`)
assert.NotContains(t, output, `"Status"`)
assert.NotContains(t, output, `"FailingSince"`)
}
func TestRunHealthHumanOutput_ShowsFetchErrorsAsErrors(t *testing.T) {
dir := t.TempDir()
writeTestFile(t, filepath.Join(dir, "repos.yaml"), `version: 1
org: forge
base_path: .
repos:
alpha:
type: module
beta:
type: module
`)
writeExecutable(t, filepath.Join(dir, "gh"), `#!/bin/sh
case "$*" in
*"--repo forge/alpha"*)
cat <<'JSON'
[
{
"status": "completed",
"conclusion": "success",
"name": "CI",
"headSha": "abc123",
"updatedAt": "2026-03-30T00:00:00Z",
"url": "https://example.com/alpha/run/1"
}
]
JSON
;;
*"--repo forge/beta"*)
printf '%s\n' 'simulated workflow lookup failure' >&2
exit 1
;;
*)
printf '%s\n' "unexpected gh invocation: $*" >&2
exit 1
;;
esac
`)
restoreWorkingDir(t, dir)
prependPath(t, dir)
resetHealthFlags(t)
t.Cleanup(func() {
healthRegistry = ""
})
parent := &cli.Command{Use: "qa"}
addHealthCommand(parent)
command := findSubcommand(t, parent, "health")
require.NoError(t, command.Flags().Set("registry", filepath.Join(dir, "repos.yaml")))
output := captureStdout(t, func() {
require.NoError(t, command.RunE(command, nil))
})
assert.Contains(t, output, "cmd.qa.health.summary")
assert.Contains(t, output, "alpha")
assert.Contains(t, output, "beta")
assert.Contains(t, output, "cmd.qa.health.fetch_error")
assert.NotContains(t, output, "no CI")
}
func resetHealthFlags(t *testing.T) {
t.Helper()
oldProblems := healthProblems
oldRegistry := healthRegistry
oldJSON := healthJSON
healthProblems = false
healthRegistry = ""
healthJSON = false
t.Cleanup(func() {
healthProblems = oldProblems
healthRegistry = oldRegistry
healthJSON = oldJSON
})
}

View file

@ -66,17 +66,10 @@ type Issue struct {
URL string `json:"url"`
// Computed fields
RepoName string
Priority int // Lower = higher priority
Category string // "needs_response", "ready", "blocked", "triage"
ActionHint string
}
type issuesOutput struct {
NeedsResponse []Issue `json:"needs_response"`
Ready []Issue `json:"ready"`
Blocked []Issue `json:"blocked"`
Triage []Issue `json:"triage"`
RepoName string `json:"repo_name"`
Priority int `json:"priority"` // Lower = higher priority
Category string `json:"category"` // "needs_response", "ready", "blocked", "triage"
ActionHint string `json:"action_hint,omitempty"`
}
type IssueFetchError struct {

125
cmd/qa/cmd_issues_test.go Normal file
View file

@ -0,0 +1,125 @@
package qa
import (
"encoding/json"
"fmt"
"path/filepath"
"testing"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunQAIssuesJSONOutput_UsesMachineFriendlyKeys(t *testing.T) {
dir := t.TempDir()
commentTime := time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339)
updatedAt := time.Now().UTC().Format(time.RFC3339)
writeTestFile(t, filepath.Join(dir, "repos.yaml"), `version: 1
org: forge
base_path: .
repos:
alpha:
type: module
`)
writeExecutable(t, filepath.Join(dir, "gh"), fmt.Sprintf(`#!/bin/sh
case "$*" in
*"api user"*)
printf '%%s\n' 'alice'
;;
*"issue list --repo forge/alpha"*)
cat <<JSON
[
{
"number": 7,
"title": "Clarify agent output",
"state": "OPEN",
"body": "Explain behaviour",
"createdAt": "2026-03-30T00:00:00Z",
"updatedAt": %q,
"author": {"login": "bob"},
"assignees": {"nodes": []},
"labels": {"nodes": [{"name": "agent:ready"}]},
"comments": {
"totalCount": 1,
"nodes": [
{
"author": {"login": "carol"},
"createdAt": %q
}
]
},
"url": "https://example.com/issues/7"
}
]
JSON
;;
*)
printf '%%s\n' "unexpected gh invocation: $*" >&2
exit 1
;;
esac
`, updatedAt, commentTime))
restoreWorkingDir(t, dir)
prependPath(t, dir)
resetIssuesFlags(t)
t.Cleanup(func() {
issuesRegistry = ""
})
parent := &cli.Command{Use: "qa"}
addIssuesCommand(parent)
command := findSubcommand(t, parent, "issues")
require.NoError(t, command.Flags().Set("registry", filepath.Join(dir, "repos.yaml")))
require.NoError(t, command.Flags().Set("json", "true"))
output := captureStdout(t, func() {
require.NoError(t, command.RunE(command, nil))
})
var payload IssuesOutput
require.NoError(t, json.Unmarshal([]byte(output), &payload))
assert.Equal(t, 1, payload.TotalIssues)
assert.Equal(t, 1, payload.FilteredIssues)
require.Len(t, payload.Categories, 4)
require.Len(t, payload.Categories[0].Issues, 1)
issue := payload.Categories[0].Issues[0]
assert.Equal(t, "needs_response", payload.Categories[0].Category)
assert.Equal(t, "alpha", issue.RepoName)
assert.Equal(t, 10, issue.Priority)
assert.Equal(t, "needs_response", issue.Category)
assert.Equal(t, "@carol cmd.qa.issues.hint.needs_response", issue.ActionHint)
assert.Contains(t, output, `"repo_name"`)
assert.Contains(t, output, `"action_hint"`)
assert.NotContains(t, output, `"RepoName"`)
assert.NotContains(t, output, `"ActionHint"`)
}
func resetIssuesFlags(t *testing.T) {
t.Helper()
oldMine := issuesMine
oldTriage := issuesTriage
oldBlocked := issuesBlocked
oldRegistry := issuesRegistry
oldLimit := issuesLimit
oldJSON := issuesJSON
issuesMine = false
issuesTriage = false
issuesBlocked = false
issuesRegistry = ""
issuesLimit = 50
issuesJSON = false
t.Cleanup(func() {
issuesMine = oldMine
issuesTriage = oldTriage
issuesBlocked = oldBlocked
issuesRegistry = oldRegistry
issuesLimit = oldLimit
issuesJSON = oldJSON
})
}

View file

@ -14,7 +14,7 @@
},
"health": {
"short": "Show CI health across repos",
"long": "Check GitHub Actions workflow status for all repos in the registry and report which are passing, failing, or unconfigured.",
"long": "Check GitHub Actions workflow status for all repos in the registry and report which are passing, failing, errored, or unconfigured.",
"summary": "CI Health",
"all_healthy": "All repos are healthy.",
"passing": "Passing",
@ -27,6 +27,7 @@
"no_ci_configured": "No CI configured",
"count_passing": "passing",
"count_failing": "failing",
"count_error": "error",
"count_pending": "pending",
"count_no_ci": "no CI",
"count_disabled": "disabled",