// Package postgres provides PostgreSQL-based implementations of port interfaces. package postgres import ( "context" "database/sql" "errors" "fmt" "strings" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // CheckoutRepository implements port.CheckoutRepository using PostgreSQL. type CheckoutRepository struct { db *sql.DB } // NewCheckoutRepository creates a new PostgreSQL checkout repository. func NewCheckoutRepository(db *sql.DB) *CheckoutRepository { return &CheckoutRepository{db: db} } // Ensure CheckoutRepository implements port.CheckoutRepository at compile time. var _ port.CheckoutRepository = (*CheckoutRepository)(nil) // Create stores a new checkout record. func (r *CheckoutRepository) Create(ctx context.Context, checkout *domain.Checkout) error { var id string err := r.db.QueryRowContext(ctx, ` INSERT INTO checkouts ( project_id, branch, feature_slug, gitea_token_id, gitea_token_name, clone_url, checked_out_by, checked_out_at, expires_at, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id `, string(checkout.ProjectID), checkout.Branch, nullString(checkout.FeatureSlug), checkout.GiteaTokenID, checkout.GiteaTokenName, checkout.CloneURL, checkout.CheckedOutBy, checkout.CheckedOutAt, checkout.ExpiresAt, string(checkout.Status), ).Scan(&id) if err != nil { // Check for unique constraint violation (active checkout exists) if isUniqueViolation(err) { return domain.ErrCheckoutAlreadyExists } return fmt.Errorf("insert checkout: %w", err) } checkout.ID = domain.CheckoutID(id) return nil } // Get retrieves a checkout by ID. func (r *CheckoutRepository) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) { checkout, err := r.scanCheckout(r.db.QueryRowContext(ctx, ` SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name, clone_url, checked_out_by, checked_out_at, expires_at, status, checked_in_at, review_task_id FROM checkouts WHERE id = $1 `, string(id))) if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrCheckoutNotFound } if err != nil { return nil, fmt.Errorf("query checkout: %w", err) } return checkout, nil } // GetByProjectBranch retrieves an active checkout for a project+branch. func (r *CheckoutRepository) GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error) { checkout, err := r.scanCheckout(r.db.QueryRowContext(ctx, ` SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name, clone_url, checked_out_by, checked_out_at, expires_at, status, checked_in_at, review_task_id FROM checkouts WHERE project_id = $1 AND branch = $2 AND status = 'active' `, string(projectID), branch)) if errors.Is(err, sql.ErrNoRows) { return nil, domain.ErrCheckoutNotFound } if err != nil { return nil, fmt.Errorf("query checkout by project+branch: %w", err) } return checkout, nil } // List returns checkouts matching the given options. func (r *CheckoutRepository) List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error) { query := ` SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name, clone_url, checked_out_by, checked_out_at, expires_at, status, checked_in_at, review_task_id FROM checkouts ` var args []any argIdx := 1 if opts.Status != nil { query += fmt.Sprintf(" WHERE status = $%d", argIdx) args = append(args, string(*opts.Status)) argIdx++ } query += " ORDER BY checked_out_at DESC" if opts.Limit > 0 { query += fmt.Sprintf(" LIMIT $%d", argIdx) args = append(args, opts.Limit) argIdx++ } if opts.Offset > 0 { query += fmt.Sprintf(" OFFSET $%d", argIdx) args = append(args, opts.Offset) } rows, err := r.db.QueryContext(ctx, query, args...) if err != nil { return nil, fmt.Errorf("query checkouts: %w", err) } defer func() { _ = rows.Close() }() return r.scanCheckouts(rows) } // ListByProject returns all checkouts for a project. func (r *CheckoutRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name, clone_url, checked_out_by, checked_out_at, expires_at, status, checked_in_at, review_task_id FROM checkouts WHERE project_id = $1 ORDER BY checked_out_at DESC `, string(projectID)) if err != nil { return nil, fmt.Errorf("query checkouts by project: %w", err) } defer func() { _ = rows.Close() }() return r.scanCheckouts(rows) } // UpdateStatus updates the status of a checkout. func (r *CheckoutRepository) UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error { result, err := r.db.ExecContext(ctx, ` UPDATE checkouts SET status = $2 WHERE id = $1 `, string(id), string(status)) if err != nil { return fmt.Errorf("update checkout status: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if rows == 0 { return domain.ErrCheckoutNotFound } return nil } // SetCheckedIn marks a checkout as checked in with timestamp. func (r *CheckoutRepository) SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error { result, err := r.db.ExecContext(ctx, ` UPDATE checkouts SET status = 'checked_in', checked_in_at = NOW(), review_task_id = $2 WHERE id = $1 AND status = 'active' `, string(id), nullString(reviewTaskID)) if err != nil { return fmt.Errorf("set checked in: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if rows == 0 { return domain.ErrCheckoutNotActive } return nil } // SetReviewTask sets the review task ID for a checkout. func (r *CheckoutRepository) SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error { result, err := r.db.ExecContext(ctx, ` UPDATE checkouts SET review_task_id = $2 WHERE id = $1 `, string(id), taskID) if err != nil { return fmt.Errorf("set review task: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if rows == 0 { return domain.ErrCheckoutNotFound } return nil } // CleanupExpired marks expired checkouts and returns their Gitea token IDs for revocation. func (r *CheckoutRepository) CleanupExpired(ctx context.Context) ([]int64, error) { rows, err := r.db.QueryContext(ctx, ` UPDATE checkouts SET status = 'expired' WHERE status = 'active' AND expires_at < NOW() RETURNING gitea_token_id `) if err != nil { return nil, fmt.Errorf("cleanup expired checkouts: %w", err) } defer func() { _ = rows.Close() }() var tokenIDs []int64 for rows.Next() { var tokenID int64 if err := rows.Scan(&tokenID); err != nil { return nil, fmt.Errorf("scan token id: %w", err) } tokenIDs = append(tokenIDs, tokenID) } return tokenIDs, rows.Err() } // checkoutScanner is an interface for scanning checkout rows. // Both sql.Row and sql.Rows implement this via their Scan method. type checkoutScanner interface { Scan(dest ...any) error } // scanCheckoutFields scans checkout fields from a scanner into a Checkout struct. func (r *CheckoutRepository) scanCheckoutFields(scanner checkoutScanner) (*domain.Checkout, error) { var ( checkout domain.Checkout id string projectID string featureSlug sql.NullString status string checkedInAt sql.NullTime reviewTaskID sql.NullString ) err := scanner.Scan( &id, &projectID, &checkout.Branch, &featureSlug, &checkout.GiteaTokenID, &checkout.GiteaTokenName, &checkout.CloneURL, &checkout.CheckedOutBy, &checkout.CheckedOutAt, &checkout.ExpiresAt, &status, &checkedInAt, &reviewTaskID, ) if err != nil { return nil, err } checkout.ID = domain.CheckoutID(id) checkout.ProjectID = domain.ProjectID(projectID) checkout.Status = domain.CheckoutStatus(status) if featureSlug.Valid { checkout.FeatureSlug = featureSlug.String } if checkedInAt.Valid { checkout.CheckedInAt = &checkedInAt.Time } if reviewTaskID.Valid { checkout.ReviewTaskID = reviewTaskID.String } return &checkout, nil } // scanCheckout scans a single row into a Checkout struct. func (r *CheckoutRepository) scanCheckout(row *sql.Row) (*domain.Checkout, error) { return r.scanCheckoutFields(row) } // scanCheckouts scans multiple rows into Checkout structs. func (r *CheckoutRepository) scanCheckouts(rows *sql.Rows) ([]*domain.Checkout, error) { var checkouts []*domain.Checkout for rows.Next() { checkout, err := r.scanCheckoutFields(rows) if err != nil { return nil, fmt.Errorf("scan checkout: %w", err) } checkouts = append(checkouts, checkout) } return checkouts, rows.Err() } // isUniqueViolation checks if the error is a PostgreSQL unique constraint violation. func isUniqueViolation(err error) bool { if err == nil { return false } errStr := err.Error() // PostgreSQL error code for unique_violation is 23505 return strings.Contains(errStr, "unique constraint") || strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "23505") }