All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
153 lines
6.7 KiB
Go
153 lines
6.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/auth"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/sdlc"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// SDLCHandler handles SDLC endpoints for project lifecycle management.
|
|
type SDLCHandler struct {
|
|
sdlcService *service.SDLCService
|
|
}
|
|
|
|
// NewSDLCHandler creates a new SDLC handler.
|
|
func NewSDLCHandler(sdlcService *service.SDLCService) *SDLCHandler {
|
|
return &SDLCHandler{
|
|
sdlcService: sdlcService,
|
|
}
|
|
}
|
|
|
|
// Mount registers all SDLC routes under /projects/{id}/sdlc/.
|
|
func (h *SDLCHandler) Mount(r api.Router) {
|
|
r.Route("/projects/{id}/sdlc", func(r chi.Router) {
|
|
r.Use(auth.RequireProjectAccess("id"))
|
|
|
|
// State (read)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/state", h.GetState)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/next", h.GetNext)
|
|
|
|
// Features - read
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features", h.ListFeatures)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}", h.GetFeature)
|
|
|
|
// Features - write
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features", h.CreateFeature)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/transition", h.TransitionFeature)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/block", h.BlockFeature)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/unblock", h.UnblockFeature)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/features/{slug}", h.DeleteFeature)
|
|
|
|
// Artifacts - read
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}/artifacts", h.GetArtifactStatus)
|
|
|
|
// Artifacts - write
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/approve", h.ApproveArtifact)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/reject", h.RejectArtifact)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/pass", h.PassArtifact)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/fail", h.FailArtifact)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/artifacts/{type}/needs-fix", h.NeedsFixArtifact)
|
|
|
|
// Tasks - read
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}/tasks", h.ListTasks)
|
|
|
|
// Tasks - write
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks", h.AddTask)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks/{taskId}/start", h.StartTask)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
|
|
|
|
// Branches - read
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/features/{slug}/branches", h.GetBranchStatus)
|
|
|
|
// Branches - write
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/branches", h.CreateBranch)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/branches/sync", h.SyncBranch)
|
|
|
|
// Merge / Archive (write)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/merge", h.MergeFeature)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/features/{slug}/archive", h.ArchiveFeature)
|
|
|
|
// Queries (read)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/query/blocked", h.QueryBlocked)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/query/ready", h.QueryReady)
|
|
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/query/needs-approval", h.QueryNeedsApproval)
|
|
})
|
|
}
|
|
|
|
// GetState returns the global SDLC state for a project.
|
|
// GET /projects/{id}/sdlc/state
|
|
func (h *SDLCHandler) GetState(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
|
defer cancel()
|
|
|
|
state, err := h.sdlcService.GetState(ctx, projectID)
|
|
if err != nil {
|
|
writeSDLCError(w, r, err)
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, state)
|
|
}
|
|
|
|
// GetNext returns the classifier's recommendation for the next action.
|
|
// GET /projects/{id}/sdlc/next?feature=slug
|
|
func (h *SDLCHandler) GetNext(w http.ResponseWriter, r *http.Request) {
|
|
projectID := chi.URLParam(r, "id")
|
|
feature := r.URL.Query().Get("feature")
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
|
defer cancel()
|
|
|
|
cl, err := h.sdlcService.GetNext(ctx, projectID, feature)
|
|
if err != nil {
|
|
writeSDLCError(w, r, err)
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, cl)
|
|
}
|
|
|
|
// writeSDLCError maps SDLC domain errors to HTTP responses.
|
|
func writeSDLCError(w http.ResponseWriter, r *http.Request, err error) {
|
|
switch {
|
|
case errors.Is(err, domain.ErrProjectNotFound):
|
|
api.WriteNotFound(w, r, "project not found")
|
|
case errors.Is(err, sdlc.ErrNotInitialized):
|
|
api.WriteNotFound(w, r, "sdlc not initialized for this project")
|
|
case errors.Is(err, sdlc.ErrFeatureNotFound):
|
|
api.WriteNotFound(w, r, "feature not found")
|
|
case errors.Is(err, sdlc.ErrTaskNotFound):
|
|
api.WriteNotFound(w, r, "task not found")
|
|
case errors.Is(err, sdlc.ErrArtifactNotFound):
|
|
api.WriteNotFound(w, r, "artifact not found")
|
|
case errors.Is(err, sdlc.ErrFeatureExists):
|
|
api.WriteBadRequest(w, r, "feature already exists")
|
|
case errors.Is(err, sdlc.ErrInvalidTransition):
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
case errors.Is(err, sdlc.ErrInvalidPhase):
|
|
api.WriteBadRequest(w, r, "invalid phase")
|
|
case errors.Is(err, sdlc.ErrInvalidSlug):
|
|
api.WriteBadRequest(w, r, "invalid slug: must be lowercase alphanumeric with hyphens")
|
|
case errors.Is(err, sdlc.ErrInvalidArtifact):
|
|
api.WriteBadRequest(w, r, "invalid artifact type")
|
|
case errors.Is(err, sdlc.ErrBranchExists):
|
|
api.WriteBadRequest(w, r, "branch already exists")
|
|
case errors.Is(err, sdlc.ErrBranchNotFound):
|
|
api.WriteNotFound(w, r, "branch not found")
|
|
case errors.Is(err, sdlc.ErrMergeNotReady):
|
|
api.WriteBadRequest(w, r, "feature not ready to merge: unmet gates")
|
|
default:
|
|
api.WriteInternalError(w, r, "sdlc operation failed")
|
|
}
|
|
}
|