Files
beads/internal/rpc/server_issues_epics.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

527 lines
12 KiB
Go

package rpc
import (
"encoding/json"
"fmt"
"strings"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/utils"
)
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
func normalizeLabels(ss []string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(ss))
for _, s := range ss {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
func strValue(p *string) string {
if p == nil {
return ""
}
return *p
}
func updatesFromArgs(a UpdateArgs) map[string]interface{} {
u := map[string]interface{}{}
if a.Title != nil {
u["title"] = *a.Title
}
if a.Description != nil {
u["description"] = *a.Description
}
if a.Status != nil {
u["status"] = *a.Status
}
if a.Priority != nil {
u["priority"] = *a.Priority
}
if a.Design != nil {
u["design"] = a.Design
}
if a.AcceptanceCriteria != nil {
u["acceptance_criteria"] = a.AcceptanceCriteria
}
if a.Notes != nil {
u["notes"] = a.Notes
}
if a.Assignee != nil {
u["assignee"] = a.Assignee
}
return u
}
func (s *Server) handleCreate(req *Request) Response {
var createArgs CreateArgs
if err := json.Unmarshal(req.Args, &createArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid create args: %v", err),
}
}
store := s.storage
var design, acceptance, assignee *string
if createArgs.Design != "" {
design = &createArgs.Design
}
if createArgs.AcceptanceCriteria != "" {
acceptance = &createArgs.AcceptanceCriteria
}
if createArgs.Assignee != "" {
assignee = &createArgs.Assignee
}
issue := &types.Issue{
ID: createArgs.ID,
Title: createArgs.Title,
Description: createArgs.Description,
IssueType: types.IssueType(createArgs.IssueType),
Priority: createArgs.Priority,
Design: strValue(design),
AcceptanceCriteria: strValue(acceptance),
Assignee: strValue(assignee),
Status: types.StatusOpen,
}
ctx := s.reqCtx(req)
if err := store.CreateIssue(ctx, issue, s.reqActor(req)); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to create issue: %v", err),
}
}
// Add labels if specified
for _, label := range createArgs.Labels {
if err := store.AddLabel(ctx, issue.ID, label, s.reqActor(req)); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to add label %s: %v", label, err),
}
}
}
// Add dependencies if specified
for _, depSpec := range createArgs.Dependencies {
depSpec = strings.TrimSpace(depSpec)
if depSpec == "" {
continue
}
var depType types.DependencyType
var dependsOnID string
if strings.Contains(depSpec, ":") {
parts := strings.SplitN(depSpec, ":", 2)
if len(parts) != 2 {
return Response{
Success: false,
Error: fmt.Sprintf("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec),
}
}
depType = types.DependencyType(strings.TrimSpace(parts[0]))
dependsOnID = strings.TrimSpace(parts[1])
} else {
depType = types.DepBlocks
dependsOnID = depSpec
}
if !depType.IsValid() {
return Response{
Success: false,
Error: fmt.Sprintf("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType),
}
}
dep := &types.Dependency{
IssueID: issue.ID,
DependsOnID: dependsOnID,
Type: depType,
}
if err := store.AddDependency(ctx, dep, s.reqActor(req)); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err),
}
}
}
// Emit mutation event for event-driven daemon
s.emitMutation(MutationCreate, issue.ID)
data, _ := json.Marshal(issue)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleUpdate(req *Request) Response {
var updateArgs UpdateArgs
if err := json.Unmarshal(req.Args, &updateArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid update args: %v", err),
}
}
store := s.storage
ctx := s.reqCtx(req)
updates := updatesFromArgs(updateArgs)
if len(updates) == 0 {
return Response{Success: true}
}
if err := store.UpdateIssue(ctx, updateArgs.ID, updates, s.reqActor(req)); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to update issue: %v", err),
}
}
// Emit mutation event for event-driven daemon
s.emitMutation(MutationUpdate, updateArgs.ID)
issue, err := store.GetIssue(ctx, updateArgs.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get updated issue: %v", err),
}
}
data, _ := json.Marshal(issue)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleClose(req *Request) Response {
var closeArgs CloseArgs
if err := json.Unmarshal(req.Args, &closeArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid close args: %v", err),
}
}
store := s.storage
ctx := s.reqCtx(req)
if err := store.CloseIssue(ctx, closeArgs.ID, closeArgs.Reason, s.reqActor(req)); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to close issue: %v", err),
}
}
// Emit mutation event for event-driven daemon
s.emitMutation(MutationUpdate, closeArgs.ID)
issue, _ := store.GetIssue(ctx, closeArgs.ID)
data, _ := json.Marshal(issue)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleList(req *Request) Response {
var listArgs ListArgs
if err := json.Unmarshal(req.Args, &listArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid list args: %v", err),
}
}
store := s.storage
filter := types.IssueFilter{
Limit: listArgs.Limit,
}
if listArgs.Status != "" {
status := types.Status(listArgs.Status)
filter.Status = &status
}
if listArgs.IssueType != "" {
issueType := types.IssueType(listArgs.IssueType)
filter.IssueType = &issueType
}
if listArgs.Assignee != "" {
filter.Assignee = &listArgs.Assignee
}
if listArgs.Priority != nil {
filter.Priority = listArgs.Priority
}
// Normalize and apply label filters
labels := normalizeLabels(listArgs.Labels)
labelsAny := normalizeLabels(listArgs.LabelsAny)
// Support both old single Label and new Labels array
if len(labels) > 0 {
filter.Labels = labels
} else if listArgs.Label != "" {
filter.Labels = []string{strings.TrimSpace(listArgs.Label)}
}
if len(labelsAny) > 0 {
filter.LabelsAny = labelsAny
}
if len(listArgs.IDs) > 0 {
ids := normalizeLabels(listArgs.IDs)
if len(ids) > 0 {
filter.IDs = ids
}
}
// Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000
if len(filter.IDs) > maxIDs {
return Response{
Success: false,
Error: fmt.Sprintf("--id flag supports at most %d issue IDs, got %d", maxIDs, len(filter.IDs)),
}
}
ctx := s.reqCtx(req)
issues, err := store.SearchIssues(ctx, listArgs.Query, filter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to list issues: %v", err),
}
}
// Populate labels for each issue
for _, issue := range issues {
labels, _ := store.GetLabels(ctx, issue.ID)
issue.Labels = labels
}
data, _ := json.Marshal(issues)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleResolveID(req *Request) Response {
var args ResolveIDArgs
if err := json.Unmarshal(req.Args, &args); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid resolve_id args: %v", err),
}
}
ctx := s.reqCtx(req)
resolvedID, err := utils.ResolvePartialID(ctx, s.storage, args.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to resolve ID: %v", err),
}
}
data, _ := json.Marshal(resolvedID)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleShow(req *Request) Response {
var showArgs ShowArgs
if err := json.Unmarshal(req.Args, &showArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid show args: %v", err),
}
}
store := s.storage
ctx := s.reqCtx(req)
issue, err := store.GetIssue(ctx, showArgs.ID)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get issue: %v", err),
}
}
// Populate labels, dependencies, and dependents
labels, _ := store.GetLabels(ctx, issue.ID)
deps, _ := store.GetDependencies(ctx, issue.ID)
dependents, _ := store.GetDependents(ctx, issue.ID)
// Create detailed response with related data
type IssueDetails struct {
*types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
}
details := &IssueDetails{
Issue: issue,
Labels: labels,
Dependencies: deps,
Dependents: dependents,
}
data, _ := json.Marshal(details)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleReady(req *Request) Response {
var readyArgs ReadyArgs
if err := json.Unmarshal(req.Args, &readyArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid ready args: %v", err),
}
}
store := s.storage
wf := types.WorkFilter{
Status: types.StatusOpen,
Priority: readyArgs.Priority,
Limit: readyArgs.Limit,
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
}
if readyArgs.Assignee != "" {
wf.Assignee = &readyArgs.Assignee
}
ctx := s.reqCtx(req)
issues, err := store.GetReadyWork(ctx, wf)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get ready work: %v", err),
}
}
data, _ := json.Marshal(issues)
return Response{
Success: true,
Data: data,
}
}
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
ctx := s.reqCtx(req)
stats, err := store.GetStatistics(ctx)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get statistics: %v", err),
}
}
data, _ := json.Marshal(stats)
return Response{
Success: true,
Data: data,
}
}
func (s *Server) handleEpicStatus(req *Request) Response {
var epicArgs EpicStatusArgs
if err := json.Unmarshal(req.Args, &epicArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid epic status args: %v", err),
}
}
store := s.storage
ctx := s.reqCtx(req)
epics, err := store.GetEpicsEligibleForClosure(ctx)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to get epic status: %v", err),
}
}
if epicArgs.EligibleOnly {
filtered := []*types.EpicStatus{}
for _, epic := range epics {
if epic.EligibleForClose {
filtered = append(filtered, epic)
}
}
epics = filtered
}
data, err := json.Marshal(epics)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("failed to marshal epics: %v", err),
}
}
return Response{
Success: true,
Data: data,
}
}