diff --git a/cmd/qa/cmd_health.go b/cmd/qa/cmd_health.go index 84710da..a90d91d 100644 --- a/cmd/qa/cmd_health.go +++ b/cmd/qa/cmd_health.go @@ -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": diff --git a/cmd/qa/cmd_health_test.go b/cmd/qa/cmd_health_test.go new file mode 100644 index 0000000..f33252c --- /dev/null +++ b/cmd/qa/cmd_health_test.go @@ -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 + }) +} diff --git a/cmd/qa/cmd_issues.go b/cmd/qa/cmd_issues.go index decbe91..3975142 100644 --- a/cmd/qa/cmd_issues.go +++ b/cmd/qa/cmd_issues.go @@ -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 { diff --git a/cmd/qa/cmd_issues_test.go b/cmd/qa/cmd_issues_test.go new file mode 100644 index 0000000..e488aad --- /dev/null +++ b/cmd/qa/cmd_issues_test.go @@ -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 <&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 + }) +} diff --git a/locales/en.json b/locales/en.json index 226c69b..9116bab 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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",