feat: align qa and lint outputs with agent experience

This commit is contained in:
Virgil 2026-03-30 07:21:21 +00:00
parent 8ab944d0e7
commit 182f108d37
4 changed files with 185 additions and 38 deletions

View file

@ -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)
}

View file

@ -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":

View file

@ -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)
})
}

View file

@ -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