Merge upstream/main into subtle-ux-improvements

Resolves conflicts and converts new defer/undefer commands from
fatih/color to the lipgloss semantic color system.

Key changes:
- Added StatusDeferred case in graph.go with ui.RenderAccent
- Converted status.go to use ui package for colorized output
- Converted defer.go/undefer.go to use ui package
- Merged GroupID and Aliases for status command
- Updated pre-commit hook version to 0.31.0
- Ran go mod tidy to remove fatih/color dependency
This commit is contained in:
Ryan Snodgrass
2025-12-20 17:22:43 -08:00
45 changed files with 850 additions and 316 deletions

View File

@@ -33,7 +33,7 @@ Custom Status States:
bd config set status.custom "awaiting_review,awaiting_testing,awaiting_docs"
This enables issues to use statuses like 'awaiting_review' in addition to
the built-in statuses (open, in_progress, blocked, closed).
the built-in statuses (open, in_progress, blocked, deferred, closed).
Examples:
bd config set jira.url "https://company.atlassian.net"

View File

@@ -421,7 +421,7 @@ Examples:
func init() {
// Filter flags (same as list command)
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
countCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
countCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
countCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
countCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")

140
cmd/bd/defer.go Normal file
View File

@@ -0,0 +1,140 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
var deferCmd = &cobra.Command{
Use: "defer [id...]",
Short: "Defer one or more issues for later",
Long: `Defer issues to put them on ice for later.
Deferred issues are deliberately set aside - not blocked by anything specific,
just postponed for future consideration. Unlike blocked issues, there's no
dependency keeping them from being worked. Unlike closed issues, they will
be revisited.
Deferred issues don't show in 'bd ready' but remain visible in 'bd list'.
Examples:
bd defer bd-abc # Defer a single issue
bd defer bd-abc bd-def # Defer multiple issues`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("defer")
ctx := rootCtx
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
deferredIssues := []*types.Issue{}
// If daemon is running, use RPC
if daemonClient != nil {
for _, id := range resolvedIDs {
status := string(types.StatusDeferred)
updateArgs := &rpc.UpdateArgs{
ID: id,
Status: &status,
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
deferredIssues = append(deferredIssues, &issue)
}
} else {
fmt.Printf("%s Deferred %s\n", ui.RenderAccent("*"), id)
}
}
if jsonOutput && len(deferredIssues) > 0 {
outputJSON(deferredIssues)
}
return
}
// Fall back to direct storage access
if store == nil {
fmt.Fprintln(os.Stderr, "Error: database not initialized")
os.Exit(1)
}
for _, id := range args {
fullID, err := utils.ResolvePartialID(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
updates := map[string]interface{}{
"status": string(types.StatusDeferred),
}
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error deferring %s: %v\n", fullID, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID)
if issue != nil {
deferredIssues = append(deferredIssues, issue)
}
} else {
fmt.Printf("%s Deferred %s\n", ui.RenderAccent("*"), fullID)
}
}
// Schedule auto-flush if any issues were deferred
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(deferredIssues) > 0 {
outputJSON(deferredIssues)
}
},
}
func init() {
rootCmd.AddCommand(deferCmd)
}

View File

