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