diff --git a/cmd/qa/cmd_docblock.go b/cmd/qa/cmd_docblock.go new file mode 100644 index 0000000..5b33593 --- /dev/null +++ b/cmd/qa/cmd_docblock.go @@ -0,0 +1,354 @@ +// cmd_docblock.go implements docblock/docstring coverage checking for Go code. +// +// Usage: +// +// core qa docblock # Check current directory +// core qa docblock ./pkg/... # Check specific packages +// core qa docblock --threshold=80 # Require 80% coverage +package qa + +import ( + "cmp" + "encoding/json" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "slices" + "strings" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-i18n" +) + +// Docblock command flags +var ( + docblockThreshold float64 + docblockVerbose bool + docblockJSON bool +) + +// addDocblockCommand adds the 'docblock' command to qa. +func addDocblockCommand(parent *cli.Command) { + docblockCmd := &cli.Command{ + Use: "docblock [packages...]", + Short: i18n.T("cmd.qa.docblock.short"), + Long: i18n.T("cmd.qa.docblock.long"), + RunE: func(cmd *cli.Command, args []string) error { + paths := args + if len(paths) == 0 { + paths = []string{"./..."} + } + return RunDocblockCheck(paths, docblockThreshold, docblockVerbose, docblockJSON) + }, + } + + docblockCmd.Flags().Float64Var(&docblockThreshold, "threshold", 80, i18n.T("cmd.qa.docblock.flag.threshold")) + docblockCmd.Flags().BoolVarP(&docblockVerbose, "verbose", "v", false, i18n.T("common.flag.verbose")) + docblockCmd.Flags().BoolVar(&docblockJSON, "json", false, i18n.T("common.flag.json")) + + parent.AddCommand(docblockCmd) +} + +// DocblockResult holds the result of a docblock coverage check. +type DocblockResult struct { + Coverage float64 `json:"coverage"` + Threshold float64 `json:"threshold"` + Total int `json:"total"` + Documented int `json:"documented"` + Missing []MissingDocblock `json:"missing,omitempty"` + Passed bool `json:"passed"` +} + +// MissingDocblock represents an exported symbol without documentation. +type MissingDocblock struct { + File string `json:"file"` + Line int `json:"line"` + Name string `json:"name"` + Kind string `json:"kind"` // func, type, const, var + Reason string `json:"reason,omitempty"` +} + +// RunDocblockCheck checks docblock coverage for the given packages. +func RunDocblockCheck(paths []string, threshold float64, verbose, jsonOutput bool) error { + result, err := CheckDocblockCoverage(paths) + if err != nil { + return err + } + result.Threshold = threshold + result.Passed = result.Coverage >= threshold + + if jsonOutput { + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + if !result.Passed { + return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold) + } + return nil + } + + // Sort missing by file then line + slices.SortFunc(result.Missing, func(a, b MissingDocblock) int { + return cmp.Or( + cmp.Compare(a.File, b.File), + cmp.Compare(a.Line, b.Line), + ) + }) + + // Print result + if verbose && len(result.Missing) > 0 { + cli.Print("%s\n\n", i18n.T("cmd.qa.docblock.missing_docs")) + for _, m := range result.Missing { + cli.Print(" %s:%d: %s %s\n", + dimStyle.Render(m.File), + m.Line, + dimStyle.Render(m.Kind), + m.Name, + ) + } + cli.Blank() + } + + // Summary + coverageStr := fmt.Sprintf("%.1f%%", result.Coverage) + thresholdStr := fmt.Sprintf("%.1f%%", threshold) + + if result.Passed { + cli.Print("%s %s %s/%s (%s >= %s)\n", + successStyle.Render(i18n.T("common.label.success")), + i18n.T("cmd.qa.docblock.coverage"), + fmt.Sprintf("%d", result.Documented), + fmt.Sprintf("%d", result.Total), + successStyle.Render(coverageStr), + thresholdStr, + ) + return nil + } + + cli.Print("%s %s %s/%s (%s < %s)\n", + errorStyle.Render(i18n.T("common.label.error")), + i18n.T("cmd.qa.docblock.coverage"), + fmt.Sprintf("%d", result.Documented), + fmt.Sprintf("%d", result.Total), + errorStyle.Render(coverageStr), + thresholdStr, + ) + + // Always show compact file:line list when failing (token-efficient for AI agents) + if len(result.Missing) > 0 { + cli.Blank() + for _, m := range result.Missing { + cli.Print("%s:%d\n", m.File, m.Line) + } + } + + return cli.Err("docblock coverage %.1f%% below threshold %.1f%%", result.Coverage, threshold) +} + +// CheckDocblockCoverage analyzes Go packages for docblock coverage. +func CheckDocblockCoverage(patterns []string) (*DocblockResult, error) { + result := &DocblockResult{} + + // Expand patterns to actual directories + dirs, err := expandPatterns(patterns) + if err != nil { + return nil, err + } + + fset := token.NewFileSet() + + for _, dir := range dirs { + pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool { + return !strings.HasSuffix(fi.Name(), "_test.go") + }, parser.ParseComments) + if err != nil { + // Log parse errors but continue to check other directories + cli.Warnf("failed to parse %s: %v", dir, err) + continue + } + + for _, pkg := range pkgs { + for filename, file := range pkg.Files { + checkFile(fset, filename, file, result) + } + } + } + + if result.Total > 0 { + result.Coverage = float64(result.Documented) / float64(result.Total) * 100 + } + + return result, nil +} + +// expandPatterns expands Go package patterns like ./... to actual directories. +func expandPatterns(patterns []string) ([]string, error) { + var dirs []string + seen := make(map[string]bool) + + for _, pattern := range patterns { + if strings.HasSuffix(pattern, "/...") { + // Recursive pattern + base := strings.TrimSuffix(pattern, "/...") + if base == "." { + base = "." + } + err := filepath.Walk(base, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors + } + if !info.IsDir() { + return nil + } + // Skip vendor, testdata, and hidden directories (but not "." itself) + name := info.Name() + if name == "vendor" || name == "testdata" || (strings.HasPrefix(name, ".") && name != ".") { + return filepath.SkipDir + } + // Check if directory has Go files + if hasGoFiles(path) && !seen[path] { + dirs = append(dirs, path) + seen[path] = true + } + return nil + }) + if err != nil { + return nil, err + } + } else { + // Single directory + path := pattern + if !seen[path] && hasGoFiles(path) { + dirs = append(dirs, path) + seen[path] = true + } + } + } + + return dirs, nil +} + +// hasGoFiles checks if a directory contains Go files. +func hasGoFiles(dir string) bool { + entries, err := os.ReadDir(dir) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".go") && !strings.HasSuffix(entry.Name(), "_test.go") { + return true + } + } + return false +} + +// checkFile analyzes a single file for docblock coverage. +func checkFile(fset *token.FileSet, filename string, file *ast.File, result *DocblockResult) { + // Make filename relative if possible + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, filename); err == nil { + filename = rel + } + } + + for _, decl := range file.Decls { + switch d := decl.(type) { + case *ast.FuncDecl: + // Skip unexported functions + if !ast.IsExported(d.Name.Name) { + continue + } + // Skip methods on unexported types + if d.Recv != nil && len(d.Recv.List) > 0 { + if recvType := getReceiverTypeName(d.Recv.List[0].Type); recvType != "" && !ast.IsExported(recvType) { + continue + } + } + + result.Total++ + if d.Doc != nil && len(d.Doc.List) > 0 { + result.Documented++ + } else { + pos := fset.Position(d.Pos()) + result.Missing = append(result.Missing, MissingDocblock{ + File: filename, + Line: pos.Line, + Name: d.Name.Name, + Kind: "func", + }) + } + + case *ast.GenDecl: + for _, spec := range d.Specs { + switch s := spec.(type) { + case *ast.TypeSpec: + if !ast.IsExported(s.Name.Name) { + continue + } + result.Total++ + // Type can have doc on GenDecl or TypeSpec + if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) { + result.Documented++ + } else { + pos := fset.Position(s.Pos()) + result.Missing = append(result.Missing, MissingDocblock{ + File: filename, + Line: pos.Line, + Name: s.Name.Name, + Kind: "type", + }) + } + + case *ast.ValueSpec: + // Check exported consts and vars + for _, name := range s.Names { + if !ast.IsExported(name.Name) { + continue + } + result.Total++ + // Value can have doc on GenDecl or ValueSpec + if (d.Doc != nil && len(d.Doc.List) > 0) || (s.Doc != nil && len(s.Doc.List) > 0) { + result.Documented++ + } else { + pos := fset.Position(name.Pos()) + result.Missing = append(result.Missing, MissingDocblock{ + File: filename, + Line: pos.Line, + Name: name.Name, + Kind: kindFromToken(d.Tok), + }) + } + } + } + } + } + } +} + +// getReceiverTypeName extracts the type name from a method receiver. +func getReceiverTypeName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.Ident: + return t.Name + case *ast.StarExpr: + return getReceiverTypeName(t.X) + } + return "" +} + +// kindFromToken returns a string representation of the token kind. +func kindFromToken(tok token.Token) string { + switch tok { + case token.CONST: + return "const" + case token.VAR: + return "var" + default: + return "value" + } +} diff --git a/cmd/qa/cmd_health.go b/cmd/qa/cmd_health.go new file mode 100644 index 0000000..3773908 --- /dev/null +++ b/cmd/qa/cmd_health.go @@ -0,0 +1,290 @@ +// 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 ( + "cmp" + "encoding/json" + "os/exec" + "slices" + "strings" + + "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" +) + +// 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 log.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(io.Local, healthRegistry) + } else { + registryPath, findErr := repos.FindRegistry(io.Local) + if findErr != nil { + return log.E("qa.health", i18n.T("error.registry_not_found"), nil) + } + reg, err = repos.LoadRegistry(io.Local, registryPath) + } + if err != nil { + return log.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 + slices.SortFunc(healthResults, func(a, b RepoHealth) int { + return cmp.Compare(healthPriority(a.Status), healthPriority(b.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/cmd/qa/cmd_issues.go b/cmd/qa/cmd_issues.go new file mode 100644 index 0000000..3934ab8 --- /dev/null +++ b/cmd/qa/cmd_issues.go @@ -0,0 +1,395 @@ +// 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 +) + +// 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 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 + 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 { + slices.SortFunc(result[cat], func(a, b Issue) int { + return cmp.Compare(a.Priority, b.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) + } +} diff --git a/cmd/qa/cmd_qa.go b/cmd/qa/cmd_qa.go new file mode 100644 index 0000000..eb7da63 --- /dev/null +++ b/cmd/qa/cmd_qa.go @@ -0,0 +1,45 @@ +// Package qa provides quality assurance workflow commands. +// +// Unlike `core dev` which is about doing work (commit, push, pull), +// `core qa` is about verifying work (CI status, reviews, issues). +// +// Commands: +// - watch: Monitor GitHub Actions after a push, report actionable data +// - review: PR review status with actionable next steps +// - health: Aggregate CI health across all repos +// - issues: Intelligent issue triage +package qa + +import ( + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-i18n" +) + +func init() { + cli.RegisterCommands(AddQACommands) +} + +// Style aliases from shared package +var ( + successStyle = cli.SuccessStyle + errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle + dimStyle = cli.DimStyle +) + +// AddQACommands registers the 'qa' command and all subcommands. +func AddQACommands(root *cli.Command) { + qaCmd := &cli.Command{ + Use: "qa", + Short: i18n.T("cmd.qa.short"), + Long: i18n.T("cmd.qa.long"), + } + root.AddCommand(qaCmd) + + // Subcommands + addWatchCommand(qaCmd) + addReviewCommand(qaCmd) + addHealthCommand(qaCmd) + addIssuesCommand(qaCmd) + addDocblockCommand(qaCmd) +} diff --git a/cmd/qa/cmd_review.go b/cmd/qa/cmd_review.go new file mode 100644 index 0000000..2fff525 --- /dev/null +++ b/cmd/qa/cmd_review.go @@ -0,0 +1,322 @@ +// cmd_review.go implements the 'qa review' command for PR review status. +// +// Usage: +// core qa review # Show all PRs needing attention +// core qa review --mine # Show status of your open PRs +// core qa review --requested # Show PRs you need to review + +package qa + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-i18n" + "forge.lthn.ai/core/go-log" +) + +// Review command flags +var ( + reviewMine bool + reviewRequested bool + reviewRepo string +) + +// PullRequest represents a GitHub pull request +type PullRequest struct { + Number int `json:"number"` + Title string `json:"title"` + Author Author `json:"author"` + State string `json:"state"` + IsDraft bool `json:"isDraft"` + Mergeable string `json:"mergeable"` + ReviewDecision string `json:"reviewDecision"` + URL string `json:"url"` + HeadRefName string `json:"headRefName"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + ChangedFiles int `json:"changedFiles"` + StatusChecks *StatusCheckRollup `json:"statusCheckRollup"` + ReviewRequests ReviewRequests `json:"reviewRequests"` + Reviews []Review `json:"reviews"` +} + +// Author represents a GitHub user +type Author struct { + Login string `json:"login"` +} + +// StatusCheckRollup contains CI check status +type StatusCheckRollup struct { + Contexts []StatusContext `json:"contexts"` +} + +// StatusContext represents a single check +type StatusContext struct { + State string `json:"state"` + Conclusion string `json:"conclusion"` + Name string `json:"name"` +} + +// ReviewRequests contains pending review requests +type ReviewRequests struct { + Nodes []ReviewRequest `json:"nodes"` +} + +// ReviewRequest represents a review request +type ReviewRequest struct { + RequestedReviewer Author `json:"requestedReviewer"` +} + +// Review represents a PR review +type Review struct { + Author Author `json:"author"` + State string `json:"state"` +} + +// addReviewCommand adds the 'review' subcommand to the qa command. +func addReviewCommand(parent *cli.Command) { + reviewCmd := &cli.Command{ + Use: "review", + Short: i18n.T("cmd.qa.review.short"), + Long: i18n.T("cmd.qa.review.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runReview() + }, + } + + reviewCmd.Flags().BoolVarP(&reviewMine, "mine", "m", false, i18n.T("cmd.qa.review.flag.mine")) + reviewCmd.Flags().BoolVarP(&reviewRequested, "requested", "r", false, i18n.T("cmd.qa.review.flag.requested")) + reviewCmd.Flags().StringVar(&reviewRepo, "repo", "", i18n.T("cmd.qa.review.flag.repo")) + + parent.AddCommand(reviewCmd) +} + +func runReview() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return log.E("qa.review", i18n.T("error.gh_not_found"), nil) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Determine repo + repoFullName := reviewRepo + if repoFullName == "" { + var err error + repoFullName, err = detectRepoFromGit() + if err != nil { + return log.E("qa.review", i18n.T("cmd.qa.review.error.no_repo"), nil) + } + } + + // Default: show both mine and requested if neither flag is set + showMine := reviewMine || (!reviewMine && !reviewRequested) + showRequested := reviewRequested || (!reviewMine && !reviewRequested) + + if showMine { + if err := showMyPRs(ctx, repoFullName); err != nil { + return err + } + } + + if showRequested { + if showMine { + cli.Blank() + } + if err := showRequestedReviews(ctx, repoFullName); err != nil { + return err + } + } + + return nil +} + +// showMyPRs shows the user's open PRs with status +func showMyPRs(ctx context.Context, repo string) error { + prs, err := fetchPRs(ctx, repo, "author:@me") + if err != nil { + return log.E("qa.review", "failed to fetch your PRs", err) + } + + if len(prs) == 0 { + cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_prs"))) + return nil + } + + cli.Print("%s (%d):\n", i18n.T("cmd.qa.review.your_prs"), len(prs)) + + for _, pr := range prs { + printPRStatus(pr) + } + + return nil +} + +// showRequestedReviews shows PRs where user's review is requested +func showRequestedReviews(ctx context.Context, repo string) error { + prs, err := fetchPRs(ctx, repo, "review-requested:@me") + if err != nil { + return log.E("qa.review", "failed to fetch review requests", err) + } + + if len(prs) == 0 { + cli.Print("%s\n", dimStyle.Render(i18n.T("cmd.qa.review.no_reviews"))) + return nil + } + + cli.Print("%s (%d):\n", i18n.T("cmd.qa.review.review_requested"), len(prs)) + + for _, pr := range prs { + printPRForReview(pr) + } + + return nil +} + +// fetchPRs fetches PRs matching the search query +func fetchPRs(ctx context.Context, repo, search string) ([]PullRequest, error) { + args := []string{ + "pr", "list", + "--state", "open", + "--search", search, + "--json", "number,title,author,state,isDraft,mergeable,reviewDecision,url,headRefName,createdAt,updatedAt,additions,deletions,changedFiles,statusCheckRollup,reviewRequests,reviews", + } + + if repo != "" { + args = append(args, "--repo", repo) + } + + cmd := exec.CommandContext(ctx, "gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("%s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, err + } + + var prs []PullRequest + if err := json.Unmarshal(output, &prs); err != nil { + return nil, err + } + + return prs, nil +} + +// printPRStatus prints a PR with its merge status +func printPRStatus(pr PullRequest) { + // Determine status icon and color + status, style, action := analyzePRStatus(pr) + + cli.Print(" %s #%d %s\n", + style.Render(status), + pr.Number, + truncate(pr.Title, 50)) + + if action != "" { + cli.Print(" %s %s\n", dimStyle.Render("->"), action) + } +} + +// printPRForReview prints a PR that needs review +func printPRForReview(pr PullRequest) { + // Show PR info with stats + stats := fmt.Sprintf("+%d/-%d, %d files", + pr.Additions, pr.Deletions, pr.ChangedFiles) + + cli.Print(" %s #%d %s\n", + warningStyle.Render("◯"), + pr.Number, + truncate(pr.Title, 50)) + cli.Print(" %s @%s, %s\n", + dimStyle.Render("->"), + pr.Author.Login, + stats) + cli.Print(" %s gh pr checkout %d\n", + dimStyle.Render("->"), + pr.Number) +} + +// analyzePRStatus determines the status, style, and action for a PR +func analyzePRStatus(pr PullRequest) (status string, style *cli.AnsiStyle, action string) { + // Check if draft + if pr.IsDraft { + return "◯", dimStyle, "Draft - convert to ready when done" + } + + // Check CI status + ciPassed := true + ciFailed := false + ciPending := false + var failedCheck string + + if pr.StatusChecks != nil { + for _, check := range pr.StatusChecks.Contexts { + switch check.Conclusion { + case "FAILURE", "failure": + ciFailed = true + ciPassed = false + if failedCheck == "" { + failedCheck = check.Name + } + case "PENDING", "pending", "": + if check.State == "PENDING" || check.State == "" { + ciPending = true + ciPassed = false + } + } + } + } + + // Check review status + approved := pr.ReviewDecision == "APPROVED" + changesRequested := pr.ReviewDecision == "CHANGES_REQUESTED" + + // Check mergeable status + hasConflicts := pr.Mergeable == "CONFLICTING" + + // Determine overall status + if hasConflicts { + return "✗", errorStyle, "Needs rebase - has merge conflicts" + } + + if ciFailed { + return "✗", errorStyle, fmt.Sprintf("CI failed: %s", failedCheck) + } + + if changesRequested { + return "✗", warningStyle, "Changes requested - address review feedback" + } + + if ciPending { + return "◯", warningStyle, "CI running..." + } + + if !approved && pr.ReviewDecision != "" { + return "◯", warningStyle, "Awaiting review" + } + + if approved && ciPassed { + return "✓", successStyle, "Ready to merge" + } + + return "◯", dimStyle, "" +} + +// truncate shortens a string to max length (rune-safe for UTF-8) +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-3]) + "..." +} diff --git a/cmd/qa/cmd_watch.go b/cmd/qa/cmd_watch.go new file mode 100644 index 0000000..c51ab2a --- /dev/null +++ b/cmd/qa/cmd_watch.go @@ -0,0 +1,444 @@ +// cmd_watch.go implements the 'qa watch' command for monitoring GitHub Actions. +// +// Usage: +// core qa watch # Watch current repo's latest push +// core qa watch --repo X # Watch specific repo +// core qa watch --commit SHA # Watch specific commit +// core qa watch --timeout 5m # Custom timeout (default: 10m) + +package qa + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "forge.lthn.ai/core/cli/pkg/cli" + "forge.lthn.ai/core/go-i18n" + "forge.lthn.ai/core/go-log" +) + +// Watch command flags +var ( + watchRepo string + watchCommit string + watchTimeout time.Duration +) + +// WorkflowRun represents a GitHub Actions workflow run +type WorkflowRun struct { + ID int64 `json:"databaseId"` + Name string `json:"name"` + DisplayTitle string `json:"displayTitle"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + HeadSha string `json:"headSha"` + URL string `json:"url"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// WorkflowJob represents a job within a workflow run +type WorkflowJob struct { + ID int64 `json:"databaseId"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + URL string `json:"url"` +} + +// JobStep represents a step within a job +type JobStep struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + Number int `json:"number"` +} + +// addWatchCommand adds the 'watch' subcommand to the qa command. +func addWatchCommand(parent *cli.Command) { + watchCmd := &cli.Command{ + Use: "watch", + Short: i18n.T("cmd.qa.watch.short"), + Long: i18n.T("cmd.qa.watch.long"), + RunE: func(cmd *cli.Command, args []string) error { + return runWatch() + }, + } + + watchCmd.Flags().StringVarP(&watchRepo, "repo", "r", "", i18n.T("cmd.qa.watch.flag.repo")) + watchCmd.Flags().StringVarP(&watchCommit, "commit", "c", "", i18n.T("cmd.qa.watch.flag.commit")) + watchCmd.Flags().DurationVarP(&watchTimeout, "timeout", "t", 10*time.Minute, i18n.T("cmd.qa.watch.flag.timeout")) + + parent.AddCommand(watchCmd) +} + +func runWatch() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return log.E("qa.watch", i18n.T("error.gh_not_found"), nil) + } + + // Determine repo + repoFullName, err := resolveRepo(watchRepo) + if err != nil { + return err + } + + // Determine commit + commitSha, err := resolveCommit(watchCommit) + if err != nil { + return err + } + + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("repo")), repoFullName) + // Safe prefix for display - handle short SHAs gracefully + shaPrefix := commitSha + if len(commitSha) > 8 { + shaPrefix = commitSha[:8] + } + cli.Print("%s %s\n", dimStyle.Render(i18n.T("cmd.qa.watch.commit")), shaPrefix) + cli.Blank() + + // Create context with timeout for all gh commands + ctx, cancel := context.WithTimeout(context.Background(), watchTimeout) + defer cancel() + + // Poll for workflow runs + pollInterval := 3 * time.Second + var lastStatus string + + for { + // Check if context deadline exceeded + if ctx.Err() != nil { + cli.Blank() + return log.E("qa.watch", i18n.T("cmd.qa.watch.timeout", map[string]any{"Duration": watchTimeout}), nil) + } + + runs, err := fetchWorkflowRunsForCommit(ctx, repoFullName, commitSha) + if err != nil { + return log.Wrap(err, "qa.watch", "failed to fetch workflow runs") + } + + if len(runs) == 0 { + // No workflows triggered yet, keep waiting + cli.Print("\033[2K\r%s", dimStyle.Render(i18n.T("cmd.qa.watch.waiting_for_workflows"))) + time.Sleep(pollInterval) + continue + } + + // Check status of all runs + allComplete := true + var pending, success, failed int + for _, run := range runs { + switch run.Status { + case "completed": + if run.Conclusion == "success" { + success++ + } else { + // Count all non-success conclusions as failed + // (failure, cancelled, timed_out, action_required, stale, etc.) + failed++ + } + default: + allComplete = false + pending++ + } + } + + // Build status line + status := fmt.Sprintf("%d workflow(s): ", len(runs)) + if pending > 0 { + status += warningStyle.Render(fmt.Sprintf("%d running", pending)) + if success > 0 || failed > 0 { + status += ", " + } + } + if success > 0 { + status += successStyle.Render(fmt.Sprintf("%d passed", success)) + if failed > 0 { + status += ", " + } + } + if failed > 0 { + status += errorStyle.Render(fmt.Sprintf("%d failed", failed)) + } + + // Only print if status changed + if status != lastStatus { + cli.Print("\033[2K\r%s", status) + lastStatus = status + } + + if allComplete { + cli.Blank() + cli.Blank() + return printResults(ctx, repoFullName, runs) + } + + time.Sleep(pollInterval) + } +} + +// resolveRepo determines the repo to watch +func resolveRepo(specified string) (string, error) { + if specified != "" { + // If it contains /, assume it's already full name + if strings.Contains(specified, "/") { + return specified, nil + } + // Try to get org from current directory + org := detectOrgFromGit() + if org != "" { + return org + "/" + specified, nil + } + return "", log.E("qa.watch", i18n.T("cmd.qa.watch.error.repo_format"), nil) + } + + // Detect from current directory + return detectRepoFromGit() +} + +// resolveCommit determines the commit to watch +func resolveCommit(specified string) (string, error) { + if specified != "" { + return specified, nil + } + + // Get HEAD commit + cmd := exec.Command("git", "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "", log.Wrap(err, "qa.watch", "failed to get HEAD commit") + } + + return strings.TrimSpace(string(output)), nil +} + +// detectRepoFromGit detects the repo from git remote +func detectRepoFromGit() (string, error) { + cmd := exec.Command("git", "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "", log.E("qa.watch", i18n.T("cmd.qa.watch.error.not_git_repo"), nil) + } + + url := strings.TrimSpace(string(output)) + return parseGitHubRepo(url) +} + +// detectOrgFromGit tries to detect the org from git remote +func detectOrgFromGit() string { + repo, err := detectRepoFromGit() + if err != nil { + return "" + } + parts := strings.Split(repo, "/") + if len(parts) >= 1 { + return parts[0] + } + return "" +} + +// parseGitHubRepo extracts org/repo from a git URL +func parseGitHubRepo(url string) (string, error) { + // Handle SSH URLs: git@github.com:org/repo.git + if strings.HasPrefix(url, "git@github.com:") { + path := strings.TrimPrefix(url, "git@github.com:") + path = strings.TrimSuffix(path, ".git") + return path, nil + } + + // Handle HTTPS URLs: https://github.com/org/repo.git + if strings.Contains(url, "github.com/") { + parts := strings.Split(url, "github.com/") + if len(parts) >= 2 { + path := strings.TrimSuffix(parts[1], ".git") + return path, nil + } + } + + return "", fmt.Errorf("could not parse GitHub repo from URL: %s", url) +} + +// fetchWorkflowRunsForCommit fetches workflow runs for a specific commit +func fetchWorkflowRunsForCommit(ctx context.Context, repoFullName, commitSha string) ([]WorkflowRun, error) { + args := []string{ + "run", "list", + "--repo", repoFullName, + "--commit", commitSha, + "--json", "databaseId,name,displayTitle,status,conclusion,headSha,url,createdAt,updatedAt", + } + + cmd := exec.CommandContext(ctx, "gh", args...) + output, err := cmd.Output() + if err != nil { + // Check if context was cancelled/deadline exceeded + if ctx.Err() != nil { + return nil, ctx.Err() + } + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, err + } + + var runs []WorkflowRun + if err := json.Unmarshal(output, &runs); err != nil { + return nil, err + } + + return runs, nil +} + +// printResults prints the final results with actionable information +func printResults(ctx context.Context, repoFullName string, runs []WorkflowRun) error { + var failures []WorkflowRun + var successes []WorkflowRun + + for _, run := range runs { + if run.Conclusion == "success" { + successes = append(successes, run) + } else { + // Treat all non-success as failures (failure, cancelled, timed_out, etc.) + failures = append(failures, run) + } + } + + // Print successes briefly + for _, run := range successes { + cli.Print("%s %s\n", successStyle.Render(cli.Glyph(":check:")), run.Name) + } + + // Print failures with details + for _, run := range failures { + cli.Print("%s %s\n", errorStyle.Render(cli.Glyph(":cross:")), run.Name) + + // Fetch failed job details + failedJob, failedStep, errorLine := fetchFailureDetails(ctx, repoFullName, run.ID) + if failedJob != "" { + cli.Print(" %s Job: %s", dimStyle.Render("->"), failedJob) + if failedStep != "" { + cli.Print(" (step: %s)", failedStep) + } + cli.Blank() + } + if errorLine != "" { + cli.Print(" %s Error: %s\n", dimStyle.Render("->"), errorLine) + } + cli.Print(" %s %s\n", dimStyle.Render("->"), run.URL) + } + + // Exit with error if any failures + if len(failures) > 0 { + cli.Blank() + return cli.Err("%s", i18n.T("cmd.qa.watch.workflows_failed", map[string]any{"Count": len(failures)})) + } + + cli.Blank() + cli.Print("%s\n", successStyle.Render(i18n.T("cmd.qa.watch.all_passed"))) + return nil +} + +// fetchFailureDetails fetches details about why a workflow failed +func fetchFailureDetails(ctx context.Context, repoFullName string, runID int64) (jobName, stepName, errorLine string) { + // Fetch jobs for this run + args := []string{ + "run", "view", fmt.Sprintf("%d", runID), + "--repo", repoFullName, + "--json", "jobs", + } + + cmd := exec.CommandContext(ctx, "gh", args...) + output, err := cmd.Output() + if err != nil { + return "", "", "" + } + + var result struct { + Jobs []struct { + Name string `json:"name"` + Conclusion string `json:"conclusion"` + Steps []struct { + Name string `json:"name"` + Conclusion string `json:"conclusion"` + Number int `json:"number"` + } `json:"steps"` + } `json:"jobs"` + } + + if err := json.Unmarshal(output, &result); err != nil { + return "", "", "" + } + + // Find the failed job and step + for _, job := range result.Jobs { + if job.Conclusion == "failure" { + jobName = job.Name + for _, step := range job.Steps { + if step.Conclusion == "failure" { + stepName = fmt.Sprintf("%d: %s", step.Number, step.Name) + break + } + } + break + } + } + + // Try to get the error line from logs (if available) + errorLine = fetchErrorFromLogs(ctx, repoFullName, runID) + + return jobName, stepName, errorLine +} + +// fetchErrorFromLogs attempts to extract the first error line from workflow logs +func fetchErrorFromLogs(ctx context.Context, repoFullName string, runID int64) string { + // Use gh run view --log-failed to get failed step logs + args := []string{ + "run", "view", fmt.Sprintf("%d", runID), + "--repo", repoFullName, + "--log-failed", + } + + cmd := exec.CommandContext(ctx, "gh", args...) + output, err := cmd.Output() + if err != nil { + return "" + } + + // Parse output to find the first meaningful error line + lines := strings.Split(string(output), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + // Skip common metadata/progress lines + lower := strings.ToLower(line) + if strings.HasPrefix(lower, "##[") { // GitHub Actions command markers + continue + } + if strings.HasPrefix(line, "Run ") || strings.HasPrefix(line, "Running ") { + continue + } + + // Look for error indicators + if strings.Contains(lower, "error") || + strings.Contains(lower, "failed") || + strings.Contains(lower, "fatal") || + strings.Contains(lower, "panic") || + strings.Contains(line, ": ") { // Likely a file:line or key: value format + // Truncate long lines + if len(line) > 120 { + line = line[:117] + "..." + } + return line + } + } + + return "" +} diff --git a/go.mod b/go.mod index 6af6fe6..1f1888a 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,23 @@ module forge.lthn.ai/core/lint go 1.26.0 require ( + forge.lthn.ai/core/cli v0.2.2 + forge.lthn.ai/core/go-i18n v0.1.0 + forge.lthn.ai/core/go-io v0.0.3 + forge.lthn.ai/core/go-log v0.0.1 + forge.lthn.ai/core/go-scm v0.1.0 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) require ( - forge.lthn.ai/core/cli v0.2.2 // indirect forge.lthn.ai/core/go v0.1.0 // indirect forge.lthn.ai/core/go-cache v0.1.0 // indirect forge.lthn.ai/core/go-config v0.1.0 // indirect forge.lthn.ai/core/go-crypt v0.1.0 // indirect forge.lthn.ai/core/go-devops v0.0.3 // indirect forge.lthn.ai/core/go-help v0.1.2 // indirect - forge.lthn.ai/core/go-i18n v0.1.0 // indirect forge.lthn.ai/core/go-inference v0.0.2 // indirect - forge.lthn.ai/core/go-io v0.0.3 // indirect - forge.lthn.ai/core/go-log v0.0.1 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect