Files

355 lines
10 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/events"
"github.com/forgeo/forgebucket/internal/models"
)
type EnvironmentHandler struct {
db *xorm.Engine
bus events.EventBus
}
func NewEnvironmentHandler(db *xorm.Engine, bus events.EventBus) *EnvironmentHandler {
return &EnvironmentHandler{db: db, bus: bus}
}
// ── Environment CRUD ──────────────────────────────────────────────────────────
// ListEnvironments returns all environments for a repository, each annotated
// with its most recent deployment (or nil if none).
func (h *EnvironmentHandler) ListEnvironments(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.resolveRepo(w, r)
if !ok {
return
}
var envs []models.Environment
if err := h.db.Where("repo_id = ?", repoID).Asc("name").Find(&envs); err != nil {
jsonError(w, "could not list environments", http.StatusInternalServerError)
return
}
if envs == nil {
envs = []models.Environment{}
}
type envResponse struct {
models.Environment
LatestDeployment *models.Deployment `json:"latestDeployment"`
}
result := make([]envResponse, len(envs))
for i, env := range envs {
var latest models.Deployment
found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&latest)
er := envResponse{Environment: env}
if found {
er.LatestDeployment = &latest
}
result[i] = er
}
jsonOK(w, result)
}
// CreateEnvironment creates a new named environment for a repository.
func (h *EnvironmentHandler) CreateEnvironment(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.resolveRepo(w, r)
if !ok {
return
}
var body struct {
Name string `json:"name"`
URL string `json:"url"`
ProtectionRules string `json:"protectionRules"` // raw JSON string
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Name == "" {
jsonError(w, "name is required", http.StatusBadRequest)
return
}
// Reject duplicate name within repo.
var existing models.Environment
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, body.Name).Get(&existing); found {
jsonError(w, "environment with this name already exists", http.StatusConflict)
return
}
env := &models.Environment{
RepoID: repoID,
Name: body.Name,
URL: body.URL,
ProtectionRules: body.ProtectionRules,
}
if _, err := h.db.Insert(env); err != nil {
jsonError(w, "could not create environment", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(env) //nolint:errcheck
}
// GetEnvironment returns a single environment with its latest deployment.
func (h *EnvironmentHandler) GetEnvironment(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveEnv(w, r)
if !ok {
return
}
var latest models.Deployment
found, _ := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(1).Get(&latest)
type envResponse struct {
models.Environment
LatestDeployment *models.Deployment `json:"latestDeployment"`
}
resp := envResponse{Environment: *env}
if found {
resp.LatestDeployment = &latest
}
jsonOK(w, resp)
}
// UpdateEnvironment patches the URL and/or protection rules of an environment.
func (h *EnvironmentHandler) UpdateEnvironment(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveEnv(w, r)
if !ok {
return
}
var body struct {
URL *string `json:"url"`
ProtectionRules *string `json:"protectionRules"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
cols := []string{}
if body.URL != nil {
env.URL = *body.URL
cols = append(cols, "url")
}
if body.ProtectionRules != nil {
env.ProtectionRules = *body.ProtectionRules
cols = append(cols, "protection_rules")
}
if len(cols) > 0 {
if _, err := h.db.ID(env.ID).Cols(cols...).Update(env); err != nil {
jsonError(w, "could not update environment", http.StatusInternalServerError)
return
}
}
jsonOK(w, env)
}
// DeleteEnvironment removes an environment and all its deployment records.
func (h *EnvironmentHandler) DeleteEnvironment(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveEnv(w, r)
if !ok {
return
}
// Cascade-delete deployments first (XORM has no cascade by default).
h.db.Where("env_id = ?", env.ID).Delete(&models.Deployment{}) //nolint:errcheck
if _, err := h.db.ID(env.ID).Delete(&models.Environment{}); err != nil {
jsonError(w, "could not delete environment", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── Deployment endpoints ──────────────────────────────────────────────────────
// ListDeployments returns all deployments for an environment, newest first.
func (h *EnvironmentHandler) ListDeployments(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveEnv(w, r)
if !ok {
return
}
limit := 30
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 {
limit = l
}
var deploys []models.Deployment
if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&deploys); err != nil {
jsonError(w, "could not list deployments", http.StatusInternalServerError)
return
}
if deploys == nil {
deploys = []models.Deployment{}
}
jsonOK(w, deploys)
}
// CreateDeployment records a new deployment event (pending → triggers the deploy workflow).
func (h *EnvironmentHandler) CreateDeployment(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveEnv(w, r)
if !ok {
return
}
userID, _ := middleware.UserIDFromContext(r.Context())
var actor models.User
h.db.ID(userID).Cols("username").Get(&actor)
var body struct {
SHA string `json:"sha"`
Ref string `json:"ref"`
Description string `json:"description"`
RunID *int64 `json:"runId"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.SHA == "" {
jsonError(w, "sha is required", http.StatusBadRequest)
return
}
now := time.Now().UTC()
deploy := &models.Deployment{
EnvID: env.ID,
RepoID: env.RepoID,
SHA: body.SHA,
Ref: body.Ref,
Status: models.DeployStatusPending,
TriggeredBy: actor.Username,
Description: body.Description,
RunID: body.RunID,
StartedAt: &now,
}
if _, err := h.db.Insert(deploy); err != nil {
jsonError(w, "could not create deployment", http.StatusInternalServerError)
return
}
h.publishDeployEvent("deployment.started", env, deploy)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(deploy) //nolint:errcheck
}
// UpdateDeploymentStatus allows external systems (CI runners, webhooks) to
// advance a deployment through its lifecycle states.
func (h *EnvironmentHandler) UpdateDeploymentStatus(w http.ResponseWriter, r *http.Request) {
env, ok := h.resolveEnv(w, r)
if !ok {
return
}
deployID, err := strconv.ParseInt(chi.URLParam(r, "deployID"), 10, 64)
if err != nil {
jsonError(w, "invalid deployment ID", http.StatusBadRequest)
return
}
var deploy models.Deployment
if found, _ := h.db.Where("id = ? AND env_id = ?", deployID, env.ID).Get(&deploy); !found {
jsonError(w, "deployment not found", http.StatusNotFound)
return
}
var body struct {
Status string `json:"status"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
allowed := map[string]bool{
"in_progress": true,
"success": true,
"failure": true,
"cancelled": true,
}
if !allowed[body.Status] {
jsonError(w, "invalid status; must be in_progress, success, failure, or cancelled", http.StatusBadRequest)
return
}
deploy.Status = models.DeployStatus(body.Status)
if body.Description != "" {
deploy.Description = body.Description
}
cols := []string{"status", "description"}
if deploy.Status == models.DeployStatusSuccess ||
deploy.Status == models.DeployStatusFailure ||
deploy.Status == models.DeployStatusCancelled {
now := time.Now().UTC()
deploy.FinishedAt = &now
cols = append(cols, "finished_at")
}
if _, err := h.db.ID(deploy.ID).Cols(cols...).Update(&deploy); err != nil {
jsonError(w, "could not update deployment status", http.StatusInternalServerError)
return
}
subject := map[string]string{
"success": "deployment.succeeded",
"failure": "deployment.failed",
"cancelled": "deployment.failed",
}[body.Status]
if subject != "" {
h.publishDeployEvent(subject, env, &deploy)
}
jsonOK(w, deploy)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func (h *EnvironmentHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (int64, bool) {
return resolveRepoID(h.db, w, r)
}
func (h *EnvironmentHandler) resolveEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) {
repoID, ok := h.resolveRepo(w, r)
if !ok {
return nil, false
}
envName := chi.URLParam(r, "envName")
var env models.Environment
if found, _ := h.db.Where("repo_id = ? AND name = ?", repoID, envName).Get(&env); !found {
jsonError(w, "environment not found", http.StatusNotFound)
return nil, false
}
return &env, true
}
func (h *EnvironmentHandler) publishDeployEvent(subject string, env *models.Environment, d *models.Deployment) {
h.bus.Publish(subject, events.DeploymentEvent{ //nolint:errcheck
DeploymentID: d.ID,
EnvID: env.ID,
EnvName: env.Name,
RepoID: d.RepoID,
SHA: d.SHA,
Ref: d.Ref,
Status: string(d.Status),
TriggeredBy: d.TriggeredBy,
})
}