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, `