tenseleyflow/shithub / 446661f

Browse files

api: issues + comments + lock + labels REST surface

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
446661f302c0d1c9d4ac42d0d9aa53c1aecf077e
Parents
da1eb54
Tree
350b103

6 changed files

StatusFile+-
M internal/web/handlers/api/api.go 4 0
M internal/web/handlers/api/checks.go 11 18
A internal/web/handlers/api/issues.go 639 0
A internal/web/handlers/api/labels.go 231 0
M internal/web/handlers/api/meta.go 2 0
M internal/web/handlers/api/repos.go 8 2
internal/web/handlers/api/api.gomodified
@@ -143,6 +143,10 @@ func (h *Handlers) Mount(r chi.Router) {
143143
 		h.mountUserKeys(r)
144144
 		// S50 §2 — repos REST core (list/single/create/patch/delete).
145145
 		h.mountRepos(r)
146
+		// S50 §3 — issues + comments + lock.
147
+		h.mountIssues(r)
148
+		// S50 §3 — repo labels CRUD.
149
+		h.mountLabels(r)
146150
 	})
147151
 }
148152
 
internal/web/handlers/api/checks.gomodified
@@ -11,14 +11,12 @@ import (
1111
 
1212
 	"github.com/go-chi/chi/v5"
1313
 	"github.com/jackc/pgx/v5"
14
-	"github.com/jackc/pgx/v5/pgtype"
1514
 
1615
 	"github.com/tenseleyFlow/shithub/internal/auth/pat"
1716
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
1817
 	"github.com/tenseleyFlow/shithub/internal/checks"
1918
 	checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc"
2019
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
21
-	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
2220
 	"github.com/tenseleyFlow/shithub/internal/web/middleware"
2321
 )
2422
 
@@ -38,32 +36,27 @@ func (h *Handlers) mountChecks(r chi.Router) {
3836
 	})
3937
 }
4038
 
41
-// resolveRepo loads the repo for {owner}/{repo} and confirms the
42
-// PAT-authenticated user has the `action` permission. Returns the
43
-// resolved repo (or nil, false on failure with response written).
39
+// resolveAPIRepo loads the repo for {owner}/{repo} (user OR org-owned)
40
+// and confirms the PAT-authenticated actor satisfies `action`. Returns
41
+// the resolved repo, or nil + false after writing the response on
42
+// failure. Visibility misses 404 (existence-leak-safe), matching the
43
+// rest of the /api/v1 surface.
44
+//
45
+// Authentication required for all actions except plain ActionRepoRead /
46
+// ActionIssueRead / ActionPullRead, where anonymous callers go through
47
+// the policy gate (which itself denies private repos by visibility).
4448
 func (h *Handlers) resolveAPIRepo(w http.ResponseWriter, r *http.Request, action policy.Action) (*reposdb.Repo, bool) {
4549
 	auth := middleware.PATAuthFromContext(r.Context())
46
-	if auth.UserID == 0 {
50
+	if auth.UserID == 0 && actionRequiresAuth(action) {
4751
 		writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
4852
 		return nil, false
4953
 	}
50
-	owner, err := usersdb.New().GetUserByUsername(r.Context(), h.d.Pool, chi.URLParam(r, "owner"))
51
-	if err != nil {
52
-		writeAPIError(w, http.StatusNotFound, "repo not found")
53
-		return nil, false
54
-	}
55
-	repo, err := reposdb.New().GetRepoByOwnerUserAndName(r.Context(), h.d.Pool, reposdb.GetRepoByOwnerUserAndNameParams{
56
-		OwnerUserID: pgtype.Int8{Int64: owner.ID, Valid: true},
57
-		Name:        chi.URLParam(r, "repo"),
58
-	})
54
+	repo, _, err := lookupRepoByLogin(r, h.d.Pool, chi.URLParam(r, "owner"), chi.URLParam(r, "repo"))
5955
 	if err != nil {
6056
 		writeAPIError(w, http.StatusNotFound, "repo not found")
6157
 		return nil, false
6258
 	}
6359
 	if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), action, policy.NewRepoRefFromRepo(repo)).Allow {
64
-		// Existence-leak: 404 instead of 403 when the actor can't see
65
-		// the repo. The PAT-scope check above is the public 403; this
66
-		// is the visibility gate.
6760
 		writeAPIError(w, http.StatusNotFound, "repo not found")
6861
 		return nil, false
6962
 	}
