package handlers import ( "encoding/json" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/events" "github.com/forgeo/forgebucket/internal/models" ) type GitOpsHandler struct { db *xorm.Engine bus events.EventBus } func NewGitOpsHandler(db *xorm.Engine, bus events.EventBus) *GitOpsHandler { return &GitOpsHandler{db: db, bus: bus} } // GetConfig returns the GitOpsConfig for an environment, or 404 if not configured. func (h *GitOpsHandler) GetConfig(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } var cfg models.GitOpsConfig if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found { jsonError(w, "gitops not configured for this environment", http.StatusNotFound) return } jsonOK(w, cfg) } // UpsertConfig creates or replaces the GitOpsConfig for an environment. func (h *GitOpsHandler) UpsertConfig(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } var body struct { Branch string `json:"branch"` AutoSync bool `json:"autoSync"` SyncInterval int `json:"syncInterval"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) return } if body.Branch == "" { jsonError(w, "branch is required", http.StatusBadRequest) return } var cfg models.GitOpsConfig exists, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg) cfg.EnvID = env.ID cfg.RepoID = env.RepoID cfg.Branch = body.Branch cfg.AutoSync = body.AutoSync cfg.SyncInterval = body.SyncInterval if cfg.SyncStatus == "" { cfg.SyncStatus = "unknown" } var err error if exists { _, err = h.db.ID(cfg.ID).Cols("branch", "auto_sync", "sync_interval").Update(&cfg) } else { _, err = h.db.Insert(&cfg) } if err != nil { jsonError(w, "could not save gitops config", http.StatusInternalServerError) return } jsonOK(w, cfg) } // DeleteConfig removes the GitOpsConfig for an environment without deleting deployments. func (h *GitOpsHandler) DeleteConfig(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } if _, err := h.db.Where("env_id = ?", env.ID).Delete(&models.GitOpsConfig{}); err != nil { jsonError(w, "could not delete gitops config", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // TriggerSync manually initiates a reconciliation for the environment. func (h *GitOpsHandler) TriggerSync(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } var cfg models.GitOpsConfig if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found { jsonError(w, "gitops not configured for this environment", http.StatusNotFound) return } if cfg.DesiredSHA == "" { jsonError(w, "no desired SHA known yet — push to the configured branch first", http.StatusConflict) return } if cfg.SyncStatus == "syncing" { jsonError(w, "a sync is already in progress", http.StatusConflict) return } now := time.Now().UTC() deploy := &models.Deployment{ EnvID: env.ID, RepoID: env.RepoID, SHA: cfg.DesiredSHA, Ref: "refs/heads/" + cfg.Branch, Status: models.DeployStatusPending, TriggeredBy: "gitops-manual", Description: "Manual GitOps sync", StartedAt: &now, } if _, err := h.db.Insert(deploy); err != nil { jsonError(w, "could not create deployment", http.StatusInternalServerError) return } cfg.SyncStatus = "syncing" h.db.ID(cfg.ID).Cols("sync_status").Update(&cfg) //nolint:errcheck h.bus.Publish(events.SubjectDeploymentStarted, events.DeploymentEvent{ //nolint:errcheck DeploymentID: deploy.ID, EnvID: env.ID, EnvName: env.Name, RepoID: deploy.RepoID, SHA: deploy.SHA, Ref: deploy.Ref, Status: string(deploy.Status), TriggeredBy: deploy.TriggeredBy, }) w.WriteHeader(http.StatusCreated) jsonOK(w, deploy) } // GetDriftStatus returns the current sync status and SHA comparison for an environment. func (h *GitOpsHandler) GetDriftStatus(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } var cfg models.GitOpsConfig if found, _ := h.db.Where("env_id = ?", env.ID).Get(&cfg); !found { jsonError(w, "gitops not configured for this environment", http.StatusNotFound) return } type driftStatus struct { SyncStatus string `json:"syncStatus"` DesiredSHA string `json:"desiredSha"` ActualSHA string `json:"actualSha"` Branch string `json:"branch"` IsDrifted bool `json:"isDrifted"` } jsonOK(w, driftStatus{ SyncStatus: cfg.SyncStatus, DesiredSHA: cfg.DesiredSHA, ActualSHA: cfg.ActualSHA, Branch: cfg.Branch, IsDrifted: cfg.DesiredSHA != cfg.ActualSHA && cfg.DesiredSHA != "", }) } // ListDriftEvents returns the drift history for an environment, newest first. func (h *GitOpsHandler) ListDriftEvents(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } limit := 50 if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 200 { limit = l } var drifts []models.GitOpsDriftEvent if err := h.db.Where("env_id = ?", env.ID).Desc("id").Limit(limit).Find(&drifts); err != nil { jsonError(w, "could not list drift events", http.StatusInternalServerError) return } if drifts == nil { drifts = []models.GitOpsDriftEvent{} } jsonOK(w, drifts) } // AcknowledgeDrift marks a drift event as acknowledged without triggering a sync. func (h *GitOpsHandler) AcknowledgeDrift(w http.ResponseWriter, r *http.Request) { env, ok := h.resolveGitOpsEnv(w, r) if !ok { return } driftID, err := strconv.ParseInt(chi.URLParam(r, "driftID"), 10, 64) if err != nil { jsonError(w, "invalid drift event ID", http.StatusBadRequest) return } var drift models.GitOpsDriftEvent if found, _ := h.db.Where("id = ? AND env_id = ?", driftID, env.ID).Get(&drift); !found { jsonError(w, "drift event not found", http.StatusNotFound) return } if drift.ResolvedAt != nil { jsonError(w, "drift event is already resolved", http.StatusConflict) return } now := time.Now().UTC() drift.SyncStatus = "acknowledged" drift.ResolvedAt = &now if _, err := h.db.ID(drift.ID).Cols("sync_status", "resolved_at").Update(&drift); err != nil { jsonError(w, "could not acknowledge drift", http.StatusInternalServerError) return } jsonOK(w, drift) } // ── Helpers ─────────────────────────────────────────────────────────────────── func (h *GitOpsHandler) resolveGitOpsEnv(w http.ResponseWriter, r *http.Request) (*models.Environment, bool) { repoID, ok := resolveRepoID(h.db, 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 }