Breaks down large functions into smaller, focused helpers to pass gocyclo linter: Auto-import refactoring: - Extract parseJSONLIssues() to handle JSONL parsing - Extract handleCollisions() to detect and report conflicts - Extract importIssueData() to coordinate issue/dep/label imports - Extract updateExistingIssue() and createNewIssue() for clarity - Extract importDependencies() and importLabels() for modularity Flush refactoring: - Extract recordFlushFailure() and recordFlushSuccess() for state management - Extract readExistingJSONL() to isolate file reading logic - Extract fetchDirtyIssuesFromDB() to separate DB access - Extract writeIssuesToJSONL() to handle atomic writes Command improvements: - Extract executeLabelCommand() to eliminate duplication in label.go - Extract addLabelsToIssue() helper for label management - Replace deprecated strings.Title with manual capitalization Configuration: - Add gocyclo exception for test files in .golangci.yml All tests passing, no functionality changes.
326 lines
8.5 KiB
Go
326 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()
|
|
}
|