rdev/internal/handlers/builds.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:05:28 -07:00

239 lines
6.8 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"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/pkg/api"
)
// maxRequestBodySize is the maximum allowed size for request bodies (1MB).
const maxRequestBodySize = 1 << 20
// BuildsHandler handles project-scoped build endpoints.
type BuildsHandler struct {
buildService *service.BuildService
}
// NewBuildsHandler creates a new builds handler.
func NewBuildsHandler(buildService *service.BuildService) *BuildsHandler {
return &BuildsHandler{
buildService: buildService,
}
}
// Mount registers the build routes.
func (h *BuildsHandler) Mount(r api.Router) {
// Project-scoped build endpoints
r.With(auth.RequireScope(auth.ScopeBuildWrite, auth.ScopeAdmin)).
Post("/projects/{id}/builds", h.StartBuild)
r.With(auth.RequireScope(auth.ScopeBuildRead, auth.ScopeAdmin)).
Get("/projects/{id}/builds", h.ListBuilds)
// Build detail by task ID
r.With(auth.RequireScope(auth.ScopeBuildRead, auth.ScopeAdmin)).
Get("/builds/{taskId}", h.GetBuild)
}
// StartBuildRequest is the request body for POST /projects/{id}/builds.
type StartBuildRequest struct {
Prompt string `json:"prompt"`
Template string `json:"template,omitempty"`
Variables map[string]string `json:"variables,omitempty"`
AutoCommit bool `json:"auto_commit"`
AutoPush bool `json:"auto_push"`
CallbackURL string `json:"callback_url,omitempty"`
}
// StartBuildResponse is the response for POST /projects/{id}/builds.
type StartBuildResponse struct {
TaskID string `json:"task_id"`
ProjectID string `json:"project_id"`
Status string `json:"status"`
StatusURL string `json:"status_url"`
}
// BuildAuditDTO is the data transfer object for build audit entries.
type BuildAuditDTO struct {
TaskID string `json:"task_id"`
ProjectID string `json:"project_id"`
WorkerID string `json:"worker_id,omitempty"`
Status string `json:"status"`
Prompt string `json:"prompt"`
Template string `json:"template,omitempty"`
AutoCommit bool `json:"auto_commit"`
AutoPush bool `json:"auto_push"`
Result *BuildResultDTO `json:"result,omitempty"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at,omitempty"`
}
// BuildResultDTO is the data transfer object for build results.
type BuildResultDTO struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
DurationMs int64 `json:"duration_ms"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
func toBuildAuditDTO(e *domain.BuildAuditEntry) *BuildAuditDTO {
if e == nil {
return nil
}
dto := &BuildAuditDTO{
TaskID: e.TaskID,
ProjectID: e.ProjectID,
WorkerID: e.WorkerID,
Status: string(e.Status),
Prompt: e.Spec.Prompt,
Template: e.Spec.Template,
AutoCommit: e.Spec.AutoCommit,
AutoPush: e.Spec.AutoPush,
StartedAt: e.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if e.CompletedAt != nil {
dto.CompletedAt = e.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
}
if e.Result != nil {
dto.Result = &BuildResultDTO{
Success: e.Result.Success,
Output: e.Result.Output,
Error: e.Result.Error,
CommitSHA: e.Result.CommitSHA,
FilesChanged: e.Result.FilesChanged,
DurationMs: e.Result.DurationMs,
Artifacts: e.Result.Artifacts,
}
}
return dto
}
// StartBuild enqueues a build task for a project.
// POST /projects/{id}/builds
func (h *BuildsHandler) StartBuild(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req StartBuildRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
// Validate callback URL to prevent SSRF
if req.CallbackURL != "" {
if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
}
spec := domain.BuildSpec{
Prompt: req.Prompt,
Template: req.Template,
Variables: req.Variables,
AutoCommit: req.AutoCommit,
AutoPush: req.AutoPush,
CallbackURL: req.CallbackURL,
}
taskID, err := h.buildService.StartBuild(r.Context(), projectID, spec)
if err != nil {
if errors.Is(err, domain.ErrPromptRequired) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to start build")
return
}
api.WriteCreated(w, r, StartBuildResponse{
TaskID: taskID,
ProjectID: projectID,
Status: "pending",
StatusURL: "/builds/" + taskID,
})
}
// ListBuilds returns build history for a project.
// GET /projects/{id}/builds?limit=50
func (h *BuildsHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
l, err := strconv.Atoi(limitStr)
if err != nil {
api.WriteBadRequest(w, r, "limit must be a valid integer")
return
}
if l < 1 || l > 200 {
api.WriteBadRequest(w, r, "limit must be between 1 and 200")
return
}
limit = l
}
builds, err := h.buildService.ListBuilds(r.Context(), projectID, limit)
if err != nil {
api.WriteInternalError(w, r, "failed to list builds")
return
}
dtos := make([]*BuildAuditDTO, len(builds))
for i, b := range builds {
dtos[i] = toBuildAuditDTO(b)
}
api.WriteSuccess(w, r, map[string]any{
"builds": dtos,
"project_id": projectID,
"total": len(dtos),
})
}
// GetBuild returns the status of a specific build.
// GET /builds/{taskId}
func (h *BuildsHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
entry, err := h.buildService.GetBuildStatus(r.Context(), taskID)
if err != nil {
if errors.Is(err, domain.ErrBuildNotFound) {
api.WriteNotFound(w, r, "build not found: "+taskID)
return
}
api.WriteInternalError(w, r, "failed to get build status")
return
}
api.WriteSuccess(w, r, toBuildAuditDTO(entry))
}