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