rin
Публичный Open-source lightweight git hosting.
pulls.go · 393 строки · 13.0 KB
Raw
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