Refactor: consolidate ID generation and shared helpers (bd-0702, bd-1445)

- Created ids.go with ValidateIssueIDPrefix, GenerateIssueID, EnsureIDs
- Created issues.go with insertIssue/insertIssues helpers
- Created events_helpers.go with recordCreatedEvent/recordCreatedEvents
- Created dirty_helpers.go with markDirty/markDirtyBatch
- Refactored sqlite.go and batch_ops.go to use new helpers
- Removed duplicate code from hash_ids.go

Amp-Thread-ID: https://ampcode.com/threads/T-b1ab5a16-96de-4e4d-b255-3617055a89eb
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-02 14:49:23 -08:00
parent a488b8a082
commit 2da7487b69
7 changed files with 366 additions and 250 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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