package handlers import ( "context" "errors" "log/slog" "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 logger *slog.Logger } // NewSDLCHandler creates a new SDLC handler. func NewSDLCHandler(sdlcService *service.SDLCService, logger *slog.Logger) *SDLCHandler { if logger == nil { logger = slog.Default() } return &SDLCHandler{ sdlcService: sdlcService, logger: logger, } } // 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) { // 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) // 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") } }