feat: align qa and lint outputs with agent experience
This commit is contained in:
parent
8ab944d0e7
commit
182f108d37
4 changed files with 185 additions and 38 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue