- Dereference Design, AcceptanceCriteria, Notes, and Assignee pointers in updatesFromArgs - Fixes EOF errors when using --notes, --design, --assignee, or --acceptance-criteria flags - Enhance TestUpdateIssue to verify all pointer-dereferenced fields are correctly stored
579 lines
14 KiB
Go
579 lines
14 KiB
Go
package rpc
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"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),
|
|
}
|
|
}
|
|
if issue == nil {
|
|
return Response{
|
|
Success: false,
|
|
Error: fmt.Sprintf("issue not found: %s", showArgs.ID),
|
|
}
|
|
}
|
|
|
|
// Populate labels, dependencies (with metadata), and dependents (with metadata)
|
|
labels, _ := store.GetLabels(ctx, issue.ID)
|
|
|
|
// Get dependencies and dependents with metadata (including dependency type)
|
|
var deps []*types.IssueWithDependencyMetadata
|
|
var dependents []*types.IssueWithDependencyMetadata
|
|
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
|
|
deps, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
|
|
dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
|
|
} else {
|
|
// Fallback for non-SQLite storage (won't have dependency type metadata)
|
|
regularDeps, _ := store.GetDependencies(ctx, issue.ID)
|
|
for _, d := range regularDeps {
|
|
deps = append(deps, &types.IssueWithDependencyMetadata{
|
|
Issue: *d,
|
|
DependencyType: types.DepBlocks, // default
|
|
})
|
|
}
|
|
regularDependents, _ := store.GetDependents(ctx, issue.ID)
|
|
for _, d := range regularDependents {
|
|
dependents = append(dependents, &types.IssueWithDependencyMetadata{
|
|
Issue: *d,
|
|
DependencyType: types.DepBlocks, // default
|
|
})
|
|
}
|
|
}
|
|
|
|
// Create detailed response with related data
|
|
type IssueDetails struct {
|
|
*types.Issue
|
|
Labels []string `json:"labels,omitempty"`
|
|
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"`
|
|
Dependents []*types.IssueWithDependencyMetadata `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),
|
|
Labels: normalizeLabels(readyArgs.Labels),
|
|
LabelsAny: normalizeLabels(readyArgs.LabelsAny),
|
|
}
|
|
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,
|
|
}
|
|
}
|