0310986644
Update — PATCH /{prID} edits title and/or body, validates title non-empty, returns prWithReviewers
Reopen — POST /{prID}/reopen transitions closed → open, fires webhook
Close now returns prWithReviewers and fires a webhook
Merge already existed; no changes needed
Frontend — PRDetailPage.tsx full rewrite:
Inline title editing — pencil icon (visible to author/admin when open), Enter to save, Esc to cancel
Inline body editing — same pattern in the description panel
Merge sidebar — radio buttons for allowed strategies (fetched from repo's merge strategy settings), "Merge pull request" button in Bitbucket purple, "Close without merging" below it
Status banner — merged (purple) or closed (grey) with the date, shown below the description
File list — scrollable +N −N table above the diff viewer showing all changed files with addition/deletion counts
Reopen button — appears in the sidebar when the PR is closed
Reviewers panel — lists assigned reviewers with avatars/initials
Details panel — from/into branches, opened date, last updated
Quick links — back to all PRs, open new PR
PRsPage.tsx — now shows real data:
Two tabs: "My pull requests" and "Awaiting my review" (with live counts from dashboard)
Per-repo quick links at the bottom showing open PR count badges
284 lines
7.1 KiB
Go
284 lines
7.1 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
|
|
}
|
|
|
|
// Auto-assign default reviewers.
|
|
AutoAssignReviewers(h.db, repoID, pr.ID)
|
|
|
|
// Fire webhook.
|
|
go FireWebhooks(h.db, repoID, "pull_request", map[string]interface{}{
|
|
"action": "opened",
|
|
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
|
|
})
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(prWithReviewers(h.db, pr))
|
|
}
|
|
|
|
type prResponse struct {
|
|
models.PullRequest
|
|
Reviewers []ReviewerInfo `json:"reviewers"`
|
|
}
|
|
|
|
func prWithReviewers(db *xorm.Engine, pr *models.PullRequest) prResponse {
|
|
return prResponse{
|
|
PullRequest: *pr,
|
|
Reviewers: GetPrReviewers(db, pr.ID),
|
|
}
|
|
}
|
|
|
|
func (h *PRHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
pr, ok := h.lookupPR(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
jsonOK(w, prWithReviewers(h.db, 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
|
|
}
|
|
go FireWebhooks(h.db, pr.RepoID, "pull_request", map[string]interface{}{
|
|
"action": "closed",
|
|
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
|
|
})
|
|
jsonOK(w, prWithReviewers(h.db, pr))
|
|
}
|
|
|
|
func (h *PRHandler) Reopen(w http.ResponseWriter, r *http.Request) {
|
|
pr, ok := h.lookupPR(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if pr.Status != models.PRStatusClosed {
|
|
jsonError(w, "pull request is not closed", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
pr.Status = models.PRStatusOpen
|
|
if _, err := h.db.ID(pr.ID).Cols("status").Update(pr); err != nil {
|
|
jsonError(w, "could not reopen pull request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
go FireWebhooks(h.db, pr.RepoID, "pull_request", map[string]interface{}{
|
|
"action": "reopened",
|
|
"pullRequest": map[string]interface{}{"id": pr.ID, "title": pr.Title},
|
|
})
|
|
jsonOK(w, prWithReviewers(h.db, pr))
|
|
}
|
|
|
|
func (h *PRHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
pr, ok := h.lookupPR(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Title *string `json:"title"`
|
|
Body *string `json:"body"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var cols []string
|
|
if body.Title != nil {
|
|
if *body.Title == "" {
|
|
jsonError(w, "title cannot be empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
pr.Title = *body.Title
|
|
cols = append(cols, "title")
|
|
}
|
|
if body.Body != nil {
|
|
pr.Body = *body.Body
|
|
cols = append(cols, "body")
|
|
}
|
|
if len(cols) > 0 {
|
|
if _, err := h.db.ID(pr.ID).Cols(cols...).Update(pr); err != nil {
|
|
jsonError(w, "could not update pull request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
jsonOK(w, prWithReviewers(h.db, 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
|
|
}
|