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:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user