// 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" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/hooks" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/validation" ) 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 } // 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 := validation.ParsePriority(content); p != -1 { issue.Priority = p } case "type": t, err := validation.ParseIssueType(content) if err != nil { fmt.Fprintf(os.Stderr, "Warning: invalid issue type '%s' in '%s', using default 'task'\n", strings.TrimSpace(content), issue.Title) issue.IssueType = types.TypeTask } else { issue.IssueType = t } 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 // // markdownParseState holds state for parsing markdown files type markdownParseState struct { issues []*IssueTemplate currentIssue *IssueTemplate currentSection string sectionContent strings.Builder } // finalizeSection processes and resets the current section func (s *markdownParseState) finalizeSection() { if s.currentIssue == nil || s.currentSection == "" { return } content := s.sectionContent.String() processIssueSection(s.currentIssue, s.currentSection, content) s.sectionContent.Reset() } // handleH2Header handles H2 headers (new issue titles) func (s *markdownParseState) handleH2Header(matches []string) { // Finalize previous section if any s.finalizeSection() // Save previous issue if any if s.currentIssue != nil { s.issues = append(s.issues, s.currentIssue) } // Start new issue s.currentIssue = &IssueTemplate{ Title: strings.TrimSpace(matches[1]), Priority: 2, // Default priority IssueType: "task", // Default type } s.currentSection = "" } // handleH3Header handles H3 headers (section titles) func (s *markdownParseState) handleH3Header(matches []string) { // Finalize previous section s.finalizeSection() // Start new section s.currentSection = strings.TrimSpace(matches[1]) } // handleContentLine handles regular content lines func (s *markdownParseState) handleContentLine(line string) { if s.currentIssue == nil { return } // Content within a section if s.currentSection != "" { if s.sectionContent.Len() > 0 { s.sectionContent.WriteString("\n") } s.sectionContent.WriteString(line) return } // First lines after title (before any section) become description if s.currentIssue.Description == "" && line != "" { if s.currentIssue.Description != "" { s.currentIssue.Description += "\n" } s.currentIssue.Description += line } } // finalize completes parsing and returns the results func (s *markdownParseState) finalize() ([]*IssueTemplate, error) { // Finalize last section and issue s.finalizeSection() if s.currentIssue != nil { s.issues = append(s.issues, s.currentIssue) } // Check if we found any issues if len(s.issues) == 0 { return nil, fmt.Errorf("no issues found in markdown file (expected ## Issue Title format)") } return s.issues, nil } // createMarkdownScanner creates a scanner with appropriate buffer size func createMarkdownScanner(file *os.File) *bufio.Scanner { scanner := bufio.NewScanner(file) // Increase buffer size for large markdown files const maxScannerBuffer = 1024 * 1024 // 1MB buf := make([]byte, maxScannerBuffer) scanner.Buffer(buf, maxScannerBuffer) return scanner } 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 }() state := &markdownParseState{} scanner := createMarkdownScanner(file) for scanner.Scan() { line := scanner.Text() // Check for H2 (new issue) if matches := h2Regex.FindStringSubmatch(line); matches != nil { state.handleH2Header(matches) continue } // Check for H3 (section within issue) if matches := h3Regex.FindStringSubmatch(line); matches != nil { state.handleH3Header(matches) continue } // Regular content line state.handleContentLine(line) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("error reading file: %w", err) } return state.finalize() } // createIssuesFromMarkdown parses a markdown file and creates multiple issues from it func createIssuesFromMarkdown(_ *cobra.Command, filepath string) { // Parse markdown file first (doesn't require store access) 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) } // If daemon is running, use RPC batch create (GH#719) if daemonClient != nil { createIssuesFromMarkdownViaDaemon(templates, filepath) return } // Direct mode: ensure globals are initialized if store == nil { fmt.Fprintf(os.Stderr, "Error: database not initialized\n") os.Exit(1) } if actor == "" { actor = "bd" // Default actor if not set } ctx := rootCtx 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 { fmt.Fprintf(os.Stderr, "\n%s Failed to create %d issues:\n", ui.RenderFail("✗"), len(failedIssues)) for _, title := range failedIssues { fmt.Fprintf(os.Stderr, " - %s\n", title) } } if jsonOutput { outputJSON(createdIssues) } else { fmt.Printf("%s Created %d issues from %s:\n", ui.RenderPass("✓"), len(createdIssues), filepath) for _, issue := range createdIssues { fmt.Printf(" %s: %s [P%d, %s]\n", issue.ID, issue.Title, issue.Priority, issue.IssueType) } } } // createIssuesFromMarkdownViaDaemon creates issues via daemon RPC batch operation func createIssuesFromMarkdownViaDaemon(templates []*IssueTemplate, filepath string) { createdIssues := []*types.Issue{} failedIssues := []string{} // Build batch operations for all issues operations := make([]rpc.BatchOperation, 0, len(templates)) for _, template := range templates { createArgs := &rpc.CreateArgs{ Title: template.Title, Description: template.Description, Design: template.Design, AcceptanceCriteria: template.AcceptanceCriteria, IssueType: string(template.IssueType), Priority: template.Priority, Assignee: template.Assignee, Labels: template.Labels, Dependencies: template.Dependencies, } argsJSON, err := json.Marshal(createArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error marshaling args for '%s': %v\n", template.Title, err) failedIssues = append(failedIssues, template.Title) continue } operations = append(operations, rpc.BatchOperation{ Operation: "create", Args: argsJSON, }) } // Execute batch batchArgs := &rpc.BatchArgs{Operations: operations} resp, err := daemonClient.Batch(batchArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error executing batch create: %v\n", err) os.Exit(1) } // Parse batch response var batchResp rpc.BatchResponse if err := json.Unmarshal(resp.Data, &batchResp); err != nil { fmt.Fprintf(os.Stderr, "Error parsing batch response: %v\n", err) os.Exit(1) } // Process results for i, result := range batchResp.Results { if i >= len(templates) { break } template := templates[i] if !result.Success { fmt.Fprintf(os.Stderr, "Error creating issue '%s': %s\n", template.Title, result.Error) failedIssues = append(failedIssues, template.Title) continue } var issue types.Issue if err := json.Unmarshal(result.Data, &issue); err != nil { fmt.Fprintf(os.Stderr, "Warning: created issue '%s' but failed to parse response: %v\n", template.Title, err) // Still count as success since the issue was created createdIssues = append(createdIssues, &types.Issue{Title: template.Title}) continue } // Run create hook for each issue if hookRunner != nil { hookRunner.Run(hooks.EventCreate, &issue) } createdIssues = append(createdIssues, &issue) } // Report failures if any if len(failedIssues) > 0 { fmt.Fprintf(os.Stderr, "\n%s Failed to create %d issues:\n", ui.RenderFail("✗"), len(failedIssues)) for _, title := range failedIssues { fmt.Fprintf(os.Stderr, " - %s\n", title) } } if jsonOutput { outputJSON(createdIssues) } else { fmt.Printf("%s Created %d issues from %s:\n", ui.RenderPass("✓"), len(createdIssues), filepath) for _, issue := range createdIssues { fmt.Printf(" %s: %s [P%d, %s]\n", issue.ID, issue.Title, issue.Priority, issue.IssueType) } } }