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 } // Get dependency counts in bulk (single query instead of N queries) issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } depCounts, _ := store.GetDependencyCounts(ctx, issueIDs) // Build response with counts issuesWithCounts := make([]*types.IssueWithCounts, len(issues)) for i, issue := range issues { counts := depCounts[issue.ID] if counts == nil { counts = &types.DependencyCounts{DependencyCount: 0, DependentCount: 0} } issuesWithCounts[i] = &types.IssueWithCounts{ Issue: issue, DependencyCount: counts.DependencyCount, DependentCount: counts.DependentCount, } } data, _ := json.Marshal(issuesWithCounts) 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, } }