350 lines
10 KiB
Go
350 lines
10 KiB
Go
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) {
|
|
repo, ok := resolveRepo(h.db, w, r)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
var owner models.User
|
|
h.db.ID(repo.OwnerID).Get(&owner)
|
|
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,
|
|
}
|
|
}
|