diff --git a/internal/storage/sqlite/multirepo.go b/internal/storage/sqlite/multirepo.go index 74f4890b..4d5b7045 100644 --- a/internal/storage/sqlite/multirepo.go +++ b/internal/storage/sqlite/multirepo.go @@ -333,6 +333,9 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * // Pinned field fix (bd-phtv): Use COALESCE(NULLIF(?, 0), pinned) to preserve // existing pinned=1 when incoming pinned=0 (which means field was absent in // JSONL due to omitempty). This prevents auto-import from resetting pinned issues. + // Gate field fix (bd-gr4q): Same pattern for await_type, await_id, timeout_ns, waiters. + // Gates are wisps and aren't exported to JSONL, so importing an issue with the same + // ID would clear these fields without this protection. _, err = tx.ExecContext(ctx, ` UPDATE issues SET content_hash = ?, title = ?, description = ?, design = ?, @@ -341,7 +344,10 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue * updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?, deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?, sender = ?, ephemeral = ?, pinned = COALESCE(NULLIF(?, 0), pinned), is_template = ?, - await_type = ?, await_id = ?, timeout_ns = ?, waiters = ? + await_type = COALESCE(NULLIF(?, ''), await_type), + await_id = COALESCE(NULLIF(?, ''), await_id), + timeout_ns = COALESCE(NULLIF(?, 0), timeout_ns), + waiters = COALESCE(NULLIF(?, ''), waiters) WHERE id = ? `, issue.ContentHash, issue.Title, issue.Description, issue.Design, diff --git a/internal/storage/sqlite/multirepo_test.go b/internal/storage/sqlite/multirepo_test.go index cbdee8ee..5741fd60 100644 --- a/internal/storage/sqlite/multirepo_test.go +++ b/internal/storage/sqlite/multirepo_test.go @@ -892,3 +892,108 @@ func TestExportToMultiRepo(t *testing.T) { } }) } + +// TestUpsertPreservesGateFields tests that gate await fields are preserved during upsert (bd-gr4q). +// Gates are wisps and aren't exported to JSONL. When an issue with the same ID is imported, +// the await fields should NOT be cleared. +func TestUpsertPreservesGateFields(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + + // Create a gate with await fields directly in the database + gate := &types.Issue{ + ID: "bd-gate1", + Title: "Test Gate", + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeGate, + Wisp: true, + AwaitType: "gh:run", + AwaitID: "123456789", + Timeout: 30 * 60 * 1000000000, // 30 minutes in nanoseconds + Waiters: []string{"beads/dave"}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + gate.ContentHash = gate.ComputeContentHash() + + if err := store.CreateIssue(ctx, gate, "test"); err != nil { + t.Fatalf("failed to create gate: %v", err) + } + + // Verify gate was created with await fields + retrieved, err := store.GetIssue(ctx, gate.ID) + if err != nil || retrieved == nil { + t.Fatalf("failed to get gate: %v", err) + } + if retrieved.AwaitType != "gh:run" { + t.Errorf("expected AwaitType=gh:run, got %q", retrieved.AwaitType) + } + if retrieved.AwaitID != "123456789" { + t.Errorf("expected AwaitID=123456789, got %q", retrieved.AwaitID) + } + + // Create a JSONL file with an issue that has the same ID but no await fields + // (simulating what happens when a non-gate issue is imported) + tmpDir := t.TempDir() + jsonlPath := filepath.Join(tmpDir, "issues.jsonl") + f, err := os.Create(jsonlPath) + if err != nil { + t.Fatalf("failed to create JSONL file: %v", err) + } + + // Same ID, different content (to trigger update), no await fields + incomingIssue := types.Issue{ + ID: "bd-gate1", + Title: "Test Gate Updated", // Different title to trigger update + Status: types.StatusOpen, + Priority: 1, + IssueType: types.TypeGate, + AwaitType: "", // Empty - simulating JSONL without await fields + AwaitID: "", // Empty + Timeout: 0, + Waiters: nil, + CreatedAt: time.Now(), + UpdatedAt: time.Now().Add(time.Second), // Newer timestamp + } + incomingIssue.ContentHash = incomingIssue.ComputeContentHash() + + enc := json.NewEncoder(f) + if err := enc.Encode(incomingIssue); err != nil { + t.Fatalf("failed to encode issue: %v", err) + } + f.Close() + + // Import the JSONL file (this should NOT clear the await fields) + _, err = store.importJSONLFile(ctx, jsonlPath, "test") + if err != nil { + t.Fatalf("importJSONLFile failed: %v", err) + } + + // Verify await fields are preserved + updated, err := store.GetIssue(ctx, gate.ID) + if err != nil || updated == nil { + t.Fatalf("failed to get updated gate: %v", err) + } + + // Title should be updated + if updated.Title != "Test Gate Updated" { + t.Errorf("expected title to be updated, got %q", updated.Title) + } + + // Await fields should be PRESERVED (not cleared) + if updated.AwaitType != "gh:run" { + t.Errorf("AwaitType was cleared! expected 'gh:run', got %q", updated.AwaitType) + } + if updated.AwaitID != "123456789" { + t.Errorf("AwaitID was cleared! expected '123456789', got %q", updated.AwaitID) + } + if updated.Timeout != 30*60*1000000000 { + t.Errorf("Timeout was cleared! expected %d, got %d", 30*60*1000000000, updated.Timeout) + } + if len(updated.Waiters) != 1 || updated.Waiters[0] != "beads/dave" { + t.Errorf("Waiters was cleared! expected [beads/dave], got %v", updated.Waiters) + } +}