fix(ax): make health and issues machine-friendly
This commit is contained in:
parent
95c32c21ca
commit
1f34ead44f
5 changed files with 319 additions and 30 deletions
|
|
@ -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
162
cmd/qa/cmd_health_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
125
cmd/qa/cmd_issues_test.go
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue