feat(cli): add bd lint command for template validation (bd-gn5r)
Checks issues for missing recommended sections based on type: - bd lint # Lint all open issues - bd lint bd-xxx # Lint specific issue - bd lint --type bug # Filter by type - bd lint --json # JSON output for CI Supports both daemon and direct mode. Exit code 1 when warnings found. Part of opt-in validation epic (bd-ou35). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
db93ba0709
commit
be70972eec
227
cmd/bd/lint.go
Normal file
227
cmd/bd/lint.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/validation"
|
||||
)
|
||||
|
||||
// LintResult holds the validation result for a single issue.
|
||||
type LintResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
Warnings int `json:"warnings"`
|
||||
}
|
||||
|
||||
var lintCmd = &cobra.Command{
|
||||
Use: "lint [issue-id...]",
|
||||
GroupID: "views",
|
||||
Short: "Check issues for missing template sections",
|
||||
Long: `Check issues for missing recommended sections based on issue type.
|
||||
|
||||
By default, lints all open issues. Specify issue IDs to lint specific issues.
|
||||
|
||||
Section requirements by type:
|
||||
bug: Steps to Reproduce, Acceptance Criteria
|
||||
task: Acceptance Criteria
|
||||
feature: Acceptance Criteria
|
||||
epic: Success Criteria
|
||||
chore: (none)
|
||||
|
||||
Examples:
|
||||
bd lint # Lint all open issues
|
||||
bd lint bd-abc # Lint specific issue
|
||||
bd lint bd-abc bd-def # Lint multiple issues
|
||||
bd lint --type bug # Lint only bugs
|
||||
bd lint --status all # Lint all issues (including closed)
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := rootCtx
|
||||
|
||||
typeFilter, _ := cmd.Flags().GetString("type")
|
||||
statusFilter, _ := cmd.Flags().GetString("status")
|
||||
|
||||
var issues []*types.Issue
|
||||
|
||||
// Use daemon if available, otherwise direct mode
|
||||
if daemonClient != nil {
|
||||
if len(args) > 0 {
|
||||
// Get specific issues via show
|
||||
for _, id := range args {
|
||||
showArgs := &rpc.ShowArgs{ID: id}
|
||||
resp, err := daemonClient.Show(showArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
var details types.IssueDetails
|
||||
if err := json.Unmarshal(resp.Data, &details); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, &details.Issue)
|
||||
}
|
||||
} else {
|
||||
// List issues via daemon
|
||||
listArgs := &rpc.ListArgs{
|
||||
Limit: 1000, // reasonable limit
|
||||
}
|
||||
|
||||
// Default to open issues unless --status specified
|
||||
if statusFilter == "" || statusFilter == "open" {
|
||||
listArgs.Status = "open"
|
||||
} else if statusFilter != "all" {
|
||||
listArgs.Status = statusFilter
|
||||
}
|
||||
|
||||
if typeFilter != "" {
|
||||
listArgs.IssueType = typeFilter
|
||||
}
|
||||
|
||||
resp, err := daemonClient.List(listArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var issuesWithCounts []*types.IssueWithCounts
|
||||
if err := json.Unmarshal(resp.Data, &issuesWithCounts); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, iwc := range issuesWithCounts {
|
||||
issues = append(issues, iwc.Issue)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct mode
|
||||
if err := ensureDatabaseFresh(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if store == nil {
|
||||
fmt.Fprintln(os.Stderr, "Error: database not initialized")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
// Lint specific issues
|
||||
for _, id := range args {
|
||||
issue, err := store.GetIssue(ctx, id)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error getting %s: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
if issue == nil {
|
||||
fmt.Fprintf(os.Stderr, "Issue not found: %s\n", id)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
} else {
|
||||
// Lint all matching issues
|
||||
filter := types.IssueFilter{}
|
||||
|
||||
// Default to open issues unless --status specified
|
||||
if statusFilter == "" || statusFilter == "open" {
|
||||
s := types.StatusOpen
|
||||
filter.Status = &s
|
||||
} else if statusFilter != "all" {
|
||||
s := types.Status(statusFilter)
|
||||
filter.Status = &s
|
||||
}
|
||||
|
||||
if typeFilter != "" {
|
||||
t := types.IssueType(typeFilter)
|
||||
filter.IssueType = &t
|
||||
}
|
||||
|
||||
var err error
|
||||
issues, err = store.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var results []LintResult
|
||||
totalWarnings := 0
|
||||
|
||||
for _, issue := range issues {
|
||||
err := validation.LintIssue(issue)
|
||||
if err == nil {
|
||||
continue // No warnings for this issue
|
||||
}
|
||||
|
||||
templateErr, ok := err.(*validation.TemplateError)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
missing := make([]string, len(templateErr.Missing))
|
||||
for i, m := range templateErr.Missing {
|
||||
missing[i] = m.Heading
|
||||
}
|
||||
|
||||
result := LintResult{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Type: string(issue.IssueType),
|
||||
Missing: missing,
|
||||
Warnings: len(missing),
|
||||
}
|
||||
results = append(results, result)
|
||||
totalWarnings += len(missing)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
output := struct {
|
||||
Total int `json:"total"`
|
||||
Issues int `json:"issues"`
|
||||
Results []LintResult `json:"results"`
|
||||
}{
|
||||
Total: totalWarnings,
|
||||
Issues: len(results),
|
||||
Results: results,
|
||||
}
|
||||
data, _ := json.MarshalIndent(output, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
if len(results) == 0 {
|
||||
fmt.Printf("✓ No template warnings found (%d issues checked)\n", len(issues))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Template warnings (%d issues, %d warnings):\n\n", len(results), totalWarnings)
|
||||
for _, r := range results {
|
||||
fmt.Printf("%s [%s]: %s\n", r.ID, r.Type, r.Title)
|
||||
for _, m := range r.Missing {
|
||||
fmt.Printf(" ⚠ Missing: %s\n", m)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Exit with error code if warnings found (useful for CI)
|
||||
os.Exit(1)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
lintCmd.Flags().StringP("type", "t", "", "Filter by issue type (bug, task, feature, epic)")
|
||||
lintCmd.Flags().StringP("status", "s", "", "Filter by status (default: open, use 'all' for all)")
|
||||
|
||||
rootCmd.AddCommand(lintCmd)
|
||||
}
|
||||
Reference in New Issue
Block a user