// 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 ( "encoding/json" "os/exec" "sort" "strings" "github.com/host-uk/core/pkg/cli" "github.com/host-uk/core/pkg/i18n" "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/log" "github.com/host-uk/core/pkg/repos" ) // Health command flags var ( healthProblems bool healthRegistry string ) // 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 Status string // "passing", "failing", "pending", "no_ci", "disabled" Message string URL string FailingSince string } // addHealthCommand adds the 'health' subcommand to qa. 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")) parent.AddCommand(healthCmd) } func runHealth() error { // Check gh is available if _, err := exec.LookPath("gh"); err != nil { return log.E("qa.health", i18n.T("error.gh_not_found"), nil) } // Load registry 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) } // Fetch CI status from all repos var healthResults []RepoHealth repoList := reg.List() for i, repo := range repoList { cli.Print("\033[2K\r%s %d/%d %s", dimStyle.Render(i18n.T("cmd.qa.issues.fetching")), i+1, len(repoList), repo.Name) health := fetchRepoHealth(reg.Org, repo.Name) healthResults = append(healthResults, health) } cli.Print("\033[2K\r") // Clear progress // Sort: problems first, then passing sort.Slice(healthResults, func(i, j int) bool { return healthPriority(healthResults[i].Status) < healthPriority(healthResults[j].Status) }) // Filter if --problems flag if healthProblems { var problems []RepoHealth for _, h := range healthResults { if h.Status != "passing" { problems = append(problems, h) } } healthResults = problems } // Calculate summary passing := 0 for _, h := range healthResults { if h.Status == "passing" { passing++ } } total := len(repoList) percentage := 0 if total > 0 { percentage = (passing * 100) / total } // Print summary cli.Print("%s: %d/%d repos healthy (%d%%)\n\n", i18n.T("cmd.qa.health.summary"), passing, total, percentage) if len(healthResults) == 0 { cli.Text(i18n.T("cmd.qa.health.all_healthy")) return nil } // Group by status grouped := make(map[string][]RepoHealth) for _, h := range healthResults { grouped[h.Status] = append(grouped[h.Status], h) } // Print problems first printHealthGroup("failing", grouped["failing"], 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 { // Check if it's a 404 (no workflows) 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: "no_ci", Message: i18n.T("cmd.qa.health.fetch_error"), } } var runs []HealthWorkflowRun if err := json.Unmarshal(output, &runs); err != nil { return RepoHealth{ Name: repoName, Status: "no_ci", 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 "pending": return 1 case "no_ci": return 2 case "disabled": return 3 case "passing": return 4 default: return 5 } } func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) { if len(repos) == 0 { return } var label string switch status { case "failing": label = i18n.T("cmd.qa.health.count_failing") 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() }