package handlers import ( "encoding/json" "net/http" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/models" ) // ── shared lookup ───────────────────────────────────────────────────────────── type WorkflowHandler struct{ db *xorm.Engine } func NewWorkflowHandler(db *xorm.Engine) *WorkflowHandler { return &WorkflowHandler{db: db} } func (h *WorkflowHandler) resolveRepo(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.User, bool) { ownerName := chi.URLParam(r, "owner") repoName := chi.URLParam(r, "repo") var owner models.User if found, _ := h.db.Where("username = ?", ownerName).Get(&owner); !found { jsonError(w, "repository not found", http.StatusNotFound) return nil, nil, false } var repo models.Repository if found, _ := h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo); !found { jsonError(w, "repository not found", http.StatusNotFound) return nil, nil, false } return &repo, &owner, true } func (h *WorkflowHandler) canManage(repo *models.Repository, callerID int64) bool { if callerID == repo.OwnerID { return true } var m models.RepoMember found, _ := h.db.Where("repo_id = ? AND user_id = ? AND permission = 'admin'", repo.ID, callerID).Get(&m) return found } // ── branch protections ──────────────────────────────────────────────────────── type branchProtectionResponse struct { ID int64 `json:"id"` Pattern string `json:"pattern"` RequirePR bool `json:"requirePR"` BlockForcePush bool `json:"blockForcePush"` AllowedUsers string `json:"allowedUsers"` CreatedAt string `json:"createdAt"` } func toBranchProtResp(bp models.BranchProtection) branchProtectionResponse { return branchProtectionResponse{ ID: bp.ID, Pattern: bp.Pattern, RequirePR: bp.RequirePR, BlockForcePush: bp.BlockForcePush, AllowedUsers: bp.AllowedUsers, CreatedAt: bp.CreatedAt.Format(time.RFC3339), } } func (h *WorkflowHandler) ListBranchProtections(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } var bps []models.BranchProtection h.db.Where("repo_id = ?", repo.ID).OrderBy("created_at").Find(&bps) resp := make([]branchProtectionResponse, len(bps)) for i, bp := range bps { resp[i] = toBranchProtResp(bp) } jsonOK(w, resp) } func (h *WorkflowHandler) CreateBranchProtection(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } var body struct { Pattern string `json:"pattern"` RequirePR bool `json:"requirePR"` BlockForcePush bool `json:"blockForcePush"` AllowedUsers string `json:"allowedUsers"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Pattern == "" { jsonError(w, "pattern is required", http.StatusBadRequest) return } bp := &models.BranchProtection{ RepoID: repo.ID, Pattern: body.Pattern, RequirePR: body.RequirePR, BlockForcePush: body.BlockForcePush, AllowedUsers: body.AllowedUsers, } if _, err := h.db.Insert(bp); err != nil { jsonError(w, "could not create protection", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(toBranchProtResp(*bp)) } func (h *WorkflowHandler) UpdateBranchProtection(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } bpID, err := strconv.ParseInt(chi.URLParam(r, "bpID"), 10, 64) if err != nil { jsonError(w, "invalid ID", http.StatusBadRequest) return } var bp models.BranchProtection if found, _ := h.db.Where("id = ? AND repo_id = ?", bpID, repo.ID).Get(&bp); !found { jsonError(w, "rule not found", http.StatusNotFound) return } var body struct { RequirePR bool `json:"requirePR"` BlockForcePush bool `json:"blockForcePush"` AllowedUsers string `json:"allowedUsers"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid body", http.StatusBadRequest) return } bp.RequirePR = body.RequirePR bp.BlockForcePush = body.BlockForcePush bp.AllowedUsers = body.AllowedUsers h.db.ID(bp.ID).Cols("require_pr", "block_force_push", "allowed_users").Update(&bp) jsonOK(w, toBranchProtResp(bp)) } func (h *WorkflowHandler) DeleteBranchProtection(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } bpID, _ := strconv.ParseInt(chi.URLParam(r, "bpID"), 10, 64) h.db.Where("id = ? AND repo_id = ?", bpID, repo.ID).Delete(&models.BranchProtection{}) w.WriteHeader(http.StatusNoContent) } // CheckBranchProtection returns a deny reason if the push violates a protection rule, // or "" if the push is allowed. Called from githttp.go before running the backend. func CheckBranchProtection(db *xorm.Engine, repoID int64, pusherUsername, refname string, isForcePush bool) string { branchName := strings.TrimPrefix(refname, "refs/heads/") if branchName == refname { return "" // not a branch ref } var protections []models.BranchProtection db.Where("repo_id = ?", repoID).Find(&protections) for _, bp := range protections { matched, err := filepath.Match(bp.Pattern, branchName) if err != nil || !matched { continue } // Check if the pusher is in the allowed list. if bp.AllowedUsers != "" { allowed := false for _, u := range strings.Split(bp.AllowedUsers, ",") { if strings.TrimSpace(u) == pusherUsername { allowed = true break } } if allowed { continue } } // Enforce rules. if bp.RequirePR { return "push rejected: '" + branchName + "' is protected and requires a pull request" } if bp.BlockForcePush && isForcePush { return "push rejected: force push to '" + branchName + "' is not allowed" } } return "" } // ── branching model ─────────────────────────────────────────────────────────── type branchingModelResponse struct { Enabled bool `json:"enabled"` FeaturePrefix string `json:"featurePrefix"` BugfixPrefix string `json:"bugfixPrefix"` ReleasePrefix string `json:"releasePrefix"` HotfixPrefix string `json:"hotfixPrefix"` } func (h *WorkflowHandler) GetBranchingModel(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } var bm models.BranchingModel found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&bm) if !found { // Return sensible defaults. jsonOK(w, branchingModelResponse{ Enabled: false, FeaturePrefix: "feature/", BugfixPrefix: "bugfix/", ReleasePrefix: "release/", HotfixPrefix: "hotfix/", }) return } jsonOK(w, branchingModelResponse{ Enabled: bm.Enabled, FeaturePrefix: bm.FeaturePrefix, BugfixPrefix: bm.BugfixPrefix, ReleasePrefix: bm.ReleasePrefix, HotfixPrefix: bm.HotfixPrefix, }) } func (h *WorkflowHandler) UpdateBranchingModel(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } var body branchingModelResponse if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid body", http.StatusBadRequest) return } var bm models.BranchingModel found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&bm) bm.RepoID = repo.ID bm.Enabled = body.Enabled bm.FeaturePrefix = body.FeaturePrefix bm.BugfixPrefix = body.BugfixPrefix bm.ReleasePrefix = body.ReleasePrefix bm.HotfixPrefix = body.HotfixPrefix if found { h.db.ID(bm.ID).Cols("enabled", "feature_prefix", "bugfix_prefix", "release_prefix", "hotfix_prefix").Update(&bm) } else { h.db.Insert(&bm) } jsonOK(w, body) } // ── merge strategies ────────────────────────────────────────────────────────── type mergeStrategiesResponse struct { AllowMergeCommit bool `json:"allowMergeCommit"` AllowSquash bool `json:"allowSquash"` AllowRebase bool `json:"allowRebase"` } func (h *WorkflowHandler) GetMergeStrategies(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } var ms models.MergeStrategies found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&ms) if !found { jsonOK(w, mergeStrategiesResponse{AllowMergeCommit: true, AllowSquash: true, AllowRebase: true}) return } jsonOK(w, mergeStrategiesResponse{ AllowMergeCommit: ms.AllowMergeCommit, AllowSquash: ms.AllowSquash, AllowRebase: ms.AllowRebase, }) } func (h *WorkflowHandler) UpdateMergeStrategies(w http.ResponseWriter, r *http.Request) { repo, _, ok := h.resolveRepo(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) if !h.canManage(repo, callerID) { jsonError(w, "forbidden", http.StatusForbidden) return } var body mergeStrategiesResponse if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid body", http.StatusBadRequest) return } if !body.AllowMergeCommit && !body.AllowSquash && !body.AllowRebase { jsonError(w, "at least one merge strategy must be enabled", http.StatusBadRequest) return } var ms models.MergeStrategies found, _ := h.db.Where("repo_id = ?", repo.ID).Get(&ms) ms.RepoID = repo.ID ms.AllowMergeCommit = body.AllowMergeCommit ms.AllowSquash = body.AllowSquash ms.AllowRebase = body.AllowRebase if found { h.db.ID(ms.ID).Cols("allow_merge_commit", "allow_squash", "allow_rebase").Update(&ms) } else { h.db.Insert(&ms) } jsonOK(w, body) } // GetAllowedStrategies returns the allowed strategy set for a repo (used by PR merge handler). func GetAllowedStrategies(db *xorm.Engine, repoID int64) map[string]bool { var ms models.MergeStrategies if found, _ := db.Where("repo_id = ?", repoID).Get(&ms); !found { return map[string]bool{"merge": true, "squash": true, "rebase": true} } return map[string]bool{ "merge": ms.AllowMergeCommit, "squash": ms.AllowSquash, "rebase": ms.AllowRebase, } }