Implement bd stale command (bd-c01f, closes #184)

- Add bd stale command to find abandoned/forgotten issues
- Support --days (default 30), --status, --limit, --json flags
- Implement GetStaleIssues in SQLite and Memory storage
- Add full RPC/daemon support
- Comprehensive test suite (6 tests, all passing)
- Update AGENTS.md documentation

Resolves GitHub issue #184

Amp-Thread-ID: https://ampcode.com/threads/T-f021ddb8-54e3-41bf-ba7a-071749663c1d
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-31 23:03:48 -07:00
parent 645af0b72d
commit cc7918daf4
12 changed files with 721 additions and 1 deletions

124
cmd/bd/stale.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
)
var staleCmd = &cobra.Command{
Use: "stale",
Short: "Show stale issues (not updated recently)",
Long: `Show issues that haven't been updated recently and may need attention.
This helps identify:
- In-progress issues with no recent activity (may be abandoned)
- Open issues that have been forgotten
- Issues that might be outdated or no longer relevant`,
Run: func(cmd *cobra.Command, args []string) {
days, _ := cmd.Flags().GetInt("days")
status, _ := cmd.Flags().GetString("status")
limit, _ := cmd.Flags().GetInt("limit")
jsonOutput, _ := cmd.Flags().GetBool("json")
// 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)
os.Exit(1)
}
filter := types.StaleFilter{
Days: days,
Status: status,
Limit: limit,
}
// If daemon is running, use RPC
if daemonClient != nil {
staleArgs := &rpc.StaleArgs{
Days: days,
Status: status,
Limit: limit,
}
resp, err := daemonClient.Stale(staleArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
displayStaleIssues(issues, days)
return
}
// Direct mode
ctx := context.Background()
issues, err := store.GetStaleIssues(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
displayStaleIssues(issues, days)
},
}
func displayStaleIssues(issues []*types.Issue, days int) {
if len(issues) == 0 {
green := color.New(color.FgGreen).SprintFunc()
fmt.Printf("\n%s No stale issues found (all active)\n\n", green("✨"))
return
}
yellow := color.New(color.FgYellow).SprintFunc()
fmt.Printf("\n%s Stale issues (%d not updated in %d+ days):\n\n", yellow("⏰"), len(issues), days)
now := time.Now()
for i, issue := range issues {
daysStale := int(now.Sub(issue.UpdatedAt).Hours() / 24)
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
fmt.Printf(" Status: %s, Last updated: %d days ago\n", issue.Status, daysStale)
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
fmt.Println()
}
}
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().IntP("limit", "n", 50, "Maximum issues to show")
staleCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(staleCmd)
}

408
cmd/bd/stale_test.go Normal file
View File

