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:
Steve Yegge
2025-10-14 13:10:06 -07:00
parent 5db7dffa6c
commit 92885bb7a3
6 changed files with 761 additions and 3 deletions

View File

@@ -77,6 +77,16 @@
{"id":"bd-168","title":"Another test with multiple deps","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-14T03:24:32.746757-07:00","updated_at":"2025-10-14T03:24:46.261275-07:00","closed_at":"2025-10-14T03:24:46.261275-07:00","dependencies":[{"issue_id":"bd-168","depends_on_id":"bd-89","type":"blocks","created_at":"2025-10-14T03:24:32.747029-07:00","created_by":"stevey"},{"issue_id":"bd-168","depends_on_id":"bd-90","type":"blocks","created_at":"2025-10-14T03:24:32.747181-07:00","created_by":"stevey"}]}
{"id":"bd-169","title":"Fix: bd init --prefix test -q flag not recognized","description":"The init command doesn't recognize the -q flag. When running 'bd init --prefix test -q', it fails silently or behaves unexpectedly. The flag should either be implemented for quiet mode or removed from documentation if not supported.","status":"open","priority":2,"issue_type":"bug","created_at":"2025-10-14T12:33:51.614293-07:00","updated_at":"2025-10-14T12:33:51.614293-07:00"}
{"id":"bd-17","title":"Update documentation for collision resolution","description":"Update README.md with collision resolution section. Update CLAUDE.md with new workflow. Document --resolve-collisions and --dry-run flags. Add example scenarios showing branch merge workflows.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-12T14:40:56.866649-07:00","updated_at":"2025-10-14T03:04:05.959647-07:00","closed_at":"2025-10-12T17:06:14.930928-07:00","dependencies":[{"issue_id":"bd-17","depends_on_id":"bd-9","type":"parent-child","created_at":"2025-10-12T14:41:07.970302-07:00","created_by":"stevey"}]}
{"id":"bd-170","title":"Add OAuth2 support","description":"Implement OAuth2 authentication flow with support for Google and GitHub providers.","design":"- Create OAuth2 provider interface\n- Implement Google provider\n- Implement GitHub provider\n- Add callback handler\n- Store tokens securely","acceptance_criteria":"- Users can authenticate with Google\n- Users can authenticate with GitHub\n- Tokens are stored securely in database\n- Token refresh works automatically","status":"closed","priority":1,"issue_type":"feature","assignee":"alice","created_at":"2025-10-14T12:40:30.990247-07:00","updated_at":"2025-10-14T12:40:50.292308-07:00","closed_at":"2025-10-14T12:40:50.292308-07:00"}
{"id":"bd-171","title":"Add rate limiting to auth endpoints","description":"Auth endpoints are vulnerable to brute force attacks. Need to add rate limiting.","status":"closed","priority":0,"issue_type":"bug","assignee":"bob","created_at":"2025-10-14T12:40:30.996332-07:00","updated_at":"2025-10-14T12:40:50.293099-07:00","closed_at":"2025-10-14T12:40:50.293099-07:00"}
{"id":"bd-172","title":"Improve session management","description":"Current session management is basic. Need to improve with better expiration handling.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-14T12:40:30.997104-07:00","updated_at":"2025-10-14T12:40:50.29329-07:00","closed_at":"2025-10-14T12:40:50.29329-07:00"}
{"id":"bd-173","title":"Refactor parseMarkdownFile to reduce cyclomatic complexity","description":"The parseMarkdownFile function in cmd/bd/markdown.go has a cyclomatic complexity of 38, which exceeds the recommended threshold of 30. This makes the function harder to understand, test, and maintain.","design":"Split the function into smaller, focused units:\n\n1. parseMarkdownFile(filepath) - Main entry point, handles file I/O\n2. parseMarkdownContent(scanner) - Core parsing logic\n3. processIssueSection(issue, section, content) - Handle section finalization (current switch statement)\n4. parseLabels(content) []string - Extract labels from content\n5. parseDependencies(content) []string - Extract dependencies from content\n6. parsePriority(content) int - Parse and validate priority\n\nBenefits:\n- Each function has a single responsibility\n- Easier to test individual components\n- Lower cognitive load when reading code\n- Better encapsulation of parsing logic","acceptance_criteria":"- parseMarkdownFile complexity \u003c 15\n- New helper functions each have complexity \u003c 10\n- All existing tests still pass\n- No change in functionality or behavior\n- Code coverage maintained or improved","status":"closed","priority":3,"issue_type":"task","created_at":"2025-10-14T12:51:21.241236-07:00","updated_at":"2025-10-14T12:55:35.140001-07:00","closed_at":"2025-10-14T12:55:35.140001-07:00","dependencies":[{"issue_id":"bd-173","depends_on_id":"bd-91","type":"discovered-from","created_at":"2025-10-14T12:51:21.24297-07:00","created_by":"stevey"}]}
{"id":"bd-174","title":"Add OAuth2 support","description":"Implement OAuth2 authentication flow with support for Google and GitHub providers.","design":"- Create OAuth2 provider interface\n- Implement Google provider\n- Implement GitHub provider\n- Add callback handler\n- Store tokens securely","acceptance_criteria":"- Users can authenticate with Google\n- Users can authenticate with GitHub\n- Tokens are stored securely in database\n- Token refresh works automatically","status":"closed","priority":1,"issue_type":"feature","assignee":"alice","created_at":"2025-10-14T12:55:09.226351-07:00","updated_at":"2025-10-14T12:55:17.818093-07:00","closed_at":"2025-10-14T12:55:17.818093-07:00"}
{"id":"bd-175","title":"Add rate limiting to auth endpoints","description":"Auth endpoints are vulnerable to brute force attacks. Need to add rate limiting.","status":"closed","priority":0,"issue_type":"bug","assignee":"bob","created_at":"2025-10-14T12:55:09.228394-07:00","updated_at":"2025-10-14T12:55:17.819352-07:00","closed_at":"2025-10-14T12:55:17.819352-07:00"}
{"id":"bd-176","title":"Improve session management","description":"Current session management is basic. Need to improve with better expiration handling.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-14T12:55:09.228919-07:00","updated_at":"2025-10-14T12:55:17.819557-07:00","closed_at":"2025-10-14T12:55:17.819557-07:00"}
{"id":"bd-177","title":"Add OAuth2 support","description":"Implement OAuth2 authentication flow with support for Google and GitHub providers.","design":"- Create OAuth2 provider interface\n- Implement Google provider\n- Implement GitHub provider\n- Add callback handler\n- Store tokens securely","acceptance_criteria":"- Users can authenticate with Google\n- Users can authenticate with GitHub\n- Tokens are stored securely in database\n- Token refresh works automatically","status":"closed","priority":1,"issue_type":"feature","assignee":"alice","created_at":"2025-10-14T13:01:35.935497-07:00","updated_at":"2025-10-14T13:01:35.950067-07:00","closed_at":"2025-10-14T13:01:35.950067-07:00"}
{"id":"bd-178","title":"Add rate limiting to auth endpoints","description":"Auth endpoints are vulnerable to brute force attacks. Need to add rate limiting.","status":"closed","priority":0,"issue_type":"bug","assignee":"bob","created_at":"2025-10-14T13:01:35.937662-07:00","updated_at":"2025-10-14T13:01:35.950387-07:00","closed_at":"2025-10-14T13:01:35.950387-07:00"}
{"id":"bd-179","title":"Improve session management","description":"Current session management is basic. Need to improve with better expiration handling.","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-14T13:01:35.93812-07:00","updated_at":"2025-10-14T13:01:35.950517-07:00","closed_at":"2025-10-14T13:01:35.950517-07:00"}
{"id":"bd-18","title":"Add design/notes/acceptance_criteria fields to update command","description":"Currently bd update only supports status, priority, title, assignee. Add support for --design, --notes, --acceptance-criteria flags. This makes it easier to add detailed designs to issues after creation.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-10-12T14:40:57.032395-07:00","updated_at":"2025-10-14T03:04:05.959826-07:00","closed_at":"2025-10-12T17:10:53.958318-07:00"}
{"id":"bd-19","title":"Fix import zero-value field handling","description":"Import uses zero-value checks (Priority != 0) to determine field updates. This prevents setting priority to 0 or clearing string fields. Export/import round-trip not fully idempotent for zero values. Consider JSON presence detection or explicit preserve-existing semantics. Location: cmd/bd/import.go:95-106","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-10-12T15:13:17.895083-07:00","updated_at":"2025-10-14T03:04:05.959987-07:00"}
{"id":"bd-2","title":"Add PostgreSQL backend","description":"Implement PostgreSQL storage backend as alternative to SQLite for larger teams","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-10-12T00:43:03.457453-07:00","updated_at":"2025-10-14T03:04:05.960143-07:00","closed_at":"2025-10-12T14:15:04.00695-07:00"}
@@ -158,7 +168,7 @@
{"id":"bd-89","title":"GH-6: Fix race condition in parallel issue creation","description":"Creating multiple issues rapidly in parallel causes 'UNIQUE constraint failed: issues.id' error. The ID generation has a race condition. Reproducible with: for i in {26..35}; do ./bd create parallel_ 2\u003e\u00261 \u0026 done","status":"open","priority":0,"issue_type":"bug","created_at":"2025-10-14T02:44:55.510776-07:00","updated_at":"2025-10-14T03:04:05.97313-07:00","closed_at":"2025-10-14T02:58:22.645874-07:00","external_ref":"gh-6"}
{"id":"bd-9","title":"Build collision resolution tooling for distributed branch workflows","description":"When branches diverge and both create issues, auto-incrementing IDs collide on merge. Build excellent tooling to detect collisions during import, auto-renumber issues with fewer dependencies, update all references in descriptions and dependency links, and provide clear user feedback. Goal: keep beautiful brevity of numeric IDs (bd-302) while handling distributed creation gracefully.","status":"in_progress","priority":1,"issue_type":"feature","created_at":"2025-10-12T13:39:34.608218-07:00","updated_at":"2025-10-14T03:04:05.973251-07:00"}
{"id":"bd-90","title":"GH-7: Package available in AUR (beads-git)","description":"Community member created AUR package for Arch Linux: https://aur.archlinux.org/packages/beads-git. This is informational - no action needed, but good to track for release process and documentation.","status":"open","priority":4,"issue_type":"chore","created_at":"2025-10-14T02:44:56.4535-07:00","updated_at":"2025-10-14T03:04:05.973364-07:00","external_ref":"gh-7"}
{"id":"bd-91","title":"GH-9: Support markdown files in bd create","description":"Request to support markdown files as input to bd create, which would parse the markdown and split it into multiple issues. Use case: developers keep feature drafts in markdown files in version control, then want to convert them into issues. Example: bd create -f feature-draft.md","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-14T02:44:57.405586-07:00","updated_at":"2025-10-14T03:04:05.973505-07:00","external_ref":"gh-9"}
{"id":"bd-91","title":"GH-9: Support markdown files in bd create","description":"Request to support markdown files as input to bd create, which would parse the markdown and split it into multiple issues. Use case: developers keep feature drafts in markdown files in version control, then want to convert them into issues. Example: bd create -f feature-draft.md","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-10-14T02:44:57.405586-07:00","updated_at":"2025-10-14T12:42:14.457949-07:00","closed_at":"2025-10-14T12:42:14.457949-07:00","external_ref":"gh-9"}
{"id":"bd-92","title":"GH-11: Add Docker support for hosted/shared instance","description":"Request for Docker container hosting to use beads across multiple dev machines. Would need to consider: centralized database (PostgreSQL?), authentication, concurrent access, API server, etc. This is a significant architectural change from the current local-first model.","status":"open","priority":2,"issue_type":"feature","created_at":"2025-10-14T02:44:58.469094-07:00","updated_at":"2025-10-14T03:04:05.973622-07:00","external_ref":"gh-11"}
{"id":"bd-93","title":"GH-18: Add --deps flag to bd create for one-command issue creation","description":"Request to add dependency specification to bd create command instead of requiring separate 'bd dep add' command. Proposed syntax: bd create 'Fix bug' --deps discovered-from=bd-20. This would be especially useful for aider integration and reducing command verbosity.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-10-14T02:44:59.610192-07:00","updated_at":"2025-10-14T03:26:59.536349-07:00","closed_at":"2025-10-14T03:26:59.536349-07:00","external_ref":"gh-18"}
{"id":"bd-94","title":"parallel_test_1","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-14T02:55:46.913771-07:00","updated_at":"2025-10-14T02:55:46.913771-07:00"}

