fix(multirepo): Preserve gate await fields during upsert (bd-gr4q)

Apply COALESCE(NULLIF(...)) pattern to await_type, await_id, timeout_ns,
and waiters fields in upsertIssueInTx. This prevents gate await fields
from being cleared when importing issues from JSONL that don't have
these fields (since gates are wisps and aren't exported to JSONL.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
EOF
)
This commit is contained in:
Steve Yegge
2025-12-25 23:20:47 -08:00
parent 5788f90aa1
commit 7fbc2766e2
2 changed files with 112 additions and 1 deletions

View File

@@ -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,

View File

@@ -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)
}
}