Go · 22605 bytes Raw Blame History
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 }
640