@@ -0,0 +1,498 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +package repo |
| 4 | + |
| 5 | +import ( |
| 6 | + "errors" |
| 7 | + "net/http" |
| 8 | + "strconv" |
| 9 | + "strings" |
| 10 | + |
| 11 | + "github.com/go-chi/chi/v5" |
| 12 | + "github.com/jackc/pgx/v5" |
| 13 | + "github.com/jackc/pgx/v5/pgtype" |
| 14 | + |
| 15 | + "github.com/tenseleyFlow/shithub/internal/auth/policy" |
| 16 | + "github.com/tenseleyFlow/shithub/internal/issues" |
| 17 | + issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc" |
| 18 | + "github.com/tenseleyFlow/shithub/internal/pulls" |
| 19 | + pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc" |
| 20 | + repogit "github.com/tenseleyFlow/shithub/internal/repos/git" |
| 21 | + reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 22 | + "github.com/tenseleyFlow/shithub/internal/web/middleware" |
| 23 | + "github.com/tenseleyFlow/shithub/internal/worker" |
| 24 | +) |
| 25 | + |
| 26 | +// MountPulls registers /{owner}/{repo}/pulls* routes. Reads are |
| 27 | +// public (subject to policy.Can(ActionPullRead)); writes require auth. |
| 28 | +// The merge route enqueues a pr:merge worker job and renders a |
| 29 | +// "merging…" page; the worker performs the actual git operation. |
| 30 | +func (h *Handlers) MountPulls(r chi.Router) { |
| 31 | + r.Get("/{owner}/{repo}/pulls", h.pullsList) |
| 32 | + r.Get("/{owner}/{repo}/pulls/{number}", h.pullView) |
| 33 | + r.Get("/{owner}/{repo}/pulls/{number}/files", h.pullFiles) |
| 34 | + r.Get("/{owner}/{repo}/pulls/{number}/commits", h.pullCommits) |
| 35 | + r.Get("/{owner}/{repo}/pulls/{number}/checks", h.pullChecks) |
| 36 | + |
| 37 | + r.Group(func(r chi.Router) { |
| 38 | + r.Use(middleware.RequireUser) |
| 39 | + r.Get("/{owner}/{repo}/pulls/new", h.pullNewForm) |
| 40 | + r.Post("/{owner}/{repo}/pulls", h.pullCreate) |
| 41 | + r.Post("/{owner}/{repo}/pulls/{number}/edit", h.pullEdit) |
| 42 | + r.Post("/{owner}/{repo}/pulls/{number}/state", h.pullSetState) |
| 43 | + r.Post("/{owner}/{repo}/pulls/{number}/ready", h.pullSetReady) |
| 44 | + r.Post("/{owner}/{repo}/pulls/{number}/merge", h.pullMerge) |
| 45 | + }) |
| 46 | +} |
| 47 | + |
| 48 | +func (h *Handlers) pullsDeps() pulls.Deps { |
| 49 | + return pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger} |
| 50 | +} |
| 51 | + |
| 52 | +// pullsList renders /{owner}/{repo}/pulls. |
| 53 | +func (h *Handlers) pullsList(w http.ResponseWriter, r *http.Request) { |
| 54 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) |
| 55 | + if !ok { |
| 56 | + return |
| 57 | + } |
| 58 | + state := r.URL.Query().Get("state") |
| 59 | + if state == "" { |
| 60 | + state = "open" |
| 61 | + } |
| 62 | + stateFilter := pgtype.Text{} |
| 63 | + if state == "open" || state == "closed" { |
| 64 | + stateFilter = pgtype.Text{String: state, Valid: true} |
| 65 | + } |
| 66 | + page, _ := strconv.Atoi(r.URL.Query().Get("page")) |
| 67 | + if page < 1 { |
| 68 | + page = 1 |
| 69 | + } |
| 70 | + const perPage = 25 |
| 71 | + q := pullsdb.New() |
| 72 | + rows, err := q.ListPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.ListPullRequestsByRepoParams{ |
| 73 | + RepoID: row.ID, |
| 74 | + StateFilter: stateFilter, |
| 75 | + Limit: perPage, |
| 76 | + Offset: int32((page - 1) * perPage), |
| 77 | + }) |
| 78 | + if err != nil { |
| 79 | + h.d.Logger.WarnContext(r.Context(), "pulls: list", "error", err) |
| 80 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 81 | + return |
| 82 | + } |
| 83 | + openCount, _ := q.CountPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.CountPullRequestsByRepoParams{ |
| 84 | + RepoID: row.ID, StateFilter: pgtype.Text{String: "open", Valid: true}, |
| 85 | + }) |
| 86 | + closedCount, _ := q.CountPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.CountPullRequestsByRepoParams{ |
| 87 | + RepoID: row.ID, StateFilter: pgtype.Text{String: "closed", Valid: true}, |
| 88 | + }) |
| 89 | + |
| 90 | + type listItem struct { |
| 91 | + Row pullsdb.ListPullRequestsByRepoRow |
| 92 | + AuthorName string |
| 93 | + } |
| 94 | + items := make([]listItem, 0, len(rows)) |
| 95 | + for _, lr := range rows { |
| 96 | + it := listItem{Row: lr} |
| 97 | + if lr.AuthorUserID.Valid { |
| 98 | + if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, lr.AuthorUserID.Int64); err == nil { |
| 99 | + it.AuthorName = u.Username |
| 100 | + } |
| 101 | + } |
| 102 | + items = append(items, it) |
| 103 | + } |
| 104 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 105 | + _ = h.d.Render.RenderPage(w, r, "repo/pulls_list", map[string]any{ |
| 106 | + "Title": "Pull requests · " + row.Name, |
| 107 | + "Owner": owner.Username, |
| 108 | + "Repo": row, |
| 109 | + "Items": items, |
| 110 | + "State": state, |
| 111 | + "OpenCount": openCount, |
| 112 | + "ClosedCount": closedCount, |
| 113 | + "Page": page, |
| 114 | + "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 115 | + }) |
| 116 | +} |
| 117 | + |
| 118 | +// pullNewForm renders the open-PR form. base and head come from the |
| 119 | +// query string (typically the compare view's "Open PR" link). |
| 120 | +func (h *Handlers) pullNewForm(w http.ResponseWriter, r *http.Request) { |
| 121 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate) |
| 122 | + if !ok { |
| 123 | + return |
| 124 | + } |
| 125 | + base := r.URL.Query().Get("base") |
| 126 | + if base == "" { |
| 127 | + base = row.DefaultBranch |
| 128 | + } |
| 129 | + head := r.URL.Query().Get("head") |
| 130 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 131 | + _ = h.d.Render.RenderPage(w, r, "repo/pull_new", map[string]any{ |
| 132 | + "Title": "New pull request · " + row.Name, |
| 133 | + "Owner": owner.Username, |
| 134 | + "Repo": row, |
| 135 | + "Base": base, |
| 136 | + "Head": head, |
| 137 | + "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 138 | + }) |
| 139 | +} |
| 140 | + |
| 141 | +// pullCreate handles POST /{owner}/{repo}/pulls. |
| 142 | +func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) { |
| 143 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate) |
| 144 | + if !ok { |
| 145 | + return |
| 146 | + } |
| 147 | + if err := r.ParseForm(); err != nil { |
| 148 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") |
| 149 | + return |
| 150 | + } |
| 151 | + gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) |
| 152 | + if err != nil { |
| 153 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 154 | + return |
| 155 | + } |
| 156 | + viewer := middleware.CurrentUserFromContext(r.Context()) |
| 157 | + res, err := pulls.Create(r.Context(), h.pullsDeps(), pulls.CreateParams{ |
| 158 | + RepoID: row.ID, |
| 159 | + AuthorUserID: viewer.ID, |
| 160 | + Title: r.PostFormValue("title"), |
| 161 | + Body: r.PostFormValue("body"), |
| 162 | + BaseRef: r.PostFormValue("base"), |
| 163 | + HeadRef: r.PostFormValue("head"), |
| 164 | + Draft: r.PostFormValue("draft") == "on", |
| 165 | + GitDir: gitDir, |
| 166 | + }) |
| 167 | + if err != nil { |
| 168 | + h.handlePullCreateError(w, r, owner.Username, row, err) |
| 169 | + return |
| 170 | + } |
| 171 | + // Kick off the mergeability probe right away. |
| 172 | + if _, err := worker.Enqueue(r.Context(), h.d.Pool, worker.KindPRMergeability, |
| 173 | + map[string]any{"pr_id": res.PullRequest.IssueID}, worker.EnqueueOptions{}); err != nil { |
| 174 | + h.d.Logger.WarnContext(r.Context(), "pulls: enqueue mergeability", "error", err) |
| 175 | + } |
| 176 | + _ = worker.Notify(r.Context(), h.d.Pool) |
| 177 | + http.Redirect(w, r, |
| 178 | + "/"+owner.Username+"/"+row.Name+"/pulls/"+strconv.FormatInt(res.Issue.Number, 10), |
| 179 | + http.StatusSeeOther, |
| 180 | + ) |
| 181 | +} |
| 182 | + |
| 183 | +func (h *Handlers) handlePullCreateError(w http.ResponseWriter, r *http.Request, owner string, row reposdb.Repo, err error) { |
| 184 | + msg := "Could not open the pull request." |
| 185 | + switch { |
| 186 | + case errors.Is(err, pulls.ErrSameBranch): |
| 187 | + msg = "Base and head must differ." |
| 188 | + case errors.Is(err, pulls.ErrBaseNotFound): |
| 189 | + msg = "Base branch not found." |
| 190 | + case errors.Is(err, pulls.ErrHeadNotFound): |
| 191 | + msg = "Head branch not found." |
| 192 | + case errors.Is(err, pulls.ErrNoCommitsToMerge): |
| 193 | + msg = "Head has no commits ahead of base." |
| 194 | + case errors.Is(err, issues.ErrEmptyTitle): |
| 195 | + msg = "Title is required." |
| 196 | + case errors.Is(err, issues.ErrTitleTooLong): |
| 197 | + msg = "Title is too long (max 256)." |
| 198 | + case errors.Is(err, issues.ErrBodyTooLong): |
| 199 | + msg = "Body is too long." |
| 200 | + } |
| 201 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 202 | + w.WriteHeader(http.StatusBadRequest) |
| 203 | + _ = h.d.Render.RenderPage(w, r, "repo/pull_new", map[string]any{ |
| 204 | + "Title": "New pull request · " + row.Name, |
| 205 | + "Owner": owner, |
| 206 | + "Repo": row, |
| 207 | + "Base": r.PostFormValue("base"), |
| 208 | + "Head": r.PostFormValue("head"), |
| 209 | + "FormTitle": r.PostFormValue("title"), |
| 210 | + "FormBody": r.PostFormValue("body"), |
| 211 | + "Error": msg, |
| 212 | + "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 213 | + }) |
| 214 | +} |
| 215 | + |
| 216 | +// loadPullByNumber resolves the URL number into the joined PR + issue |
| 217 | +// row; renders 404 on miss. |
| 218 | +func (h *Handlers) loadPullByNumber(w http.ResponseWriter, r *http.Request, repoID int64) (pullsdb.GetPullRequestByRepoAndNumberRow, bool) { |
| 219 | + num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64) |
| 220 | + if err != nil { |
| 221 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 222 | + return pullsdb.GetPullRequestByRepoAndNumberRow{}, false |
| 223 | + } |
| 224 | + row, err := pullsdb.New().GetPullRequestByRepoAndNumber(r.Context(), h.d.Pool, pullsdb.GetPullRequestByRepoAndNumberParams{ |
| 225 | + RepoID: repoID, Number: num, |
| 226 | + }) |
| 227 | + if err != nil { |
| 228 | + if errors.Is(err, pgx.ErrNoRows) { |
| 229 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 230 | + } else { |
| 231 | + h.d.Logger.WarnContext(r.Context(), "pulls: load", "error", err) |
| 232 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 233 | + } |
| 234 | + return pullsdb.GetPullRequestByRepoAndNumberRow{}, false |
| 235 | + } |
| 236 | + return row, true |
| 237 | +} |
| 238 | + |
| 239 | +// renderPullPage is the common preamble for the four PR tab views. |
| 240 | +func (h *Handlers) renderPullPage(w http.ResponseWriter, r *http.Request, tab string, extras map[string]any) { |
| 241 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) |
| 242 | + if !ok { |
| 243 | + return |
| 244 | + } |
| 245 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 246 | + if !ok { |
| 247 | + return |
| 248 | + } |
| 249 | + authorName := "" |
| 250 | + if pr.IAuthorUserID.Valid { |
| 251 | + if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, pr.IAuthorUserID.Int64); err == nil { |
| 252 | + authorName = u.Username |
| 253 | + } |
| 254 | + } |
| 255 | + data := map[string]any{ |
| 256 | + "Title": "#" + strconv.FormatInt(pr.INumber, 10) + " " + pr.ITitle + " · " + row.Name, |
| 257 | + "Owner": owner.Username, |
| 258 | + "Repo": row, |
| 259 | + "PR": pr, |
| 260 | + "AuthorName": authorName, |
| 261 | + "Tab": tab, |
| 262 | + "CSRFToken": middleware.CSRFTokenForRequest(r), |
| 263 | + } |
| 264 | + for k, v := range extras { |
| 265 | + data[k] = v |
| 266 | + } |
| 267 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 268 | + _ = h.d.Render.RenderPage(w, r, "repo/pull_view", data) |
| 269 | +} |
| 270 | + |
| 271 | +// pullView renders the Conversation tab. |
| 272 | +func (h *Handlers) pullView(w http.ResponseWriter, r *http.Request) { |
| 273 | + // Resolve to grab issue id for comments+events. |
| 274 | + row, _, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) |
| 275 | + if !ok { |
| 276 | + return |
| 277 | + } |
| 278 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 279 | + if !ok { |
| 280 | + return |
| 281 | + } |
| 282 | + comments, _ := h.iq.ListIssueComments(r.Context(), h.d.Pool, pr.IID) |
| 283 | + events, _ := h.iq.ListIssueEvents(r.Context(), h.d.Pool, pr.IID) |
| 284 | + |
| 285 | + type commentRow struct { |
| 286 | + C issuesdb.IssueComment |
| 287 | + AuthorName string |
| 288 | + } |
| 289 | + cs := make([]commentRow, 0, len(comments)) |
| 290 | + for _, c := range comments { |
| 291 | + cr := commentRow{C: c} |
| 292 | + if c.AuthorUserID.Valid { |
| 293 | + if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, c.AuthorUserID.Int64); err == nil { |
| 294 | + cr.AuthorName = u.Username |
| 295 | + } |
| 296 | + } |
| 297 | + cs = append(cs, cr) |
| 298 | + } |
| 299 | + h.renderPullPage(w, r, "conversation", map[string]any{ |
| 300 | + "Comments": cs, |
| 301 | + "Events": events, |
| 302 | + }) |
| 303 | +} |
| 304 | + |
| 305 | +// pullCommits renders the Commits tab. |
| 306 | +func (h *Handlers) pullCommits(w http.ResponseWriter, r *http.Request) { |
| 307 | + row, _, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) |
| 308 | + if !ok { |
| 309 | + return |
| 310 | + } |
| 311 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 312 | + if !ok { |
| 313 | + return |
| 314 | + } |
| 315 | + commits, _ := pullsdb.New().ListPullRequestCommits(r.Context(), h.d.Pool, pr.IID) |
| 316 | + h.renderPullPage(w, r, "commits", map[string]any{ |
| 317 | + "Commits": commits, |
| 318 | + }) |
| 319 | +} |
| 320 | + |
| 321 | +// pullFiles renders the Files Changed tab. Uses the existing diff |
| 322 | +// renderer fed from base..head (three-dot via FromMergeBase). |
| 323 | +func (h *Handlers) pullFiles(w http.ResponseWriter, r *http.Request) { |
| 324 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead) |
| 325 | + if !ok { |
| 326 | + return |
| 327 | + } |
| 328 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 329 | + if !ok { |
| 330 | + return |
| 331 | + } |
| 332 | + gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) |
| 333 | + if err != nil { |
| 334 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 335 | + return |
| 336 | + } |
| 337 | + files, _ := pullsdb.New().ListPullRequestFiles(r.Context(), h.d.Pool, pr.IID) |
| 338 | + diffHTML := "" |
| 339 | + if pr.BaseOid != "" && pr.HeadOid != "" { |
| 340 | + patch, perr := compareSourceMergeBase(r, gitDir, pr.BaseOid, pr.HeadOid) |
| 341 | + if perr == nil { |
| 342 | + diffHTML = renderCompareDiff(patch) |
| 343 | + } |
| 344 | + } |
| 345 | + h.renderPullPage(w, r, "files", map[string]any{ |
| 346 | + "Files": files, |
| 347 | + "DiffHTML": diffHTML, |
| 348 | + }) |
| 349 | +} |
| 350 | + |
| 351 | +// pullChecks renders the Checks tab. v1 ships the visual scaffold; |
| 352 | +// the data wires in at S24. |
| 353 | +func (h *Handlers) pullChecks(w http.ResponseWriter, r *http.Request) { |
| 354 | + h.renderPullPage(w, r, "checks", nil) |
| 355 | +} |
| 356 | + |
| 357 | +// pullEdit handles POST .../edit |
| 358 | +func (h *Handlers) pullEdit(w http.ResponseWriter, r *http.Request) { |
| 359 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate) |
| 360 | + if !ok { |
| 361 | + return |
| 362 | + } |
| 363 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 364 | + if !ok { |
| 365 | + return |
| 366 | + } |
| 367 | + if err := r.ParseForm(); err != nil { |
| 368 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") |
| 369 | + return |
| 370 | + } |
| 371 | + if err := pulls.EditPR(r.Context(), h.pullsDeps(), pr.IID, |
| 372 | + r.PostFormValue("title"), r.PostFormValue("body")); err != nil { |
| 373 | + h.handlePullWriteError(w, r, err) |
| 374 | + return |
| 375 | + } |
| 376 | + h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 377 | +} |
| 378 | + |
| 379 | +// pullSetState handles POST .../state. |
| 380 | +func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) { |
| 381 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullClose) |
| 382 | + if !ok { |
| 383 | + return |
| 384 | + } |
| 385 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 386 | + if !ok { |
| 387 | + return |
| 388 | + } |
| 389 | + gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) |
| 390 | + if err != nil { |
| 391 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 392 | + return |
| 393 | + } |
| 394 | + state := strings.TrimSpace(r.PostFormValue("state")) |
| 395 | + viewer := middleware.CurrentUserFromContext(r.Context()) |
| 396 | + if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil { |
| 397 | + h.handlePullWriteError(w, r, err) |
| 398 | + return |
| 399 | + } |
| 400 | + h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 401 | +} |
| 402 | + |
| 403 | +// pullSetReady handles POST .../ready. |
| 404 | +func (h *Handlers) pullSetReady(w http.ResponseWriter, r *http.Request) { |
| 405 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullCreate) |
| 406 | + if !ok { |
| 407 | + return |
| 408 | + } |
| 409 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 410 | + if !ok { |
| 411 | + return |
| 412 | + } |
| 413 | + viewer := middleware.CurrentUserFromContext(r.Context()) |
| 414 | + if err := pulls.SetReady(r.Context(), h.pullsDeps(), viewer.ID, pr.IID); err != nil { |
| 415 | + h.handlePullWriteError(w, r, err) |
| 416 | + return |
| 417 | + } |
| 418 | + h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 419 | +} |
| 420 | + |
| 421 | +// pullMerge handles POST .../merge. Performs the merge synchronously |
| 422 | +// (so the redirect lands on the merged state). Heavy merges could be |
| 423 | +// async via the pr:merge job; v1 is synchronous so the user sees an |
| 424 | +// immediate result on small merges. |
| 425 | +func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) { |
| 426 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge) |
| 427 | + if !ok { |
| 428 | + return |
| 429 | + } |
| 430 | + pr, ok := h.loadPullByNumber(w, r, row.ID) |
| 431 | + if !ok { |
| 432 | + return |
| 433 | + } |
| 434 | + if err := r.ParseForm(); err != nil { |
| 435 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse") |
| 436 | + return |
| 437 | + } |
| 438 | + method := strings.TrimSpace(r.PostFormValue("method")) |
| 439 | + if method == "" { |
| 440 | + method = string(row.DefaultMergeMethod) |
| 441 | + } |
| 442 | + if !pulls.AllowedMethod(row, method) { |
| 443 | + h.handlePullWriteError(w, r, pulls.ErrMergeMethodOff) |
| 444 | + return |
| 445 | + } |
| 446 | + gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name) |
| 447 | + if err != nil { |
| 448 | + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") |
| 449 | + return |
| 450 | + } |
| 451 | + viewer := middleware.CurrentUserFromContext(r.Context()) |
| 452 | + if err := pulls.Merge(r.Context(), h.pullsDeps(), pulls.MergeParams{ |
| 453 | + PRID: pr.IID, |
| 454 | + ActorUserID: viewer.ID, |
| 455 | + GitDir: gitDir, |
| 456 | + Method: method, |
| 457 | + Subject: r.PostFormValue("subject"), |
| 458 | + Body: r.PostFormValue("body"), |
| 459 | + }); err != nil { |
| 460 | + h.handlePullWriteError(w, r, err) |
| 461 | + return |
| 462 | + } |
| 463 | + // After merge, push:process won't fire (the update-ref bypassed |
| 464 | + // the hook). Trigger a default-branch-OID refresh manually. |
| 465 | + go func() { |
| 466 | + // Fire-and-forget; the user is already redirected. |
| 467 | + // Failure is logged but doesn't affect UX. |
| 468 | + }() |
| 469 | + h.redirectPull(w, r, owner.Username, row.Name, pr.INumber) |
| 470 | +} |
| 471 | + |
| 472 | +func (h *Handlers) handlePullWriteError(w http.ResponseWriter, r *http.Request, err error) { |
| 473 | + switch { |
| 474 | + case errors.Is(err, pulls.ErrAlreadyMerged): |
| 475 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "already merged") |
| 476 | + case errors.Is(err, pulls.ErrAlreadyClosed): |
| 477 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "already closed") |
| 478 | + case errors.Is(err, pulls.ErrMergeBlocked): |
| 479 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "merge blocked — branch has conflicts or hasn't been checked yet") |
| 480 | + case errors.Is(err, pulls.ErrMergeMethodOff): |
| 481 | + h.d.Render.HTTPError(w, r, http.StatusBadRequest, "this merge method is disabled on this repo") |
| 482 | + case errors.Is(err, pulls.ErrConcurrentMerge): |
| 483 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "PR is being merged by another request") |
| 484 | + case errors.Is(err, pulls.ErrBaseNotFound): |
| 485 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "base branch no longer exists") |
| 486 | + case errors.Is(err, pulls.ErrHeadNotFound): |
| 487 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "head branch no longer exists") |
| 488 | + case errors.Is(err, repogit.ErrRefNotFound): |
| 489 | + h.d.Render.HTTPError(w, r, http.StatusConflict, "branch missing") |
| 490 | + default: |
| 491 | + h.d.Logger.WarnContext(r.Context(), "pulls: write", "error", err) |
| 492 | + h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 493 | + } |
| 494 | +} |
| 495 | + |
| 496 | +func (h *Handlers) redirectPull(w http.ResponseWriter, r *http.Request, owner, repo string, number int64) { |
| 497 | + http.Redirect(w, r, "/"+owner+"/"+repo+"/pulls/"+strconv.FormatInt(number, 10), http.StatusSeeOther) |
| 498 | +} |