Files
beads/cmd/bd/markdown.go
Steve Yegge a86f3e139e Add native Windows support (#91)
- Native Windows daemon using TCP loopback endpoints
- Direct-mode fallback for CLI/daemon compatibility
- Comment operations over RPC
- PowerShell installer script
- Go 1.24 requirement
- Cross-OS testing documented

Co-authored-by: danshapiro <danshapiro@users.noreply.github.com>
Amp-Thread-ID: https://ampcode.com/threads/T-c6230265-055f-4af1-9712-4481061886db
Co-authored-by: Amp <amp@ampcode.com>
2025-10-20 21:08:49 -07:00

328 lines
8.5 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"
"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
//
// 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()
}