@@ -0,0 +1,408 @@
package main
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
func TestStaleIssues(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
sqliteStore := newTestStore(t, dbPath)
ctx := context.Background()
now := time.Now()
oldTime := now.Add(-40 * 24 * time.Hour) // 40 days ago
recentTime := now.Add(-10 * 24 * time.Hour) // 10 days ago
// Create issues with different update times
issues := []*types.Issue{
{
ID: "test-stale-1",
Title: "Very stale issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: oldTime,
},
{
ID: "test-stale-2",
Title: "Stale in-progress",
Status: types.StatusInProgress,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: oldTime,
},
{
ID: "test-recent",
Title: "Recently updated",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: recentTime,
},
{
ID: "test-closed",
Title: "Closed issue",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: oldTime,
ClosedAt: ptrTime(oldTime),
},
}
for _, issue := range issues {
if err := sqliteStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Update timestamps directly in DB (CreateIssue sets updated_at to now)
// Use datetime() function to compute old timestamps
db := sqliteStore.UnderlyingDB()
_, err := db.ExecContext(ctx, "UPDATE issues SET updated_at = datetime('now', '-40 days') WHERE id IN (?, ?)", "test-stale-1", "test-stale-2")
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(ctx, "UPDATE issues SET updated_at = datetime('now', '-10 days') WHERE id = ?", "test-recent")
if err != nil {
t.Fatal(err)
}
// Test basic stale detection (30 days)
stale, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 30,
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues failed: %v", err)
}
// Should have test-stale-1 and test-stale-2 (not test-recent or test-closed)
if len(stale) != 2 {
t.Errorf("Expected 2 stale issues, got %d", len(stale))
}
// Verify closed issues are excluded
for _, issue := range stale {
if issue.Status == types.StatusClosed {
t.Error("Closed issues should not appear in stale results")
}
if issue.ID == "test-closed" {
t.Error("test-closed should not be in stale results")
}
if issue.ID == "test-recent" {
t.Error("test-recent should not be in stale results (updated 10 days ago)")
}
}
// Verify issues are sorted by updated_at (oldest first)
for i := 0; i < len(stale)-1; i++ {
if stale[i].UpdatedAt.After(stale[i+1].UpdatedAt) {
t.Error("Stale issues should be sorted by updated_at ascending (oldest first)")
}
}
}
func TestStaleIssuesWithStatusFilter(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
sqliteStore := newTestStore(t, dbPath)
ctx := context.Background()
oldTime := time.Now().Add(-40 * 24 * time.Hour)
// Create stale issues with different statuses
issues := []*types.Issue{
{
ID: "test-open",
Title: "Stale open",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: oldTime,
},
{
ID: "test-in-progress",
Title: "Stale in-progress",
Status: types.StatusInProgress,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: oldTime,
},
{
ID: "test-blocked",
Title: "Stale blocked",
Status: types.StatusBlocked,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: oldTime,
},
}
for _, issue := range issues {
if err := sqliteStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Update timestamps directly in DB using datetime() function
db := sqliteStore.UnderlyingDB()
_, err := db.ExecContext(ctx, "UPDATE issues SET updated_at = datetime('now', '-40 days') WHERE id IN (?, ?, ?)",
"test-open", "test-in-progress", "test-blocked")
if err != nil {
t.Fatal(err)
}
// Test status filter: only in_progress
stale, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 30,
Status: "in_progress",
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues with status filter failed: %v", err)
}
if len(stale) != 1 {
t.Errorf("Expected 1 in_progress stale issue, got %d", len(stale))
}
if len(stale) > 0 && stale[0].Status != types.StatusInProgress {
t.Errorf("Expected status=in_progress, got %s", stale[0].Status)
}
// Test status filter: only open
staleOpen, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 30,
Status: "open",
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues with status=open failed: %v", err)
}
if len(staleOpen) != 1 {
t.Errorf("Expected 1 open stale issue, got %d", len(staleOpen))
}
if len(staleOpen) > 0 && staleOpen[0].Status != types.StatusOpen {
t.Errorf("Expected status=open, got %s", staleOpen[0].Status)
}
}
func TestStaleIssuesWithLimit(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
sqliteStore := newTestStore(t, dbPath)
ctx := context.Background()
oldTime := time.Now().Add(-40 * 24 * time.Hour)
// Create multiple stale issues
for i := 1; i <= 5; i++ {
updatedAt := oldTime.Add(time.Duration(i) * time.Hour) // Slightly different times for sorting
issue := &types.Issue{
ID: "test-stale-" + string(rune('0'+i)),
Title: "Stale issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: oldTime,
UpdatedAt: updatedAt,
}
if err := sqliteStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Update timestamps directly in DB using datetime() function
db := sqliteStore.UnderlyingDB()
for i := 1; i <= 5; i++ {
id := "test-stale-" + string(rune('0'+i))
// Make each slightly different (40 days ago + i hours)
_, err := db.ExecContext(ctx, "UPDATE issues SET updated_at = datetime('now', '-40 days', '+' || ? || ' hours') WHERE id = ?", i, id)
if err != nil {
t.Fatal(err)
}
}
// Test with limit
stale, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 30,
Limit: 2,
})
if err != nil {
t.Fatalf("GetStaleIssues with limit failed: %v", err)
}
if len(stale) != 2 {
t.Errorf("Expected 2 issues with limit=2, got %d", len(stale))
}
}
func TestStaleIssuesEmpty(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
sqliteStore := newTestStore(t, dbPath)
ctx := context.Background()
recentTime := time.Now().Add(-10 * 24 * time.Hour)
// Create only recent issues
issue := &types.Issue{
ID: "test-recent",
Title: "Recent issue",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: recentTime,
UpdatedAt: recentTime,
}
if err := sqliteStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
// Test stale detection with no stale issues
stale, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 30,
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues failed: %v", err)
}
if len(stale) != 0 {
t.Errorf("Expected 0 stale issues, got %d", len(stale))
}
}
func TestStaleIssuesDifferentDaysThreshold(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, ".beads", "beads.db")
sqliteStore := newTestStore(t, dbPath)
ctx := context.Background()
now := time.Now()
time20DaysAgo := now.Add(-20 * 24 * time.Hour)
time50DaysAgo := now.Add(-50 * 24 * time.Hour)
issues := []*types.Issue{
{
ID: "test-20-days",
Title: "20 days stale",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time20DaysAgo,
UpdatedAt: time20DaysAgo,
},
{
ID: "test-50-days",
Title: "50 days stale",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeTask,
CreatedAt: time50DaysAgo,
UpdatedAt: time50DaysAgo,
},
}
for _, issue := range issues {
if err := sqliteStore.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatal(err)
}
}
// Update timestamps directly in DB using datetime() function
db := sqliteStore.UnderlyingDB()
_, err := db.ExecContext(ctx, "UPDATE issues SET updated_at = datetime('now', '-20 days') WHERE id = ?", "test-20-days")
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(ctx, "UPDATE issues SET updated_at = datetime('now', '-50 days') WHERE id = ?", "test-50-days")
if err != nil {
t.Fatal(err)
}
// Test with 30 days threshold - should get both
stale30, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 30,
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues(30 days) failed: %v", err)
}
if len(stale30) != 1 {
t.Errorf("Expected 1 issue stale for 30+ days, got %d", len(stale30))
}
// Test with 10 days threshold - should get both
stale10, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 10,
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues(10 days) failed: %v", err)
}
if len(stale10) != 2 {
t.Errorf("Expected 2 issues stale for 10+ days, got %d", len(stale10))
}
// Test with 60 days threshold - should get only the 50-day old one
stale60, err := sqliteStore.GetStaleIssues(ctx, types.StaleFilter{
Days: 60,
Limit: 50,
})
if err != nil {
t.Fatalf("GetStaleIssues(60 days) failed: %v", err)
}
if len(stale60) != 0 {
t.Errorf("Expected 0 issues stale for 60+ days, got %d", len(stale60))
}
}
func TestStaleCommandInit(t *testing.T) {
if staleCmd == nil {
t.Fatal("staleCmd should be initialized")
}
if staleCmd.Use != "stale" {
t.Errorf("Expected Use='stale', got %q", staleCmd.Use)
}
if len(staleCmd.Short) == 0 {
t.Error("staleCmd should have Short description")
}
// Check flags are defined
flags := staleCmd.Flags()
if flags.Lookup("days") == nil {
t.Error("staleCmd should have --days flag")
}
if flags.Lookup("status") == nil {
t.Error("staleCmd should have --status flag")
}
if flags.Lookup("limit") == nil {
t.Error("staleCmd should have --limit flag")
}
if flags.Lookup("json") == nil {
t.Error("staleCmd should have --json flag")
}
}