@@ -29,8 +29,12 @@ import ( |
| 29 | | 29 | |
| 30 | // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are | 30 | // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are |
| 31 | // public (subject to policy.Can(ActionPullRead)); writes require auth. | 31 | // public (subject to policy.Can(ActionPullRead)); writes require auth. |
| 32 | -// The merge route enqueues a pr:merge worker job and renders a | 32 | +// The merge route runs synchronously inside the request: pulls.Merge |
| 33 | -// "merging…" page; the worker performs the actual git operation. | 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 | func (h *Handlers) MountPulls(r chi.Router) { | 38 | func (h *Handlers) MountPulls(r chi.Router) { |
| 35 | r.Get("/{owner}/{repo}/pulls", h.pullsList) | 39 | r.Get("/{owner}/{repo}/pulls", h.pullsList) |
| 36 | r.Get("/{owner}/{repo}/pulls/{number}", h.pullView) | 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 | func (h *Handlers) pullsDeps() pulls.Deps { | 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 | // pullsList renders /{owner}/{repo}/pulls. | 63 | // pullsList renders /{owner}/{repo}/pulls. |
@@ -472,6 +476,11 @@ func renderCheckSummary(raw []byte) template.HTML { |
| 472 | if err := json.Unmarshal(raw, &o); err != nil || o.Summary == "" { | 476 | if err := json.Unmarshal(raw, &o); err != nil || o.Summary == "" { |
| 473 | return "" | 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 | html, _ := mdrender.RenderHTML([]byte(o.Summary)) | 484 | html, _ := mdrender.RenderHTML([]byte(o.Summary)) |
| 476 | return template.HTML(html) //nolint:gosec // sanitized by bluemonday UGCPolicy | 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 | // pullSetState handles POST .../state. | 510 | // pullSetState handles POST .../state. |
| 502 | func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) { | 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 | if !ok { | 517 | if !ok { |
| 505 | return | 518 | return |
| 506 | } | 519 | } |
@@ -508,13 +521,22 @@ func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) { |
| 508 | if !ok { | 521 | if !ok { |
| 509 | return | 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 | gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) | 534 | gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) |
| 512 | if err != nil { | 535 | if err != nil { |
| 513 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") | 536 | h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 514 | return | 537 | return |
| 515 | } | 538 | } |
| 516 | state := strings.TrimSpace(r.PostFormValue("state")) | 539 | state := strings.TrimSpace(r.PostFormValue("state")) |
| 517 | - viewer := middleware.CurrentUserFromContext(r.Context()) | | |
| 518 | if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil { | 540 | if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil { |
| 519 | h.handlePullWriteError(w, r, err) | 541 | h.handlePullWriteError(w, r, err) |
| 520 | return | 542 | return |
@@ -541,9 +563,10 @@ func (h *Handlers) pullSetReady(w http.ResponseWriter, r *http.Request) { |
| 541 | } | 563 | } |
| 542 | | 564 | |
| 543 | // pullMerge handles POST .../merge. Performs the merge synchronously | 565 | // pullMerge handles POST .../merge. Performs the merge synchronously |
| 544 | -// (so the redirect lands on the merged state). Heavy merges could be | 566 | +// inside the request so the redirect lands on the merged state. The |
| 545 | -// async via the pr:merge job; v1 is synchronous so the user sees an | 567 | +// pulls.Merge orchestrator updates repos.default_branch_oid in the |
| 546 | -// immediate result on small merges. | 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 | func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { | 570 | func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { |
| 548 | row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge) | 571 | row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge) |
| 549 | if !ok { | 572 | if !ok { |
@@ -582,12 +605,6 @@ func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { |
| 582 | h.handlePullWriteError(w, r, err) | 605 | h.handlePullWriteError(w, r, err) |
| 583 | return | 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 | h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) | 608 | h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 592 | } | 609 | } |
| 593 | | 610 | |