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>
254 lines
7.9 KiB
Go
254 lines
7.9 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"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), auth.RequireProjectAccess("id")).
|
|
Post("/projects/{id}/builds", h.StartBuild)
|
|
r.With(auth.RequireScope(auth.ScopeBuildRead, auth.ScopeAdmin), auth.RequireProjectAccess("id")).
|
|
Get("/projects/{id}/builds", h.ListBuilds)
|
|
|
|
// Build detail by task ID (no project ID in URL, no project access check needed)
|
|
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"`
|
|
GitCloneURL string `json:"git_clone_url,omitempty"` // Required when auto_commit or auto_push is true
|
|
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 0 = default (10m), valid range: 60-5400
|
|
}
|
|
|
|
// 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"`
|
|
StreamURL string `json:"stream_url"` // SSE endpoint for real-time build events
|
|
}
|
|
|
|
// 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"`
|
|
StreamURL string `json:"stream_url"` // SSE endpoint for real-time build events
|
|
}
|
|
|
|
// 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"`
|
|
ErrorCode string `json:"error_code,omitempty"` // Categorized error type for programmatic handling
|
|
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"),
|
|
StreamURL: "/projects/" + e.ProjectID + "/events?stream_id=" + e.TaskID,
|
|
}
|
|
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,
|
|
ErrorCode: string(e.Result.ErrorCode),
|
|
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 := api.DecodeJSON(r, &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,
|
|
GitCloneURL: req.GitCloneURL,
|
|
TimeoutSeconds: req.TimeoutSeconds,
|
|
}
|
|
|
|
// Validate git_clone_url is provided when auto_commit or auto_push is enabled
|
|
if (req.AutoCommit || req.AutoPush) && req.GitCloneURL == "" {
|
|
api.WriteBadRequest(w, r, "git_clone_url is required when auto_commit or auto_push is enabled")
|
|
return
|
|
}
|
|
|
|
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,
|
|
StreamURL: "/projects/" + projectID + "/events?stream_id=" + 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))
|
|
}
|