diff --git a/internal/storage/sqlite/batch_ops.go b/internal/storage/sqlite/batch_ops.go index 91e993a9..25ea387e 100644 --- a/internal/storage/sqlite/batch_ops.go +++ b/internal/storage/sqlite/batch_ops.go @@ -3,9 +3,7 @@ package sqlite import ( "context" "database/sql" - "encoding/json" "fmt" - "strings" "time" "github.com/steveyegge/beads/internal/types" @@ -46,68 +44,9 @@ func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue return fmt.Errorf("failed to get config: %w", err) } - // Validate explicitly provided IDs and generate IDs for those that need them - expectedPrefix := prefix + "-" - usedIDs := make(map[string]bool) - - // First pass: record explicitly provided IDs - for i := range issues { - if issues[i].ID != "" { - // Validate that explicitly provided ID matches the configured prefix (bd-177) - if !strings.HasPrefix(issues[i].ID, expectedPrefix) { - return fmt.Errorf("issue ID '%s' does not match configured prefix '%s'", issues[i].ID, prefix) - } - usedIDs[issues[i].ID] = true - } - } - - // Second pass: generate IDs for issues that need them - // Hash mode: generate with adaptive length based on database size (bd-ea2a13) - // Get adaptive base length based on current database size - baseLength, err := GetAdaptiveIDLength(ctx, conn, prefix) - if err != nil { - // Fallback to 6 on error - baseLength = 6 - } - - // Try baseLength, baseLength+1, baseLength+2, up to max of 8 - maxLength := 8 - if baseLength > maxLength { - baseLength = maxLength - } - - for i := range issues { - if issues[i].ID == "" { - var generated bool - // Try lengths from baseLength to maxLength with progressive fallback - for length := baseLength; length <= maxLength && !generated; length++ { - for nonce := 0; nonce < 10; nonce++ { - candidate := generateHashID(prefix, issues[i].Title, issues[i].Description, actor, issues[i].CreatedAt, length, nonce) - - // Check if this ID is already used in this batch or in the database - if usedIDs[candidate] { - continue - } - - var count int - err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check for ID collision: %w", err) - } - - if count == 0 { - issues[i].ID = candidate - usedIDs[candidate] = true - generated = true - break - } - } - } - - if !generated { - return fmt.Errorf("failed to generate unique ID for issue %d after trying lengths 6-8 with 10 nonces each", i) - } - } + // Generate or validate IDs for all issues + if err := EnsureIDs(ctx, conn, prefix, issues, actor); err != nil { + return err } // Compute content hashes @@ -119,81 +58,19 @@ func generateBatchIDs(ctx context.Context, conn *sql.Conn, issues []*types.Issue return nil } -// bulkInsertIssues inserts all issues using a prepared statement +// bulkInsertIssues delegates to insertIssues helper func bulkInsertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) error { - stmt, err := conn.PrepareContext(ctx, ` - INSERT INTO issues ( - id, content_hash, title, description, design, acceptance_criteria, notes, - status, priority, issue_type, assignee, estimated_minutes, - created_at, updated_at, closed_at, external_ref - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `) - if err != nil { - return fmt.Errorf("failed to prepare statement: %w", err) - } - defer func() { _ = stmt.Close() }() - - for _, issue := range issues { - _, err = stmt.ExecContext(ctx, - issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, - issue.AcceptanceCriteria, issue.Notes, issue.Status, - issue.Priority, issue.IssueType, issue.Assignee, - issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, - issue.ClosedAt, issue.ExternalRef, - ) - if err != nil { - return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) - } - } - return nil + return insertIssues(ctx, conn, issues) } -// bulkRecordEvents records creation events for all issues +// bulkRecordEvents delegates to recordCreatedEvents helper func bulkRecordEvents(ctx context.Context, conn *sql.Conn, issues []*types.Issue, actor string) error { - stmt, err := conn.PrepareContext(ctx, ` - INSERT INTO events (issue_id, event_type, actor, new_value) - VALUES (?, ?, ?, ?) - `) - if err != nil { - return fmt.Errorf("failed to prepare event statement: %w", err) - } - defer func() { _ = stmt.Close() }() - - for _, issue := range issues { - eventData, err := json.Marshal(issue) - if err != nil { - // Fall back to minimal description if marshaling fails - eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title)) - } - - _, err = stmt.ExecContext(ctx, issue.ID, types.EventCreated, actor, string(eventData)) - if err != nil { - return fmt.Errorf("failed to record event for %s: %w", issue.ID, err) - } - } - return nil + return recordCreatedEvents(ctx, conn, issues, actor) } -// bulkMarkDirty marks all issues as dirty for incremental export +// bulkMarkDirty delegates to markDirtyBatch helper func bulkMarkDirty(ctx context.Context, conn *sql.Conn, issues []*types.Issue) error { - stmt, err := conn.PrepareContext(ctx, ` - INSERT INTO dirty_issues (issue_id, marked_at) - VALUES (?, ?) - ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at - `) - if err != nil { - return fmt.Errorf("failed to prepare dirty statement: %w", err) - } - defer func() { _ = stmt.Close() }() - - dirtyTime := time.Now() - for _, issue := range issues { - _, err = stmt.ExecContext(ctx, issue.ID, dirtyTime) - if err != nil { - return fmt.Errorf("failed to mark dirty %s: %w", issue.ID, err) - } - } - return nil + return markDirtyBatch(ctx, conn, issues) } // CreateIssues creates multiple issues atomically in a single transaction. diff --git a/internal/storage/sqlite/dirty_helpers.go b/internal/storage/sqlite/dirty_helpers.go new file mode 100644 index 00000000..0441dac8 --- /dev/null +++ b/internal/storage/sqlite/dirty_helpers.go @@ -0,0 +1,45 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +// markDirty marks a single issue as dirty for incremental export +func markDirty(ctx context.Context, conn *sql.Conn, issueID string) error { + _, err := conn.ExecContext(ctx, ` + INSERT INTO dirty_issues (issue_id, marked_at) + VALUES (?, ?) + ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at + `, issueID, time.Now()) + if err != nil { + return fmt.Errorf("failed to mark issue dirty: %w", err) + } + return nil +} + +// markDirtyBatch marks multiple issues as dirty for incremental export +func markDirtyBatch(ctx context.Context, conn *sql.Conn, issues []*types.Issue) error { + stmt, err := conn.PrepareContext(ctx, ` + INSERT INTO dirty_issues (issue_id, marked_at) + VALUES (?, ?) + ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at + `) + if err != nil { + return fmt.Errorf("failed to prepare dirty statement: %w", err) + } + defer func() { _ = stmt.Close() }() + + dirtyTime := time.Now() + for _, issue := range issues { + _, err = stmt.ExecContext(ctx, issue.ID, dirtyTime) + if err != nil { + return fmt.Errorf("failed to mark issue %s dirty: %w", issue.ID, err) + } + } + return nil +} diff --git a/internal/storage/sqlite/events_helpers.go b/internal/storage/sqlite/events_helpers.go new file mode 100644 index 00000000..c202eb33 --- /dev/null +++ b/internal/storage/sqlite/events_helpers.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/steveyegge/beads/internal/types" +) + +// recordCreatedEvent records a single creation event for an issue +func recordCreatedEvent(ctx context.Context, conn *sql.Conn, issue *types.Issue, actor string) error { + eventData, err := json.Marshal(issue) + if err != nil { + // Fall back to minimal description if marshaling fails + eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title)) + } + eventDataStr := string(eventData) + + _, err = conn.ExecContext(ctx, ` + INSERT INTO events (issue_id, event_type, actor, new_value) + VALUES (?, ?, ?, ?) + `, issue.ID, types.EventCreated, actor, eventDataStr) + if err != nil { + return fmt.Errorf("failed to record event: %w", err) + } + return nil +} + +// recordCreatedEvents bulk records creation events for multiple issues +func recordCreatedEvents(ctx context.Context, conn *sql.Conn, issues []*types.Issue, actor string) error { + stmt, err := conn.PrepareContext(ctx, ` + INSERT INTO events (issue_id, event_type, actor, new_value) + VALUES (?, ?, ?, ?) + `) + if err != nil { + return fmt.Errorf("failed to prepare event statement: %w", err) + } + defer func() { _ = stmt.Close() }() + + for _, issue := range issues { + eventData, err := json.Marshal(issue) + if err != nil { + // Fall back to minimal description if marshaling fails + eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title)) + } + + _, err = stmt.ExecContext(ctx, issue.ID, types.EventCreated, actor, string(eventData)) + if err != nil { + return fmt.Errorf("failed to record event for %s: %w", issue.ID, err) + } + } + return nil +} diff --git a/internal/storage/sqlite/hash_ids.go b/internal/storage/sqlite/hash_ids.go index b037546f..5afc564a 100644 --- a/internal/storage/sqlite/hash_ids.go +++ b/internal/storage/sqlite/hash_ids.go @@ -2,11 +2,8 @@ package sqlite import ( "context" - "crypto/sha256" - "encoding/hex" "fmt" "strings" - "time" ) // getNextChildNumber atomically increments and returns the next child counter for a parent issue. @@ -57,37 +54,4 @@ func (s *SQLiteStorage) GetNextChildID(ctx context.Context, parentID string) (st return childID, nil } -// generateHashID creates a hash-based ID for a top-level issue. -// For child issues, use the parent ID with a numeric suffix (e.g., "bd-a3f8e9.1"). -// Supports adaptive length from 4-8 chars based on database size (bd-ea2a13). -// Includes a nonce parameter to handle same-length collisions. -func generateHashID(prefix, title, description, creator string, timestamp time.Time, length, nonce int) string { - // Combine inputs into a stable content string - // Include nonce to handle hash collisions - content := fmt.Sprintf("%s|%s|%s|%d|%d", title, description, creator, timestamp.UnixNano(), nonce) - - // Hash the content - hash := sha256.Sum256([]byte(content)) - - // Use variable length (4-8 hex chars) - // length determines how many bytes to use (2, 2.5, 3, 3.5, or 4) - var shortHash string - switch length { - case 4: - shortHash = hex.EncodeToString(hash[:2]) - case 5: - // 2.5 bytes: use 3 bytes but take only first 5 chars - shortHash = hex.EncodeToString(hash[:3])[:5] - case 6: - shortHash = hex.EncodeToString(hash[:3]) - case 7: - // 3.5 bytes: use 4 bytes but take only first 7 chars - shortHash = hex.EncodeToString(hash[:4])[:7] - case 8: - shortHash = hex.EncodeToString(hash[:4]) - default: - shortHash = hex.EncodeToString(hash[:3]) // default to 6 - } - - return fmt.Sprintf("%s-%s", prefix, shortHash) -} +// generateHashID moved to ids.go (bd-0702) diff --git a/internal/storage/sqlite/ids.go b/internal/storage/sqlite/ids.go new file mode 100644 index 00000000..6bf503b9 --- /dev/null +++ b/internal/storage/sqlite/ids.go @@ -0,0 +1,185 @@ +package sqlite + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/steveyegge/beads/internal/types" +) + +// ValidateIssueIDPrefix validates that an issue ID matches the configured prefix +// Supports both top-level (bd-a3f8e9) and hierarchical (bd-a3f8e9.1) IDs +func ValidateIssueIDPrefix(id, prefix string) error { + expectedPrefix := prefix + "-" + if !strings.HasPrefix(id, expectedPrefix) { + return fmt.Errorf("issue ID '%s' does not match configured prefix '%s'", id, prefix) + } + return nil +} + +// GenerateIssueID generates a unique hash-based ID for an issue +// Uses adaptive length based on database size and tries multiple nonces on collision +func GenerateIssueID(ctx context.Context, conn *sql.Conn, prefix string, issue *types.Issue, actor string) (string, error) { + // Get adaptive base length based on current database size + baseLength, err := GetAdaptiveIDLength(ctx, conn, prefix) + if err != nil { + // Fallback to 6 on error + baseLength = 6 + } + + // Try baseLength, baseLength+1, baseLength+2, up to max of 8 + maxLength := 8 + if baseLength > maxLength { + baseLength = maxLength + } + + for length := baseLength; length <= maxLength; length++ { + // Try up to 10 nonces at each length + for nonce := 0; nonce < 10; nonce++ { + candidate := generateHashID(prefix, issue.Title, issue.Description, actor, issue.CreatedAt, length, nonce) + + // Check if this ID already exists + var count int + err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count) + if err != nil { + return "", fmt.Errorf("failed to check for ID collision: %w", err) + } + + if count == 0 { + return candidate, nil + } + } + } + + return "", fmt.Errorf("failed to generate unique ID after trying lengths %d-%d with 10 nonces each", baseLength, maxLength) +} + +// GenerateBatchIssueIDs generates unique IDs for multiple issues in a single batch +// Tracks used IDs to prevent intra-batch collisions +func GenerateBatchIssueIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*types.Issue, actor string, usedIDs map[string]bool) error { + // Get adaptive base length based on current database size + baseLength, err := GetAdaptiveIDLength(ctx, conn, prefix) + if err != nil { + // Fallback to 6 on error + baseLength = 6 + } + + // Try baseLength, baseLength+1, baseLength+2, up to max of 8 + maxLength := 8 + if baseLength > maxLength { + baseLength = maxLength + } + + for i := range issues { + if issues[i].ID == "" { + var generated bool + // Try lengths from baseLength to maxLength with progressive fallback + for length := baseLength; length <= maxLength && !generated; length++ { + for nonce := 0; nonce < 10; nonce++ { + candidate := generateHashID(prefix, issues[i].Title, issues[i].Description, actor, issues[i].CreatedAt, length, nonce) + + // Check if this ID is already used in this batch or in the database + if usedIDs[candidate] { + continue + } + + var count int + err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check for ID collision: %w", err) + } + + if count == 0 { + issues[i].ID = candidate + usedIDs[candidate] = true + generated = true + break + } + } + } + + if !generated { + return fmt.Errorf("failed to generate unique ID for issue %d after trying lengths %d-%d with 10 nonces each", i, baseLength, maxLength) + } + } + } + return nil +} + +// EnsureIDs generates or validates IDs for issues +// For issues with empty IDs, generates unique hash-based IDs +// For issues with existing IDs, validates they match the prefix and parent exists (if hierarchical) +func EnsureIDs(ctx context.Context, conn *sql.Conn, prefix string, issues []*types.Issue, actor string) error { + usedIDs := make(map[string]bool) + + // First pass: record explicitly provided IDs + for i := range issues { + if issues[i].ID != "" { + // Validate that explicitly provided ID matches the configured prefix (bd-177) + if err := ValidateIssueIDPrefix(issues[i].ID, prefix); err != nil { + return err + } + + // For hierarchical IDs (bd-a3f8e9.1), validate parent exists + if strings.Contains(issues[i].ID, ".") { + // Extract parent ID (everything before the last dot) + lastDot := strings.LastIndex(issues[i].ID, ".") + parentID := issues[i].ID[:lastDot] + + var parentCount int + err := conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, parentID).Scan(&parentCount) + if err != nil { + return fmt.Errorf("failed to check parent existence: %w", err) + } + if parentCount == 0 { + return fmt.Errorf("parent issue %s does not exist", parentID) + } + } + + usedIDs[issues[i].ID] = true + } + } + + // Second pass: generate IDs for issues that need them + return GenerateBatchIssueIDs(ctx, conn, prefix, issues, actor, usedIDs) +} + +// generateHashID creates a hash-based ID for a top-level issue. +// For child issues, use the parent ID with a numeric suffix (e.g., "bd-a3f8e9.1"). +// Supports adaptive length from 4-8 chars based on database size (bd-ea2a13). +// Includes a nonce parameter to handle same-length collisions. +func generateHashID(prefix, title, description, creator string, timestamp time.Time, length, nonce int) string { + // Combine inputs into a stable content string + // Include nonce to handle hash collisions + content := fmt.Sprintf("%s|%s|%s|%d|%d", title, description, creator, timestamp.UnixNano(), nonce) + + // Hash the content + hash := sha256.Sum256([]byte(content)) + + // Use variable length (4-8 hex chars) + // length determines how many bytes to use (2, 2.5, 3, 3.5, or 4) + var shortHash string + switch length { + case 4: + shortHash = hex.EncodeToString(hash[:2]) + case 5: + // 2.5 bytes: use 3 bytes but take only first 5 chars + shortHash = hex.EncodeToString(hash[:3])[:5] + case 6: + shortHash = hex.EncodeToString(hash[:3]) + case 7: + // 3.5 bytes: use 4 bytes but take only first 7 chars + shortHash = hex.EncodeToString(hash[:4])[:7] + case 8: + shortHash = hex.EncodeToString(hash[:4]) + default: + shortHash = hex.EncodeToString(hash[:3]) // default to 6 + } + + return fmt.Sprintf("%s-%s", prefix, shortHash) +} diff --git a/internal/storage/sqlite/issues.go b/internal/storage/sqlite/issues.go new file mode 100644 index 00000000..2fc377f3 --- /dev/null +++ b/internal/storage/sqlite/issues.go @@ -0,0 +1,59 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + + "github.com/steveyegge/beads/internal/types" +) + +// insertIssue inserts a single issue into the database +func insertIssue(ctx context.Context, conn *sql.Conn, issue *types.Issue) error { + _, err := conn.ExecContext(ctx, ` + INSERT INTO issues ( + id, content_hash, title, description, design, acceptance_criteria, notes, + status, priority, issue_type, assignee, estimated_minutes, + created_at, updated_at, closed_at, external_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, + issue.AcceptanceCriteria, issue.Notes, issue.Status, + issue.Priority, issue.IssueType, issue.Assignee, + issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, + issue.ClosedAt, issue.ExternalRef, + ) + if err != nil { + return fmt.Errorf("failed to insert issue: %w", err) + } + return nil +} + +// insertIssues bulk inserts multiple issues using a prepared statement +func insertIssues(ctx context.Context, conn *sql.Conn, issues []*types.Issue) error { + stmt, err := conn.PrepareContext(ctx, ` + INSERT INTO issues ( + id, content_hash, title, description, design, acceptance_criteria, notes, + status, priority, issue_type, assignee, estimated_minutes, + created_at, updated_at, closed_at, external_ref + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer func() { _ = stmt.Close() }() + + for _, issue := range issues { + _, err = stmt.ExecContext(ctx, + issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, + issue.AcceptanceCriteria, issue.Notes, issue.Status, + issue.Priority, issue.IssueType, issue.Assignee, + issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, + issue.ClosedAt, issue.ExternalRef, + ) + if err != nil { + return fmt.Errorf("failed to insert issue %s: %w", issue.ID, err) + } + } + return nil +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go index 17bac9f6..25c57bba 100644 --- a/internal/storage/sqlite/sqlite.go +++ b/internal/storage/sqlite/sqlite.go @@ -159,59 +159,18 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act return fmt.Errorf("failed to get config: %w", err) } - // Generate ID if not set (inside transaction to prevent race conditions) + // Generate or validate ID if issue.ID == "" { // Generate hash-based ID with adaptive length based on database size (bd-ea2a13) - // Start with length determined by database size, expand on collision - var err error - - // Get adaptive base length based on current database size - baseLength, err := GetAdaptiveIDLength(ctx, conn, prefix) + generatedID, err := GenerateIssueID(ctx, conn, prefix, issue, actor) if err != nil { - // Fallback to 6 on error - baseLength = 6 - } - - // Try baseLength, baseLength+1, baseLength+2, up to max of 8 - maxLength := 8 - if baseLength > maxLength { - baseLength = maxLength - } - - for length := baseLength; length <= maxLength; length++ { - // Try up to 10 nonces at each length - for nonce := 0; nonce < 10; nonce++ { - candidate := generateHashID(prefix, issue.Title, issue.Description, actor, issue.CreatedAt, length, nonce) - - // Check if this ID already exists - var count int - err = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM issues WHERE id = ?`, candidate).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check for ID collision: %w", err) - } - - if count == 0 { - issue.ID = candidate - break - } - } - - // If we found a unique ID, stop trying longer lengths - if issue.ID != "" { - break - } - } - - if issue.ID == "" { - return fmt.Errorf("failed to generate unique ID after trying lengths %d-%d with 10 nonces each", baseLength, maxLength) + return err } + issue.ID = generatedID } else { // Validate that explicitly provided ID matches the configured prefix (bd-177) - // This prevents wrong-prefix bugs when IDs are manually specified - // Support both top-level (bd-a3f8e9) and hierarchical (bd-a3f8e9.1) IDs - expectedPrefix := prefix + "-" - if !strings.HasPrefix(issue.ID, expectedPrefix) { - return fmt.Errorf("issue ID '%s' does not match configured prefix '%s'", issue.ID, prefix) + if err := ValidateIssueIDPrefix(issue.ID, prefix); err != nil { + return err } // For hierarchical IDs (bd-a3f8e9.1), validate parent exists @@ -232,46 +191,18 @@ func (s *SQLiteStorage) CreateIssue(ctx context.Context, issue *types.Issue, act } // Insert issue - _, err = conn.ExecContext(ctx, ` - INSERT INTO issues ( - id, content_hash, title, description, design, acceptance_criteria, notes, - status, priority, issue_type, assignee, estimated_minutes, - created_at, updated_at, closed_at, external_ref - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - issue.ID, issue.ContentHash, issue.Title, issue.Description, issue.Design, - issue.AcceptanceCriteria, issue.Notes, issue.Status, - issue.Priority, issue.IssueType, issue.Assignee, - issue.EstimatedMinutes, issue.CreatedAt, issue.UpdatedAt, - issue.ClosedAt, issue.ExternalRef, - ) - if err != nil { - return fmt.Errorf("failed to insert issue: %w", err) + if err := insertIssue(ctx, conn, issue); err != nil { + return err } // Record creation event - eventData, err := json.Marshal(issue) - if err != nil { - // Fall back to minimal description if marshaling fails - eventData = []byte(fmt.Sprintf(`{"id":"%s","title":"%s"}`, issue.ID, issue.Title)) - } - eventDataStr := string(eventData) - _, err = conn.ExecContext(ctx, ` - INSERT INTO events (issue_id, event_type, actor, new_value) - VALUES (?, ?, ?, ?) - `, issue.ID, types.EventCreated, actor, eventDataStr) - if err != nil { - return fmt.Errorf("failed to record event: %w", err) + if err := recordCreatedEvent(ctx, conn, issue, actor); err != nil { + return err } // Mark issue as dirty for incremental export - _, err = conn.ExecContext(ctx, ` - INSERT INTO dirty_issues (issue_id, marked_at) - VALUES (?, ?) - ON CONFLICT (issue_id) DO UPDATE SET marked_at = excluded.marked_at - `, issue.ID, time.Now()) - if err != nil { - return fmt.Errorf("failed to mark issue dirty: %w", err) + if err := markDirty(ctx, conn, issue.ID); err != nil { + return err } // Commit the transaction