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) { r.Use(auth.RequireProjectAccess("id")) // 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 }