Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy, TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage) from internal/types/types.go. Beads now only has core work types built-in: - bug, feature, task, epic, chore All Gas Town types are now purely custom types with no special handling in beads. Use string literals like "gate" or "molecule" when needed, and configure types.custom in config.yaml for validation. Changes: - Remove Gas Town type constants from types.go - Remove mr/mol aliases from Normalize() - Update bd types command to only show core types - Replace all constant usages with string literals throughout codebase - Update tests to use string literals This decouples beads from Gas Town, making it a generic issue tracker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
200 lines
5.3 KiB
Go
200 lines
5.3 KiB
Go
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 (core types only)
|
|
// Gas Town types are custom types and will use their issue_type value as a tag.
|
|
var obsidianTypeTag = map[types.IssueType]string{
|
|
types.TypeBug: "#Bug",
|
|
types.TypeFeature: "#Feature",
|
|
types.TypeTask: "#Task",
|
|
types.TypeEpic: "#Epic",
|
|
types.TypeChore: "#Chore",
|
|
}
|
|
|
|
// 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
|
|
}
|