From 182f108d377703c6f3ac6a7fa44ddc7dcd61c561 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 07:21:21 +0000 Subject: [PATCH] feat: align qa and lint outputs with agent experience --- cmd/core-lint/main.go | 37 +++++++++++++++++--- cmd/qa/cmd_health.go | 80 ++++++++++++++++++++++++++++++++++++------- cmd/qa/cmd_issues.go | 42 +++++++++++++++++++---- cmd/qa/cmd_review.go | 64 ++++++++++++++++++++++++++-------- 4 files changed, 185 insertions(+), 38 deletions(-) diff --git a/cmd/core-lint/main.go b/cmd/core-lint/main.go index 0e9eeef..2eb1b4c 100644 --- a/cmd/core-lint/main.go +++ b/cmd/core-lint/main.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "sort" "strings" "forge.lthn.ai/core/cli/pkg/cli" @@ -94,15 +95,33 @@ func addLintCommands(root *cli.Command) { if len(allFindings) > 0 { summary := lintpkg.Summarise(allFindings) - fmt.Fprintf(os.Stderr, "\n%d finding(s)", summary.Total) + severityOrder := []string{"critical", "high", "medium", "low", "info"} + seen := make(map[string]bool, len(summary.BySeverity)) + var parts []string - for sev, count := range summary.BySeverity { - parts = append(parts, fmt.Sprintf("%d %s", count, sev)) + for _, severity := range severityOrder { + if count, ok := summary.BySeverity[severity]; ok { + parts = append(parts, fmt.Sprintf("%d %s", count, severity)) + seen[severity] = true + } } + + var unknown []string + for severity := range summary.BySeverity { + if !seen[severity] { + unknown = append(unknown, severity) + } + } + sort.Strings(unknown) + for _, severity := range unknown { + parts = append(parts, fmt.Sprintf("%d %s", summary.BySeverity[severity], severity)) + } + + fmt.Printf("\n%d finding(s)", summary.Total) if len(parts) > 0 { - fmt.Fprintf(os.Stderr, " (%s)", strings.Join(parts, ", ")) + fmt.Printf(" (%s)", strings.Join(parts, ", ")) } - fmt.Fprintln(os.Stderr) + fmt.Println() } return nil @@ -134,6 +153,14 @@ func addLintCommands(root *cli.Command) { return nil } + rules = append([]lintpkg.Rule(nil), rules...) + sort.Slice(rules, func(i, j int) int { + if rules[i].Severity == rules[j].Severity { + return strings.Compare(rules[i].ID, rules[j].ID) + } + return strings.Compare(rules[i].Severity, rules[j].Severity) + }) + for _, r := range rules { fmt.Printf("%-14s [%-8s] %s\n", r.ID, r.Severity, r.Title) } diff --git a/cmd/qa/cmd_health.go b/cmd/qa/cmd_health.go index 3773908..c0298e9 100644 --- a/cmd/qa/cmd_health.go +++ b/cmd/qa/cmd_health.go @@ -24,6 +24,7 @@ import ( var ( healthProblems bool healthRegistry string + healthJSON bool ) // HealthWorkflowRun represents a GitHub Actions workflow run @@ -38,13 +39,25 @@ type HealthWorkflowRun struct { // RepoHealth represents the CI health of a single repo type RepoHealth struct { - Name string + Name string `json:"name"` Status string // "passing", "failing", "pending", "no_ci", "disabled" - Message string - URL string + Message string `json:"message"` + URL string `json:"url"` FailingSince string } +type healthOutput struct { + Repos []RepoHealth `json:"repos"` + Counts struct { + Total int `json:"total"` + Passing int `json:"passing"` + Failing int `json:"failing"` + Pending int `json:"pending"` + NoCI int `json:"no_ci"` + Disabled int `json:"disabled"` + } `json:"counts"` +} + // addHealthCommand adds the 'health' subcommand to qa. func addHealthCommand(parent *cli.Command) { healthCmd := &cli.Command{ @@ -58,6 +71,7 @@ func addHealthCommand(parent *cli.Command) { 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) } @@ -89,19 +103,19 @@ func runHealth() error { 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) - + for _, repo := range repoList { health := fetchRepoHealth(reg.Org, repo.Name) healthResults = append(healthResults, health) } - cli.Print("\033[2K\r") // Clear progress + + allHealthResults := append([]RepoHealth(nil), healthResults...) // Sort: problems first, then passing slices.SortFunc(healthResults, func(a, b RepoHealth) int { - return cmp.Compare(healthPriority(a.Status), healthPriority(b.Status)) + if p := cmp.Compare(healthPriority(a.Status), healthPriority(b.Status)); p != 0 { + return p + } + return strings.Compare(a.Name, b.Name) }) // Filter if --problems flag @@ -115,14 +129,52 @@ func runHealth() error { healthResults = problems } + if healthJSON { + var counts struct { + Total int `json:"total"` + Passing int `json:"passing"` + Failing int `json:"failing"` + Pending int `json:"pending"` + NoCI int `json:"no_ci"` + Disabled int `json:"disabled"` + } + + for _, h := range healthResults { + counts.Total++ + switch h.Status { + case "passing": + counts.Passing++ + case "failing": + counts.Failing++ + case "pending": + counts.Pending++ + case "no_ci": + counts.NoCI++ + case "disabled": + counts.Disabled++ + } + } + + output := healthOutput{ + Repos: healthResults, + Counts: counts, + } + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return err + } + cli.Print("%s", string(data)) + return nil + } + // Calculate summary passing := 0 - for _, h := range healthResults { + for _, h := range allHealthResults { if h.Status == "passing" { passing++ } } - total := len(repoList) + total := len(allHealthResults) percentage := 0 if total > 0 { percentage = (passing * 100) / total @@ -263,6 +315,10 @@ func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) { return } + slices.SortFunc(repos, func(a, b RepoHealth) int { + return strings.Compare(a.Name, b.Name) + }) + var label string switch status { case "failing": diff --git a/cmd/qa/cmd_issues.go b/cmd/qa/cmd_issues.go index 3934ab8..93bc6c5 100644 --- a/cmd/qa/cmd_issues.go +++ b/cmd/qa/cmd_issues.go @@ -30,6 +30,7 @@ var ( issuesBlocked bool issuesRegistry string issuesLimit int + issuesJSON bool ) // Issue represents a GitHub issue with triage metadata @@ -71,6 +72,13 @@ type Issue struct { ActionHint string } +type issuesOutput struct { + NeedsResponse []Issue `json:"needs_response"` + Ready []Issue `json:"ready"` + Blocked []Issue `json:"blocked"` + Triage []Issue `json:"triage"` +} + // addIssuesCommand adds the 'issues' subcommand to qa. func addIssuesCommand(parent *cli.Command) { issuesCmd := &cli.Command{ @@ -87,6 +95,7 @@ func addIssuesCommand(parent *cli.Command) { issuesCmd.Flags().BoolVarP(&issuesBlocked, "blocked", "b", false, i18n.T("cmd.qa.issues.flag.blocked")) issuesCmd.Flags().StringVar(&issuesRegistry, "registry", "", i18n.T("common.flag.registry")) issuesCmd.Flags().IntVarP(&issuesLimit, "limit", "l", 50, i18n.T("cmd.qa.issues.flag.limit")) + issuesCmd.Flags().BoolVar(&issuesJSON, "json", false, i18n.T("common.flag.json")) parent.AddCommand(issuesCmd) } @@ -118,18 +127,13 @@ func runQAIssues() error { var allIssues []Issue 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) - + for _, repo := range repoList { issues, err := fetchQAIssues(reg.Org, repo.Name, issuesLimit) if err != nil { continue // Skip repos with errors } allIssues = append(allIssues, issues...) } - cli.Print("\033[2K\r") // Clear progress if len(allIssues) == 0 { cli.Text(i18n.T("cmd.qa.issues.no_issues")) @@ -150,6 +154,21 @@ func runQAIssues() error { categorised = filterCategory(categorised, "blocked") } + if issuesJSON { + output := issuesOutput{ + NeedsResponse: categorised["needs_response"], + Ready: categorised["ready"], + Blocked: categorised["blocked"], + Triage: categorised["triage"], + } + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return err + } + cli.Print("%s", string(data)) + return nil + } + // Print categorised issues printCategorisedIssues(categorised) @@ -205,7 +224,16 @@ func categoriseIssues(issues []Issue) map[string][]Issue { // Sort each category by priority for cat := range result { slices.SortFunc(result[cat], func(a, b Issue) int { - return cmp.Compare(a.Priority, b.Priority) + if priority := cmp.Compare(a.Priority, b.Priority); priority != 0 { + return priority + } + if byDate := cmp.Compare(b.UpdatedAt.Unix(), a.UpdatedAt.Unix()); byDate != 0 { + return byDate + } + if repo := cmp.Compare(a.RepoName, b.RepoName); repo != 0 { + return repo + } + return cmp.Compare(a.Number, b.Number) }) } diff --git a/cmd/qa/cmd_review.go b/cmd/qa/cmd_review.go index b1088f5..9630e95 100644 --- a/cmd/qa/cmd_review.go +++ b/cmd/qa/cmd_review.go @@ -12,6 +12,7 @@ import ( "encoding/json" "fmt" "os/exec" + "sort" "strings" "time" @@ -25,6 +26,7 @@ var ( reviewMine bool reviewRequested bool reviewRepo string + reviewJSON bool ) // PullRequest represents a GitHub pull request @@ -81,6 +83,11 @@ type Review struct { State string `json:"state"` } +type reviewOutput struct { + Mine []PullRequest `json:"mine"` + Requested []PullRequest `json:"requested"` +} + // addReviewCommand adds the 'review' subcommand to the qa command. func addReviewCommand(parent *cli.Command) { reviewCmd := &cli.Command{ @@ -95,6 +102,7 @@ func addReviewCommand(parent *cli.Command) { reviewCmd.Flags().BoolVarP(&reviewMine, "mine", "m", false, i18n.T("cmd.qa.review.flag.mine")) reviewCmd.Flags().BoolVarP(&reviewRequested, "requested", "r", false, i18n.T("cmd.qa.review.flag.requested")) reviewCmd.Flags().StringVar(&reviewRepo, "repo", "", i18n.T("cmd.qa.review.flag.repo")) + reviewCmd.Flags().BoolVar(&reviewJSON, "json", false, i18n.T("common.flag.json")) parent.AddCommand(reviewCmd) } @@ -121,9 +129,47 @@ func runReview() error { // Default: show both mine and requested if neither flag is set showMine := reviewMine || (!reviewMine && !reviewRequested) showRequested := reviewRequested || (!reviewMine && !reviewRequested) + var minePRs, requestedPRs []PullRequest if showMine { - if err := showMyPRs(ctx, repoFullName); err != nil { + prs, err := fetchPRs(ctx, repoFullName, "author:@me") + if err != nil { + return log.E("qa.review", "failed to fetch your PRs", err) + } + sort.Slice(prs, func(i, j int) int { + if prs[i].Number == prs[j].Number { + return strings.Compare(prs[i].Title, prs[j].Title) + } + return prs[i].Number - prs[j].Number + }) + minePRs = prs + } + + if showRequested { + prs, err := fetchPRs(ctx, repoFullName, "review-requested:@me") + if err != nil { + return log.E("qa.review", "failed to fetch review requests", err) + } + sort.Slice(prs, func(i, j int) int { + if prs[i].Number == prs[j].Number { + return strings.Compare(prs[i].Title, prs[j].Title) + } + return prs[i].Number - prs[j].Number + }) + requestedPRs = prs + } + + if reviewJSON { + data, err := json.MarshalIndent(reviewOutput{Mine: minePRs, Requested: requestedPRs}, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + } + + if showMine { + if err := printMyPRs(minePRs); err != nil { return err } } @@ -132,7 +178,7 @@ func runReview() error { if showMine { cli.Blank() } - if err := showRequestedReviews(ctx, repoFullName); err != nil { + if err := printRequestedPRs(requestedPRs); err != nil { return err } } @@ -141,12 +187,7 @@ func runReview() error { } // showMyPRs shows the user's open PRs with status -func showMyPRs(ctx context.Context, repo string) error { - prs, err := fetchPRs(ctx, repo, "author:@me") - if err != nil { - return log.E("qa.review", "failed to fetch your PRs", err) - } - +func printMyPRs(prs []PullRequest) error { if len(prs) == 0 { cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_prs"))) return nil @@ -162,12 +203,7 @@ func showMyPRs(ctx context.Context, repo string) error { } // showRequestedReviews shows PRs where user's review is requested -func showRequestedReviews(ctx context.Context, repo string) error { - prs, err := fetchPRs(ctx, repo, "review-requested:@me") - if err != nil { - return log.E("qa.review", "failed to fetch review requests", err) - } - +func printRequestedPRs(prs []PullRequest) error { if len(prs) == 0 { cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_reviews"))) return nil