Branch restrictions — fully enforced:
CRUD rules with pattern (exact or glob like release/*), requirePR, blockForcePush, bypass user list Enforcement via pkt-line parsing inside the git HTTP handler — before any data reaches git http-backend, each ref update is extracted and checked against stored rules Direct push to main with requirePR: true → 403 with message; push to unprotected branches still works Inline checkboxes in the UI update rules immediately Branching model — stored config: GET/PUT per repo, defaults to feature/bugfix/release/hotfix prefixes Toggle enabled/disabled, custom prefix per type with live preview No enforcement (naming guide only, as Bitbucket does) Merge strategies — enforced in PR merge endpoint: GET/PUT per repo, defaults all three allowed Merge handler now accepts strategy: "merge"|"squash"|"rebase" in request body, checks against stored policy Disallowed strategy → 409 with clear error; allowed strategy → merges and fires pull_request webhook Must have at least one strategy enabled (validated server-side) Webhooks — full delivery with HMAC: CRUD with title, URL, secret (optional), events (push/pull_request/issue), active toggle Test button sends live HTTP POST to the configured URL and shows status code in UI FireWebhooks() fires asynchronously from PR merge and can be called from any handler X-ForgeBucket-Signature-256: sha256=<hmac> header when secret is set Last delivery status and timestamp stored on webhook record and shown in list
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user