diff --git a/cmd/bd/export.go b/cmd/bd/export.go index bfa59a29..bfa1f6d7 100644 --- a/cmd/bd/export.go +++ b/cmd/bd/export.go @@ -114,14 +114,21 @@ func validateExportPath(path string) error { var exportCmd = &cobra.Command{ Use: "export", GroupID: "sync", - Short: "Export issues to JSONL format", - Long: `Export all issues to JSON Lines format (one JSON object per line). + Short: "Export issues to JSONL or Obsidian format", + Long: `Export all issues to JSON Lines or Obsidian Tasks markdown format. Issues are sorted by ID for consistent diffs. 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: 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 --created-after 2025-01-01 --assignee alice`, Run: func(cmd *cobra.Command, args []string) { @@ -144,11 +151,16 @@ Examples: debug.Logf("Debug: export flags - output=%q, force=%v\n", output, force) - if format != "jsonl" { - fmt.Fprintf(os.Stderr, "Error: only 'jsonl' format is currently supported\n") + if format != "jsonl" && format != "obsidian" { + fmt.Fprintf(os.Stderr, "Error: format must be 'jsonl' or 'obsidian'\n") 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 // If daemon is connected, close it and open direct connection if daemonClient != nil { @@ -408,6 +420,13 @@ Examples: // Create temporary file in same directory for atomic rename dir := filepath.Dir(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 tempFile, err = os.CreateTemp(dir, base+".tmp.*") if err != nil { @@ -428,17 +447,29 @@ Examples: out = tempFile } - // Write JSONL (timestamp-only deduplication DISABLED due to bd-160) - encoder := json.NewEncoder(out) + // Write output based on format exportedIDs := make([]string, 0, len(issues)) skippedCount := 0 - for _, issue := range issues { - if err := encoder.Encode(issue); err != nil { - fmt.Fprintf(os.Stderr, "Error encoding issue %s: %v\n", issue.ID, err) + + if format == "obsidian" { + // 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) } - - exportedIDs = append(exportedIDs, issue.ID) + for _, issue := range issues { + 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) @@ -495,18 +526,20 @@ Examples: } } - // Verify JSONL file integrity after export - actualCount, err := countIssuesInJSONL(finalPath) - if err != nil { - 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") - fmt.Fprintf(os.Stderr, " Expected: %d issues\n", len(exportedIDs)) - fmt.Fprintf(os.Stderr, " JSONL file: %d lines\n", actualCount) - fmt.Fprintf(os.Stderr, " Mismatch indicates export failed to write all issues\n") - os.Exit(1) + // Verify JSONL file integrity after export (skip for other formats) + if format == "jsonl" { + actualCount, err := countIssuesInJSONL(finalPath) + if err != nil { + 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") + fmt.Fprintf(os.Stderr, " Expected: %d issues\n", len(exportedIDs)) + fmt.Fprintf(os.Stderr, " JSONL file: %d lines\n", actualCount) + 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) @@ -540,7 +573,7 @@ Examples: } 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("status", "s", "", "Filter by status") exportCmd.Flags().Bool("force", false, "Force export even if database is empty") diff --git a/cmd/bd/export_obsidian.go b/cmd/bd/export_obsidian.go new file mode 100644 index 00000000..182f2f0e --- /dev/null +++ b/cmd/bd/export_obsidian.go @@ -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 +} diff --git a/cmd/bd/export_obsidian_test.go b/cmd/bd/export_obsidian_test.go new file mode 100644 index 00000000..8ff15760 --- /dev/null +++ b/cmd/bd/export_obsidian_test.go @@ -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) + } +}