Enable daemon RPC support for delete operations to trigger auto-sync, Fix for issue #527 (#528)

Enable daemon RPC support for delete operations to trigger auto-sync.

This PR adds delete operation support to the RPC daemon, ensuring that delete operations emit mutation events and trigger auto-sync like other mutating operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Charles P. Cross
2025-12-12 16:19:31 -05:00
committed by GitHub
parent 454e8f5f9a
commit 8af08460a7
6 changed files with 346 additions and 19 deletions

View File

@@ -293,6 +293,11 @@ func (c *Client) CloseIssue(args *CloseArgs) (*Response, error) {
return c.Execute(OpClose, args)
}
// Delete deletes one or more issues via the daemon.
func (c *Client) Delete(args *DeleteArgs) (*Response, error) {
return c.Execute(OpDelete, args)
}
// List lists issues via the daemon
func (c *Client) List(args *ListArgs) (*Response, error) {
return c.Execute(OpList, args)

View File

@@ -36,6 +36,7 @@ const (
OpEpicStatus = "epic_status"
OpGetMutations = "get_mutations"
OpShutdown = "shutdown"
OpDelete = "delete"
)
// Request represents an RPC request from client to daemon
@@ -97,6 +98,15 @@ type CloseArgs struct {
Reason string `json:"reason,omitempty"`
}
// DeleteArgs represents arguments for the delete operation
type DeleteArgs struct {
IDs []string `json:"ids"` // Issue IDs to delete
Force bool `json:"force,omitempty"` // Force deletion without confirmation
DryRun bool `json:"dry_run,omitempty"` // Preview mode
Cascade bool `json:"cascade,omitempty"` // Recursively delete dependents
Reason string `json:"reason,omitempty"` // Reason for deletion
}
// ListArgs represents arguments for the list operation
type ListArgs struct {
Query string `json:"query,omitempty"`

View File

@@ -409,6 +409,98 @@ func (s *Server) handleClose(req *Request) Response {
}
}
func (s *Server) handleDelete(req *Request) Response {
var deleteArgs DeleteArgs
if err := json.Unmarshal(req.Args, &deleteArgs); err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid delete args: %v", err),
}
}
store := s.storage
if store == nil {
return Response{
Success: false,
Error: "storage not available (global daemon deprecated - use local daemon instead with 'bd daemon' in your project)",
}
}
// Validate that we have issue IDs to delete
if len(deleteArgs.IDs) == 0 {
return Response{
Success: false,
Error: "no issue IDs provided for deletion",
}
}
// DryRun mode: just return what would be deleted
if deleteArgs.DryRun {
data, _ := json.Marshal(map[string]interface{}{
"dry_run": true,
"issue_count": len(deleteArgs.IDs),
"issues": deleteArgs.IDs,
})
return Response{
Success: true,
Data: data,
}
}
ctx := s.reqCtx(req)
deletedCount := 0
errors := make([]string, 0)
// Delete each issue
for _, issueID := range deleteArgs.IDs {
// Verify issue exists before deleting
issue, err := store.GetIssue(ctx, issueID)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", issueID, err))
continue
}
if issue == nil {
errors = append(errors, fmt.Sprintf("%s: not found", issueID))
continue
}
// Delete the issue
if err := store.DeleteIssue(ctx, issueID); err != nil {
errors = append(errors, fmt.Sprintf("%s: %v", issueID, err))
continue
}
// Emit mutation event for event-driven daemon
s.emitMutation(MutationDelete, issueID)
deletedCount++
}
// Build response
result := map[string]interface{}{
"deleted_count": deletedCount,
"total_count": len(deleteArgs.IDs),
}
if len(errors) > 0 {
result["errors"] = errors
if deletedCount == 0 {
// All deletes failed
return Response{
Success: false,
Error: fmt.Sprintf("failed to delete all issues: %v", errors),
}
}
// Partial success
result["partial_success"] = true
}
data, _ := json.Marshal(result)
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 {

View File

@@ -275,3 +275,153 @@ func TestEmitMutation_NonBlocking(t *testing.T) {
t.Errorf("expected at most 100 mutations in buffer, got %d", len(mutations))
}
}
// TestHandleDelete_EmitsMutation verifies that delete operations emit mutation events
// This is a regression test for the issue where delete operations bypass the daemon
// and don't trigger auto-sync. The delete RPC handler should emit MutationDelete events.
func TestHandleDelete_EmitsMutation(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create an issue first
createArgs := CreateArgs{
Title: "Test Issue for Deletion",
IssueType: "bug",
Priority: 1,
}
createJSON, _ := json.Marshal(createArgs)
createReq := &Request{
Operation: OpCreate,
Args: createJSON,
Actor: "test-user",
}
createResp := server.handleCreate(createReq)
if !createResp.Success {
t.Fatalf("failed to create test issue: %s", createResp.Error)
}
// Parse the created issue to get its ID
var createdIssue map[string]interface{}
if err := json.Unmarshal(createResp.Data, &createdIssue); err != nil {
t.Fatalf("failed to parse created issue: %v", err)
}
issueID := createdIssue["id"].(string)
// Clear mutation buffer to isolate delete event
_ = server.GetRecentMutations(time.Now().UnixMilli())
// Now delete the issue via RPC
deleteArgs := DeleteArgs{
IDs: []string{issueID},
Force: true,
Reason: "test deletion",
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("delete operation failed: %s", deleteResp.Error)
}
// Verify mutation event was emitted
mutations := server.GetRecentMutations(0)
if len(mutations) == 0 {
t.Fatal("expected delete mutation event, but no mutations were emitted")
}
// Find the delete mutation
var deleteMutation *MutationEvent
for _, m := range mutations {
if m.Type == MutationDelete && m.IssueID == issueID {
deleteMutation = &m
break
}
}
if deleteMutation == nil {
t.Errorf("expected MutationDelete event for issue %s, but none found in mutations: %+v", issueID, mutations)
}
}
// TestHandleDelete_BatchEmitsMutations verifies batch delete emits mutation for each issue
func TestHandleDelete_BatchEmitsMutations(t *testing.T) {
store := memory.New("/tmp/test.jsonl")
server := NewServer("/tmp/test.sock", store, "/tmp", "/tmp/test.db")
// Create multiple issues
issueIDs := make([]string, 3)
for i := 0; i < 3; i++ {
createArgs := CreateArgs{
Title: "Test Issue " + string(rune('A'+i)),
IssueType: "bug",
Priority: 1,
}
createJSON, _ := json.Marshal(createArgs)
createReq := &Request{
Operation: OpCreate,
Args: createJSON,
Actor: "test-user",
}
createResp := server.handleCreate(createReq)
if !createResp.Success {
t.Fatalf("failed to create test issue %d: %s", i, createResp.Error)
}
var createdIssue map[string]interface{}
if err := json.Unmarshal(createResp.Data, &createdIssue); err != nil {
t.Fatalf("failed to parse created issue %d: %v", i, err)
}
issueIDs[i] = createdIssue["id"].(string)
}
// Clear mutation buffer
_ = server.GetRecentMutations(time.Now().UnixMilli())
// Batch delete all issues
deleteArgs := DeleteArgs{
IDs: issueIDs,
Force: true,
Reason: "batch test deletion",
}
deleteJSON, _ := json.Marshal(deleteArgs)
deleteReq := &Request{
Operation: OpDelete,
Args: deleteJSON,
Actor: "test-user",
}
deleteResp := server.handleDelete(deleteReq)
if !deleteResp.Success {
t.Fatalf("batch delete operation failed: %s", deleteResp.Error)
}
// Verify mutation events were emitted for each deleted issue
mutations := server.GetRecentMutations(0)
deleteMutations := 0
deletedIDs := make(map[string]bool)
for _, m := range mutations {
if m.Type == MutationDelete {
deleteMutations++
deletedIDs[m.IssueID] = true
}
}
if deleteMutations != len(issueIDs) {
t.Errorf("expected %d delete mutations, got %d", len(issueIDs), deleteMutations)
}
// Verify all issue IDs have corresponding mutations
for _, id := range issueIDs {
if !deletedIDs[id] {
t.Errorf("no delete mutation found for issue %s", id)
}
}
}

View File

@@ -176,6 +176,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleUpdate(req)
case OpClose:
resp = s.handleClose(req)
case OpDelete:
resp = s.handleDelete(req)
case OpList:
resp = s.handleList(req)
case OpCount: