Go · 22126 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package api
4
5 import (
6 "context"
7 "encoding/json"
8 "errors"
9 "net/http"
10 "strconv"
11 "strings"
12 "time"
13
14 "github.com/go-chi/chi/v5"
15 "github.com/jackc/pgx/v5"
16 "github.com/jackc/pgx/v5/pgtype"
17
18 "github.com/tenseleyFlow/shithub/internal/auth/pat"
19 "github.com/tenseleyFlow/shithub/internal/auth/policy"
20 "github.com/tenseleyFlow/shithub/internal/issues"
21 issuesdb "github.com/tenseleyFlow/shithub/internal/issues/sqlc"
22 "github.com/tenseleyFlow/shithub/internal/web/handlers/api/apipage"
23 "github.com/tenseleyFlow/shithub/internal/web/middleware"
24 )
25
26 // mountIssues registers the S50 §3 issue REST surface.
27 //
28 // GET /api/v1/repos/{o}/{r}/issues list
29 // POST /api/v1/repos/{o}/{r}/issues create
30 // GET /api/v1/repos/{o}/{r}/issues/{number} get
31 // PATCH /api/v1/repos/{o}/{r}/issues/{number} update (title, body, state, state_reason)
32 // GET /api/v1/repos/{o}/{r}/issues/{number}/comments list comments
33 // POST /api/v1/repos/{o}/{r}/issues/{number}/comments add comment
34 // PATCH /api/v1/repos/{o}/{r}/issues/comments/{id} edit comment
35 // DELETE /api/v1/repos/{o}/{r}/issues/comments/{id} delete comment
36 // PUT /api/v1/repos/{o}/{r}/issues/{number}/lock lock
37 // DELETE /api/v1/repos/{o}/{r}/issues/{number}/lock unlock
38 //
39 // PAT scopes: repo:read on GETs, repo:write on mutations. Policy gates
40 // (ActionIssueRead/Create/Close/etc.) layer on top of the scope check;
41 // existence-leak-safe 404 on visibility miss.
42 func (h *Handlers) mountIssues(r chi.Router) {
43 r.Group(func(r chi.Router) {
44 r.Use(middleware.RequireScope(pat.ScopeRepoRead))
45 r.Get("/api/v1/repos/{owner}/{repo}/issues", h.issuesList)
46 r.Get("/api/v1/repos/{owner}/{repo}/issues/{number}", h.issueGet)
47 r.Get("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", h.issueCommentsList)
48 })
49 r.Group(func(r chi.Router) {
50 r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
51 r.Post("/api/v1/repos/{owner}/{repo}/issues", h.issueCreate)
52 r.Patch("/api/v1/repos/{owner}/{repo}/issues/{number}", h.issuePatch)
53 r.Post("/api/v1/repos/{owner}/{repo}/issues/{number}/comments", h.issueCommentCreate)
54 r.Patch("/api/v1/repos/{owner}/{repo}/issues/comments/{cid}", h.issueCommentUpdate)
55 r.Delete("/api/v1/repos/{owner}/{repo}/issues/comments/{cid}", h.issueCommentDelete)
56 r.Put("/api/v1/repos/{owner}/{repo}/issues/{number}/lock", h.issueLock)
57 r.Delete("/api/v1/repos/{owner}/{repo}/issues/{number}/lock", h.issueUnlock)
58 })
59 }
60
61 // ─── presentation ───────────────────────────────────────────────────
62
63 type issueResponse struct {
64 ID int64 `json:"id"`
65 Number int64 `json:"number"`
66 Title string `json:"title"`
67 Body string `json:"body"`
68 State string `json:"state"`
69 StateReason string `json:"state_reason,omitempty"`
70 Locked bool `json:"locked"`
71 LockReason string `json:"lock_reason,omitempty"`
72 AuthorID int64 `json:"author_id,omitempty"`
73 Labels []string `json:"labels,omitempty"`
74 CreatedAt string `json:"created_at"`
75 UpdatedAt string `json:"updated_at"`
76 ClosedAt string `json:"closed_at,omitempty"`
77 }
78
79 func presentIssue(i issuesdb.Issue, labels []string) issueResponse {
80 out := issueResponse{
81 ID: i.ID,
82 Number: i.Number,
83 Title: i.Title,
84 Body: i.Body,
85 State: string(i.State),
86 Locked: i.Locked,
87 Labels: labels,
88 CreatedAt: i.CreatedAt.Time.UTC().Format(time.RFC3339),
89 UpdatedAt: i.UpdatedAt.Time.UTC().Format(time.RFC3339),
90 }
91 if i.StateReason.Valid {
92 out.StateReason = string(i.StateReason.IssueStateReason)
93 }
94 if i.LockReason.Valid {
95 out.LockReason = i.LockReason.String
96 }
97 if i.AuthorUserID.Valid {
98 out.AuthorID = i.AuthorUserID.Int64
99 }
100 if i.ClosedAt.Valid {
101 out.ClosedAt = i.ClosedAt.Time.UTC().Format(time.RFC3339)
102 }
103 return out
104 }
105
106 type commentResponse struct {
107 ID int64 `json:"id"`
108 IssueID int64 `json:"issue_id"`
109 AuthorID int64 `json:"author_id,omitempty"`
110 Body string `json:"body"`
111 CreatedAt string `json:"created_at"`
112 UpdatedAt string `json:"updated_at"`
113 EditedAt string `json:"edited_at,omitempty"`
114 }
115
116 func presentComment(c issuesdb.IssueComment) commentResponse {
117 out := commentResponse{
118 ID: c.ID,
119 IssueID: c.IssueID,
120 Body: c.Body,
121 CreatedAt: c.CreatedAt.Time.UTC().Format(time.RFC3339),
122 UpdatedAt: c.UpdatedAt.Time.UTC().Format(time.RFC3339),
123 }
124 if c.AuthorUserID.Valid {
125 out.AuthorID = c.AuthorUserID.Int64
126 }
127 if c.EditedAt.Valid {
128 out.EditedAt = c.EditedAt.Time.UTC().Format(time.RFC3339)
129 }
130 return out
131 }
132
133 // ─── list ───────────────────────────────────────────────────────────
134
135 func (h *Handlers) issuesList(w http.ResponseWriter, r *http.Request) {
136 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
137 if !ok {
138 return
139 }
140 page, perPage := apipage.ParseQuery(r, apipage.DefaultPerPage, apipage.MaxPerPage)
141 stateFilter := normalizeIssueState(r.URL.Query().Get("state"))
142 q := issuesdb.New()
143 total, err := q.CountIssues(r.Context(), h.d.Pool, issuesdb.CountIssuesParams{
144 RepoID: repo.ID,
145 StateFilter: stateFilter,
146 Kind: issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
147 })
148 if err != nil {
149 h.d.Logger.ErrorContext(r.Context(), "api: count issues", "error", err)
150 writeAPIError(w, http.StatusInternalServerError, "list failed")
151 return
152 }
153 rows, err := q.ListIssues(r.Context(), h.d.Pool, issuesdb.ListIssuesParams{
154 RepoID: repo.ID,
155 Limit: int32(perPage),
156 Offset: int32((page - 1) * perPage),
157 StateFilter: stateFilter,
158 Kind: issuesdb.NullIssueKind{IssueKind: issuesdb.IssueKindIssue, Valid: true},
159 })
160 if err != nil {
161 h.d.Logger.ErrorContext(r.Context(), "api: list issues", "error", err)
162 writeAPIError(w, http.StatusInternalServerError, "list failed")
163 return
164 }
165 link := apipage.Page{Current: page, PerPage: perPage, Total: int(total)}.LinkHeader(h.d.BaseURL, sanitizedURL(r))
166 if link != "" {
167 w.Header().Set("Link", link)
168 }
169 out := make([]issueResponse, 0, len(rows))
170 for _, row := range rows {
171 out = append(out, presentIssue(row, h.labelNamesFor(r.Context(), row.ID)))
172 }
173 writeJSON(w, http.StatusOK, out)
174 }
175
176 func normalizeIssueState(s string) pgtype.Text {
177 switch strings.ToLower(strings.TrimSpace(s)) {
178 case "open":
179 return pgtype.Text{String: "open", Valid: true}
180 case "closed":
181 return pgtype.Text{String: "closed", Valid: true}
182 case "", "all":
183 // Encoded as NULL in the sqlc query so the WHERE clause is a no-op.
184 return pgtype.Text{}
185 default:
186 // Unknown values fall back to "all" — gh-style leniency for
187 // list endpoints; tightening would break script ports.
188 return pgtype.Text{}
189 }
190 }
191
192 func (h *Handlers) labelNamesFor(ctx context.Context, issueID int64) []string {
193 rows, err := issuesdb.New().ListLabelsOnIssue(ctx, h.d.Pool, issueID)
194 if err != nil {
195 return nil
196 }
197 out := make([]string, 0, len(rows))
198 for _, r := range rows {
199 out = append(out, r.Name)
200 }
201 return out
202 }
203
204 // ─── single get ─────────────────────────────────────────────────────
205
206 func (h *Handlers) issueGet(w http.ResponseWriter, r *http.Request) {
207 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
208 if !ok {
209 return
210 }
211 num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
212 if err != nil {
213 writeAPIError(w, http.StatusNotFound, "issue not found")
214 return
215 }
216 issue, err := issuesdb.New().GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
217 RepoID: repo.ID, Number: num,
218 })
219 if err != nil {
220 if errors.Is(err, pgx.ErrNoRows) {
221 writeAPIError(w, http.StatusNotFound, "issue not found")
222 return
223 }
224 h.d.Logger.ErrorContext(r.Context(), "api: get issue", "error", err)
225 writeAPIError(w, http.StatusInternalServerError, "lookup failed")
226 return
227 }
228 if issue.Kind != issuesdb.IssueKindIssue {
229 // PRs share the `issues` table but are not exposed on the
230 // /issues REST surface — they get their own routes in §4.
231 writeAPIError(w, http.StatusNotFound, "issue not found")
232 return
233 }
234 writeJSON(w, http.StatusOK, presentIssue(issue, h.labelNamesFor(r.Context(), issue.ID)))
235 }
236
237 // ─── create ─────────────────────────────────────────────────────────
238
239 type issueCreateRequest struct {
240 Title string `json:"title"`
241 Body string `json:"body"`
242 }
243
244 func (h *Handlers) issueCreate(w http.ResponseWriter, r *http.Request) {
245 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueCreate)
246 if !ok {
247 return
248 }
249 auth := middleware.PATAuthFromContext(r.Context())
250 var body issueCreateRequest
251 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
252 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
253 return
254 }
255 issue, err := issues.Create(r.Context(), h.issuesDeps(), issues.CreateParams{
256 RepoID: repo.ID,
257 AuthorUserID: auth.UserID,
258 Title: body.Title,
259 Body: body.Body,
260 Kind: "issue",
261 })
262 if err != nil {
263 writeIssuesError(w, err)
264 return
265 }
266 writeJSON(w, http.StatusCreated, presentIssue(issue, nil))
267 }
268
269 // ─── patch ──────────────────────────────────────────────────────────
270
271 type issuePatchRequest struct {
272 Title *string `json:"title,omitempty"`
273 Body *string `json:"body,omitempty"`
274 State *string `json:"state,omitempty"`
275 StateReason *string `json:"state_reason,omitempty"`
276 }
277
278 func (h *Handlers) issuePatch(w http.ResponseWriter, r *http.Request) {
279 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
280 if !ok {
281 return
282 }
283 auth := middleware.PATAuthFromContext(r.Context())
284 if auth.UserID == 0 {
285 writeAPIError(w, http.StatusUnauthorized, "unauthenticated")
286 return
287 }
288 num, err := strconv.ParseInt(chi.URLParam(r, "number"), 10, 64)
289 if err != nil {
290 writeAPIError(w, http.StatusNotFound, "issue not found")
291 return
292 }
293 q := issuesdb.New()
294 issue, err := q.GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
295 RepoID: repo.ID, Number: num,
296 })
297 if err != nil || issue.Kind != issuesdb.IssueKindIssue {
298 writeAPIError(w, http.StatusNotFound, "issue not found")
299 return
300 }
301 var body issuePatchRequest
302 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
303 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
304 return
305 }
306
307 // Title/body: only the author or a repo collaborator with write
308 // access can edit. We deliberately gate via ActionRepoWrite (not
309 // ActionIssueComment) — comment-create is open to any logged-in
310 // reader on a public repo, but editing someone else's issue is a
311 // moderation action.
312 if body.Title != nil || body.Body != nil {
313 canEdit := issue.AuthorUserID.Valid && issue.AuthorUserID.Int64 == auth.UserID
314 if !canEdit {
315 canEdit = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
316 }
317 if !canEdit {
318 writeAPIError(w, http.StatusForbidden, "only the author or a repo collaborator may edit this issue")
319 return
320 }
321 updated, err := issues.Edit(r.Context(), h.issuesDeps(), issues.EditParams{
322 IssueID: issue.ID,
323 Title: body.Title,
324 Body: body.Body,
325 })
326 if err != nil {
327 writeIssuesError(w, err)
328 return
329 }
330 issue = updated
331 }
332
333 if body.State != nil {
334 // State changes require ActionIssueClose.
335 if !policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionIssueClose, policy.NewRepoRefFromRepo(*repo)).Allow {
336 writeAPIError(w, http.StatusForbidden, "lack permission to change issue state")
337 return
338 }
339 newState := strings.ToLower(*body.State)
340 if newState != "open" && newState != "closed" {
341 writeAPIError(w, http.StatusUnprocessableEntity, "state must be open or closed")
342 return
343 }
344 reason := ""
345 if body.StateReason != nil {
346 reason = strings.ToLower(*body.StateReason)
347 switch reason {
348 case "", "completed", "not_planned", "duplicate", "reopened":
349 default:
350 writeAPIError(w, http.StatusUnprocessableEntity, "state_reason must be one of completed, not_planned, duplicate, reopened")
351 return
352 }
353 }
354 if err := issues.SetState(r.Context(), h.issuesDeps(), auth.UserID, issue.ID, newState, reason); err != nil {
355 writeIssuesError(w, err)
356 return
357 }
358 }
359
360 fresh, err := q.GetIssueByID(r.Context(), h.d.Pool, issue.ID)
361 if err != nil {
362 writeAPIError(w, http.StatusInternalServerError, "reload failed")
363 return
364 }
365 writeJSON(w, http.StatusOK, presentIssue(fresh, h.labelNamesFor(r.Context(), fresh.ID)))
366 }
367
368 // ─── comments ───────────────────────────────────────────────────────
369
370 func (h *Handlers) issueCommentsList(w http.ResponseWriter, r *http.Request) {
371 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
372 if !ok {
373 return
374 }
375 issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
376 if !ok {
377 return
378 }
379 rows, err := issuesdb.New().ListIssueComments(r.Context(), h.d.Pool, issue.ID)
380 if err != nil {
381 h.d.Logger.ErrorContext(r.Context(), "api: list comments", "error", err)
382 writeAPIError(w, http.StatusInternalServerError, "list failed")
383 return
384 }
385 out := make([]commentResponse, 0, len(rows))
386 for _, c := range rows {
387 out = append(out, presentComment(c))
388 }
389 writeJSON(w, http.StatusOK, out)
390 }
391
392 type commentCreateRequest struct {
393 Body string `json:"body"`
394 }
395
396 func (h *Handlers) issueCommentCreate(w http.ResponseWriter, r *http.Request) {
397 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueComment)
398 if !ok {
399 return
400 }
401 auth := middleware.PATAuthFromContext(r.Context())
402 issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
403 if !ok {
404 return
405 }
406 var body commentCreateRequest
407 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
408 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
409 return
410 }
411 isCollab := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
412 c, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{
413 IssueID: issue.ID,
414 AuthorUserID: auth.UserID,
415 Body: body.Body,
416 IsCollab: isCollab,
417 })
418 if err != nil {
419 writeIssuesError(w, err)
420 return
421 }
422 writeJSON(w, http.StatusCreated, presentComment(c))
423 }
424
425 type commentUpdateRequest struct {
426 Body string `json:"body"`
427 }
428
429 func (h *Handlers) issueCommentUpdate(w http.ResponseWriter, r *http.Request) {
430 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
431 if !ok {
432 return
433 }
434 auth := middleware.PATAuthFromContext(r.Context())
435 cid, err := strconv.ParseInt(chi.URLParam(r, "cid"), 10, 64)
436 if err != nil {
437 writeAPIError(w, http.StatusNotFound, "comment not found")
438 return
439 }
440 q := issuesdb.New()
441 comment, err := q.GetIssueComment(r.Context(), h.d.Pool, cid)
442 if err != nil {
443 writeAPIError(w, http.StatusNotFound, "comment not found")
444 return
445 }
446 // Cross-repo guard: the comment must belong to an issue in this
447 // repo. Without this, a caller could /repos/foo/bar/issues/comments/{id}
448 // against an unrelated comment id.
449 issue, err := q.GetIssueByID(r.Context(), h.d.Pool, comment.IssueID)
450 if err != nil || issue.RepoID != repo.ID {
451 writeAPIError(w, http.StatusNotFound, "comment not found")
452 return
453 }
454 if !canEditComment(comment, auth.UserID) {
455 writeAPIError(w, http.StatusForbidden, "only the author may edit this comment")
456 return
457 }
458 var body commentUpdateRequest
459 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
460 writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
461 return
462 }
463 trimmed := strings.TrimSpace(body.Body)
464 if trimmed == "" {
465 writeAPIError(w, http.StatusUnprocessableEntity, "body is required")
466 return
467 }
468 if len(trimmed) > 65535 {
469 writeAPIError(w, http.StatusUnprocessableEntity, "body too long")
470 return
471 }
472 if err := q.UpdateIssueCommentBody(r.Context(), h.d.Pool, issuesdb.UpdateIssueCommentBodyParams{
473 ID: comment.ID, Body: trimmed,
474 // body_html_cached is cleared; the next render path picks the
475 // fresh body up. Matches how the HTML comment editor handles
476 // re-renders (lazy regeneration on read).
477 BodyHtmlCached: pgtype.Text{},
478 }); err != nil {
479 h.d.Logger.ErrorContext(r.Context(), "api: update comment", "error", err)
480 writeAPIError(w, http.StatusInternalServerError, "update failed")
481 return
482 }
483 fresh, _ := q.GetIssueComment(r.Context(), h.d.Pool, comment.ID)
484 writeJSON(w, http.StatusOK, presentComment(fresh))
485 }
486
487 func (h *Handlers) issueCommentDelete(w http.ResponseWriter, r *http.Request) {
488 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueRead)
489 if !ok {
490 return
491 }
492 auth := middleware.PATAuthFromContext(r.Context())
493 cid, err := strconv.ParseInt(chi.URLParam(r, "cid"), 10, 64)
494 if err != nil {
495 writeAPIError(w, http.StatusNotFound, "comment not found")
496 return
497 }
498 q := issuesdb.New()
499 comment, err := q.GetIssueComment(r.Context(), h.d.Pool, cid)
500 if err != nil {
501 writeAPIError(w, http.StatusNotFound, "comment not found")
502 return
503 }
504 issue, err := q.GetIssueByID(r.Context(), h.d.Pool, comment.IssueID)
505 if err != nil || issue.RepoID != repo.ID {
506 writeAPIError(w, http.StatusNotFound, "comment not found")
507 return
508 }
509 // Delete is broader than edit: a repo collaborator with write
510 // access can remove any comment (matches GitHub's "moderation"
511 // affordance), the comment author can remove their own.
512 canDelete := comment.AuthorUserID.Valid && comment.AuthorUserID.Int64 == auth.UserID
513 if !canDelete {
514 canDelete = policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, auth.PolicyActor(), policy.ActionRepoWrite, policy.NewRepoRefFromRepo(*repo)).Allow
515 }
516 if !canDelete {
517 writeAPIError(w, http.StatusForbidden, "lack permission to delete this comment")
518 return
519 }
520 if err := q.DeleteIssueComment(r.Context(), h.d.Pool, comment.ID); err != nil {
521 h.d.Logger.ErrorContext(r.Context(), "api: delete comment", "error", err)
522 writeAPIError(w, http.StatusInternalServerError, "delete failed")
523 return
524 }
525 w.WriteHeader(http.StatusNoContent)
526 }
527
528 func canEditComment(c issuesdb.IssueComment, actorUserID int64) bool {
529 if !c.AuthorUserID.Valid {
530 return false
531 }
532 return c.AuthorUserID.Int64 == actorUserID
533 }
534
535 // ─── lock ───────────────────────────────────────────────────────────
536
537 type issueLockRequest struct {
538 Reason string `json:"lock_reason"`
539 }
540
541 func (h *Handlers) issueLock(w http.ResponseWriter, r *http.Request) {
542 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueClose)
543 if !ok {
544 return
545 }
546 auth := middleware.PATAuthFromContext(r.Context())
547 issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
548 if !ok {
549 return
550 }
551 var body issueLockRequest
552 _ = json.NewDecoder(r.Body).Decode(&body) // body is optional
553 if err := issues.SetLock(r.Context(), h.issuesDeps(), auth.UserID, issue.ID, true, body.Reason); err != nil {
554 h.d.Logger.ErrorContext(r.Context(), "api: lock", "error", err)
555 writeAPIError(w, http.StatusInternalServerError, "lock failed")
556 return
557 }
558 w.WriteHeader(http.StatusNoContent)
559 }
560
561 func (h *Handlers) issueUnlock(w http.ResponseWriter, r *http.Request) {
562 repo, ok := h.resolveAPIRepo(w, r, policy.ActionIssueClose)
563 if !ok {
564 return
565 }
566 auth := middleware.PATAuthFromContext(r.Context())
567 issue, ok := h.resolveIssueByNumber(w, r, repo.ID, chi.URLParam(r, "number"))
568 if !ok {
569 return
570 }
571 if err := issues.SetLock(r.Context(), h.issuesDeps(), auth.UserID, issue.ID, false, ""); err != nil {
572 h.d.Logger.ErrorContext(r.Context(), "api: unlock", "error", err)
573 writeAPIError(w, http.StatusInternalServerError, "unlock failed")
574 return
575 }
576 w.WriteHeader(http.StatusNoContent)
577 }
578
579 // ─── helpers ────────────────────────────────────────────────────────
580
581 func (h *Handlers) resolveIssueByNumber(w http.ResponseWriter, r *http.Request, repoID int64, numberRaw string) (issuesdb.Issue, bool) {
582 num, err := strconv.ParseInt(numberRaw, 10, 64)
583 if err != nil {
584 writeAPIError(w, http.StatusNotFound, "issue not found")
585 return issuesdb.Issue{}, false
586 }
587 issue, err := issuesdb.New().GetIssueByNumber(r.Context(), h.d.Pool, issuesdb.GetIssueByNumberParams{
588 RepoID: repoID, Number: num,
589 })
590 if err != nil || issue.Kind != issuesdb.IssueKindIssue {
591 writeAPIError(w, http.StatusNotFound, "issue not found")
592 return issuesdb.Issue{}, false
593 }
594 return issue, true
595 }
596
597 func (h *Handlers) issuesDeps() issues.Deps {
598 return issues.Deps{
599 Pool: h.d.Pool,
600 Limiter: h.d.Throttle,
601 Logger: h.d.Logger,
602 Audit: h.d.Audit,
603 }
604 }
605
606 func writeIssuesError(w http.ResponseWriter, err error) {
607 switch {
608 case errors.Is(err, issues.ErrEmptyTitle),
609 errors.Is(err, issues.ErrTitleTooLong),
610 errors.Is(err, issues.ErrBodyTooLong),
611 errors.Is(err, issues.ErrEmptyComment),
612 errors.Is(err, issues.ErrCommentTooLong):
613 writeAPIError(w, http.StatusUnprocessableEntity, err.Error())
614 case errors.Is(err, issues.ErrCommentRateLimit):
615 writeAPIError(w, http.StatusTooManyRequests, "comment rate limit exceeded")
616 case errors.Is(err, issues.ErrIssueLocked):
617 writeAPIError(w, http.StatusLocked, "issue is locked")
618 case errors.Is(err, issues.ErrIssueNotFound):
619 writeAPIError(w, http.StatusNotFound, "issue not found")
620 default:
621 writeAPIError(w, http.StatusInternalServerError, "internal error")
622 }
623 }
624