@@ -490,6 +490,8 @@ func getStatusEmoji(status types.Status) string {
return "◧" // U+25E7 Square Left Half Black
case types.StatusBlocked:
return "⚠" // U+26A0 Warning Sign
case types.StatusDeferred:
return "❄" // U+2744 Snowflake (on ice)
case types.StatusClosed:
return "☑" // U+2611 Ballot Box with Check
default:
@@ -736,7 +738,7 @@ func init() {
depTreeCmd.Flags().IntP("max-depth", "d", 50, "Maximum tree depth to display (safety limit)")
depTreeCmd.Flags().Bool("reverse", false, "Show dependent tree (deprecated: use --direction=up)")
depTreeCmd.Flags().String("direction", "", "Tree direction: 'down' (dependencies), 'up' (dependents), or 'both'")
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, closed)")
depTreeCmd.Flags().String("status", "", "Filter to only show issues with this status (open, in_progress, blocked, deferred, closed)")
depTreeCmd.Flags().String("format", "", "Output format: 'mermaid' for Mermaid.js flowchart")
// Note: --json flag is defined as a persistent flag in main.go, not here

View File

@@ -5,11 +5,10 @@ import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/configfile"
)
// CheckCLIVersion checks if the CLI version is up to date.
@@ -53,62 +52,73 @@ func CheckCLIVersion(cliVersion string) DoctorCheck {
}
}
// CheckMetadataVersionTracking checks if metadata.json has proper version tracking.
// localVersionFile is the gitignored file that stores the last bd version used locally.
// Must match the constant in version_tracking.go.
const localVersionFile = ".local_version"
// CheckMetadataVersionTracking checks if version tracking is properly configured.
// Version tracking uses .local_version file (gitignored) to track the last bd version used.
//
// GH#662: This was updated to check .local_version instead of metadata.json:LastBdVersion,
// which is now deprecated.
func CheckMetadataVersionTracking(path string, currentVersion string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
localVersionPath := filepath.Join(beadsDir, localVersionFile)
// Load metadata.json
cfg, err := configfile.Load(beadsDir)
// Read .local_version file
// #nosec G304 - path is constructed from controlled beadsDir + constant
data, err := os.ReadFile(localVersionPath)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist yet - will be created on next bd command
return DoctorCheck{
Name: "Version Tracking",
Status: StatusWarning,
Message: "Version tracking not initialized",
Detail: "The .local_version file will be created on next bd command",
Fix: "Run any bd command (e.g., 'bd ready') to initialize version tracking",
}
}
// Other error reading file
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusError,
Message: "Unable to read metadata.json",
Message: "Unable to read .local_version file",
Detail: err.Error(),
Fix: "Ensure metadata.json exists and is valid JSON. Run 'bd init' if needed.",
Fix: "Check file permissions on .beads/.local_version",
}
}
// Check if metadata.json exists
if cfg == nil {
return DoctorCheck{
Name: "Metadata Version Tracking",
Status: StatusWarning,
Message: "metadata.json not found",
Fix: "Run any bd command to create metadata.json with version tracking",
}
}
lastVersion := strings.TrimSpace(string(data))
// Check if LastBdVersion field is present
if cfg.LastBdVersion == "" {
// Check if file is empty
if lastVersion == "" {
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusWarning,
Message: "LastBdVersion field is empty (first run)",
Message: ".local_version file is empty",
Detail: "Version tracking will be initialized on next command",
Fix: "Run any bd command to initialize version tracking",
}
}
// Validate that LastBdVersion is a valid semver-like string
// Simple validation: should be X.Y.Z format where X, Y, Z are numbers
if !IsValidSemver(cfg.LastBdVersion) {
// Validate that version is a valid semver-like string
if !IsValidSemver(lastVersion) {
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("LastBdVersion has invalid format: %q", cfg.LastBdVersion),
Message: fmt.Sprintf("Invalid version format in .local_version: %q", lastVersion),
Detail: "Expected semver format like '0.24.2'",
Fix: "Run any bd command to reset version tracking to current version",
}
}
// Check if LastBdVersion is very old (> 10 versions behind)
// Calculate version distance
versionDiff := CompareVersions(currentVersion, cfg.LastBdVersion)
// Check if version is very old (> 10 versions behind)
versionDiff := CompareVersions(currentVersion, lastVersion)
if versionDiff > 0 {
// Current version is newer - check how far behind
currentParts := ParseVersionParts(currentVersion)
lastParts := ParseVersionParts(cfg.LastBdVersion)
lastParts := ParseVersionParts(lastVersion)
// Simple heuristic: warn if minor version is 10+ behind or major version differs by 1+
majorDiff := currentParts[0] - lastParts[0]
@@ -116,27 +126,27 @@ func CheckMetadataVersionTracking(path string, currentVersion string) DoctorChec
if majorDiff >= 1 || (majorDiff == 0 && minorDiff >= 10) {
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusWarning,
Message: fmt.Sprintf("LastBdVersion is very old: %s (current: %s)", cfg.LastBdVersion, currentVersion),
Message: fmt.Sprintf("Last recorded version is very old: %s (current: %s)", lastVersion, currentVersion),
Detail: "You may have missed important upgrade notifications",
Fix: "Run 'bd upgrade review' to see recent changes",
}
}
// Version is behind but not too old
// Version is behind but not too old - this is normal after upgrade
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", cfg.LastBdVersion, currentVersion),
Message: fmt.Sprintf("Version tracking active (last: %s, current: %s)", lastVersion, currentVersion),
}
}
// Version is current or ahead (shouldn't happen, but handle it)
// Version is current or ahead
return DoctorCheck{
Name: "Metadata Version Tracking",
Name: "Version Tracking",
Status: StatusOK,
Message: fmt.Sprintf("Version tracking active (version: %s)", cfg.LastBdVersion),
Message: fmt.Sprintf("Version tracking active (version: %s)", lastVersion),
}
}

View File

