package handlers import ( "context" "fmt" "strconv" "strings" "github.com/gofiber/fiber/v2" "polar/internal/git" "polar/internal/repo" "polar/internal/user" "polar/internal/web/middleware" "polar/internal/web/views" ) // headRepoCandidates lists repositories a viewer may open a pull request from: // the base repository itself plus the viewer's forks of it. func (h *Handler) headRepoCandidates(c *fiber.Ctx, r *repo.Repository, viewer *user.User) []*repo.Repository { candidates := []*repo.Repository{r} if viewer == nil { return candidates } forks, _ := h.repoSvc.ListForks(c.UserContext(), r.ID, 100) for _, f := range forks { if f.OwnerID == viewer.ID { fork := f candidates = append(candidates, &fork) } } return candidates } func (h *Handler) findHeadRepo(candidates []*repo.Repository, id int64) *repo.Repository { for _, cand := range candidates { if cand.ID == id { return cand } } return nil } // resolveHeadRef makes headBranch of headRepo visible inside baseDisk. // For same-repository PRs the branch name is returned unchanged. For // cross-repository (fork) PRs the branch is fetched into a temporary ref and // the returned cleanup removes it again. func (h *Handler) resolveHeadRef(c *fiber.Ctx, r *repo.Repository, baseDisk string, headRepoID int64, headBranch string) (ref string, headRepo *repo.Repository, cleanup func(), err error) { noop := func() {} if headRepoID == 0 || headRepoID == r.ID { return headBranch, r, noop, nil } hr, err := h.repoSvc.GetByID(c.UserContext(), headRepoID) if err != nil || hr == nil { return "", nil, noop, fmt.Errorf("head repository not found") } if hr.ForkedFromRepoID == nil || *hr.ForkedFromRepoID != r.ID { return "", nil, noop, fmt.Errorf("head repository is not a fork of this repository") } forkDisk, err := h.repoSvc.DiskPath(hr.OwnerUsername, hr.Name) if err != nil { return "", nil, noop, err } tmpRef := fmt.Sprintf("refs/polar/tmp-pr-%d", hr.ID) if err := git.FetchToTemp(c.UserContext(), baseDisk, forkDisk, headBranch, tmpRef); err != nil { return "", nil, noop, err } cleanup = func() { git.DeleteRef(context.WithoutCancel(c.UserContext()), baseDisk, tmpRef) } return tmpRef, hr, cleanup, nil } func (h *Handler) RepoPullsPage(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } state := c.Query("state", "open") if state != "open" && state != "closed" && state != "merged" { state = "open" } pulls, _ := h.repoSvc.ListPullRequests(c.UserContext(), r.ID, state) openCount, closedCount, mergedCount, _ := h.repoSvc.CountPullRequests(c.UserContext(), r.ID) viewer := middleware.CurrentUser(c) data := h.baseData(c, h.tr(c, "pulls.title")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "pulls" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Pulls"] = pulls data["CurrentState"] = state data["OpenCount"] = openCount data["ClosedCount"] = closedCount data["MergedCount"] = mergedCount return h.render(c, fiber.StatusOK, "pulls", data) } func (h *Handler) RepoNewPullPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } viewer := middleware.CurrentUser(c) candidates := h.headRepoCandidates(c, r, viewer) headRepo := r if v := strings.TrimSpace(c.Query("head_repo")); v != "" { if id, convErr := strconv.ParseInt(v, 10, 64); convErr == nil { if found := h.findHeadRepo(candidates, id); found != nil { headRepo = found } } } headDisk, err := h.repoSvc.DiskPath(headRepo.OwnerUsername, headRepo.Name) if err != nil { headDisk = diskPath } branches, _ := git.ListBranches(c.UserContext(), headDisk) baseBranches, _ := git.ListBranches(c.UserContext(), diskPath) baseBranch := strings.TrimSpace(c.Query("base")) if baseBranch == "" { baseBranch = r.DefaultBranch } headBranch := strings.TrimSpace(c.Query("head")) if headBranch == "" && len(branches) > 1 { for _, b := range branches { if b != baseBranch || headRepo.ID != r.ID { headBranch = b break } } } data := h.baseData(c, h.tr(c, "pulls.new")+" · "+r.OwnerUsername+"/"+r.Name) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "pulls" data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["Branches"] = branches data["BaseBranches"] = baseBranches data["BaseBranch"] = baseBranch data["HeadBranch"] = headBranch data["HeadRepo"] = headRepo data["HeadRepos"] = candidates if baseBranch != "" && headBranch != "" && (baseBranch != headBranch || headRepo.ID != r.ID) { headRef, _, cleanup, refErr := h.resolveHeadRef(c, r, diskPath, headRepo.ID, headBranch) if refErr == nil { defer cleanup() diffDetail, cmpCommits, cmpErr := git.CompareBranches(c.UserContext(), diskPath, baseBranch, headRef) if cmpErr == nil { data["Compare"] = diffDetail data["CompareCommits"] = cmpCommits } canMerge, _, _ := git.CanMergeBranches(c.UserContext(), diskPath, baseBranch, headRef) data["CanMerge"] = canMerge } else { data["CompareError"] = refErr.Error() } } return h.render(c, fiber.StatusOK, "pull_new", data) } func (h *Handler) RepoNewPullSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } u := middleware.CurrentUser(c) title := strings.TrimSpace(c.FormValue("title")) body := strings.TrimSpace(c.FormValue("body")) baseBranch := strings.TrimSpace(c.FormValue("base_branch")) headBranch := strings.TrimSpace(c.FormValue("head_branch")) headRepoRaw := strings.TrimSpace(c.FormValue("head_repo")) var headRepoID *int64 if headRepoRaw != "" { if id, convErr := strconv.ParseInt(headRepoRaw, 10, 64); convErr == nil && id != r.ID { candidates := h.headRepoCandidates(c, r, u) if h.findHeadRepo(candidates, id) == nil { h.setFlashT(c, "flash.error", "err.pr_head_repo_invalid") return c.Redirect(fmt.Sprintf("/%s/%s/pulls/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) } headRepoID = &id } } pr, err := h.repoSvc.CreatePullRequest(c.UserContext(), r.ID, u.ID, title, body, baseBranch, headBranch, headRepoID) if err != nil { h.setFlashT(c, "flash.error", err.Error()) redirect := fmt.Sprintf("/%s/%s/pulls/new?base=%s&head=%s", r.OwnerUsername, r.Name, baseBranch, headBranch) if headRepoID != nil { redirect += fmt.Sprintf("&head_repo=%d", *headRepoID) } return c.Redirect(redirect, fiber.StatusSeeOther) } h.setFlashT(c, "flash.pr_created", pr.Number) return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) } func (h *Handler) RepoPullDetailPage(c *fiber.Ctx) error { r, diskPath, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } num, _ := strconv.Atoi(c.Params("number")) pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") } comments, _ := h.repoSvc.ListPullRequestComments(c.UserContext(), pr.ID) reviews, _ := h.repoSvc.ListReviews(c.UserContext(), pr.ID) approved, changesRequested := h.repoSvc.ReviewCounts(c.UserContext(), pr.ID) var headRef string var cleanup func() if pr.HeadRepoID != nil && *pr.HeadRepoID != r.ID { ref, _, cl, refErr := h.resolveHeadRef(c, r, diskPath, *pr.HeadRepoID, pr.HeadBranch) if refErr == nil { headRef = ref cleanup = cl defer cleanup() } } else { headRef = pr.HeadBranch } var diffDetail *git.CommitDetail var cmpCommits []git.Commit canMerge := false if headRef != "" { diffDetail, cmpCommits, _ = git.CompareBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef) canMerge, _, _ = git.CanMergeBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef) } viewer := middleware.CurrentUser(c) canWrite := h.repoSvc.CanWrite(c.UserContext(), r, viewer) canToggle := viewer != nil && (viewer.ID == pr.AuthorID || canWrite) canMergeAction := viewer != nil && canWrite && pr.Status == "open" && canMerge canReview := viewer != nil && viewer.ID != pr.AuthorID && pr.Status == "open" subTab := c.Query("tab", "conversation") if subTab != "conversation" && subTab != "commits" && subTab != "files" { subTab = "conversation" } data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", pr.Number, pr.Title, r.OwnerUsername, r.Name)) data["Repo"] = r data["Ref"] = r.DefaultBranch data["ActiveTab"] = "pulls" data["CanWrite"] = canWrite data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) data["PR"] = pr data["Comments"] = comments data["Reviews"] = reviews data["ApprovedCount"] = approved data["ChangesRequestedCount"] = changesRequested data["CanReview"] = canReview data["Compare"] = diffDetail data["CompareCommits"] = cmpCommits data["CanMerge"] = canMerge data["CanToggle"] = canToggle data["CanMergeAction"] = canMergeAction data["SubTab"] = subTab data["BodyHTML"] = views.RenderMarkdown([]byte(pr.Body)) return h.render(c, fiber.StatusOK, "pull_detail", data) } func (h *Handler) RepoPullCommentSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } num, _ := strconv.Atoi(c.Params("number")) pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") } u := middleware.CurrentUser(c) body := strings.TrimSpace(c.FormValue("body")) if err := h.repoSvc.CreatePullRequestComment(c.UserContext(), pr.ID, u.ID, body); err != nil { h.setFlashT(c, "flash.comment_error", err.Error()) } return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) } func (h *Handler) RepoPullStateSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, false) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } num, _ := strconv.Atoi(c.Params("number")) pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") } viewer := middleware.CurrentUser(c) if viewer == nil || (viewer.ID != pr.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) { return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_state_forbidden") } if pr.Status == "merged" { h.setFlashT(c, "flash.pr_merged_state") return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) } targetState := strings.TrimSpace(c.FormValue("state")) if targetState == "closed" { _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "closed", nil) h.setFlashT(c, "flash.pr_closed", pr.Number) } else if targetState == "open" { _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "open", nil) h.setFlashT(c, "flash.pr_reopened", pr.Number) } return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) } func (h *Handler) RepoPullMergeSubmit(c *fiber.Ctx) error { r, _, err := h.loadAuthorizedRepo(c, true) if err != nil { return nil } if !r.EnablePulls { return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") } num, _ := strconv.Atoi(c.Params("number")) pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) if err != nil { return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") } if pr.Status != "open" { h.setFlashT(c, "flash.pr_merge_open_only") return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) } viewer := middleware.CurrentUser(c) if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) { return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_merge_forbidden") } authorName := viewer.FullName if authorName == "" { authorName = viewer.Username } if mergeErr := h.repoSvc.MergePullRequest(c.UserContext(), r, pr, authorName, viewer.Email, viewer.ID); mergeErr != nil { h.setFlashT(c, "flash.pr_merge_failed", mergeErr.Error()) return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) } h.setFlashT(c, "flash.pr_merged", pr.Number) return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) }