fix: preserve pinned field during JSONL import (bd-phtv)
Fixed two code paths where pinned=false from JSONL would overwrite existing pinned=true in database: - importer.go: Only update pinned if explicitly true in JSONL - multirepo.go: Use COALESCE to preserve existing pinned value Added tests for pinned field preservation. Note: Bug may have additional code path - investigation ongoing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -567,8 +567,11 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
|||||||
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
|
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
|
||||||
updates["notes"] = incoming.Notes
|
updates["notes"] = incoming.Notes
|
||||||
updates["closed_at"] = incoming.ClosedAt
|
updates["closed_at"] = incoming.ClosedAt
|
||||||
// Pinned field (bd-7h5)
|
// Pinned field (bd-phtv): Only update if explicitly true in JSONL
|
||||||
updates["pinned"] = incoming.Pinned
|
// (omitempty means false values are absent, so false = don't change existing)
|
||||||
|
if incoming.Pinned {
|
||||||
|
updates["pinned"] = incoming.Pinned
|
||||||
|
}
|
||||||
|
|
||||||
if incoming.Assignee != "" {
|
if incoming.Assignee != "" {
|
||||||
updates["assignee"] = incoming.Assignee
|
updates["assignee"] = incoming.Assignee
|
||||||
@@ -662,8 +665,11 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
|
|||||||
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
|
updates["acceptance_criteria"] = incoming.AcceptanceCriteria
|
||||||
updates["notes"] = incoming.Notes
|
updates["notes"] = incoming.Notes
|
||||||
updates["closed_at"] = incoming.ClosedAt
|
updates["closed_at"] = incoming.ClosedAt
|
||||||
// Pinned field (bd-7h5)
|
// Pinned field (bd-phtv): Only update if explicitly true in JSONL
|
||||||
updates["pinned"] = incoming.Pinned
|
// (omitempty means false values are absent, so false = don't change existing)
|
||||||
|
if incoming.Pinned {
|
||||||
|
updates["pinned"] = incoming.Pinned
|
||||||
|
}
|
||||||
|
|
||||||
if incoming.Assignee != "" {
|
if incoming.Assignee != "" {
|
||||||
updates["assignee"] = incoming.Assignee
|
updates["assignee"] = incoming.Assignee
|
||||||
|
|||||||
@@ -1479,7 +1479,151 @@ func TestImportMixedPrefixMismatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMultiRepoPrefixValidation tests GH#686: multi-repo allows foreign prefixes.
|
// TestImportPreservesPinnedField tests that importing from JSONL (which has omitempty
|
||||||
|
// for the pinned field) does NOT reset an existing pinned=true issue to pinned=false.
|
||||||
|
//
|
||||||
|
// Bug scenario (bd-phtv):
|
||||||
|
// 1. User runs `bd pin <issue-id>` which sets pinned=true in SQLite
|
||||||
|
// 2. Any subsequent bd command (e.g., `bd show`) triggers auto-import from JSONL
|
||||||
|
// 3. JSONL has pinned=false due to omitempty (field absent means false in Go)
|
||||||
|
// 4. Import overwrites pinned=true with pinned=false, losing the pinned state
|
||||||
|
//
|
||||||
|
// Expected: Import should preserve existing pinned=true when incoming pinned=false
|
||||||
|
// (since false just means "field was absent in JSONL due to omitempty").
|
||||||
|
func TestImportPreservesPinnedField(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDB := t.TempDir() + "/test.db"
|
||||||
|
store, err := sqlite.New(context.Background(), tmpDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an issue with pinned=true (simulates `bd pin` command)
|
||||||
|
pinnedIssue := &types.Issue{
|
||||||
|
ID: "test-abc123",
|
||||||
|
Title: "Pinned Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Pinned: true, // This is set by `bd pin`
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
UpdatedAt: time.Now().Add(-time.Hour),
|
||||||
|
}
|
||||||
|
pinnedIssue.ContentHash = pinnedIssue.ComputeContentHash()
|
||||||
|
if err := store.CreateIssue(ctx, pinnedIssue, "test-setup"); err != nil {
|
||||||
|
t.Fatalf("Failed to create pinned issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify issue is pinned before import
|
||||||
|
before, err := store.GetIssue(ctx, "test-abc123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get issue before import: %v", err)
|
||||||
|
}
|
||||||
|
if !before.Pinned {
|
||||||
|
t.Fatal("Issue should be pinned before import")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import same issue from JSONL (simulates auto-import after git pull)
|
||||||
|
// JSONL has pinned=false because omitempty means absent fields are false
|
||||||
|
importedIssue := &types.Issue{
|
||||||
|
ID: "test-abc123",
|
||||||
|
Title: "Pinned Issue", // Same content
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Pinned: false, // This is what JSONL deserialization produces due to omitempty
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
UpdatedAt: time.Now(), // Newer timestamp to trigger update
|
||||||
|
}
|
||||||
|
importedIssue.ContentHash = importedIssue.ComputeContentHash()
|
||||||
|
|
||||||
|
result, err := ImportIssues(ctx, tmpDB, store, []*types.Issue{importedIssue}, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Import failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import should recognize this as an update (same ID, different timestamp)
|
||||||
|
// The unchanged count may vary based on whether other fields changed
|
||||||
|
t.Logf("Import result: Created=%d Updated=%d Unchanged=%d", result.Created, result.Updated, result.Unchanged)
|
||||||
|
|
||||||
|
// CRITICAL: Verify pinned field was preserved
|
||||||
|
after, err := store.GetIssue(ctx, "test-abc123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get issue after import: %v", err)
|
||||||
|
}
|
||||||
|
if !after.Pinned {
|
||||||
|
t.Error("FAIL (bd-phtv): pinned=true was reset to false by import. " +
|
||||||
|
"Import should preserve existing pinned field when incoming is false (omitempty).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportSetsPinnedTrue tests that importing an issue with pinned=true
|
||||||
|
// correctly sets the pinned field in the database.
|
||||||
|
func TestImportSetsPinnedTrue(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tmpDB := t.TempDir() + "/test.db"
|
||||||
|
store, err := sqlite.New(context.Background(), tmpDB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create store: %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil {
|
||||||
|
t.Fatalf("Failed to set prefix: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an unpinned issue
|
||||||
|
unpinnedIssue := &types.Issue{
|
||||||
|
ID: "test-abc123",
|
||||||
|
Title: "Unpinned Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Pinned: false,
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
UpdatedAt: time.Now().Add(-time.Hour),
|
||||||
|
}
|
||||||
|
unpinnedIssue.ContentHash = unpinnedIssue.ComputeContentHash()
|
||||||
|
if err := store.CreateIssue(ctx, unpinnedIssue, "test-setup"); err != nil {
|
||||||
|
t.Fatalf("Failed to create issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import with pinned=true (from JSONL that explicitly has "pinned": true)
|
||||||
|
importedIssue := &types.Issue{
|
||||||
|
ID: "test-abc123",
|
||||||
|
Title: "Unpinned Issue",
|
||||||
|
Status: types.StatusOpen,
|
||||||
|
Priority: 2,
|
||||||
|
IssueType: types.TypeTask,
|
||||||
|
Pinned: true, // Explicitly set to true in JSONL
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
UpdatedAt: time.Now(), // Newer timestamp
|
||||||
|
}
|
||||||
|
importedIssue.ContentHash = importedIssue.ComputeContentHash()
|
||||||
|
|
||||||
|
result, err := ImportIssues(ctx, tmpDB, store, []*types.Issue{importedIssue}, Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Import failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("Import result: Created=%d Updated=%d Unchanged=%d", result.Created, result.Updated, result.Unchanged)
|
||||||
|
|
||||||
|
// Verify pinned field was set to true
|
||||||
|
after, err := store.GetIssue(ctx, "test-abc123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get issue after import: %v", err)
|
||||||
|
}
|
||||||
|
if !after.Pinned {
|
||||||
|
t.Error("FAIL: pinned=true from JSONL should set the field to true in database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMultiRepoPrefixValidation(t *testing.T) {
|
func TestMultiRepoPrefixValidation(t *testing.T) {
|
||||||
if err := config.Initialize(); err != nil {
|
if err := config.Initialize(); err != nil {
|
||||||
t.Fatalf("Failed to initialize config: %v", err)
|
t.Fatalf("Failed to initialize config: %v", err)
|
||||||
|
|||||||
@@ -330,6 +330,9 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
|||||||
}
|
}
|
||||||
|
|
||||||
if existingHash != issue.ContentHash {
|
if existingHash != issue.ContentHash {
|
||||||
|
// 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.
|
||||||
_, err = tx.ExecContext(ctx, `
|
_, err = tx.ExecContext(ctx, `
|
||||||
UPDATE issues SET
|
UPDATE issues SET
|
||||||
content_hash = ?, title = ?, description = ?, design = ?,
|
content_hash = ?, title = ?, description = ?, design = ?,
|
||||||
@@ -337,7 +340,7 @@ func (s *SQLiteStorage) upsertIssueInTx(ctx context.Context, tx *sql.Tx, issue *
|
|||||||
issue_type = ?, assignee = ?, estimated_minutes = ?,
|
issue_type = ?, assignee = ?, estimated_minutes = ?,
|
||||||
updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?,
|
updated_at = ?, closed_at = ?, external_ref = ?, source_repo = ?,
|
||||||
deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?,
|
deleted_at = ?, deleted_by = ?, delete_reason = ?, original_type = ?,
|
||||||
sender = ?, ephemeral = ?, pinned = ?, is_template = ?,
|
sender = ?, ephemeral = ?, pinned = COALESCE(NULLIF(?, 0), pinned), is_template = ?,
|
||||||
await_type = ?, await_id = ?, timeout_ns = ?, waiters = ?
|
await_type = ?, await_id = ?, timeout_ns = ?, waiters = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`,
|
`,
|
||||||
|
|||||||
Reference in New Issue
Block a user