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{
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")

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)
}
}