Go · 37962 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repo
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "html/template"
10 "net/http"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/go-chi/chi/v5"
17 "github.com/jackc/pgx/v5"
18 "github.com/jackc/pgx/v5/pgtype"
19
20 "github.com/tenseleyFlow/shithub/internal/auth/policy"
21 checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
22 "github.com/tenseleyFlow/shithub/internal/issues"
23 mdrender "github.com/tenseleyFlow/shithub/internal/markdown"
24 "github.com/tenseleyFlow/shithub/internal/pulls"
25 pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
26 repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
27 "github.com/tenseleyFlow/shithub/internal/repos/identity"
28 reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
29 "github.com/tenseleyFlow/shithub/internal/social"
30 "github.com/tenseleyFlow/shithub/internal/web/middleware"
31 "github.com/tenseleyFlow/shithub/internal/worker"
32 )
33
34 type pullPageStats struct {
35 Comments int
36 Commits int
37 Files int
38 Checks int
39 SuccessfulChecks int
40 PendingChecks int
41 FailedChecks int
42 CheckState string
43 }
44
45 type pullCheckRunView struct {
46 R checksdb.CheckRun
47 SummaryHTML template.HTML
48 AppSlug string
49 }
50
51 type pullCheckSuiteView struct {
52 Suite checksdb.CheckSuite
53 Runs []pullCheckRunView
54 }
55
56 type pullFileView struct {
57 F pullsdb.PullRequestFile
58 Anchor string
59 Dir string
60 Name string
61 }
62
63 type pullCommitView struct {
64 C pullsdb.PullRequestCommit
65 Author identity.Resolved
66 ShortSHA string
67 When time.Time
68 HasWhen bool
69 AuthorLabel string
70 }
71
72 type pullCommitGroup struct {
73 Title string
74 Commits []pullCommitView
75 }
76
77 // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are
78 // public (subject to policy.Can(ActionPullRead)); writes require auth.
79 // The merge route runs synchronously inside the request: pulls.Merge
80 // performs the worktree operation, updates DB state, and the response
81 // redirects the user straight to the merged view. (An async-merge path
82 // can be reintroduced when very-large-repo merges become a real
83 // concern; the worker registration was deleted alongside the unused
84 // KindPRMerge in the audit remediation sprint.)
85 func (h *Handlers) MountPulls(r chi.Router) {
86 r.Get("/{owner}/{repo}/pulls", h.pullsList)
87 r.Get("/{owner}/{repo}/pulls/{number}.diff", h.pullRawDiff)
88 r.Get("/{owner}/{repo}/pulls/{number}.patch", h.pullRawDiff)
89 r.Get("/{owner}/{repo}/pulls/{number}", h.pullView)
90 r.Get("/{owner}/{repo}/pulls/{number}/files", h.pullFiles)
91 r.Get("/{owner}/{repo}/pulls/{number}/commits", h.pullCommits)
92 r.Get("/{owner}/{repo}/pulls/{number}/checks", h.pullChecks)
93
94 r.Group(func(r chi.Router) {
95 r.Use(middleware.RequireUser)
96 r.Get("/{owner}/{repo}/pulls/new", h.pullNewForm)
97 r.Post("/{owner}/{repo}/pulls", h.pullCreate)
98 r.Post("/{owner}/{repo}/pulls/{number}/edit", h.pullEdit)
99 r.Post("/{owner}/{repo}/pulls/{number}/state", h.pullSetState)
100 r.Post("/{owner}/{repo}/pulls/{number}/ready", h.pullSetReady)
101 r.Post("/{owner}/{repo}/pulls/{number}/merge", h.pullMerge)
102 r.Post("/{owner}/{repo}/pulls/{number}/delete-branch", h.pullDeleteHeadBranch)
103 })
104 // S23 review surface — its own group so the auth-required wrapper
105 // is shared cleanly without rewriting this file's existing one.
106 h.MountPullReview(r)
107 }
108
109 func (h *Handlers) pullsDeps() pulls.Deps {
110 return pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}
111 }
112
113 // pullsList renders /{owner}/{repo}/pulls.
114 func (h *Handlers) pullsList(w http.ResponseWriter, r *http.Request) {
115 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
116 if !ok {
117 return
118 }
119 state := r.URL.Query().Get("state")
120 if state == "" {
121 state = "open"
122 }
123 stateFilter := pgtype.Text{}
124 if state == "open" || state == "closed" {
125 stateFilter = pgtype.Text{String: state, Valid: true}
126 }
127 page, _ := strconv.Atoi(r.URL.Query().Get("page"))
128 if page < 1 {
129 page = 1
130 }
131 const perPage = 25
132 q := pullsdb.New()
133 rows, err := q.ListPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.ListPullRequestsByRepoParams{
134 RepoID: row.ID,
135 StateFilter: stateFilter,
136 Limit: perPage,
137 Offset: int32((page - 1) * perPage),
138 })
139 if err != nil {
140 h.d.Logger.WarnContext(r.Context(), "pulls: list", "error", err)
141 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
142 return
143 }
144 openCount, _ := q.CountPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.CountPullRequestsByRepoParams{
145 RepoID: row.ID, StateFilter: pgtype.Text{String: "open", Valid: true},
146 })
147 closedCount, _ := q.CountPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.CountPullRequestsByRepoParams{
148 RepoID: row.ID, StateFilter: pgtype.Text{String: "closed", Valid: true},
149 })
150
151 type listItem struct {
152 Row pullsdb.ListPullRequestsByRepoRow
153 AuthorName string
154 }
155 items := make([]listItem, 0, len(rows))
156 for _, lr := range rows {
157 it := listItem{Row: lr}
158 if lr.AuthorUserID.Valid {
159 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, lr.AuthorUserID.Int64); err == nil {
160 it.AuthorName = u.Username
161 }
162 }
163 items = append(items, it)
164 }
165 w.Header().Set("Content-Type", "text/html; charset=utf-8")
166 _ = h.d.Render.RenderPage(w, r, "repo/pulls_list", map[string]any{
167 "Title": "Pull requests · " + row.Name,
168 "Owner": owner.Username,
169 "Repo": row,
170 "Items": items,
171 "State": state,
172 "OpenCount": openCount,
173 "ClosedCount": closedCount,
174 "Page": page,
175 "CSRFToken": middleware.CSRFTokenForRequest(r),
176 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
177 "CanSettings": h.canViewSettings(middleware.CurrentUserFromContext(r.Context())),
178 "ActiveSubnav": "pulls",
179 })
180 }
181
182 // pullNewForm renders the open-PR form. base and head come from the
183 // query string (typically the compare view's "Open PR" link).
184 func (h *Handlers) pullNewForm(w http.ResponseWriter, r *http.Request) {
185 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate)
186 if !ok {
187 return
188 }
189 base := r.URL.Query().Get("base")
190 if base == "" {
191 base = row.DefaultBranch
192 }
193 head := r.URL.Query().Get("head")
194 if strings.TrimSpace(head) == "" {
195 http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/compare", http.StatusSeeOther)
196 return
197 }
198 h.renderPullNewForm(w, r, owner.Username, row, pullNewFormOptions{
199 Base: base,
200 Head: head,
201 })
202 }
203
204 type pullNewFormOptions struct {
205 Base string
206 Head string
207 FormTitle string
208 FormBody string
209 Error string
210 Status int
211 }
212
213 func (h *Handlers) renderPullNewForm(w http.ResponseWriter, r *http.Request, owner string, row reposdb.Repo, opts pullNewFormOptions) {
214 gitDir, err := h.d.RepoFS.RepoPath(owner, row.Name)
215 if err != nil {
216 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
217 return
218 }
219 base := strings.TrimSpace(opts.Base)
220 if base == "" {
221 base = row.DefaultBranch
222 }
223 head := strings.TrimSpace(opts.Head)
224 if head == "" {
225 head = row.DefaultBranch
226 }
227 state := h.buildCompareState(r, owner, row, gitDir, base, head, true, compareMenuTargetPullNew)
228 formTitle := opts.FormTitle
229 if strings.TrimSpace(formTitle) == "" && opts.Error == "" {
230 formTitle = defaultPullTitle(state.Head, state.Commits)
231 }
232 viewer := middleware.CurrentUserFromContext(r.Context())
233 status := opts.Status
234 if status == 0 {
235 status = http.StatusOK
236 }
237 w.Header().Set("Content-Type", "text/html; charset=utf-8")
238 if status != http.StatusOK {
239 w.WriteHeader(status)
240 }
241 _ = h.d.Render.RenderPage(w, r, "repo/pull_new", mergePageData(
242 h.repoPageChrome(r, owner, row, "pulls"),
243 map[string]any{
244 "Title": "Open a pull request · " + row.Name,
245 "UseCompareJS": true,
246 "UseCommentEditor": true,
247 "CommentEditorConfig": commentEditorConfigJSON(h.pullNewCommentEditorConfig(r.Context(), row, viewer)),
248 "Viewer": viewer,
249 "ViewerAvatarURL": commentEditorAvatarURL(viewer.Username),
250 "Error": opts.Error,
251 "FormTitle": formTitle,
252 "FormBody": opts.FormBody,
253 "Base": state.Base,
254 "Head": state.Head,
255 "HasSelection": state.HasSelection,
256 "SameRef": state.SameRef,
257 "NotFound": state.NotFound,
258 "CommitsErr": state.CommitsErr,
259 "NoCommits": state.NoCommits,
260 "Ahead": state.Ahead,
261 "Behind": state.Behind,
262 "Commits": state.Commits,
263 "DiffHTML": state.DiffHTML,
264 "Stats": state.Stats,
265 "MergeState": state.MergeState,
266 "CanOpenPull": state.CanOpenPull,
267 "CanCreatePull": state.CanOpenPull && !state.NotFound && !state.CommitsErr,
268 "PullNewHref": state.PullNewHref,
269 "BaseMenu": state.BaseMenu,
270 "HeadMenu": state.HeadMenu,
271 },
272 ))
273 }
274
275 // pullCreate handles POST /{owner}/{repo}/pulls.
276 func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) {
277 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate)
278 if !ok {
279 return
280 }
281 if err := r.ParseForm(); err != nil {
282 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
283 return
284 }
285 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
286 if err != nil {
287 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
288 return
289 }
290 viewer := middleware.CurrentUserFromContext(r.Context())
291 res, err := pulls.Create(r.Context(), h.pullsDeps(), pulls.CreateParams{
292 RepoID: row.ID,
293 AuthorUserID: viewer.ID,
294 Title: r.PostFormValue("title"),
295 Body: r.PostFormValue("body"),
296 BaseRef: r.PostFormValue("base"),
297 HeadRef: r.PostFormValue("head"),
298 Draft: r.PostFormValue("draft") == "on",
299 GitDir: gitDir,
300 })
301 if err != nil {
302 h.handlePullCreateError(w, r, owner.Username, row, err)
303 return
304 }
305 // Auto-watch on first involvement (S26): subscribe the PR author
306 // at `participating` so notifications fan-out routes future
307 // thread events to them.
308 _ = social.AutoWatchOnInvolvement(r.Context(), h.socialDeps(), viewer.ID, row.ID)
309 // Kick off the mergeability probe right away.
310 if _, err := worker.Enqueue(r.Context(), h.d.Pool, worker.KindPRMergeability,
311 map[string]any{"pr_id": res.PullRequest.IssueID}, worker.EnqueueOptions{}); err != nil {
312 h.d.Logger.WarnContext(r.Context(), "pulls: enqueue mergeability", "error", err)
313 }
314 _ = worker.Notify(r.Context(), h.d.Pool)
315 http.Redirect(
316 w, r,
317 "/"+owner.Username+"/"+row.Name+"/pulls/"+strconv.FormatInt(res.Issue.Number, 10),
318 http.StatusSeeOther,
319 )
320 }
321
322 func (h *Handlers) handlePullCreateError(w http.ResponseWriter, r *http.Request, owner string, row reposdb.Repo, err error) {
323 msg := "Could not open the pull request."
324 switch {
325 case errors.Is(err, pulls.ErrSameBranch):
326 msg = "Base and head must differ."
327 case errors.Is(err, pulls.ErrBaseNotFound):
328 msg = "Base branch not found."
329 case errors.Is(err, pulls.ErrHeadNotFound):
330 msg = "Head branch not found."
331 case errors.Is(err, pulls.ErrNoCommitsToMerge):
332 msg = "Head has no commits ahead of base."
333 case errors.Is(err, issues.ErrEmptyTitle):
334 msg = "Title is required."
335 case errors.Is(err, issues.ErrTitleTooLong):
336 msg = "Title is too long (max 256)."
337 case errors.Is(err, issues.ErrBodyTooLong):
338 msg = "Body is too long."
339 }
340 h.renderPullNewForm(w, r, owner, row, pullNewFormOptions{
341 Base: r.PostFormValue("base"),
342 Head: r.PostFormValue("head"),
343 FormTitle: r.PostFormValue("title"),
344 FormBody: r.PostFormValue("body"),
345 Error: msg,
346 Status: http.StatusBadRequest,
347 })
348 }
349
350 // loadPullByNumber resolves the URL number into the joined PR + issue
351 // row; renders 404 on miss.
352 func (h *Handlers) loadPullByNumber(w http.ResponseWriter, r *http.Request, repoID int64) (pullsdb.GetPullRequestByRepoAndNumberRow, bool) {
353 num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
354 if err != nil {
355 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
356 return pullsdb.GetPullRequestByRepoAndNumberRow{}, false
357 }
358 row, err := pullsdb.New().GetPullRequestByRepoAndNumber(r.Context(), h.d.Pool, pullsdb.GetPullRequestByRepoAndNumberParams{
359 RepoID: repoID, Number: num,
360 })
361 if err != nil {
362 if errors.Is(err, pgx.ErrNoRows) {
363 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
364 } else {
365 h.d.Logger.WarnContext(r.Context(), "pulls: load", "error", err)
366 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
367 }
368 return pullsdb.GetPullRequestByRepoAndNumberRow{}, false
369 }
370 return row, true
371 }
372
373 // renderPullPage is the common preamble for the four PR tab views.
374 func (h *Handlers) renderPullPage(w http.ResponseWriter, r *http.Request, tab string, extras map[string]any) {
375 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
376 if !ok {
377 return
378 }
379 pr, ok := h.loadPullByNumber(w, r, row.ID)
380 if !ok {
381 return
382 }
383 authorName := ""
384 if pr.IAuthorUserID.Valid {
385 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, pr.IAuthorUserID.Int64); err == nil {
386 authorName = u.Username
387 }
388 }
389 mergedByName := ""
390 if pr.MergedByUserID.Valid {
391 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, pr.MergedByUserID.Int64); err == nil {
392 mergedByName = u.Username
393 }
394 }
395 if pr.IState == pullsdb.IssueStateOpen &&
396 pr.MergeableState == pullsdb.PrMergeableStateUnknown &&
397 pr.BaseOid != "" && pr.HeadOid != "" {
398 h.kickMergeability(r, pr.IID)
399 }
400 viewer := middleware.CurrentUserFromContext(r.Context())
401 actor := viewer.PolicyActor()
402 pdeps := policy.Deps{Pool: h.d.Pool}
403 repoRef := policy.NewRepoRefFromRepo(row)
404 stateRef := repoRef
405 if pr.IAuthorUserID.Valid {
406 stateRef.AuthorUserID = pr.IAuthorUserID.Int64
407 }
408 canReviewPull := policy.Can(r.Context(), pdeps, actor, policy.ActionPullReview, repoRef).Allow
409 canMergePull := policy.Can(r.Context(), pdeps, actor, policy.ActionPullMerge, repoRef).Allow
410 canSetPullState := policy.Can(r.Context(), pdeps, actor, policy.ActionPullClose, stateRef).Allow
411 canReadyPull := policy.Can(r.Context(), pdeps, actor, policy.ActionPullCreate, repoRef).Allow
412 headOwner := owner.Username
413 if pr.HeadRepoID != 0 {
414 if headRepo, err := h.rq.GetRepoOwnerUsernameByID(r.Context(), h.d.Pool, pr.HeadRepoID); err == nil {
415 if ownerName := repoOwnerName(headRepo.OwnerUsername); ownerName != "" {
416 headOwner = ownerName
417 }
418 }
419 }
420 headBranchExists := false
421 if pr.HeadRepoID == row.ID && pr.HeadRef != "" && pr.HeadRef != row.DefaultBranch {
422 if gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name); err == nil {
423 if _, err := repogit.ResolveRefOID(r.Context(), gitDir, "refs/heads/"+pr.HeadRef); err == nil {
424 headBranchExists = true
425 }
426 }
427 }
428 defaultMethod := string(row.DefaultMergeMethod)
429 if defaultMethod == "" {
430 defaultMethod = "merge"
431 }
432 checkGroups := h.pullCheckGroups(r.Context(), row.ID, pr.HeadOid)
433 stats := h.pullStats(r.Context(), pr, checkGroups)
434 data := map[string]any{
435 "Title": "#" + strconv.FormatInt(pr.INumber, 10) + " " + pr.ITitle + " · " + row.Name,
436 "Owner": owner.Username,
437 "Repo": row,
438 "PR": pr,
439 "AuthorName": authorName,
440 "MergedByName": mergedByName,
441 "Tab": tab,
442 "PullStats": stats,
443 "CheckGroups": checkGroups,
444 "CSRFToken": middleware.CSRFTokenForRequest(r),
445 "RepoActions": h.repoActions(r, row.ID),
446 "RepoCounts": h.subnavCounts(r.Context(), row.ID, row.ForkCount),
447 "CanSettings": h.canViewSettings(viewer),
448 "ActiveSubnav": "pulls",
449 "UsePullViewJS": true,
450 "MergeDefaultMethod": defaultMethod,
451 "MergeFormSubject": defaultMergeSubject(pr, headOwner),
452 "MergeFormBody": strings.TrimSpace(pr.ITitle),
453 "MergeAuthorLine": h.mergeAuthorLine(r.Context(), viewer),
454 "CanDeleteHeadBranch": canMergePull && pr.MergedAt.Valid && pr.HeadRepoID == row.ID && pr.HeadRef != row.DefaultBranch && headBranchExists,
455 "HeadBranchAlreadyGone": pr.MergedAt.Valid && pr.HeadRepoID == row.ID && pr.HeadRef != row.DefaultBranch && !headBranchExists,
456 }
457 data["CanReviewPull"] = canReviewPull
458 data["CanMergePull"] = canMergePull
459 data["CanSetPullState"] = canSetPullState
460 data["CanReadyPull"] = canReadyPull
461 for k, v := range extras {
462 data[k] = v
463 }
464 w.Header().Set("Content-Type", "text/html; charset=utf-8")
465 _ = h.d.Render.RenderPage(w, r, "repo/pull_view", data)
466 }
467
468 func repoOwnerName(raw interface{}) string {
469 switch v := raw.(type) {
470 case string:
471 return v
472 case []byte:
473 return string(v)
474 default:
475 return ""
476 }
477 }
478
479 func defaultMergeSubject(pr pullsdb.GetPullRequestByRepoAndNumberRow, headOwner string) string {
480 head := strings.TrimSpace(headOwner)
481 if head == "" {
482 head = "head"
483 }
484 if pr.HeadRef != "" {
485 head += "/" + pr.HeadRef
486 }
487 return "Merge pull request #" + strconv.FormatInt(pr.INumber, 10) + " from " + head
488 }
489
490 func (h *Handlers) mergeAuthorLine(ctx context.Context, viewer middleware.CurrentUser) string {
491 if viewer.IsAnonymous() {
492 return ""
493 }
494 email := viewer.Username + "@noreply.shithub.local"
495 if u, err := h.uq.GetUserByID(ctx, h.d.Pool, viewer.ID); err == nil && u.PrimaryEmailID.Valid {
496 if em, err := h.uq.GetUserEmailByID(ctx, h.d.Pool, u.PrimaryEmailID.Int64); err == nil && em.Verified {
497 email = string(em.Email)
498 }
499 }
500 return "This commit will be authored by " + email + "."
501 }
502
503 func (h *Handlers) pullStats(ctx context.Context, pr pullsdb.GetPullRequestByRepoAndNumberRow, checkGroups []pullCheckSuiteView) pullPageStats {
504 stats := pullPageStats{CheckState: "none"}
505 if comments, err := h.iq.ListIssueComments(ctx, h.d.Pool, pr.IID); err == nil {
506 stats.Comments = len(comments)
507 }
508 if commits, err := h.pq.ListPullRequestCommits(ctx, h.d.Pool, pr.IID); err == nil {
509 stats.Commits = len(commits)
510 }
511 if files, err := h.pq.ListPullRequestFiles(ctx, h.d.Pool, pr.IID); err == nil {
512 stats.Files = len(files)
513 }
514 for _, group := range checkGroups {
515 for _, run := range group.Runs {
516 stats.Checks++
517 if run.R.Conclusion.Valid {
518 switch run.R.Conclusion.CheckConclusion {
519 case checksdb.CheckConclusionSuccess, checksdb.CheckConclusionSkipped, checksdb.CheckConclusionNeutral:
520 stats.SuccessfulChecks++
521 case checksdb.CheckConclusionFailure, checksdb.CheckConclusionCancelled, checksdb.CheckConclusionTimedOut, checksdb.CheckConclusionActionRequired, checksdb.CheckConclusionStale:
522 stats.FailedChecks++
523 default:
524 stats.PendingChecks++
525 }
526 continue
527 }
528 stats.PendingChecks++
529 }
530 }
531 switch {
532 case stats.Checks == 0:
533 stats.CheckState = "none"
534 case stats.FailedChecks > 0:
535 stats.CheckState = "failure"
536 case stats.PendingChecks > 0:
537 stats.CheckState = "pending"
538 default:
539 stats.CheckState = "success"
540 }
541 return stats
542 }
543
544 func (h *Handlers) pullCheckGroups(ctx context.Context, repoID int64, headOID string) []pullCheckSuiteView {
545 groups := []pullCheckSuiteView{}
546 if headOID == "" {
547 return groups
548 }
549 suites, _ := h.cq.ListCheckSuitesForCommit(ctx, h.d.Pool, checksdb.ListCheckSuitesForCommitParams{
550 RepoID: repoID, HeadSha: headOID,
551 })
552 for _, suite := range suites {
553 runs, _ := h.cq.ListCheckRunsBySuite(ctx, h.d.Pool, suite.ID)
554 rs := make([]pullCheckRunView, 0, len(runs))
555 for _, run := range runs {
556 rs = append(rs, pullCheckRunView{
557 R: run,
558 SummaryHTML: renderCheckSummary(run.Output),
559 AppSlug: suite.AppSlug,
560 })
561 }
562 groups = append(groups, pullCheckSuiteView{Suite: suite, Runs: rs})
563 }
564 return groups
565 }
566
567 // pullView renders the Conversation tab.
568 func (h *Handlers) pullView(w http.ResponseWriter, r *http.Request) {
569 // Resolve to grab issue id for comments+events.
570 row, _, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
571 if !ok {
572 return
573 }
574 pr, ok := h.loadPullByNumber(w, r, row.ID)
575 if !ok {
576 return
577 }
578 comments, _ := h.iq.ListIssueComments(r.Context(), h.d.Pool, pr.IID)
579 events, _ := h.iq.ListIssueEvents(r.Context(), h.d.Pool, pr.IID)
580 labels, _ := h.iq.ListLabelsOnIssue(r.Context(), h.d.Pool, pr.IID)
581 assignees, _ := h.iq.ListIssueAssignees(r.Context(), h.d.Pool, pr.IID)
582 allLabels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
583 milestones, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
584
585 usernames := map[int64]string{}
586 usernameFor := func(id int64) string {
587 if id == 0 {
588 return ""
589 }
590 if name, ok := usernames[id]; ok {
591 return name
592 }
593 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, id); err == nil {
594 usernames[id] = u.Username
595 return u.Username
596 }
597 return ""
598 }
599 timeline := h.issueTimelineRows(comments, events, allLabels, milestones, usernameFor)
600
601 // Reviews + reviewer requests for the Conversation sidebar.
602 reviews, _ := h.pq.ListPRReviews(r.Context(), h.d.Pool, pr.IID)
603 type reviewRow struct {
604 R pullsdb.PrReview
605 AuthorName string
606 }
607 rs := make([]reviewRow, 0, len(reviews))
608 for _, rv := range reviews {
609 rr := reviewRow{R: rv}
610 if rv.AuthorUserID.Valid {
611 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, rv.AuthorUserID.Int64); err == nil {
612 rr.AuthorName = u.Username
613 }
614 }
615 rs = append(rs, rr)
616 }
617 requests, _ := h.pq.ListPRReviewRequests(r.Context(), h.d.Pool, pr.IID)
618 type reqRow struct {
619 R pullsdb.PrReviewRequest
620 Username string
621 }
622 reqs := make([]reqRow, 0, len(requests))
623 for _, rq := range requests {
624 rr := reqRow{R: rq}
625 if rq.RequestedUserID.Valid {
626 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, rq.RequestedUserID.Int64); err == nil {
627 rr.Username = u.Username
628 }
629 }
630 reqs = append(reqs, rr)
631 }
632 viewer := middleware.CurrentUserFromContext(r.Context())
633 actor := viewer.PolicyActor()
634 pdeps := policy.Deps{Pool: h.d.Pool}
635 repoRef := policy.NewRepoRefFromRepo(row)
636 canCommentAction := policy.Can(r.Context(), pdeps, actor, policy.ActionIssueComment, repoRef).Allow
637 canCommentThroughLock := policy.HasRoleAtLeast(r.Context(), pdeps, actor, repoRef, policy.RoleTriage)
638 viewerAssigned := false
639 participants := map[string]struct{}{}
640 if pr.IAuthorUserID.Valid {
641 if name := usernameFor(pr.IAuthorUserID.Int64); name != "" {
642 participants[name] = struct{}{}
643 }
644 }
645 for _, c := range comments {
646 if c.AuthorUserID.Valid {
647 if name := usernameFor(c.AuthorUserID.Int64); name != "" {
648 participants[name] = struct{}{}
649 }
650 }
651 }
652 for _, a := range assignees {
653 participants[a.Username] = struct{}{}
654 if a.UserID == viewer.ID {
655 viewerAssigned = true
656 }
657 }
658 participantNames := make([]string, 0, len(participants))
659 for name := range participants {
660 participantNames = append(participantNames, name)
661 }
662 sort.Strings(participantNames)
663 h.renderPullPage(w, r, "conversation", map[string]any{
664 "Timeline": timeline,
665 "Events": events,
666 "Labels": labels,
667 "Assignees": assignees,
668 "Participants": participantNames,
669 "ViewerAssigned": viewerAssigned,
670 "AllLabels": allLabels,
671 "Milestones": milestones,
672 "Reviews": rs,
673 "ReviewRequests": reqs,
674 "CanComment": canCommentAction && (!pr.ILocked || canCommentThroughLock),
675 "CanEditIssueLabels": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
676 "CanEditIssueAssignees": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueAssign, repoRef).Allow,
677 "CanEditIssueMilestone": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueLabel, repoRef).Allow,
678 "CanLockIssue": policy.Can(r.Context(), pdeps, actor, policy.ActionIssueClose, repoRef).Allow,
679 "UseCommentEditor": true,
680 "ViewerAvatarURL": commentEditorAvatarURL(viewer.Username),
681 "CommentEditorConfig": commentEditorConfigJSON(h.pullCommentEditorConfig(r.Context(), row, pr, viewer, comments, assignees, reviews, requests)),
682 })
683 }
684
685 // pullCommits renders the Commits tab.
686 func (h *Handlers) pullCommits(w http.ResponseWriter, r *http.Request) {
687 row, _, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
688 if !ok {
689 return
690 }
691 pr, ok := h.loadPullByNumber(w, r, row.ID)
692 if !ok {
693 return
694 }
695 commits, _ := pullsdb.New().ListPullRequestCommits(r.Context(), h.d.Pool, pr.IID)
696 commitGroups := pullCommitGroups(r.Context(), commits, identity.New(h.d.Pool))
697 h.renderPullPage(w, r, "commits", map[string]any{
698 "CommitGroups": commitGroups,
699 })
700 }
701
702 func pullCommitGroups(ctx context.Context, commits []pullsdb.PullRequestCommit, resolver *identity.Resolver) []pullCommitGroup {
703 groups := make([]pullCommitGroup, 0, 2)
704 for _, commit := range commits {
705 when, hasWhen := pullCommitWhen(commit)
706 title := "Commits"
707 if hasWhen {
708 title = "Commits on " + when.Format("January 2, 2006")
709 }
710 if len(groups) == 0 || groups[len(groups)-1].Title != title {
711 groups = append(groups, pullCommitGroup{Title: title})
712 }
713 author := identity.Resolved{}
714 if resolver != nil {
715 author = resolver.Resolve(ctx, commit.AuthorEmail)
716 }
717 authorLabel := commit.AuthorName
718 if author.User && author.DisplayName != "" {
719 authorLabel = author.DisplayName
720 } else if author.User {
721 authorLabel = author.Username
722 }
723 shortSHA := commit.Sha
724 if len(shortSHA) > 7 {
725 shortSHA = shortSHA[:7]
726 }
727 groups[len(groups)-1].Commits = append(groups[len(groups)-1].Commits, pullCommitView{
728 C: commit,
729 Author: author,
730 ShortSHA: shortSHA,
731 When: when,
732 HasWhen: hasWhen,
733 AuthorLabel: authorLabel,
734 })
735 }
736 return groups
737 }
738
739 func pullCommitWhen(commit pullsdb.PullRequestCommit) (time.Time, bool) {
740 if commit.CommittedAt.Valid {
741 return commit.CommittedAt.Time, true
742 }
743 if commit.AuthoredAt.Valid {
744 return commit.AuthoredAt.Time, true
745 }
746 return time.Time{}, false
747 }
748
749 // pullFiles renders the Files Changed tab. Uses the existing diff
750 // renderer fed from base..head (three-dot via FromMergeBase).
751 func (h *Handlers) pullFiles(w http.ResponseWriter, r *http.Request) {
752 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
753 if !ok {
754 return
755 }
756 pr, ok := h.loadPullByNumber(w, r, row.ID)
757 if !ok {
758 return
759 }
760 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
761 if err != nil {
762 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
763 return
764 }
765 files, _ := pullsdb.New().ListPullRequestFiles(r.Context(), h.d.Pool, pr.IID)
766 fileViews := make([]pullFileView, 0, len(files))
767 for _, f := range files {
768 label := pullFileLabel(f)
769 dir, name := splitPullFilePath(f.Path)
770 fileViews = append(fileViews, pullFileView{
771 F: f,
772 Anchor: pullFileAnchor(label),
773 Dir: dir,
774 Name: name,
775 })
776 }
777 diffHTML := ""
778 if pr.BaseOid != "" && pr.HeadOid != "" {
779 patch, perr := compareSourceMergeBase(r, gitDir, pr.BaseOid, pr.HeadOid)
780 if perr == nil {
781 diffHTML = renderCompareDiff(patch)
782 }
783 }
784 // Per-file inline review threads. v1 groups by file_path; the
785 // Files tab shows them collapsed under each section. Position-
786 // mapped comments display inline; outdated ones are hidden by
787 // default behind the "Show outdated" toggle.
788 type commentRow struct {
789 C pullsdb.PrReviewComment
790 AuthorName string
791 }
792 threadsByFile := map[string][]commentRow{}
793 for _, f := range files {
794 rows, _ := h.pq.ListPRReviewCommentsForFile(r.Context(), h.d.Pool, pullsdb.ListPRReviewCommentsForFileParams{
795 PrIssueID: pr.IID,
796 FilePath: f.Path,
797 })
798 out := make([]commentRow, 0, len(rows))
799 for _, c := range rows {
800 cr := commentRow{C: c}
801 if c.AuthorUserID.Valid {
802 if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, c.AuthorUserID.Int64); err == nil {
803 cr.AuthorName = u.Username
804 }
805 }
806 out = append(out, cr)
807 }
808 threadsByFile[f.Path] = out
809 }
810 h.renderPullPage(w, r, "files", map[string]any{
811 "Files": fileViews,
812 "DiffHTML": diffHTML,
813 "ThreadsByFile": threadsByFile,
814 })
815 }
816
817 func pullFileLabel(f pullsdb.PullRequestFile) string {
818 if f.OldPath.Valid && f.OldPath.String != "" && f.OldPath.String != f.Path {
819 return f.OldPath.String + " → " + f.Path
820 }
821 return f.Path
822 }
823
824 func splitPullFilePath(p string) (string, string) {
825 idx := strings.LastIndex(p, "/")
826 if idx < 0 {
827 return "", p
828 }
829 return p[:idx], p[idx+1:]
830 }
831
832 func pullFileAnchor(p string) string {
833 var b strings.Builder
834 b.WriteString("diff-")
835 for _, r := range p {
836 switch {
837 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
838 b.WriteRune(r)
839 default:
840 b.WriteByte('-')
841 }
842 }
843 return b.String()
844 }
845
846 func (h *Handlers) pullRawDiff(w http.ResponseWriter, r *http.Request) {
847 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
848 if !ok {
849 return
850 }
851 pr, ok := h.loadPullByNumber(w, r, row.ID)
852 if !ok {
853 return
854 }
855 if pr.BaseOid == "" || pr.HeadOid == "" {
856 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
857 return
858 }
859 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
860 if err != nil {
861 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
862 return
863 }
864 patch, err := compareSourceMergeBase(r, gitDir, pr.BaseOid, pr.HeadOid)
865 if err != nil {
866 h.d.Logger.WarnContext(r.Context(), "pulls: raw diff", "error", err, "repo_id", row.ID, "pr", pr.INumber)
867 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
868 return
869 }
870 ext := ".diff"
871 if strings.HasSuffix(r.URL.Path, ".patch") {
872 ext = ".patch"
873 }
874 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
875 w.Header().Set("X-Content-Type-Options", "nosniff")
876 w.Header().Set("Content-Disposition", "inline; filename=\""+row.Name+"-"+strconv.FormatInt(pr.INumber, 10)+ext+"\"")
877 _, _ = w.Write(patch) // #nosec G705 -- git diff bytes are served as text/plain with nosniff, not HTML.
878 }
879
880 // pullChecks renders the Checks tab. Loads suites + runs grouped by
881 // suite for the PR's head_oid, plus the markdown-rendered output.summary
882 // for each run.
883 func (h *Handlers) pullChecks(w http.ResponseWriter, r *http.Request) {
884 row, _, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
885 if !ok {
886 return
887 }
888 pr, ok := h.loadPullByNumber(w, r, row.ID)
889 if !ok {
890 return
891 }
892 h.renderPullPage(w, r, "checks", map[string]any{
893 "CheckGroups": h.pullCheckGroups(r.Context(), row.ID, pr.HeadOid),
894 })
895 }
896
897 // renderCheckSummary parses the JSON `output` blob and renders the
898 // `summary` field as Markdown via the existing pipeline. Returns empty
899 // HTML on any error so a malformed payload doesn't break the page.
900 func renderCheckSummary(raw []byte) template.HTML {
901 if len(raw) == 0 {
902 return ""
903 }
904 var o struct {
905 Summary string `json:"summary"`
906 }
907 if err := json.Unmarshal(raw, &o); err != nil || o.Summary == "" {
908 return ""
909 }
910 // Summary is bounded by the API's 256 KiB body cap (well under
911 // markdown's 1 MiB ceiling). An error here only fires if a
912 // structural precondition regresses; the function is a pure
913 // presenter so we degrade to empty (the caller is just rendering
914 // a tooltip-grade snippet on the PR checks panel).
915 html, _ := mdrender.RenderHTML([]byte(o.Summary))
916 return template.HTML(html) //nolint:gosec // sanitized by bluemonday UGCPolicy
917 }
918
919 // pullEdit handles POST .../edit
920 func (h *Handlers) pullEdit(w http.ResponseWriter, r *http.Request) {
921 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate)
922 if !ok {
923 return
924 }
925 pr, ok := h.loadPullByNumber(w, r, row.ID)
926 if !ok {
927 return
928 }
929 if err := r.ParseForm(); err != nil {
930 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
931 return
932 }
933 if err := pulls.EditPR(r.Context(), h.pullsDeps(), pr.IID,
934 r.PostFormValue("title"), r.PostFormValue("body")); err != nil {
935 h.handlePullWriteError(w, r, err)
936 return
937 }
938 h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
939 }
940
941 // pullSetState handles POST .../state.
942 func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) {
943 // Two-pass authorization: read access first, then ActionPullClose
944 // with `repo.AuthorUserID = pr.AuthorUserID` set so the policy engine
945 // grants author-self-close. Without the second pass, a non-collab
946 // fork-PR author couldn't close their own PR (S00-S25 audit, H1).
947 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
948 if !ok {
949 return
950 }
951 pr, ok := h.loadPullByNumber(w, r, row.ID)
952 if !ok {
953 return
954 }
955 viewer := middleware.CurrentUserFromContext(r.Context())
956 actor := viewer.PolicyActor()
957 repoRef := policy.NewRepoRefFromRepo(row)
958 if pr.IAuthorUserID.Valid {
959 repoRef.AuthorUserID = pr.IAuthorUserID.Int64
960 }
961 if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionPullClose, repoRef); !dec.Allow {
962 h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
963 return
964 }
965 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
966 if err != nil {
967 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
968 return
969 }
970 state := strings.TrimSpace(r.PostFormValue("state"))
971 if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil {
972 h.handlePullWriteError(w, r, err)
973 return
974 }
975 h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
976 }
977
978 // pullSetReady handles POST .../ready.
979 func (h *Handlers) pullSetReady(w http.ResponseWriter, r *http.Request) {
980 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate)
981 if !ok {
982 return
983 }
984 pr, ok := h.loadPullByNumber(w, r, row.ID)
985 if !ok {
986 return
987 }
988 viewer := middleware.CurrentUserFromContext(r.Context())
989 if err := pulls.SetReady(r.Context(), h.pullsDeps(), viewer.ID, pr.IID); err != nil {
990 h.handlePullWriteError(w, r, err)
991 return
992 }
993 h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
994 }
995
996 // pullMerge handles POST .../merge. Performs the merge synchronously
997 // inside the request so the redirect lands on the merged state. The
998 // pulls.Merge orchestrator updates repos.default_branch_oid in the
999 // same tx when the base IS the default branch, since update-ref
1000 // bypasses the push:process hook that normally maintains the column.
1001 func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) {
1002 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge)
1003 if !ok {
1004 return
1005 }
1006 pr, ok := h.loadPullByNumber(w, r, row.ID)
1007 if !ok {
1008 return
1009 }
1010 if err := r.ParseForm(); err != nil {
1011 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
1012 return
1013 }
1014 method := strings.TrimSpace(r.PostFormValue("method"))
1015 if method == "" {
1016 method = string(row.DefaultMergeMethod)
1017 }
1018 if !pulls.AllowedMethod(row, method) {
1019 h.handlePullWriteError(w, r, pulls.ErrMergeMethodOff)
1020 return
1021 }
1022 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
1023 if err != nil {
1024 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
1025 return
1026 }
1027 viewer := middleware.CurrentUserFromContext(r.Context())
1028 if err := pulls.Merge(r.Context(), h.pullsDeps(), pulls.MergeParams{
1029 PRID: pr.IID,
1030 ActorUserID: viewer.ID,
1031 GitDir: gitDir,
1032 Method: method,
1033 Subject: r.PostFormValue("subject"),
1034 Body: r.PostFormValue("body"),
1035 }); err != nil {
1036 h.handlePullWriteError(w, r, err)
1037 return
1038 }
1039 h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
1040 }
1041
1042 func (h *Handlers) pullDeleteHeadBranch(w http.ResponseWriter, r *http.Request) {
1043 row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge)
1044 if !ok {
1045 return
1046 }
1047 pr, ok := h.loadPullByNumber(w, r, row.ID)
1048 if !ok {
1049 return
1050 }
1051 if err := r.ParseForm(); err != nil {
1052 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
1053 return
1054 }
1055 if !pr.MergedAt.Valid || pr.HeadRepoID != row.ID || strings.TrimSpace(pr.HeadRef) == "" || pr.HeadRef == row.DefaultBranch {
1056 h.d.Render.HTTPError(w, r, http.StatusConflict, "head branch cannot be deleted from this pull request")
1057 return
1058 }
1059 gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
1060 if err != nil {
1061 h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
1062 return
1063 }
1064 if err := repogit.DeleteBranch(r.Context(), gitDir, pr.HeadRef, pr.HeadOid); err != nil {
1065 if errors.Is(err, repogit.ErrRefNotFound) {
1066 h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
1067 return
1068 }
1069 if errors.Is(err, repogit.ErrRefRaced) {
1070 h.d.Render.HTTPError(w, r, http.StatusConflict, "branch moved after this pull request was merged")
1071 return
1072 }
1073 h.d.Logger.WarnContext(r.Context(), "pulls: delete head branch", "error", err, "repo_id", row.ID, "pr", pr.INumber, "branch", pr.HeadRef)
1074 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
1075 return
1076 }
1077 h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
1078 }
1079
1080 func (h *Handlers) handlePullWriteError(w http.ResponseWriter, r *http.Request, err error) {
1081 switch {
1082 case errors.Is(err, pulls.ErrAlreadyMerged):
1083 h.d.Render.HTTPError(w, r, http.StatusConflict, "already merged")
1084 case errors.Is(err, pulls.ErrAlreadyClosed):
1085 h.d.Render.HTTPError(w, r, http.StatusConflict, "already closed")
1086 case errors.Is(err, pulls.ErrMergeBlocked):
1087 h.d.Render.HTTPError(w, r, http.StatusConflict, "merge blocked — branch has conflicts or hasn't been checked yet")
1088 case errors.Is(err, pulls.ErrMergeMethodOff):
1089 h.d.Render.HTTPError(w, r, http.StatusBadRequest, "this merge method is disabled on this repo")
1090 case errors.Is(err, pulls.ErrConcurrentMerge):
1091 h.d.Render.HTTPError(w, r, http.StatusConflict, "PR is being merged by another request")
1092 case errors.Is(err, pulls.ErrBaseNotFound):
1093 h.d.Render.HTTPError(w, r, http.StatusConflict, "base branch no longer exists")
1094 case errors.Is(err, pulls.ErrHeadNotFound):
1095 h.d.Render.HTTPError(w, r, http.StatusConflict, "head branch no longer exists")
1096 case errors.Is(err, repogit.ErrRefNotFound):
1097 h.d.Render.HTTPError(w, r, http.StatusConflict, "branch missing")
1098 default:
1099 h.d.Logger.WarnContext(r.Context(), "pulls: write", "error", err)
1100 h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
1101 }
1102 }
1103
1104 func (h *Handlers) redirectPull(w http.ResponseWriter, r *http.Request, owner, repo string, number int64) {
1105 http.Redirect(w, r, "/"+owner+"/"+repo+"/pulls/"+strconv.FormatInt(number, 10), http.StatusSeeOther)
1106 }
1107