tenseleyflow/shithub / 7313b93

Browse files

api: pulls REST core (list/get/create/patch/commits/files/merge)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7313b9300c7b080c7e7685237b681c45c19f9bd6
Parents
eeeb7d2
Tree
4c061ca

3 changed files

StatusFile+-
M internal/web/handlers/api/api.go 2 0
M internal/web/handlers/api/meta.go 1 0
A internal/web/handlers/api/pulls.go 588 0
internal/web/handlers/api/api.gomodified
@@ -147,6 +147,8 @@ func (h *Handlers) Mount(r chi.Router) {
147147
 		h.mountIssues(r)
148148
 		// S50 §3 — repo labels CRUD.
149149
 		h.mountLabels(r)
150
+		// S50 §4 — pull requests core (list/get/create/patch/merge).
151
+		h.mountPulls(r)
150152
 	})
151153
 }
152154
 
internal/web/handlers/api/meta.gomodified
@@ -28,6 +28,7 @@ var APICapabilities = []string{
2828
 	"repos",
2929
 	"issues",
3030
 	"labels",
31
+	"pulls",
3132
 }
3233
 
3334
 type metaResponse struct {
internal/web/handlers/api/pulls.goadded
@@ -0,0 +1,588 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"context"
7
+	"encoding/json"
8
+	"errors"
9
+	"net/http"
10
+	"strconv"
11
+	"strings"
12
+	"time"
13
+
14
+	"github.com/go-chi/chi/v5"
15
+	"github.com/jackc/pgx/v5"
16
+	"github.com/jackc/pgx/v5/pgtype"
17
+
18
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
19
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
20
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
21
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
22
+	"github.com/tenseleyFlow/shithub/internal/pulls"
23
+	pullsdb "github.com/tenseleyFlow/shithub/internal/pulls/sqlc"
24
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
25
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
26
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
27
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
28
+)
29
+
30
+// mountPulls registers the S50 §4 pull-request REST surface.
31
+//
32
+//	GET    /api/v1/repos/{o}/{r}/pulls                    list
33
+//	POST   /api/v1/repos/{o}/{r}/pulls                    create
34
+//	GET    /api/v1/repos/{o}/{r}/pulls/{number}           get
35
+//	PATCH  /api/v1/repos/{o}/{r}/pulls/{number}           update (title/body/state/draft)
36
+//	GET    /api/v1/repos/{o}/{r}/pulls/{number}/commits   list commits
37
+//	GET    /api/v1/repos/{o}/{r}/pulls/{number}/files     list files
38
+//	PUT    /api/v1/repos/{o}/{r}/pulls/{number}/merge     merge
39
+//
40
+// Reviews, review comments, requested reviewers, update-branch,
41
+// auto-merge land in follow-up batches.
42
+func (h *Handlers) mountPulls(r chi.Router) {
43
+	r.Group(func(r chi.Router) {
44
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
45
+		r.Get("/api/v1/repos/{owner}/{repo}/pulls", h.pullsList)
46
+		r.Get("/api/v1/repos/{owner}/{repo}/pulls/{number}", h.pullGet)
47
+		r.Get("/api/v1/repos/{owner}/{repo}/pulls/{number}/commits", h.pullCommitsList)
48
+		r.Get("/api/v1/repos/{owner}/{repo}/pulls/{number}/files", h.pullFilesList)
49
+	})
50
+	r.Group(func(r chi.Router) {
51
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
52
+		r.Post("/api/v1/repos/{owner}/{repo}/pulls", h.pullCreate)
53
+		r.Patch("/api/v1/repos/{owner}/{repo}/pulls/{number}", h.pullPatch)
54
+		r.Put("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge", h.pullMerge)
55
+	})
56
+}
57
+
58
+// ─── presentation ───────────────────────────────────────────────────
59
+
60
+type pullResponse struct {
61
+	ID             int64  `json:"id"`
62
+	Number         int64  `json:"number"`
63
+	Title          string `json:"title"`
64
+	Body           string `json:"body"`
65
+	State          string `json:"state"`
66
+	Draft          bool   `json:"draft"`
67
+	BaseRef        string `json:"base_ref"`
68
+	HeadRef        string `json:"head_ref"`
69
+	BaseOID        string `json:"base_oid"`
70
+	HeadOID        string `json:"head_oid"`
71
+	Mergeable      *bool  `json:"mergeable,omitempty"`
72
+	MergeableState string `json:"mergeable_state"`
73
+	Merged         bool   `json:"merged"`
74
+	MergeCommit    string `json:"merge_commit_sha,omitempty"`
75
+	MergeMethod    string `json:"merge_method,omitempty"`
76
+	MergedAt       string `json:"merged_at,omitempty"`
77
+	AuthorID       int64  `json:"author_id,omitempty"`
78
+	CreatedAt      string `json:"created_at"`
79
+	UpdatedAt      string `json:"updated_at"`
80
+	ClosedAt       string `json:"closed_at,omitempty"`
81
+}
82
+
83
+func presentPull(issue issuesdb.Issue, pr pullsdb.PullRequest) pullResponse {
84
+	out := pullResponse{
85
+		ID:             issue.ID,
86
+		Number:         issue.Number,
87
+		Title:          issue.Title,
88
+		Body:           issue.Body,
89
+		State:          string(issue.State),
90
+		Draft:          pr.Draft,
91
+		BaseRef:        pr.BaseRef,
92
+		HeadRef:        pr.HeadRef,
93
+		BaseOID:        pr.BaseOid,
94
+		HeadOID:        pr.HeadOid,
95
+		MergeableState: string(pr.MergeableState),
96
+		Merged:         pr.MergedAt.Valid,
97
+		CreatedAt:      issue.CreatedAt.Time.UTC().Format(time.RFC3339),
98
+		UpdatedAt:      issue.UpdatedAt.Time.UTC().Format(time.RFC3339),
99
+	}
100
+	if pr.Mergeable.Valid {
101
+		v := pr.Mergeable.Bool
102
+		out.Mergeable = &v
103
+	}
104
+	if pr.MergeCommitSha.Valid {
105
+		out.MergeCommit = pr.MergeCommitSha.String
106
+	}
107
+	if pr.MergeMethod.Valid {
108
+		out.MergeMethod = string(pr.MergeMethod.PrMergeMethod)
109
+	}
110
+	if pr.MergedAt.Valid {
111
+		out.MergedAt = pr.MergedAt.Time.UTC().Format(time.RFC3339)
112
+	}
113
+	if issue.AuthorUserID.Valid {
114
+		out.AuthorID = issue.AuthorUserID.Int64
115
+	}
116
+	if issue.ClosedAt.Valid {
117
+		out.ClosedAt = issue.ClosedAt.Time.UTC().Format(time.RFC3339)
118
+	}
119
+	return out
120
+}
121
+
122
+type commitResponse2 struct {
123
+	SHA            string `json:"sha"`
124
+	Subject        string `json:"subject"`
125
+	Body           string `json:"body,omitempty"`
126
+	AuthorName     string `json:"author_name"`
127
+	AuthorEmail    string `json:"author_email"`
128
+	CommitterName  string `json:"committer_name"`
129
+	CommitterEmail string `json:"committer_email"`
130
+	AuthoredAt     string `json:"authored_at,omitempty"`
131
+	CommittedAt    string `json:"committed_at,omitempty"`
132
+}
133
+
134
+type prFileResponse struct {
135
+	Path      string `json:"path"`
136
+	OldPath   string `json:"old_path,omitempty"`
137
+	Status    string `json:"status"`
138
+	Additions int32  `json:"additions"`
139
+	Deletions int32  `json:"deletions"`
140
+	Changes   int32  `json:"changes"`
141
+}
142
+
143
+// ─── list ───────────────────────────────────────────────────────────
144
+
145
+func (h *Handlers) pullsList(w http.ResponseWriter, r *http.Request) {
146
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullRead)
147
+	if !ok {
148
+		return
149
+	}
150
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
151
+	stateFilter := normalizeIssueState(r.URL.Query().Get("state"))
152
+	draftFilter := normalizeDraftFilter(r.URL.Query().Get("draft"))
153
+
154
+	q := pullsdb.New()
155
+	total, err := q.CountPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.CountPullRequestsByRepoParams{
156
+		RepoID:      repo.ID,
157
+		StateFilter: stateFilter,
158
+		Draft:       draftFilter,
159
+	})
160
+	if err != nil {
161
+		h.d.Logger.ErrorContext(r.Context(), "api: count pulls", "error", err)
162
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
163
+		return
164
+	}
165
+	rows, err := q.ListPullRequestsByRepo(r.Context(), h.d.Pool, pullsdb.ListPullRequestsByRepoParams{
166
+		RepoID:      repo.ID,
167
+		Limit:       int32(perPage),
168
+		Offset:      int32((page - 1) * perPage),
169
+		StateFilter: stateFilter,
170
+		Draft:       draftFilter,
171
+	})
172
+	if err != nil {
173
+		h.d.Logger.ErrorContext(r.Context(), "api: list pulls", "error", err)
174
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
175
+		return
176
+	}
177
+	link := apipage.Page{Current: page, PerPage: perPage, Total: int(total)}.LinkHeader(h.d.BaseURL, sanitizedURL(r))
178
+	if link != "" {
179
+		w.Header().Set("Link", link)
180
+	}
181
+	out := make([]pullResponse, 0, len(rows))
182
+	for _, row := range rows {
183
+		out = append(out, presentPull(issuesdb.Issue{
184
+			ID:           row.ID,
185
+			RepoID:       row.RepoID,
186
+			Number:       row.Number,
187
+			Title:        row.Title,
188
+			Body:         row.Body,
189
+			AuthorUserID: row.AuthorUserID,
190
+			State:        issuesdb.IssueState(row.State),
191
+			CreatedAt:    row.CreatedAt,
192
+			UpdatedAt:    row.UpdatedAt,
193
+		}, pullsdb.PullRequest{
194
+			IssueID:        row.IssueID,
195
+			BaseRef:        row.BaseRef,
196
+			HeadRef:        row.HeadRef,
197
+			HeadRepoID:     row.HeadRepoID,
198
+			BaseOid:        row.BaseOid,
199
+			HeadOid:        row.HeadOid,
200
+			Draft:          row.Draft,
201
+			Mergeable:      row.Mergeable,
202
+			MergeableState: row.MergeableState,
203
+			MergeCommitSha: row.MergeCommitSha,
204
+			MergedAt:       row.MergedAt,
205
+			MergedByUserID: row.MergedByUserID,
206
+			MergeMethod:    row.MergeMethod,
207
+		}))
208
+	}
209
+	writeJSON(w, http.StatusOK, out)
210
+}
211
+
212
+func normalizeDraftFilter(s string) pgtype.Bool {
213
+	switch strings.ToLower(strings.TrimSpace(s)) {
214
+	case "true":
215
+		return pgtype.Bool{Bool: true, Valid: true}
216
+	case "false":
217
+		return pgtype.Bool{Bool: false, Valid: true}
218
+	default:
219
+		return pgtype.Bool{}
220
+	}
221
+}
222
+
223
+// ─── single ─────────────────────────────────────────────────────────
224
+
225
+func (h *Handlers) pullGet(w http.ResponseWriter, r *http.Request) {
226
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullRead)
227
+	if !ok {
228
+		return
229
+	}
230
+	issue, pr, ok := h.resolvePRByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
231
+	if !ok {
232
+		return
233
+	}
234
+	writeJSON(w, http.StatusOK, presentPull(issue, pr))
235
+}
236
+
237
+// ─── create ─────────────────────────────────────────────────────────
238
+
239
+type pullCreateRequest struct {
240
+	Title string `json:"title"`
241
+	Body  string `json:"body"`
242
+	Base  string `json:"base"`
243
+	Head  string `json:"head"`
244
+	Draft bool   `json:"draft"`
245
+}
246
+
247
+func (h *Handlers) pullCreate(w http.ResponseWriter, r *http.Request) {
248
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullCreate)
249
+	if !ok {
250
+		return
251
+	}
252
+	auth := middleware.PATAuthFromContext(r.Context())
253
+	var body pullCreateRequest
254
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
255
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
256
+		return
257
+	}
258
+	gitDir, err := h.repoGitDir(r.Context(), repo)
259
+	if err != nil {
260
+		h.d.Logger.ErrorContext(r.Context(), "api: resolve gitDir", "error", err)
261
+		writeAPIError(w, http.StatusInternalServerError, "create failed")
262
+		return
263
+	}
264
+	res, err := pulls.Create(r.Context(), pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}, pulls.CreateParams{
265
+		RepoID:       repo.ID,
266
+		AuthorUserID: auth.UserID,
267
+		Title:        body.Title,
268
+		Body:         body.Body,
269
+		BaseRef:      body.Base,
270
+		HeadRef:      body.Head,
271
+		Draft:        body.Draft,
272
+		GitDir:       gitDir,
273
+	})
274
+	if err != nil {
275
+		writePullsError(w, err)
276
+		return
277
+	}
278
+	writeJSON(w, http.StatusCreated, presentPull(res.Issue, res.PullRequest))
279
+}
280
+
281
+// ─── patch ──────────────────────────────────────────────────────────
282
+
283
+type pullPatchRequest struct {
284
+	Title *string `json:"title,omitempty"`
285
+	Body  *string `json:"body,omitempty"`
286
+	State *string `json:"state,omitempty"`
287
+	Draft *bool   `json:"draft,omitempty"`
288
+}
289
+
290
+func (h *Handlers) pullPatch(w http.ResponseWriter, r *http.Request) {
291
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullRead)
292
+	if !ok {
293
+		return
294
+	}
295
+	auth := middleware.PATAuthFromContext(r.Context())
296
+	if auth.UserID == 0 {
297
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
298
+		return
299
+	}
300
+	issue, pr, ok := h.resolvePRByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
301
+	if !ok {
302
+		return
303
+	}
304
+	var body pullPatchRequest
305
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
306
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
307
+		return
308
+	}
309
+
310
+	if body.Title != nil || body.Body != nil {
311
+		canEdit := issue.AuthorUserID.Valid && issue.AuthorUserID.Int64 == auth.UserID
312
+		if !canEdit {
313
+			canEdit = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
314
+		}
315
+		if !canEdit {
316
+			writeAPIError(w, http.StatusForbidden, "only the author or a repo collaborator may edit this pull request")
317
+			return
318
+		}
319
+		title := issue.Title
320
+		if body.Title != nil {
321
+			title = *body.Title
322
+		}
323
+		bodyText := issue.Body
324
+		if body.Body != nil {
325
+			bodyText = *body.Body
326
+		}
327
+		if err := pulls.EditPR(r.Context(), pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}, issue.ID, title, bodyText); err != nil {
328
+			writePullsError(w, err)
329
+			return
330
+		}
331
+	}
332
+
333
+	if body.Draft != nil && pr.Draft && !*body.Draft {
334
+		// Only the author can flip draft→ready in v1.
335
+		isAuthor := issue.AuthorUserID.Valid && issue.AuthorUserID.Int64 == auth.UserID
336
+		if !isAuthor {
337
+			writeAPIError(w, http.StatusForbidden, "only the author may mark a draft PR as ready")
338
+			return
339
+		}
340
+		if err := pulls.SetReady(r.Context(), pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}, auth.UserID, issue.ID); err != nil {
341
+			writePullsError(w, err)
342
+			return
343
+		}
344
+	}
345
+	if body.Draft != nil && !pr.Draft && *body.Draft {
346
+		writeAPIError(w, http.StatusUnprocessableEntity, "ready→draft is not supported")
347
+		return
348
+	}
349
+
350
+	if body.State != nil {
351
+		if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionPullClose, policy.NewRepoRefFromRepo(*repo)).Allow {
352
+			writeAPIError(w, http.StatusForbidden, "lack permission to change PR state")
353
+			return
354
+		}
355
+		newState := strings.ToLower(*body.State)
356
+		if newState != "open" && newState != "closed" {
357
+			writeAPIError(w, http.StatusUnprocessableEntity, "state must be open or closed")
358
+			return
359
+		}
360
+		gitDir, err := h.repoGitDir(r.Context(), repo)
361
+		if err != nil {
362
+			h.d.Logger.ErrorContext(r.Context(), "api: resolve gitDir", "error", err)
363
+			writeAPIError(w, http.StatusInternalServerError, "state change failed")
364
+			return
365
+		}
366
+		if err := pulls.SetState(r.Context(), pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}, gitDir, auth.UserID, issue.ID, newState); err != nil {
367
+			writePullsError(w, err)
368
+			return
369
+		}
370
+	}
371
+
372
+	// Reload everything for the response.
373
+	freshIssue, _ := issuesdb.New().GetIssueByID(r.Context(), h.d.Pool, issue.ID)
374
+	freshPR, _ := pullsdb.New().GetPullRequestByIssueID(r.Context(), h.d.Pool, issue.ID)
375
+	writeJSON(w, http.StatusOK, presentPull(freshIssue, freshPR))
376
+}
377
+
378
+// ─── commits + files ────────────────────────────────────────────────
379
+
380
+func (h *Handlers) pullCommitsList(w http.ResponseWriter, r *http.Request) {
381
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullRead)
382
+	if !ok {
383
+		return
384
+	}
385
+	_, pr, ok := h.resolvePRByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
386
+	if !ok {
387
+		return
388
+	}
389
+	rows, err := pullsdb.New().ListPullRequestCommits(r.Context(), h.d.Pool, pr.IssueID)
390
+	if err != nil {
391
+		h.d.Logger.ErrorContext(r.Context(), "api: list pr commits", "error", err)
392
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
393
+		return
394
+	}
395
+	out := make([]commitResponse2, 0, len(rows))
396
+	for _, c := range rows {
397
+		entry := commitResponse2{
398
+			SHA:            c.Sha,
399
+			Subject:        c.Subject,
400
+			Body:           c.Body,
401
+			AuthorName:     c.AuthorName,
402
+			AuthorEmail:    c.AuthorEmail,
403
+			CommitterName:  c.CommitterName,
404
+			CommitterEmail: c.CommitterEmail,
405
+		}
406
+		if c.AuthoredAt.Valid {
407
+			entry.AuthoredAt = c.AuthoredAt.Time.UTC().Format(time.RFC3339)
408
+		}
409
+		if c.CommittedAt.Valid {
410
+			entry.CommittedAt = c.CommittedAt.Time.UTC().Format(time.RFC3339)
411
+		}
412
+		out = append(out, entry)
413
+	}
414
+	writeJSON(w, http.StatusOK, out)
415
+}
416
+
417
+func (h *Handlers) pullFilesList(w http.ResponseWriter, r *http.Request) {
418
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullRead)
419
+	if !ok {
420
+		return
421
+	}
422
+	_, pr, ok := h.resolvePRByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
423
+	if !ok {
424
+		return
425
+	}
426
+	rows, err := pullsdb.New().ListPullRequestFiles(r.Context(), h.d.Pool, pr.IssueID)
427
+	if err != nil {
428
+		h.d.Logger.ErrorContext(r.Context(), "api: list pr files", "error", err)
429
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
430
+		return
431
+	}
432
+	out := make([]prFileResponse, 0, len(rows))
433
+	for _, f := range rows {
434
+		entry := prFileResponse{
435
+			Path:      f.Path,
436
+			Status:    string(f.Status),
437
+			Additions: f.Additions,
438
+			Deletions: f.Deletions,
439
+			Changes:   f.Changes,
440
+		}
441
+		if f.OldPath.Valid {
442
+			entry.OldPath = f.OldPath.String
443
+		}
444
+		out = append(out, entry)
445
+	}
446
+	writeJSON(w, http.StatusOK, out)
447
+}
448
+
449
+// ─── merge ──────────────────────────────────────────────────────────
450
+
451
+type pullMergeRequest struct {
452
+	CommitTitle   string `json:"commit_title"`
453
+	CommitMessage string `json:"commit_message"`
454
+	MergeMethod   string `json:"merge_method"`
455
+	SHA           string `json:"sha"`
456
+}
457
+
458
+func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) {
459
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionPullMerge)
460
+	if !ok {
461
+		return
462
+	}
463
+	auth := middleware.PATAuthFromContext(r.Context())
464
+	if auth.UserID == 0 {
465
+		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
466
+		return
467
+	}
468
+	_, pr, ok := h.resolvePRByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
469
+	if !ok {
470
+		return
471
+	}
472
+	var body pullMergeRequest
473
+	_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
474
+	method := strings.ToLower(strings.TrimSpace(body.MergeMethod))
475
+	if method == "" {
476
+		method = string(repo.DefaultMergeMethod)
477
+	}
478
+	if body.SHA != "" && body.SHA != pr.HeadOid {
479
+		writeAPIError(w, http.StatusConflict, "head sha mismatch")
480
+		return
481
+	}
482
+	gitDir, err := h.repoGitDir(r.Context(), repo)
483
+	if err != nil {
484
+		h.d.Logger.ErrorContext(r.Context(), "api: resolve gitDir", "error", err)
485
+		writeAPIError(w, http.StatusInternalServerError, "merge failed")
486
+		return
487
+	}
488
+	if err := pulls.Merge(r.Context(), pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}, pulls.MergeParams{
489
+		PRID:        pr.IssueID,
490
+		ActorUserID: auth.UserID,
491
+		GitDir:      gitDir,
492
+		Method:      method,
493
+		Subject:     body.CommitTitle,
494
+		Body:        body.CommitMessage,
495
+	}); err != nil {
496
+		writePullsError(w, err)
497
+		return
498
+	}
499
+	freshIssue, _ := issuesdb.New().GetIssueByID(r.Context(), h.d.Pool, pr.IssueID)
500
+	freshPR, _ := pullsdb.New().GetPullRequestByIssueID(r.Context(), h.d.Pool, pr.IssueID)
501
+	writeJSON(w, http.StatusOK, presentPull(freshIssue, freshPR))
502
+}
503
+
504
+// ─── helpers ────────────────────────────────────────────────────────
505
+
506
+// resolvePRByNumber resolves a PR by repo+number. The repo gate has
507
+// already been satisfied by resolveAPIRepo; non-PR issues (kind="issue")
508
+// 404 here.
509
+func (h *Handlers) resolvePRByNumber(w http.ResponseWriter, r *http.Request, repoID int64, numberRaw string) (issuesdb.Issue, pullsdb.PullRequest, bool) {
510
+	num, err := strconv.ParseInt(numberRaw, 10, 64)
511
+	if err != nil {
512
+		writeAPIError(w, http.StatusNotFound, "pull request not found")
513
+		return issuesdb.Issue{}, pullsdb.PullRequest{}, false
514
+	}
515
+	issue, err := issuesdb.New().GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
516
+		RepoID: repoID, Number: num,
517
+	})
518
+	if err != nil || issue.Kind != issuesdb.IssueKindPr {
519
+		writeAPIError(w, http.StatusNotFound, "pull request not found")
520
+		return issuesdb.Issue{}, pullsdb.PullRequest{}, false
521
+	}
522
+	pr, err := pullsdb.New().GetPullRequestByIssueID(r.Context(), h.d.Pool, issue.ID)
523
+	if err != nil {
524
+		if errors.Is(err, pgx.ErrNoRows) {
525
+			writeAPIError(w, http.StatusNotFound, "pull request not found")
526
+			return issuesdb.Issue{}, pullsdb.PullRequest{}, false
527
+		}
528
+		h.d.Logger.ErrorContext(r.Context(), "api: load pr row", "error", err)
529
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
530
+		return issuesdb.Issue{}, pullsdb.PullRequest{}, false
531
+	}
532
+	return issue, pr, true
533
+}
534
+
535
+// writePullsError maps the orchestrator's typed errors to HTTP codes.
536
+func writePullsError(w http.ResponseWriter, err error) {
537
+	switch {
538
+	case errors.Is(err, pulls.ErrSameBranch),
539
+		errors.Is(err, pulls.ErrBaseNotFound),
540
+		errors.Is(err, pulls.ErrHeadNotFound),
541
+		errors.Is(err, pulls.ErrNoCommitsToMerge),
542
+		errors.Is(err, pulls.ErrMergeMethodOff):
543
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
544
+	case errors.Is(err, pulls.ErrAlreadyMerged),
545
+		errors.Is(err, pulls.ErrAlreadyClosed),
546
+		errors.Is(err, pulls.ErrMergeBlocked):
547
+		writeAPIError(w, http.StatusConflict, err.Error())
548
+	case errors.Is(err, pulls.ErrConcurrentMerge):
549
+		writeAPIError(w, http.StatusServiceUnavailable, "another merge is in flight")
550
+	case errors.Is(err, pulls.ErrPRNotFound):
551
+		writeAPIError(w, http.StatusNotFound, "pull request not found")
552
+	default:
553
+		writeAPIError(w, http.StatusInternalServerError, "internal error")
554
+	}
555
+}
556
+
557
+// repoGitDir resolves the on-disk bare-repo path for a row through
558
+// RepoFS. User-owned repos use the username as the on-disk slug;
559
+// org-owned repos use the org slug. Mirrors the layout repos.Create
560
+// established at creation time.
561
+func (h *Handlers) repoGitDir(ctx context.Context, repo *reposdb.Repo) (string, error) {
562
+	if h.d.RepoFS == nil {
563
+		return "", errors.New("api: RepoFS not configured")
564
+	}
565
+	slug, err := h.repoOwnerSlug(ctx, repo)
566
+	if err != nil {
567
+		return "", err
568
+	}
569
+	return h.d.RepoFS.RepoPath(slug, repo.Name)
570
+}
571
+
572
+func (h *Handlers) repoOwnerSlug(ctx context.Context, repo *reposdb.Repo) (string, error) {
573
+	if repo.OwnerUserID.Valid {
574
+		user, err := usersdb.New().GetUserByID(ctx, h.d.Pool, repo.OwnerUserID.Int64)
575
+		if err != nil {
576
+			return "", err
577
+		}
578
+		return user.Username, nil
579
+	}
580
+	if repo.OwnerOrgID.Valid {
581
+		org, err := orgsdb.New().GetOrgByID(ctx, h.d.Pool, repo.OwnerOrgID.Int64)
582
+		if err != nil {
583
+			return "", err
584
+		}
585
+		return string(org.Slug), nil
586
+	}
587
+	return "", errors.New("api: repo has no owner")
588
+}