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>
228 lines
5.8 KiB
Go
228 lines
5.8 KiB
Go
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)
|
|
}
|