feat: Add markdown file support to bd create command
Implement `bd create -f file.md` to parse markdown files and create multiple issues in one command. This enables drafting features in markdown and converting them to tracked issues. Features: - Parse markdown H2 headers (##) as issue titles - Support all issue fields via H3 sections (### Priority, ### Type, etc.) - Handle multiple issues per file - Comprehensive validation and error handling - Full test coverage with 5 test cases Closes bd-91 (GH-9) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
238
cmd/bd/markdown_test.go
Normal file
238
cmd/bd/markdown_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMarkdownFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected []*IssueTemplate
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple issue",
|
||||
content: `## Fix authentication bug
|
||||
|
||||
This is a critical bug in the auth system.
|
||||
|
||||
### Priority
|
||||
1
|
||||
|
||||
### Type
|
||||
bug
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Fix authentication bug",
|
||||
Description: "This is a critical bug in the auth system.",
|
||||
Priority: 1,
|
||||
IssueType: "bug",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple issues",
|
||||
content: `## First Issue
|
||||
|
||||
Description for first issue.
|
||||
|
||||
### Priority
|
||||
0
|
||||
|
||||
### Type
|
||||
feature
|
||||
|
||||
## Second Issue
|
||||
|
||||
Description for second issue.
|
||||
|
||||
### Priority
|
||||
2
|
||||
|
||||
### Type
|
||||
task
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "First Issue",
|
||||
Description: "Description for first issue.",
|
||||
Priority: 0,
|
||||
IssueType: "feature",
|
||||
},
|
||||
{
|
||||
Title: "Second Issue",
|
||||
Description: "Description for second issue.",
|
||||
Priority: 2,
|
||||
IssueType: "task",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "issue with all fields",
|
||||
content: `## Comprehensive Issue
|
||||
|
||||
Initial description text.
|
||||
|
||||
### Priority
|
||||
1
|
||||
|
||||
### Type
|
||||
feature
|
||||
|
||||
### Description
|
||||
Detailed description here.
|
||||
Multi-line support.
|
||||
|
||||
### Design
|
||||
Design notes go here.
|
||||
|
||||
### Acceptance Criteria
|
||||
- Must do this
|
||||
- Must do that
|
||||
|
||||
### Assignee
|
||||
alice
|
||||
|
||||
### Labels
|
||||
backend, urgent
|
||||
|
||||
### Dependencies
|
||||
bd-10, bd-20
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Comprehensive Issue",
|
||||
Description: "Detailed description here.\nMulti-line support.",
|
||||
Design: "Design notes go here.",
|
||||
AcceptanceCriteria: "- Must do this\n- Must do that",
|
||||
Priority: 1,
|
||||
IssueType: "feature",
|
||||
Assignee: "alice",
|
||||
Labels: []string{"backend", "urgent"},
|
||||
Dependencies: []string{"bd-10", "bd-20"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dependencies with types",
|
||||
content: `## Issue with typed dependencies
|
||||
|
||||
### Priority
|
||||
2
|
||||
|
||||
### Type
|
||||
task
|
||||
|
||||
### Dependencies
|
||||
blocks:bd-10, discovered-from:bd-20
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Issue with typed dependencies",
|
||||
Priority: 2,
|
||||
IssueType: "task",
|
||||
Dependencies: []string{"blocks:bd-10", "discovered-from:bd-20"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default values",
|
||||
content: `## Minimal Issue
|
||||
|
||||
Just a title and description.
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Minimal Issue",
|
||||
Description: "Just a title and description.",
|
||||
Priority: 2, // default
|
||||
IssueType: "task", // default
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.md")
|
||||
if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Parse file
|
||||
got, err := parseMarkdownFile(tmpFile)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseMarkdownFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("parseMarkdownFile() got %d issues, want %d", len(got), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
// Compare each issue
|
||||
for i, gotIssue := range got {
|
||||
wantIssue := tt.expected[i]
|
||||
|
||||
if gotIssue.Title != wantIssue.Title {
|
||||
t.Errorf("Issue %d: Title = %q, want %q", i, gotIssue.Title, wantIssue.Title)
|
||||
}
|
||||
if gotIssue.Description != wantIssue.Description {
|
||||
t.Errorf("Issue %d: Description = %q, want %q", i, gotIssue.Description, wantIssue.Description)
|
||||
}
|
||||
if gotIssue.Priority != wantIssue.Priority {
|
||||
t.Errorf("Issue %d: Priority = %d, want %d", i, gotIssue.Priority, wantIssue.Priority)
|
||||
}
|
||||
if gotIssue.IssueType != wantIssue.IssueType {
|
||||
t.Errorf("Issue %d: IssueType = %q, want %q", i, gotIssue.IssueType, wantIssue.IssueType)
|
||||
}
|
||||
if gotIssue.Design != wantIssue.Design {
|
||||
t.Errorf("Issue %d: Design = %q, want %q", i, gotIssue.Design, wantIssue.Design)
|
||||
}
|
||||
if gotIssue.AcceptanceCriteria != wantIssue.AcceptanceCriteria {
|
||||
t.Errorf("Issue %d: AcceptanceCriteria = %q, want %q", i, gotIssue.AcceptanceCriteria, wantIssue.AcceptanceCriteria)
|
||||
}
|
||||
if gotIssue.Assignee != wantIssue.Assignee {
|
||||
t.Errorf("Issue %d: Assignee = %q, want %q", i, gotIssue.Assignee, wantIssue.Assignee)
|
||||
}
|
||||
|
||||
// Compare slices
|
||||
if !stringSlicesEqual(gotIssue.Labels, wantIssue.Labels) {
|
||||
t.Errorf("Issue %d: Labels = %v, want %v", i, gotIssue.Labels, wantIssue.Labels)
|
||||
}
|
||||
if !stringSlicesEqual(gotIssue.Dependencies, wantIssue.Dependencies) {
|
||||
t.Errorf("Issue %d: Dependencies = %v, want %v", i, gotIssue.Dependencies, wantIssue.Dependencies)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownFile_FileNotFound(t *testing.T) {
|
||||
_, err := parseMarkdownFile("/nonexistent/file.md")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user