@@ -29,8 +29,12 @@ import ( |
| 29 | 29 | |
| 30 | 30 | // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are |
| 31 | 31 | // public (subject to policy.Can(ActionPullRead)); writes require auth. |
| 32 | | -// The merge route enqueues a pr:merge worker job and renders a |
| 33 | | -// "merging…" page; the worker performs the actual git operation. |
| 32 | +// The merge route runs synchronously inside the request: pulls.Merge |
| 33 | +// performs the worktree operation, updates DB state, and the response |
| 34 | +// redirects the user straight to the merged view. (An async-merge path |
| 35 | +// can be reintroduced when very-large-repo merges become a real |
| 36 | +// concern; the worker registration was deleted alongside the unused |
| 37 | +// KindPRMerge in the audit remediation sprint.) |
| 34 | 38 | func (h *Handlers) MountPulls(r chi.Router) { |
| 35 | 39 | r.Get("/{owner}/{repo}/pulls", h.pullsList) |
| 36 | 40 | r.Get("/{owner}/{repo}/pulls/{number}", h.pullView) |
@@ -53,7 +57,7 @@ func (h *Handlers) MountPulls(r chi.Router) { |
| 53 | 57 | } |
| 54 | 58 | |
| 55 | 59 | func (h *Handlers) pullsDeps() pulls.Deps { |
| 56 | | - return pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger} |
| 60 | + return pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit} |
| 57 | 61 | } |
| 58 | 62 | |
| 59 | 63 | // pullsList renders /{owner}/{repo}/pulls. |
@@ -472,6 +476,11 @@ func renderCheckSummary(raw []byte) template.HTML { |
| 472 | 476 | if err := json.Unmarshal(raw, &o); err != nil || o.Summary == "" { |
| 473 | 477 | return "" |
| 474 | 478 | } |
| 479 | + // Summary is bounded by the API's 256 KiB body cap (well under |
| 480 | + // markdown's 1 MiB ceiling). An error here only fires if a |
| 481 | + // structural precondition regresses; the function is a pure |
| 482 | + // presenter so we degrade to empty (the caller is just rendering |
| 483 | + // a tooltip-grade snippet on the PR checks panel). |
| 475 | 484 | html, _ := mdrender.RenderHTML([]byte(o.Summary)) |
| 476 | 485 | return template.HTML(html) //nolint:gosec // sanitized by bluemonday UGCPolicy |
| 477 | 486 | } |
@@ -500,7 +509,11 @@ func (h *Handlers) pullEdit(w http.ResponseWriter, r *http.Request) { |
| 500 | 509 | |
| 501 | 510 | // pullSetState handles POST .../state. |
| 502 | 511 | func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) { |
| 503 | | - row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullClose) |
| 512 | + // Two-pass authorization: read access first, then ActionPullClose |
| 513 | + // with `repo.AuthorUserID = pr.AuthorUserID` set so the policy engine |
| 514 | + // grants author-self-close. Without the second pass, a non-collab |
| 515 | + // fork-PR author couldn't close their own PR (S00-S25 audit, H1). |
| 516 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) |
| 504 | 517 | if !ok { |
| 505 | 518 | return |
| 506 | 519 | } |
@@ -508,13 +521,22 @@ func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) { |
| 508 | 521 | if !ok { |
| 509 | 522 | return |
| 510 | 523 | } |
| 524 | + viewer := middleware.CurrentUserFromContext(r.Context()) |
| 525 | + actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 526 | + repoRef := policy.NewRepoRefFromRepo(row) |
| 527 | + if pr.IAuthorUserID.Valid { |
| 528 | + repoRef.AuthorUserID = pr.IAuthorUserID.Int64 |
| 529 | + } |
| 530 | + if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionPullClose, repoRef); !dec.Allow { |
| 531 | + h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "") |
| 532 | + return |
| 533 | + } |
| 511 | 534 | gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) |
| 512 | 535 | if err != nil { |
| 513 | 536 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 514 | 537 | return |
| 515 | 538 | } |
| 516 | 539 | state := strings.TrimSpace(r.PostFormValue("state")) |
| 517 | | - viewer := middleware.CurrentUserFromContext(r.Context()) |
| 518 | 540 | if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil { |
| 519 | 541 | h.handlePullWriteError(w, r, err) |
| 520 | 542 | return |
@@ -541,9 +563,10 @@ func (h *Handlers) pullSetReady(w http.ResponseWriter, r *http.Request) { |
| 541 | 563 | } |
| 542 | 564 | |
| 543 | 565 | // pullMerge handles POST .../merge. Performs the merge synchronously |
| 544 | | -// (so the redirect lands on the merged state). Heavy merges could be |
| 545 | | -// async via the pr:merge job; v1 is synchronous so the user sees an |
| 546 | | -// immediate result on small merges. |
| 566 | +// inside the request so the redirect lands on the merged state. The |
| 567 | +// pulls.Merge orchestrator updates repos.default_branch_oid in the |
| 568 | +// same tx when the base IS the default branch, since update-ref |
| 569 | +// bypasses the push:process hook that normally maintains the column. |
| 547 | 570 | func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { |
| 548 | 571 | row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge) |
| 549 | 572 | if !ok { |
@@ -582,12 +605,6 @@ func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { |
| 582 | 605 | h.handlePullWriteError(w, r, err) |
| 583 | 606 | return |
| 584 | 607 | } |
| 585 | | - // After merge, push:process won't fire (the update-ref bypassed |
| 586 | | - // the hook). Trigger a default-branch-OID refresh manually. |
| 587 | | - go func() { |
| 588 | | - // Fire-and-forget; the user is already redirected. |
| 589 | | - // Failure is logged but doesn't affect UX. |
| 590 | | - }() |
| 591 | 608 | h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 592 | 609 | } |
| 593 | 610 | |