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>
458 lines
13 KiB
Go
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
|
|
}
|