diff --git a/cmd/bd/close.go b/cmd/bd/close.go index 7a4f2cbc..12f6ec62 100644 --- a/cmd/bd/close.go +++ b/cmd/bd/close.go @@ -191,7 +191,7 @@ create, update, show, or close operation).`, continue } - if err := result.Store.CloseIssue(ctx, result.ResolvedID, reason, actor); err != nil { + if err := result.Store.CloseIssue(ctx, result.ResolvedID, reason, actor, session); err != nil { result.Close() fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) continue @@ -283,7 +283,7 @@ create, update, show, or close operation).`, continue } - if err := result.Store.CloseIssue(ctx, result.ResolvedID, reason, actor); err != nil { + if err := result.Store.CloseIssue(ctx, result.ResolvedID, reason, actor, session); err != nil { result.Close() fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err) continue diff --git a/internal/validation/template.go b/internal/validation/template.go new file mode 100644 index 00000000..fb1ccc6a --- /dev/null +++ b/internal/validation/template.go @@ -0,0 +1,82 @@ +package validation + +import ( + "fmt" + "strings" + + "github.com/steveyegge/beads/internal/types" +) + +// MissingSection describes a section that should be present but isn't. +type MissingSection struct { + Heading string // The expected heading, e.g., "## Steps to Reproduce" + Hint string // Guidance for what to include +} + +// TemplateError is returned when template validation fails. +// It contains all missing sections for a single error report. +type TemplateError struct { + IssueType types.IssueType + Missing []MissingSection +} + +func (e *TemplateError) Error() string { + if len(e.Missing) == 0 { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "missing required sections for %s:", e.IssueType) + for _, m := range e.Missing { + fmt.Fprintf(&b, "\n - %s (%s)", m.Heading, m.Hint) + } + return b.String() +} + +// ValidateTemplate checks if the description contains all required sections +// for the given issue type. Returns nil if validation passes or if the +// issue type has no required sections. +// +// Section matching is case-insensitive and looks for the heading text +// anywhere in the description (doesn't require exact markdown format). +func ValidateTemplate(issueType types.IssueType, description string) error { + required := issueType.RequiredSections() + if len(required) == 0 { + return nil + } + + descLower := strings.ToLower(description) + var missing []MissingSection + + for _, section := range required { + // Extract the heading text without markdown prefix for flexible matching + // e.g., "## Steps to Reproduce" -> "steps to reproduce" + headingText := strings.TrimPrefix(section.Heading, "## ") + headingText = strings.TrimPrefix(headingText, "# ") + headingLower := strings.ToLower(headingText) + + if !strings.Contains(descLower, headingLower) { + missing = append(missing, MissingSection{ + Heading: section.Heading, + Hint: section.Hint, + }) + } + } + + if len(missing) > 0 { + return &TemplateError{ + IssueType: issueType, + Missing: missing, + } + } + return nil +} + +// LintIssue checks an existing issue for missing template sections. +// Unlike ValidateTemplate, this operates on a full Issue struct. +// Returns nil if the issue passes validation or has no requirements. +func LintIssue(issue *types.Issue) error { + if issue == nil { + return nil + } + return ValidateTemplate(issue.IssueType, issue.Description) +} diff --git a/internal/validation/template_test.go b/internal/validation/template_test.go new file mode 100644 index 00000000..73dbab5b --- /dev/null +++ b/internal/validation/template_test.go @@ -0,0 +1,255 @@ +package validation + +import ( + "strings" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestValidateTemplate(t *testing.T) { + tests := []struct { + name string + issueType types.IssueType + description string + wantErr bool + wantMissing int // Number of missing sections expected + }{ + // Bug type tests + { + name: "bug with all sections", + issueType: types.TypeBug, + description: `## Steps to Reproduce +1. Do this +2. Do that + +## Acceptance Criteria +- Bug is fixed`, + wantErr: false, + }, + { + name: "bug missing all sections", + issueType: types.TypeBug, + description: "This is broken", + wantErr: true, + wantMissing: 2, + }, + { + name: "bug missing acceptance criteria", + issueType: types.TypeBug, + description: `## Steps to Reproduce +1. Click button +2. See error`, + wantErr: true, + wantMissing: 1, + }, + { + name: "bug with case-insensitive headings", + issueType: types.TypeBug, + description: `## steps to reproduce +Click the button + +## acceptance criteria +It works`, + wantErr: false, + }, + { + name: "bug with inline mentions (no markdown)", + issueType: types.TypeBug, + description: `Steps to reproduce: click the button. +Acceptance criteria: it should work.`, + wantErr: false, + }, + + // Task type tests + { + name: "task with acceptance criteria", + issueType: types.TypeTask, + description: `## Acceptance Criteria +- [ ] Task complete`, + wantErr: false, + }, + { + name: "task missing acceptance criteria", + issueType: types.TypeTask, + description: "Do the thing", + wantErr: true, + wantMissing: 1, + }, + + // Feature type tests + { + name: "feature with acceptance criteria", + issueType: types.TypeFeature, + description: `Add new widget + +## Acceptance Criteria +Widget displays correctly`, + wantErr: false, + }, + { + name: "feature missing acceptance criteria", + issueType: types.TypeFeature, + description: "Add a new feature", + wantErr: true, + wantMissing: 1, + }, + + // Epic type tests + { + name: "epic with success criteria", + issueType: types.TypeEpic, + description: `Big project + +## Success Criteria +- Project ships +- Users happy`, + wantErr: false, + }, + { + name: "epic missing success criteria", + issueType: types.TypeEpic, + description: "Do everything", + wantErr: true, + wantMissing: 1, + }, + + // Types with no requirements + { + name: "chore has no requirements", + issueType: types.TypeChore, + description: "Update deps", + wantErr: false, + }, + { + name: "message has no requirements", + issueType: types.TypeMessage, + description: "Hello", + wantErr: false, + }, + { + name: "molecule has no requirements", + issueType: types.TypeMolecule, + description: "", + wantErr: false, + }, + + // Edge cases + { + name: "empty description for bug", + issueType: types.TypeBug, + description: "", + wantErr: true, + wantMissing: 2, + }, + { + name: "empty description for chore", + issueType: types.TypeChore, + description: "", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTemplate(tt.issueType, tt.description) + if tt.wantErr { + if err == nil { + t.Errorf("ValidateTemplate() expected error, got nil") + return + } + templateErr, ok := err.(*TemplateError) + if !ok { + t.Errorf("ValidateTemplate() error type = %T, want *TemplateError", err) + return + } + if len(templateErr.Missing) != tt.wantMissing { + t.Errorf("ValidateTemplate() missing sections = %d, want %d", + len(templateErr.Missing), tt.wantMissing) + } + } else { + if err != nil { + t.Errorf("ValidateTemplate() unexpected error: %v", err) + } + } + }) + } +} + +func TestTemplateErrorMessage(t *testing.T) { + err := &TemplateError{ + IssueType: types.TypeBug, + Missing: []MissingSection{ + {Heading: "## Steps to Reproduce", Hint: "Describe how to reproduce"}, + {Heading: "## Acceptance Criteria", Hint: "Define fix criteria"}, + }, + } + + msg := err.Error() + if !strings.Contains(msg, "bug") { + t.Errorf("Error message should contain issue type, got: %s", msg) + } + if !strings.Contains(msg, "Steps to Reproduce") { + t.Errorf("Error message should contain missing heading, got: %s", msg) + } + if !strings.Contains(msg, "Describe how to reproduce") { + t.Errorf("Error message should contain hint, got: %s", msg) + } +} + +func TestTemplateErrorEmpty(t *testing.T) { + err := &TemplateError{ + IssueType: types.TypeBug, + Missing: nil, + } + if err.Error() != "" { + t.Errorf("Empty TemplateError should return empty string, got: %s", err.Error()) + } +} + +func TestLintIssue(t *testing.T) { + tests := []struct { + name string + issue *types.Issue + wantErr bool + }{ + { + name: "nil issue", + issue: nil, + wantErr: false, + }, + { + name: "valid bug", + issue: &types.Issue{ + IssueType: types.TypeBug, + Description: "## Steps to Reproduce\nClick\n\n## Acceptance Criteria\nFixed", + }, + wantErr: false, + }, + { + name: "invalid bug", + issue: &types.Issue{ + IssueType: types.TypeBug, + Description: "It's broken", + }, + wantErr: true, + }, + { + name: "chore always valid", + issue: &types.Issue{ + IssueType: types.TypeChore, + Description: "", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := LintIssue(tt.issue) + if (err != nil) != tt.wantErr { + t.Errorf("LintIssue() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}