Files
beads/cmd/bd/stale_test.go
Steve Yegge cc7918daf4 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>
2025-10-31 23:03:56 -07:00

409 lines
10 KiB
Go

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")
}
}