@@ -877,89 +877,57 @@ func TestGetClaudePluginVersion(t *testing.T) {
}
func TestCheckMetadataVersionTracking(t *testing.T) {
// GH#662: Tests updated to use .local_version file instead of metadata.json:LastBdVersion
tests := []struct {
name string
setupMetadata func(beadsDir string) error
setupVersion func(beadsDir string) error
expectedStatus string
expectWarning bool
}{
{
name: "valid current version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": Version,
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(Version+"\n"), 0644)
},
expectedStatus: doctor.StatusOK,
expectWarning: false,
},
{
name: "slightly outdated version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "0.24.0",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.24.0\n"), 0644)
},
expectedStatus: doctor.StatusOK,
expectWarning: false,
},
{
name: "very old version",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "0.14.0",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("0.14.0\n"), 0644)
},
expectedStatus: doctor.StatusWarning,
expectWarning: true,
},
{
name: "empty version field",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
name: "empty version file",
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte(""), 0644)
},
expectedStatus: doctor.StatusWarning,
expectWarning: true,
},
{
name: "invalid version format",
setupMetadata: func(beadsDir string) error {
cfg := map[string]string{
"database": "beads.db",
"last_bd_version": "invalid-version",
}
data, _ := json.Marshal(cfg)
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), data, 0644)
setupVersion: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, ".local_version"), []byte("invalid-version\n"), 0644)
},
expectedStatus: doctor.StatusWarning,
expectWarning: true,
},
{
name: "corrupted metadata.json",
setupMetadata: func(beadsDir string) error {
return os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte("{invalid json}"), 0644)
},
expectedStatus: doctor.StatusError,
expectWarning: false,
},
{
name: "missing metadata.json",
setupMetadata: func(beadsDir string) error {
// Don't create metadata.json
name: "missing .local_version file",
setupVersion: func(beadsDir string) error {
// Don't create .local_version
return nil
},
expectedStatus: doctor.StatusWarning,
@@ -975,8 +943,8 @@ func TestCheckMetadataVersionTracking(t *testing.T) {
t.Fatal(err)
}
// Setup metadata.json
if err := tc.setupMetadata(beadsDir); err != nil {
// Setup .local_version file
if err := tc.setupVersion(beadsDir); err != nil {
t.Fatal(err)
}

View File

@@ -384,6 +384,9 @@ func renderNodeBox(node *GraphNode, width int) string {
case types.StatusBlocked:
statusIcon = "●"
titleStr = ui.RenderFail(padRight(title, width-4))
case types.StatusDeferred:
statusIcon = "❄"
titleStr = ui.RenderAccent(padRight(title, width-4))
case types.StatusClosed:
statusIcon = "✓"
titleStr = ui.RenderPass(padRight(title, width-4))
@@ -461,6 +464,9 @@ func renderNodeBoxWithDeps(node *GraphNode, width int, blocksCount int, blockedB
case types.StatusBlocked:
statusIcon = "●"
titleStr = ui.RenderFail(padRight(title, width-4))
case types.StatusDeferred:
statusIcon = "❄"
titleStr = ui.RenderAccent(padRight(title, width-4))
case types.StatusClosed:
statusIcon = "✓"
titleStr = ui.RenderPass(padRight(title, width-4))

View File

@@ -409,7 +409,7 @@ func uninstallHooks() error {
// runPreCommitHook flushes pending changes to JSONL before commit.
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - warns but doesn't block commits
//nolint:unparam // Always returns 0 by design - warnings don't block commits
func runPreCommitHook() int {
// Check if we're in a bd workspace
if _, err := os.Stat(".beads"); os.IsNotExist(err) {
@@ -433,7 +433,7 @@ func runPreCommitHook() int {
// Stage all tracked JSONL files
for _, f := range []string{".beads/beads.jsonl", ".beads/issues.jsonl", ".beads/deletions.jsonl", ".beads/interactions.jsonl"} {
if _, err := os.Stat(f); err == nil {
// #nosec G204 -- f is a fixed string from the hardcoded slice above
// #nosec G204 - f is from hardcoded list above, not user input
gitAdd := exec.Command("git", "add", f)
_ = gitAdd.Run() // Ignore errors - file may not exist
}
@@ -445,7 +445,7 @@ func runPreCommitHook() int {
// runPostMergeHook imports JSONL after pull/merge.
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - warns but doesn't block merges
//nolint:unparam // Always returns 0 by design - warnings don't block merges
func runPostMergeHook() int {
// Skip during rebase
if isRebaseInProgress() {
@@ -510,7 +510,7 @@ func runPrePushHook() int {
files = append(files, f)
} else {
// Check if tracked by git
// #nosec G204 -- f is a fixed string from the hardcoded slice above
// #nosec G204 - f is from hardcoded list above, not user input
checkCmd := exec.Command("git", "ls-files", "--error-unmatch", f)
if checkCmd.Run() == nil {
files = append(files, f)
@@ -524,7 +524,7 @@ func runPrePushHook() int {
// Check for uncommitted changes using git status
args := append([]string{"status", "--porcelain", "--"}, files...)
// #nosec G204 -- args contains only fixed strings from hardcoded slice
// #nosec G204 - args built from hardcoded list and git subcommands
statusCmd := exec.Command("git", args...)
output, _ := statusCmd.Output()
if len(output) > 0 {
@@ -548,7 +548,7 @@ func runPrePushHook() int {
// args: [previous-HEAD, new-HEAD, flag] where flag=1 for branch checkout
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - warns but doesn't block checkouts
//nolint:unparam // Always returns 0 by design - warnings don't block checkouts
func runPostCheckoutHook(args []string) int {
// Only run on branch checkouts (flag=1)
if len(args) >= 3 && args[2] != "1" {

View File

@@ -288,6 +288,24 @@ type VersionChange struct {
// versionChanges contains agent-actionable changes for recent versions
var versionChanges = []VersionChange{
{
Version: "0.31.0",
Date: "2025-12-20",
Changes: []string{
"NEW: bd defer/bd undefer commands - Deferred status for icebox issues (bd-4jr)",
"NEW: Agent audit trail - .beads/interactions.jsonl with bd audit record/label (GH#649)",
"NEW: Directory-aware label scoping for monorepos (GH#541) - Auto-filter by directory.labels config",
"NEW: Molecules catalog - Templates in separate molecules.jsonl with hierarchical loading",
"NEW: Git commit config - git.author and git.no-gpg-sign options (GH#600)",
"NEW: create.require-description config option (GH#596)",
"CHANGED: bd stats merged into bd status (GH#644) - stats is now alias, colorized output",
"CHANGED: Thin hook shims (GH#615) - Hooks delegate to bd hooks run, no more version drift",
"CHANGED: MCP context tool consolidation - set_context/where_am_i/init merged into single context tool",
"FIX: relates-to excluded from cycle detection (GH#661)",
"FIX: Doctor checks .local_version instead of deprecated LastBdVersion (GH#662)",
"FIX: Read-only gitignore in stealth mode prints manual instructions (GH#663)",
},
},
{
Version: "0.30.7",
Date: "2025-12-19",

View File

@@ -1489,7 +1489,19 @@ func setupGlobalGitIgnore(homeDir string, projectPath string, verbose bool) erro
// Write the updated ignore file
// #nosec G306 - config file needs 0644
if err := os.WriteFile(ignorePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write global gitignore: %w", err)
fmt.Printf("\nUnable to write to %s (file is read-only)\n\n", ignorePath)
fmt.Printf("To enable stealth mode, add these lines to your global gitignore:\n\n")
if !hasBeads || !hasClaude {
fmt.Printf("# Beads stealth mode: %s\n", projectPath)
}
if !hasBeads {
fmt.Printf("%s\n", beadsPattern)
}
if !hasClaude {
fmt.Printf("%s\n", claudePattern)
}
fmt.Println()
return nil
}
if verbose {

View File

@@ -1047,3 +1047,92 @@ func TestSetupClaudeSettings_NoExistingFile(t *testing.T) {
t.Error("File should contain bd onboard prompt")
}
}
// TestSetupGlobalGitIgnore_ReadOnly verifies graceful handling when the
// gitignore file cannot be written (prints manual instructions instead of failing).
func TestSetupGlobalGitIgnore_ReadOnly(t *testing.T) {
t.Run("read-only file", func(t *testing.T) {
tmpDir := t.TempDir()
configDir := filepath.Join(tmpDir, ".config", "git")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
ignorePath := filepath.Join(configDir, "ignore")
if err := os.WriteFile(ignorePath, []byte("# existing\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(ignorePath, 0444); err != nil {
t.Fatal(err)
}
defer os.Chmod(ignorePath, 0644)
output := captureStdout(t, func() error {
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
})
if !strings.Contains(output, "Unable to write") {
t.Error("expected instructions for manual addition")
}
if !strings.Contains(output, "/test/project/.beads/") {
t.Error("expected .beads pattern in output")
}
})
t.Run("symlink to read-only file", func(t *testing.T) {
tmpDir := t.TempDir()
// Target file in a separate location
targetDir := filepath.Join(tmpDir, "target")
if err := os.MkdirAll(targetDir, 0755); err != nil {
t.Fatal(err)
}
targetFile := filepath.Join(targetDir, "ignore")
if err := os.WriteFile(targetFile, []byte("# existing\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(targetFile, 0444); err != nil {
t.Fatal(err)
}
defer os.Chmod(targetFile, 0644)
// Symlink from expected location
configDir := filepath.Join(tmpDir, ".config", "git")
if err := os.MkdirAll(configDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(targetFile, filepath.Join(configDir, "ignore")); err != nil {
t.Fatal(err)
}
output := captureStdout(t, func() error {
return setupGlobalGitIgnore(tmpDir, "/test/project", false)
})
if !strings.Contains(output, "Unable to write") {
t.Error("expected instructions for manual addition")
}
if !strings.Contains(output, "/test/project/.beads/") {
t.Error("expected .beads pattern in output")
}
})
}
func captureStdout(t *testing.T, fn func() error) string {
t.Helper()
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
err := fn()
w.Close()
var buf bytes.Buffer
buf.ReadFrom(r)
os.Stdout = oldStdout
if err != nil {
t.Errorf("unexpected error: %v", err)
}
return buf.String()
}

View File

@@ -621,7 +621,7 @@ var listCmd = &cobra.Command{
}
func init() {
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
registerPriorityFlag(listCmd, "")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule)")

View File

@@ -251,90 +251,7 @@ var blockedCmd = &cobra.Command{
}
},
}
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show statistics",
Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running, use RPC
if daemonClient != nil {
resp, err := daemonClient.Stats()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var stats types.Statistics
if err := json.Unmarshal(resp.Data, &stats); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(stats)
return
}
fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
if stats.TombstoneIssues > 0 {
fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues)
}
if stats.PinnedIssues > 0 {
fmt.Printf("Pinned: %d\n", stats.PinnedIssues)
}
if stats.AverageLeadTime > 0 {
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
fmt.Println()
return
}
// Direct mode
ctx := rootCtx
stats, err := store.GetStatistics(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// If no issues found, check if git has issues and auto-import
if stats.TotalIssues == 0 {
if checkAndAutoImport(ctx, store) {
// Re-run the stats after import
stats, err = store.GetStatistics(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
if jsonOutput {
outputJSON(stats)
return
}
fmt.Printf("\n%s Beads Statistics:\n\n", ui.RenderAccent("📊"))
fmt.Printf("Total Issues: %d\n", stats.TotalIssues)
fmt.Printf("Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf("In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf("Closed: %d\n", stats.ClosedIssues)
fmt.Printf("Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf("Ready: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
if stats.TombstoneIssues > 0 {
fmt.Printf("Deleted: %d (tombstones)\n", stats.TombstoneIssues)
}
if stats.PinnedIssues > 0 {
fmt.Printf("Pinned: %d\n", stats.PinnedIssues)
}
if stats.EpicsEligibleForClosure > 0 {
fmt.Printf("Epics Ready to Close: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
}
if stats.AverageLeadTime > 0 {
fmt.Printf("Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
fmt.Println()
},
}
func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
@@ -346,5 +263,4 @@ func init() {
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)")
rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd)
rootCmd.AddCommand(statsCmd)
}

View File

@@ -371,7 +371,7 @@ func outputSearchResults(issues []*types.Issue, query string, longFormat bool) {
func init() {
searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)")
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, closed)")
searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)")

View File

@@ -26,8 +26,8 @@ This helps identify:
limit, _ := cmd.Flags().GetInt("limit")
// Use global jsonOutput set by PersistentPreRun
// Validate status if provided
if status != "" && status != "open" && status != "in_progress" && status != "blocked" {
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked\n", status)
if status != "" && status != "open" && status != "in_progress" && status != "blocked" && status != "deferred" {
fmt.Fprintf(os.Stderr, "Error: invalid status '%s'. Valid values: open, in_progress, blocked, deferred\n", status)
os.Exit(1)
}
filter := types.StaleFilter{
@@ -108,7 +108,7 @@ func displayStaleIssues(issues []*types.Issue, days int) {
}
func init() {
staleCmd.Flags().IntP("days", "d", 30, "Issues not updated in this many days")
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked)")
staleCmd.Flags().StringP("status", "s", "", "Filter by status (open|in_progress|blocked|deferred)")
staleCmd.Flags().IntP("limit", "n", 50, "Maximum issues to show")
staleCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format")
rootCmd.AddCommand(staleCmd)

View File

@@ -11,24 +11,15 @@ import (
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// StatusOutput represents the complete status output
type StatusOutput struct {
Summary *StatusSummary `json:"summary"`
Summary *types.Statistics `json:"summary"`
RecentActivity *RecentActivitySummary `json:"recent_activity,omitempty"`
}
// StatusSummary represents counts by state
type StatusSummary struct {
TotalIssues int `json:"total_issues"`
OpenIssues int `json:"open_issues"`
InProgressIssues int `json:"in_progress_issues"`
BlockedIssues int `json:"blocked_issues"`
ClosedIssues int `json:"closed_issues"`
ReadyIssues int `json:"ready_issues"`
}
// RecentActivitySummary represents activity from git history
type RecentActivitySummary struct {
HoursTracked int `json:"hours_tracked"`
@@ -43,11 +34,13 @@ type RecentActivitySummary struct {
var statusCmd = &cobra.Command{
Use: "status",
GroupID: "views",
Short: "Show issue database overview",
Long: `Show a quick snapshot of the issue database state.
Aliases: []string{"stats"},
Short: "Show issue database overview and statistics",
Long: `Show a quick snapshot of the issue database state and statistics.
This command provides a summary of issue counts by state (open, in_progress,
blocked, closed), ready work, and recent activity over the last 24 hours from git history.
blocked, closed), ready work, extended statistics (tombstones, pinned issues,
average lead time), and recent activity over the last 24 hours from git history.
Similar to how 'git status' shows working tree state, 'bd status' gives you
a quick overview of your issue database without needing multiple queries.
@@ -59,13 +52,15 @@ Use cases:
- Daily standup reference
Examples:
bd status # Show summary
bd status # Show summary with activity
bd status --no-activity # Skip git activity (faster)
bd status --json # JSON format output
bd status --assigned # Show issues assigned to current user
bd status --all # Show all issues (same as default)`,
bd stats # Alias for bd status`,
Run: func(cmd *cobra.Command, args []string) {
showAll, _ := cmd.Flags().GetBool("all")
showAssigned, _ := cmd.Flags().GetBool("assigned")
noActivity, _ := cmd.Flags().GetBool("no-activity")
jsonFormat, _ := cmd.Flags().GetBool("json")
// Override global jsonOutput if --json flag is set
@@ -109,28 +104,23 @@ Examples:
}
}
// Build summary
summary := &StatusSummary{
TotalIssues: stats.TotalIssues,
OpenIssues: stats.OpenIssues,
InProgressIssues: stats.InProgressIssues,
BlockedIssues: stats.BlockedIssues,
ClosedIssues: stats.ClosedIssues,
ReadyIssues: stats.ReadyIssues,
// Filter by assignee if requested (overrides stats with filtered counts)
if showAssigned {
stats = getAssignedStatistics(actor)
if stats == nil {
fmt.Fprintf(os.Stderr, "Error: failed to get assigned statistics\n")
os.Exit(1)
}
}
// Get recent activity from git history (last 24 hours)
// Get recent activity from git history (last 24 hours) unless --no-activity
var recentActivity *RecentActivitySummary
recentActivity = getGitActivity(24)
// Filter by assignee if requested
if showAssigned {
// Get filtered statistics for assigned issues
summary = getAssignedStatus(actor)
if !noActivity {
recentActivity = getGitActivity(24)
}
output := &StatusOutput{
Summary: summary,
Summary: stats,
RecentActivity: recentActivity,
}
@@ -140,25 +130,43 @@ Examples:
return
}
// Human-readable output
fmt.Println("\nIssue Database Status")
fmt.Println("=====================")
fmt.Printf("\nSummary:\n")
fmt.Printf(" Total Issues: %d\n", summary.TotalIssues)
fmt.Printf(" Open: %d\n", summary.OpenIssues)
fmt.Printf(" In Progress: %d\n", summary.InProgressIssues)
fmt.Printf(" Blocked: %d\n", summary.BlockedIssues)
fmt.Printf(" Closed: %d\n", summary.ClosedIssues)
fmt.Printf(" Ready to Work: %d\n", summary.ReadyIssues)
// Human-readable colorized output using semantic ui package
fmt.Printf("\n%s Issue Database Status\n\n", ui.RenderAccent("📊"))
fmt.Printf("Summary:\n")
fmt.Printf(" Total Issues: %d\n", stats.TotalIssues)
fmt.Printf(" Open: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.OpenIssues)))
fmt.Printf(" In Progress: %s\n", ui.RenderWarn(fmt.Sprintf("%d", stats.InProgressIssues)))
fmt.Printf(" Blocked: %s\n", ui.RenderFail(fmt.Sprintf("%d", stats.BlockedIssues)))
fmt.Printf(" Closed: %d\n", stats.ClosedIssues)
fmt.Printf(" Ready to Work: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.ReadyIssues)))
// Extended statistics (only show if non-zero)
hasExtended := stats.TombstoneIssues > 0 || stats.PinnedIssues > 0 ||
stats.EpicsEligibleForClosure > 0 || stats.AverageLeadTime > 0
if hasExtended {
fmt.Printf("\nExtended:\n")
if stats.TombstoneIssues > 0 {
fmt.Printf(" Deleted: %d (tombstones)\n", stats.TombstoneIssues)
}
if stats.PinnedIssues > 0 {
fmt.Printf(" Pinned: %d\n", stats.PinnedIssues)
}
if stats.EpicsEligibleForClosure > 0 {
fmt.Printf(" Epics Ready to Close: %s\n", ui.RenderPass(fmt.Sprintf("%d", stats.EpicsEligibleForClosure)))
}
if stats.AverageLeadTime > 0 {
fmt.Printf(" Avg Lead Time: %.1f hours\n", stats.AverageLeadTime)
}
}
if recentActivity != nil {
fmt.Printf("\nRecent Activity (last %d hours, from git history):\n", recentActivity.HoursTracked)
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
fmt.Printf("\nRecent Activity (last %d hours):\n", recentActivity.HoursTracked)
fmt.Printf(" Commits: %d\n", recentActivity.CommitCount)
fmt.Printf(" Total Changes: %d\n", recentActivity.TotalChanges)
fmt.Printf(" Issues Created: %d\n", recentActivity.IssuesCreated)
fmt.Printf(" Issues Closed: %d\n", recentActivity.IssuesClosed)
fmt.Printf(" Issues Reopened: %d\n", recentActivity.IssuesReopened)
fmt.Printf(" Issues Updated: %d\n", recentActivity.IssuesUpdated)
}
// Show hint for more details
@@ -268,8 +276,8 @@ func getGitActivity(hours int) *RecentActivitySummary {
return activity
}
// getAssignedStatus returns status summary for issues assigned to a specific user
func getAssignedStatus(assignee string) *StatusSummary {
// getAssignedStatistics returns statistics for issues assigned to a specific user
func getAssignedStatistics(assignee string) *types.Statistics {
if store == nil {
return nil
}
@@ -287,7 +295,7 @@ func getAssignedStatus(assignee string) *StatusSummary {
return nil
}
summary := &StatusSummary{
stats := &types.Statistics{
TotalIssues: len(issues),
}
@@ -295,13 +303,15 @@ func getAssignedStatus(assignee string) *StatusSummary {
for _, issue := range issues {
switch issue.Status {
case types.StatusOpen:
summary.OpenIssues++
stats.OpenIssues++
case types.StatusInProgress:
summary.InProgressIssues++
stats.InProgressIssues++
case types.StatusBlocked:
summary.BlockedIssues++
stats.BlockedIssues++
case types.StatusDeferred:
stats.DeferredIssues++
case types.StatusClosed:
summary.ClosedIssues++
stats.ClosedIssues++
}
}
@@ -311,15 +321,16 @@ func getAssignedStatus(assignee string) *StatusSummary {
}
readyIssues, err := store.GetReadyWork(ctx, readyFilter)
if err == nil {
summary.ReadyIssues = len(readyIssues)
stats.ReadyIssues = len(readyIssues)
}
return summary
return stats
}
func init() {
statusCmd.Flags().Bool("all", false, "Show all issues (default behavior)")
statusCmd.Flags().Bool("assigned", false, "Show issues assigned to current user")
statusCmd.Flags().Bool("no-activity", false, "Skip git activity tracking (faster)")
// Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(statusCmd)
}

View File

@@ -112,19 +112,9 @@ func TestStatusCommand(t *testing.T) {
t.Errorf("Expected 1 closed issue, got %d", stats.ClosedIssues)
}
// Test status output structures
summary := &StatusSummary{
TotalIssues: stats.TotalIssues,
OpenIssues: stats.OpenIssues,
InProgressIssues: stats.InProgressIssues,
BlockedIssues: stats.BlockedIssues,
ClosedIssues: stats.ClosedIssues,
ReadyIssues: stats.ReadyIssues,
}
// Test JSON marshaling
// Test JSON marshaling with full Statistics
output := &StatusOutput{
Summary: summary,
Summary: stats,
}
jsonBytes, err := json.MarshalIndent(output, "", " ")
@@ -178,7 +168,7 @@ func TestGetGitActivity(t *testing.T) {
}
}
func TestGetAssignedStatus(t *testing.T) {
func TestGetAssignedStatistics(t *testing.T) {
// Create a temporary directory for the test database
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, ".beads", "test.db")
@@ -202,7 +192,7 @@ func TestGetAssignedStatus(t *testing.T) {
t.Fatalf("Failed to set issue prefix: %v", err)
}
// Set global store and rootCtx for getAssignedStatus
// Set global store and rootCtx for getAssignedStatistics
oldRootCtx := rootCtx
rootCtx = ctx
defer func() { rootCtx = oldRootCtx }()
@@ -239,29 +229,29 @@ func TestGetAssignedStatus(t *testing.T) {
}
}
// Test getAssignedStatus for Alice
summary := getAssignedStatus("alice")
if summary == nil {
t.Fatal("getAssignedStatus returned nil")
// Test getAssignedStatistics for Alice
stats := getAssignedStatistics("alice")
if stats == nil {
t.Fatal("getAssignedStatistics returned nil")
}
if summary.TotalIssues != 2 {
t.Errorf("Expected 2 issues for alice, got %d", summary.TotalIssues)
if stats.TotalIssues != 2 {
t.Errorf("Expected 2 issues for alice, got %d", stats.TotalIssues)
}
if summary.OpenIssues != 1 {
t.Errorf("Expected 1 open issue for alice, got %d", summary.OpenIssues)
if stats.OpenIssues != 1 {
t.Errorf("Expected 1 open issue for alice, got %d", stats.OpenIssues)
}
if summary.InProgressIssues != 1 {
t.Errorf("Expected 1 in-progress issue for alice, got %d", summary.InProgressIssues)
if stats.InProgressIssues != 1 {
t.Errorf("Expected 1 in-progress issue for alice, got %d", stats.InProgressIssues)
}
// Test for Bob
bobSummary := getAssignedStatus("bob")
if bobSummary == nil {
t.Fatal("getAssignedStatus returned nil for bob")
bobStats := getAssignedStatistics("bob")
if bobStats == nil {
t.Fatal("getAssignedStatistics returned nil for bob")
}
if bobSummary.TotalIssues != 1 {
t.Errorf("Expected 1 issue for bob, got %d", bobSummary.TotalIssues)
if bobStats.TotalIssues != 1 {
t.Errorf("Expected 1 issue for bob, got %d", bobStats.TotalIssues)
}
}

View File

@@ -1,5 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
#
# bd (beads) post-checkout hook - thin shim
#

View File

@@ -1,5 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
#
# bd (beads) post-merge hook - thin shim
#

View File

@@ -1,6 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.30.7
# bd-hooks-version: 0.31.0
#
# bd (beads) pre-commit hook - thin shim
#

View File

@@ -1,5 +1,6 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.31.0
#
# bd (beads) pre-push hook - thin shim
#

136
cmd/bd/undefer.go Normal file
View File

@@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
var undeferCmd = &cobra.Command{
Use: "undefer [id...]",
Short: "Undefer one or more issues (restore to open)",
Long: `Undefer issues to restore them to open status.
This brings issues back from the icebox so they can be worked on again.
Issues will appear in 'bd ready' if they have no blockers.
Examples:
bd undefer bd-abc # Undefer a single issue
bd undefer bd-abc bd-def # Undefer multiple issues`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("undefer")
ctx := rootCtx
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
for _, id := range args {
resolveArgs := &rpc.ResolveIDArgs{ID: id}
resp, err := daemonClient.ResolveID(resolveArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
}
} else {
var err error
resolvedIDs, err = utils.ResolvePartialIDs(ctx, store, args)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
undeferredIssues := []*types.Issue{}
// If daemon is running, use RPC
if daemonClient != nil {
for _, id := range resolvedIDs {
status := string(types.StatusOpen)
updateArgs := &rpc.UpdateArgs{
ID: id,
Status: &status,
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
undeferredIssues = append(undeferredIssues, &issue)
}
} else {
fmt.Printf("%s Undeferred %s (now open)\n", ui.RenderPass("*"), id)
}
}
if jsonOutput && len(undeferredIssues) > 0 {
outputJSON(undeferredIssues)
}
return
}
// Fall back to direct storage access
if store == nil {
fmt.Fprintln(os.Stderr, "Error: database not initialized")
os.Exit(1)
}
for _, id := range args {
fullID, err := utils.ResolvePartialID(ctx, store, id)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving %s: %v\n", id, err)
continue
}
updates := map[string]interface{}{
"status": string(types.StatusOpen),
}
if err := store.UpdateIssue(ctx, fullID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error undeferring %s: %v\n", fullID, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, fullID)
if issue != nil {
undeferredIssues = append(undeferredIssues, issue)
}
} else {
fmt.Printf("%s Undeferred %s (now open)\n", ui.RenderPass("*"), fullID)
}
}
// Schedule auto-flush if any issues were undeferred
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(undeferredIssues) > 0 {
outputJSON(undeferredIssues)
}
},
}
func init() {
rootCmd.AddCommand(undeferCmd)
}

View File

@@ -14,7 +14,7 @@ import (
var (
// Version is the current version of bd (overridden by ldflags at build time)
Version = "0.30.7"
Version = "0.31.0"
// Build can be set via ldflags at compile time
Build = "dev"
// Commit and branch the git revision the binary was built from (optional ldflag)