View File

@@ -20,6 +20,9 @@ bd create "Issue title" -t bug|feature|task -p 0-4 -d "Description" --json
# Create with explicit ID (for parallel workers)
bd create "Issue title" --id worker1-100 -p 1 --json
# Create multiple issues from markdown file
bd create -f feature-plan.md --json
# Update issue status
bd update <id> --status in_progress --json

View File

@@ -207,9 +207,13 @@ bd create "Worker task" --id worker1-100 -p 1
# Get JSON output for programmatic use
bd create "Fix bug" -d "Description" --json
# Create multiple issues from a markdown file
bd create -f feature-plan.md
```
Options:
- `-f, --file` - Create multiple issues from markdown file
- `-d, --description` - Issue description
- `-p, --priority` - Priority (0-4, 0=highest)
- `-t, --type` - Type (bug|feature|task|epic|chore)
@@ -218,6 +222,90 @@ Options:
- `--id` - Explicit issue ID (e.g., `worker1-100` for ID space partitioning)
- `--json` - Output in JSON format
#### Creating Issues from Markdown
You can draft multiple issues in a markdown file and create them all at once. This is useful for planning features or converting written notes into tracked work.
Markdown format:
```markdown
## Issue Title
Optional description text here.
### Priority
1
### Type
feature
### Description
More detailed description (overrides text after title).
### Design
Design notes and implementation details.
### Acceptance Criteria
- Must do this
- Must do that
### Assignee
username
### Labels
label1, label2, label3
### Dependencies
bd-10, bd-20
```
Example markdown file (`auth-improvements.md`):
```markdown
## Add OAuth2 support
We need to support OAuth2 authentication.
### Priority
1
### Type
feature
### Assignee
alice
### Labels
auth, high-priority
## Add rate limiting
### Priority
0
### Type
bug
### Description
Auth endpoints are vulnerable to brute force attacks.
### Labels
security, urgent
```
Create all issues:
```bash
bd create -f auth-improvements.md
# ✓ Created 2 issues from auth-improvements.md:
# bd-42: Add OAuth2 support [P1, feature]
# bd-43: Add rate limiting [P0, bug]
```
**Notes:**
- Each `## Heading` creates a new issue
- Sections (`### Priority`, `### Type`, etc.) are optional
- Defaults: Priority=2, Type=task
- Text immediately after the title becomes the description (unless overridden by `### Description`)
- All standard issue fields are supported
### Viewing Issues
```bash

View File

@@ -527,11 +527,140 @@ func init() {
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
}
// createIssuesFromMarkdown parses a markdown file and creates multiple issues
func createIssuesFromMarkdown(cmd *cobra.Command, filepath string) {
// Parse markdown file
templates, err := parseMarkdownFile(filepath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing markdown file: %v\n", err)
os.Exit(1)
}
if len(templates) == 0 {
fmt.Fprintf(os.Stderr, "No issues found in markdown file\n")
os.Exit(1)
}
ctx := context.Background()
createdIssues := []*types.Issue{}
failedIssues := []string{}
// Create each issue
for _, template := range templates {
issue := &types.Issue{
Title: template.Title,
Description: template.Description,
Design: template.Design,
AcceptanceCriteria: template.AcceptanceCriteria,
Status: types.StatusOpen,
Priority: template.Priority,
IssueType: template.IssueType,
Assignee: template.Assignee,
}
if err := store.CreateIssue(ctx, issue, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error creating issue '%s': %v\n", template.Title, err)
failedIssues = append(failedIssues, template.Title)
continue
}
// Add labels
for _, label := range template.Labels {
if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to add label %s to %s: %v\n", label, issue.ID, err)
}
}
// Add dependencies
for _, depSpec := range template.Dependencies {
depSpec = strings.TrimSpace(depSpec)
if depSpec == "" {
continue
}
var depType types.DependencyType
var dependsOnID string
// Parse format: "type:id" or just "id" (defaults to "blocks")
if strings.Contains(depSpec, ":") {
parts := strings.SplitN(depSpec, ":", 2)
if len(parts) != 2 {
fmt.Fprintf(os.Stderr, "Warning: invalid dependency format '%s' for %s\n", depSpec, issue.ID)
continue
}
depType = types.DependencyType(strings.TrimSpace(parts[0]))
dependsOnID = strings.TrimSpace(parts[1])
} else {
depType = types.DepBlocks
dependsOnID = depSpec
}
if !depType.IsValid() {
fmt.Fprintf(os.Stderr, "Warning: invalid dependency type '%s' for %s\n", depType, issue.ID)
continue
}
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: dependsOnID,
Type: depType,
}
if err := store.AddDependency(ctx, dep, actor); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to add dependency %s -> %s: %v\n", issue.ID, dependsOnID, err)
}
}
createdIssues = append(createdIssues, issue)
}
// Schedule auto-flush
if len(createdIssues) > 0 {
markDirtyAndScheduleFlush()
}
// Report failures if any
if len(failedIssues) > 0 {
red := color.New(color.FgRed).SprintFunc()
fmt.Fprintf(os.Stderr, "\n%s Failed to create %d issues:\n", red("✗"), len(failedIssues))
for _, title := range failedIssues {
fmt.Fprintf(os.Stderr, " - %s\n", title)
}
}
if jsonOutput {
outputJSON(createdIssues)
} else {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("%s Created %d issues from %s:\n", green("✓"), len(createdIssues), filepath)
for _, issue := range createdIssues {
fmt.Printf(" %s: %s [P%d, %s]\n", issue.ID, issue.Title, issue.Priority, issue.IssueType)
}
}
}
var createCmd = &cobra.Command{
Use: "create [title]",
Short: "Create a new issue",
Args: cobra.MinimumNArgs(1),
Short: "Create a new issue (or multiple issues from markdown file)",
Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f
Run: func(cmd *cobra.Command, args []string) {
file, _ := cmd.Flags().GetString("file")
// If file flag is provided, parse markdown and create multiple issues
if file != "" {
if len(args) > 0 {
fmt.Fprintf(os.Stderr, "Error: cannot specify both title and --file flag\n")
os.Exit(1)
}
createIssuesFromMarkdown(cmd, file)
return
}
// Original single-issue creation logic
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Error: title required (or use --file to create from markdown)\n")
os.Exit(1)
}
title := args[0]
description, _ := cmd.Flags().GetString("description")
design, _ := cmd.Flags().GetString("design")
@@ -649,6 +778,7 @@ var createCmd = &cobra.Command{
}
func init() {
createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file")
createCmd.Flags().StringP("description", "d", "", "Issue description")
createCmd.Flags().String("design", "", "Design notes")
createCmd.Flags().String("acceptance", "", "Acceptance criteria")

289
cmd/bd/markdown.go Normal file
View File

@@ -0,0 +1,289 @@
// Package main provides the bd command-line interface.
// This file implements markdown file parsing for bulk issue creation from structured markdown documents.
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/steveyegge/beads/internal/types"
)
var (
// h2Regex matches markdown H2 headers (## Title) for issue titles.
// Compiled once at package init for performance.
h2Regex = regexp.MustCompile(`^##\s+(.+)$`)
// h3Regex matches markdown H3 headers (### Section) for issue sections.
// Compiled once at package init for performance.
h3Regex = regexp.MustCompile(`^###\s+(.+)$`)
)
// IssueTemplate represents a parsed issue from markdown
type IssueTemplate struct {
Title string
Description string
Design string
AcceptanceCriteria string
Priority int
IssueType types.IssueType
Assignee string
Labels []string
Dependencies []string
}
// parsePriority extracts and validates a priority value from content.
// Returns the parsed priority (0-4) or -1 if invalid.
func parsePriority(content string) int {
var p int
if _, err := fmt.Sscanf(content, "%d", &p); err == nil && p >= 0 && p <= 4 {
return p
}
return -1 // Invalid
}
// parseIssueType extracts and validates an issue type from content.
// Returns the validated type or empty string if invalid.
func parseIssueType(content, issueTitle string) types.IssueType {
issueType := types.IssueType(strings.TrimSpace(content))
// Validate issue type
validTypes := map[types.IssueType]bool{
types.TypeBug: true,
types.TypeFeature: true,
types.TypeTask: true,
types.TypeEpic: true,
types.TypeChore: true,
}
if !validTypes[issueType] {
// Warn but continue with default
fmt.Fprintf(os.Stderr, "Warning: invalid issue type '%s' in '%s', using default 'task'\n",
issueType, issueTitle)
return types.TypeTask
}
return issueType
}
// parseStringList extracts a list of strings from content, splitting by comma or whitespace.
// This is a generic helper used by parseLabels and parseDependencies.
func parseStringList(content string) []string {
var items []string
fields := strings.FieldsFunc(content, func(r rune) bool {
return r == ',' || r == ' ' || r == '\n'
})
for _, item := range fields {
item = strings.TrimSpace(item)
if item != "" {
items = append(items, item)
}
}
return items
}
// parseLabels extracts labels from content, splitting by comma or whitespace.
func parseLabels(content string) []string {
return parseStringList(content)
}
// parseDependencies extracts dependencies from content, splitting by comma or whitespace.
func parseDependencies(content string) []string {
return parseStringList(content)
}
// processIssueSection processes a parsed section and updates the issue template.
func processIssueSection(issue *IssueTemplate, section, content string) {
content = strings.TrimSpace(content)
if content == "" {
return
}
switch strings.ToLower(section) {
case "priority":
if p := parsePriority(content); p != -1 {
issue.Priority = p
}
case "type":
issue.IssueType = parseIssueType(content, issue.Title)
case "description":
issue.Description = content
case "design":
issue.Design = content
case "acceptance criteria", "acceptance":
issue.AcceptanceCriteria = content
case "assignee":
issue.Assignee = strings.TrimSpace(content)
case "labels":
issue.Labels = parseLabels(content)
case "dependencies", "deps":
issue.Dependencies = parseDependencies(content)
}
}
// validateMarkdownPath validates and cleans a markdown file path to prevent security issues.
// It checks for directory traversal attempts and ensures the file is a markdown file.
func validateMarkdownPath(path string) (string, error) {
// Clean the path
cleanPath := filepath.Clean(path)
// Prevent directory traversal
if strings.Contains(cleanPath, "..") {
return "", fmt.Errorf("invalid file path: directory traversal not allowed")
}
// Ensure it's a markdown file
ext := strings.ToLower(filepath.Ext(cleanPath))
if ext != ".md" && ext != ".markdown" {
return "", fmt.Errorf("invalid file type: only .md and .markdown files are supported")
}
// Check file exists and is not a directory
info, err := os.Stat(cleanPath)
if err != nil {
return "", fmt.Errorf("cannot access file: %w", err)
}
if info.IsDir() {
return "", fmt.Errorf("path is a directory, not a file")
}
return cleanPath, nil
}
// parseMarkdownFile parses a markdown file and extracts issue templates.
// Expected format:
// ## Issue Title
// Description text...
//
// ### Priority
// 2
//
// ### Type
// feature
//
// ### Description
// Detailed description...
//
// ### Design
// Design notes...
//
// ### Acceptance Criteria
// - Criterion 1
// - Criterion 2
//
// ### Assignee
// username
//
// ### Labels
// label1, label2
//
// ### Dependencies
// bd-10, bd-20
func parseMarkdownFile(path string) ([]*IssueTemplate, error) {
// Validate and clean the file path
cleanPath, err := validateMarkdownPath(path)
if err != nil {
return nil, err
}
// #nosec G304 -- Path is validated by validateMarkdownPath which prevents traversal
file, err := os.Open(cleanPath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer func() {
_ = file.Close() // Close errors on read-only operations are not actionable
}()
var issues []*IssueTemplate
var currentIssue *IssueTemplate
var currentSection string
var sectionContent strings.Builder
scanner := bufio.NewScanner(file)
// Increase buffer size for large markdown files
const maxScannerBuffer = 1024 * 1024 // 1MB
buf := make([]byte, maxScannerBuffer)
scanner.Buffer(buf, maxScannerBuffer)
// Helper to finalize current section
finalizeSection := func() {
if currentIssue == nil || currentSection == "" {
return
}
content := sectionContent.String()
processIssueSection(currentIssue, currentSection, content)
sectionContent.Reset()
}
for scanner.Scan() {
line := scanner.Text()
// Check for H2 (new issue)
if matches := h2Regex.FindStringSubmatch(line); matches != nil {
// Finalize previous section if any
finalizeSection()
// Save previous issue if any
if currentIssue != nil {
issues = append(issues, currentIssue)
}
// Start new issue
currentIssue = &IssueTemplate{
Title: strings.TrimSpace(matches[1]),
Priority: 2, // Default priority
IssueType: "task", // Default type
}
currentSection = ""
continue
}
// Check for H3 (section within issue)
if matches := h3Regex.FindStringSubmatch(line); matches != nil {
// Finalize previous section
finalizeSection()
// Start new section
currentSection = strings.TrimSpace(matches[1])
continue
}
// Regular content line - append to current section
if currentIssue != nil && currentSection != "" {
if sectionContent.Len() > 0 {
sectionContent.WriteString("\n")
}
sectionContent.WriteString(line)
} else if currentIssue != nil && currentSection == "" && currentIssue.Description == "" {
// First lines after title (before any section) become description
if line != "" {
if currentIssue.Description != "" {
currentIssue.Description += "\n"
}
currentIssue.Description += line
}
}
}
// Finalize last section and issue
finalizeSection()
if currentIssue != nil {
issues = append(issues, currentIssue)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading file: %w", err)
}
// Check if we found any issues
if len(issues) == 0 {
return nil, fmt.Errorf("no issues found in markdown file (expected ## Issue Title format)")
}
return issues, nil
}

238
cmd/bd/markdown_test.go Normal file
View 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
}