tenseleyflow/shithub / fd99b56

Browse files

S22: pulls web handlers + routes (auth-gated writes)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
fd99b56df1844c53c2bf55d0af5dc48123670d23
Parents
b4f92ba
Tree
dbb9ded

4 changed files

StatusFile+-
M internal/web/handlers/handlers.go 6 0
A internal/web/handlers/repo/pulls.go 498 0
M internal/web/handlers/repo/repo.go 3 1
M internal/web/server.go 1 0
internal/web/handlers/handlers.gomodified
@@ -75,6 +75,9 @@ type Deps struct {
7575
 	// /milestones routes (S21). Reads are public (per-repo policy gate);
7676
 	// writes are auth-required.
7777
 	RepoIssuesMounter func(chi.Router)
78
+	// RepoPullsMounter registers /{owner}/{repo}/pulls* routes (S22).
79
+	// Same auth shape as issues — reads public, writes auth-required.
80
+	RepoPullsMounter func(chi.Router)
7881
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
7982
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
8083
 	// land in a route group that bypasses CSRF, response compression,
@@ -196,6 +199,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
196199
 		if deps.RepoIssuesMounter != nil {
197200
 			deps.RepoIssuesMounter(r)
198201
 		}
202
+		if deps.RepoPullsMounter != nil {
203
+			deps.RepoPullsMounter(r)
204
+		}
199205
 		if deps.RepoHomeMounter != nil {
200206
 			deps.RepoHomeMounter(r)
201207
 		}
internal/web/handlers/repo/pulls.goadded
@@ -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
+}
internal/web/handlers/repo/repo.gomodified
@@ -22,6 +22,7 @@ import (
2222
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2323
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
2424
 	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
25
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
2526
 	"github.com/tenseleyFlow/shithub/internal/repos"
2627
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
2728
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -61,6 +62,7 @@ type Handlers struct {
6162
 	rq *reposdb.Queries
6263
 	uq *usersdb.Queries
6364
 	iq *issuesdb.Queries
65
+	pq *pullsdb.Queries
6466
 }
6567
 
6668
 // New constructs the handler set, validating Deps.
@@ -80,7 +82,7 @@ func New(d Deps) (*Handlers, error) {
8082
 	if d.Limiter == nil {
8183
 		d.Limiter = throttle.NewLimiter()
8284
 	}
83
-	return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New(), iq: issuesdb.New()}, nil
85
+	return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New(), iq: issuesdb.New(), pq: pullsdb.New()}, nil
8486
 }
8587
 
8688
 // MountNew registers /new (auth-required). Caller wraps with
internal/web/server.gomodified
@@ -203,6 +203,7 @@ func Run(ctx context.Context, opts Options) error {
203203
 		// so the POST routes get wrapped through the same group with
204204
 		// RequireUser inserted only for state-mutating verbs.
205205
 		deps.RepoIssuesMounter = repoH.MountIssues
206
+		deps.RepoPullsMounter = repoH.MountPulls
206207
 		// Lifecycle danger-zone routes — also auth-required.
207208
 		deps.RepoLifecycleMounter = func(r chi.Router) {
208209
 			r.Group(func(r chi.Router) {