// 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://@git.threesix.ai/owner/repo.git u.User = url.User(token) authURL = u.String() return authURL, baseURL, nil }