When using `bd list --json`, each issue now includes: - `dependency_count`: Number of issues this issue depends on - `dependent_count`: Number of issues that depend on this issue This provides quick access to dependency relationship counts without needing to fetch full dependency lists or run multiple bd show commands. Performance: - Uses single bulk query (GetDependencyCounts) instead of N individual queries - Overhead: ~26% for 500 issues (24ms vs 19ms baseline) - Avoids N+1 query problem that would have caused 2.2x slowdown Implementation: - Added GetDependencyCounts() to Storage interface for bulk counting - Efficient SQLite query using UNION ALL + GROUP BY - Memory storage implementation for testing - Moved IssueWithCounts to types package to avoid duplication - Both RPC and direct modes use optimized bulk query Tests: - Added comprehensive tests for GetDependencyCounts - Tests cover: normal operation, empty list, nonexistent IDs - All existing tests continue to pass Backwards compatible: JSON structure is additive, all original fields preserved. Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
548 lines
12 KiB
Go
548 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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|