diff --git a/pkg/dev/cmd_dev.go b/pkg/dev/cmd_dev.go index a595c888..c79d8b8c 100644 --- a/pkg/dev/cmd_dev.go +++ b/pkg/dev/cmd_dev.go @@ -13,6 +13,10 @@ // - ci: Check GitHub Actions CI status // - impact: Analyse dependency impact of changes // +// CI/Workflow Management: +// - workflow list: Show table of repos vs workflows +// - workflow sync: Copy workflow template to all repos +// // API Tools: // - api sync: Synchronize public service APIs // @@ -77,6 +81,9 @@ func AddDevCommands(root *cli.Command) { addCICommand(devCmd) addImpactCommand(devCmd) + // CI/Workflow management + addWorkflowCommands(devCmd) + // API tools addAPICommands(devCmd) diff --git a/pkg/dev/cmd_workflow.go b/pkg/dev/cmd_workflow.go new file mode 100644 index 00000000..354f9387 --- /dev/null +++ b/pkg/dev/cmd_workflow.go @@ -0,0 +1,307 @@ +package dev + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +// Workflow command flags +var ( + workflowRegistryPath string + workflowDryRun bool +) + +// addWorkflowCommands adds the 'workflow' subcommand and its subcommands. +func addWorkflowCommands(parent *cli.Command) { + workflowCmd := &cli.Command{ + Use: "workflow", + Short: i18n.T("cmd.dev.workflow.short"), + Long: i18n.T("cmd.dev.workflow.long"), + } + + // Shared flags + workflowCmd.PersistentFlags().StringVar(&workflowRegistryPath, "registry", "", i18n.T("common.flag.registry")) + + // Subcommands + addWorkflowListCommand(workflowCmd) + addWorkflowSyncCommand(workflowCmd) + + parent.AddCommand(workflowCmd) +} + +// addWorkflowListCommand adds the 'workflow list' subcommand. +func addWorkflowListCommand(parent *cli.Command) { + listCmd := &cli.Command{ + Use: "list", + Short: i18n.T("cmd.dev.workflow.list.short"), + Long: i18n.T("cmd.dev.workflow.list.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runWorkflowList(workflowRegistryPath) + }, + } + + parent.AddCommand(listCmd) +} + +// addWorkflowSyncCommand adds the 'workflow sync' subcommand. +func addWorkflowSyncCommand(parent *cli.Command) { + syncCmd := &cli.Command{ + Use: "sync ", + Short: i18n.T("cmd.dev.workflow.sync.short"), + Long: i18n.T("cmd.dev.workflow.sync.long"), + Args: cli.ExactArgs(1), + RunE: func(cmd *cli.Command, args []string) error { + return runWorkflowSync(workflowRegistryPath, args[0], workflowDryRun) + }, + } + + syncCmd.Flags().BoolVar(&workflowDryRun, "dry-run", false, i18n.T("cmd.dev.workflow.sync.flag.dry_run")) + + parent.AddCommand(syncCmd) +} + +// runWorkflowList shows a table of repos vs workflows. +func runWorkflowList(registryPath string) error { + reg, registryDir, err := loadRegistryWithConfig(registryPath) + if err != nil { + return err + } + + repoList := reg.List() + if len(repoList) == 0 { + cli.Text(i18n.T("cmd.dev.no_git_repos")) + return nil + } + + // Sort repos by name for consistent output + sort.Slice(repoList, func(i, j int) bool { + return repoList[i].Name < repoList[j].Name + }) + + // Collect all unique workflow files across all repos + workflowSet := make(map[string]bool) + repoWorkflows := make(map[string]map[string]bool) + + for _, repo := range repoList { + workflows := findWorkflows(repo.Path) + repoWorkflows[repo.Name] = make(map[string]bool) + for _, wf := range workflows { + workflowSet[wf] = true + repoWorkflows[repo.Name][wf] = true + } + } + + // Sort workflow names + var workflowNames []string + for wf := range workflowSet { + workflowNames = append(workflowNames, wf) + } + sort.Strings(workflowNames) + + if len(workflowNames) == 0 { + cli.Text(i18n.T("cmd.dev.workflow.no_workflows")) + return nil + } + + // Check for template workflows in the registry directory + templateWorkflows := findWorkflows(filepath.Join(registryDir, ".github", "workflow-templates")) + if len(templateWorkflows) == 0 { + // Also check .github/workflows in the devops repo itself + templateWorkflows = findWorkflows(filepath.Join(registryDir, ".github", "workflows")) + } + templateSet := make(map[string]bool) + for _, wf := range templateWorkflows { + templateSet[wf] = true + } + + // Build table + headers := []string{i18n.T("cmd.dev.workflow.header.repo")} + headers = append(headers, workflowNames...) + table := cli.NewTable(headers...) + + for _, repo := range repoList { + row := []string{repo.Name} + for _, wf := range workflowNames { + if repoWorkflows[repo.Name][wf] { + row = append(row, successStyle.Render(cli.Glyph(":check:"))) + } else { + row = append(row, errorStyle.Render(cli.Glyph(":cross:"))) + } + } + table.AddRow(row...) + } + + cli.Blank() + table.Render() + + return nil +} + +// runWorkflowSync copies a workflow template to all repos. +func runWorkflowSync(registryPath string, workflowFile string, dryRun bool) error { + reg, registryDir, err := loadRegistryWithConfig(registryPath) + if err != nil { + return err + } + + // Find the template workflow + templatePath := findTemplateWorkflow(registryDir, workflowFile) + if templatePath == "" { + return cli.Err("%s", i18n.T("cmd.dev.workflow.template_not_found", map[string]interface{}{"File": workflowFile})) + } + + // Read template content + templateContent, err := os.ReadFile(templatePath) + if err != nil { + return cli.Wrap(err, i18n.T("cmd.dev.workflow.read_template_error")) + } + + repoList := reg.List() + if len(repoList) == 0 { + cli.Text(i18n.T("cmd.dev.no_git_repos")) + return nil + } + + // Sort repos by name for consistent output + sort.Slice(repoList, func(i, j int) bool { + return repoList[i].Name < repoList[j].Name + }) + + if dryRun { + cli.Text(i18n.T("cmd.dev.workflow.dry_run_mode")) + cli.Blank() + } + + var synced, skipped, failed int + + for _, repo := range repoList { + if !repo.IsGitRepo() { + skipped++ + continue + } + + destDir := filepath.Join(repo.Path, ".github", "workflows") + destPath := filepath.Join(destDir, workflowFile) + + // Check if workflow already exists and is identical + if existingContent, err := os.ReadFile(destPath); err == nil { + if string(existingContent) == string(templateContent) { + cli.Print(" %s %s %s\n", + dimStyle.Render("-"), + repoNameStyle.Render(repo.Name), + dimStyle.Render(i18n.T("cmd.dev.workflow.up_to_date"))) + skipped++ + continue + } + } + + if dryRun { + cli.Print(" %s %s %s\n", + warningStyle.Render("*"), + repoNameStyle.Render(repo.Name), + i18n.T("cmd.dev.workflow.would_sync")) + synced++ + continue + } + + // Create .github/workflows directory if needed + if err := os.MkdirAll(destDir, 0755); err != nil { + cli.Print(" %s %s %s\n", + errorStyle.Render(cli.Glyph(":cross:")), + repoNameStyle.Render(repo.Name), + err.Error()) + failed++ + continue + } + + // Write workflow file + if err := os.WriteFile(destPath, templateContent, 0644); err != nil { + cli.Print(" %s %s %s\n", + errorStyle.Render(cli.Glyph(":cross:")), + repoNameStyle.Render(repo.Name), + err.Error()) + failed++ + continue + } + + cli.Print(" %s %s %s\n", + successStyle.Render(cli.Glyph(":check:")), + repoNameStyle.Render(repo.Name), + i18n.T("cmd.dev.workflow.synced")) + synced++ + } + + cli.Blank() + + // Summary + if dryRun { + cli.Print("%s %s\n", + i18n.T("cmd.dev.workflow.would_sync_count", map[string]interface{}{"Count": synced}), + dimStyle.Render(i18n.T("cmd.dev.workflow.skipped_count", map[string]interface{}{"Count": skipped}))) + cli.Text(i18n.T("cmd.dev.workflow.run_without_dry_run")) + } else { + cli.Print("%s %s\n", + successStyle.Render(i18n.T("cmd.dev.workflow.synced_count", map[string]interface{}{"Count": synced})), + dimStyle.Render(i18n.T("cmd.dev.workflow.skipped_count", map[string]interface{}{"Count": skipped}))) + if failed > 0 { + cli.Print("%s\n", errorStyle.Render(i18n.T("cmd.dev.workflow.failed_count", map[string]interface{}{"Count": failed}))) + } + } + + return nil +} + +// findWorkflows returns a list of workflow file names in a directory. +func findWorkflows(dir string) []string { + workflowsDir := filepath.Join(dir, ".github", "workflows") + // If dir already ends with workflows path, use it directly + if strings.HasSuffix(dir, "workflows") || strings.HasSuffix(dir, "workflow-templates") { + workflowsDir = dir + } + + entries, err := os.ReadDir(workflowsDir) + if err != nil { + return nil + } + + var workflows []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") { + workflows = append(workflows, name) + } + } + + return workflows +} + +// findTemplateWorkflow finds a workflow template file in common locations. +func findTemplateWorkflow(registryDir, workflowFile string) string { + // Ensure .yml extension + if !strings.HasSuffix(workflowFile, ".yml") && !strings.HasSuffix(workflowFile, ".yaml") { + workflowFile = workflowFile + ".yml" + } + + // Check common template locations + candidates := []string{ + filepath.Join(registryDir, ".github", "workflow-templates", workflowFile), + filepath.Join(registryDir, ".github", "workflows", workflowFile), + filepath.Join(registryDir, "workflow-templates", workflowFile), + } + + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate + } + } + + return "" +} diff --git a/pkg/dev/cmd_workflow_test.go b/pkg/dev/cmd_workflow_test.go new file mode 100644 index 00000000..3f0cd822 --- /dev/null +++ b/pkg/dev/cmd_workflow_test.go @@ -0,0 +1,107 @@ +package dev + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindWorkflows_Good(t *testing.T) { + // Create a temp directory with workflow files + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows dir: %v", err) + } + + // Create some workflow files + for _, name := range []string{"qa.yml", "tests.yml", "codeql.yaml"} { + if err := os.WriteFile(filepath.Join(workflowsDir, name), []byte("name: Test"), 0644); err != nil { + t.Fatalf("Failed to create workflow file: %v", err) + } + } + + // Create a non-workflow file (should be ignored) + if err := os.WriteFile(filepath.Join(workflowsDir, "readme.md"), []byte("# Workflows"), 0644); err != nil { + t.Fatalf("Failed to create readme file: %v", err) + } + + workflows := findWorkflows(tmpDir) + + if len(workflows) != 3 { + t.Errorf("Expected 3 workflows, got %d", len(workflows)) + } + + // Check that all expected workflows are found + found := make(map[string]bool) + for _, wf := range workflows { + found[wf] = true + } + + for _, expected := range []string{"qa.yml", "tests.yml", "codeql.yaml"} { + if !found[expected] { + t.Errorf("Expected to find workflow %s", expected) + } + } +} + +func TestFindWorkflows_NoWorkflowsDir(t *testing.T) { + tmpDir := t.TempDir() + workflows := findWorkflows(tmpDir) + + if len(workflows) != 0 { + t.Errorf("Expected 0 workflows for non-existent dir, got %d", len(workflows)) + } +} + +func TestFindTemplateWorkflow_Good(t *testing.T) { + tmpDir := t.TempDir() + templatesDir := filepath.Join(tmpDir, ".github", "workflow-templates") + if err := os.MkdirAll(templatesDir, 0755); err != nil { + t.Fatalf("Failed to create templates dir: %v", err) + } + + templateContent := "name: QA\non: [push]" + if err := os.WriteFile(filepath.Join(templatesDir, "qa.yml"), []byte(templateContent), 0644); err != nil { + t.Fatalf("Failed to create template file: %v", err) + } + + // Test finding with .yml extension + result := findTemplateWorkflow(tmpDir, "qa.yml") + if result == "" { + t.Error("Expected to find qa.yml template") + } + + // Test finding without extension (should auto-add .yml) + result = findTemplateWorkflow(tmpDir, "qa") + if result == "" { + t.Error("Expected to find qa template without extension") + } +} + +func TestFindTemplateWorkflow_FallbackToWorkflows(t *testing.T) { + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create workflows dir: %v", err) + } + + templateContent := "name: Tests\non: [push]" + if err := os.WriteFile(filepath.Join(workflowsDir, "tests.yml"), []byte(templateContent), 0644); err != nil { + t.Fatalf("Failed to create workflow file: %v", err) + } + + result := findTemplateWorkflow(tmpDir, "tests.yml") + if result == "" { + t.Error("Expected to find tests.yml in workflows dir") + } +} + +func TestFindTemplateWorkflow_NotFound(t *testing.T) { + tmpDir := t.TempDir() + + result := findTemplateWorkflow(tmpDir, "nonexistent.yml") + if result != "" { + t.Errorf("Expected empty string for non-existent template, got %s", result) + } +} diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index df398c2b..d58c444c 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -299,7 +299,41 @@ "review.review_requested": "Review Requested", "review.no_prs": "No open PRs", "review.no_reviews": "No reviews requested", - "review.error.no_repo": "Not in a git repository. Use --repo to specify one" + "review.error.no_repo": "Not in a git repository. Use --repo to specify one", + "health.short": "Aggregate CI health across all repos", + "health.long": "Shows CI health summary across all repos with focus on problems that need attention.", + "health.flag.problems": "Show only repos with problems", + "health.summary": "CI Health", + "health.all_healthy": "All repos are healthy", + "health.passing": "Passing", + "health.tests_failing": "Tests failing", + "health.running": "Running", + "health.cancelled": "Cancelled", + "health.skipped": "Skipped", + "health.no_ci_configured": "No CI configured", + "health.workflow_disabled": "Workflow disabled", + "health.fetch_error": "Failed to fetch status", + "health.parse_error": "Failed to parse response", + "health.count_passing": "Passing", + "health.count_failing": "Failing", + "health.count_pending": "Pending", + "health.count_no_ci": "No CI", + "health.count_disabled": "Disabled", + "issues.short": "Intelligent issue triage", + "issues.long": "Show prioritised, actionable issues across all repos. Groups by: needs response, ready to work, blocked, and needs triage.", + "issues.flag.mine": "Show only issues assigned to you", + "issues.flag.triage": "Show only issues needing triage", + "issues.flag.blocked": "Show only blocked issues", + "issues.flag.limit": "Maximum issues per repo", + "issues.fetching": "Fetching...", + "issues.no_issues": "No open issues found", + "issues.category.needs_response": "Needs Response", + "issues.category.ready": "Ready to Work", + "issues.category.blocked": "Blocked", + "issues.category.triage": "Needs Triage", + "issues.hint.needs_response": "commented recently", + "issues.hint.blocked": "Waiting on dependency", + "issues.hint.triage": "Add labels and assignee" }, "test": { "short": "Run Go tests with coverage" diff --git a/pkg/qa/cmd_health.go b/pkg/qa/cmd_health.go new file mode 100644 index 00000000..1a3d4b1e --- /dev/null +++ b/pkg/qa/cmd_health.go @@ -0,0 +1,288 @@ +// cmd_health.go implements the 'qa health' command for aggregate CI health. +// +// Usage: +// core qa health # Show CI health summary +// core qa health --problems # Show only repos with problems + +package qa + +import ( + "encoding/json" + "os/exec" + "sort" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/repos" +) + +// Health command flags +var ( + healthProblems bool + healthRegistry string +) + +// HealthWorkflowRun represents a GitHub Actions workflow run +type HealthWorkflowRun struct { + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Name string `json:"name"` + HeadSha string `json:"headSha"` + UpdatedAt string `json:"updatedAt"` + URL string `json:"url"` +} + +// 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 +} + +// addHealthCommand adds the 'health' subcommand to qa. +func addHealthCommand(parent *cli.Command) { + healthCmd := &cli.Command{ + Use: "health", + Short: i18n.T("cmd.qa.health.short"), + Long: i18n.T("cmd.qa.health.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runHealth() + }, + } + + healthCmd.Flags().BoolVarP(&healthProblems, "problems", "p", false, i18n.T("cmd.qa.health.flag.problems")) + healthCmd.Flags().StringVar(&healthRegistry, "registry", "", i18n.T("common.flag.registry")) + + parent.AddCommand(healthCmd) +} + +func runHealth() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return errors.E("qa.health", i18n.T("error.gh_not_found"), nil) + } + + // Load registry + var reg *repos.Registry + var err error + + if healthRegistry != "" { + reg, err = repos.LoadRegistry(healthRegistry) + } else { + registryPath, findErr := repos.FindRegistry() + if findErr != nil { + return errors.E("qa.health", i18n.T("error.registry_not_found"), nil) + } + reg, err = repos.LoadRegistry(registryPath) + } + if err != nil { + return errors.E("qa.health", "failed to load registry", err) + } + + // Fetch CI status from all repos + 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) + + health := fetchRepoHealth(reg.Org, repo.Name) + healthResults = append(healthResults, health) + } + cli.Print("\033[2K\r") // Clear progress + + // Sort: problems first, then passing + sort.Slice(healthResults, func(i, j int) bool { + return healthPriority(healthResults[i].Status) < healthPriority(healthResults[j].Status) + }) + + // Filter if --problems flag + if healthProblems { + var problems []RepoHealth + for _, h := range healthResults { + if h.Status != "passing" { + problems = append(problems, h) + } + } + 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 + } + + // Print summary + cli.Print("%s: %d/%d repos healthy (%d%%)\n\n", + i18n.T("cmd.qa.health.summary"), + passing, total, percentage) + + if len(healthResults) == 0 { + cli.Text(i18n.T("cmd.qa.health.all_healthy")) + return nil + } + + // Group by status + grouped := make(map[string][]RepoHealth) + for _, h := range healthResults { + grouped[h.Status] = append(grouped[h.Status], h) + } + + // Print problems first + printHealthGroup("failing", grouped["failing"], errorStyle) + printHealthGroup("pending", grouped["pending"], warningStyle) + printHealthGroup("no_ci", grouped["no_ci"], dimStyle) + printHealthGroup("disabled", grouped["disabled"], dimStyle) + + if !healthProblems { + printHealthGroup("passing", grouped["passing"], successStyle) + } + + return nil +} + +func fetchRepoHealth(org, repoName string) RepoHealth { + repoFullName := cli.Sprintf("%s/%s", org, repoName) + + args := []string{ + "run", "list", + "--repo", repoFullName, + "--limit", "1", + "--json", "status,conclusion,name,headSha,updatedAt,url", + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + // Check if it's a 404 (no workflows) + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + if strings.Contains(stderr, "no workflows") || strings.Contains(stderr, "not found") { + return RepoHealth{ + Name: repoName, + Status: "no_ci", + Message: i18n.T("cmd.qa.health.no_ci_configured"), + } + } + } + return RepoHealth{ + Name: repoName, + Status: "no_ci", + Message: i18n.T("cmd.qa.health.fetch_error"), + } + } + + var runs []HealthWorkflowRun + if err := json.Unmarshal(output, &runs); err != nil { + return RepoHealth{ + Name: repoName, + Status: "no_ci", + Message: i18n.T("cmd.qa.health.parse_error"), + } + } + + if len(runs) == 0 { + return RepoHealth{ + Name: repoName, + Status: "no_ci", + Message: i18n.T("cmd.qa.health.no_ci_configured"), + } + } + + run := runs[0] + health := RepoHealth{ + Name: repoName, + URL: run.URL, + } + + switch run.Status { + case "completed": + switch run.Conclusion { + case "success": + health.Status = "passing" + health.Message = i18n.T("cmd.qa.health.passing") + case "failure": + health.Status = "failing" + health.Message = i18n.T("cmd.qa.health.tests_failing") + case "cancelled": + health.Status = "pending" + health.Message = i18n.T("cmd.qa.health.cancelled") + case "skipped": + health.Status = "passing" + health.Message = i18n.T("cmd.qa.health.skipped") + default: + health.Status = "failing" + health.Message = run.Conclusion + } + case "in_progress", "queued", "waiting": + health.Status = "pending" + health.Message = i18n.T("cmd.qa.health.running") + default: + health.Status = "no_ci" + health.Message = run.Status + } + + return health +} + +func healthPriority(status string) int { + switch status { + case "failing": + return 0 + case "pending": + return 1 + case "no_ci": + return 2 + case "disabled": + return 3 + case "passing": + return 4 + default: + return 5 + } +} + +func printHealthGroup(status string, repos []RepoHealth, style *cli.AnsiStyle) { + if len(repos) == 0 { + return + } + + var label string + switch status { + case "failing": + label = i18n.T("cmd.qa.health.count_failing") + case "pending": + label = i18n.T("cmd.qa.health.count_pending") + case "no_ci": + label = i18n.T("cmd.qa.health.count_no_ci") + case "disabled": + label = i18n.T("cmd.qa.health.count_disabled") + case "passing": + label = i18n.T("cmd.qa.health.count_passing") + } + + cli.Print("%s (%d):\n", style.Render(label), len(repos)) + for _, repo := range repos { + cli.Print(" %s %s\n", + cli.RepoStyle.Render(repo.Name), + dimStyle.Render(repo.Message)) + if repo.URL != "" && status == "failing" { + cli.Print(" -> %s\n", dimStyle.Render(repo.URL)) + } + } + cli.Blank() +} diff --git a/pkg/qa/cmd_issues.go b/pkg/qa/cmd_issues.go new file mode 100644 index 00000000..d243fc03 --- /dev/null +++ b/pkg/qa/cmd_issues.go @@ -0,0 +1,400 @@ +// 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 ( + "encoding/json" + "os/exec" + "sort" + "strings" + "time" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/errors" + "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/repos" +) + +// Issue command flags +var ( + issuesMine bool + issuesTriage bool + issuesBlocked bool + issuesRegistry string + issuesLimit int +) + +// 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 + Priority int // Lower = higher priority + Category string // "needs_response", "ready", "blocked", "triage" + ActionHint string +} + +// 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")) + + parent.AddCommand(issuesCmd) +} + +func runQAIssues() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return errors.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(issuesRegistry) + } else { + registryPath, findErr := repos.FindRegistry() + if findErr != nil { + return errors.E("qa.issues", i18n.T("error.registry_not_found"), nil) + } + reg, err = repos.LoadRegistry(registryPath) + } + if err != nil { + return errors.E("qa.issues", "failed to load registry", err) + } + + // Fetch issues from all repos + 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) + + 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")) + 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") + } + + // 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 { + 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 { + sort.Slice(result[cat], func(i, j int) bool { + return result[cat][i].Priority < result[cat][j].Priority + }) + } + + 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(issue, labels) + issue.ActionHint = "" +} + +func calculatePriority(issue *Issue, labels []string) int { + priority := 50 + + // Priority labels + for _, l := range labels { + switch { + case strings.Contains(l, "critical") || strings.Contains(l, "urgent"): + priority = 1 + case strings.Contains(l, "high"): + priority = 10 + case strings.Contains(l, "medium"): + priority = 30 + case strings.Contains(l, "low"): + 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 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 { + 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) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/qa/cmd_qa.go b/pkg/qa/cmd_qa.go index c862f852..9d699111 100644 --- a/pkg/qa/cmd_qa.go +++ b/pkg/qa/cmd_qa.go @@ -6,10 +6,8 @@ // Commands: // - watch: Monitor GitHub Actions after a push, report actionable data // - review: PR review status with actionable next steps -// -// Future commands: -// - issues: Intelligent issue triage // - health: Aggregate CI health across all repos +// - issues: Intelligent issue triage package qa import ( @@ -41,4 +39,6 @@ func AddQACommands(root *cli.Command) { // Subcommands addWatchCommand(qaCmd) addReviewCommand(qaCmd) + addHealthCommand(qaCmd) + addIssuesCommand(qaCmd) } diff --git a/pkg/qa/cmd_watch.go b/pkg/qa/cmd_watch.go index 251d3f6e..2db17fe3 100644 --- a/pkg/qa/cmd_watch.go +++ b/pkg/qa/cmd_watch.go @@ -335,7 +335,7 @@ func printResults(ctx context.Context, repoFullName string, runs []WorkflowRun) // Exit with error if any failures if len(failures) > 0 { cli.Blank() - return cli.Err(i18n.T("cmd.qa.watch.workflows_failed", map[string]interface{}{"Count": len(failures)})) + return cli.Err("%s", i18n.T("cmd.qa.watch.workflows_failed", map[string]interface{}{"Count": len(failures)})) } cli.Blank()