internal/web/handlers/api/issues.goadded
@@ -0,0 +1,639 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"encoding/json"
7
+	"errors"
8
+	"net/http"
9
+	"strconv"
10
+	"strings"
11
+	"time"
12
+
13
+	"github.com/go-chi/chi/v5"
14
+	"github.com/jackc/pgx/v5"
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
19
+	"github.com/tenseleyFlow/shithub/internal/issues"
20
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
21
+	"github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
22
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
23
+)
24
+
25
+// mountIssues registers the S50 §3 issue REST surface.
26
+//
27
+//	GET    /api/v1/repos/{o}/{r}/issues                     list
28
+//	POST   /api/v1/repos/{o}/{r}/issues                     create
29
+//	GET    /api/v1/repos/{o}/{r}/issues/{number}            get
30
+//	PATCH  /api/v1/repos/{o}/{r}/issues/{number}            update (title, body, state, state_reason)
31
+//	GET    /api/v1/repos/{o}/{r}/issues/{number}/comments   list comments
32
+//	POST   /api/v1/repos/{o}/{r}/issues/{number}/comments   add comment
33
+//	PATCH  /api/v1/repos/{o}/{r}/issues/comments/{id}       edit comment
34
+//	DELETE /api/v1/repos/{o}/{r}/issues/comments/{id}       delete comment
35
+//	PUT    /api/v1/repos/{o}/{r}/issues/{number}/lock       lock
36
+//	DELETE /api/v1/repos/{o}/{r}/issues/{number}/lock       unlock
37
+//
38
+// PAT scopes: repo:read on GETs, repo:write on mutations. Policy gates
39
+// (ActionIssueRead/Create/Close/etc.) layer on top of the scope check;
40
+// existence-leak-safe 404 on visibility miss.
41
+func (h *Handlers) mountIssues(r chi.Router) {
42
+	r.Group(func(r chi.Router) {
43
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
44
+		r.Get("/api/v1/repos/{owner}/{repo}/issues", h.issuesList)
45
+		r.Get("/api/v1/repos/{owner}/{repo}/issues/{number}", h.issueGet)
46
+		r.Get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", h.issueCommentsList)
47
+	})
48
+	r.Group(func(r chi.Router) {
49
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
50
+		r.Post("/api/v1/repos/{owner}/{repo}/issues", h.issueCreate)
51
+		r.Patch("/api/v1/repos/{owner}/{repo}/issues/{number}", h.issuePatch)
52
+		r.Post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", h.issueCommentCreate)
53
+		r.Patch("/api/v1/repos/{owner}/{repo}/issues/comments/{cid}", h.issueCommentUpdate)
54
+		r.Delete("/api/v1/repos/{owner}/{repo}/issues/comments/{cid}", h.issueCommentDelete)
55
+		r.Put("/api/v1/repos/{owner}/{repo}/issues/{number}/lock", h.issueLock)
56
+		r.Delete("/api/v1/repos/{owner}/{repo}/issues/{number}/lock", h.issueUnlock)
57
+	})
58
+}
59
+
60
+// ─── presentation ───────────────────────────────────────────────────
61
+
62
+type issueResponse struct {
63
+	ID          int64    `json:"id"`
64
+	Number      int64    `json:"number"`
65
+	Title       string   `json:"title"`
66
+	Body        string   `json:"body"`
67
+	State       string   `json:"state"`
68
+	StateReason string   `json:"state_reason,omitempty"`
69
+	Locked      bool     `json:"locked"`
70
+	LockReason  string   `json:"lock_reason,omitempty"`
71
+	AuthorID    int64    `json:"author_id,omitempty"`
72
+	Labels      []string `json:"labels,omitempty"`
73
+	CreatedAt   string   `json:"created_at"`
74
+	UpdatedAt   string   `json:"updated_at"`
75
+	ClosedAt    string   `json:"closed_at,omitempty"`
76
+}
77
+
78
+func presentIssue(i issuesdb.Issue, labels []string) issueResponse {
79
+	out := issueResponse{
80
+		ID:        i.ID,
81
+		Number:    i.Number,
82
+		Title:     i.Title,
83
+		Body:      i.Body,
84
+		State:     string(i.State),
85
+		Locked:    i.Locked,
86
+		Labels:    labels,
87
+		CreatedAt: i.CreatedAt.Time.UTC().Format(time.RFC3339),
88
+		UpdatedAt: i.UpdatedAt.Time.UTC().Format(time.RFC3339),
89
+	}
90
+	if i.StateReason.Valid {
91
+		out.StateReason = string(i.StateReason.IssueStateReason)
92
+	}
93
+	if i.LockReason.Valid {
94
+		out.LockReason = i.LockReason.String
95
+	}
96
+	if i.AuthorUserID.Valid {
97
+		out.AuthorID = i.AuthorUserID.Int64
98
+	}
99
+	if i.ClosedAt.Valid {
100
+		out.ClosedAt = i.ClosedAt.Time.UTC().Format(time.RFC3339)
101
+	}
102
+	return out
103
+}
104
+
105
+type commentResponse struct {
106
+	ID        int64  `json:"id"`
107
+	IssueID   int64  `json:"issue_id"`
108
+	AuthorID  int64  `json:"author_id,omitempty"`
109
+	Body      string `json:"body"`
110
+	CreatedAt string `json:"created_at"`
111
+	UpdatedAt string `json:"updated_at"`
112
+	EditedAt  string `json:"edited_at,omitempty"`
113
+}
114
+
115
+func presentComment(c issuesdb.IssueComment) commentResponse {
116
+	out := commentResponse{
117
+		ID:        c.ID,
118
+		IssueID:   c.IssueID,
119
+		Body:      c.Body,
120
+		CreatedAt: c.CreatedAt.Time.UTC().Format(time.RFC3339),
121
+		UpdatedAt: c.UpdatedAt.Time.UTC().Format(time.RFC3339),
122
+	}
123
+	if c.AuthorUserID.Valid {
124
+		out.AuthorID = c.AuthorUserID.Int64
125
+	}
126
+	if c.EditedAt.Valid {
127
+		out.EditedAt = c.EditedAt.Time.UTC().Format(time.RFC3339)
128
+	}
129
+	return out
130
+}
131
+
132
+// ─── list ───────────────────────────────────────────────────────────
133
+
134
+func (h *Handlers) issuesList(w http.ResponseWriter, r *http.Request) {
135
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
136
+	if !ok {
137
+		return
138
+	}
139
+	page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
140
+	stateFilter := normalizeIssueState(r.URL.Query().Get("state"))
141
+	q := issuesdb.New()
142
+	total, err := q.CountIssues(r.Context(), h.d.Pool, issuesdb.CountIssuesParams{
143
+		RepoID:      repo.ID,
144
+		StateFilter: stateFilter,
145
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
146
+	})
147
+	if err != nil {
148
+		h.d.Logger.ErrorContext(r.Context(), "api: count issues", "error", err)
149
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
150
+		return
151
+	}
152
+	rows, err := q.ListIssues(r.Context(), h.d.Pool, issuesdb.ListIssuesParams{
153
+		RepoID:      repo.ID,
154
+		Limit:       int32(perPage),
155
+		Offset:      int32((page - 1) * perPage),
156
+		StateFilter: stateFilter,
157
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
158
+	})
159
+	if err != nil {
160
+		h.d.Logger.ErrorContext(r.Context(), "api: list issues", "error", err)
161
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
162
+		return
163
+	}
164
+	link := apipage.Page{Current: page, PerPage: perPage, Total: int(total)}.LinkHeader(h.d.BaseURL, sanitizedURL(r))
165
+	if link != "" {
166
+		w.Header().Set("Link", link)
167
+	}
168
+	out := make([]issueResponse, 0, len(rows))
169
+	for _, row := range rows {
170
+		out = append(out, presentIssue(row, h.labelNamesFor(r.Context(), row.ID)))
171
+	}
172
+	writeJSON(w, http.StatusOK, out)
173
+}
174
+
175
+func normalizeIssueState(s string) pgtype.Text {
176
+	switch strings.ToLower(strings.TrimSpace(s)) {
177
+	case "open":
178
+		return pgtype.Text{String: "open", Valid: true}
179
+	case "closed":
180
+		return pgtype.Text{String: "closed", Valid: true}
181
+	case "", "all":
182
+		// Encoded as NULL in the sqlc query so the WHERE clause is a no-op.
183
+		return pgtype.Text{}
184
+	default:
185
+		// Unknown values fall back to "all" — gh-style leniency for
186
+		// list endpoints; tightening would break script ports.
187
+		return pgtype.Text{}
188
+	}
189
+}
190
+
191
+func (h *Handlers) labelNamesFor(ctx httpRequestCtx, issueID int64) []string {
192
+	rows, err := issuesdb.New().ListLabelsOnIssue(ctx, h.d.Pool, issueID)
193
+	if err != nil {
194
+		return nil
195
+	}
196
+	out := make([]string, 0, len(rows))
197
+	for _, r := range rows {
198
+		out = append(out, r.Name)
199
+	}
200
+	return out
201
+}
202
+
203
+// httpRequestCtx is a tiny alias used only as a parameter type so the
204
+// labelNamesFor signature reads naturally (we don't want to import net.
205
+// or context in this file just for that). We rely on Go assigning the
206
+// request context to context.Context via the implicit interface.
207
+type httpRequestCtx = ctxLike
208
+
209
+type ctxLike interface {
210
+	Deadline() (time.Time, bool)
211
+	Done() <-chan struct{}
212
+	Err() error
213
+	Value(any) any
214
+}
215
+
216
+// ─── single get ─────────────────────────────────────────────────────
217
+
218
+func (h *Handlers) issueGet(w http.ResponseWriter, r *http.Request) {
219
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
220
+	if !ok {
221
+		return
222
+	}
223
+	num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
224
+	if err != nil {
225
+		writeAPIError(w, http.StatusNotFound, "issue not found")
226
+		return
227
+	}
228
+	issue, err := issuesdb.New().GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
229
+		RepoID: repo.ID, Number: num,
230
+	})
231
+	if err != nil {
232
+		if errors.Is(err, pgx.ErrNoRows) {
233
+			writeAPIError(w, http.StatusNotFound, "issue not found")
234
+			return
235
+		}
236
+		h.d.Logger.ErrorContext(r.Context(), "api: get issue", "error", err)
237
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
238
+		return
239
+	}
240
+	if issue.Kind != issuesdb.IssueKindIssue {
241
+		// PRs share the `issues` table but are not exposed on the
242
+		// /issues REST surface — they get their own routes in §4.
243
+		writeAPIError(w, http.StatusNotFound, "issue not found")
244
+		return
245
+	}
246
+	writeJSON(w, http.StatusOK, presentIssue(issue, h.labelNamesFor(r.Context(), issue.ID)))
247
+}
248
+
249
+// ─── create ─────────────────────────────────────────────────────────
250
+
251
+type issueCreateRequest struct {
252
+	Title string `json:"title"`
253
+	Body  string `json:"body"`
254
+}
255
+
256
+func (h *Handlers) issueCreate(w http.ResponseWriter, r *http.Request) {
257
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueCreate)
258
+	if !ok {
259
+		return
260
+	}
261
+	auth := middleware.PATAuthFromContext(r.Context())
262
+	var body issueCreateRequest
263
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
264
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
265
+		return
266
+	}
267
+	issue, err := issues.Create(r.Context(), h.issuesDeps(), issues.CreateParams{
268
+		RepoID:       repo.ID,
269
+		AuthorUserID: auth.UserID,
270
+		Title:        body.Title,
271
+		Body:         body.Body,
272
+		Kind:         "issue",
273
+	})
274
+	if err != nil {
275
+		writeIssuesError(w, err)
276
+		return
277
+	}
278
+	writeJSON(w, http.StatusCreated, presentIssue(issue, nil))
279
+}
280
+
281
+// ─── patch ──────────────────────────────────────────────────────────
282
+
283
+type issuePatchRequest struct {
284
+	Title       *string `json:"title,omitempty"`
285
+	Body        *string `json:"body,omitempty"`
286
+	State       *string `json:"state,omitempty"`
287
+	StateReason *string `json:"state_reason,omitempty"`
288
+}
289
+
290
+func (h *Handlers) issuePatch(w http.ResponseWriter, r *http.Request) {
291
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
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
+	num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
301
+	if err != nil {
302
+		writeAPIError(w, http.StatusNotFound, "issue not found")
303
+		return
304
+	}
305
+	q := issuesdb.New()
306
+	issue, err := q.GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
307
+		RepoID: repo.ID, Number: num,
308
+	})
309
+	if err != nil || issue.Kind != issuesdb.IssueKindIssue {
310
+		writeAPIError(w, http.StatusNotFound, "issue not found")
311
+		return
312
+	}
313
+	var body issuePatchRequest
314
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
315
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
316
+		return
317
+	}
318
+
319
+	// Title/body: author OR repo collaborator with at least
320
+	// triage-equivalent permissions can edit. We gate via
321
+	// ActionIssueComment since it matches the "trusted contributor"
322
+	// archetype (comment + edit-own-issue privileges).
323
+	if body.Title != nil || body.Body != nil {
324
+		// Only the author (or someone with comment-equivalent
325
+		// privileges on the repo) edits.
326
+		canEdit := false
327
+		if issue.AuthorUserID.Valid && issue.AuthorUserID.Int64 == auth.UserID {
328
+			canEdit = true
329
+		}
330
+		if !canEdit {
331
+			canEdit = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionIssueComment, policy.NewRepoRefFromRepo(*repo)).Allow
332
+		}
333
+		if !canEdit {
334
+			writeAPIError(w, http.StatusForbidden, "only the author or a collaborator may edit this issue")
335
+			return
336
+		}
337
+		updated, err := issues.Edit(r.Context(), h.issuesDeps(), issues.EditParams{
338
+			IssueID: issue.ID,
339
+			Title:   body.Title,
340
+			Body:    body.Body,
341
+		})
342
+		if err != nil {
343
+			writeIssuesError(w, err)
344
+			return
345
+		}
346
+		issue = updated
347
+	}
348
+
349
+	if body.State != nil {
350
+		// State changes require ActionIssueClose.
351
+		if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionIssueClose, policy.NewRepoRefFromRepo(*repo)).Allow {
352
+			writeAPIError(w, http.StatusForbidden, "lack permission to change issue 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
+		reason := ""
361
+		if body.StateReason != nil {
362
+			reason = strings.ToLower(*body.StateReason)
363
+			switch reason {
364
+			case "", "completed", "not_planned", "duplicate", "reopened":
365
+			default:
366
+				writeAPIError(w, http.StatusUnprocessableEntity, "state_reason must be one of completed, not_planned, duplicate, reopened")
367
+				return
368
+			}
369
+		}
370
+		if err := issues.SetState(r.Context(), h.issuesDeps(), auth.UserID, issue.ID, newState, reason); err != nil {
371
+			writeIssuesError(w, err)
372
+			return
373
+		}
374
+	}
375
+
376
+	fresh, err := q.GetIssueByID(r.Context(), h.d.Pool, issue.ID)
377
+	if err != nil {
378
+		writeAPIError(w, http.StatusInternalServerError, "reload failed")
379
+		return
380
+	}
381
+	writeJSON(w, http.StatusOK, presentIssue(fresh, h.labelNamesFor(r.Context(), fresh.ID)))
382
+}
383
+
384
+// ─── comments ───────────────────────────────────────────────────────
385
+
386
+func (h *Handlers) issueCommentsList(w http.ResponseWriter, r *http.Request) {
387
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
388
+	if !ok {
389
+		return
390
+	}
391
+	issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
392
+	if !ok {
393
+		return
394
+	}
395
+	rows, err := issuesdb.New().ListIssueComments(r.Context(), h.d.Pool, issue.ID)
396
+	if err != nil {
397
+		h.d.Logger.ErrorContext(r.Context(), "api: list comments", "error", err)
398
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
399
+		return
400
+	}
401
+	out := make([]commentResponse, 0, len(rows))
402
+	for _, c := range rows {
403
+		out = append(out, presentComment(c))
404
+	}
405
+	writeJSON(w, http.StatusOK, out)
406
+}
407
+
408
+type commentCreateRequest struct {
409
+	Body string `json:"body"`
410
+}
411
+
412
+func (h *Handlers) issueCommentCreate(w http.ResponseWriter, r *http.Request) {
413
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueComment)
414
+	if !ok {
415
+		return
416
+	}
417
+	auth := middleware.PATAuthFromContext(r.Context())
418
+	issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
419
+	if !ok {
420
+		return
421
+	}
422
+	var body commentCreateRequest
423
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
424
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
425
+		return
426
+	}
427
+	isCollab := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
428
+	c, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{
429
+		IssueID:      issue.ID,
430
+		AuthorUserID: auth.UserID,
431
+		Body:         body.Body,
432
+		IsCollab:     isCollab,
433
+	})
434
+	if err != nil {
435
+		writeIssuesError(w, err)
436
+		return
437
+	}
438
+	writeJSON(w, http.StatusCreated, presentComment(c))
439
+}
440
+
441
+type commentUpdateRequest struct {
442
+	Body string `json:"body"`
443
+}
444
+
445
+func (h *Handlers) issueCommentUpdate(w http.ResponseWriter, r *http.Request) {
446
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
447
+	if !ok {
448
+		return
449
+	}
450
+	auth := middleware.PATAuthFromContext(r.Context())
451
+	cid, err := strconv.ParseInt(chi.URLParam(r, "cid"), 10, 64)
452
+	if err != nil {
453
+		writeAPIError(w, http.StatusNotFound, "comment not found")
454
+		return
455
+	}
456
+	q := issuesdb.New()
457
+	comment, err := q.GetIssueComment(r.Context(), h.d.Pool, cid)
458
+	if err != nil {
459
+		writeAPIError(w, http.StatusNotFound, "comment not found")
460
+		return
461
+	}
462
+	// Cross-repo guard: the comment must belong to an issue in this
463
+	// repo. Without this, a caller could /repos/foo/bar/issues/comments/{id}
464
+	// against an unrelated comment id.
465
+	issue, err := q.GetIssueByID(r.Context(), h.d.Pool, comment.IssueID)
466
+	if err != nil || issue.RepoID != repo.ID {
467
+		writeAPIError(w, http.StatusNotFound, "comment not found")
468
+		return
469
+	}
470
+	if !canEditComment(comment, auth.UserID) {
471
+		writeAPIError(w, http.StatusForbidden, "only the author may edit this comment")
472
+		return
473
+	}
474
+	var body commentUpdateRequest
475
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
476
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
477
+		return
478
+	}
479
+	trimmed := strings.TrimSpace(body.Body)
480
+	if trimmed == "" {
481
+		writeAPIError(w, http.StatusUnprocessableEntity, "body is required")
482
+		return
483
+	}
484
+	if len(trimmed) > 65535 {
485
+		writeAPIError(w, http.StatusUnprocessableEntity, "body too long")
486
+		return
487
+	}
488
+	if err := q.UpdateIssueCommentBody(r.Context(), h.d.Pool, issuesdb.UpdateIssueCommentBodyParams{
489
+		ID: comment.ID, Body: trimmed,
490
+		// body_html_cached is cleared; the next render path picks the
491
+		// fresh body up. Matches how the HTML comment editor handles
492
+		// re-renders (lazy regeneration on read).
493
+		BodyHtmlCached: pgtype.Text{},
494
+	}); err != nil {
495
+		h.d.Logger.ErrorContext(r.Context(), "api: update comment", "error", err)
496
+		writeAPIError(w, http.StatusInternalServerError, "update failed")
497
+		return
498
+	}
499
+	fresh, _ := q.GetIssueComment(r.Context(), h.d.Pool, comment.ID)
500
+	writeJSON(w, http.StatusOK, presentComment(fresh))
501
+}
502
+
503
+func (h *Handlers) issueCommentDelete(w http.ResponseWriter, r *http.Request) {
504
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
505
+	if !ok {
506
+		return
507
+	}
508
+	auth := middleware.PATAuthFromContext(r.Context())
509
+	cid, err := strconv.ParseInt(chi.URLParam(r, "cid"), 10, 64)
510
+	if err != nil {
511
+		writeAPIError(w, http.StatusNotFound, "comment not found")
512
+		return
513
+	}
514
+	q := issuesdb.New()
515
+	comment, err := q.GetIssueComment(r.Context(), h.d.Pool, cid)
516
+	if err != nil {
517
+		writeAPIError(w, http.StatusNotFound, "comment not found")
518
+		return
519
+	}
520
+	issue, err := q.GetIssueByID(r.Context(), h.d.Pool, comment.IssueID)
521
+	if err != nil || issue.RepoID != repo.ID {
522
+		writeAPIError(w, http.StatusNotFound, "comment not found")
523
+		return
524
+	}
525
+	// Delete is broader than edit: a repo collaborator with write
526
+	// access can remove any comment (matches GitHub's "moderation"
527
+	// affordance), the comment author can remove their own.
528
+	canDelete := comment.AuthorUserID.Valid && comment.AuthorUserID.Int64 == auth.UserID
529
+	if !canDelete {
530
+		canDelete = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
531
+	}
532
+	if !canDelete {
533
+		writeAPIError(w, http.StatusForbidden, "lack permission to delete this comment")
534
+		return
535
+	}
536
+	if err := q.DeleteIssueComment(r.Context(), h.d.Pool, comment.ID); err != nil {
537
+		h.d.Logger.ErrorContext(r.Context(), "api: delete comment", "error", err)
538
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
539
+		return
540
+	}
541
+	w.WriteHeader(http.StatusNoContent)
542
+}
543
+
544
+func canEditComment(c issuesdb.IssueComment, actorUserID int64) bool {
545
+	if !c.AuthorUserID.Valid {
546
+		return false
547
+	}
548
+	return c.AuthorUserID.Int64 == actorUserID
549
+}
550
+
551
+// ─── lock ───────────────────────────────────────────────────────────
552
+
553
+type issueLockRequest struct {
554
+	Reason string `json:"lock_reason"`
555
+}
556
+
557
+func (h *Handlers) issueLock(w http.ResponseWriter, r *http.Request) {
558
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueClose)
559
+	if !ok {
560
+		return
561
+	}
562
+	auth := middleware.PATAuthFromContext(r.Context())
563
+	issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
564
+	if !ok {
565
+		return
566
+	}
567
+	var body issueLockRequest
568
+	_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
569
+	if err := issues.SetLock(r.Context(), h.issuesDeps(), auth.UserID, issue.ID, true, body.Reason); err != nil {
570
+		h.d.Logger.ErrorContext(r.Context(), "api: lock", "error", err)
571
+		writeAPIError(w, http.StatusInternalServerError, "lock failed")
572
+		return
573
+	}
574
+	w.WriteHeader(http.StatusNoContent)
575
+}
576
+
577
+func (h *Handlers) issueUnlock(w http.ResponseWriter, r *http.Request) {
578
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueClose)
579
+	if !ok {
580
+		return
581
+	}
582
+	auth := middleware.PATAuthFromContext(r.Context())
583
+	issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
584
+	if !ok {
585
+		return
586
+	}
587
+	if err := issues.SetLock(r.Context(), h.issuesDeps(), auth.UserID, issue.ID, false, ""); err != nil {
588
+		h.d.Logger.ErrorContext(r.Context(), "api: unlock", "error", err)
589
+		writeAPIError(w, http.StatusInternalServerError, "unlock failed")
590
+		return
591
+	}
592
+	w.WriteHeader(http.StatusNoContent)
593
+}
594
+
595
+// ─── helpers ────────────────────────────────────────────────────────
596
+
597
+func (h *Handlers) resolveIssueByNumber(w http.ResponseWriter, r *http.Request, repoID int64, numberRaw string) (issuesdb.Issue, bool) {
598
+	num, err := strconv.ParseInt(numberRaw, 10, 64)
599
+	if err != nil {
600
+		writeAPIError(w, http.StatusNotFound, "issue not found")
601
+		return issuesdb.Issue{}, false
602
+	}
603
+	issue, err := issuesdb.New().GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
604
+		RepoID: repoID, Number: num,
605
+	})
606
+	if err != nil || issue.Kind != issuesdb.IssueKindIssue {
607
+		writeAPIError(w, http.StatusNotFound, "issue not found")
608
+		return issuesdb.Issue{}, false
609
+	}
610
+	return issue, true
611
+}
612
+
613
+func (h *Handlers) issuesDeps() issues.Deps {
614
+	return issues.Deps{
615
+		Pool:    h.d.Pool,
616
+		Limiter: h.d.Throttle,
617
+		Logger:  h.d.Logger,
618
+		Audit:   h.d.Audit,
619
+	}
620
+}
621
+
622
+func writeIssuesError(w http.ResponseWriter, err error) {
623
+	switch {
624
+	case errors.Is(err, issues.ErrEmptyTitle),
625
+		errors.Is(err, issues.ErrTitleTooLong),
626
+		errors.Is(err, issues.ErrBodyTooLong),
627
+		errors.Is(err, issues.ErrEmptyComment),
628
+		errors.Is(err, issues.ErrCommentTooLong):
629
+		writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
630
+	case errors.Is(err, issues.ErrCommentRateLimit):
631
+		writeAPIError(w, http.StatusTooManyRequests, "comment rate limit exceeded")
632
+	case errors.Is(err, issues.ErrIssueLocked):
633
+		writeAPIError(w, http.StatusLocked, "issue is locked")
634
+	case errors.Is(err, issues.ErrIssueNotFound):
635
+		writeAPIError(w, http.StatusNotFound, "issue not found")
636
+	default:
637
+		writeAPIError(w, http.StatusInternalServerError, "internal error")
638
+	}
639
+}
internal/web/handlers/api/labels.goadded
@@ -0,0 +1,231 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"encoding/json"
7
+	"errors"
8
+	"net/http"
9
+	"time"
10
+
11
+	"github.com/go-chi/chi/v5"
12
+	"github.com/jackc/pgx/v5"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
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/web/middleware"
19
+)
20
+
21
+// mountLabels registers the S50 §3 repo-label REST surface.
22
+//
23
+//	GET    /api/v1/repos/{o}/{r}/labels           list
24
+//	POST   /api/v1/repos/{o}/{r}/labels           create
25
+//	GET    /api/v1/repos/{o}/{r}/labels/{name}    fetch
26
+//	PATCH  /api/v1/repos/{o}/{r}/labels/{name}    update
27
+//	DELETE /api/v1/repos/{o}/{r}/labels/{name}    delete
28
+//
29
+// Labels are repo-scoped; manage requires ActionIssueLabel (the same
30
+// gate the HTML labels page uses), so a triage-equivalent role is the
31
+// minimum bar. List reads under ActionIssueRead.
32
+func (h *Handlers) mountLabels(r chi.Router) {
33
+	r.Group(func(r chi.Router) {
34
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
35
+		r.Get("/api/v1/repos/{owner}/{repo}/labels", h.labelsList)
36
+		r.Get("/api/v1/repos/{owner}/{repo}/labels/{name}", h.labelGet)
37
+	})
38
+	r.Group(func(r chi.Router) {
39
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
40
+		r.Post("/api/v1/repos/{owner}/{repo}/labels", h.labelCreate)
41
+		r.Patch("/api/v1/repos/{owner}/{repo}/labels/{name}", h.labelUpdate)
42
+		r.Delete("/api/v1/repos/{owner}/{repo}/labels/{name}", h.labelDelete)
43
+	})
44
+}
45
+
46
+type labelResponse struct {
47
+	ID          int64  `json:"id"`
48
+	Name        string `json:"name"`
49
+	Color       string `json:"color"`
50
+	Description string `json:"description,omitempty"`
51
+	CreatedAt   string `json:"created_at"`
52
+}
53
+
54
+func presentLabel(l issuesdb.Label) labelResponse {
55
+	return labelResponse{
56
+		ID:          l.ID,
57
+		Name:        l.Name,
58
+		Color:       l.Color,
59
+		Description: l.Description,
60
+		CreatedAt:   l.CreatedAt.Time.UTC().Format(time.RFC3339),
61
+	}
62
+}
63
+
64
+func (h *Handlers) labelsList(w http.ResponseWriter, r *http.Request) {
65
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
66
+	if !ok {
67
+		return
68
+	}
69
+	rows, err := issuesdb.New().ListLabels(r.Context(), h.d.Pool, repo.ID)
70
+	if err != nil {
71
+		h.d.Logger.ErrorContext(r.Context(), "api: list labels", "error", err)
72
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
73
+		return
74
+	}
75
+	out := make([]labelResponse, 0, len(rows))
76
+	for _, l := range rows {
77
+		out = append(out, presentLabel(l))
78
+	}
79
+	writeJSON(w, http.StatusOK, out)
80
+}
81
+
82
+func (h *Handlers) labelGet(w http.ResponseWriter, r *http.Request) {
83
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
84
+	if !ok {
85
+		return
86
+	}
87
+	row, err := issuesdb.New().GetLabelByName(r.Context(), h.d.Pool, issuesdb.GetLabelByNameParams{
88
+		RepoID: repo.ID,
89
+		Name:   chi.URLParam(r, "name"),
90
+	})
91
+	if err != nil {
92
+		if errors.Is(err, pgx.ErrNoRows) {
93
+			writeAPIError(w, http.StatusNotFound, "label not found")
94
+			return
95
+		}
96
+		h.d.Logger.ErrorContext(r.Context(), "api: get label", "error", err)
97
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
98
+		return
99
+	}
100
+	writeJSON(w, http.StatusOK, presentLabel(row))
101
+}
102
+
103
+type labelCreateRequest struct {
104
+	Name        string `json:"name"`
105
+	Color       string `json:"color"`
106
+	Description string `json:"description"`
107
+}
108
+
109
+func (h *Handlers) labelCreate(w http.ResponseWriter, r *http.Request) {
110
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueLabel)
111
+	if !ok {
112
+		return
113
+	}
114
+	var body labelCreateRequest
115
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
116
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
117
+		return
118
+	}
119
+	created, err := issues.CreateLabel(r.Context(), issues.Deps{
120
+		Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit,
121
+	}, issues.LabelCreateParams{
122
+		RepoID:      repo.ID,
123
+		Name:        body.Name,
124
+		Color:       body.Color,
125
+		Description: body.Description,
126
+	})
127
+	if err != nil {
128
+		writeLabelsError(w, err)
129
+		return
130
+	}
131
+	writeJSON(w, http.StatusCreated, presentLabel(created))
132
+}
133
+
134
+type labelUpdateRequest struct {
135
+	Name        *string `json:"name,omitempty"`
136
+	Color       *string `json:"color,omitempty"`
137
+	Description *string `json:"description,omitempty"`
138
+}
139
+
140
+func (h *Handlers) labelUpdate(w http.ResponseWriter, r *http.Request) {
141
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueLabel)
142
+	if !ok {
143
+		return
144
+	}
145
+	q := issuesdb.New()
146
+	cur, err := q.GetLabelByName(r.Context(), h.d.Pool, issuesdb.GetLabelByNameParams{
147
+		RepoID: repo.ID, Name: chi.URLParam(r, "name"),
148
+	})
149
+	if err != nil {
150
+		if errors.Is(err, pgx.ErrNoRows) {
151
+			writeAPIError(w, http.StatusNotFound, "label not found")
152
+			return
153
+		}
154
+		h.d.Logger.ErrorContext(r.Context(), "api: get label", "error", err)
155
+		writeAPIError(w, http.StatusInternalServerError, "lookup failed")
156
+		return
157
+	}
158
+	var body labelUpdateRequest
159
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
160
+		writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
161
+		return
162
+	}
163
+	name := cur.Name
164
+	if body.Name != nil {
165
+		name = *body.Name
166
+	}
167
+	color := cur.Color
168
+	if body.Color != nil {
169
+		color = *body.Color
170
+	}
171
+	desc := cur.Description
172
+	if body.Description != nil {
173
+		desc = *body.Description
174
+	}
175
+	if err := issues.UpdateLabel(r.Context(), issues.Deps{
176
+		Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit,
177
+	}, issues.LabelUpdateParams{
178
+		ID: cur.ID, Name: name, Color: color, Description: desc,
179
+	}); err != nil {
180
+		writeLabelsError(w, err)
181
+		return
182
+	}
183
+	fresh, _ := q.GetLabelByName(r.Context(), h.d.Pool, issuesdb.GetLabelByNameParams{
184
+		RepoID: repo.ID, Name: name,
185
+	})
186
+	writeJSON(w, http.StatusOK, presentLabel(fresh))
187
+}
188
+
189
+func (h *Handlers) labelDelete(w http.ResponseWriter, r *http.Request) {
190
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueLabel)
191
+	if !ok {
192
+		return
193
+	}
194
+	q := issuesdb.New()
195
+	cur, err := q.GetLabelByName(r.Context(), h.d.Pool, issuesdb.GetLabelByNameParams{
196
+		RepoID: repo.ID, Name: chi.URLParam(r, "name"),
197
+	})
198
+	if err != nil {
199
+		writeAPIError(w, http.StatusNotFound, "label not found")
200
+		return
201
+	}
202
+	if err := issues.DeleteLabel(r.Context(), issues.Deps{
203
+		Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit,
204
+	}, cur.ID); err != nil {
205
+		h.d.Logger.ErrorContext(r.Context(), "api: delete label", "error", err)
206
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
207
+		return
208
+	}
209
+	w.WriteHeader(http.StatusNoContent)
210
+}
211
+
212
+func writeLabelsError(w http.ResponseWriter, err error) {
213
+	switch {
214
+	case errors.Is(err, issues.ErrLabelExists):
215
+		writeAPIError(w, http.StatusConflict, "label name already taken on this repo")
216
+	case errors.Is(err, issues.ErrLabelInvalidColor):
217
+		writeAPIError(w, http.StatusUnprocessableEntity, "color must be 6 hex chars")
218
+	default:
219
+		// CreateLabel returns plain errors for bad-name validation; map
220
+		// generically as 422 since those are user-input failures.
221
+		if err != nil && err.Error() != "" && (containsPrefix(err.Error(), "issues:")) {
222
+			writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
223
+			return
224
+		}
225
+		writeAPIError(w, http.StatusInternalServerError, "internal error")
226
+	}
227
+}
228
+
229
+func containsPrefix(s, prefix string) bool {
230
+	return len(s) >= len(prefix) && s[:len(prefix)] == prefix
231
+}
internal/web/handlers/api/meta.gomodified
@@ -26,6 +26,8 @@ var APICapabilities = []string{
2626
 	"user-emails",
2727
 	"ssh-keys",
2828
 	"repos",
29
+	"issues",
30
+	"labels",
2931
 }
3032
 
3133
 type metaResponse struct {
internal/web/handlers/api/repos.gomodified
@@ -603,9 +603,15 @@ func (h *Handlers) resolveAPIRepoWithLogin(w http.ResponseWriter, r *http.Reques
603603
 }
604604
 
605605
 // actionRequiresAuth returns true for actions that always require a
606
-// logged-in caller (everything except plain read).
606
+// logged-in caller. Read-shaped actions pass through anonymously so
607
+// the visibility gate inside policy.Can does the talking.
607608
 func actionRequiresAuth(a policy.Action) bool {
608
-	return a != policy.ActionRepoRead
609
+	switch a {
610
+	case policy.ActionRepoRead, policy.ActionIssueRead, policy.ActionPullRead:
611
+		return false
612
+	default:
613
+		return true
614
+	}
609615
 }
610616
 
611617
 // lookupRepoByLogin tries the user-owner path first, then the org-owner