rdev/internal/handlers/sdlc.go
jordan 425ef0f806 feat: add SDLC orchestration - library, CLI, and API integration
Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.

Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence

CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods

API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval

Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)

Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:57:05 -07:00

130 lines
4.0 KiB
Go

package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"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
r.Get("/state", h.GetState)
r.Get("/next", h.GetNext)
// Features
r.Get("/features", h.ListFeatures)
r.Post("/features", h.CreateFeature)
r.Get("/features/{slug}", h.GetFeature)
r.Post("/features/{slug}/transition", h.TransitionFeature)
r.Post("/features/{slug}/block", h.BlockFeature)
r.Post("/features/{slug}/unblock", h.UnblockFeature)
r.Delete("/features/{slug}", h.DeleteFeature)
// Artifacts
r.Get("/features/{slug}/artifacts", h.GetArtifactStatus)
r.Post("/features/{slug}/artifacts/{type}/approve", h.ApproveArtifact)
r.Post("/features/{slug}/artifacts/{type}/reject", h.RejectArtifact)
// Tasks
r.Get("/features/{slug}/tasks", h.ListTasks)
r.Post("/features/{slug}/tasks", h.AddTask)
r.Post("/features/{slug}/tasks/{taskId}/start", h.StartTask)
r.Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask)
r.Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask)
// Queries
r.Get("/query/blocked", h.QueryBlocked)
r.Get("/query/ready", h.QueryReady)
r.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")
default:
api.WriteInternalError(w, r, "sdlc operation failed")
}
}