feat: Add markdown file support to bd create command
Implement `bd create -f file.md` to parse markdown files and create multiple issues in one command. This enables drafting features in markdown and converting them to tracked issues. Features: - Parse markdown H2 headers (##) as issue titles - Support all issue fields via H3 sections (### Priority, ### Type, etc.) - Handle multiple issues per file - Comprehensive validation and error handling - Full test coverage with 5 test cases Closes bd-91 (GH-9) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
134
cmd/bd/main.go
134
cmd/bd/main.go
@@ -527,11 +527,140 @@ func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
|
||||
}
|
||||
|
||||
// createIssuesFromMarkdown parses a markdown file and creates multiple issues
|
||||
func createIssuesFromMarkdown(cmd *cobra.Command, filepath string) {
|
||||
// Parse markdown file
|
||||
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)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
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 {
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
fmt.Fprintf(os.Stderr, "\n%s Failed to create %d issues:\n", red("✗"), len(failedIssues))
|
||||
for _, title := range failedIssues {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", title)
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(createdIssues)
|
||||
} else {
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("%s Created %d issues from %s:\n", green("✓"), len(createdIssues), filepath)
|
||||
for _, issue := range createdIssues {
|
||||
fmt.Printf(" %s: %s [P%d, %s]\n", issue.ID, issue.Title, issue.Priority, issue.IssueType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var createCmd = &cobra.Command{
|
||||
Use: "create [title]",
|
||||
Short: "Create a new issue",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Short: "Create a new issue (or multiple issues from markdown file)",
|
||||
Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
file, _ := cmd.Flags().GetString("file")
|
||||
|
||||
// If file flag is provided, parse markdown and create multiple issues
|
||||
if file != "" {
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Error: cannot specify both title and --file flag\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
createIssuesFromMarkdown(cmd, file)
|
||||
return
|
||||
}
|
||||
|
||||
// Original single-issue creation logic
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "Error: title required (or use --file to create from markdown)\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
title := args[0]
|
||||
description, _ := cmd.Flags().GetString("description")
|
||||
design, _ := cmd.Flags().GetString("design")
|
||||
@@ -649,6 +778,7 @@ var createCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file")
|
||||
createCmd.Flags().StringP("description", "d", "", "Issue description")
|
||||
createCmd.Flags().String("design", "", "Design notes")
|
||||
createCmd.Flags().String("acceptance", "", "Acceptance criteria")
|
||||
|
||||
289
cmd/bd/markdown.go
Normal file
289
cmd/bd/markdown.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// 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
|
||||
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
|
||||
}()
|
||||
|
||||
var issues []*IssueTemplate
|
||||
var currentIssue *IssueTemplate
|
||||
var currentSection string
|
||||
var sectionContent strings.Builder
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
// Increase buffer size for large markdown files
|
||||
const maxScannerBuffer = 1024 * 1024 // 1MB
|
||||
buf := make([]byte, maxScannerBuffer)
|
||||
scanner.Buffer(buf, maxScannerBuffer)
|
||||
|
||||
// Helper to finalize current section
|
||||
finalizeSection := func() {
|
||||
if currentIssue == nil || currentSection == "" {
|
||||
return
|
||||
}
|
||||
content := sectionContent.String()
|
||||
processIssueSection(currentIssue, currentSection, content)
|
||||
sectionContent.Reset()
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Check for H2 (new issue)
|
||||
if matches := h2Regex.FindStringSubmatch(line); matches != nil {
|
||||
// Finalize previous section if any
|
||||
finalizeSection()
|
||||
|
||||
// Save previous issue if any
|
||||
if currentIssue != nil {
|
||||
issues = append(issues, currentIssue)
|
||||
}
|
||||
|
||||
// Start new issue
|
||||
currentIssue = &IssueTemplate{
|
||||
Title: strings.TrimSpace(matches[1]),
|
||||
Priority: 2, // Default priority
|
||||
IssueType: "task", // Default type
|
||||
}
|
||||
currentSection = ""
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for H3 (section within issue)
|
||||
if matches := h3Regex.FindStringSubmatch(line); matches != nil {
|
||||
// Finalize previous section
|
||||
finalizeSection()
|
||||
|
||||
// Start new section
|
||||
currentSection = strings.TrimSpace(matches[1])
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular content line - append to current section
|
||||
if currentIssue != nil && currentSection != "" {
|
||||
if sectionContent.Len() > 0 {
|
||||
sectionContent.WriteString("\n")
|
||||
}
|
||||
sectionContent.WriteString(line)
|
||||
} else if currentIssue != nil && currentSection == "" && currentIssue.Description == "" {
|
||||
// First lines after title (before any section) become description
|
||||
if line != "" {
|
||||
if currentIssue.Description != "" {
|
||||
currentIssue.Description += "\n"
|
||||
}
|
||||
currentIssue.Description += line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize last section and issue
|
||||
finalizeSection()
|
||||
if currentIssue != nil {
|
||||
issues = append(issues, currentIssue)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
// Check if we found any issues
|
||||
if len(issues) == 0 {
|
||||
return nil, fmt.Errorf("no issues found in markdown file (expected ## Issue Title format)")
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
238
cmd/bd/markdown_test.go
Normal file
238
cmd/bd/markdown_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMarkdownFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected []*IssueTemplate
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple issue",
|
||||
content: `## Fix authentication bug
|
||||
|
||||
This is a critical bug in the auth system.
|
||||
|
||||
### Priority
|
||||
1
|
||||
|
||||
### Type
|
||||
bug
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Fix authentication bug",
|
||||
Description: "This is a critical bug in the auth system.",
|
||||
Priority: 1,
|
||||
IssueType: "bug",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple issues",
|
||||
content: `## First Issue
|
||||
|
||||
Description for first issue.
|
||||
|
||||
### Priority
|
||||
0
|
||||
|
||||
### Type
|
||||
feature
|
||||
|
||||
## Second Issue
|
||||
|
||||
Description for second issue.
|
||||
|
||||
### Priority
|
||||
2
|
||||
|
||||
### Type
|
||||
task
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "First Issue",
|
||||
Description: "Description for first issue.",
|
||||
Priority: 0,
|
||||
IssueType: "feature",
|
||||
},
|
||||
{
|
||||
Title: "Second Issue",
|
||||
Description: "Description for second issue.",
|
||||
Priority: 2,
|
||||
IssueType: "task",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "issue with all fields",
|
||||
content: `## Comprehensive Issue
|
||||
|
||||
Initial description text.
|
||||
|
||||
### Priority
|
||||
1
|
||||
|
||||
### Type
|
||||
feature
|
||||
|
||||
### Description
|
||||
Detailed description here.
|
||||
Multi-line support.
|
||||
|
||||
### Design
|
||||
Design notes go here.
|
||||
|
||||
### Acceptance Criteria
|
||||
- Must do this
|
||||
- Must do that
|
||||
|
||||
### Assignee
|
||||
alice
|
||||
|
||||
### Labels
|
||||
backend, urgent
|
||||
|
||||
### Dependencies
|
||||
bd-10, bd-20
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Comprehensive Issue",
|
||||
Description: "Detailed description here.\nMulti-line support.",
|
||||
Design: "Design notes go here.",
|
||||
AcceptanceCriteria: "- Must do this\n- Must do that",
|
||||
Priority: 1,
|
||||
IssueType: "feature",
|
||||
Assignee: "alice",
|
||||
Labels: []string{"backend", "urgent"},
|
||||
Dependencies: []string{"bd-10", "bd-20"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dependencies with types",
|
||||
content: `## Issue with typed dependencies
|
||||
|
||||
### Priority
|
||||
2
|
||||
|
||||
### Type
|
||||
task
|
||||
|
||||
### Dependencies
|
||||
blocks:bd-10, discovered-from:bd-20
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Issue with typed dependencies",
|
||||
Priority: 2,
|
||||
IssueType: "task",
|
||||
Dependencies: []string{"blocks:bd-10", "discovered-from:bd-20"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "default values",
|
||||
content: `## Minimal Issue
|
||||
|
||||
Just a title and description.
|
||||
`,
|
||||
expected: []*IssueTemplate{
|
||||
{
|
||||
Title: "Minimal Issue",
|
||||
Description: "Just a title and description.",
|
||||
Priority: 2, // default
|
||||
IssueType: "task", // default
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.md")
|
||||
if err := os.WriteFile(tmpFile, []byte(tt.content), 0600); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Parse file
|
||||
got, err := parseMarkdownFile(tmpFile)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseMarkdownFile() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if len(got) != len(tt.expected) {
|
||||
t.Errorf("parseMarkdownFile() got %d issues, want %d", len(got), len(tt.expected))
|
||||
return
|
||||
}
|
||||
|
||||
// Compare each issue
|
||||
for i, gotIssue := range got {
|
||||
wantIssue := tt.expected[i]
|
||||
|
||||
if gotIssue.Title != wantIssue.Title {
|
||||
t.Errorf("Issue %d: Title = %q, want %q", i, gotIssue.Title, wantIssue.Title)
|
||||
}
|
||||
if gotIssue.Description != wantIssue.Description {
|
||||
t.Errorf("Issue %d: Description = %q, want %q", i, gotIssue.Description, wantIssue.Description)
|
||||
}
|
||||
if gotIssue.Priority != wantIssue.Priority {
|
||||
t.Errorf("Issue %d: Priority = %d, want %d", i, gotIssue.Priority, wantIssue.Priority)
|
||||
}
|
||||
if gotIssue.IssueType != wantIssue.IssueType {
|
||||
t.Errorf("Issue %d: IssueType = %q, want %q", i, gotIssue.IssueType, wantIssue.IssueType)
|
||||
}
|
||||
if gotIssue.Design != wantIssue.Design {
|
||||
t.Errorf("Issue %d: Design = %q, want %q", i, gotIssue.Design, wantIssue.Design)
|
||||
}
|
||||
if gotIssue.AcceptanceCriteria != wantIssue.AcceptanceCriteria {
|
||||
t.Errorf("Issue %d: AcceptanceCriteria = %q, want %q", i, gotIssue.AcceptanceCriteria, wantIssue.AcceptanceCriteria)
|
||||
}
|
||||
if gotIssue.Assignee != wantIssue.Assignee {
|
||||
t.Errorf("Issue %d: Assignee = %q, want %q", i, gotIssue.Assignee, wantIssue.Assignee)
|
||||
}
|
||||
|
||||
// Compare slices
|
||||
if !stringSlicesEqual(gotIssue.Labels, wantIssue.Labels) {
|
||||
t.Errorf("Issue %d: Labels = %v, want %v", i, gotIssue.Labels, wantIssue.Labels)
|
||||
}
|
||||
if !stringSlicesEqual(gotIssue.Dependencies, wantIssue.Dependencies) {
|
||||
t.Errorf("Issue %d: Dependencies = %v, want %v", i, gotIssue.Dependencies, wantIssue.Dependencies)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMarkdownFile_FileNotFound(t *testing.T) {
|
||||
_, err := parseMarkdownFile("/nonexistent/file.md")
|
||||
if err == nil {
|
||||
t.Error("Expected error for non-existent file, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 && len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user