// 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)) }