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