rdev/internal/handlers/checkout.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

458 lines
13 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// CheckoutHandler handles checkout/checkin endpoints for sidecar development.
type CheckoutHandler struct {
checkoutService *service.CheckoutService
}
// NewCheckoutHandler creates a new checkout handler.
func NewCheckoutHandler(checkoutService *service.CheckoutService) *CheckoutHandler {
return &CheckoutHandler{checkoutService: checkoutService}
}
// Mount registers the checkout routes.
func (h *CheckoutHandler) Mount(r api.Router) {
r.Route("/projects/{id}/checkout", func(r chi.Router) {
// Branch listing (read access)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/branches", h.ListBranches)
// List active checkouts (read access)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/", h.List)
// Create checkout (execute access - creates tokens)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/", h.Create)
// Get single checkout (read access)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/{checkout_id}", h.Get)
// Checkin (execute access - completes checkout)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/{checkout_id}/checkin", h.Checkin)
// Revoke checkout (admin only - security sensitive)
r.With(auth.RequireScope(auth.ScopeAdmin)).
Delete("/{checkout_id}", h.Revoke)
})
}
// BranchResponse is the JSON response for a branch.
type BranchResponse struct {
Name string `json:"name"`
CommitSHA string `json:"commit_sha"`
Protected bool `json:"protected"`
}
// ListBranches returns all branches for a project's repository.
// GET /projects/{id}/checkout/branches
func (h *CheckoutHandler) ListBranches(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
branches, err := h.checkoutService.ListBranches(ctx, domain.ProjectID(projectID))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
api.WriteInternalError(w, r, "Failed to list branches")
return
}
resp := make([]BranchResponse, len(branches))
for i, b := range branches {
resp[i] = BranchResponse{
Name: b.Name,
CommitSHA: b.CommitSHA,
Protected: b.Protected,
}
}
api.WriteSuccess(w, r, map[string]any{
"branches": resp,
})
}
// CheckoutRequest is the JSON body for creating a checkout.
type CheckoutRequest struct {
Branch string `json:"branch,omitempty"` // Existing branch to checkout
NewBranch string `json:"new_branch,omitempty"` // New branch to create
FromRef string `json:"from_ref,omitempty"` // Reference for new branch (default: main)
FeatureSlug string `json:"feature_slug,omitempty"` // Optional SDLC feature link
ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d")
}
// CheckoutResponse is the JSON response for a checkout.
type CheckoutResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Branch string `json:"branch"`
FeatureSlug string `json:"feature_slug,omitempty"`
CloneURL string `json:"clone_url"`
CheckedOutBy string `json:"checked_out_by"`
CheckedOutAt string `json:"checked_out_at"`
ExpiresAt string `json:"expires_at"`
Status string `json:"status"`
CheckedInAt *string `json:"checked_in_at,omitempty"`
ReviewTaskID string `json:"review_task_id,omitempty"`
Instructions string `json:"instructions,omitempty"`
}
// List returns active checkouts for a project.
// GET /projects/{id}/checkout
func (h *CheckoutHandler) List(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
checkouts, err := h.checkoutService.List(ctx, domain.ProjectID(projectID))
if err != nil {
api.WriteInternalError(w, r, "Failed to list checkouts")
return
}
resp := make([]CheckoutResponse, len(checkouts))
for i, c := range checkouts {
resp[i] = checkoutToResponse(c, "")
}
api.WriteSuccess(w, r, map[string]any{
"checkouts": resp,
})
}
// Create creates a new checkout with a temporary git token.
// POST /projects/{id}/checkout
func (h *CheckoutHandler) Create(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
var req CheckoutRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate: need either branch or new_branch
if req.Branch == "" && req.NewBranch == "" {
api.WriteBadRequest(w, r, "branch or new_branch is required")
return
}
if req.Branch != "" && req.NewBranch != "" {
api.WriteBadRequest(w, r, "specify either branch or new_branch, not both")
return
}
// Validate branch name if provided
if req.NewBranch != "" {
if err := validate.Name(req.NewBranch, "new_branch"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
}
// Parse expiry duration
var expiresIn time.Duration
if req.ExpiresIn != "" {
var err error
expiresIn, err = time.ParseDuration(req.ExpiresIn)
if err != nil {
// Try parsing as days (e.g., "7d")
if len(req.ExpiresIn) > 1 && req.ExpiresIn[len(req.ExpiresIn)-1] == 'd' {
days, parseErr := strconv.Atoi(req.ExpiresIn[:len(req.ExpiresIn)-1])
if parseErr == nil && days > 0 && days <= 30 {
expiresIn = time.Duration(days) * 24 * time.Hour
} else {
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
return
}
} else {
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
return
}
}
// Cap at 30 days
if expiresIn > 30*24*time.Hour {
api.WriteBadRequest(w, r, "expires_in cannot exceed 30 days")
return
}
}
// Get user from API key
checkedOutBy := "unknown"
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil {
checkedOutBy = string(apiKey.ID)
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
result, err := h.checkoutService.Checkout(ctx, service.CheckoutRequest{
ProjectID: domain.ProjectID(projectID),
Branch: req.Branch,
NewBranch: req.NewBranch,
FromRef: req.FromRef,
FeatureSlug: req.FeatureSlug,
ExpiresIn: expiresIn,
CheckedOutBy: checkedOutBy,
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
if errors.Is(err, domain.ErrBranchNotFound) {
api.WriteNotFound(w, r, "branch not found")
return
}
if errors.Is(err, domain.ErrBranchProtected) {
api.WriteBadRequest(w, r, "cannot checkout protected branch")
return
}
if errors.Is(err, domain.ErrCheckoutAlreadyExists) {
api.WriteError(w, r, http.StatusConflict, "CHECKOUT_EXISTS",
"active checkout already exists for this branch")
return
}
api.WriteInternalError(w, r, "Failed to create checkout")
return
}
// Return authenticated URL only at creation time
resp := checkoutToResponse(result.Checkout, result.Instructions)
resp.CloneURL = result.AuthenticatedCloneURL // Override with authenticated URL for creation response
api.WriteCreated(w, r, resp)
}
// Get retrieves a checkout by ID.
// GET /projects/{id}/checkout/{checkout_id}
func (h *CheckoutHandler) Get(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
api.WriteBadRequest(w, r, "checkout_id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteInternalError(w, r, "Failed to get checkout")
return
}
// Verify checkout belongs to project
if string(checkout.ProjectID) != projectID {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteSuccess(w, r, checkoutToResponse(checkout, ""))
}
// CheckinRequest is the JSON body for checking in.
type CheckinRequestBody struct {
SkipReview bool `json:"skip_review,omitempty"` // Skip automatic review
AutoMerge bool `json:"auto_merge,omitempty"` // Merge to main if review passes
}
// CheckinResponse is the JSON response for a checkin.
type CheckinResponse struct {
CheckoutID string `json:"checkout_id"`
Status string `json:"status"`
ReviewTaskID string `json:"review_task_id,omitempty"`
Message string `json:"message"`
}
// Checkin completes a checkout and optionally queues a review.
// POST /projects/{id}/checkout/{checkout_id}/checkin
func (h *CheckoutHandler) Checkin(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
api.WriteBadRequest(w, r, "checkout_id is required")
return
}
var req CheckinRequestBody
if err := api.DecodeJSON(r, &req); err != nil {
// Empty body is OK - use defaults
req = CheckinRequestBody{}
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// First verify checkout exists and belongs to project
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteInternalError(w, r, "Failed to get checkout")
return
}
if string(checkout.ProjectID) != projectID {
api.WriteNotFound(w, r, "checkout not found")
return
}
result, err := h.checkoutService.Checkin(ctx, service.CheckinRequest{
CheckoutID: domain.CheckoutID(checkoutID),
SkipReview: req.SkipReview,
AutoMerge: req.AutoMerge,
})
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
if errors.Is(err, domain.ErrCheckoutNotActive) {
api.WriteBadRequest(w, r, "checkout is not active")
return
}
api.WriteInternalError(w, r, "Failed to checkin")
return
}
message := "Checkout completed. Token has been revoked."
if result.ReviewTaskID != "" {
message = "Checkout completed. Review task queued."
}
api.WriteSuccess(w, r, CheckinResponse{
CheckoutID: string(result.CheckoutID),
Status: string(result.Status),
ReviewTaskID: result.ReviewTaskID,
Message: message,
})
}
// Revoke manually revokes an active checkout.
// DELETE /projects/{id}/checkout/{checkout_id}
func (h *CheckoutHandler) Revoke(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
api.WriteBadRequest(w, r, "checkout_id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// First verify checkout exists and belongs to project
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteInternalError(w, r, "Failed to get checkout")
return
}
if string(checkout.ProjectID) != projectID {
api.WriteNotFound(w, r, "checkout not found")
return
}
if err := h.checkoutService.Revoke(ctx, domain.CheckoutID(checkoutID)); err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
if errors.Is(err, domain.ErrCheckoutNotActive) {
api.WriteBadRequest(w, r, "checkout is not active")
return
}
api.WriteInternalError(w, r, "Failed to revoke checkout")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "revoked",
"id": checkoutID,
"message": "Checkout revoked. Token has been invalidated.",
})
}
// checkoutToResponse converts a domain checkout to a response.
func checkoutToResponse(c *domain.Checkout, instructions string) CheckoutResponse {
resp := CheckoutResponse{
ID: string(c.ID),
ProjectID: string(c.ProjectID),
Branch: c.Branch,
FeatureSlug: c.FeatureSlug,
CloneURL: c.CloneURL,
CheckedOutBy: c.CheckedOutBy,
CheckedOutAt: c.CheckedOutAt.Format(time.RFC3339),
ExpiresAt: c.ExpiresAt.Format(time.RFC3339),
Status: string(c.Status),
ReviewTaskID: c.ReviewTaskID,
Instructions: instructions,
}
if c.CheckedInAt != nil {
s := c.CheckedInAt.Format(time.RFC3339)
resp.CheckedInAt = &s
}
return resp
}