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:
committed by
Steve Yegge
parent
8ab9b815ba
commit
ee51298fd5
@@ -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
206
cmd/bd/export_obsidian.go
Normal 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
|
||||||
|
}
|
||||||
428
cmd/bd/export_obsidian_test.go
Normal file
428
cmd/bd/export_obsidian_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user