rdev/internal/adapter/postgres/checkout_repository.go
jordan 9226454b85
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
- Add UndeployAll() using label selectors to clean up monorepo components
  on project deletion (replaces name-based Undeploy in DeleteProject and
  the direct undeploy handler)
- Add ResourceGC background worker that periodically finds K8s resources
  whose project label has no matching DB record, deletes after 1h safety
  window
- Widen deployer client type from *kubernetes.Clientset to
  kubernetes.Interface for testability
- UndeployAll accumulates errors via errors.Join instead of failing fast
- Add checkout/checkin sidecar dev flow: temporary git tokens, branch
  checkout, review on checkin with cleanup workers
- Add interactive sessions: pod binding, command execution, SSE streaming,
  ephemeral preview URLs with session cleanup workers
- Add GET /workers/pool endpoint for aggregate capacity and queue depth
- Add sessions:read and sessions:execute auth scopes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:11:28 -07:00

330 lines
9.1 KiB
Go

// 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")
}