lint/cmd/qa/cmd_health.go

355 lines
8.9 KiB
Go

// cmd_health.go implements the `qa health` command for aggregate CI health.
//
// Usage:
// core qa health # Show CI health summary
// core qa health --problems # Show only repos with problems
package qa
import (
"cmp"
"encoding/json"
"os/exec"
"slices"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/repos"
)
// Health command flags.
var (
healthProblems bool
healthRegistry string
healthJSON bool
)
// HealthWorkflowRun represents a GitHub Actions workflow run.
type HealthWorkflowRun struct {
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Name string `json:"name"`
HeadSha string `json:"headSha"`
UpdatedAt string `json:"updatedAt"`
URL string `json:"url"`
}
// RepoHealth represents the CI health of a single repo.
type RepoHealth struct {
Name string `json:"name"`
Status string `json:"status"` // passing, failing, error, pending, no_ci, disabled
Message string `json:"message"`
URL string `json:"url"`
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"`
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"`
}
// HealthOutput is the JSON payload for `qa health --json`.
type HealthOutput struct {
Summary HealthSummary `json:"summary"`
Repos []RepoHealth `json:"repos"`
}
func addHealthCommand(parent *cli.Command) {
healthCmd := &cli.Command{
Use: "health",
Short: i18n.T("cmd.qa.health.short"),
Long: i18n.T("cmd.qa.health.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runHealth()
},
}
healthCmd.Flags().BoolVarP(&healthProblems, "problems", "p", false, i18n.T("cmd.qa.health.flag.problems"))
healthCmd.Flags().StringVar(&healthRegistry, "registry", "", i18n.T("common.flag.registry"))
healthCmd.Flags().BoolVar(&healthJSON, "json", false, i18n.T("common.flag.json"))
parent.AddCommand(healthCmd)
}
func runHealth() error {
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.health", i18n.T("error.gh_not_found"), nil)
}
var reg *repos.Registry
var err error
if healthRegistry != "" {
reg, err = repos.LoadRegistry(io.Local, healthRegistry)
} else {
registryPath, findErr := repos.FindRegistry(io.Local)
if findErr != nil {
return log.E("qa.health", i18n.T("error.registry_not_found"), nil)
}
reg, err = repos.LoadRegistry(io.Local, registryPath)
}
if err != nil {
return log.E("qa.health", "failed to load registry", err)
}
repoList := reg.List()
allHealthResults := make([]RepoHealth, 0, len(repoList))
for _, repo := range repoList {
health := fetchRepoHealth(reg.Org, repo.Name)
allHealthResults = append(allHealthResults, health)
}
// Sort by severity first, then repo name for deterministic output.
slices.SortFunc(allHealthResults, func(a, b RepoHealth) int {
if p := cmp.Compare(healthPriority(a.Status), healthPriority(b.Status)); p != 0 {
return p
}
return strings.Compare(a.Name, b.Name)
})
healthResults := allHealthResults
if healthProblems {
problems := make([]RepoHealth, 0, len(healthResults))
for _, h := range healthResults {
if h.Status != "passing" {
problems = append(problems, h)
}
}
healthResults = problems
}
summary := summariseHealthResults(len(repoList), len(healthResults), allHealthResults, healthProblems)
if healthJSON {
return printHealthJSON(summary, healthResults)
}
cli.Print("%s: %d/%d repos healthy (%d%%)\n\n",
i18n.T("cmd.qa.health.summary"),
summary.Passing,
summary.TotalRepos,
summary.PassingRate)
if len(healthResults) == 0 {
cli.Text(i18n.T("cmd.qa.health.all_healthy"))
return nil
}
grouped := make(map[string][]RepoHealth)
for _, h := range healthResults {
grouped[h.Status] = append(grouped[h.Status], h)
}
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)
if !healthProblems {
printHealthGroup("passing", grouped["passing"], successStyle)
}
return nil
}
func fetchRepoHealth(org, repoName string) RepoHealth {
repoFullName := cli.Sprintf("%s/%s", org, repoName)
args := []string{
"run", "list",
"--repo", repoFullName,
"--limit", "1",
"--json", "status,conclusion,name,headSha,updatedAt,url",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := string(exitErr.Stderr)
if strings.Contains(stderr, "no workflows") || strings.Contains(stderr, "not found") {
return RepoHealth{
Name: repoName,
Status: "no_ci",
Message: i18n.T("cmd.qa.health.no_ci_configured"),
}
}
}
return RepoHealth{
Name: repoName,
Status: "error",
Message: i18n.T("cmd.qa.health.fetch_error"),
}
}
var runs []HealthWorkflowRun
if err := json.Unmarshal(output, &runs); err != nil {
return RepoHealth{
Name: repoName,
Status: "error",
Message: i18n.T("cmd.qa.health.parse_error"),
}
}
if len(runs) == 0 {
return RepoHealth{
Name: repoName,
Status: "no_ci",
Message: i18n.T("cmd.qa.health.no_ci_configured"),
}
}
run := runs[0]
health := RepoHealth{
Name: repoName,
URL: run.URL,
}
switch run.Status {
case "completed":
switch run.Conclusion {
case "success":
health.Status = "passing"
health.Message = i18n.T("cmd.qa.health.passing")
case "failure":
health.Status = "failing"
health.Message = i18n.T("cmd.qa.health.tests_failing")
case "cancelled":
health.Status = "pending"
health.Message = i18n.T("cmd.qa.health.cancelled")
case "skipped":
health.Status = "passing"
health.Message = i18n.T("cmd.qa.health.skipped")
default:
health.Status = "failing"
health.Message = run.Conclusion
}
case "in_progress", "queued", "waiting":
health.Status = "pending"
health.Message = i18n.T("cmd.qa.health.running")
default:
health.Status = "no_ci"
health.Message = run.Status
}
return health
}
func healthPriority(status string) int {
switch status {
case "failing":
return 0
case "error":
return 1
case "pending":
return 2
case "no_ci":
return 3
case "disabled":
return 4
case "passing":
return 5
default:
return 6
}
}
func summariseHealthResults(totalRepos int, filteredRepos int, results []RepoHealth, problemsOnly bool) HealthSummary {
summary := HealthSummary{
TotalRepos: totalRepos,
FilteredRepos: filteredRepos,
ByStatus: map[string]int{
"passing": 0,
"failing": 0,
"error": 0,
"pending": 0,
"disabled": 0,
"no_ci": 0,
},
ProblemsOnly: problemsOnly,
}
for _, health := range results {
summary.ByStatus[health.Status]++
switch health.Status {
case "passing":
summary.Passing++
case "failing":
summary.Failing++
case "error":
summary.Errors++
case "pending":
summary.Pending++
case "disabled":
summary.Disabled++
case "no_ci":
summary.NotConfigured++
}
}
if summary.TotalRepos > 0 {
summary.PassingRate = (summary.Passing * 100) / summary.TotalRepos
}
return summary
}
func printHealthJSON(summary HealthSummary, repos []RepoHealth) error {
data, err := json.MarshalIndent(HealthOutput{
Summary: summary,
Repos: repos,
}, "", " ")
if err != nil {
return err
}
cli.Print("%s\n", string(data))
return nil
}
func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) {
if len(repos) == 0 {
return
}
slices.SortFunc(repos, func(a, b RepoHealth) int {
return strings.Compare(a.Name, b.Name)
})
var label string
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":
label = i18n.T("cmd.qa.health.count_no_ci")
case "disabled":
label = i18n.T("cmd.qa.health.count_disabled")
case "passing":
label = i18n.T("cmd.qa.health.count_passing")
}
cli.Print("%s (%d):\n", style.Render(label), len(repos))
for _, repo := range repos {
cli.Print(" %s %s\n",
cli.RepoStyle.Render(repo.Name),
dimStyle.Render(repo.Message))
if repo.URL != "" && status == "failing" {
cli.Print(" -> %s\n", dimStyle.Render(repo.URL))
}
}
cli.Blank()
}