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

View File

@@ -265,6 +265,11 @@ func (c *Client) Ready(args *ReadyArgs) (*Response, error) {
return c.Execute(OpReady, args)
}
// Stale gets stale issues via the daemon
func (c *Client) Stale(args *StaleArgs) (*Response, error) {
return c.Execute(OpStale, args)
}
// Stats gets statistics via the daemon
func (c *Client) Stats() (*Response, error) {
return c.Execute(OpStats, nil)

View File

@@ -16,6 +16,7 @@ const (
OpList = "list"
OpShow = "show"
OpReady = "ready"
OpStale = "stale"
OpStats = "stats"
OpDepAdd = "dep_add"
OpDepRemove = "dep_remove"
@@ -118,6 +119,13 @@ type ReadyArgs struct {
SortPolicy string `json:"sort_policy,omitempty"`
}
// StaleArgs represents arguments for the stale command
type StaleArgs struct {
Days int `json:"days,omitempty"`
Status string `json:"status,omitempty"`
Limit int `json:"limit,omitempty"`
}
// DepAddArgs represents arguments for adding a dependency
type DepAddArgs struct {
FromID string `json:"from_id"`

View File

@@ -429,6 +429,39 @@ func (s *Server) handleReady(req *Request) Response {
}
}
func (s *Server) handleStale(req *Request) Response {
var staleArgs StaleArgs
if err := json.Unmarshal(req.Args, &staleArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid stale args: %v", err),
}
}
store := s.storage
filter := types.StaleFilter{
Days: staleArgs.Days,
Status: staleArgs.Status,
Limit: staleArgs.Limit,
}
ctx := s.reqCtx(req)
issues, err := store.GetStaleIssues(ctx, filter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get stale issues: %v", err),
}
}
data, _ := json.Marshal(issues)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleStats(req *Request) Response {
store := s.storage

View File

@@ -172,6 +172,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleResolveID(req)
case OpReady:
resp = s.handleReady(req)
case OpStale:
resp = s.handleStale(req)
case OpStats:
resp = s.handleStats(req)
case OpDepAdd:

View File

@@ -731,6 +731,37 @@ func (m *MemoryStorage) GetEpicsEligibleForClosure(ctx context.Context) ([]*type
return nil, nil
}
func (m *MemoryStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
m.mu.RLock()
defer m.mu.RUnlock()
cutoff := time.Now().AddDate(0, 0, -filter.Days)
var stale []*types.Issue
for _, issue := range m.issues {
if issue.Status == types.StatusClosed {
continue
}
if filter.Status != "" && string(issue.Status) != filter.Status {
continue
}
if issue.UpdatedAt.Before(cutoff) {
stale = append(stale, issue)
}
}
// Sort by updated_at ascending (oldest first)
sort.Slice(stale, func(i, j int) bool {
return stale[i].UpdatedAt.Before(stale[j].UpdatedAt)
})
if filter.Limit > 0 && len(stale) > filter.Limit {
stale = stale[:filter.Limit]
}
return stale, nil
}
func (m *MemoryStorage) AddComment(ctx context.Context, issueID, actor, comment string) error {
return nil
}

View File

@@ -106,6 +106,101 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
return s.scanIssues(ctx, rows)
}
// GetStaleIssues returns issues that haven't been updated recently
func (s *SQLiteStorage) GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error) {
// Build query with optional status filter
query := `
SELECT
id, content_hash, title, description, design, acceptance_criteria, notes,
status, priority, issue_type, assignee, estimated_minutes,
created_at, updated_at, closed_at, external_ref,
compaction_level, compacted_at, compacted_at_commit, original_size
FROM issues
WHERE status != 'closed'
AND datetime(updated_at) < datetime('now', '-' || ? || ' days')
`
args := []interface{}{filter.Days}
// Add optional status filter
if filter.Status != "" {
query += " AND status = ?"
args = append(args, filter.Status)
}
query += " ORDER BY updated_at ASC"
// Add limit
if filter.Limit > 0 {
query += " LIMIT ?"
args = append(args, filter.Limit)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query stale issues: %w", err)
}
defer func() { _ = rows.Close() }()
var issues []*types.Issue
for rows.Next() {
var issue types.Issue
var closedAt sql.NullTime
var estimatedMinutes sql.NullInt64
var assignee sql.NullString
var externalRef sql.NullString
var contentHash sql.NullString
var compactionLevel sql.NullInt64
var compactedAt sql.NullTime
var compactedAtCommit sql.NullString
var originalSize sql.NullInt64
err := rows.Scan(
&issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design,
&issue.AcceptanceCriteria, &issue.Notes, &issue.Status,
&issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes,
&issue.CreatedAt, &issue.UpdatedAt, &closedAt, &externalRef,
&compactionLevel, &compactedAt, &compactedAtCommit, &originalSize,
)
if err != nil {
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
}
if contentHash.Valid {
issue.ContentHash = contentHash.String
}
if closedAt.Valid {
issue.ClosedAt = &closedAt.Time
}
if estimatedMinutes.Valid {
mins := int(estimatedMinutes.Int64)
issue.EstimatedMinutes = &mins
}
if assignee.Valid {
issue.Assignee = assignee.String
}
if externalRef.Valid {
issue.ExternalRef = &externalRef.String
}
if compactionLevel.Valid {
issue.CompactionLevel = int(compactionLevel.Int64)
}
if compactedAt.Valid {
issue.CompactedAt = &compactedAt.Time
}
if compactedAtCommit.Valid {
issue.CompactedAtCommit = &compactedAtCommit.String
}
if originalSize.Valid {
issue.OriginalSize = int(originalSize.Int64)
}
issues = append(issues, &issue)
}
return issues, rows.Err()
}
// GetBlockedIssues returns issues that are blocked by dependencies
func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error) {
// Use GROUP_CONCAT to get all blocker IDs in a single query (no N+1)

View File

@@ -38,6 +38,7 @@ type Storage interface {
GetReadyWork(ctx context.Context, filter types.WorkFilter) ([]*types.Issue, error)
GetBlockedIssues(ctx context.Context) ([]*types.BlockedIssue, error)
GetEpicsEligibleForClosure(ctx context.Context) ([]*types.EpicStatus, error)
GetStaleIssues(ctx context.Context, filter types.StaleFilter) ([]*types.Issue, error)
// Events
AddComment(ctx context.Context, issueID, actor, comment string) error

View File

@@ -289,6 +289,13 @@ type WorkFilter struct {
SortPolicy SortPolicy
}
// StaleFilter is used to filter stale issue queries
type StaleFilter struct {
Days int // Issues not updated in this many days
Status string // Filter by status (open|in_progress|blocked), empty = all non-closed
Limit int // Maximum issues to return
}
// EpicStatus represents an epic with its completion status
type EpicStatus struct {
Epic *Issue `json:"epic"`