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-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-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-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-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-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"} {"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-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-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-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-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-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"} {"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) # Create with explicit ID (for parallel workers)
bd create "Issue title" --id worker1-100 -p 1 --json 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 # Update issue status
bd update <id> --status in_progress --json 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 # Get JSON output for programmatic use
bd create "Fix bug" -d "Description" --json bd create "Fix bug" -d "Description" --json
# Create multiple issues from a markdown file
bd create -f feature-plan.md
``` ```
Options: Options:
- `-f, --file` - Create multiple issues from markdown file
- `-d, --description` - Issue description - `-d, --description` - Issue description
- `-p, --priority` - Priority (0-4, 0=highest) - `-p, --priority` - Priority (0-4, 0=highest)
- `-t, --type` - Type (bug|feature|task|epic|chore) - `-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) - `--id` - Explicit issue ID (e.g., `worker1-100` for ID space partitioning)
- `--json` - Output in JSON format - `--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 ### Viewing Issues
```bash ```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") 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{ var createCmd = &cobra.Command{
Use: "create [title]", Use: "create [title]",
Short: "Create a new issue", Short: "Create a new issue (or multiple issues from markdown file)",
Args: cobra.MinimumNArgs(1), Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f
Run: func(cmd *cobra.Command, args []string) { 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] title := args[0]
description, _ := cmd.Flags().GetString("description") description, _ := cmd.Flags().GetString("description")
design, _ := cmd.Flags().GetString("design") design, _ := cmd.Flags().GetString("design")
@@ -649,6 +778,7 @@ var createCmd = &cobra.Command{
} }
func init() { func init() {
createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file")
createCmd.Flags().StringP("description", "d", "", "Issue description") createCmd.Flags().StringP("description", "d", "", "Issue description")
createCmd.Flags().String("design", "", "Design notes") createCmd.Flags().String("design", "", "Design notes")
createCmd.Flags().String("acceptance", "", "Acceptance criteria") 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
}