Files
ForgeBucket/internal/api/handlers/prs.go
T
erangel1 f211cfc7db 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
2026-05-07 15:27:48 +02:00

200 lines
4.9 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/api/middleware"
"github.com/forgeo/forgebucket/internal/models"
)
type PRHandler struct {
db *xorm.Engine
}
func NewPRHandler(db *xorm.Engine) *PRHandler {
return &PRHandler{db: db}
}
func (h *PRHandler) List(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.repoIDFromURL(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
sess := h.db.Where("repo_id = ?", repoID)
if status != "" {
sess = sess.And("status = ?", status)
}
var prs []models.PullRequest
if err := sess.Find(&prs); err != nil {
jsonError(w, "could not list pull requests", http.StatusInternalServerError)
return
}
if prs == nil {
prs = []models.PullRequest{}
}
jsonOK(w, prs)
}
func (h *PRHandler) Create(w http.ResponseWriter, r *http.Request) {
repoID, ok := h.repoIDFromURL(w, r)
if !ok {
return
}
authorID, _ := middleware.UserIDFromContext(r.Context())
var body struct {
Title string `json:"title"`
Body string `json:"body"`
SourceBranch string `json:"sourceBranch"`
TargetBranch string `json:"targetBranch"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Title == "" || body.SourceBranch == "" {
jsonError(w, "title and sourceBranch are required", http.StatusBadRequest)
return
}
if body.TargetBranch == "" {
body.TargetBranch = "main"
}
pr := &models.PullRequest{
RepoID: repoID,
AuthorID: authorID,
Title: body.Title,
Body: body.Body,
SourceBranch: body.SourceBranch,
TargetBranch: body.TargetBranch,
Status: models.PRStatusOpen,
}
if _, err := h.db.Insert(pr); err != nil {
jsonError(w, "could not create pull request", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(pr)
}
func (h *PRHandler) Get(w http.ResponseWriter, r *http.Request) {
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
jsonOK(w, pr)
}
func (h *PRHandler) Merge(w http.ResponseWriter, r *http.Request) {
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
if pr.Status != models.PRStatusOpen {
jsonError(w, "pull request is not open", http.StatusConflict)
return
}
// Parse optional strategy from body; default to "merge".
var body struct {
Strategy string `json:"strategy"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.Strategy == "" {
body.Strategy = "merge"
}
// Enforce merge strategy policy for this repo.
allowed := GetAllowedStrategies(h.db, pr.RepoID)
if !allowed[body.Strategy] {
jsonError(w, "merge strategy '"+body.Strategy+"' is not allowed for this repository", http.StatusConflict)
return
}
pr.Status = models.PRStatusMerged
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
jsonError(w, "could not merge pull request", http.StatusInternalServerError)
return
}
// Fire pull_request webhook.
go FireWebhooks(h.db, pr.RepoID, "pull_request", map[string]interface{}{
"action": "merged",
"strategy": body.Strategy,
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
})
jsonOK(w, pr)
}
func (h *PRHandler) Close(w http.ResponseWriter, r *http.Request) {
pr, ok := h.lookupPR(w, r)
if !ok {
return
}
if pr.Status != models.PRStatusOpen {
jsonError(w, "pull request is not open", http.StatusConflict)
return
}
pr.Status = models.PRStatusClosed
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
jsonError(w, "could not close pull request", http.StatusInternalServerError)
return
}
jsonOK(w, pr)
}
func (h *PRHandler) repoIDFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
ownerName := chi.URLParam(r, "owner")
repoName := chi.URLParam(r, "repo")
var owner models.User
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
var repo models.Repository
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
if err != nil || !found {
jsonError(w, "repository not found", http.StatusNotFound)
return 0, false
}
return repo.ID, true
}
func (h *PRHandler) lookupPR(w http.ResponseWriter, r *http.Request) (*models.PullRequest, bool) {
repoID, ok := h.repoIDFromURL(w, r)
if !ok {
return nil, false
}
prIDStr := chi.URLParam(r, "prID")
prID, err := strconv.ParseInt(prIDStr, 10, 64)
if err != nil {
jsonError(w, "invalid pull request ID", http.StatusBadRequest)
return nil, false
}
var pr models.PullRequest
found, err := h.db.Where("id = ? AND repo_id = ?", prID, repoID).Get(&pr)
if err != nil || !found {
jsonError(w, "pull request not found", http.StatusNotFound)
return nil, false
}
return &pr, true
}