Fix bd-206: Handle status transitions and closed_at constraint

- Updated manageClosedAt to handle both string and types.Status type assertions
- Added equalTime function for comparing timestamps in import change detection
- Added tests for open→closed and closed→open transitions
- Added comment clarifying closed_at is managed automatically

The bug occurred when UpdateIssue received types.Status instead of string,
causing manageClosedAt to skip setting closed_at when status changed to closed.

Amp-Thread-ID: https://ampcode.com/threads/T-ee774f6d-3b90-4311-976d-60c8dd8fe677
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-27 19:14:46 -07:00
parent 299d1c2c21
commit db1458bfed
5 changed files with 175 additions and 6 deletions

View File

@@ -181,16 +181,19 @@ func upsertIssues(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issues
updates["acceptance_criteria"] = issue.AcceptanceCriteria
updates["notes"] = issue.Notes
// bd-206: closed_at is managed automatically by UpdateIssue based on status
// No need to set it explicitly here
if issue.Assignee != "" {
updates["assignee"] = issue.Assignee
updates["assignee"] = issue.Assignee
} else {
updates["assignee"] = nil
updates["assignee"] = nil
}
if issue.ExternalRef != nil && *issue.ExternalRef != "" {
updates["external_ref"] = *issue.ExternalRef
updates["external_ref"] = *issue.ExternalRef
} else {
updates["external_ref"] = nil
updates["external_ref"] = nil
}
// bd-88: Only update if data actually changed (prevents timestamp churn)

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/steveyegge/beads/internal/importer"
"github.com/steveyegge/beads/internal/storage"
@@ -114,6 +115,29 @@ func (fc *fieldComparator) equalPriority(existing int, newVal interface{}) bool
return existing == int(p)
}
// equalTime compares *time.Time field
func (fc *fieldComparator) equalTime(existing *time.Time, newVal interface{}) bool {
switch t := newVal.(type) {
case *time.Time:
if existing == nil && t == nil {
return true
}
if existing == nil || t == nil {
return false
}
return existing.Equal(*t)
case time.Time:
if existing == nil {
return false
}
return existing.Equal(t)
case nil:
return existing == nil
default:
return false
}
}
// checkFieldChanged checks if a specific field has changed
func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue, newVal interface{}) bool {
switch key {
@@ -137,6 +161,8 @@ func (fc *fieldComparator) checkFieldChanged(key string, existing *types.Issue,
return !fc.equalStr(existing.Assignee, newVal)
case "external_ref":
return !fc.equalPtrStr(existing.ExternalRef, newVal)
case "closed_at":
return !fc.equalTime(existing.ClosedAt, newVal)
default:
// Unknown field - treat as changed to be conservative
// This prevents skipping updates when new fields are added

View File

@@ -1156,3 +1156,136 @@ func TestAutoImportClosedAtInvariant(t *testing.T) {
t.Error("Expected closed_at to be set for closed issue")
}
}
// bd-206: Test updating open issue to closed preserves closed_at
func TestImportOpenToClosedTransition(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-open-to-closed-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
ctx := context.Background()
// Initialize database with prefix
if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Step 1: Create an open issue in the database
openIssue := &types.Issue{
ID: "bd-transition-1",
Title: "Test transition",
Description: "This will be closed",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ClosedAt: nil,
}
err = testStore.CreateIssue(ctx, openIssue, "test")
if err != nil {
t.Fatalf("Failed to create open issue: %v", err)
}
// Step 2: Update via UpdateIssue with closed status (closed_at managed automatically)
updates := map[string]interface{}{
"status": types.StatusClosed,
}
err = testStore.UpdateIssue(ctx, "bd-transition-1", updates, "test")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Step 3: Verify the issue is now closed with correct closed_at
updated, err := testStore.GetIssue(ctx, "bd-transition-1")
if err != nil {
t.Fatalf("Failed to get updated issue: %v", err)
}
if updated.Status != types.StatusClosed {
t.Errorf("Expected status to be closed, got %s", updated.Status)
}
if updated.ClosedAt == nil {
t.Fatal("Expected closed_at to be set after transition to closed")
}
}
// bd-206: Test updating closed issue to open clears closed_at
func TestImportClosedToOpenTransition(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-closed-to-open-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
dbPath := filepath.Join(tmpDir, "test.db")
testStore, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("Failed to create storage: %v", err)
}
defer testStore.Close()
ctx := context.Background()
// Initialize database with prefix
if err := testStore.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
t.Fatalf("Failed to set issue_prefix: %v", err)
}
// Step 1: Create a closed issue in the database
closedTime := time.Now()
closedIssue := &types.Issue{
ID: "bd-transition-2",
Title: "Test reopening",
Description: "This will be reopened",
Status: types.StatusClosed,
Priority: 1,
IssueType: types.TypeBug,
CreatedAt: time.Now(),
UpdatedAt: closedTime,
ClosedAt: &closedTime,
}
err = testStore.CreateIssue(ctx, closedIssue, "test")
if err != nil {
t.Fatalf("Failed to create closed issue: %v", err)
}
// Step 2: Update via UpdateIssue with open status (closed_at managed automatically)
updates := map[string]interface{}{
"status": types.StatusOpen,
}
err = testStore.UpdateIssue(ctx, "bd-transition-2", updates, "test")
if err != nil {
t.Fatalf("Update failed: %v", err)
}
// Step 3: Verify the issue is now open with null closed_at
updated, err := testStore.GetIssue(ctx, "bd-transition-2")
if err != nil {
t.Fatalf("Failed to get updated issue: %v", err)
}
if updated.Status != types.StatusOpen {
t.Errorf("Expected status to be open, got %s", updated.Status)
}
if updated.ClosedAt != nil {
t.Errorf("Expected closed_at to be nil after reopening, got %v", updated.ClosedAt)
}
}