feat(qa,dev): add issues, health, and workflow commands (#67)
- qa issues: intelligent issue triage with priority grouping - Groups: needs response, ready to work, blocked, needs triage - Flags: --mine, --triage, --blocked Closes #61 - qa health: aggregate CI health across all repos - Shows passing/failing/pending summary - Flag: --problems for filtering Closes #63 - dev workflow: CI template management - list: show workflows across repos - sync: copy workflow to repos (with --dry-run) Closes #54 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
17ca111a1c
commit
15d803c0e7
8 changed files with 1148 additions and 5 deletions
|
|
@ -13,6 +13,10 @@
|
||||||
// - ci: Check GitHub Actions CI status
|
// - ci: Check GitHub Actions CI status
|
||||||
// - impact: Analyse dependency impact of changes
|
// - 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 Tools:
|
||||||
// - api sync: Synchronize public service APIs
|
// - api sync: Synchronize public service APIs
|
||||||
//
|
//
|
||||||
|
|
@ -77,6 +81,9 @@ func AddDevCommands(root *cli.Command) {
|
||||||
addCICommand(devCmd)
|
addCICommand(devCmd)
|
||||||
addImpactCommand(devCmd)
|
addImpactCommand(devCmd)
|
||||||
|
|
||||||
|
// CI/Workflow management
|
||||||
|
addWorkflowCommands(devCmd)
|
||||||
|
|
||||||
// API tools
|
// API tools
|
||||||
addAPICommands(devCmd)
|
addAPICommands(devCmd)
|
||||||
|
|
||||||
|
|
|
||||||
307
pkg/dev/cmd_workflow.go
Normal file
307
pkg/dev/cmd_workflow.go
Normal file
|
|
@ -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 <workflow>",
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
107
pkg/dev/cmd_workflow_test.go
Normal file
107
pkg/dev/cmd_workflow_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -299,7 +299,41 @@
|
||||||
"review.review_requested": "Review Requested",
|
"review.review_requested": "Review Requested",
|
||||||
"review.no_prs": "No open PRs",
|
"review.no_prs": "No open PRs",
|
||||||
"review.no_reviews": "No reviews requested",
|
"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": {
|
"test": {
|
||||||
"short": "Run Go tests with coverage"
|
"short": "Run Go tests with coverage"
|
||||||
|
|
|
||||||
288
pkg/qa/cmd_health.go
Normal file
288
pkg/qa/cmd_health.go
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
400
pkg/qa/cmd_issues.go
Normal file
400
pkg/qa/cmd_issues.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,8 @@
|
||||||
// Commands:
|
// Commands:
|
||||||
// - watch: Monitor GitHub Actions after a push, report actionable data
|
// - watch: Monitor GitHub Actions after a push, report actionable data
|
||||||
// - review: PR review status with actionable next steps
|
// - review: PR review status with actionable next steps
|
||||||
//
|
|
||||||
// Future commands:
|
|
||||||
// - issues: Intelligent issue triage
|
|
||||||
// - health: Aggregate CI health across all repos
|
// - health: Aggregate CI health across all repos
|
||||||
|
// - issues: Intelligent issue triage
|
||||||
package qa
|
package qa
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -41,4 +39,6 @@ func AddQACommands(root *cli.Command) {
|
||||||
// Subcommands
|
// Subcommands
|
||||||
addWatchCommand(qaCmd)
|
addWatchCommand(qaCmd)
|
||||||
addReviewCommand(qaCmd)
|
addReviewCommand(qaCmd)
|
||||||
|
addHealthCommand(qaCmd)
|
||||||
|
addIssuesCommand(qaCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,7 @@ func printResults(ctx context.Context, repoFullName string, runs []WorkflowRun)
|
||||||
// Exit with error if any failures
|
// Exit with error if any failures
|
||||||
if len(failures) > 0 {
|
if len(failures) > 0 {
|
||||||
cli.Blank()
|
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()
|
cli.Blank()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue