tenseleyflow/shithub / e32287a

Browse files

S21: issues, labels, milestones routes + handlers (auth-gated writes)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e32287a6974cc7ae30565f3a053c67c3d89abdbe
Parents
b4aa714
Tree
165be3d

5 changed files

StatusFile+-
M internal/web/handlers/handlers.go 7 0
A internal/web/handlers/repo/issues.go 487 0
A internal/web/handlers/repo/labels_milestones.go 240 0
M internal/web/handlers/repo/repo.go 3 1
M internal/web/server.go 8 0
internal/web/handlers/handlers.gomodified
@@ -71,6 +71,10 @@ type Deps struct {
7171
 	// RepoSettingsBranchesMounter registers /settings/branches +
7272
 	// /settings/default-branch (S20). Auth-required.
7373
 	RepoSettingsBranchesMounter func(chi.Router)
74
+	// RepoIssuesMounter registers /{owner}/{repo}/issues, /labels, and
75
+	// /milestones routes (S21). Reads are public (per-repo policy gate);
76
+	// writes are auth-required.
77
+	RepoIssuesMounter func(chi.Router)
7478
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
7579
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
7680
 	// land in a route group that bypasses CSRF, response compression,
@@ -189,6 +193,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
189193
 		if deps.RepoSettingsBranchesMounter != nil {
190194
 			deps.RepoSettingsBranchesMounter(r)
191195
 		}
196
+		if deps.RepoIssuesMounter != nil {
197
+			deps.RepoIssuesMounter(r)
198
+		}
192199
 		if deps.RepoHomeMounter != nil {
193200
 			deps.RepoHomeMounter(r)
194201
 		}
internal/web/handlers/repo/issues.goadded
@@ -0,0 +1,487 @@
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/auth/throttle"
17
+	"github.com/tenseleyFlow/shithub/internal/issues"
18
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
19
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
20
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
21
+)
22
+
23
+// MountIssues registers the issues + labels + milestones routes under
24
+// /{owner}/{repo}. Read paths are public (subject to policy.Can which
25
+// gates private repos); write paths run through RequireUser so an
26
+// anonymous browser hits the login redirect rather than the 404
27
+// existence-leak path.
28
+func (h *Handlers) MountIssues(r chi.Router) {
29
+	// Public reads.
30
+	r.Get("/{owner}/{repo}/issues", h.issuesList)
31
+	r.Get("/{owner}/{repo}/issues/{number}", h.issueView)
32
+	r.Get("/{owner}/{repo}/labels", h.labelsList)
33
+	r.Get("/{owner}/{repo}/milestones", h.milestonesList)
34
+
35
+	// Auth-required: form GETs + state-changing POSTs. policy.Can
36
+	// inside the handler still applies (e.g. archived repos block
37
+	// writes even for the owner), but RequireUser handles the simpler
38
+	// "you need to be logged in" case with a /login redirect.
39
+	r.Group(func(r chi.Router) {
40
+		r.Use(middleware.RequireUser)
41
+		r.Get("/{owner}/{repo}/issues/new", h.issueNewForm)
42
+		r.Post("/{owner}/{repo}/issues", h.issueCreate)
43
+		r.Post("/{owner}/{repo}/issues/{number}/comments", h.issueComment)
44
+		r.Post("/{owner}/{repo}/issues/{number}/state", h.issueSetState)
45
+		r.Post("/{owner}/{repo}/issues/{number}/lock", h.issueSetLock)
46
+		r.Post("/{owner}/{repo}/issues/{number}/labels", h.issueApplyLabels)
47
+		r.Post("/{owner}/{repo}/issues/{number}/milestone", h.issueAssignMilestone)
48
+		r.Post("/{owner}/{repo}/issues/{number}/assignees", h.issueToggleAssignee)
49
+
50
+		r.Post("/{owner}/{repo}/labels", h.labelCreate)
51
+		r.Post("/{owner}/{repo}/labels/{id}/update", h.labelUpdate)
52
+		r.Post("/{owner}/{repo}/labels/{id}/delete", h.labelDelete)
53
+
54
+		r.Post("/{owner}/{repo}/milestones", h.milestoneCreate)
55
+		r.Post("/{owner}/{repo}/milestones/{id}/update", h.milestoneUpdate)
56
+		r.Post("/{owner}/{repo}/milestones/{id}/state", h.milestoneSetState)
57
+		r.Post("/{owner}/{repo}/milestones/{id}/delete", h.milestoneDelete)
58
+	})
59
+}
60
+
61
+// issuesDeps materializes an issues.Deps from the handler-set deps.
62
+// Limiter is the same per-process instance used by repo create — the
63
+// orchestrator scopes by Identifier so namespaces don't collide.
64
+func (h *Handlers) issuesDeps() issues.Deps {
65
+	return issues.Deps{
66
+		Pool:    h.d.Pool,
67
+		Limiter: h.d.Limiter,
68
+		Logger:  h.d.Logger,
69
+	}
70
+}
71
+
72
+// issuesList renders /{owner}/{repo}/issues with optional ?state filter.
73
+func (h *Handlers) issuesList(w http.ResponseWriter, r *http.Request) {
74
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueRead)
75
+	if !ok {
76
+		return
77
+	}
78
+	stateFilter := r.URL.Query().Get("state")
79
+	if stateFilter == "" {
80
+		stateFilter = "open"
81
+	}
82
+	stateNarg := pgtype.Text{}
83
+	if stateFilter == "open" || stateFilter == "closed" {
84
+		stateNarg = pgtype.Text{String: stateFilter, Valid: true}
85
+	}
86
+	page, _ := strconv.Atoi(r.URL.Query().Get("page"))
87
+	if page < 1 {
88
+		page = 1
89
+	}
90
+	const perPage = 25
91
+	rows, err := h.iq.ListIssues(r.Context(), h.d.Pool, issuesdb.ListIssuesParams{
92
+		RepoID:      row.ID,
93
+		StateFilter: stateNarg,
94
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
95
+		Limit:       perPage,
96
+		Offset:      int32((page - 1) * perPage),
97
+	})
98
+	if err != nil {
99
+		h.d.Logger.WarnContext(r.Context(), "issues: list", "error", err)
100
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
101
+		return
102
+	}
103
+	total, _ := h.iq.CountIssues(r.Context(), h.d.Pool, issuesdb.CountIssuesParams{
104
+		RepoID:      row.ID,
105
+		StateFilter: stateNarg,
106
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
107
+	})
108
+	openCount, _ := h.iq.CountIssues(r.Context(), h.d.Pool, issuesdb.CountIssuesParams{
109
+		RepoID:      row.ID,
110
+		StateFilter: pgtype.Text{String: "open", Valid: true},
111
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
112
+	})
113
+	closedCount, _ := h.iq.CountIssues(r.Context(), h.d.Pool, issuesdb.CountIssuesParams{
114
+		RepoID:      row.ID,
115
+		StateFilter: pgtype.Text{String: "closed", Valid: true},
116
+		Kind:        issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
117
+	})
118
+
119
+	// Decorate with author username + label set + assignee count for the
120
+	// list. Cheap N+1 for v1; S36 will batch this.
121
+	type listItem struct {
122
+		Issue        issuesdb.Issue
123
+		AuthorName   string
124
+		Labels       []issuesdb.Label
125
+		Assignees    []issuesdb.ListIssueAssigneesRow
126
+		CommentCount int64
127
+	}
128
+	items := make([]listItem, 0, len(rows))
129
+	for _, ir := range rows {
130
+		it := listItem{Issue: ir}
131
+		if ir.AuthorUserID.Valid {
132
+			if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, ir.AuthorUserID.Int64); err == nil {
133
+				it.AuthorName = u.Username
134
+			}
135
+		}
136
+		it.Labels, _ = h.iq.ListLabelsOnIssue(r.Context(), h.d.Pool, ir.ID)
137
+		it.Assignees, _ = h.iq.ListIssueAssignees(r.Context(), h.d.Pool, ir.ID)
138
+		items = append(items, it)
139
+	}
140
+
141
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
142
+	if err := h.d.Render.RenderPage(w, r, "repo/issues_list", map[string]any{
143
+		"Title":       "Issues · " + row.Name,
144
+		"Owner":       owner.Username,
145
+		"Repo":        row,
146
+		"Items":       items,
147
+		"State":       stateFilter,
148
+		"OpenCount":   openCount,
149
+		"ClosedCount": closedCount,
150
+		"Total":       total,
151
+		"Page":        page,
152
+		"PerPage":     perPage,
153
+		"CSRFToken":   middleware.CSRFTokenForRequest(r),
154
+	}); err != nil {
155
+		h.d.Logger.ErrorContext(r.Context(), "issues: render list", "error", err)
156
+	}
157
+}
158
+
159
+// issueNewForm renders the new-issue form.
160
+func (h *Handlers) issueNewForm(w http.ResponseWriter, r *http.Request) {
161
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueCreate)
162
+	if !ok {
163
+		return
164
+	}
165
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
166
+	_ = h.d.Render.RenderPage(w, r, "repo/issue_new", map[string]any{
167
+		"Title":     "New issue · " + row.Name,
168
+		"Owner":     owner.Username,
169
+		"Repo":      row,
170
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
171
+	})
172
+}
173
+
174
+// issueCreate handles the new-issue POST.
175
+func (h *Handlers) issueCreate(w http.ResponseWriter, r *http.Request) {
176
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueCreate)
177
+	if !ok {
178
+		return
179
+	}
180
+	if err := r.ParseForm(); err != nil {
181
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
182
+		return
183
+	}
184
+	viewer := middleware.CurrentUserFromContext(r.Context())
185
+	title := strings.TrimSpace(r.PostFormValue("title"))
186
+	body := r.PostFormValue("body")
187
+	created, err := issues.Create(r.Context(), h.issuesDeps(), issues.CreateParams{
188
+		RepoID:       row.ID,
189
+		AuthorUserID: viewer.ID,
190
+		Title:        title,
191
+		Body:         body,
192
+		Kind:         "issue",
193
+	})
194
+	if err != nil {
195
+		h.renderIssueCreateError(w, r, owner.Username, row, title, body, err)
196
+		return
197
+	}
198
+	http.Redirect(w, r,
199
+		"/"+owner.Username+"/"+row.Name+"/issues/"+strconv.FormatInt(created.Number, 10),
200
+		http.StatusSeeOther,
201
+	)
202
+}
203
+
204
+func (h *Handlers) renderIssueCreateError(w http.ResponseWriter, r *http.Request, owner string, row reposdb.Repo, title, body string, err error) {
205
+	msg := "Could not create the issue. Try again."
206
+	switch {
207
+	case errors.Is(err, issues.ErrEmptyTitle):
208
+		msg = "Title is required."
209
+	case errors.Is(err, issues.ErrTitleTooLong):
210
+		msg = "Title is too long (max 256)."
211
+	case errors.Is(err, issues.ErrBodyTooLong):
212
+		msg = "Body is too long."
213
+	}
214
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
215
+	w.WriteHeader(http.StatusBadRequest)
216
+	_ = h.d.Render.RenderPage(w, r, "repo/issue_new", map[string]any{
217
+		"Title":     "New issue · " + row.Name,
218
+		"Owner":     owner,
219
+		"Repo":      row,
220
+		"FormTitle": title,
221
+		"FormBody":  body,
222
+		"Error":     msg,
223
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
224
+	})
225
+}
226
+
227
+// issueView renders /{owner}/{repo}/issues/{number} with the timeline.
228
+func (h *Handlers) issueView(w http.ResponseWriter, r *http.Request) {
229
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueRead)
230
+	if !ok {
231
+		return
232
+	}
233
+	num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
234
+	if err != nil {
235
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
236
+		return
237
+	}
238
+	issue, err := h.iq.GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
239
+		RepoID: row.ID, Number: num,
240
+	})
241
+	if err != nil {
242
+		if errors.Is(err, pgx.ErrNoRows) {
243
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
244
+			return
245
+		}
246
+		h.d.Logger.WarnContext(r.Context(), "issues: get", "error", err)
247
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
248
+		return
249
+	}
250
+
251
+	comments, _ := h.iq.ListIssueComments(r.Context(), h.d.Pool, issue.ID)
252
+	events, _ := h.iq.ListIssueEvents(r.Context(), h.d.Pool, issue.ID)
253
+	labels, _ := h.iq.ListLabelsOnIssue(r.Context(), h.d.Pool, issue.ID)
254
+	assignees, _ := h.iq.ListIssueAssignees(r.Context(), h.d.Pool, issue.ID)
255
+	allLabels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
256
+	milestones, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
257
+
258
+	// Resolve usernames for comment authors.
259
+	type commentRow struct {
260
+		C          issuesdb.IssueComment
261
+		AuthorName string
262
+	}
263
+	cs := make([]commentRow, 0, len(comments))
264
+	for _, c := range comments {
265
+		cr := commentRow{C: c}
266
+		if c.AuthorUserID.Valid {
267
+			if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, c.AuthorUserID.Int64); err == nil {
268
+				cr.AuthorName = u.Username
269
+			}
270
+		}
271
+		cs = append(cs, cr)
272
+	}
273
+	// Author username on the issue itself.
274
+	authorName := ""
275
+	if issue.AuthorUserID.Valid {
276
+		if u, err := h.uq.GetUserByID(r.Context(), h.d.Pool, issue.AuthorUserID.Int64); err == nil {
277
+			authorName = u.Username
278
+		}
279
+	}
280
+
281
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
282
+	_ = h.d.Render.RenderPage(w, r, "repo/issue_view", map[string]any{
283
+		"Title":      issue.Title + " · " + row.Name,
284
+		"Owner":      owner.Username,
285
+		"Repo":       row,
286
+		"Issue":      issue,
287
+		"AuthorName": authorName,
288
+		"Comments":   cs,
289
+		"Events":     events,
290
+		"Labels":     labels,
291
+		"Assignees":  assignees,
292
+		"AllLabels":  allLabels,
293
+		"Milestones": milestones,
294
+		"CSRFToken":  middleware.CSRFTokenForRequest(r),
295
+	})
296
+}
297
+
298
+func (h *Handlers) loadIssueByNumber(w http.ResponseWriter, r *http.Request, repo reposdb.Repo) (issuesdb.Issue, bool) {
299
+	num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
300
+	if err != nil {
301
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
302
+		return issuesdb.Issue{}, false
303
+	}
304
+	issue, err := h.iq.GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
305
+		RepoID: repo.ID, Number: num,
306
+	})
307
+	if err != nil {
308
+		if errors.Is(err, pgx.ErrNoRows) {
309
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
310
+		} else {
311
+			h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
312
+		}
313
+		return issuesdb.Issue{}, false
314
+	}
315
+	return issue, true
316
+}
317
+
318
+// issueComment handles POST .../comments
319
+func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) {
320
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueComment)
321
+	if !ok {
322
+		return
323
+	}
324
+	if err := r.ParseForm(); err != nil {
325
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
326
+		return
327
+	}
328
+	issue, ok := h.loadIssueByNumber(w, r, row)
329
+	if !ok {
330
+		return
331
+	}
332
+	viewer := middleware.CurrentUserFromContext(r.Context())
333
+	body := r.PostFormValue("body")
334
+
335
+	// IsCollab — for v1 only the repo owner counts as collaborator
336
+	// (S15 attaches collaborators table; lookup deferred for the
337
+	// locked-issue gate). Owners always pass.
338
+	isCollab := row.OwnerUserID.Valid && row.OwnerUserID.Int64 == viewer.ID
339
+
340
+	_, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{
341
+		IssueID:      issue.ID,
342
+		AuthorUserID: viewer.ID,
343
+		Body:         body,
344
+		IsCollab:     isCollab,
345
+	})
346
+	if err != nil {
347
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
348
+		return
349
+	}
350
+	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
351
+}
352
+
353
+func (h *Handlers) issueSetState(w http.ResponseWriter, r *http.Request) {
354
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueClose)
355
+	if !ok {
356
+		return
357
+	}
358
+	issue, ok := h.loadIssueByNumber(w, r, row)
359
+	if !ok {
360
+		return
361
+	}
362
+	state := strings.TrimSpace(r.PostFormValue("state"))
363
+	reason := strings.TrimSpace(r.PostFormValue("reason"))
364
+	viewer := middleware.CurrentUserFromContext(r.Context())
365
+	if err := issues.SetState(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, state, reason); err != nil {
366
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
367
+		return
368
+	}
369
+	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
370
+}
371
+
372
+func (h *Handlers) issueSetLock(w http.ResponseWriter, r *http.Request) {
373
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueClose)
374
+	if !ok {
375
+		return
376
+	}
377
+	issue, ok := h.loadIssueByNumber(w, r, row)
378
+	if !ok {
379
+		return
380
+	}
381
+	locked := r.PostFormValue("lock") == "true"
382
+	reason := strings.TrimSpace(r.PostFormValue("reason"))
383
+	viewer := middleware.CurrentUserFromContext(r.Context())
384
+	if err := issues.SetLock(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, locked, reason); err != nil {
385
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
386
+		return
387
+	}
388
+	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
389
+}
390
+
391
+func (h *Handlers) issueApplyLabels(w http.ResponseWriter, r *http.Request) {
392
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
393
+	if !ok {
394
+		return
395
+	}
396
+	issue, ok := h.loadIssueByNumber(w, r, row)
397
+	if !ok {
398
+		return
399
+	}
400
+	if err := r.ParseForm(); err != nil {
401
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
402
+		return
403
+	}
404
+	raw := r.PostForm["label_ids"]
405
+	ids := make([]int64, 0, len(raw))
406
+	for _, s := range raw {
407
+		if id, err := strconv.ParseInt(s, 10, 64); err == nil {
408
+			ids = append(ids, id)
409
+		}
410
+	}
411
+	viewer := middleware.CurrentUserFromContext(r.Context())
412
+	if err := issues.ApplyLabels(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, ids); err != nil {
413
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
414
+		return
415
+	}
416
+	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
417
+}
418
+
419
+func (h *Handlers) issueAssignMilestone(w http.ResponseWriter, r *http.Request) {
420
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
421
+	if !ok {
422
+		return
423
+	}
424
+	issue, ok := h.loadIssueByNumber(w, r, row)
425
+	if !ok {
426
+		return
427
+	}
428
+	mid, _ := strconv.ParseInt(strings.TrimSpace(r.PostFormValue("milestone_id")), 10, 64)
429
+	viewer := middleware.CurrentUserFromContext(r.Context())
430
+	if err := issues.AssignMilestone(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, mid); err != nil {
431
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
432
+		return
433
+	}
434
+	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
435
+}
436
+
437
+func (h *Handlers) issueToggleAssignee(w http.ResponseWriter, r *http.Request) {
438
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueAssign)
439
+	if !ok {
440
+		return
441
+	}
442
+	issue, ok := h.loadIssueByNumber(w, r, row)
443
+	if !ok {
444
+		return
445
+	}
446
+	target := strings.TrimSpace(r.PostFormValue("username"))
447
+	mode := r.PostFormValue("mode") // "add" | "remove"
448
+	tu, err := h.uq.GetUserByUsername(r.Context(), h.d.Pool, target)
449
+	if err != nil {
450
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, errors.New("user not found"))
451
+		return
452
+	}
453
+	viewer := middleware.CurrentUserFromContext(r.Context())
454
+	if mode == "remove" {
455
+		err = issues.UnassignUser(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, tu.ID)
456
+	} else {
457
+		err = issues.AssignUser(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, tu.ID)
458
+	}
459
+	if err != nil {
460
+		h.handleIssueWriteError(w, r, owner.Username, row, issue, err)
461
+		return
462
+	}
463
+	h.redirectIssue(w, r, owner.Username, row.Name, issue.Number)
464
+}
465
+
466
+func (h *Handlers) redirectIssue(w http.ResponseWriter, r *http.Request, owner, repo string, number int64) {
467
+	http.Redirect(w, r, "/"+owner+"/"+repo+"/issues/"+strconv.FormatInt(number, 10), http.StatusSeeOther)
468
+}
469
+
470
+func (h *Handlers) handleIssueWriteError(w http.ResponseWriter, r *http.Request, _ string, _ reposdb.Repo, _ issuesdb.Issue, err error) {
471
+	switch {
472
+	case errors.Is(err, issues.ErrIssueLocked):
473
+		h.d.Render.HTTPError(w, r, http.StatusLocked, "issue is locked")
474
+	case errors.Is(err, issues.ErrCommentRateLimit):
475
+		h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
476
+	case errors.Is(err, issues.ErrCommentTooLong):
477
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "comment too long")
478
+	default:
479
+		var t *throttle.ErrThrottled
480
+		if errors.As(err, &t) {
481
+			h.d.Render.HTTPError(w, r, http.StatusTooManyRequests, "rate limit")
482
+			return
483
+		}
484
+		h.d.Logger.WarnContext(r.Context(), "issues: write", "error", err)
485
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
486
+	}
487
+}
internal/web/handlers/repo/labels_milestones.goadded
@@ -0,0 +1,240 @@
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
+	"time"
11
+
12
+	"github.com/go-chi/chi/v5"
13
+
14
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
15
+	"github.com/tenseleyFlow/shithub/internal/issues"
16
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
17
+)
18
+
19
+// ─── labels ──────────────────────────────────────────────────────────
20
+
21
+func (h *Handlers) labelsList(w http.ResponseWriter, r *http.Request) {
22
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
23
+	if !ok {
24
+		return
25
+	}
26
+	labels, _ := h.iq.ListLabels(r.Context(), h.d.Pool, row.ID)
27
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
28
+	_ = h.d.Render.RenderPage(w, r, "repo/labels", map[string]any{
29
+		"Title":     "Labels · " + row.Name,
30
+		"Owner":     owner.Username,
31
+		"Repo":      row,
32
+		"Labels":    labels,
33
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
34
+	})
35
+}
36
+
37
+func (h *Handlers) labelCreate(w http.ResponseWriter, r *http.Request) {
38
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
39
+	if !ok {
40
+		return
41
+	}
42
+	if err := r.ParseForm(); err != nil {
43
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
44
+		return
45
+	}
46
+	_, err := issues.CreateLabel(r.Context(), h.issuesDeps(), issues.LabelCreateParams{
47
+		RepoID:      row.ID,
48
+		Name:        r.PostFormValue("name"),
49
+		Color:       r.PostFormValue("color"),
50
+		Description: r.PostFormValue("description"),
51
+	})
52
+	if err != nil {
53
+		h.handleLabelError(w, r, err)
54
+		return
55
+	}
56
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/labels", http.StatusSeeOther)
57
+}
58
+
59
+func (h *Handlers) labelUpdate(w http.ResponseWriter, r *http.Request) {
60
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
61
+	if !ok {
62
+		return
63
+	}
64
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
65
+	if err != nil {
66
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
67
+		return
68
+	}
69
+	if err := r.ParseForm(); err != nil {
70
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
71
+		return
72
+	}
73
+	if err := issues.UpdateLabel(r.Context(), h.issuesDeps(), issues.LabelUpdateParams{
74
+		ID:          id,
75
+		Name:        r.PostFormValue("name"),
76
+		Color:       r.PostFormValue("color"),
77
+		Description: r.PostFormValue("description"),
78
+	}); err != nil {
79
+		h.handleLabelError(w, r, err)
80
+		return
81
+	}
82
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/labels", http.StatusSeeOther)
83
+}
84
+
85
+func (h *Handlers) labelDelete(w http.ResponseWriter, r *http.Request) {
86
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
87
+	if !ok {
88
+		return
89
+	}
90
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
91
+	if err != nil {
92
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
93
+		return
94
+	}
95
+	if err := issues.DeleteLabel(r.Context(), h.issuesDeps(), id); err != nil {
96
+		h.handleLabelError(w, r, err)
97
+		return
98
+	}
99
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/labels", http.StatusSeeOther)
100
+}
101
+
102
+func (h *Handlers) handleLabelError(w http.ResponseWriter, r *http.Request, err error) {
103
+	switch {
104
+	case errors.Is(err, issues.ErrLabelExists):
105
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "label name already taken")
106
+	case errors.Is(err, issues.ErrLabelInvalidColor):
107
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "label color must be 6 hex chars")
108
+	default:
109
+		h.d.Logger.WarnContext(r.Context(), "labels: write", "error", err)
110
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
111
+	}
112
+}
113
+
114
+// ─── milestones ──────────────────────────────────────────────────────
115
+
116
+func (h *Handlers) milestonesList(w http.ResponseWriter, r *http.Request) {
117
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
118
+	if !ok {
119
+		return
120
+	}
121
+	ms, _ := h.iq.ListMilestones(r.Context(), h.d.Pool, row.ID)
122
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
123
+	_ = h.d.Render.RenderPage(w, r, "repo/milestones", map[string]any{
124
+		"Title":      "Milestones · " + row.Name,
125
+		"Owner":      owner.Username,
126
+		"Repo":       row,
127
+		"Milestones": ms,
128
+		"CSRFToken":  middleware.CSRFTokenForRequest(r),
129
+	})
130
+}
131
+
132
+func (h *Handlers) milestoneCreate(w http.ResponseWriter, r *http.Request) {
133
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
134
+	if !ok {
135
+		return
136
+	}
137
+	if err := r.ParseForm(); err != nil {
138
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
139
+		return
140
+	}
141
+	due := parseDueOn(r.PostFormValue("due_on"))
142
+	_, err := issues.CreateMilestone(r.Context(), h.issuesDeps(), issues.MilestoneCreateParams{
143
+		RepoID:      row.ID,
144
+		Title:       r.PostFormValue("title"),
145
+		Description: r.PostFormValue("description"),
146
+		DueOn:       due,
147
+	})
148
+	if err != nil {
149
+		h.handleMilestoneError(w, r, err)
150
+		return
151
+	}
152
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
153
+}
154
+
155
+func (h *Handlers) milestoneUpdate(w http.ResponseWriter, r *http.Request) {
156
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
157
+	if !ok {
158
+		return
159
+	}
160
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
161
+	if err != nil {
162
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
163
+		return
164
+	}
165
+	if err := r.ParseForm(); err != nil {
166
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "form parse")
167
+		return
168
+	}
169
+	due := parseDueOn(r.PostFormValue("due_on"))
170
+	if err := issues.UpdateMilestone(r.Context(), h.issuesDeps(), issues.MilestoneUpdateParams{
171
+		ID:          id,
172
+		Title:       r.PostFormValue("title"),
173
+		Description: r.PostFormValue("description"),
174
+		DueOn:       due,
175
+	}); err != nil {
176
+		h.handleMilestoneError(w, r, err)
177
+		return
178
+	}
179
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
180
+}
181
+
182
+func (h *Handlers) milestoneSetState(w http.ResponseWriter, r *http.Request) {
183
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
184
+	if !ok {
185
+		return
186
+	}
187
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
188
+	if err != nil {
189
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
190
+		return
191
+	}
192
+	state := strings.TrimSpace(r.PostFormValue("state"))
193
+	if err := issues.SetMilestoneState(r.Context(), h.issuesDeps(), id, state); err != nil {
194
+		h.handleMilestoneError(w, r, err)
195
+		return
196
+	}
197
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
198
+}
199
+
200
+func (h *Handlers) milestoneDelete(w http.ResponseWriter, r *http.Request) {
201
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueLabel)
202
+	if !ok {
203
+		return
204
+	}
205
+	id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
206
+	if err != nil {
207
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
208
+		return
209
+	}
210
+	if err := issues.DeleteMilestone(r.Context(), h.issuesDeps(), id); err != nil {
211
+		h.handleMilestoneError(w, r, err)
212
+		return
213
+	}
214
+	http.Redirect(w, r, "/"+owner.Username+"/"+row.Name+"/milestones", http.StatusSeeOther)
215
+}
216
+
217
+func (h *Handlers) handleMilestoneError(w http.ResponseWriter, r *http.Request, err error) {
218
+	switch {
219
+	case errors.Is(err, issues.ErrMilestoneExists):
220
+		h.d.Render.HTTPError(w, r, http.StatusConflict, "milestone title already taken")
221
+	default:
222
+		h.d.Logger.WarnContext(r.Context(), "milestones: write", "error", err)
223
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
224
+	}
225
+}
226
+
227
+// parseDueOn accepts a yyyy-mm-dd input from the form (HTML date input
228
+// shape). Empty string clears the due date. Anything unparseable is
229
+// treated as cleared so a malformed date doesn't 400 the form.
230
+func parseDueOn(s string) *time.Time {
231
+	s = strings.TrimSpace(s)
232
+	if s == "" {
233
+		return nil
234
+	}
235
+	t, err := time.Parse("2006-01-02", s)
236
+	if err != nil {
237
+		return nil
238
+	}
239
+	return &t
240
+}
internal/web/handlers/repo/repo.gomodified
@@ -21,6 +21,7 @@ import (
2121
 	"github.com/tenseleyFlow/shithub/internal/auth/policy"
2222
 	"github.com/tenseleyFlow/shithub/internal/auth/throttle"
2323
 	"github.com/tenseleyFlow/shithub/internal/infra/storage"
24
+	issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
2425
 	"github.com/tenseleyFlow/shithub/internal/repos"
2526
 	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
2627
 	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
@@ -59,6 +60,7 @@ type Handlers struct {
5960
 	d  Deps
6061
 	rq *reposdb.Queries
6162
 	uq *usersdb.Queries
63
+	iq *issuesdb.Queries
6264
 }
6365
 
6466
 // New constructs the handler set, validating Deps.
@@ -78,7 +80,7 @@ func New(d Deps) (*Handlers, error) {
7880
 	if d.Limiter == nil {
7981
 		d.Limiter = throttle.NewLimiter()
8082
 	}
81
-	return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New()}, nil
83
+	return &Handlers{d: d, rq: reposdb.New(), uq: usersdb.New(), iq: issuesdb.New()}, nil
8284
 }
8385
 
8486
 // MountNew registers /new (auth-required). Caller wraps with
internal/web/server.gomodified
@@ -195,6 +195,14 @@ func Run(ctx context.Context, opts Options) error {
195195
 				repoH.MountSettingsBranches(r)
196196
 			})
197197
 		}
198
+		// Issues GETs are public (subject to policy.Can), POSTs require
199
+		// auth. The handler enforces auth + policy per request, so we
200
+		// register the whole surface in the public group; an unauth
201
+		// POST hits the policy gate and 404s out of the existence-leak
202
+		// path. Browser flows still need RequireUser to redirect-to-login,
203
+		// so the POST routes get wrapped through the same group with
204
+		// RequireUser inserted only for state-mutating verbs.
205
+		deps.RepoIssuesMounter = repoH.MountIssues
198206
 		// Lifecycle danger-zone routes — also auth-required.
199207
 		deps.RepoLifecycleMounter = func(r chi.Router) {
200208
 			r.Group(func(r chi.Router) {