issues.go
⎇
Raw
1package web
2
3import (
4 "net/http"
5 "slices"
6 "strconv"
7 "strings"
8
9 "github.com/go-chi/chi/v5"
10
11 "hearthforge/internal/db"
12 "hearthforge/internal/util"
13 "hearthforge/internal/web/views"
14)
15
16const issuesPerPage = 20
17
18// allowedReaction reports whether the emoji is in the picker set.
19func allowedReaction(emoji string) bool {
20 return slices.Contains(views.AllowedReactions, emoji)
21}
22
23// visibleRepo loads the {repo} URL parameter and hides private repos from
24// non-admins. It writes a 404 and returns false when the repo is not visible.
25func (s *Server) visibleRepo(w http.ResponseWriter, r *http.Request) (*db.Repo, bool) {
26 u := User(r)
27 repo, err := s.DB.GetRepo(r.Context(), chi.URLParam(r, "repo"), u != nil && u.IsAdmin)
28 if err != nil {
29 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
30 return nil, false
31 }
32 if repo == nil {
33 http.Error(w, "Not found", http.StatusNotFound)
34 return nil, false
35 }
36 return repo, true
37}
38
39// leadingInt parses the digits at the start of s, like JavaScript's parseInt.
40// It returns false when s does not start with a number.
41func leadingInt(s string) (int64, bool) {
42 end := 0
43 for end < len(s) && s[end] >= '0' && s[end] <= '9' {
44 end++
45 }
46 if end == 0 {
47 return 0, false
48 }
49 n, err := strconv.ParseInt(s[:end], 10, 64)
50 return n, err == nil
51}
52
53// parseLabelIDs turns repeated form or query values into label ids.
54func parseLabelIDs(values []string) []int64 {
55 var out []int64
56 for _, v := range values {
57 if n, ok := leadingInt(v); ok {
58 out = append(out, n)
59 }
60 }
61 return out
62}
63
64// groupReactions counts the reactions on one target. commentID is nil for the
65// issue or patch body itself. userID is 0 for anonymous viewers.
66func groupReactions(reactions []db.Reaction, commentID *int64, userID int64) []views.ReactionCount {
67 var out []views.ReactionCount
68 index := map[string]int{}
69 for _, r := range reactions {
70 if commentID == nil {
71 if r.CommentID != nil {
72 continue
73 }
74 } else if r.CommentID == nil || *r.CommentID != *commentID {
75 continue
76 }
77 i, ok := index[r.Emoji]
78 if !ok {
79 i = len(out)
80 index[r.Emoji] = i
81 out = append(out, views.ReactionCount{Emoji: r.Emoji})
82 }
83 out[i].Count++
84 if userID != 0 && r.UserID == userID {
85 out[i].UserReacted = true
86 }
87 }
88 return out
89}
90
91// issueNumber reads the {number} URL parameter.
92func issueNumber(r *http.Request) int64 {
93 n, _ := leadingInt(chi.URLParam(r, "number"))
94 return n
95}
96
97// tooLong rejects a field that exceeds its byte cap.
98func tooLong(w http.ResponseWriter, value string, max int) bool {
99 if len(value) <= max {
100 return false
101 }
102 http.Error(w, "Bad Request", http.StatusUnprocessableEntity)
103 return true
104}
105
106func (s *Server) issueRoutes(r chi.Router) {
107 r.Get("/{repo}/issues", s.issueList)
108 r.Get("/{repo}/issues/{number}", s.issueDetail)
109
110 r.Group(func(r chi.Router) {
111 r.Use(s.requireAuth)
112 r.Get("/{repo}/issues/new", s.newIssue)
113 r.Post("/{repo}/issues", s.createIssue)
114 r.Post("/{repo}/issues/{number}/comments", s.addIssueComment)
115 r.Post("/{repo}/issues/{number}/comments/{id}/edit", s.editIssueComment)
116 r.Post("/{repo}/issues/{number}/react", s.reactIssue)
117 r.Post("/{repo}/issues/{number}/delete", s.deleteIssue)
118 r.Post("/{repo}/issues/{number}/edit", s.editIssue)
119 })
120
121 r.Group(func(r chi.Router) {
122 r.Use(s.requireAdmin)
123 r.Post("/{repo}/issues/{number}/complete", s.completeIssue)
124 r.Post("/{repo}/issues/{number}/close", s.closeIssue)
125 })
126
127 // The label routes answer 401 instead of redirecting, so they do their
128 // own auth check.
129 r.Post("/{repo}/issues/{number}/labels/add", s.addIssueLabel)
130 r.Post("/{repo}/issues/{number}/labels/remove", s.removeIssueLabel)
131}
132
133func (s *Server) issueList(w http.ResponseWriter, r *http.Request) {
134 repo, ok := s.visibleRepo(w, r)
135 if !ok {
136 return
137 }
138 q := r.URL.Query()
139 status := "open"
140 switch q.Get("status") {
141 case "closed":
142 status = "closed"
143 case "completed":
144 status = "completed"
145 }
146 labelIDs := parseLabelIDs(q["labels"])
147
148 repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID)
149 if err != nil {
150 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
151 return
152 }
153 counts, err := s.DB.IssueCounts(r.Context(), repo.ID, labelIDs)
154 if err != nil {
155 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
156 return
157 }
158 page := util.Paginate(util.ParsePage(q.Get("page")), counts[status], issuesPerPage)
159 issues, err := s.DB.ListIssues(r.Context(), repo.ID, status, labelIDs, issuesPerPage, page.Offset)
160 if err != nil {
161 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
162 return
163 }
164 ids := make([]int64, len(issues))
165 for i, issue := range issues {
166 ids[i] = issue.ID
167 }
168 labelsByIssue, err := s.DB.IssueLabelsByIssue(r.Context(), ids)
169 if err != nil {
170 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
171 return
172 }
173
174 pageInfo := views.PageInfo{
175 Page: page.Page,
176 TotalPages: page.TotalPages,
177 URLTemplate: "/" + repo.Name + "/issues?status=" + status +
178 views.LabelsQueryParam(labelIDs) + "&page={page}",
179 }
180 views.Render(w, http.StatusOK, views.IssueList(s.Cfg, User(r), repo, issues, status,
181 counts, pageInfo, repoLabels, labelIDs, labelsByIssue))
182}
183
184func (s *Server) newIssue(w http.ResponseWriter, r *http.Request) {
185 repo, ok := s.visibleRepo(w, r)
186 if !ok {
187 return
188 }
189 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
190 if err != nil {
191 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
192 return
193 }
194 template := ""
195 if repo.IssueTemplate != nil {
196 template = *repo.IssueTemplate
197 }
198 views.Render(w, http.StatusOK, views.NewIssue(s.Cfg, User(r), repo, "", template, labels))
199}
200
201func (s *Server) createIssue(w http.ResponseWriter, r *http.Request) {
202 if s.limited(w, r, issueCreateLimiter, false) {
203 return
204 }
205 repo, ok := s.visibleRepo(w, r)
206 if !ok {
207 return
208 }
209 user := User(r)
210 title := r.FormValue("title")
211 body := r.FormValue("body")
212 if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
213 return
214 }
215 if strings.TrimSpace(title) == "" {
216 labels, err := s.DB.ListLabels(r.Context(), repo.ID)
217 if err != nil {
218 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
219 return
220 }
221 views.Render(w, http.StatusOK,
222 views.NewIssue(s.Cfg, user, repo, "Title is required", "", labels))
223 return
224 }
225
226 var labelIDs []int64
227 if user.IsAdmin || repo.AllowUserLabels {
228 labelIDs = parseLabelIDs(r.Form["label_ids"])
229 }
230 number, err := s.DB.CreateIssue(r.Context(), repo.ID, &user.ID, strings.TrimSpace(title), body,
231 db.NowISO(), labelIDs)
232 if err != nil {
233 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
234 return
235 }
236 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(number, 10), http.StatusFound)
237}
238
239func (s *Server) issueDetail(w http.ResponseWriter, r *http.Request) {
240 repo, ok := s.visibleRepo(w, r)
241 if !ok {
242 return
243 }
244 issue, err := s.DB.IssueByNumber(r.Context(), repo.ID, issueNumber(r))
245 if err != nil {
246 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
247 return
248 }
249 if issue == nil {
250 http.Error(w, "Not found", http.StatusNotFound)
251 return
252 }
253 user := User(r)
254 viewerID := int64(0)
255 if user != nil {
256 viewerID = user.ID
257 }
258
259 comments, err := s.DB.ListIssueComments(r.Context(), issue.ID)
260 if err != nil {
261 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
262 return
263 }
264 reactionRows, err := s.DB.ListIssueReactions(r.Context(), issue.ID)
265 if err != nil {
266 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
267 return
268 }
269 issueLabels, err := s.DB.IssueLabels(r.Context(), issue.ID)
270 if err != nil {
271 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
272 return
273 }
274 repoLabels, err := s.DB.ListLabels(r.Context(), repo.ID)
275 if err != nil {
276 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
277 return
278 }
279
280 thread := make([]views.ThreadComment, len(comments))
281 commentReactions := make(map[int64][]views.ReactionCount, len(comments))
282 for i, c := range comments {
283 username := ""
284 if c.AuthorUsername != nil {
285 username = *c.AuthorUsername
286 }
287 thread[i] = views.ThreadComment{
288 ID: c.ID,
289 AuthorID: c.AuthorID,
290 AuthorUsername: username,
291 AuthorAvatarVersion: c.AuthorAvatarVersion,
292 Body: c.Body,
293 BodyHTML: s.MD.Render(c.Body, "", nil),
294 CreatedAt: c.CreatedAt,
295 EditedAt: c.EditedAt,
296 }
297 id := c.ID
298 commentReactions[c.ID] = groupReactions(reactionRows, &id, viewerID)
299 }
300
301 views.Render(w, http.StatusOK, views.IssueDetail(s.Cfg, user, repo, issue,
302 s.MD.Render(issue.Body, "", nil), thread,
303 groupReactions(reactionRows, nil, viewerID), commentReactions, issueLabels, repoLabels))
304}
305
306func (s *Server) addIssueComment(w http.ResponseWriter, r *http.Request) {
307 if s.limited(w, r, commentLimiter, false) {
308 return
309 }
310 repo, ok := s.visibleRepo(w, r)
311 if !ok {
312 return
313 }
314 num := issueNumber(r)
315 issue, ok := s.issueRef(w, r, repo.ID, num)
316 if !ok {
317 return
318 }
319 target := "/" + repo.Name + "/issues/" + strconv.FormatInt(num, 10)
320 user := User(r)
321 // Only an admin may comment on a closed issue.
322 if issue.Status == "closed" && !user.IsAdmin {
323 http.Redirect(w, r, target, http.StatusFound)
324 return
325 }
326 body := r.FormValue("body")
327 if tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
328 return
329 }
330 if strings.TrimSpace(body) == "" {
331 http.Redirect(w, r, target, http.StatusFound)
332 return
333 }
334 if err := s.DB.AddIssueComment(r.Context(), issue.ID, &user.ID, strings.TrimSpace(body), db.NowISO()); err != nil {
335 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
336 return
337 }
338 http.Redirect(w, r, target, http.StatusFound)
339}
340
341func (s *Server) editIssueComment(w http.ResponseWriter, r *http.Request) {
342 if s.limited(w, r, commentLimiter, false) {
343 return
344 }
345 repo, ok := s.visibleRepo(w, r)
346 if !ok {
347 return
348 }
349 commentID, _ := leadingInt(chi.URLParam(r, "id"))
350 auth, err := s.DB.IssueCommentAuth(r.Context(), commentID, repo.ID)
351 if err != nil {
352 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
353 return
354 }
355 if auth == nil {
356 http.Error(w, "Not found", http.StatusNotFound)
357 return
358 }
359 user := User(r)
360 if (auth.AuthorID == nil || *auth.AuthorID != user.ID) && !user.IsAdmin {
361 http.Error(w, "Forbidden", http.StatusForbidden)
362 return
363 }
364 if auth.Status != "open" && !user.IsAdmin {
365 http.Error(w, "Forbidden", http.StatusForbidden)
366 return
367 }
368 body := r.FormValue("edit_body")
369 if tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
370 return
371 }
372 if strings.TrimSpace(body) == "" {
373 http.Error(w, "Comment is required", http.StatusUnprocessableEntity)
374 return
375 }
376 if err := s.DB.UpdateIssueComment(r.Context(), commentID, strings.TrimSpace(body), db.NowISO()); err != nil {
377 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
378 return
379 }
380 http.Redirect(w, r, "/"+repo.Name+"/issues/"+chi.URLParam(r, "number"), http.StatusFound)
381}
382
383func (s *Server) reactIssue(w http.ResponseWriter, r *http.Request) {
384 if s.limited(w, r, reactionLimiter, false) {
385 return
386 }
387 repo, ok := s.visibleRepo(w, r)
388 if !ok {
389 return
390 }
391 emoji := r.FormValue("emoji")
392 if !allowedReaction(emoji) {
393 http.Error(w, "Invalid emoji", http.StatusBadRequest)
394 return
395 }
396 num := issueNumber(r)
397 issue, ok := s.issueRef(w, r, repo.ID, num)
398 if !ok {
399 return
400 }
401 var commentID *int64
402 if raw := r.FormValue("comment_id"); raw != "" {
403 if n, ok := leadingInt(raw); ok {
404 commentID = &n
405 }
406 }
407 if err := s.DB.ToggleIssueReaction(r.Context(), issue.ID, commentID, User(r).ID, emoji); err != nil {
408 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
409 return
410 }
411 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusSeeOther)
412}
413
414func (s *Server) completeIssue(w http.ResponseWriter, r *http.Request) {
415 repo, ok := s.visibleRepo(w, r)
416 if !ok {
417 return
418 }
419 num := issueNumber(r)
420 issue, ok := s.issueRef(w, r, repo.ID, num)
421 if !ok {
422 return
423 }
424 if err := s.DB.CompleteIssue(r.Context(), issue.ID, db.NowISO()); err != nil {
425 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
426 return
427 }
428 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
429}
430
431func (s *Server) closeIssue(w http.ResponseWriter, r *http.Request) {
432 repo, ok := s.visibleRepo(w, r)
433 if !ok {
434 return
435 }
436 num := issueNumber(r)
437 issue, ok := s.issueRef(w, r, repo.ID, num)
438 if !ok {
439 return
440 }
441 if err := s.DB.ToggleIssueClosed(r.Context(), issue.ID, db.NowISO()); err != nil {
442 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
443 return
444 }
445 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
446}
447
448func (s *Server) deleteIssue(w http.ResponseWriter, r *http.Request) {
449 repo, ok := s.visibleRepo(w, r)
450 if !ok {
451 return
452 }
453 issue, ok := s.issueRef(w, r, repo.ID, issueNumber(r))
454 if !ok {
455 return
456 }
457 user := User(r)
458 if (issue.AuthorID == nil || *issue.AuthorID != user.ID) && !user.IsAdmin {
459 http.Error(w, "Forbidden", http.StatusForbidden)
460 return
461 }
462 if err := s.DB.DeleteIssue(r.Context(), issue.ID); err != nil {
463 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
464 return
465 }
466 http.Redirect(w, r, "/"+repo.Name+"/issues", http.StatusFound)
467}
468
469func (s *Server) editIssue(w http.ResponseWriter, r *http.Request) {
470 if s.limited(w, r, commentLimiter, false) {
471 return
472 }
473 repo, ok := s.visibleRepo(w, r)
474 if !ok {
475 return
476 }
477 num := issueNumber(r)
478 issue, ok := s.issueRef(w, r, repo.ID, num)
479 if !ok {
480 return
481 }
482 user := User(r)
483 if (issue.AuthorID == nil || *issue.AuthorID != user.ID) && !user.IsAdmin {
484 http.Error(w, "Forbidden", http.StatusForbidden)
485 return
486 }
487 if issue.Status != "open" && !user.IsAdmin {
488 http.Error(w, "Forbidden", http.StatusForbidden)
489 return
490 }
491 title := r.FormValue("title")
492 body := r.FormValue("edit_body")
493 if tooLong(w, title, s.Cfg.MaxTitleBytes) || tooLong(w, body, s.Cfg.MaxTextBodyBytes) {
494 return
495 }
496 if strings.TrimSpace(title) == "" {
497 http.Error(w, "Title is required", http.StatusUnprocessableEntity)
498 return
499 }
500 if err := s.DB.UpdateIssue(r.Context(), issue.ID, strings.TrimSpace(title), body, db.NowISO()); err != nil {
501 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
502 return
503 }
504 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
505}
506
507func (s *Server) addIssueLabel(w http.ResponseWriter, r *http.Request) {
508 repo, issue, num, ok := s.issueLabelTarget(w, r)
509 if !ok {
510 return
511 }
512 labelID, _ := leadingInt(r.FormValue("label_id"))
513 target := "/" + repo.Name + "/issues/" + strconv.FormatInt(num, 10)
514 label, err := s.DB.LabelInRepo(r.Context(), labelID, repo.ID)
515 if err != nil {
516 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
517 return
518 }
519 if label == nil {
520 http.Redirect(w, r, target, http.StatusFound)
521 return
522 }
523 if err := s.DB.AddIssueLabel(r.Context(), issue.ID, label.ID); err != nil {
524 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
525 return
526 }
527 http.Redirect(w, r, target, http.StatusFound)
528}
529
530func (s *Server) removeIssueLabel(w http.ResponseWriter, r *http.Request) {
531 repo, issue, num, ok := s.issueLabelTarget(w, r)
532 if !ok {
533 return
534 }
535 labelID, _ := leadingInt(r.FormValue("label_id"))
536 if err := s.DB.RemoveIssueLabel(r.Context(), issue.ID, labelID); err != nil {
537 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
538 return
539 }
540 http.Redirect(w, r, "/"+repo.Name+"/issues/"+strconv.FormatInt(num, 10), http.StatusFound)
541}
542
543// issueLabelTarget runs the shared checks of the label add and remove routes.
544func (s *Server) issueLabelTarget(w http.ResponseWriter, r *http.Request) (*db.Repo, *db.IssueRef, int64, bool) {
545 user := User(r)
546 if user == nil {
547 http.Error(w, "Unauthorized", http.StatusUnauthorized)
548 return nil, nil, 0, false
549 }
550 if s.limited(w, r, labelWriteLimiter, false) {
551 return nil, nil, 0, false
552 }
553 repo, ok := s.visibleRepo(w, r)
554 if !ok {
555 return nil, nil, 0, false
556 }
557 num := issueNumber(r)
558 issue, ok := s.issueRef(w, r, repo.ID, num)
559 if !ok {
560 return nil, nil, 0, false
561 }
562 canManage := user.IsAdmin ||
563 (repo.AllowUserLabels && issue.AuthorID != nil && user.ID == *issue.AuthorID)
564 if !canManage {
565 http.Error(w, "Forbidden", http.StatusForbidden)
566 return nil, nil, 0, false
567 }
568 return repo, issue, num, true
569}
570
571// issueRef loads the small issue row and writes a 404 when it is missing.
572func (s *Server) issueRef(w http.ResponseWriter, r *http.Request, repoID, number int64) (*db.IssueRef, bool) {
573 issue, err := s.DB.IssueRefByNumber(r.Context(), repoID, number)
574 if err != nil {
575 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
576 return nil, false
577 }
578 if issue == nil {
579 http.Error(w, "Not found", http.StatusNotFound)
580 return nil, false
581 }
582 return issue, true
583}
584