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