Add closed_at timestamp tracking to issues

- Add closed_at field to Issue type with JSON marshaling
- Implement closed_at timestamp in SQLite storage layer
- Update import/export to handle closed_at field
- Add comprehensive tests for closed_at functionality
- Maintain backward compatibility with existing databases

Amp-Thread-ID: https://ampcode.com/threads/T-f3a7799b-f91e-4432-a690-aae0aed364b3
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-15 14:52:29 -07:00
parent ab809c5baf
commit d2b50e6cdc
8 changed files with 290 additions and 11 deletions

View File

@@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS issues (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
closed_at DATETIME,
external_ref TEXT
external_ref TEXT,
CHECK ((status = 'closed') = (closed_at IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);

View File

@@ -67,6 +67,11 @@ func New(path string) (*SQLiteStorage, error) {
return nil, fmt.Errorf("failed to migrate composite indexes: %w", err)
}
// Migrate existing databases to add status/closed_at CHECK constraint
if err := migrateClosedAtConstraint(db); err != nil {
return nil, fmt.Errorf("failed to migrate closed_at constraint: %w", err)
}
return &SQLiteStorage{
db: db,
}, nil
@@ -238,6 +243,53 @@ func migrateCompositeIndexes(db *sql.DB) error {
return nil
}
// migrateClosedAtConstraint cleans up inconsistent status/closed_at data.
// The CHECK constraint is in the schema for new databases, but we can't easily
// add it to existing tables without recreating them. Instead, we clean the data
// and rely on application code (UpdateIssue, import.go) to maintain the invariant.
func migrateClosedAtConstraint(db *sql.DB) error {
// Check if there are any inconsistent rows
var count int
err := db.QueryRow(`
SELECT COUNT(*)
FROM issues
WHERE (CASE WHEN status = 'closed' THEN 1 ELSE 0 END) <>
(CASE WHEN closed_at IS NOT NULL THEN 1 ELSE 0 END)
`).Scan(&count)
if err != nil {
return fmt.Errorf("failed to count inconsistent issues: %w", err)
}
if count == 0 {
// No inconsistent data, nothing to do
return nil
}
// Clean inconsistent data: trust the status field
// Strategy: If status != 'closed' but closed_at is set, clear closed_at
// If status = 'closed' but closed_at is not set, set it to updated_at (best guess)
_, err = db.Exec(`
UPDATE issues
SET closed_at = NULL
WHERE status != 'closed' AND closed_at IS NOT NULL
`)
if err != nil {
return fmt.Errorf("failed to clear closed_at for non-closed issues: %w", err)
}
_, err = db.Exec(`
UPDATE issues
SET closed_at = COALESCE(updated_at, CURRENT_TIMESTAMP)
WHERE status = 'closed' AND closed_at IS NULL
`)
if err != nil {
return fmt.Errorf("failed to set closed_at for closed issues: %w", err)
}
// Migration complete - data is now consistent
return nil
}
// getNextIDForPrefix atomically generates the next ID for a given prefix
// Uses the issue_counters table for atomic, cross-process ID generation
func (s *SQLiteStorage) getNextIDForPrefix(ctx context.Context, prefix string) (int, error) {
@@ -570,6 +622,26 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
setClauses = append(setClauses, fmt.Sprintf("%s = ?", key))
args = append(args, value)
}
// Auto-manage closed_at when status changes (enforce invariant)
if statusVal, ok := updates["status"]; ok {
newStatus := statusVal.(string)
if newStatus == string(types.StatusClosed) {
// Changing to closed: ensure closed_at is set
if _, hasClosedAt := updates["closed_at"]; !hasClosedAt {
now := time.Now()
updates["closed_at"] = now
setClauses = append(setClauses, "closed_at = ?")
args = append(args, now)
}
} else if oldIssue.Status == types.StatusClosed {
// Changing from closed to something else: clear closed_at
updates["closed_at"] = nil
setClauses = append(setClauses, "closed_at = ?")
args = append(args, nil)
}
}
args = append(args, id)
// Start transaction
@@ -604,6 +676,9 @@ func (s *SQLiteStorage) UpdateIssue(ctx context.Context, id string, updates map[
if statusVal, ok := updates["status"]; ok {
if statusVal == string(types.StatusClosed) {
eventType = types.EventClosed
} else if oldIssue.Status == types.StatusClosed {
// Reopening a closed issue
eventType = types.EventReopened
} else {
eventType = types.EventStatusChanged
}

View File

@@ -282,6 +282,125 @@ func TestCloseIssue(t *testing.T) {
}
}
func TestClosedAtInvariant(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("UpdateIssue auto-sets closed_at when closing", func(t *testing.T) {
issue := &types.Issue{
Title: "Test",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
err := store.CreateIssue(ctx, issue, "test-user")
if err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Update to closed without providing closed_at
updates := map[string]interface{}{
"status": string(types.StatusClosed),
}
err = store.UpdateIssue(ctx, issue.ID, updates, "test-user")
if err != nil {
t.Fatalf("UpdateIssue failed: %v", err)
}
// Verify closed_at was auto-set
updated, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if updated.Status != types.StatusClosed {
t.Errorf("Status should be closed, got %v", updated.Status)
}
if updated.ClosedAt == nil {
t.Error("ClosedAt should be auto-set when changing to closed status")
}
})
t.Run("UpdateIssue clears closed_at when reopening", func(t *testing.T) {
issue := &types.Issue{
Title: "Test",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
}
err := store.CreateIssue(ctx, issue, "test-user")
if err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Close the issue
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
// Verify it's closed with closed_at set
closed, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if closed.ClosedAt == nil {
t.Fatal("ClosedAt should be set after closing")
}
// Reopen the issue
updates := map[string]interface{}{
"status": string(types.StatusOpen),
}
err = store.UpdateIssue(ctx, issue.ID, updates, "test-user")
if err != nil {
t.Fatalf("UpdateIssue failed: %v", err)
}
// Verify closed_at was cleared
reopened, err := store.GetIssue(ctx, issue.ID)
if err != nil {
t.Fatalf("GetIssue failed: %v", err)
}
if reopened.Status != types.StatusOpen {
t.Errorf("Status should be open, got %v", reopened.Status)
}
if reopened.ClosedAt != nil {
t.Error("ClosedAt should be cleared when reopening issue")
}
})
t.Run("CreateIssue rejects closed issue without closed_at", func(t *testing.T) {
issue := &types.Issue{
Title: "Test",
Status: types.StatusClosed,
Priority: 2,
IssueType: types.TypeTask,
ClosedAt: nil, // Invalid: closed without closed_at
}
err := store.CreateIssue(ctx, issue, "test-user")
if err == nil {
t.Error("CreateIssue should reject closed issue without closed_at")
}
})
t.Run("CreateIssue rejects open issue with closed_at", func(t *testing.T) {
now := time.Now()
issue := &types.Issue{
Title: "Test",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
ClosedAt: &now, // Invalid: open with closed_at
}
err := store.CreateIssue(ctx, issue, "test-user")
if err == nil {
t.Error("CreateIssue should reject open issue with closed_at")
}
})
}
func TestSearchIssues(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
@@ -292,7 +411,7 @@ func TestSearchIssues(t *testing.T) {
issues := []*types.Issue{
{Title: "Bug in login", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeBug},
{Title: "Feature request", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeFeature},
{Title: "Another bug", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeBug},
{Title: "Another bug", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeBug},
}
for _, issue := range issues {
@@ -300,6 +419,13 @@ func TestSearchIssues(t *testing.T) {
if err != nil {
t.Fatalf("CreateIssue failed: %v", err)
}
// Close the third issue
if issue.Title == "Another bug" {
err = store.CloseIssue(ctx, issue.ID, "Done", "test-user")
if err != nil {
t.Fatalf("CloseIssue failed: %v", err)
}
}
}
// Test query search
@@ -375,7 +501,7 @@ func TestGetStatistics(t *testing.T) {
issues := []*types.Issue{
{Title: "Open task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
{Title: "In progress task", Status: types.StatusInProgress, Priority: 1, IssueType: types.TypeTask},
{Title: "Closed task", Status: types.StatusClosed, Priority: 1, IssueType: types.TypeTask},
{Title: "Closed task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask},
{Title: "Another open task", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask},
}