pulls.go
·
393 строки
·
13.0 KB
| 1 | package handlers |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/gofiber/fiber/v2" |
| 10 | |
| 11 | "polar/internal/git" |
| 12 | "polar/internal/repo" |
| 13 | "polar/internal/user" |
| 14 | "polar/internal/web/middleware" |
| 15 | "polar/internal/web/views" |
| 16 | ) |
| 17 | |
| 18 | // headRepoCandidates lists repositories a viewer may open a pull request from: |
| 19 | // the base repository itself plus the viewer's forks of it. |
| 20 | func (h *Handler) headRepoCandidates(c *fiber.Ctx, r *repo.Repository, viewer *user.User) []*repo.Repository { |
| 21 | candidates := []*repo.Repository{r} |
| 22 | if viewer == nil { |
| 23 | return candidates |
| 24 | } |
| 25 | forks, _ := h.repoSvc.ListForks(c.UserContext(), r.ID, 100) |
| 26 | for _, f := range forks { |
| 27 | if f.OwnerID == viewer.ID { |
| 28 | fork := f |
| 29 | candidates = append(candidates, &fork) |
| 30 | } |
| 31 | } |
| 32 | return candidates |
| 33 | } |
| 34 | |
| 35 | func (h *Handler) findHeadRepo(candidates []*repo.Repository, id int64) *repo.Repository { |
| 36 | for _, cand := range candidates { |
| 37 | if cand.ID == id { |
| 38 | return cand |
| 39 | } |
| 40 | } |
| 41 | return nil |
| 42 | } |
| 43 | |
| 44 | // resolveHeadRef makes headBranch of headRepo visible inside baseDisk. |
| 45 | // For same-repository PRs the branch name is returned unchanged. For |
| 46 | // cross-repository (fork) PRs the branch is fetched into a temporary ref and |
| 47 | // the returned cleanup removes it again. |
| 48 | 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) { |
| 49 | noop := func() {} |
| 50 | if headRepoID == 0 || headRepoID == r.ID { |
| 51 | return headBranch, r, noop, nil |
| 52 | } |
| 53 | hr, err := h.repoSvc.GetByID(c.UserContext(), headRepoID) |
| 54 | if err != nil || hr == nil { |
| 55 | return "", nil, noop, fmt.Errorf("head repository not found") |
| 56 | } |
| 57 | if hr.ForkedFromRepoID == nil || *hr.ForkedFromRepoID != r.ID { |
| 58 | return "", nil, noop, fmt.Errorf("head repository is not a fork of this repository") |
| 59 | } |
| 60 | forkDisk, err := h.repoSvc.DiskPath(hr.OwnerUsername, hr.Name) |
| 61 | if err != nil { |
| 62 | return "", nil, noop, err |
| 63 | } |
| 64 | tmpRef := fmt.Sprintf("refs/polar/tmp-pr-%d", hr.ID) |
| 65 | if err := git.FetchToTemp(c.UserContext(), baseDisk, forkDisk, headBranch, tmpRef); err != nil { |
| 66 | return "", nil, noop, err |
| 67 | } |
| 68 | cleanup = func() { git.DeleteRef(context.WithoutCancel(c.UserContext()), baseDisk, tmpRef) } |
| 69 | return tmpRef, hr, cleanup, nil |
| 70 | } |
| 71 | |
| 72 | func (h *Handler) RepoPullsPage(c *fiber.Ctx) error { |
| 73 | r, _, err := h.loadAuthorizedRepo(c, false) |
| 74 | if err != nil { |
| 75 | return nil |
| 76 | } |
| 77 | if !r.EnablePulls { |
| 78 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 79 | } |
| 80 | |
| 81 | state := c.Query("state", "open") |
| 82 | if state != "open" && state != "closed" && state != "merged" { |
| 83 | state = "open" |
| 84 | } |
| 85 | |
| 86 | pulls, _ := h.repoSvc.ListPullRequests(c.UserContext(), r.ID, state) |
| 87 | openCount, closedCount, mergedCount, _ := h.repoSvc.CountPullRequests(c.UserContext(), r.ID) |
| 88 | |
| 89 | viewer := middleware.CurrentUser(c) |
| 90 | data := h.baseData(c, h.tr(c, "pulls.title")+" · "+r.OwnerUsername+"/"+r.Name) |
| 91 | data["Repo"] = r |
| 92 | data["Ref"] = r.DefaultBranch |
| 93 | data["ActiveTab"] = "pulls" |
| 94 | data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) |
| 95 | data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) |
| 96 | data["Pulls"] = pulls |
| 97 | data["CurrentState"] = state |
| 98 | data["OpenCount"] = openCount |
| 99 | data["ClosedCount"] = closedCount |
| 100 | data["MergedCount"] = mergedCount |
| 101 | |
| 102 | return h.render(c, fiber.StatusOK, "pulls", data) |
| 103 | } |
| 104 | |
| 105 | func (h *Handler) RepoNewPullPage(c *fiber.Ctx) error { |
| 106 | r, diskPath, err := h.loadAuthorizedRepo(c, false) |
| 107 | if err != nil { |
| 108 | return nil |
| 109 | } |
| 110 | if !r.EnablePulls { |
| 111 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 112 | } |
| 113 | |
| 114 | viewer := middleware.CurrentUser(c) |
| 115 | candidates := h.headRepoCandidates(c, r, viewer) |
| 116 | |
| 117 | headRepo := r |
| 118 | if v := strings.TrimSpace(c.Query("head_repo")); v != "" { |
| 119 | if id, convErr := strconv.ParseInt(v, 10, 64); convErr == nil { |
| 120 | if found := h.findHeadRepo(candidates, id); found != nil { |
| 121 | headRepo = found |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | headDisk, err := h.repoSvc.DiskPath(headRepo.OwnerUsername, headRepo.Name) |
| 127 | if err != nil { |
| 128 | headDisk = diskPath |
| 129 | } |
| 130 | |
| 131 | branches, _ := git.ListBranches(c.UserContext(), headDisk) |
| 132 | baseBranches, _ := git.ListBranches(c.UserContext(), diskPath) |
| 133 | baseBranch := strings.TrimSpace(c.Query("base")) |
| 134 | if baseBranch == "" { |
| 135 | baseBranch = r.DefaultBranch |
| 136 | } |
| 137 | headBranch := strings.TrimSpace(c.Query("head")) |
| 138 | if headBranch == "" && len(branches) > 1 { |
| 139 | for _, b := range branches { |
| 140 | if b != baseBranch || headRepo.ID != r.ID { |
| 141 | headBranch = b |
| 142 | break |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | data := h.baseData(c, h.tr(c, "pulls.new")+" · "+r.OwnerUsername+"/"+r.Name) |
| 148 | data["Repo"] = r |
| 149 | data["Ref"] = r.DefaultBranch |
| 150 | data["ActiveTab"] = "pulls" |
| 151 | data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) |
| 152 | data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) |
| 153 | data["Branches"] = branches |
| 154 | data["BaseBranches"] = baseBranches |
| 155 | data["BaseBranch"] = baseBranch |
| 156 | data["HeadBranch"] = headBranch |
| 157 | data["HeadRepo"] = headRepo |
| 158 | data["HeadRepos"] = candidates |
| 159 | |
| 160 | if baseBranch != "" && headBranch != "" && (baseBranch != headBranch || headRepo.ID != r.ID) { |
| 161 | headRef, _, cleanup, refErr := h.resolveHeadRef(c, r, diskPath, headRepo.ID, headBranch) |
| 162 | if refErr == nil { |
| 163 | defer cleanup() |
| 164 | diffDetail, cmpCommits, cmpErr := git.CompareBranches(c.UserContext(), diskPath, baseBranch, headRef) |
| 165 | if cmpErr == nil { |
| 166 | data["Compare"] = diffDetail |
| 167 | data["CompareCommits"] = cmpCommits |
| 168 | } |
| 169 | canMerge, _, _ := git.CanMergeBranches(c.UserContext(), diskPath, baseBranch, headRef) |
| 170 | data["CanMerge"] = canMerge |
| 171 | } else { |
| 172 | data["CompareError"] = refErr.Error() |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | return h.render(c, fiber.StatusOK, "pull_new", data) |
| 177 | } |
| 178 | |
| 179 | func (h *Handler) RepoNewPullSubmit(c *fiber.Ctx) error { |
| 180 | r, _, err := h.loadAuthorizedRepo(c, false) |
| 181 | if err != nil { |
| 182 | return nil |
| 183 | } |
| 184 | if !r.EnablePulls { |
| 185 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 186 | } |
| 187 | |
| 188 | u := middleware.CurrentUser(c) |
| 189 | title := strings.TrimSpace(c.FormValue("title")) |
| 190 | body := strings.TrimSpace(c.FormValue("body")) |
| 191 | baseBranch := strings.TrimSpace(c.FormValue("base_branch")) |
| 192 | headBranch := strings.TrimSpace(c.FormValue("head_branch")) |
| 193 | headRepoRaw := strings.TrimSpace(c.FormValue("head_repo")) |
| 194 | |
| 195 | var headRepoID *int64 |
| 196 | if headRepoRaw != "" { |
| 197 | if id, convErr := strconv.ParseInt(headRepoRaw, 10, 64); convErr == nil && id != r.ID { |
| 198 | candidates := h.headRepoCandidates(c, r, u) |
| 199 | if h.findHeadRepo(candidates, id) == nil { |
| 200 | h.setFlashT(c, "flash.error", "err.pr_head_repo_invalid") |
| 201 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/new", r.OwnerUsername, r.Name), fiber.StatusSeeOther) |
| 202 | } |
| 203 | headRepoID = &id |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | pr, err := h.repoSvc.CreatePullRequest(c.UserContext(), r.ID, u.ID, title, body, baseBranch, headBranch, headRepoID) |
| 208 | if err != nil { |
| 209 | h.setFlashT(c, "flash.error", err.Error()) |
| 210 | redirect := fmt.Sprintf("/%s/%s/pulls/new?base=%s&head=%s", r.OwnerUsername, r.Name, baseBranch, headBranch) |
| 211 | if headRepoID != nil { |
| 212 | redirect += fmt.Sprintf("&head_repo=%d", *headRepoID) |
| 213 | } |
| 214 | return c.Redirect(redirect, fiber.StatusSeeOther) |
| 215 | } |
| 216 | |
| 217 | h.setFlashT(c, "flash.pr_created", pr.Number) |
| 218 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 219 | } |
| 220 | |
| 221 | func (h *Handler) RepoPullDetailPage(c *fiber.Ctx) error { |
| 222 | r, diskPath, err := h.loadAuthorizedRepo(c, false) |
| 223 | if err != nil { |
| 224 | return nil |
| 225 | } |
| 226 | if !r.EnablePulls { |
| 227 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 228 | } |
| 229 | |
| 230 | num, _ := strconv.Atoi(c.Params("number")) |
| 231 | pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) |
| 232 | if err != nil { |
| 233 | return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") |
| 234 | } |
| 235 | |
| 236 | comments, _ := h.repoSvc.ListPullRequestComments(c.UserContext(), pr.ID) |
| 237 | reviews, _ := h.repoSvc.ListReviews(c.UserContext(), pr.ID) |
| 238 | approved, changesRequested := h.repoSvc.ReviewCounts(c.UserContext(), pr.ID) |
| 239 | |
| 240 | var headRef string |
| 241 | var cleanup func() |
| 242 | if pr.HeadRepoID != nil && *pr.HeadRepoID != r.ID { |
| 243 | ref, _, cl, refErr := h.resolveHeadRef(c, r, diskPath, *pr.HeadRepoID, pr.HeadBranch) |
| 244 | if refErr == nil { |
| 245 | headRef = ref |
| 246 | cleanup = cl |
| 247 | defer cleanup() |
| 248 | } |
| 249 | } else { |
| 250 | headRef = pr.HeadBranch |
| 251 | } |
| 252 | |
| 253 | var diffDetail *git.CommitDetail |
| 254 | var cmpCommits []git.Commit |
| 255 | canMerge := false |
| 256 | if headRef != "" { |
| 257 | diffDetail, cmpCommits, _ = git.CompareBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef) |
| 258 | canMerge, _, _ = git.CanMergeBranches(c.UserContext(), diskPath, pr.BaseBranch, headRef) |
| 259 | } |
| 260 | |
| 261 | viewer := middleware.CurrentUser(c) |
| 262 | canWrite := h.repoSvc.CanWrite(c.UserContext(), r, viewer) |
| 263 | canToggle := viewer != nil && (viewer.ID == pr.AuthorID || canWrite) |
| 264 | canMergeAction := viewer != nil && canWrite && pr.Status == "open" && canMerge |
| 265 | canReview := viewer != nil && viewer.ID != pr.AuthorID && pr.Status == "open" |
| 266 | |
| 267 | subTab := c.Query("tab", "conversation") |
| 268 | if subTab != "conversation" && subTab != "commits" && subTab != "files" { |
| 269 | subTab = "conversation" |
| 270 | } |
| 271 | |
| 272 | data := h.baseData(c, fmt.Sprintf("#%d: %s · %s/%s", pr.Number, pr.Title, r.OwnerUsername, r.Name)) |
| 273 | data["Repo"] = r |
| 274 | data["Ref"] = r.DefaultBranch |
| 275 | data["ActiveTab"] = "pulls" |
| 276 | data["CanWrite"] = canWrite |
| 277 | data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) |
| 278 | data["PR"] = pr |
| 279 | data["Comments"] = comments |
| 280 | data["Reviews"] = reviews |
| 281 | data["ApprovedCount"] = approved |
| 282 | data["ChangesRequestedCount"] = changesRequested |
| 283 | data["CanReview"] = canReview |
| 284 | data["Compare"] = diffDetail |
| 285 | data["CompareCommits"] = cmpCommits |
| 286 | data["CanMerge"] = canMerge |
| 287 | data["CanToggle"] = canToggle |
| 288 | data["CanMergeAction"] = canMergeAction |
| 289 | data["SubTab"] = subTab |
| 290 | data["BodyHTML"] = views.RenderMarkdown([]byte(pr.Body)) |
| 291 | |
| 292 | return h.render(c, fiber.StatusOK, "pull_detail", data) |
| 293 | } |
| 294 | |
| 295 | func (h *Handler) RepoPullCommentSubmit(c *fiber.Ctx) error { |
| 296 | r, _, err := h.loadAuthorizedRepo(c, false) |
| 297 | if err != nil { |
| 298 | return nil |
| 299 | } |
| 300 | if !r.EnablePulls { |
| 301 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 302 | } |
| 303 | |
| 304 | num, _ := strconv.Atoi(c.Params("number")) |
| 305 | pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) |
| 306 | if err != nil { |
| 307 | return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") |
| 308 | } |
| 309 | |
| 310 | u := middleware.CurrentUser(c) |
| 311 | body := strings.TrimSpace(c.FormValue("body")) |
| 312 | if err := h.repoSvc.CreatePullRequestComment(c.UserContext(), pr.ID, u.ID, body); err != nil { |
| 313 | h.setFlashT(c, "flash.comment_error", err.Error()) |
| 314 | } |
| 315 | |
| 316 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 317 | } |
| 318 | |
| 319 | func (h *Handler) RepoPullStateSubmit(c *fiber.Ctx) error { |
| 320 | r, _, err := h.loadAuthorizedRepo(c, false) |
| 321 | if err != nil { |
| 322 | return nil |
| 323 | } |
| 324 | if !r.EnablePulls { |
| 325 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 326 | } |
| 327 | |
| 328 | num, _ := strconv.Atoi(c.Params("number")) |
| 329 | pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) |
| 330 | if err != nil { |
| 331 | return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") |
| 332 | } |
| 333 | |
| 334 | viewer := middleware.CurrentUser(c) |
| 335 | if viewer == nil || (viewer.ID != pr.AuthorID && !h.repoSvc.CanWrite(c.UserContext(), r, viewer)) { |
| 336 | return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_state_forbidden") |
| 337 | } |
| 338 | |
| 339 | if pr.Status == "merged" { |
| 340 | h.setFlashT(c, "flash.pr_merged_state") |
| 341 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 342 | } |
| 343 | |
| 344 | targetState := strings.TrimSpace(c.FormValue("state")) |
| 345 | if targetState == "closed" { |
| 346 | _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "closed", nil) |
| 347 | h.setFlashT(c, "flash.pr_closed", pr.Number) |
| 348 | } else if targetState == "open" { |
| 349 | _ = h.repoSvc.SetPullRequestStatus(c.UserContext(), pr.ID, "open", nil) |
| 350 | h.setFlashT(c, "flash.pr_reopened", pr.Number) |
| 351 | } |
| 352 | |
| 353 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 354 | } |
| 355 | |
| 356 | func (h *Handler) RepoPullMergeSubmit(c *fiber.Ctx) error { |
| 357 | r, _, err := h.loadAuthorizedRepo(c, true) |
| 358 | if err != nil { |
| 359 | return nil |
| 360 | } |
| 361 | if !r.EnablePulls { |
| 362 | return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.pulls_disabled") |
| 363 | } |
| 364 | |
| 365 | num, _ := strconv.Atoi(c.Params("number")) |
| 366 | pr, err := h.repoSvc.GetPullRequest(c.UserContext(), r.ID, num) |
| 367 | if err != nil { |
| 368 | return h.renderErrorT(c, fiber.StatusNotFound, "err.pr_not_found", "err.pr_not_found_desc") |
| 369 | } |
| 370 | |
| 371 | if pr.Status != "open" { |
| 372 | h.setFlashT(c, "flash.pr_merge_open_only") |
| 373 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 374 | } |
| 375 | |
| 376 | viewer := middleware.CurrentUser(c) |
| 377 | if !h.repoSvc.CanWrite(c.UserContext(), r, viewer) { |
| 378 | return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.pr_merge_forbidden") |
| 379 | } |
| 380 | |
| 381 | authorName := viewer.FullName |
| 382 | if authorName == "" { |
| 383 | authorName = viewer.Username |
| 384 | } |
| 385 | |
| 386 | if mergeErr := h.repoSvc.MergePullRequest(c.UserContext(), r, pr, authorName, viewer.Email, viewer.ID); mergeErr != nil { |
| 387 | h.setFlashT(c, "flash.pr_merge_failed", mergeErr.Error()) |
| 388 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 389 | } |
| 390 | |
| 391 | h.setFlashT(c, "flash.pr_merged", pr.Number) |
| 392 | return c.Redirect(fmt.Sprintf("/%s/%s/pulls/%d", r.OwnerUsername, r.Name, pr.Number), fiber.StatusSeeOther) |
| 393 | } |
| 394 |