diff --git a/internal/storage/dolt/dependencies.go b/internal/storage/dolt/dependencies.go index f469c86b..ac6f46c1 100644 --- a/internal/storage/dolt/dependencies.go +++ b/internal/storage/dolt/dependencies.go @@ -523,6 +523,7 @@ func (s *DoltStore) GetIssuesByIDs(ctx context.Context, ids []string) ([]*types. // scanIssueRow scans a single issue from a rows result func scanIssueRow(rows *sql.Rows) (*types.Issue, error) { var issue types.Issue + var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime var estimatedMinutes, originalSize, timeoutNs sql.NullInt64 var assignee, externalRef, compactedAtCommit, owner sql.NullString @@ -538,7 +539,7 @@ func scanIssueRow(rows *sql.Rows) (*types.Issue, error) { &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &ephemeral, &pinned, &isTemplate, &crystallizes, @@ -551,6 +552,14 @@ func scanIssueRow(rows *sql.Rows) (*types.Issue, error) { return nil, fmt.Errorf("failed to scan issue row: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + // Map nullable fields if contentHash.Valid { issue.ContentHash = contentHash.String diff --git a/internal/storage/dolt/history.go b/internal/storage/dolt/history.go index e1dbdb7f..bf558d53 100644 --- a/internal/storage/dolt/history.go +++ b/internal/storage/dolt/history.go @@ -74,6 +74,7 @@ func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*Iss for rows.Next() { var h IssueHistory var issue types.Issue + var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually var closedAt sql.NullTime var assignee, owner, createdBy, closeReason, molType sql.NullString var estimatedMinutes sql.NullInt64 @@ -82,13 +83,21 @@ func (s *DoltStore) GetIssueHistory(ctx context.Context, issueID string) ([]*Iss if err := rows.Scan( &issue.ID, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &owner, &createdBy, - &estimatedMinutes, &issue.CreatedAt, &issue.UpdatedAt, &closedAt, &closeReason, + &estimatedMinutes, &createdAtStr, &updatedAtStr, &closedAt, &closeReason, &pinned, &molType, &h.CommitHash, &h.Committer, &h.CommitDate, ); err != nil { return nil, fmt.Errorf("failed to scan history: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if closedAt.Valid { issue.ClosedAt = &closedAt.Time } @@ -130,6 +139,7 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string } var issue types.Issue + var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually var closedAt sql.NullTime var assignee, owner, contentHash sql.NullString var estimatedMinutes sql.NullInt64 @@ -144,7 +154,7 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string err := s.db.QueryRowContext(ctx, query, issueID).Scan( &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, ) if err == sql.ErrNoRows { @@ -154,6 +164,14 @@ func (s *DoltStore) GetIssueAsOf(ctx context.Context, issueID string, ref string return nil, fmt.Errorf("failed to get issue as of %s: %w", ref, err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } diff --git a/internal/storage/dolt/issues.go b/internal/storage/dolt/issues.go index dc313943..e911973a 100644 --- a/internal/storage/dolt/issues.go +++ b/internal/storage/dolt/issues.go @@ -410,6 +410,7 @@ func insertIssue(ctx context.Context, tx *sql.Tx, issue *types.Issue) error { func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) { var issue types.Issue + var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually var closedAt, compactedAt, deletedAt, lastActivity, dueAt, deferUntil sql.NullTime var estimatedMinutes, originalSize, timeoutNs sql.NullInt64 var assignee, externalRef, compactedAtCommit, owner sql.NullString @@ -439,7 +440,7 @@ func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, &externalRef, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &externalRef, &issue.CompactionLevel, &compactedAt, &compactedAtCommit, &originalSize, &sourceRepo, &closeReason, &deletedAt, &deletedBy, &deleteReason, &originalType, &sender, &ephemeral, &pinned, &isTemplate, &crystallizes, @@ -457,6 +458,14 @@ func scanIssue(ctx context.Context, db *sql.DB, id string) (*types.Issue, error) return nil, fmt.Errorf("failed to get issue: %w", err) } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + // Map nullable fields if contentHash.Valid { issue.ContentHash = contentHash.String diff --git a/internal/storage/dolt/transaction.go b/internal/storage/dolt/transaction.go index 93c3df23..e2806413 100644 --- a/internal/storage/dolt/transaction.go +++ b/internal/storage/dolt/transaction.go @@ -376,6 +376,7 @@ func insertIssueTx(ctx context.Context, tx *sql.Tx, issue *types.Issue) error { func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, error) { var issue types.Issue + var createdAtStr, updatedAtStr sql.NullString // TEXT columns - must parse manually var closedAt sql.NullTime var estimatedMinutes sql.NullInt64 var assignee, owner, contentHash sql.NullString @@ -392,7 +393,7 @@ func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, erro &issue.ID, &contentHash, &issue.Title, &issue.Description, &issue.Design, &issue.AcceptanceCriteria, &issue.Notes, &issue.Status, &issue.Priority, &issue.IssueType, &assignee, &estimatedMinutes, - &issue.CreatedAt, &issue.CreatedBy, &owner, &issue.UpdatedAt, &closedAt, + &createdAtStr, &issue.CreatedBy, &owner, &updatedAtStr, &closedAt, &ephemeral, &pinned, &isTemplate, &crystallizes, ) @@ -403,6 +404,14 @@ func scanIssueTx(ctx context.Context, tx *sql.Tx, id string) (*types.Issue, erro return nil, err } + // Parse timestamp strings (TEXT columns require manual parsing) + if createdAtStr.Valid { + issue.CreatedAt = parseTimeString(createdAtStr.String) + } + if updatedAtStr.Valid { + issue.UpdatedAt = parseTimeString(updatedAtStr.String) + } + if contentHash.Valid { issue.ContentHash = contentHash.String } diff --git a/internal/storage/dolt/util.go b/internal/storage/dolt/util.go new file mode 100644 index 00000000..06387e77 --- /dev/null +++ b/internal/storage/dolt/util.go @@ -0,0 +1,38 @@ +package dolt + +import ( + "database/sql" + "time" +) + +// parseTimeString parses a time string from database TEXT columns (non-nullable). +// Used for required timestamp fields like created_at/updated_at when stored as TEXT. +// Returns zero time if parsing fails, which maintains backwards compatibility. +func parseTimeString(s string) time.Time { + if s == "" { + return time.Time{} + } + // Try RFC3339Nano first (more precise), then RFC3339, then SQLite format + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, s); err == nil { + return t + } + } + return time.Time{} // Unparseable - shouldn't happen with valid data +} + +// parseNullableTimeString parses a nullable time string from database TEXT columns. +// For columns declared as TEXT (not DATETIME), we must parse manually. +// Supports RFC3339, RFC3339Nano, and SQLite's native format. +func parseNullableTimeString(ns sql.NullString) *time.Time { + if !ns.Valid || ns.String == "" { + return nil + } + // Try RFC3339Nano first (more precise), then RFC3339, then SQLite format + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, ns.String); err == nil { + return &t + } + } + return nil // Unparseable - shouldn't happen with valid data +}