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:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user