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