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>
528 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|