From 9743b45c6dcca133d4fd24ed57c27ad8da490867 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Wed, 24 Dec 2025 12:45:20 -0800 Subject: [PATCH] fix(import): preserve comment created_at timestamps during import (#735) Comment timestamps were being overwritten with CURRENT_TIMESTAMP during import, causing infinite sync loops between hosts as each import would update timestamps. Added ImportIssueComment() method that accepts and preserves the original timestamp from JSONL, and updated importComments() to use it. Closes #735 --- internal/importer/importer.go | 6 +++- internal/storage/sqlite/comments.go | 48 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/importer/importer.go b/internal/importer/importer.go index 6adb527a..69213a5a 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -6,6 +6,7 @@ import ( "os" "sort" "strings" + "time" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/linear" @@ -892,7 +893,10 @@ func importComments(ctx context.Context, sqliteStore *sqlite.SQLiteStorage, issu for _, comment := range issue.Comments { key := fmt.Sprintf("%s:%s", comment.Author, strings.TrimSpace(comment.Text)) if !existingComments[key] { - if _, err := sqliteStore.AddIssueComment(ctx, issue.ID, comment.Author, comment.Text); err != nil { + // Use ImportIssueComment to preserve original timestamp (GH#735) + // Format timestamp as RFC3339 for SQLite compatibility + createdAt := comment.CreatedAt.UTC().Format(time.RFC3339) + if _, err := sqliteStore.ImportIssueComment(ctx, issue.ID, comment.Author, comment.Text, createdAt); err != nil { if opts.Strict { return fmt.Errorf("error adding comment to %s: %w", issue.ID, err) } diff --git a/internal/storage/sqlite/comments.go b/internal/storage/sqlite/comments.go index 58e5e62d..1cdc48d3 100644 --- a/internal/storage/sqlite/comments.go +++ b/internal/storage/sqlite/comments.go @@ -52,6 +52,54 @@ func (s *SQLiteStorage) AddIssueComment(ctx context.Context, issueID, author, te return comment, nil } +// ImportIssueComment adds a comment during import, preserving the original timestamp. +// Unlike AddIssueComment which uses CURRENT_TIMESTAMP, this method uses the provided +// createdAt time from the JSONL file. This prevents timestamp drift during sync cycles. +// GH#735: Comment created_at timestamps were being overwritten with current time during import. +func (s *SQLiteStorage) ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt string) (*types.Comment, error) { + // Verify issue exists + var exists bool + err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM issues WHERE id = ?)`, issueID).Scan(&exists) + if err != nil { + return nil, fmt.Errorf("failed to check issue existence: %w", err) + } + if !exists { + return nil, fmt.Errorf("issue %s not found", issueID) + } + + // Insert comment with provided timestamp + result, err := s.db.ExecContext(ctx, ` + INSERT INTO comments (issue_id, author, text, created_at) + VALUES (?, ?, ?, ?) + `, issueID, author, text, createdAt) + if err != nil { + return nil, fmt.Errorf("failed to insert comment: %w", err) + } + + // Get the inserted comment ID + commentID, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get comment ID: %w", err) + } + + // Fetch the complete comment + comment := &types.Comment{} + err = s.db.QueryRowContext(ctx, ` + SELECT id, issue_id, author, text, created_at + FROM comments WHERE id = ? + `, commentID).Scan(&comment.ID, &comment.IssueID, &comment.Author, &comment.Text, &comment.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to fetch comment: %w", err) + } + + // Mark issue as dirty for JSONL export + if err := s.MarkIssueDirty(ctx, issueID); err != nil { + return nil, fmt.Errorf("failed to mark issue dirty: %w", err) + } + + return comment, nil +} + // GetIssueComments retrieves all comments for an issue func (s *SQLiteStorage) GetIssueComments(ctx context.Context, issueID string) ([]*types.Comment, error) { rows, err := s.db.QueryContext(ctx, `