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