8edadb5208260eff2e5321fba47e152f97967693 / internal/web/pulls.go · 13938 bytes · raw
package web
import (
"errors"
"fmt"
"html/template"
"log"
"net/http"
"slices"
"strings"
"time"
"git.josie-c.com/josie/simplegit/internal/db"
"git.josie-c.com/josie/simplegit/internal/git"
)
func pullBasePath(r *http.Request) string {
return "/" + r.PathValue("user") + "/" + r.PathValue("repo") + "/pulls"
}
type pullListRow struct {
Number int64
Title string
State string
Pending bool
Base string
Head string
Author string
AuthorIsOwner bool
Created string
Href string
}
type pullListData struct {
navData
Show string
IsOwner bool
CanWrite bool
PendingCount int
Pulls []pullListRow
}
// handlePulls renders the pull-request list. Anonymous visitors of a public
// repo see non-pending PRs; the owner additionally sees pending ones.
func (s *Server) handlePulls(w http.ResponseWriter, r *http.Request) {
repo, user, ok := s.repoPage(w, r)
if !ok {
return
}
ownerView := isOwner(user, repo)
show, state := showFilter(r, stateClosed, stateMerged, showAll)
pulls, err := db.ListPulls(s.database, repo.ID, ownerView, state)
if err != nil {
s.internalError(w, r, err)
return
}
data := pullListData{
navData: nav(r, repo, user, "pulls"), Show: show,
IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
}
if ownerView {
if data.PendingCount, err = db.CountPending(s.database, repo.ID); err != nil {
s.internalError(w, r, err)
return
}
}
for _, p := range pulls {
data.Pulls = append(data.Pulls, pullListRow{
Number: p.Number,
Title: p.Title,
State: p.State,
Pending: p.Pending,
Base: p.Base,
Head: p.Head,
Author: guestAuthorName(p.AuthorName),
AuthorIsOwner: p.AuthorID.Valid &&
p.AuthorID.Int64 == repo.OwnerID,
Created: issuedAt(p.CreatedAt),
Href: threadHref(pullBasePath(r), p.Number),
})
}
s.render(w, "pulls.html", http.StatusOK, data)
}
type pullNewData struct {
navData
IsOwner bool
Branches []string
Base string
Head string
Title string
Body string
Error string
}
// handlePullNewForm renders the open-PR form, listing the repo's branches.
func (s *Server) handlePullNewForm(w http.ResponseWriter, r *http.Request) {
repo, user, ok := s.repoPage(w, r)
if !ok {
return
}
if !canWriteContent(user, repo) {
http.NotFound(w, r)
return
}
branches, err := git.Branches(s.repoPathFor(r.PathValue("user"), repo))
if err != nil {
s.internalError(w, r, err)
return
}
base := repo.DefaultBranch
head := ""
for _, branch := range branches {
if branch != base && head == "" {
head = branch
}
}
data := pullNewData{
navData: nav(r, repo, user, "pulls"),
IsOwner: isOwner(user, repo),
Branches: branches, Base: base, Head: head,
}
s.render(w, "pull_new.html", http.StatusOK, data)
}
// handleCreatePull validates the branch pair and stores the PR. The owner's
// PR is published immediately; a guest's is pending owner moderation.
func (s *Server) handleCreatePull(w http.ResponseWriter, r *http.Request) {
repo, user, ok := s.repoPage(w, r)
if !ok {
return
}
repoPath := s.repoPathFor(r.PathValue("user"), repo)
trusted := isOwner(user, repo)
if !canWriteContent(user, repo) {
http.NotFound(w, r)
return
}
if !trusted && !s.guestThreads.allow(clientIP(r)) {
http.Error(w, "too many pull requests opened; try again later", http.StatusTooManyRequests)
return
}
branches, err := git.Branches(repoPath)
if err != nil {
s.internalError(w, r, err)
return
}
r.Body = http.MaxBytesReader(w, r.Body, issueFormMax)
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
base := strings.TrimSpace(r.FormValue("base"))
head := strings.TrimSpace(r.FormValue("head"))
title := formText(r, "title", maxTitleLen)
body := formText(r, "body", maxIssueBody)
fail := func(status int, msg string) {
data := pullNewData{
navData: nav(r, repo, user, "pulls"), IsOwner: trusted,
Branches: branches, Base: base, Head: head, Title: title, Body: body, Error: msg,
}
s.render(w, "pull_new.html", status, data)
}
if title == "" {
fail(http.StatusUnprocessableEntity, "a title is required")
return
}
if !validRef(base) || !validRef(head) {
fail(http.StatusUnprocessableEntity, "base and head must be branch names")
return
}
if base == head {
fail(http.StatusUnprocessableEntity, "base and head must differ")
return
}
if !slices.Contains(branches, base) || !slices.Contains(branches, head) {
fail(http.StatusUnprocessableEntity, "both base and head must be existing branches")
return
}
if !trusted && strings.TrimSpace(r.FormValue(honeypotField)) != "" {
http.Redirect(w, r, pullBasePath(r)+"?"+submittedParam+"=1", http.StatusSeeOther)
return
}
authorID, authorName, authorEmail := issueIdentity(r, user, r.PathValue("user"))
if !trusted {
dup, err := db.HasDuplicatePull(s.database, repo.ID, title, body, base, head, authorName, authorEmail)
if err != nil {
s.internalError(w, r, err)
return
}
if dup {
// Identical PR already stored; answer exactly like a fresh one.
s.render(w, "pull_submitted.html", http.StatusOK, nav(r, repo, user, "pulls"))
return
}
}
pull, err := db.CreatePull(s.database, repo.ID, title, body, base, head, authorID, authorName, authorEmail, !trusted)
if err != nil {
s.internalError(w, r, err)
return
}
if trusted {
http.Redirect(w, r, threadHref(pullBasePath(r), pull.Number), http.StatusSeeOther)
return
}
s.render(w, "pull_submitted.html", http.StatusOK, nav(r, repo, user, "pulls"))
}
type pullViewData struct {
navData
Number int64
Title string
State string
Pending bool
Base string
Head string
Author string
AuthorIsOwner bool
AuthorEmail string
Created string
BodyHTML template.HTML
DiffHTML template.HTML
DiffNotice string
MergeCommit string
Comments []commentView
IsOwner bool
CanWrite bool
BasePath string
Submitted bool
Error string
}
// handlePullView shows one PR: metadata, the three-dot diff, and comments. A
// non-owner cannot see a pending PR; pending comments are owner-only.
func (s *Server) handlePullView(w http.ResponseWriter, r *http.Request) {
repo, user, number, ok := s.repoNumber(w, r)
if !ok {
return
}
pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
if !ok {
return
}
ownerView := isOwner(user, repo)
if pull.Pending && !ownerView {
http.NotFound(w, r)
return
}
comments, err := db.ListPullComments(s.database, pull.ID, ownerView)
if err != nil {
s.internalError(w, r, err)
return
}
data := s.newPullViewData(r, repo, user, pull, ownerView)
data.Comments = commentViews(comments, repo.OwnerID, ownerView, data.BasePath, pullCommentRow)
s.render(w, "pull.html", http.StatusOK, data)
}
func (s *Server) newPullViewData(r *http.Request, repo db.Repo, user *db.User, pull db.Pull, ownerView bool) pullViewData {
data := pullViewData{
navData: nav(r, repo, user, "pulls"), Number: pull.Number,
Title: pull.Title, State: pull.State, Pending: pull.Pending,
Base: pull.Base, Head: pull.Head,
Author: guestAuthorName(pull.AuthorName),
AuthorIsOwner: pull.AuthorID.Valid && pull.AuthorID.Int64 == repo.OwnerID,
AuthorEmail: pull.AuthorEmail,
Created: issuedAt(pull.CreatedAt),
BodyHTML: markdownHTML(pull.Body),
MergeCommit: pull.MergeCommit,
IsOwner: ownerView, CanWrite: canWriteContent(user, repo),
BasePath: pullBasePath(r),
Submitted: submitted(r),
}
s.attachPullDiff(&data, repo, pull)
return data
}
// attachPullDiff renders git's three-dot base...head patch. The stored
// branch names are re-validated before they reach git, so a corrupted or
// legacy row cannot smuggle options into the diff command.
func (s *Server) attachPullDiff(data *pullViewData, repo db.Repo, pull db.Pull) {
if pull.State == stateMerged {
return
}
if !validRef(pull.Base) || !validRef(pull.Head) {
data.DiffNotice = "could not compute the diff between base and head."
return
}
patch, err := git.Diff(s.repoPathFor(data.Owner, repo), pull.Base, pull.Head, maxDiffBytes)
if err != nil {
data.DiffNotice = "could not compute the diff between base and head."
return
}
if len(patch) == 0 {
data.DiffNotice = "No changes between base and head."
return
}
data.DiffHTML, data.DiffNotice = renderDiffHTML(patch)
}
type pullStateData struct {
BasePath string
State string
IsOwner bool
Pending bool
}
// handlePullMerge merges head into base (owner-only) with a fast-forward when
// possible, otherwise a merge commit.
func (s *Server) handlePullMerge(w http.ResponseWriter, r *http.Request) {
repo, user, number, ok := s.ownerNumber(w, r)
if !ok {
return
}
pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
if !ok {
return
}
if pull.State != stateOpen {
s.renderPullError(w, r, repo, user, pull, "This pull request is not open.")
return
}
message := fmt.Sprintf("Merge branch '%s' into %s", pull.Head, pull.Base)
sha, _, err := git.Merge(s.repoPathFor(r.PathValue("user"), repo), pull.Base, pull.Head, message, user.Username)
switch {
case errors.Is(err, git.ErrMergeConflict):
s.renderPullError(w, r, repo, user, pull, "This pull request has merge conflicts and cannot be merged automatically.")
return
case errors.Is(err, git.ErrNoCommonAncestor):
s.renderPullError(w, r, repo, user, pull, "Base and head have unrelated histories and cannot be merged.")
return
case errors.Is(err, git.ErrNotFound):
s.renderPullError(w, r, repo, user, pull, "A branch no longer exists; this pull request cannot be merged.")
return
case errors.Is(err, git.ErrAlreadyMerged):
// head's changes are already in base; record the merge without a new commit.
case err != nil:
s.internalError(w, r, err)
return
}
if err := db.SetPullMerged(s.database, repo.ID, number, sha); err != nil {
s.internalError(w, r, err)
return
}
_ = db.RecordPush(s.database, r.PathValue("user"), repo.Name, time.Now().Unix(), "")
http.Redirect(w, r, threadHref(pullBasePath(r), number), http.StatusSeeOther)
}
func (s *Server) renderPullError(w http.ResponseWriter, r *http.Request, repo db.Repo, user *db.User, pull db.Pull, msg string) {
ownerView := isOwner(user, repo)
data := s.newPullViewData(r, repo, user, pull, ownerView)
data.Error = msg
comments, err := db.ListPullComments(s.database, pull.ID, ownerView)
if err != nil {
log.Printf("web: list pull comments %d: %v", pull.ID, err)
}
data.Comments = commentViews(comments, repo.OwnerID, ownerView, data.BasePath, pullCommentRow)
s.render(w, "pull.html", http.StatusUnprocessableEntity, data)
}
// handlePullState closes or reopens a PR (owner-only).
func (s *Server) handlePullState(w http.ResponseWriter, r *http.Request, state string) {
repo, user, number, ok := s.ownerNumber(w, r)
if !ok {
return
}
pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
if !ok {
return
}
if pull.State == stateMerged {
s.renderPullError(w, r, repo, user, pull, "A merged pull request cannot be reopened.")
return
}
if err := db.SetPullState(s.database, repo.ID, number, state); err != nil {
s.internalError(w, r, err)
return
}
basePath := threadHref(pullBasePath(r), number)
if isHTMX(r) {
s.renderFragment(w, "pull.html", "pstate", pullStateData{
BasePath: basePath, State: state, IsOwner: true, Pending: pull.Pending,
})
return
}
http.Redirect(w, r, basePath, http.StatusSeeOther)
}
// handleCreatePullComment stores a PR comment through the shared comment
// pipeline: owner comments publish immediately, guest comments are pending
// moderation.
func (s *Server) handleCreatePullComment(w http.ResponseWriter, r *http.Request) {
repo, user, number, ok := s.repoNumber(w, r)
if !ok {
return
}
basePath := threadHref(pullBasePath(r), number)
pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
if !ok {
return
}
if pull.Pending && !isOwner(user, repo) {
http.NotFound(w, r)
return
}
s.createComment(w, r, repo, user, pull.ID, basePath, commentFlow{
page: "pull.html", pendingFrag: "comment_pending", commentFrag: "comment",
create: func(in commentInput) (commentView, error) {
created, err := db.CreatePullComment(s.database, in.parentID, in.body, in.authorID, in.authorName, in.authorEmail, in.pending)
if err != nil {
return commentView{}, err
}
return commentViews([]db.PullComment{created}, repo.OwnerID, true, basePath, pullCommentRow)[0], nil
},
})
}
// handleApprovePull publishes a pending PR (owner-only).
func (s *Server) handleApprovePull(w http.ResponseWriter, r *http.Request) {
s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
return db.ApprovePull(s.database, repo.ID, number)
}, pullBasePath(r)+"/"+r.PathValue("number"))
}
// handleDeletePull removes a PR (owner-only).
func (s *Server) handleDeletePull(w http.ResponseWriter, r *http.Request) {
s.moderateNumber(w, r, func(repo db.Repo, number int64) error {
return db.DeletePull(s.database, repo.ID, number)
}, pullBasePath(r))
}
// handleModeratePullComment approves or deletes a PR comment (owner-only).
func (s *Server) handleModeratePullComment(w http.ResponseWriter, r *http.Request, approve bool) {
repo, _, number, ok := s.ownerNumber(w, r)
if !ok {
return
}
basePath := threadHref(pullBasePath(r), number)
pull, ok := fetchByNumber(s, w, r, repo.ID, number, db.GetPullByNumber)
if !ok {
return
}
id, ok := pathID(r)
if !ok {
http.NotFound(w, r)
return
}
moderate := db.ApprovePullComment
if !approve {
moderate = db.DeletePullComment
}
if err := moderate(s.database, pull.ID, id); err != nil {
s.internalError(w, r, err)
return
}
http.Redirect(w, r, basePath, http.StatusSeeOther)
}