feat: add cmd/qa from go-devops

QA commands (watch, review, health, issues, docblock) now live
alongside the lint library they depend on.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-09 12:28:24 +00:00
parent 15c9fe85c8
commit bf06489806
7 changed files with 1855 additions and 4 deletions

354
cmd/qa/cmd_docblock.go Normal file
View file

@ -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"
}
}

290
cmd/qa/cmd_health.go Normal file
View file

@ -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()
}

395
cmd/qa/cmd_issues.go Normal file
View file

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

45
cmd/qa/cmd_qa.go Normal file
View file

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

322
cmd/qa/cmd_review.go Normal file
View file

@ -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]) + "..."
}

444
cmd/qa/cmd_watch.go Normal file
View file

@ -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 ""
}

9
go.mod
View file

@ -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