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