Files
beads/cmd/bd/export_obsidian.go
dave a70c3a8cbe feat: extract Gas Town types from beads core (bd-i54l)
Remove Gas Town-specific issue types (agent, role, rig, convoy, slot)
from beads core. These types are now identified by labels instead:
- gt:agent, gt:role, gt:rig, gt:convoy, gt:slot

Changes:
- internal/types/types.go: Remove TypeAgent, TypeRole, TypeRig, TypeConvoy, TypeSlot constants
- cmd/bd/agent.go: Create agents with TypeTask + gt:agent label
- cmd/bd/merge_slot.go: Create slots with TypeTask + gt:slot label
- internal/storage/sqlite/queries.go, transaction.go: Query convoys by gt:convoy label
- internal/rpc/server_issues_epics.go: Check gt:agent label for role_type/rig label auto-add
- cmd/bd/create.go: Check gt:agent label for role_type/rig label auto-add
- internal/ui/styles.go: Remove agent/role/rig type colors
- cmd/bd/export_obsidian.go: Remove agent/role/rig/convoy type tag mappings
- Update all affected tests

This enables beads to be a generic issue tracker while Gas Town
uses labels for its specific type semantics.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
2026-01-06 22:18:37 -08:00

206 lines
5.6 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
// Note: Gas Town-specific types (agent, role, rig, convoy, slot) are now labels.
// The labels will be converted to tags automatically via the label->tag logic.
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.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
}