Files
beads/cmd/bd/markdown.go
Steve Yegge bd6fa5cbbb fix: bd create -f now works with daemon mode (GH#719)
When using bd create -f with a running daemon, the command would fail
with 'database not initialized' because it bypassed the daemon RPC path.

The fix adds createIssuesFromMarkdownViaDaemon which:
- Parses the markdown file (no store access needed)
- Converts issue templates to CreateArgs
- Uses daemon Batch RPC to create all issues efficiently
- Preserves all features: labels, dependencies, hooks, JSON output

Tested with daemon and non-daemon modes, verifying priority 0 is
preserved correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 23:30:08 -08:00

528 lines
14 KiB
Go

// 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)
}
}
}