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:
parent
15c9fe85c8
commit
bf06489806
7 changed files with 1855 additions and 4 deletions
354
cmd/qa/cmd_docblock.go
Normal file
354
cmd/qa/cmd_docblock.go
Normal 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
290
cmd/qa/cmd_health.go
Normal 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
395
cmd/qa/cmd_issues.go
Normal 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
45
cmd/qa/cmd_qa.go
Normal 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
322
cmd/qa/cmd_review.go
Normal 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
444
cmd/qa/cmd_watch.go
Normal 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
9
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue