Fixed bug where PersistentPostRun was clearing isDirty flag before calling flushToJSONL(), causing the flush to abort immediately. The fix ensures flushToJSONL() handles the isDirty flag itself, allowing the JSONL export to complete successfully. Also added Arch Linux AUR installation instructions to README. Changes: - cmd/bd/main.go: Fixed PersistentPostRun flush logic - README.md: Added Arch Linux (AUR) installation section - .beads/bd.jsonl: Auto-exported issue bd-169 (init -q flag bug) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
944 lines
26 KiB
Go
944 lines
26 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
var (
|
|
dbPath string
|
|
actor string
|
|
store storage.Storage
|
|
jsonOutput bool
|
|
|
|
// Auto-flush state
|
|
autoFlushEnabled = true // Can be disabled with --no-auto-flush
|
|
isDirty = false
|
|
flushMutex sync.Mutex
|
|
flushTimer *time.Timer
|
|
flushDebounce = 5 * time.Second
|
|
storeMutex sync.Mutex // Protects store access from background goroutine
|
|
storeActive = false // Tracks if store is available
|
|
flushFailureCount = 0 // Consecutive flush failures
|
|
lastFlushError error // Last flush error for debugging
|
|
|
|
// Auto-import state
|
|
autoImportEnabled = true // Can be disabled with --no-auto-import
|
|
)
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "bd",
|
|
Short: "bd - Dependency-aware issue tracker",
|
|
Long: `Issues chained together like beads. A lightweight issue tracker with first-class dependency support.`,
|
|
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
|
// Skip database initialization for init command
|
|
if cmd.Name() == "init" {
|
|
return
|
|
}
|
|
|
|
// Set auto-flush based on flag (invert no-auto-flush)
|
|
autoFlushEnabled = !noAutoFlush
|
|
|
|
// Set auto-import based on flag (invert no-auto-import)
|
|
autoImportEnabled = !noAutoImport
|
|
|
|
// Initialize storage
|
|
if dbPath == "" {
|
|
// Use public API to find database (same logic as extensions)
|
|
if foundDB := beads.FindDatabasePath(); foundDB != "" {
|
|
dbPath = foundDB
|
|
} else {
|
|
// Fallback to default location (will be created by init command)
|
|
home, _ := os.UserHomeDir()
|
|
dbPath = filepath.Join(home, ".beads", "default.db")
|
|
}
|
|
}
|
|
|
|
var err error
|
|
store, err = sqlite.New(dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Mark store as active for flush goroutine safety
|
|
storeMutex.Lock()
|
|
storeActive = true
|
|
storeMutex.Unlock()
|
|
|
|
// Set actor from flag, env, or default
|
|
// Priority: --actor flag > BD_ACTOR env > USER env > "unknown"
|
|
if actor == "" {
|
|
if bdActor := os.Getenv("BD_ACTOR"); bdActor != "" {
|
|
actor = bdActor
|
|
} else if user := os.Getenv("USER"); user != "" {
|
|
actor = user
|
|
} else {
|
|
actor = "unknown"
|
|
}
|
|
}
|
|
|
|
// Auto-import if JSONL is newer than DB (e.g., after git pull)
|
|
// Skip for import command itself to avoid recursion
|
|
if cmd.Name() != "import" && autoImportEnabled {
|
|
autoImportIfNewer()
|
|
}
|
|
},
|
|
PersistentPostRun: func(cmd *cobra.Command, args []string) {
|
|
// Flush any pending changes before closing
|
|
flushMutex.Lock()
|
|
needsFlush := isDirty && autoFlushEnabled
|
|
if needsFlush {
|
|
// Cancel timer and flush immediately
|
|
if flushTimer != nil {
|
|
flushTimer.Stop()
|
|
flushTimer = nil
|
|
}
|
|
// Don't clear isDirty here - let flushToJSONL do it
|
|
}
|
|
flushMutex.Unlock()
|
|
|
|
if needsFlush {
|
|
// Call the shared flush function (no code duplication)
|
|
flushToJSONL()
|
|
}
|
|
|
|
// Signal that store is closing (prevents background flush from accessing closed store)
|
|
storeMutex.Lock()
|
|
storeActive = false
|
|
storeMutex.Unlock()
|
|
|
|
if store != nil {
|
|
_ = store.Close()
|
|
}
|
|
},
|
|
}
|
|
|
|
// outputJSON outputs data as pretty-printed JSON
|
|
func outputJSON(v interface{}) {
|
|
encoder := json.NewEncoder(os.Stdout)
|
|
encoder.SetIndent("", " ")
|
|
if err := encoder.Encode(v); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// findJSONLPath finds the JSONL file path for the current database
|
|
func findJSONLPath() string {
|
|
// Use public API for path discovery
|
|
jsonlPath := beads.FindJSONLPath(dbPath)
|
|
|
|
// Ensure the directory exists (important for new databases)
|
|
// This is the only difference from the public API - we create the directory
|
|
dbDir := filepath.Dir(dbPath)
|
|
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
|
// If we can't create the directory, return discovered path anyway
|
|
// (the subsequent write will fail with a clearer error)
|
|
return jsonlPath
|
|
}
|
|
|
|
return jsonlPath
|
|
}
|
|
|
|
// autoImportIfNewer checks if JSONL content changed (via hash) and imports if so
|
|
// Fixes bd-84: Hash-based comparison is git-proof (mtime comparison fails after git pull)
|
|
func autoImportIfNewer() {
|
|
// Find JSONL path
|
|
jsonlPath := findJSONLPath()
|
|
|
|
// Read JSONL file
|
|
jsonlData, err := os.ReadFile(jsonlPath)
|
|
if err != nil {
|
|
// JSONL doesn't exist or can't be accessed, skip import
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: auto-import skipped, JSONL not found: %v\n", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Compute current JSONL hash
|
|
hasher := sha256.New()
|
|
hasher.Write(jsonlData)
|
|
currentHash := hex.EncodeToString(hasher.Sum(nil))
|
|
|
|
// Get last import hash from DB metadata
|
|
ctx := context.Background()
|
|
lastHash, err := store.GetMetadata(ctx, "last_import_hash")
|
|
if err != nil {
|
|
// Metadata not supported or error reading - this shouldn't happen
|
|
// since we added metadata table, but be defensive
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: auto-import skipped, metadata error: %v\n", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Compare hashes
|
|
if currentHash == lastHash {
|
|
// Content unchanged, skip import
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: auto-import skipped, JSONL unchanged (hash match)\n")
|
|
}
|
|
return
|
|
}
|
|
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: auto-import triggered (hash changed)\n")
|
|
}
|
|
|
|
// Content changed - perform silent import
|
|
scanner := bufio.NewScanner(strings.NewReader(string(jsonlData)))
|
|
var allIssues []*types.Issue
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err != nil {
|
|
// Parse error, skip this import
|
|
if os.Getenv("BD_DEBUG") != "" {
|
|
fmt.Fprintf(os.Stderr, "Debug: auto-import skipped, parse error: %v\n", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
allIssues = append(allIssues, &issue)
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return
|
|
}
|
|
|
|
// Import issues (create new, update existing)
|
|
for _, issue := range allIssues {
|
|
existing, err := store.GetIssue(ctx, issue.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if existing != nil {
|
|
// Update existing issue
|
|
updates := make(map[string]interface{})
|
|
updates["title"] = issue.Title
|
|
updates["description"] = issue.Description
|
|
updates["design"] = issue.Design
|
|
updates["acceptance_criteria"] = issue.AcceptanceCriteria
|
|
updates["notes"] = issue.Notes
|
|
updates["status"] = issue.Status
|
|
updates["priority"] = issue.Priority
|
|
updates["issue_type"] = issue.IssueType
|
|
updates["assignee"] = issue.Assignee
|
|
if issue.EstimatedMinutes != nil {
|
|
updates["estimated_minutes"] = *issue.EstimatedMinutes
|
|
}
|
|
if issue.ExternalRef != nil {
|
|
updates["external_ref"] = *issue.ExternalRef
|
|
}
|
|
|
|
_ = store.UpdateIssue(ctx, issue.ID, updates, "auto-import")
|
|
} else {
|
|
// Create new issue
|
|
_ = store.CreateIssue(ctx, issue, "auto-import")
|
|
}
|
|
}
|
|
|
|
// Import dependencies
|
|
for _, issue := range allIssues {
|
|
if len(issue.Dependencies) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Get existing dependencies
|
|
existingDeps, err := store.GetDependencyRecords(ctx, issue.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Add missing dependencies
|
|
for _, dep := range issue.Dependencies {
|
|
exists := false
|
|
for _, existing := range existingDeps {
|
|
if existing.DependsOnID == dep.DependsOnID && existing.Type == dep.Type {
|
|
exists = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !exists {
|
|
_ = store.AddDependency(ctx, dep, "auto-import")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Store new hash after successful import
|
|
_ = store.SetMetadata(ctx, "last_import_hash", currentHash)
|
|
}
|
|
|
|
// markDirtyAndScheduleFlush marks the database as dirty and schedules a flush
|
|
func markDirtyAndScheduleFlush() {
|
|
if !autoFlushEnabled {
|
|
return
|
|
}
|
|
|
|
flushMutex.Lock()
|
|
defer flushMutex.Unlock()
|
|
|
|
isDirty = true
|
|
|
|
// Cancel existing timer if any
|
|
if flushTimer != nil {
|
|
flushTimer.Stop()
|
|
flushTimer = nil
|
|
}
|
|
|
|
// Schedule new flush
|
|
flushTimer = time.AfterFunc(flushDebounce, func() {
|
|
flushToJSONL()
|
|
})
|
|
}
|
|
|
|
// clearAutoFlushState cancels pending flush and marks DB as clean (after manual export)
|
|
func clearAutoFlushState() {
|
|
flushMutex.Lock()
|
|
defer flushMutex.Unlock()
|
|
|
|
// Cancel pending timer
|
|
if flushTimer != nil {
|
|
flushTimer.Stop()
|
|
flushTimer = nil
|
|
}
|
|
|
|
// Clear dirty flag
|
|
isDirty = false
|
|
|
|
// Reset failure counter (manual export succeeded)
|
|
flushFailureCount = 0
|
|
lastFlushError = nil
|
|
}
|
|
|
|
// flushToJSONL exports dirty issues to JSONL using incremental updates
|
|
func flushToJSONL() {
|
|
// Check if store is still active (not closed)
|
|
storeMutex.Lock()
|
|
if !storeActive {
|
|
storeMutex.Unlock()
|
|
return
|
|
}
|
|
storeMutex.Unlock()
|
|
|
|
flushMutex.Lock()
|
|
if !isDirty {
|
|
flushMutex.Unlock()
|
|
return
|
|
}
|
|
isDirty = false
|
|
flushMutex.Unlock()
|
|
|
|
jsonlPath := findJSONLPath()
|
|
|
|
// Double-check store is still active before accessing
|
|
storeMutex.Lock()
|
|
if !storeActive {
|
|
storeMutex.Unlock()
|
|
return
|
|
}
|
|
storeMutex.Unlock()
|
|
|
|
// Helper to record failure
|
|
recordFailure := func(err error) {
|
|
flushMutex.Lock()
|
|
flushFailureCount++
|
|
lastFlushError = err
|
|
failCount := flushFailureCount
|
|
flushMutex.Unlock()
|
|
|
|
// Always show the immediate warning
|
|
fmt.Fprintf(os.Stderr, "Warning: auto-flush failed: %v\n", err)
|
|
|
|
// Show prominent warning after 3+ consecutive failures
|
|
if failCount >= 3 {
|
|
red := color.New(color.FgRed, color.Bold).SprintFunc()
|
|
fmt.Fprintf(os.Stderr, "\n%s\n", red("⚠️ CRITICAL: Auto-flush has failed "+fmt.Sprint(failCount)+" times consecutively!"))
|
|
fmt.Fprintf(os.Stderr, "%s\n", red("⚠️ Your JSONL file may be out of sync with the database."))
|
|
fmt.Fprintf(os.Stderr, "%s\n\n", red("⚠️ Run 'bd export -o .beads/issues.jsonl' manually to fix."))
|
|
}
|
|
}
|
|
|
|
// Helper to record success
|
|
recordSuccess := func() {
|
|
flushMutex.Lock()
|
|
flushFailureCount = 0
|
|
lastFlushError = nil
|
|
flushMutex.Unlock()
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Get dirty issue IDs (bd-39: incremental export optimization)
|
|
dirtyIDs, err := store.GetDirtyIssues(ctx)
|
|
if err != nil {
|
|
recordFailure(fmt.Errorf("failed to get dirty issues: %w", err))
|
|
return
|
|
}
|
|
|
|
// No dirty issues? Nothing to do!
|
|
if len(dirtyIDs) == 0 {
|
|
recordSuccess()
|
|
return
|
|
}
|
|
|
|
// Read existing JSONL into a map
|
|
issueMap := make(map[string]*types.Issue)
|
|
if existingFile, err := os.Open(jsonlPath); err == nil {
|
|
scanner := bufio.NewScanner(existingFile)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var issue types.Issue
|
|
if err := json.Unmarshal([]byte(line), &issue); err == nil {
|
|
issueMap[issue.ID] = &issue
|
|
} else {
|
|
// Warn about malformed JSONL lines
|
|
fmt.Fprintf(os.Stderr, "Warning: skipping malformed JSONL line %d: %v\n", lineNum, err)
|
|
}
|
|
}
|
|
existingFile.Close()
|
|
}
|
|
|
|
// Fetch only dirty issues from DB
|
|
for _, issueID := range dirtyIDs {
|
|
issue, err := store.GetIssue(ctx, issueID)
|
|
if err != nil {
|
|
recordFailure(fmt.Errorf("failed to get issue %s: %w", issueID, err))
|
|
return
|
|
}
|
|
if issue == nil {
|
|
// Issue was deleted, remove from map
|
|
delete(issueMap, issueID)
|
|
continue
|
|
}
|
|
|
|
// Get dependencies for this issue
|
|
deps, err := store.GetDependencyRecords(ctx, issueID)
|
|
if err != nil {
|
|
recordFailure(fmt.Errorf("failed to get dependencies for %s: %w", issueID, err))
|
|
return
|
|
}
|
|
issue.Dependencies = deps
|
|
|
|
// Update map
|
|
issueMap[issueID] = issue
|
|
}
|
|
|
|
// Convert map to sorted slice
|
|
issues := make([]*types.Issue, 0, len(issueMap))
|
|
for _, issue := range issueMap {
|
|
issues = append(issues, issue)
|
|
}
|
|
sort.Slice(issues, func(i, j int) bool {
|
|
return issues[i].ID < issues[j].ID
|
|
})
|
|
|
|
// Write to temp file first, then rename (atomic)
|
|
tempPath := jsonlPath + ".tmp"
|
|
f, err := os.Create(tempPath)
|
|
if err != nil {
|
|
recordFailure(fmt.Errorf("failed to create temp file: %w", err))
|
|
return
|
|
}
|
|
|
|
encoder := json.NewEncoder(f)
|
|
for _, issue := range issues {
|
|
if err := encoder.Encode(issue); err != nil {
|
|
f.Close()
|
|
os.Remove(tempPath)
|
|
recordFailure(fmt.Errorf("failed to encode issue %s: %w", issue.ID, err))
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := f.Close(); err != nil {
|
|
os.Remove(tempPath)
|
|
recordFailure(fmt.Errorf("failed to close temp file: %w", err))
|
|
return
|
|
}
|
|
|
|
// Atomic rename
|
|
if err := os.Rename(tempPath, jsonlPath); err != nil {
|
|
os.Remove(tempPath)
|
|
recordFailure(fmt.Errorf("failed to rename file: %w", err))
|
|
return
|
|
}
|
|
|
|
// Clear only the dirty issues that were actually exported (fixes bd-52 race condition)
|
|
if err := store.ClearDirtyIssuesByID(ctx, dirtyIDs); err != nil {
|
|
// Don't fail the whole flush for this, but warn
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to clear dirty issues: %v\n", err)
|
|
}
|
|
|
|
// Store hash of exported JSONL (fixes bd-84: enables hash-based auto-import)
|
|
jsonlData, err := os.ReadFile(jsonlPath)
|
|
if err == nil {
|
|
hasher := sha256.New()
|
|
hasher.Write(jsonlData)
|
|
exportedHash := hex.EncodeToString(hasher.Sum(nil))
|
|
_ = store.SetMetadata(ctx, "last_import_hash", exportedHash)
|
|
}
|
|
|
|
// Success!
|
|
recordSuccess()
|
|
}
|
|
|
|
var (
|
|
noAutoFlush bool
|
|
noAutoImport bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.PersistentFlags().StringVar(&dbPath, "db", "", "Database path (default: auto-discover .beads/*.db or ~/.beads/default.db)")
|
|
rootCmd.PersistentFlags().StringVar(&actor, "actor", "", "Actor name for audit trail (default: $BD_ACTOR or $USER)")
|
|
rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
|
|
rootCmd.PersistentFlags().BoolVar(&noAutoFlush, "no-auto-flush", false, "Disable automatic JSONL sync after CRUD operations")
|
|
rootCmd.PersistentFlags().BoolVar(&noAutoImport, "no-auto-import", false, "Disable automatic JSONL import when newer than DB")
|
|
}
|
|
|
|
var createCmd = &cobra.Command{
|
|
Use: "create [title]",
|
|
Short: "Create a new issue",
|
|
Args: cobra.MinimumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
title := args[0]
|
|
description, _ := cmd.Flags().GetString("description")
|
|
design, _ := cmd.Flags().GetString("design")
|
|
acceptance, _ := cmd.Flags().GetString("acceptance")
|
|
priority, _ := cmd.Flags().GetInt("priority")
|
|
issueType, _ := cmd.Flags().GetString("type")
|
|
assignee, _ := cmd.Flags().GetString("assignee")
|
|
labels, _ := cmd.Flags().GetStringSlice("labels")
|
|
explicitID, _ := cmd.Flags().GetString("id")
|
|
externalRef, _ := cmd.Flags().GetString("external-ref")
|
|
deps, _ := cmd.Flags().GetStringSlice("deps")
|
|
|
|
// Validate explicit ID format if provided (prefix-number)
|
|
if explicitID != "" {
|
|
// Check format: must contain hyphen and have numeric suffix
|
|
parts := strings.Split(explicitID, "-")
|
|
if len(parts) != 2 {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid ID format '%s' (expected format: prefix-number, e.g., 'bd-42')\n", explicitID)
|
|
os.Exit(1)
|
|
}
|
|
// Validate numeric suffix
|
|
if _, err := fmt.Sscanf(parts[1], "%d", new(int)); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid ID format '%s' (numeric suffix required, e.g., 'bd-42')\n", explicitID)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
var externalRefPtr *string
|
|
if externalRef != "" {
|
|
externalRefPtr = &externalRef
|
|
}
|
|
|
|
issue := &types.Issue{
|
|
ID: explicitID, // Set explicit ID if provided (empty string if not)
|
|
Title: title,
|
|
Description: description,
|
|
Design: design,
|
|
AcceptanceCriteria: acceptance,
|
|
Status: types.StatusOpen,
|
|
Priority: priority,
|
|
IssueType: types.IssueType(issueType),
|
|
Assignee: assignee,
|
|
ExternalRef: externalRefPtr,
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := store.CreateIssue(ctx, issue, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Add labels if specified
|
|
for _, label := range labels {
|
|
if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add label %s: %v\n", label, err)
|
|
}
|
|
}
|
|
|
|
// Add dependencies if specified (format: type:id or just id for default "blocks" type)
|
|
for _, depSpec := range deps {
|
|
// Skip empty specs (e.g., from trailing commas)
|
|
depSpec = strings.TrimSpace(depSpec)
|
|
if depSpec == "" {
|
|
continue
|
|
}
|
|
|
|
var depType types.DependencyType
|
|
var dependsOnID string
|
|
|
|
// Parse format: "type:id" or just "id" (defaults to "blocks")
|
|
if strings.Contains(depSpec, ":") {
|
|
parts := strings.SplitN(depSpec, ":", 2)
|
|
if len(parts) != 2 {
|
|
fmt.Fprintf(os.Stderr, "Warning: invalid dependency format '%s', expected 'type:id' or 'id'\n", depSpec)
|
|
continue
|
|
}
|
|
depType = types.DependencyType(strings.TrimSpace(parts[0]))
|
|
dependsOnID = strings.TrimSpace(parts[1])
|
|
} else {
|
|
// Default to "blocks" if no type specified
|
|
depType = types.DepBlocks
|
|
dependsOnID = depSpec
|
|
}
|
|
|
|
// Validate dependency type
|
|
if !depType.IsValid() {
|
|
fmt.Fprintf(os.Stderr, "Warning: invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)\n", depType)
|
|
continue
|
|
}
|
|
|
|
// Add the dependency
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: dependsOnID,
|
|
Type: depType,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to add dependency %s -> %s: %v\n", issue.ID, dependsOnID, err)
|
|
}
|
|
}
|
|
|
|
// Schedule auto-flush
|
|
markDirtyAndScheduleFlush()
|
|
|
|
if jsonOutput {
|
|
outputJSON(issue)
|
|
} else {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID)
|
|
fmt.Printf(" Title: %s\n", issue.Title)
|
|
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
|
fmt.Printf(" Status: %s\n", issue.Status)
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
createCmd.Flags().StringP("description", "d", "", "Issue description")
|
|
createCmd.Flags().String("design", "", "Design notes")
|
|
createCmd.Flags().String("acceptance", "", "Acceptance criteria")
|
|
createCmd.Flags().IntP("priority", "p", 2, "Priority (0-4, 0=highest)")
|
|
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore)")
|
|
createCmd.Flags().StringP("assignee", "a", "", "Assignee")
|
|
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
|
|
createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)")
|
|
createCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
|
|
createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')")
|
|
rootCmd.AddCommand(createCmd)
|
|
}
|
|
|
|
var showCmd = &cobra.Command{
|
|
Use: "show [id]",
|
|
Short: "Show issue details",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := context.Background()
|
|
issue, err := store.GetIssue(ctx, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if issue == nil {
|
|
fmt.Fprintf(os.Stderr, "Issue %s not found\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
// Include labels and dependencies in JSON output
|
|
type IssueDetails struct {
|
|
*types.Issue
|
|
Labels []string `json:"labels,omitempty"`
|
|
Dependencies []*types.Issue `json:"dependencies,omitempty"`
|
|
Dependents []*types.Issue `json:"dependents,omitempty"`
|
|
}
|
|
details := &IssueDetails{Issue: issue}
|
|
details.Labels, _ = store.GetLabels(ctx, issue.ID)
|
|
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
|
|
details.Dependents, _ = store.GetDependents(ctx, issue.ID)
|
|
outputJSON(details)
|
|
return
|
|
}
|
|
|
|
cyan := color.New(color.FgCyan).SprintFunc()
|
|
fmt.Printf("\n%s: %s\n", cyan(issue.ID), issue.Title)
|
|
fmt.Printf("Status: %s\n", issue.Status)
|
|
fmt.Printf("Priority: P%d\n", issue.Priority)
|
|
fmt.Printf("Type: %s\n", issue.IssueType)
|
|
if issue.Assignee != "" {
|
|
fmt.Printf("Assignee: %s\n", issue.Assignee)
|
|
}
|
|
if issue.EstimatedMinutes != nil {
|
|
fmt.Printf("Estimated: %d minutes\n", *issue.EstimatedMinutes)
|
|
}
|
|
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
|
|
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
|
|
|
|
if issue.Description != "" {
|
|
fmt.Printf("\nDescription:\n%s\n", issue.Description)
|
|
}
|
|
if issue.Design != "" {
|
|
fmt.Printf("\nDesign:\n%s\n", issue.Design)
|
|
}
|
|
if issue.Notes != "" {
|
|
fmt.Printf("\nNotes:\n%s\n", issue.Notes)
|
|
}
|
|
if issue.AcceptanceCriteria != "" {
|
|
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
|
|
}
|
|
|
|
// Show labels
|
|
labels, _ := store.GetLabels(ctx, issue.ID)
|
|
if len(labels) > 0 {
|
|
fmt.Printf("\nLabels: %v\n", labels)
|
|
}
|
|
|
|
// Show dependencies
|
|
deps, _ := store.GetDependencies(ctx, issue.ID)
|
|
if len(deps) > 0 {
|
|
fmt.Printf("\nDepends on (%d):\n", len(deps))
|
|
for _, dep := range deps {
|
|
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
|
|
}
|
|
}
|
|
|
|
// Show dependents
|
|
dependents, _ := store.GetDependents(ctx, issue.ID)
|
|
if len(dependents) > 0 {
|
|
fmt.Printf("\nBlocks (%d):\n", len(dependents))
|
|
for _, dep := range dependents {
|
|
fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(showCmd)
|
|
}
|
|
|
|
var listCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List issues",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
status, _ := cmd.Flags().GetString("status")
|
|
assignee, _ := cmd.Flags().GetString("assignee")
|
|
issueType, _ := cmd.Flags().GetString("type")
|
|
limit, _ := cmd.Flags().GetInt("limit")
|
|
|
|
filter := types.IssueFilter{
|
|
Limit: limit,
|
|
}
|
|
if status != "" {
|
|
s := types.Status(status)
|
|
filter.Status = &s
|
|
}
|
|
// Use Changed() to properly handle P0 (priority=0)
|
|
if cmd.Flags().Changed("priority") {
|
|
priority, _ := cmd.Flags().GetInt("priority")
|
|
filter.Priority = &priority
|
|
}
|
|
if assignee != "" {
|
|
filter.Assignee = &assignee
|
|
}
|
|
if issueType != "" {
|
|
t := types.IssueType(issueType)
|
|
filter.IssueType = &t
|
|
}
|
|
|
|
ctx := context.Background()
|
|
issues, err := store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(issues)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\nFound %d issues:\n\n", len(issues))
|
|
for _, issue := range issues {
|
|
fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status)
|
|
fmt.Printf(" %s\n", issue.Title)
|
|
if issue.Assignee != "" {
|
|
fmt.Printf(" Assignee: %s\n", issue.Assignee)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
listCmd.Flags().StringP("status", "s", "", "Filter by status")
|
|
listCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
|
|
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
|
listCmd.Flags().StringP("type", "t", "", "Filter by type")
|
|
listCmd.Flags().IntP("limit", "n", 0, "Limit results")
|
|
rootCmd.AddCommand(listCmd)
|
|
}
|
|
|
|
var updateCmd = &cobra.Command{
|
|
Use: "update [id]",
|
|
Short: "Update an issue",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
updates := make(map[string]interface{})
|
|
|
|
if cmd.Flags().Changed("status") {
|
|
status, _ := cmd.Flags().GetString("status")
|
|
updates["status"] = status
|
|
}
|
|
if cmd.Flags().Changed("priority") {
|
|
priority, _ := cmd.Flags().GetInt("priority")
|
|
updates["priority"] = priority
|
|
}
|
|
if cmd.Flags().Changed("title") {
|
|
title, _ := cmd.Flags().GetString("title")
|
|
updates["title"] = title
|
|
}
|
|
if cmd.Flags().Changed("assignee") {
|
|
assignee, _ := cmd.Flags().GetString("assignee")
|
|
updates["assignee"] = assignee
|
|
}
|
|
if cmd.Flags().Changed("design") {
|
|
design, _ := cmd.Flags().GetString("design")
|
|
updates["design"] = design
|
|
}
|
|
if cmd.Flags().Changed("notes") {
|
|
notes, _ := cmd.Flags().GetString("notes")
|
|
updates["notes"] = notes
|
|
}
|
|
if cmd.Flags().Changed("acceptance-criteria") {
|
|
acceptanceCriteria, _ := cmd.Flags().GetString("acceptance-criteria")
|
|
updates["acceptance_criteria"] = acceptanceCriteria
|
|
}
|
|
if cmd.Flags().Changed("external-ref") {
|
|
externalRef, _ := cmd.Flags().GetString("external-ref")
|
|
updates["external_ref"] = externalRef
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
fmt.Println("No updates specified")
|
|
return
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := store.UpdateIssue(ctx, args[0], updates, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Schedule auto-flush
|
|
markDirtyAndScheduleFlush()
|
|
|
|
if jsonOutput {
|
|
// Fetch updated issue and output
|
|
issue, _ := store.GetIssue(ctx, args[0])
|
|
outputJSON(issue)
|
|
} else {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Updated issue: %s\n", green("✓"), args[0])
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
updateCmd.Flags().StringP("status", "s", "", "New status")
|
|
updateCmd.Flags().IntP("priority", "p", 0, "New priority")
|
|
updateCmd.Flags().String("title", "", "New title")
|
|
updateCmd.Flags().StringP("assignee", "a", "", "New assignee")
|
|
updateCmd.Flags().String("design", "", "Design notes")
|
|
updateCmd.Flags().String("notes", "", "Additional notes")
|
|
updateCmd.Flags().String("acceptance-criteria", "", "Acceptance criteria")
|
|
updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
|
|
rootCmd.AddCommand(updateCmd)
|
|
}
|
|
|
|
var closeCmd = &cobra.Command{
|
|
Use: "close [id...]",
|
|
Short: "Close one or more issues",
|
|
Args: cobra.MinimumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
reason, _ := cmd.Flags().GetString("reason")
|
|
if reason == "" {
|
|
reason = "Closed"
|
|
}
|
|
|
|
ctx := context.Background()
|
|
closedIssues := []*types.Issue{}
|
|
for _, id := range args {
|
|
if err := store.CloseIssue(ctx, id, reason, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
|
|
continue
|
|
}
|
|
if jsonOutput {
|
|
issue, _ := store.GetIssue(ctx, id)
|
|
if issue != nil {
|
|
closedIssues = append(closedIssues, issue)
|
|
}
|
|
} else {
|
|
green := color.New(color.FgGreen).SprintFunc()
|
|
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
|
|
}
|
|
}
|
|
|
|
// Schedule auto-flush if any issues were closed
|
|
if len(args) > 0 {
|
|
markDirtyAndScheduleFlush()
|
|
}
|
|
|
|
if jsonOutput && len(closedIssues) > 0 {
|
|
outputJSON(closedIssues)
|
|
}
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
|
|
rootCmd.AddCommand(closeCmd)
|
|
}
|
|
|
|
func main() {
|
|
if err := rootCmd.Execute(); err != nil {
|
|
os.Exit(1)
|
|
}
|
|
}
|