Files
ForgeBucket/internal/api/handlers/workflow.go
T
2026-05-11 23:56:45 +02:00

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