josie / simplegit

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