rdev/internal/service/checkout_service.go
jordan 7249575dea
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat(sessions): add command execution endpoint and activity tracking
- Add POST /sessions/:id/exec endpoint for executing commands in sessions
- Add session activity tracking (last_activity_at timestamp)
- Add database migration 024 for session activity column
- Add comprehensive tests for session handlers and service layer
- Add wildcard TLS certificate for preview.threesix.ai subdomain
- Add infrastructure mocks for testing preview service
- Refactor preview cleanup logic to remove unused methods
- Add AIOS core documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-13 08:41:05 -07:00

453 lines
13 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"fmt"
"net/url"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// CheckoutService orchestrates checkout/checkin operations.
// It manages temporary git access tokens for local development.
type CheckoutService struct {
checkoutRepo port.CheckoutRepository
gitRepo port.GitRepository
workQueue port.WorkQueue
projectRepo port.ProjectRepository
// giteaURL is the base Gitea URL for building clone URLs.
giteaURL string
// defaultOwner is the default git org/user for repositories.
defaultOwner string
// defaultExpiry is the default checkout token expiry duration.
defaultExpiry time.Duration
}
// CheckoutServiceConfig holds configuration for the checkout service.
type CheckoutServiceConfig struct {
GiteaURL string
DefaultOwner string
DefaultExpiry time.Duration
}
// NewCheckoutService creates a new checkout service.
func NewCheckoutService(
checkoutRepo port.CheckoutRepository,
gitRepo port.GitRepository,
projectRepo port.ProjectRepository,
cfg CheckoutServiceConfig,
) *CheckoutService {
expiry := cfg.DefaultExpiry
if expiry == 0 {
expiry = 24 * time.Hour
}
return &CheckoutService{
checkoutRepo: checkoutRepo,
gitRepo: gitRepo,
projectRepo: projectRepo,
giteaURL: cfg.GiteaURL,
defaultOwner: cfg.DefaultOwner,
defaultExpiry: expiry,
}
}
// WithWorkQueue adds a work queue for review task enqueueing.
func (s *CheckoutService) WithWorkQueue(queue port.WorkQueue) *CheckoutService {
s.workQueue = queue
return s
}
// CheckoutRequest contains parameters for checking out a project.
type CheckoutRequest struct {
// ProjectID is the project to checkout.
ProjectID domain.ProjectID
// Branch is the branch to checkout. If empty and NewBranch is set, creates NewBranch from main.
Branch string
// NewBranch is an optional new branch name to create.
NewBranch string
// FromRef is the reference to create the new branch from (default: "main").
FromRef string
// FeatureSlug is an optional SDLC feature to link.
FeatureSlug string
// ExpiresIn is the optional token expiry duration (default: 24h).
ExpiresIn time.Duration
// CheckedOutBy is the user/key creating the checkout.
CheckedOutBy string
}
// CheckoutResult contains the result of a checkout operation.
type CheckoutResult struct {
// Checkout is the created checkout record.
Checkout *domain.Checkout
// AuthenticatedCloneURL is the clone URL with embedded token.
// This is only available at creation time and should not be stored.
AuthenticatedCloneURL string
// Instructions are human-readable instructions for using the checkout.
Instructions string
}
// Checkout creates a new checkout session with a temporary git token.
func (s *CheckoutService) Checkout(ctx context.Context, req CheckoutRequest) (*CheckoutResult, error) {
log := logging.FromContext(ctx).WithService("CheckoutService")
// Validate project exists
project, err := s.projectRepo.Get(ctx, req.ProjectID)
if err != nil {
return nil, fmt.Errorf("get project: %w", err)
}
// Determine branch
branch := req.Branch
if req.NewBranch != "" {
branch = req.NewBranch
}
if branch == "" {
return nil, fmt.Errorf("branch or new_branch is required")
}
// Get repo info (owner/name from project)
repoOwner := s.defaultOwner
repoName := string(project.ID)
// Check if branch exists or needs to be created
if req.NewBranch != "" {
fromRef := req.FromRef
if fromRef == "" {
fromRef = "main"
}
log.Info("creating new branch",
logging.FieldProjectID, req.ProjectID,
"branch", req.NewBranch,
"from_ref", fromRef,
)
_, err := s.gitRepo.CreateBranch(ctx, repoOwner, repoName, req.NewBranch, fromRef)
if err != nil {
return nil, fmt.Errorf("create branch: %w", err)
}
} else {
// Verify branch exists
branches, err := s.gitRepo.ListBranches(ctx, repoOwner, repoName)
if err != nil {
return nil, fmt.Errorf("list branches: %w", err)
}
found := false
for _, b := range branches {
if b.Name == branch {
found = true
if b.Protected {
return nil, domain.ErrBranchProtected
}
break
}
}
if !found {
return nil, domain.ErrBranchNotFound
}
}
// Check for existing active checkout on this branch
existing, err := s.checkoutRepo.GetByProjectBranch(ctx, req.ProjectID, branch)
if err == nil && existing != nil {
// Check if expired (cleanup job may not have run)
if existing.IsExpired() {
// Mark as expired and revoke token
_ = s.checkoutRepo.UpdateStatus(ctx, existing.ID, domain.CheckoutStatusExpired)
_ = s.gitRepo.DeleteAccessToken(ctx, existing.GiteaTokenID)
} else {
return nil, domain.ErrCheckoutAlreadyExists
}
}
// Calculate expiry
expiry := s.defaultExpiry
if req.ExpiresIn > 0 {
expiry = req.ExpiresIn
}
expiresAt := time.Now().Add(expiry)
// Create Gitea access token
tokenName := fmt.Sprintf("checkout-%s-%s-%d", repoName, branch, time.Now().Unix())
token, err := s.gitRepo.CreateAccessToken(ctx, tokenName, []string{"write:repository"}, &expiresAt)
if err != nil {
return nil, fmt.Errorf("create access token: %w", err)
}
// Build clone URLs (authenticated for return, base for storage)
authCloneURL, baseCloneURL, err := s.buildCloneURLs(token.Token, repoOwner, repoName)
if err != nil {
// Clean up token on error
_ = s.gitRepo.DeleteAccessToken(ctx, token.ID)
return nil, fmt.Errorf("build clone url: %w", err)
}
// Create checkout record (stores base URL without token)
checkout := &domain.Checkout{
ProjectID: req.ProjectID,
Branch: branch,
FeatureSlug: req.FeatureSlug,
GiteaTokenID: token.ID,
GiteaTokenName: token.Name,
CloneURL: baseCloneURL, // Base URL only - no token
CheckedOutBy: req.CheckedOutBy,
CheckedOutAt: time.Now(),
ExpiresAt: expiresAt,
Status: domain.CheckoutStatusActive,
}
if err := s.checkoutRepo.Create(ctx, checkout); err != nil {
// Clean up token on error
_ = s.gitRepo.DeleteAccessToken(ctx, token.ID)
return nil, fmt.Errorf("create checkout record: %w", err)
}
log.Info("checkout created",
logging.FieldProjectID, req.ProjectID,
"checkout_id", checkout.ID,
"branch", branch,
"expires_at", expiresAt,
)
instructions := fmt.Sprintf(`Clone with:
git clone %s
cd %s
git checkout %s
Work locally, then push and call checkin when ready.
Token expires: %s`, authCloneURL, repoName, branch, expiresAt.Format(time.RFC3339))
return &CheckoutResult{
Checkout: checkout,
AuthenticatedCloneURL: authCloneURL, // Only returned once at creation
Instructions: instructions,
}, nil
}
// CheckinRequest contains parameters for checking in.
type CheckinRequest struct {
// CheckoutID is the checkout to complete.
CheckoutID domain.CheckoutID
// SkipReview skips the automatic review (merge directly if desired).
SkipReview bool
// AutoMerge attempts to merge to main if review passes.
AutoMerge bool
}
// CheckinResult contains the result of a checkin operation.
type CheckinResult struct {
// CheckoutID is the completed checkout ID.
CheckoutID domain.CheckoutID
// ReviewTaskID is the ID of the queued review task (if review requested).
ReviewTaskID string
// Status is the current checkout status.
Status domain.CheckoutStatus
}
// Checkin completes a checkout and optionally queues a review.
func (s *CheckoutService) Checkin(ctx context.Context, req CheckinRequest) (*CheckinResult, error) {
log := logging.FromContext(ctx).WithService("CheckoutService")
// Get checkout
checkout, err := s.checkoutRepo.Get(ctx, req.CheckoutID)
if err != nil {
return nil, err
}
// Verify active
if checkout.Status != domain.CheckoutStatusActive {
return nil, domain.ErrCheckoutNotActive
}
// Revoke token immediately (security: token should not be usable after checkin)
if err := s.gitRepo.DeleteAccessToken(ctx, checkout.GiteaTokenID); err != nil {
log.Warn("failed to revoke checkout token",
"checkout_id", checkout.ID,
"token_id", checkout.GiteaTokenID,
logging.FieldError, err,
)
// Continue anyway - token revocation failure shouldn't block checkin
}
var reviewTaskID string
// Queue review task if requested
if !req.SkipReview && s.workQueue != nil {
task := &domain.WorkTask{
ProjectID: string(checkout.ProjectID),
Type: domain.WorkTaskType("review_checkout"),
Spec: map[string]any{
"checkout_id": string(checkout.ID),
"branch": checkout.Branch,
"feature_slug": checkout.FeatureSlug,
"auto_merge": req.AutoMerge,
},
Priority: 5, // Normal priority
MaxRetries: 1, // Reviews shouldn't retry automatically
}
taskID, err := s.workQueue.Enqueue(ctx, task)
if err != nil {
log.Warn("failed to enqueue review task",
"checkout_id", checkout.ID,
logging.FieldError, err,
)
} else {
reviewTaskID = taskID
log.Info("review task queued",
"checkout_id", checkout.ID,
"task_id", taskID,
)
}
}
// Mark checked in
if err := s.checkoutRepo.SetCheckedIn(ctx, checkout.ID, reviewTaskID); err != nil {
return nil, fmt.Errorf("set checked in: %w", err)
}
log.Info("checkout completed",
logging.FieldProjectID, checkout.ProjectID,
"checkout_id", checkout.ID,
"branch", checkout.Branch,
"review_queued", reviewTaskID != "",
)
return &CheckinResult{
CheckoutID: checkout.ID,
ReviewTaskID: reviewTaskID,
Status: domain.CheckoutStatusCheckedIn,
}, nil
}
// ListBranches returns all branches for a project's repository.
func (s *CheckoutService) ListBranches(ctx context.Context, projectID domain.ProjectID) ([]*domain.GitBranch, error) {
// Get project to verify it exists
project, err := s.projectRepo.Get(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("get project: %w", err)
}
repoOwner := s.defaultOwner
repoName := string(project.ID)
return s.gitRepo.ListBranches(ctx, repoOwner, repoName)
}
// Get retrieves a checkout by ID.
func (s *CheckoutService) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
return s.checkoutRepo.Get(ctx, id)
}
// List returns checkouts for a project.
func (s *CheckoutService) List(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
return s.checkoutRepo.ListByProject(ctx, projectID)
}
// Revoke manually revokes an active checkout.
func (s *CheckoutService) Revoke(ctx context.Context, id domain.CheckoutID) error {
log := logging.FromContext(ctx).WithService("CheckoutService")
checkout, err := s.checkoutRepo.Get(ctx, id)
if err != nil {
return err
}
if checkout.Status != domain.CheckoutStatusActive {
return domain.ErrCheckoutNotActive
}
// Revoke token
if err := s.gitRepo.DeleteAccessToken(ctx, checkout.GiteaTokenID); err != nil {
log.Warn("failed to revoke checkout token",
"checkout_id", checkout.ID,
"token_id", checkout.GiteaTokenID,
logging.FieldError, err,
)
}
// Update status
if err := s.checkoutRepo.UpdateStatus(ctx, id, domain.CheckoutStatusRevoked); err != nil {
return fmt.Errorf("update status: %w", err)
}
log.Info("checkout revoked",
"checkout_id", id,
logging.FieldProjectID, checkout.ProjectID,
)
return nil
}
// CleanupExpired marks expired checkouts and revokes their tokens.
// Returns the number of checkouts cleaned up.
func (s *CheckoutService) CleanupExpired(ctx context.Context) (int, error) {
log := logging.FromContext(ctx).WithService("CheckoutService")
tokenIDs, err := s.checkoutRepo.CleanupExpired(ctx)
if err != nil {
return 0, fmt.Errorf("cleanup expired: %w", err)
}
// Revoke tokens
for _, tokenID := range tokenIDs {
if err := s.gitRepo.DeleteAccessToken(ctx, tokenID); err != nil {
log.Warn("failed to revoke expired token",
"token_id", tokenID,
logging.FieldError, err,
)
}
}
if len(tokenIDs) > 0 {
log.Info("cleaned up expired checkouts", "count", len(tokenIDs))
}
return len(tokenIDs), nil
}
// buildCloneURLs constructs both authenticated and base HTTPS clone URLs.
// Returns (authenticatedURL, baseURL, error).
// The authenticated URL contains the token and should only be shown once.
// The base URL is safe to store and display.
func (s *CheckoutService) buildCloneURLs(token, owner, repo string) (authURL, baseURL string, err error) {
// Parse base URL
u, err := url.Parse(s.giteaURL)
if err != nil {
return "", "", fmt.Errorf("parse gitea url: %w", err)
}
// Build base URL (no token): https://git.threesix.ai/owner/repo.git
u.Path = fmt.Sprintf("/%s/%s.git", owner, repo)
u.RawQuery = ""
u.Fragment = ""
baseURL = u.String()
// Build authenticated URL: https://<token>@git.threesix.ai/owner/repo.git
u.User = url.User(token)
authURL = u.String()
return authURL, baseURL, nil
}