feat: add Obsidian Tasks markdown export format (GH#819)

Merge PR #819 from justbry with improvements:
- Add --format obsidian option to bd export
- Generate Obsidian Tasks-compatible markdown
- Default output to ai_docs/changes-log.md
- Map status to checkboxes, priority to emoji, type to tags
- Support parent-child hierarchy with indentation
- Use official Obsidian Tasks format (🆔,  emojis)

Improvement over PR: replaced O(n²) bubble sort with slices.SortFunc
for date ordering.

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

Co-Authored-By: justbry <justbu42@proton.me>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beads/crew/fang
2025-12-31 11:39:17 -08:00
committed by Steve Yegge
parent 8ab9b815ba
commit ee51298fd5
3 changed files with 691 additions and 24 deletions

View File

@@ -114,14 +114,21 @@ func validateExportPath(path string) error {
var exportCmd = &cobra.Command{ var exportCmd = &cobra.Command{
Use: "export", Use: "export",
GroupID: "sync", GroupID: "sync",
Short: "Export issues to JSONL format", Short: "Export issues to JSONL or Obsidian format",
Long: `Export all issues to JSON Lines format (one JSON object per line). Long: `Export all issues to JSON Lines or Obsidian Tasks markdown format.
Issues are sorted by ID for consistent diffs. Issues are sorted by ID for consistent diffs.
Output to stdout by default, or use -o flag for file output. Output to stdout by default, or use -o flag for file output.
For obsidian format, defaults to ai_docs/changes-log.md
Formats:
jsonl - JSON Lines format (one JSON object per line) [default]
obsidian - Obsidian Tasks markdown format with checkboxes, priorities, dates
Examples: Examples:
bd export --status open -o open-issues.jsonl bd export --status open -o open-issues.jsonl
bd export --format obsidian # outputs to ai_docs/changes-log.md
bd export --format obsidian -o custom.md # outputs to custom.md
bd export --type bug --priority-max 1 bd export --type bug --priority-max 1
bd export --created-after 2025-01-01 --assignee alice`, bd export --created-after 2025-01-01 --assignee alice`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -144,11 +151,16 @@ Examples:
debug.Logf("Debug: export flags - output=%q, force=%v\n", output, force) debug.Logf("Debug: export flags - output=%q, force=%v\n", output, force)
if format != "jsonl" { if format != "jsonl" && format != "obsidian" {
fmt.Fprintf(os.Stderr, "Error: only 'jsonl' format is currently supported\n") fmt.Fprintf(os.Stderr, "Error: format must be 'jsonl' or 'obsidian'\n")
os.Exit(1) os.Exit(1)
} }
// Default output path for obsidian format
if format == "obsidian" && output == "" {
output = "ai_docs/changes-log.md"
}
// Export command requires direct database access for consistent snapshot // Export command requires direct database access for consistent snapshot
// If daemon is connected, close it and open direct connection // If daemon is connected, close it and open direct connection
if daemonClient != nil { if daemonClient != nil {
@@ -408,6 +420,13 @@ Examples:
// Create temporary file in same directory for atomic rename // Create temporary file in same directory for atomic rename
dir := filepath.Dir(output) dir := filepath.Dir(output)
base := filepath.Base(output) base := filepath.Base(output)
// Ensure output directory exists
if err := os.MkdirAll(dir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
os.Exit(1)
}
var err error var err error
tempFile, err = os.CreateTemp(dir, base+".tmp.*") tempFile, err = os.CreateTemp(dir, base+".tmp.*")
if err != nil { if err != nil {
@@ -428,17 +447,29 @@ Examples:
out = tempFile out = tempFile
} }
// Write JSONL (timestamp-only deduplication DISABLED due to bd-160) // Write output based on format
encoder := json.NewEncoder(out)
exportedIDs := make([]string, 0, len(issues)) exportedIDs := make([]string, 0, len(issues))
skippedCount := 0 skippedCount := 0
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil { if format == "obsidian" {
fmt.Fprintf(os.Stderr, "Error encoding issue %s: %v\n", issue.ID, err) // Write Obsidian Tasks markdown format
if err := writeObsidianExport(out, issues); err != nil {
fmt.Fprintf(os.Stderr, "Error writing Obsidian export: %v\n", err)
os.Exit(1) os.Exit(1)
} }
for _, issue := range issues {
exportedIDs = append(exportedIDs, issue.ID) exportedIDs = append(exportedIDs, issue.ID)
}
} else {
// Write JSONL (timestamp-only deduplication DISABLED due to bd-160)
encoder := json.NewEncoder(out)
for _, issue := range issues {
if err := encoder.Encode(issue); err != nil {
fmt.Fprintf(os.Stderr, "Error encoding issue %s: %v\n", issue.ID, err)
os.Exit(1)
}
exportedIDs = append(exportedIDs, issue.ID)
}
} }
// Report skipped issues if any (helps debugging bd-159) // Report skipped issues if any (helps debugging bd-159)
@@ -495,18 +526,20 @@ Examples:
} }
} }
// Verify JSONL file integrity after export // Verify JSONL file integrity after export (skip for other formats)
actualCount, err := countIssuesInJSONL(finalPath) if format == "jsonl" {
if err != nil { actualCount, err := countIssuesInJSONL(finalPath)
fmt.Fprintf(os.Stderr, "Error: Export verification failed: %v\n", err) if err != nil {
os.Exit(1) fmt.Fprintf(os.Stderr, "Error: Export verification failed: %v\n", err)
} os.Exit(1)
if actualCount != len(exportedIDs) { }
fmt.Fprintf(os.Stderr, "Error: Export verification failed\n") if actualCount != len(exportedIDs) {
fmt.Fprintf(os.Stderr, " Expected: %d issues\n", len(exportedIDs)) fmt.Fprintf(os.Stderr, "Error: Export verification failed\n")
fmt.Fprintf(os.Stderr, " JSONL file: %d lines\n", actualCount) fmt.Fprintf(os.Stderr, " Expected: %d issues\n", len(exportedIDs))
fmt.Fprintf(os.Stderr, " Mismatch indicates export failed to write all issues\n") fmt.Fprintf(os.Stderr, " JSONL file: %d lines\n", actualCount)
os.Exit(1) fmt.Fprintf(os.Stderr, " Mismatch indicates export failed to write all issues\n")
os.Exit(1)
}
} }
// Update database mtime to be >= JSONL mtime (fixes #278, #301, #321) // Update database mtime to be >= JSONL mtime (fixes #278, #301, #321)
@@ -540,7 +573,7 @@ Examples:
} }
func init() { func init() {
exportCmd.Flags().StringP("format", "f", "jsonl", "Export format (jsonl)") exportCmd.Flags().StringP("format", "f", "jsonl", "Export format: jsonl, obsidian")
exportCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") exportCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
exportCmd.Flags().StringP("status", "s", "", "Filter by status") exportCmd.Flags().StringP("status", "s", "", "Filter by status")
exportCmd.Flags().Bool("force", false, "Force export even if database is empty") exportCmd.Flags().Bool("force", false, "Force export even if database is empty")

206
cmd/bd/export_obsidian.go Normal file
View File

@@ -0,0 +1,206 @@
package main
import (
"cmp"
"fmt"
"io"
"slices"
"strings"
"time"
"github.com/steveyegge/beads/internal/types"
)
// obsidianCheckbox maps bd status to Obsidian Tasks checkbox syntax
var obsidianCheckbox = map[types.Status]string{
types.StatusOpen: "- [ ]",
types.StatusInProgress: "- [/]",
types.StatusBlocked: "- [c]",
types.StatusClosed: "- [x]",
types.StatusTombstone: "- [-]",
types.StatusDeferred: "- [-]",
types.StatusPinned: "- [n]", // Review/attention
types.StatusHooked: "- [/]", // Treat as in-progress
}
// obsidianPriority maps bd priority (0-4) to Obsidian priority emoji
var obsidianPriority = []string{
"🔺", // 0 = critical/highest
"⏫", // 1 = high
"🔼", // 2 = medium
"🔽", // 3 = low
"⏬", // 4 = backlog/lowest
}
// obsidianTypeTag maps bd issue type to Obsidian tag
var obsidianTypeTag = map[types.IssueType]string{
types.TypeBug: "#Bug",
types.TypeFeature: "#Feature",
types.TypeTask: "#Task",
types.TypeEpic: "#Epic",
types.TypeChore: "#Chore",
types.TypeMessage: "#Message",
types.TypeMergeRequest: "#MergeRequest",
types.TypeMolecule: "#Molecule",
types.TypeGate: "#Gate",
types.TypeAgent: "#Agent",
types.TypeRole: "#Role",
types.TypeConvoy: "#Convoy",
types.TypeEvent: "#Event",
}
// formatObsidianTask converts a single issue to Obsidian Tasks format
func formatObsidianTask(issue *types.Issue) string {
var parts []string
// Checkbox based on status
checkbox, ok := obsidianCheckbox[issue.Status]
if !ok {
checkbox = "- [ ]" // default to open
}
parts = append(parts, checkbox)
// Title first
parts = append(parts, issue.Title)
// Task ID with 🆔 emoji (official Obsidian Tasks format)
parts = append(parts, fmt.Sprintf("🆔 %s", issue.ID))
// Priority emoji
if issue.Priority >= 0 && issue.Priority < len(obsidianPriority) {
parts = append(parts, obsidianPriority[issue.Priority])
}
// Type tag
if tag, ok := obsidianTypeTag[issue.IssueType]; ok {
parts = append(parts, tag)
}
// Labels as tags
for _, label := range issue.Labels {
// Sanitize label for tag use (replace spaces with dashes)
tag := "#" + strings.ReplaceAll(label, " ", "-")
parts = append(parts, tag)
}
// Start date (created_at)
parts = append(parts, fmt.Sprintf("🛫 %s", issue.CreatedAt.Format("2006-01-02")))
// End date (closed_at) if closed
if issue.ClosedAt != nil {
parts = append(parts, fmt.Sprintf("✅ %s", issue.ClosedAt.Format("2006-01-02")))
}
// Dependencies with ⛔ emoji (official Obsidian Tasks "blocked by" format)
// Include both blocks and parent-child relationships
for _, dep := range issue.Dependencies {
if dep.Type == types.DepBlocks || dep.Type == types.DepParentChild {
parts = append(parts, fmt.Sprintf("⛔ %s", dep.DependsOnID))
}
}
return strings.Join(parts, " ")
}
// groupIssuesByDate groups issues by their most recent activity date
func groupIssuesByDate(issues []*types.Issue) map[string][]*types.Issue {
grouped := make(map[string][]*types.Issue)
for _, issue := range issues {
// Use the most recent date: closed_at > updated_at > created_at
var date time.Time
if issue.ClosedAt != nil {
date = *issue.ClosedAt
} else {
date = issue.UpdatedAt
}
key := date.Format("2006-01-02")
grouped[key] = append(grouped[key], issue)
}
return grouped
}
// buildParentChildMap builds a map of parent ID -> child issues from parent-child dependencies
func buildParentChildMap(issues []*types.Issue) (map[string][]*types.Issue, map[string]bool) {
parentToChildren := make(map[string][]*types.Issue)
isChild := make(map[string]bool)
// Build lookup map
issueByID := make(map[string]*types.Issue)
for _, issue := range issues {
issueByID[issue.ID] = issue
}
// Find parent-child relationships
for _, issue := range issues {
for _, dep := range issue.Dependencies {
if dep.Type == types.DepParentChild {
parentID := dep.DependsOnID
parentToChildren[parentID] = append(parentToChildren[parentID], issue)
isChild[issue.ID] = true
}
}
}
return parentToChildren, isChild
}
// writeObsidianExport writes issues in Obsidian Tasks markdown format
func writeObsidianExport(w io.Writer, issues []*types.Issue) error {
// Write header
if _, err := fmt.Fprintln(w, "# Changes Log"); err != nil {
return err
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
// Build parent-child hierarchy
parentToChildren, isChild := buildParentChildMap(issues)
// Group by date
grouped := groupIssuesByDate(issues)
// Get sorted dates (most recent first)
dates := make([]string, 0, len(grouped))
for date := range grouped {
dates = append(dates, date)
}
// Sort descending (reverse order)
slices.SortFunc(dates, func(a, b string) int {
return cmp.Compare(b, a) // reverse: b before a for descending
})
// Write each date section
for _, date := range dates {
if _, err := fmt.Fprintf(w, "## %s\n\n", date); err != nil {
return err
}
for _, issue := range grouped[date] {
// Skip children - they'll be written under their parent
if isChild[issue.ID] {
continue
}
// Write parent issue
line := formatObsidianTask(issue)
if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
// Write children indented
if children, ok := parentToChildren[issue.ID]; ok {
for _, child := range children {
childLine := " " + formatObsidianTask(child)
if _, err := fmt.Fprintln(w, childLine); err != nil {
return err
}
}
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,428 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestFormatObsidianTask_StatusMapping(t *testing.T) {
tests := []struct {
name string
status types.Status
expected string
}{
{"open", types.StatusOpen, "- [ ]"},
{"in_progress", types.StatusInProgress, "- [/]"},
{"blocked", types.StatusBlocked, "- [c]"},
{"closed", types.StatusClosed, "- [x]"},
{"tombstone", types.StatusTombstone, "- [-]"},
{"deferred", types.StatusDeferred, "- [-]"},
{"pinned", types.StatusPinned, "- [n]"},
{"hooked", types.StatusHooked, "- [/]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: tt.status,
Priority: 2,
CreatedAt: time.Now(),
}
result := formatObsidianTask(issue)
if !strings.HasPrefix(result, tt.expected) {
t.Errorf("expected prefix %q, got %q", tt.expected, result)
}
})
}
}
func TestFormatObsidianTask_PriorityMapping(t *testing.T) {
tests := []struct {
priority int
emoji string
}{
{0, "🔺"},
{1, "⏫"},
{2, "🔼"},
{3, "🔽"},
{4, "⏬"},
}
for _, tt := range tests {
t.Run(tt.emoji, func(t *testing.T) {
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: tt.priority,
CreatedAt: time.Now(),
}
result := formatObsidianTask(issue)
if !strings.Contains(result, tt.emoji) {
t.Errorf("expected emoji %q in result %q", tt.emoji, result)
}
})
}
}
func TestFormatObsidianTask_TypeTags(t *testing.T) {
tests := []struct {
issueType types.IssueType
tag string
}{
{types.TypeBug, "#Bug"},
{types.TypeFeature, "#Feature"},
{types.TypeTask, "#Task"},
{types.TypeEpic, "#Epic"},
{types.TypeChore, "#Chore"},
}
for _, tt := range tests {
t.Run(string(tt.issueType), func(t *testing.T) {
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: tt.issueType,
CreatedAt: time.Now(),
}
result := formatObsidianTask(issue)
if !strings.Contains(result, tt.tag) {
t.Errorf("expected tag %q in result %q", tt.tag, result)
}
})
}
}
func TestFormatObsidianTask_Labels(t *testing.T) {
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 2,
Labels: []string{"urgent", "needs review"},
CreatedAt: time.Now(),
}
result := formatObsidianTask(issue)
if !strings.Contains(result, "#urgent") {
t.Errorf("expected #urgent in result %q", result)
}
if !strings.Contains(result, "#needs-review") {
t.Errorf("expected #needs-review (spaces replaced with dashes) in result %q", result)
}
}
func TestFormatObsidianTask_Dates(t *testing.T) {
created := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
closed := time.Date(2025, 1, 20, 15, 0, 0, 0, time.UTC)
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusClosed,
Priority: 2,
CreatedAt: created,
ClosedAt: &closed,
}
result := formatObsidianTask(issue)
if !strings.Contains(result, "🛫 2025-01-15") {
t.Errorf("expected start date 🛫 2025-01-15 in result %q", result)
}
if !strings.Contains(result, "✅ 2025-01-20") {
t.Errorf("expected end date ✅ 2025-01-20 in result %q", result)
}
}
func TestFormatObsidianTask_TaskID(t *testing.T) {
issue := &types.Issue{
ID: "bd-123",
Title: "Test Issue",
Status: types.StatusOpen,
Priority: 2,
CreatedAt: time.Now(),
}
result := formatObsidianTask(issue)
// Check for official Obsidian Tasks ID format: 🆔 id
if !strings.Contains(result, "🆔 bd-123") {
t.Errorf("expected '🆔 bd-123' in result %q", result)
}
}
func TestFormatObsidianTask_Dependencies(t *testing.T) {
issue := &types.Issue{
ID: "test-1",
Title: "Test Issue",
Status: types.StatusBlocked,
Priority: 2,
CreatedAt: time.Now(),
Dependencies: []*types.Dependency{
{IssueID: "test-1", DependsOnID: "test-2", Type: types.DepBlocks},
},
}
result := formatObsidianTask(issue)
// Check for official Obsidian Tasks "blocked by" format: ⛔ id
if !strings.Contains(result, "⛔ test-2") {
t.Errorf("expected '⛔ test-2' in result %q", result)
}
}
func TestGroupIssuesByDate(t *testing.T) {
date1 := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
date2 := time.Date(2025, 1, 16, 10, 0, 0, 0, time.UTC)
issues := []*types.Issue{
{ID: "test-1", UpdatedAt: date1},
{ID: "test-2", UpdatedAt: date1},
{ID: "test-3", UpdatedAt: date2},
}
grouped := groupIssuesByDate(issues)
if len(grouped) != 2 {
t.Errorf("expected 2 date groups, got %d", len(grouped))
}
if len(grouped["2025-01-15"]) != 2 {
t.Errorf("expected 2 issues for 2025-01-15, got %d", len(grouped["2025-01-15"]))
}
if len(grouped["2025-01-16"]) != 1 {
t.Errorf("expected 1 issue for 2025-01-16, got %d", len(grouped["2025-01-16"]))
}
}
func TestGroupIssuesByDate_UsesClosedAt(t *testing.T) {
updated := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
closed := time.Date(2025, 1, 20, 10, 0, 0, 0, time.UTC)
issues := []*types.Issue{
{ID: "test-1", UpdatedAt: updated, ClosedAt: &closed},
}
grouped := groupIssuesByDate(issues)
if _, ok := grouped["2025-01-20"]; !ok {
t.Error("expected issue to be grouped by closed_at date (2025-01-20)")
}
if _, ok := grouped["2025-01-15"]; ok {
t.Error("issue should not be grouped by updated_at when closed_at exists")
}
}
func TestWriteObsidianExport(t *testing.T) {
date1 := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
date2 := time.Date(2025, 1, 16, 10, 0, 0, 0, time.UTC)
issues := []*types.Issue{
{
ID: "test-1",
Title: "First Issue",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: date1,
UpdatedAt: date1,
},
{
ID: "test-2",
Title: "Second Issue",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: date2,
UpdatedAt: date2,
},
}
var buf bytes.Buffer
err := writeObsidianExport(&buf, issues)
if err != nil {
t.Fatalf("writeObsidianExport failed: %v", err)
}
output := buf.String()
// Check header
if !strings.HasPrefix(output, "# Changes Log\n") {
t.Error("expected output to start with '# Changes Log'")
}
// Check date sections exist (most recent first)
idx1 := strings.Index(output, "## 2025-01-16")
idx2 := strings.Index(output, "## 2025-01-15")
if idx1 == -1 || idx2 == -1 {
t.Error("expected both date headers to exist")
}
if idx1 > idx2 {
t.Error("expected 2025-01-16 (more recent) to appear before 2025-01-15")
}
// Check issues are present
if !strings.Contains(output, "test-1") {
t.Error("expected test-1 in output")
}
if !strings.Contains(output, "test-2") {
t.Error("expected test-2 in output")
}
}
func TestWriteObsidianExport_Empty(t *testing.T) {
var buf bytes.Buffer
err := writeObsidianExport(&buf, []*types.Issue{})
if err != nil {
t.Fatalf("writeObsidianExport failed: %v", err)
}
output := buf.String()
if !strings.HasPrefix(output, "# Changes Log\n") {
t.Error("expected output to start with '# Changes Log' even when empty")
}
}
func TestFormatObsidianTask_ParentChildDependency(t *testing.T) {
issue := &types.Issue{
ID: "test-1.1",
Title: "Child Task",
Status: types.StatusOpen,
Priority: 2,
CreatedAt: time.Now(),
Dependencies: []*types.Dependency{
{IssueID: "test-1.1", DependsOnID: "test-1", Type: types.DepParentChild},
},
}
result := formatObsidianTask(issue)
// Parent-child deps should also show as ⛔ (blocked by parent)
if !strings.Contains(result, "⛔ test-1") {
t.Errorf("expected '⛔ test-1' for parent-child dep in result %q", result)
}
}
func TestBuildParentChildMap(t *testing.T) {
date := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
issues := []*types.Issue{
{
ID: "parent-1",
Title: "Parent Epic",
IssueType: types.TypeEpic,
CreatedAt: date,
UpdatedAt: date,
},
{
ID: "parent-1.1",
Title: "Child Task 1",
IssueType: types.TypeTask,
CreatedAt: date,
UpdatedAt: date,
Dependencies: []*types.Dependency{
{IssueID: "parent-1.1", DependsOnID: "parent-1", Type: types.DepParentChild},
},
},
{
ID: "parent-1.2",
Title: "Child Task 2",
IssueType: types.TypeTask,
CreatedAt: date,
UpdatedAt: date,
Dependencies: []*types.Dependency{
{IssueID: "parent-1.2", DependsOnID: "parent-1", Type: types.DepParentChild},
},
},
}
parentToChildren, isChild := buildParentChildMap(issues)
// Check parent has 2 children
if len(parentToChildren["parent-1"]) != 2 {
t.Errorf("expected 2 children for parent-1, got %d", len(parentToChildren["parent-1"]))
}
// Check children are marked
if !isChild["parent-1.1"] {
t.Error("expected parent-1.1 to be marked as child")
}
if !isChild["parent-1.2"] {
t.Error("expected parent-1.2 to be marked as child")
}
// Parent should not be marked as child
if isChild["parent-1"] {
t.Error("parent-1 should not be marked as child")
}
}
func TestWriteObsidianExport_ParentChildHierarchy(t *testing.T) {
date := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
issues := []*types.Issue{
{
ID: "epic-1",
Title: "Auth System",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeEpic,
CreatedAt: date,
UpdatedAt: date,
},
{
ID: "epic-1.1",
Title: "Login Page",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: date,
UpdatedAt: date,
Dependencies: []*types.Dependency{
{IssueID: "epic-1.1", DependsOnID: "epic-1", Type: types.DepParentChild},
},
},
{
ID: "epic-1.2",
Title: "Logout Button",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: date,
UpdatedAt: date,
Dependencies: []*types.Dependency{
{IssueID: "epic-1.2", DependsOnID: "epic-1", Type: types.DepParentChild},
},
},
}
var buf bytes.Buffer
err := writeObsidianExport(&buf, issues)
if err != nil {
t.Fatalf("writeObsidianExport failed: %v", err)
}
output := buf.String()
// Check parent is present (not indented)
if !strings.Contains(output, "- [ ] Auth System") {
t.Error("expected parent 'Auth System' in output")
}
// Check children are indented (2 spaces)
if !strings.Contains(output, " - [ ] Login Page") {
t.Errorf("expected indented child 'Login Page' in output:\n%s", output)
}
if !strings.Contains(output, " - [ ] Logout Button") {
t.Errorf("expected indented child 'Logout Button' in output:\n%s", output)
}
// Children should have ⛔ dependency on parent
if !strings.Contains(output, "⛔ epic-1") {
t.Errorf("expected children to have '⛔ epic-1' dependency in output:\n%s", output)
}
}