Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- 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>
330 lines
9.1 KiB
Go
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")
|
|
}
|