diff --git a/.beads/bd.jsonl b/.beads/bd.jsonl index a9996bd7..9731166a 100644 --- a/.beads/bd.jsonl +++ b/.beads/bd.jsonl @@ -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"} diff --git a/CLAUDE.md b/CLAUDE.md index 8fcc8e5a..0a75315e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --status in_progress --json diff --git a/README.md b/README.md index 97eaa521..0092f93f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 61fd5445..63bd305b 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -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") diff --git a/cmd/bd/markdown.go b/cmd/bd/markdown.go new file mode 100644 index 00000000..e5b961bb --- /dev/null +++ b/cmd/bd/markdown.go @@ -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 +} diff --git a/cmd/bd/markdown_test.go b/cmd/bd/markdown_test.go new file mode 100644 index 00000000..a7672d01 --- /dev/null +++ b/cmd/bd/markdown_test.go @@ -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 +}