lint/cmd/qa/cmd_issues.go

508 lines
14 KiB
Go
Raw Permalink Normal View History

// cmd_issues.go implements the 'qa issues' command for intelligent issue triage.
//
// Usage:
// core qa issues # Show prioritised, actionable issues
// core qa issues --mine # Show issues assigned to you
// core qa issues --triage # Show issues needing triage (no labels/assignee)
// core qa issues --blocked # Show blocked issues
package qa
import (
"cmp"
"encoding/json"
"os/exec"
"slices"
"strings"
"time"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go-scm/repos"
)
// Issue command flags
var (
issuesMine bool
issuesTriage bool
issuesBlocked bool
issuesRegistry string
issuesLimit int
issuesJSON bool
)
// Issue represents a GitHub issue with triage metadata
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Author struct {
Login string `json:"login"`
} `json:"author"`
Assignees struct {
Nodes []struct {
Login string `json:"login"`
} `json:"nodes"`
} `json:"assignees"`
Labels struct {
Nodes []struct {
Name string `json:"name"`
} `json:"nodes"`
} `json:"labels"`
Comments struct {
TotalCount int `json:"totalCount"`
Nodes []struct {
Author struct {
Login string `json:"login"`
} `json:"author"`
CreatedAt time.Time `json:"createdAt"`
} `json:"nodes"`
} `json:"comments"`
URL string `json:"url"`
// Computed fields
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 {
Repo string `json:"repo"`
Error string `json:"error"`
}
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"`
FetchErrors []IssueFetchError `json:"fetch_errors"`
}
// addIssuesCommand adds the 'issues' subcommand to qa.
func addIssuesCommand(parent *cli.Command) {
issuesCmd := &cli.Command{
Use: "issues",
Short: i18n.T("cmd.qa.issues.short"),
Long: i18n.T("cmd.qa.issues.long"),
RunE: func(cmd *cli.Command, args []string) error {
return runQAIssues()
},
}
issuesCmd.Flags().BoolVarP(&issuesMine, "mine", "m", false, i18n.T("cmd.qa.issues.flag.mine"))
issuesCmd.Flags().BoolVarP(&issuesTriage, "triage", "t", false, i18n.T("cmd.qa.issues.flag.triage"))
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)
}
func runQAIssues() error {
// Check gh is available
if _, err := exec.LookPath("gh"); err != nil {
return log.E("qa.issues", i18n.T("error.gh_not_found"), nil)
}
// Load registry
var reg *repos.Registry
var err error
if issuesRegistry != "" {
reg, err = repos.LoadRegistry(io.Local, issuesRegistry)
} else {
registryPath, findErr := repos.FindRegistry(io.Local)
if findErr != nil {
return log.E("qa.issues", i18n.T("error.registry_not_found"), nil)
}
reg, err = repos.LoadRegistry(io.Local, registryPath)
}
if err != nil {
return log.E("qa.issues", "failed to load registry", err)
}
// Fetch issues from all repos
var allIssues []Issue
fetchErrors := make([]IssueFetchError, 0)
repoList := reg.List()
// Registry repos are map-backed, so sort before fetching to keep output stable.
slices.SortFunc(repoList, func(a, b *repos.Repo) int {
return cmp.Compare(a.Name, b.Name)
})
successfulFetches := 0
for i, repo := range repoList {
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 {
fetchErrors = append(fetchErrors, IssueFetchError{
Repo: repo.Name,
Error: strings.TrimSpace(err.Error()),
})
if !issuesJSON {
cli.Print("%s\n", warningStyle.Render(i18n.T(
"cmd.qa.issues.fetch_error",
map[string]any{"Repo": repo.Name, "Error": strings.TrimSpace(err.Error())},
)))
}
continue // Skip repos with errors
}
allIssues = append(allIssues, issues...)
successfulFetches++
}
totalIssues := len(allIssues)
if len(allIssues) == 0 {
emptyCategorised := map[string][]Issue{
"needs_response": {},
"ready": {},
"blocked": {},
"triage": {},
}
if issuesJSON {
if err := printCategorisedIssuesJSON(0, emptyCategorised, fetchErrors); err != nil {
return err
}
if successfulFetches == 0 && len(fetchErrors) > 0 {
return cli.Err("failed to fetch issues from any repository")
}
return nil
}
if successfulFetches == 0 && len(fetchErrors) > 0 {
return cli.Err("failed to fetch issues from any repository")
}
cli.Text(i18n.T("cmd.qa.issues.no_issues"))
return nil
}
// Categorise and prioritise issues
categorised := categoriseIssues(allIssues)
// Filter based on flags
if issuesMine {
categorised = filterMine(categorised)
}
if issuesTriage {
categorised = filterCategory(categorised, "triage")
}
if issuesBlocked {
categorised = filterCategory(categorised, "blocked")
}
if issuesJSON {
return printCategorisedIssuesJSON(totalIssues, categorised, fetchErrors)
}
// Print categorised issues
printCategorisedIssues(categorised)
return nil
}
func fetchQAIssues(org, repoName string, limit int) ([]Issue, error) {
repoFullName := cli.Sprintf("%s/%s", org, repoName)
args := []string{
"issue", "list",
"--repo", repoFullName,
"--state", "open",
"--limit", cli.Sprintf("%d", limit),
"--json", "number,title,state,body,createdAt,updatedAt,author,assignees,labels,comments,url",
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, log.E("qa.fetchQAIssues", strings.TrimSpace(string(exitErr.Stderr)), nil)
}
return nil, err
}
var issues []Issue
if err := json.Unmarshal(output, &issues); err != nil {
return nil, err
}
// Tag with repo name
for i := range issues {
issues[i].RepoName = repoName
}
return issues, nil
}
func categoriseIssues(issues []Issue) map[string][]Issue {
result := map[string][]Issue{
"needs_response": {},
"ready": {},
"blocked": {},
"triage": {},
}
currentUser := getCurrentUser()
for i := range issues {
issue := &issues[i]
categoriseIssue(issue, currentUser)
result[issue.Category] = append(result[issue.Category], *issue)
}
// Sort each category by priority
for cat := range result {
slices.SortFunc(result[cat], func(a, b Issue) int {
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)
})
}
return result
}
func categoriseIssue(issue *Issue, currentUser string) {
labels := getLabels(issue)
// Check if blocked
for _, l := range labels {
if strings.HasPrefix(l, "blocked") || l == "waiting" {
issue.Category = "blocked"
issue.Priority = 30
issue.ActionHint = i18n.T("cmd.qa.issues.hint.blocked")
return
}
}
// Check if needs triage (no labels, no assignee)
if len(issue.Labels.Nodes) == 0 && len(issue.Assignees.Nodes) == 0 {
issue.Category = "triage"
issue.Priority = 20
issue.ActionHint = i18n.T("cmd.qa.issues.hint.triage")
return
}
// Check if needs response (recent comment from someone else)
if issue.Comments.TotalCount > 0 && len(issue.Comments.Nodes) > 0 {
lastComment := issue.Comments.Nodes[len(issue.Comments.Nodes)-1]
// If last comment is not from current user and is recent
if lastComment.Author.Login != currentUser {
age := time.Since(lastComment.CreatedAt)
if age < 48*time.Hour {
issue.Category = "needs_response"
issue.Priority = 10
issue.ActionHint = cli.Sprintf("@%s %s", lastComment.Author.Login, i18n.T("cmd.qa.issues.hint.needs_response"))
return
}
}
}
// Default: ready to work
issue.Category = "ready"
issue.Priority = calculatePriority(labels)
issue.ActionHint = ""
}
// calculatePriority chooses the most urgent matching label so label order
// does not change how issues are ranked.
func calculatePriority(labels []string) int {
priority := 50
// Priority labels
for _, l := range labels {
switch {
case strings.Contains(l, "critical") || strings.Contains(l, "urgent"):
priority = min(priority, 1)
case strings.Contains(l, "high"):
priority = min(priority, 10)
case strings.Contains(l, "medium"):
priority = min(priority, 30)
case strings.Contains(l, "low"):
priority = min(priority, 70)
case l == "good-first-issue" || l == "good first issue":
priority = min(priority, 15) // Boost good first issues
case l == "help-wanted" || l == "help wanted":
priority = min(priority, 20)
case l == "agent:ready" || l == "agentic":
priority = min(priority, 5) // AI-ready issues are high priority
}
}
return priority
}
func getLabels(issue *Issue) []string {
var labels []string
for _, l := range issue.Labels.Nodes {
labels = append(labels, strings.ToLower(l.Name))
}
return labels
}
func getCurrentUser() string {
cmd := exec.Command("gh", "api", "user", "--jq", ".login")
output, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func filterMine(categorised map[string][]Issue) map[string][]Issue {
currentUser := getCurrentUser()
result := make(map[string][]Issue)
for cat, issues := range categorised {
var filtered []Issue
for _, issue := range issues {
for _, a := range issue.Assignees.Nodes {
if a.Login == currentUser {
filtered = append(filtered, issue)
break
}
}
}
if len(filtered) > 0 {
result[cat] = filtered
}
}
return result
}
func filterCategory(categorised map[string][]Issue, category string) map[string][]Issue {
if issues, ok := categorised[category]; ok && len(issues) > 0 {
return map[string][]Issue{category: issues}
}
return map[string][]Issue{}
}
func printCategorisedIssues(categorised map[string][]Issue) {
// Print in order: needs_response, ready, blocked, triage
categories := []struct {
key string
title string
style *cli.AnsiStyle
}{
{"needs_response", i18n.T("cmd.qa.issues.category.needs_response"), warningStyle},
{"ready", i18n.T("cmd.qa.issues.category.ready"), successStyle},
{"blocked", i18n.T("cmd.qa.issues.category.blocked"), errorStyle},
{"triage", i18n.T("cmd.qa.issues.category.triage"), dimStyle},
}
first := true
for _, cat := range categories {
issues := categorised[cat.key]
if len(issues) == 0 {
continue
}
if !first {
cli.Blank()
}
first = false
cli.Print("%s (%d):\n", cat.style.Render(cat.title), len(issues))
for _, issue := range issues {
printTriagedIssue(issue)
}
}
if first {
cli.Text(i18n.T("cmd.qa.issues.no_issues"))
}
}
func printCategorisedIssuesJSON(totalIssues int, categorised map[string][]Issue, fetchErrors []IssueFetchError) 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,
FetchErrors: fetchErrors,
}
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))
repo := dimStyle.Render(cli.Sprintf("[%s]", issue.RepoName))
title := cli.ValueStyle.Render(truncate(issue.Title, 50))
cli.Print(" %s %s %s", num, repo, title)
// Add labels if priority-related
var importantLabels []string
for _, l := range issue.Labels.Nodes {
name := strings.ToLower(l.Name)
if strings.Contains(name, "priority") || strings.Contains(name, "critical") ||
name == "good-first-issue" || name == "agent:ready" || name == "agentic" {
importantLabels = append(importantLabels, l.Name)
}
}
if len(importantLabels) > 0 {
slices.Sort(importantLabels)
cli.Print(" %s", warningStyle.Render("["+strings.Join(importantLabels, ", ")+"]"))
}
// Add age
age := cli.FormatAge(issue.UpdatedAt)
cli.Print(" %s\n", dimStyle.Render(age))
// Add action hint if present
if issue.ActionHint != "" {
cli.Print(" %s %s\n", dimStyle.Render("->"), issue.ActionHint)
}
}