diff --git a/cmd/core-lint/main.go b/cmd/core-lint/main.go index 6c13e9c..8956016 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" @@ -93,17 +94,42 @@ func addLintCommands(root *cli.Command) { lintpkg.WriteText(os.Stdout, allFindings) } - if len(allFindings) > 0 { + if checkFormat == "text" && len(allFindings) > 0 { summary := lintpkg.Summarise(allFindings) - fmt.Fprintf(os.Stderr, "\n%d finding(s)", summary.Total) + fmt.Fprintf(os.Stdout, "\n%d finding(s)", summary.Total) + + orderedSeverities := []string{"critical", "high", "medium", "low", "info"} + seen := map[string]bool{} var parts []string - for sev, count := range summary.BySeverity { + + for _, sev := range orderedSeverities { + count := summary.BySeverity[sev] + if count == 0 { + continue + } + seen[sev] = true parts = append(parts, fmt.Sprintf("%d %s", count, sev)) } - if len(parts) > 0 { - fmt.Fprintf(os.Stderr, " (%s)", strings.Join(parts, ", ")) + + var extraSeverities []string + for severity := range summary.BySeverity { + if seen[severity] { + continue + } + extraSeverities = append(extraSeverities, severity) } - fmt.Fprintln(os.Stderr) + sort.Strings(extraSeverities) + for _, severity := range extraSeverities { + count := summary.BySeverity[severity] + if count == 0 { + continue + } + parts = append(parts, fmt.Sprintf("%d %s", count, severity)) + } + if len(parts) > 0 { + fmt.Fprintf(os.Stdout, " (%s)", strings.Join(parts, ", ")) + } + fmt.Fprintln(os.Stdout) } return nil diff --git a/cmd/qa/cmd_health.go b/cmd/qa/cmd_health.go index 3773908..e548b3d 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,11 +39,29 @@ type HealthWorkflowRun struct { // 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 + Name string `json:"name"` + Status string `json:"status"` // "passing", "failing", "pending", "no_ci", "disabled" + Message string `json:"message"` + URL string `json:"url"` + FailingSince string `json:"failing_since"` +} + +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"` + ByStatus map[string]int `json:"by_status"` +} + +type HealthOutput struct { + Summary HealthSummary `json:"summary"` + Repos []RepoHealth `json:"repos"` } // addHealthCommand adds the 'health' subcommand to qa. @@ -58,6 +77,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) } @@ -90,18 +110,22 @@ func runHealth() error { 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) + if !healthJSON { + cli.Print("%s %d/%d %s\n", + 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 slices.SortFunc(healthResults, func(a, b RepoHealth) int { - return cmp.Compare(healthPriority(a.Status), healthPriority(b.Status)) + return cmp.Or( + cmp.Compare(healthPriority(a.Status), healthPriority(b.Status)), + cmp.Compare(a.Name, b.Name), + ) }) // Filter if --problems flag @@ -115,19 +139,16 @@ func runHealth() error { 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 + summary := summariseHealthResults(len(repoList), healthResults, healthProblems) + if healthJSON { + return printHealthJSON(summary, healthResults) } + // Calculate human summary + passing := summary.Passing + total := summary.TotalRepos + percentage := summary.PassingRate + // Print summary cli.Print("%s: %d/%d repos healthy (%d%%)\n\n", i18n.T("cmd.qa.health.summary"), @@ -258,6 +279,49 @@ func healthPriority(status string) int { } } +func summariseHealthResults(totalRepos int, results []RepoHealth, problemsOnly bool) HealthSummary { + summary := HealthSummary{ + TotalRepos: totalRepos, + FilteredRepos: len(results), + ByStatus: make(map[string]int), + ProblemsOnly: problemsOnly, + } + + for _, health := range results { + summary.ByStatus[health.Status]++ + switch health.Status { + case "passing": + summary.Passing++ + case "failing": + summary.Failing++ + case "pending": + summary.Pending++ + case "disabled": + summary.Disabled++ + case "no_ci": + summary.NotConfigured++ + } + } + + if summary.TotalRepos > 0 { + summary.PassingRate = (summary.Passing * 100) / summary.TotalRepos + } + + return summary +} + +func printHealthJSON(summary HealthSummary, repos []RepoHealth) error { + data, err := json.MarshalIndent(HealthOutput{ + Summary: summary, + Repos: repos, + }, "", " ") + if err != nil { + return err + } + cli.Print("%s\n", string(data)) + return nil +} + func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) { if len(repos) == 0 { return diff --git a/cmd/qa/cmd_issues.go b/cmd/qa/cmd_issues.go index 3934ab8..9e9dba0 100644 --- a/cmd/qa/cmd_issues.go +++ b/cmd/qa/cmd_issues.go @@ -28,6 +28,7 @@ var ( issuesMine bool issuesTriage bool issuesBlocked bool + issuesJSON bool issuesRegistry string issuesLimit int ) @@ -65,10 +66,25 @@ 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 + 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"` +} + +type IssueCategoryOutput struct { + Category string `json:"category"` + Count int `json:"count"` + Issues []Issue `json:"issues"` +} + +type IssuesOutput struct { + TotalIssues int `json:"total_issues"` + FilteredIssues int `json:"filtered_issues"` + ShowingMine bool `json:"showing_mine"` + ShowingTriage bool `json:"showing_triage"` + ShowingBlocked bool `json:"showing_blocked"` + Categories []IssueCategoryOutput `json:"categories"` } // addIssuesCommand adds the 'issues' subcommand to qa. @@ -87,6 +103,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) } @@ -119,9 +136,11 @@ func runQAIssues() error { 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) + if !issuesJSON { + cli.Print("%s %d/%d %s\n", + dimStyle.Render(i18n.T("cmd.qa.issues.fetching")), + i+1, len(repoList), repo.Name) + } issues, err := fetchQAIssues(reg.Org, repo.Name, issuesLimit) if err != nil { @@ -129,9 +148,18 @@ func runQAIssues() error { } allIssues = append(allIssues, issues...) } - cli.Print("\033[2K\r") // Clear progress + totalIssues := len(allIssues) if len(allIssues) == 0 { + emptyCategorised := map[string][]Issue{ + "needs_response": {}, + "ready": {}, + "blocked": {}, + "triage": {}, + } + if issuesJSON { + return printCategorisedIssuesJSON(0, emptyCategorised) + } cli.Text(i18n.T("cmd.qa.issues.no_issues")) return nil } @@ -150,6 +178,10 @@ func runQAIssues() error { categorised = filterCategory(categorised, "blocked") } + if issuesJSON { + return printCategorisedIssuesJSON(totalIssues, categorised) + } + // Print categorised issues printCategorisedIssues(categorised) @@ -363,6 +395,38 @@ func printCategorisedIssues(categorised map[string][]Issue) { } } +func printCategorisedIssuesJSON(totalIssues int, categorised map[string][]Issue) error { + categories := []string{"needs_response", "ready", "blocked", "triage"} + filteredIssues := 0 + categoryOutput := make([]IssueCategoryOutput, 0, len(categories)) + + for _, category := range categories { + issues := categorised[category] + filteredIssues += len(issues) + categoryOutput = append(categoryOutput, IssueCategoryOutput{ + Category: category, + Count: len(issues), + Issues: issues, + }) + } + + output := IssuesOutput{ + TotalIssues: totalIssues, + FilteredIssues: filteredIssues, + ShowingMine: issuesMine, + ShowingTriage: issuesTriage, + ShowingBlocked: issuesBlocked, + Categories: categoryOutput, + } + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + return err + } + cli.Print("%s\n", string(data)) + return nil +} + func printTriagedIssue(issue Issue) { // #42 [core-bio] Fix avatar upload num := cli.TitleStyle.Render(cli.Sprintf("#%d", issue.Number)) diff --git a/cmd/qa/cmd_review.go b/cmd/qa/cmd_review.go index b1088f5..cb5b62b 100644 --- a/cmd/qa/cmd_review.go +++ b/cmd/qa/cmd_review.go @@ -25,6 +25,7 @@ var ( reviewMine bool reviewRequested bool reviewRepo string + reviewJSON bool ) // PullRequest represents a GitHub pull request @@ -81,6 +82,12 @@ type Review struct { State string `json:"state"` } +type ReviewOutput struct { + Repository string `json:"repository"` + MyPRs []PullRequest `json:"my_pull_requests"` + Requested []PullRequest `json:"requested_pull_requests"` +} + // 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,38 @@ func runReview() error { // Default: show both mine and requested if neither flag is set showMine := reviewMine || (!reviewMine && !reviewRequested) showRequested := reviewRequested || (!reviewMine && !reviewRequested) + var myPRs, requestedPRs []PullRequest if showMine { - if err := showMyPRs(ctx, repoFullName); err != nil { + var err error + myPRs, err = fetchPRs(ctx, repoFullName, "author:@me") + if err != nil { + return log.E("qa.review", "failed to fetch your PRs", err) + } + } + if showRequested { + var err error + requestedPRs, err = fetchPRs(ctx, repoFullName, "review-requested:@me") + if err != nil { + return log.E("qa.review", "failed to fetch review requests", err) + } + } + + if reviewJSON { + data, err := json.MarshalIndent(ReviewOutput{ + Repository: repoFullName, + MyPRs: myPRs, + Requested: requestedPRs, + }, "", " ") + if err != nil { + return err + } + cli.Print("%s\n", string(data)) + return nil + } + + if showMine { + if err := printMyPRs(myPRs); err != nil { return err } } @@ -132,7 +169,7 @@ func runReview() error { if showMine { cli.Blank() } - if err := showRequestedReviews(ctx, repoFullName); err != nil { + if err := printRequestedPRs(requestedPRs); err != nil { return err } } @@ -140,13 +177,8 @@ func runReview() error { return nil } -// 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) - } - +// printMyPRs shows the user's open PRs with status +func printMyPRs(prs []PullRequest) error { if len(prs) == 0 { cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_prs"))) return nil @@ -161,13 +193,8 @@ func showMyPRs(ctx context.Context, repo string) error { return nil } -// 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) - } - +// printRequestedPRs shows PRs where user's review is requested +func printRequestedPRs(prs []PullRequest) error { if len(prs) == 0 { cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_reviews"))) return nil