Reactions on issues, merge requests and comments !527

merged merged by cmc on 2026-09-29 06:06 UTC · krz/gitbay:reactions-291 into main

30 files changed, +1174 −31

Layout: unified · split

.gitbay/wiki/Parity.org +2
@@ -35,6 +35,7 @@ browser-only and the iOS build screen unable to say more than the log.
35| review and check times | yes | yes | yes | 35| review and check times | yes | yes | yes |
36| who resolved it, when | yes | yes | yes | 36| who resolved it, when | yes | yes | yes |
37| comment | yes | yes | yes | 37| comment | yes | yes | yes |
38| react to it or a comment | yes | yes | no |
38| edit title and body | yes | yes | yes | 39| edit title and body | yes | yes | yes |
39| review (approve etc.) | yes | yes | yes | 40| review (approve etc.) | yes | yes | yes |
40| resolve a thread | yes | yes | yes | 41| resolve a thread | yes | yes | yes |
@@ -115,6 +116,7 @@ reviews, since an approval was of the diff against the old branch.
115| search title and body | yes | yes | yes | 116| search title and body | yes | yes | yes |
116| create | yes | yes | yes | 117| create | yes | yes | yes |
117| comment | yes | yes | yes | 118| comment | yes | yes | yes |
119| react to it or a comment | yes | yes | no |
118| edit title and body | yes | yes | yes | 120| edit title and body | yes | yes | yes |
119| close and reopen | yes | yes | yes | 121| close and reopen | yes | yes | yes |
120| labels, assignees | yes | yes | yes | 122| labels, assignees | yes | yes | yes |
.gitbay/wiki/Users.org +8
@@ -335,6 +335,7 @@ gitbay issue create --title "it breaks" [--body "..." | --file -]
335gitbay issue list [--state open|closed|all] 335gitbay issue list [--state open|closed|all]
336gitbay issue show 4 336gitbay issue show 4
337gitbay issue comment 4 --message "same here" 337gitbay issue comment 4 --message "same here"
338gitbay issue react 4 +1 [--comment 12] [--remove] # the same on mr
338gitbay issue edit 4 --title "better title" [--body|--file -] # author or write 339gitbay issue edit 4 --title "better title" [--body|--file -] # author or write
339gitbay issue close 4 / reopen 4 340gitbay issue close 4 / reopen 4
340gitbay issue label 4 --add bug --remove wontfix 341gitbay issue label 4 --add bug --remove wontfix
@@ -342,6 +343,13 @@ gitbay issue assign 4 --add alice
342gitbay issue milestone 4 v1.0 # or "none" to clear 343gitbay issue milestone 4 v1.0 # or "none" to clear
343#+end_src 344#+end_src
344 345
346A reaction is one of =+1 -1 laugh hooray confused heart rocket eyes=,
347given by name or as the emoji itself, on an issue or merge request or on
348one of its comments (=show= prints comment ids). Anyone who may comment
349may react; reacting twice or removing an absent reaction does nothing.
350=show= carries the counts and which are yours. A reaction sends no
351notification and appears in no activity feed.
352
345Inside a clone, the repository is inferred from the =origin= remote — 353Inside a clone, the repository is inferred from the =origin= remote —
346that is why no =owner/name= appears above. Anywhere else, pass it as the 354that is why no =owner/name= appears above. Anywhere else, pass it as the
347first argument. Long text: =--body= inline, =--file -= from stdin, or 355first argument. Long text: =--body= inline, =--file -= from stdin, or
CHANGELOG.org +3
@@ -26,6 +26,9 @@ anything beyond "replace the binary and restart" is needed.
26 the clone instead. =repo commit-file= and applying a suggestion 26 the clone instead. =repo commit-file= and applying a suggestion
27 refuse once the owner's storage quota is used up, as a push does. 27 refuse once the owner's storage quota is used up, as a push does.
28 Migration 0069 (#288). 28 Migration 0069 (#288).
29- Reactions on issues, merge requests and their comments: =issue react=
30 and =mr react= with a fixed set of eight, counts and your own in
31 =show=, comment ids in =show=, and buttons on the web pages (#291).
29 32
30* v1.38.0 — 2026-09-29 33* v1.38.0 — 2026-09-29
31 34
cmd/gitbay/main.go +2
@@ -639,6 +639,7 @@ func issueCmd() *cobra.Command {
639 pass("reopen", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), 639 pass("reopen", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
640 pass("label", passOpts{server: []string{"issue", "label"}, needsRepo: true}), 640 pass("label", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
641 pass("assign", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), 641 pass("assign", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
642 pass("react", passOpts{server: []string{"issue", "react"}, needsRepo: true}),
642 pass("edit", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}), 643 pass("edit", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}),
643 pass("milestone", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}), 644 pass("milestone", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
644 pass("templates", passOpts{server: []string{"issue", "templates"}, needsRepo: true}), 645 pass("templates", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
@@ -680,6 +681,7 @@ func mrCmd() *cobra.Command {
680 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), 681 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
681 local("rebase", "replay the MR's branch onto its target and re-push: gitbay mr rebase <n>", cmdMRRebase), 682 local("rebase", "replay the MR's branch onto its target and re-push: gitbay mr rebase <n>", cmdMRRebase),
682 pass("comment", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), 683 pass("comment", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
684 pass("react", passOpts{server: []string{"mr", "react"}, needsRepo: true}),
683 pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), 685 pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
684 pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}), 686 pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
685 pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}), 687 pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +2
@@ -50,6 +50,7 @@ var summaries = map[string]string{
50 "issue label": "add or remove labels", 50 "issue label": "add or remove labels",
51 "issue list": "list issues", 51 "issue list": "list issues",
52 "issue milestone": "set or clear an issue's milestone", 52 "issue milestone": "set or clear an issue's milestone",
53 "issue react": "react to an issue or one of its comments",
53 "issue reopen": "reopen an issue", 54 "issue reopen": "reopen an issue",
54 "issue show": "show an issue with comments", 55 "issue show": "show an issue with comments",
55 "issue templates": "list issue templates (.gitbay/issue-template*.md)", 56 "issue templates": "list issue templates (.gitbay/issue-template*.md)",
@@ -77,6 +78,7 @@ var summaries = map[string]string{
77 "mr merge": "merge", 78 "mr merge": "merge",
78 "mr milestone": "set or clear an MR's milestone", 79 "mr milestone": "set or clear an MR's milestone",
79 "mr range-diff": "what changed between two revisions of a merge request", 80 "mr range-diff": "what changed between two revisions of a merge request",
81 "mr react": "react to a merge request or one of its comments",
80 "mr ready": "take the draft mark off, so it can merge", 82 "mr ready": "take the draft mark off, so it can merge",
81 "mr resolve": "resolve a review thread", 83 "mr resolve": "resolve a review thread",
82 "mr retarget": "retarget onto another branch", 84 "mr retarget": "retarget onto another branch",
e2e/issue_test.go +1 −1
@@ -75,7 +75,7 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
75 // --format carries, and what everything written before formats existed has. 75 // --format carries, and what everything written before formats existed has.
76 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` + 76 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` +
77 `"body_format":"md",` + 77 `"body_format":"md",` +
78 `"comments":[{"author":"bob","body":"me too","body_format":"md","created_at":"TS"}],` + 78 `"comments":[{"author":"bob","body":"me too","body_format":"md","created_at":"TS","id":1}],` +
79 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` + 79 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` +
80 `"title":"first bug"},"protocol_version":1}` 80 `"title":"first bug"},"protocol_version":1}`
81 if g := golden(t, out); g != wantShow { 81 if g := golden(t, out); g != wantShow {
e2e/reactions_test.go added +156
@@ -0,0 +1,156 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// Reactions on issues, merge requests and their comments (#291): the
13// commands, the counts in show, the web buttons, and a private
14// repository staying invisible.
15func TestReactions(t *testing.T) {
16 t.Parallel()
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
18 aliceKey := inst.newKey(t, "alice")
19 bobKey := inst.newKey(t, "bob")
20 carolKey := inst.newKey(t, "carol")
21 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
22 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
23 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
24 for _, args := range [][]string{{"repo", "create", "alice/app"}, {"repo", "create", "alice/secret", "--private"}} {
25 if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
26 t.Fatalf("%v: %s", args, errOut)
27 }
28 }
29 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/secret", "bob", "read"); code != 0 {
30 t.Fatal("grant failed")
31 }
32 env := inst.gitEnv(aliceKey)
33 work := t.TempDir()
34 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
35 dir := filepath.Join(work, "w")
36 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
37 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "base")
40 mustGit(t, dir, env, "push", "-q", "origin", "main")
41 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
42 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b\n"), 0o644)
43 mustGit(t, dir, env, "commit", "-q", "-am", "change")
44 mustGit(t, dir, env, "push", "-q", "origin", "feat")
45
46 must := func(key string, args ...string) string {
47 t.Helper()
48 out, errOut, code := inst.ssh(t, key, "", args...)
49 if code != 0 {
50 t.Fatalf("%v: exit %d: %s%s", args, code, out, errOut)
51 }
52 return out
53 }
54 must(aliceKey, "issue", "create", "alice/app", "--title", "bug", "--body", "'it breaks'")
55 must(aliceKey, "issue", "comment", "alice/app", "1", "--message", "first")
56 must(aliceKey, "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "'change'")
57 must(aliceKey, "mr", "comment", "alice/app", "1", "--message", "'looks fine'")
58 must(aliceKey, "issue", "create", "alice/secret", "--title", "hidden")
59
60 type show struct {
61 Data struct {
62 Reactions []struct {
63 Reaction string
64 Count int
65 Me bool
66 }
67 Comments []struct {
68 ID int64
69 Reactions []struct {
70 Reaction string
71 Count int
72 Me bool
73 }
74 }
75 }
76 }
77 read := func(key, noun string) show {
78 t.Helper()
79 var s show
80 if err := json.Unmarshal([]byte(must(key, noun, "show", "alice/app", "1", "--json")), &s); err != nil {
81 t.Fatal(err)
82 }
83 return s
84 }
85
86 for _, noun := range []string{"issue", "mr"} {
87 cid := read(bobKey, noun).Data.Comments[0].ID
88 if cid == 0 {
89 t.Fatalf("%s show carries no comment id", noun)
90 }
91 must(bobKey, noun, "react", "alice/app", "1", "+1")
92 must(bobKey, noun, "react", "alice/app", "1", "+1") // idempotent
93 must(aliceKey, noun, "react", "alice/app", "1", "👍")
94 must(bobKey, noun, "react", "alice/app", "1", "--comment", itoa64(cid), "-1")
95 must(bobKey, noun, "react", "alice/app", "1", "--remove", "rocket") // absent: no-op
96
97 s := read(bobKey, noun)
98 if len(s.Data.Reactions) != 1 || s.Data.Reactions[0].Reaction != "+1" || s.Data.Reactions[0].Count != 2 || !s.Data.Reactions[0].Me {
99 t.Errorf("%s body reactions: %+v", noun, s.Data.Reactions)
100 }
101 if c := s.Data.Comments[0].Reactions; len(c) != 1 || c[0].Reaction != "-1" || c[0].Count != 1 || !c[0].Me {
102 t.Errorf("%s comment reactions: %+v", noun, c)
103 }
104 if a := read(aliceKey, noun); a.Data.Comments[0].Reactions[0].Me {
105 t.Errorf("%s: alice marked on bob's reaction", noun)
106 }
107 if out := must(bobKey, noun, "show", "alice/app", "1"); !strings.Contains(out, "👍 2 (you)") || !strings.Contains(out, "(comment ") {
108 t.Errorf("%s show text:\n%s", noun, out)
109 }
110 if _, _, code := inst.ssh(t, bobKey, "", noun, "react", "alice/app", "1", "nope"); code != 2 {
111 t.Errorf("%s: unknown reaction exited %d, want 2", noun, code)
112 }
113 if _, _, code := inst.ssh(t, bobKey, "", noun, "react", "alice/app", "1", "--comment", "9999", "+1"); code != 3 {
114 t.Errorf("%s: unknown comment exited %d, want 3", noun, code)
115 }
116 }
117
118 // Private: readable by bob (granted), not found for carol.
119 must(bobKey, "issue", "react", "alice/secret", "1", "eyes")
120 if _, _, code := inst.ssh(t, carolKey, "", "issue", "react", "alice/secret", "1", "eyes"); code != 3 {
121 t.Errorf("private repository: exit %d, want 3", code)
122 }
123
124 // Reacting files no activity.
125 if out := must(aliceKey, "feed", "--json"); strings.Contains(out, "react") {
126 t.Errorf("a reaction reached the feed:\n%s", out)
127 }
128
129 // Web: buttons for a signed-in viewer, counts only for anyone else.
130 bob := inst.login(t, bobKey)
131 if status, page := browserGet(t, bob, inst.base()+"/alice/app/issues/1"); status != 200 ||
132 !strings.Contains(page, `class="react mine"`) || !strings.Contains(page, `name="add" value="eyes"`) {
133 t.Errorf("signed-in issue page: %d\n%s", status, page)
134 }
135 if status, page := browserGet(t, bob, inst.base()+"/alice/app/mrs/1"); status != 200 || !strings.Contains(page, `class="react mine"`) {
136 t.Errorf("signed-in MR page: %d", status)
137 }
138 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/issues/1/react", url.Values{"add": {"rocket"}}); status != 200 {
139 t.Fatalf("web react: %d", status)
140 }
141 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/mrs/1/react", url.Values{"remove": {"+1"}}); status != 200 {
142 t.Fatalf("web unreact: %d", status)
143 }
144 if s := read(bobKey, "issue"); len(s.Data.Reactions) != 2 {
145 t.Errorf("web reaction not stored: %+v", s.Data.Reactions)
146 }
147 if s := read(bobKey, "mr"); s.Data.Reactions[0].Count != 1 || s.Data.Reactions[0].Me {
148 t.Errorf("web removal: %+v", s.Data.Reactions)
149 }
150 _, anon := inst.get(t, "/alice/app/issues/1")
151 if strings.Contains(anon, "/issues/1/react") || !strings.Contains(anon, "👍 2") {
152 t.Errorf("signed-out issue page:\n%s", anon)
153 }
154}
155
156func itoa64(n int64) string { b, _ := json.Marshal(n); return string(b) }
internal/control/issue.go +9 −3
@@ -339,11 +339,15 @@ func runIssueShow(c *Ctx, args []string) int {
339 if err != nil { 339 if err != nil {
340 return c.fail(protocol.ExitFailure, "%v", err) 340 return c.fail(protocol.ExitFailure, "%v", err)
341 } 341 }
342 rx, err := c.Store.ReactionCounts("issue", issue.ID, c.User.ID)
343 if err != nil {
344 return c.fail(protocol.ExitFailure, "%v", err)
345 }
342 var cs []commentOut 346 var cs []commentOut
343 for _, cm := range comments { 347 for _, cm := range comments {
344 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind}) 348 cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
345 } 349 }
346 d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs} 350 d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs}
347 return c.emit(d, func(w io.Writer) { 351 return c.emit(d, func(w io.Writer) {
348 v := c.view(w) 352 v := c.view(w)
349 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) 353 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
@@ -355,6 +359,7 @@ func runIssueShow(c *Ctx, args []string) int {
355 "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)), 359 "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
356 ) 360 )
357 v.body(d.Body, d.BodyFormat) 361 v.body(d.Body, d.BodyFormat)
362 v.reactions(d.Reactions)
358 events := false 363 events := false
359 for _, cm := range cs { 364 for _, cm := range cs {
360 if cm.Kind != "system" { 365 if cm.Kind != "system" {
@@ -370,7 +375,8 @@ func runIssueShow(c *Ctx, args []string) int {
370 if cm.Kind == "system" { 375 if cm.Kind == "system" {
371 continue 376 continue
372 } 377 }
373 v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) 378 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
379 v.reactions(cm.Reactions)
374 } 380 }
375 }) 381 })
376} 382}
internal/control/mr.go +9 −3
@@ -719,9 +719,13 @@ func runMRShow(c *Ctx, args []string) int {
719 } 719 }
720 checks = append(checks, out) 720 checks = append(checks, out)
721 } 721 }
722 rx, err := c.Store.ReactionCounts("mr", mr.ID, c.User.ID)
723 if err != nil {
724 return c.fail(protocol.ExitFailure, "%v", err)
725 }
722 var cs []commentOut 726 var cs []commentOut
723 for _, cm := range comments { 727 for _, cm := range comments {
724 cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind}) 728 cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])})
725 } 729 }
726 var rs []ReviewOut 730 var rs []ReviewOut
727 counts := ReviewersWhoCount(c.Store, repo, reviews) 731 counts := ReviewersWhoCount(c.Store, repo, reviews)
@@ -751,7 +755,7 @@ func runMRShow(c *Ctx, args []string) int {
751 } 755 }
752 } 756 }
753 d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined, 757 d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined,
754 UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs} 758 UnresolvedThreads: unresolved, Commits: commits, Reactions: reactionsOut(rx[0]), Comments: cs, Reviews: rs}
755 d.StackedOn, d.Stacked = stackOf(c, repo, mr) 759 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
756 if mr.State == "open" || mr.State == "source_gone" { 760 if mr.State == "open" || mr.State == "source_gone" {
757 if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil { 761 if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil {
@@ -857,6 +861,7 @@ func runMRShow(c *Ctx, args []string) int {
857 v.fields(kv...) 861 v.fields(kv...)
858 862
859 v.body(d.Body, d.BodyFormat) 863 v.body(d.Body, d.BodyFormat)
864 v.reactions(d.Reactions)
860 865
861 if len(commits) > 1 { 866 if len(commits) > 1 {
862 v.section(fmt.Sprintf("commits (%d)", len(commits))) 867 v.section(fmt.Sprintf("commits (%d)", len(commits)))
@@ -907,7 +912,8 @@ func runMRShow(c *Ctx, args []string) int {
907 if cm.Kind == "system" { 912 if cm.Kind == "system" {
908 continue 913 continue
909 } 914 }
910 v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) 915 v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat)
916 v.reactions(cm.Reactions)
911 } 917 }
912 }) 918 })
913} 919}
internal/control/output.go +9 −7
@@ -21,18 +21,20 @@ type MRCreated struct {
21// IssueShow is issue show's payload: the issue and its comments. 21// IssueShow is issue show's payload: the issue and its comments.
22type IssueShow struct { 22type IssueShow struct {
23 issueOut 23 issueOut
24 Comments []commentOut `json:"comments,omitempty"` 24 Reactions []ReactionOut `json:"reactions,omitempty"`
25 Comments []commentOut `json:"comments,omitempty"`
25} 26}
26 27
27// MRShow is mr show's payload. 28// MRShow is mr show's payload.
28type MRShow struct { 29type MRShow struct {
29 mrOut 30 mrOut
30 Checks []CheckOut `json:"checks,omitempty"` 31 Checks []CheckOut `json:"checks,omitempty"`
31 Combined string `json:"checks_combined,omitempty"` 32 Combined string `json:"checks_combined,omitempty"`
32 UnresolvedThreads int `json:"unresolved_threads,omitempty"` 33 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
33 Commits []CommitOut `json:"commits,omitempty"` 34 Commits []CommitOut `json:"commits,omitempty"`
34 Comments []commentOut `json:"comments,omitempty"` 35 Reactions []ReactionOut `json:"reactions,omitempty"`
35 Reviews []ReviewOut `json:"reviews,omitempty"` 36 Comments []commentOut `json:"comments,omitempty"`
37 Reviews []ReviewOut `json:"reviews,omitempty"`
36 // Gates is set while the merge request is open. 38 // Gates is set while the merge request is open.
37 Gates *GatesOut `json:"gates,omitempty"` 39 Gates *GatesOut `json:"gates,omitempty"`
38} 40}
internal/control/react.go added +144
@@ -0,0 +1,144 @@
1package control
2
3import (
4 "fmt"
5 "io"
6 "strconv"
7
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// Reactions on issues, merge requests and their comments (#291). A
14// reaction is not an event: it files no notification, no activity row
15// and no webhook.
16
17// ReactionOut is one reaction on one item as show emits it.
18type ReactionOut struct {
19 Reaction string `json:"reaction"`
20 Count int `json:"count"`
21 Me bool `json:"me"`
22}
23
24// Reacted is what issue react and mr react emit.
25type Reacted struct {
26 Reaction string `json:"reaction"`
27 Comment int64 `json:"comment,omitempty"`
28 Removed bool `json:"removed,omitempty"`
29}
30
31func reactionsOut(cs []store.ReactionCount) []ReactionOut {
32 var out []ReactionOut
33 for _, rc := range cs {
34 out = append(out, ReactionOut{rc.Reaction, rc.Count, rc.Me})
35 }
36 return out
37}
38
39func init() {
40 register(Command{Path: []string{"issue", "react"},
41 Summary: "react to an issue or one of its comments",
42 Usage: "issue react <owner/name> <n> [--comment <id>] <reaction> [--remove]",
43 Flags: []Flag{
44 {"--comment", "<id>", "react to this comment instead of the issue", ""},
45 {"--remove", "", "take the reaction back", ""},
46 },
47 Examples: []string{
48 "issue react krz/gitbay 42 +1",
49 "issue react krz/gitbay 42 --comment 7 hooray",
50 "issue react krz/gitbay 42 --remove +1",
51 },
52 Run: func(c *Ctx, args []string) int { return runReact(c, args, "issue") }})
53 register(Command{Path: []string{"mr", "react"},
54 Summary: "react to a merge request or one of its comments",
55 Usage: "mr react <owner/name> <n> [--comment <id>] <reaction> [--remove]",
56 Flags: []Flag{
57 {"--comment", "<id>", "react to this comment instead of the merge request", ""},
58 {"--remove", "", "take the reaction back", ""},
59 },
60 Examples: []string{
61 "mr react krz/gitbay 431 rocket",
62 "mr react krz/gitbay 431 --comment 7 eyes",
63 },
64 Run: func(c *Ctx, args []string) int { return runReact(c, args, "mr") }})
65}
66
67// runReact is issue react and mr react. Whoever may comment may react,
68// so it asks what comment asks: read access, and a repository that is
69// not archived.
70func runReact(c *Ctx, args []string, noun string) int {
71 f, err := c.parseArgs(args, flagSpec{Values: []string{"--comment"}, Bools: []string{"--remove"}, MaxPos: 3,
72 Usage: noun + " react <owner/name> <n> [--comment <id>] <reaction> [--remove]"})
73 if err != nil {
74 return c.fail(protocol.ExitUsage, "%v", err)
75 }
76 if len(f.Pos) != 3 {
77 return c.usage()
78 }
79 var (
80 repo store.Repo
81 threadID int64
82 number int64
83 code int
84 sym string
85 )
86 if noun == "issue" {
87 var iss store.Issue
88 repo, iss, code = issueRef(c, f.Pos[:2], policy.CanRead)
89 threadID, number, sym = iss.ID, iss.Number, "#"
90 } else {
91 var mr store.MR
92 repo, mr, code = mrRef(c, f.Pos[:2], policy.CanRead)
93 threadID, number, sym = mr.ID, mr.Number, "!"
94 }
95 if code >= 0 {
96 return code
97 }
98 reaction, ok := store.ParseReaction(f.Pos[2])
99 if !ok {
100 return c.fail(protocol.ExitUsage, "unknown reaction %q; one of %s", f.Pos[2], reactionNames())
101 }
102 var commentID int64
103 if f.Has("--comment") {
104 commentID, err = strconv.ParseInt(f.Value("--comment"), 10, 64)
105 if err != nil || commentID <= 0 {
106 return c.fail(protocol.ExitUsage, "bad comment id %q", f.Value("--comment"))
107 }
108 }
109 if code := refuseArchived(c, repo); code >= 0 {
110 return code
111 }
112 if err := c.Store.ReactionTarget(noun, threadID, commentID); err != nil {
113 if err == store.ErrNotFound {
114 return c.fail(protocol.ExitNotFound, "comment %d not found on %s%d", commentID, sym, number)
115 }
116 return c.fail(protocol.ExitFailure, "%v", err)
117 }
118 remove := f.Has("--remove")
119 if err := c.Store.SetReaction(noun, threadID, commentID, c.User.ID, reaction, !remove); err != nil {
120 return c.fail(protocol.ExitFailure, "%v", err)
121 }
122 return c.emit(Reacted{Reaction: reaction, Comment: commentID, Removed: remove}, func(w io.Writer) {
123 what := fmt.Sprintf("%s%s%d", repo.Path(), sym, number)
124 if commentID != 0 {
125 what += fmt.Sprintf(" comment %d", commentID)
126 }
127 verb := "reacted"
128 if remove {
129 verb = "removed reaction"
130 }
131 fmt.Fprintf(w, "%s %s on %s\n", verb, store.ReactionEmoji(reaction), what)
132 })
133}
134
135func reactionNames() string {
136 s := ""
137 for i, r := range store.Reactions {
138 if i > 0 {
139 s += " "
140 }
141 s += r.Name
142 }
143 return s
144}
internal/control/react_test.go added +269
@@ -0,0 +1,269 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "testing"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// reactFixture is alice's repository with issue #1 and one comment on
13// it, and a second user acting on the same store.
14func reactFixture(t *testing.T, visibility string) (alice, bob *Ctx, repo store.Repo, commentID int64) {
15 t.Helper()
16 alice = notifTestCtx(t, "alice")
17 repoID, err := alice.Store.CreateRepo("user", alice.User.ID, "app", visibility)
18 if err != nil {
19 t.Fatal(err)
20 }
21 repo, _ = alice.Store.RepoByID(repoID)
22 if code := runIssueCreate(alice, []string{repo.Path(), "--title", "t", "--body", "b"}); code != 0 {
23 t.Fatalf("create: %d", code)
24 }
25 if code := runIssueComment(alice, []string{repo.Path(), "1", "--message", "hi"}); code != 0 {
26 t.Fatalf("comment: %d", code)
27 }
28 iss, _ := alice.Store.IssueByNumber(repo.ID, 1)
29 cs, _ := alice.Store.ListIssueComments(iss.ID)
30 commentID = cs[0].ID
31 bobID, err := alice.Store.CreateUser("bob", false)
32 if err != nil {
33 t.Fatal(err)
34 }
35 b := *alice
36 b.User = store.User{ID: bobID, Username: "bob"}
37 b.Stdout, b.Stderr = &bytes.Buffer{}, &bytes.Buffer{}
38 return alice, &b, repo, commentID
39}
40
41func showReactIssue(t *testing.T, c *Ctx, repo store.Repo) IssueShow {
42 t.Helper()
43 var out bytes.Buffer
44 c.Stdout, c.JSON = &out, true
45 defer func() { c.JSON = false }()
46 if code := runIssueShow(c, []string{repo.Path(), "1"}); code != 0 {
47 t.Fatalf("show: %d", code)
48 }
49 var env struct{ Data IssueShow }
50 if err := json.Unmarshal(out.Bytes(), &env); err != nil {
51 t.Fatal(err)
52 }
53 return env.Data
54}
55
56func TestIssueReact(t *testing.T) {
57 alice, bob, repo, cid := reactFixture(t, "public")
58 p := repo.Path()
59 cmt := itoa(cid)
60
61 for i := 0; i < 2; i++ { // twice is a no-op
62 if code := runReact(bob, []string{p, "1", "+1"}, "issue"); code != 0 {
63 t.Fatalf("react: %d", code)
64 }
65 }
66 if code := runReact(alice, []string{p, "1", "👍"}, "issue"); code != 0 { // the emoji itself
67 t.Fatalf("react emoji: %d", code)
68 }
69 if code := runReact(bob, []string{p, "1", "--comment", cmt, "hooray"}, "issue"); code != 0 {
70 t.Fatalf("react comment: %d", code)
71 }
72
73 d := showReactIssue(t, bob, repo)
74 if len(d.Reactions) != 1 || d.Reactions[0] != (ReactionOut{"+1", 2, true}) {
75 t.Errorf("issue reactions as bob: %+v", d.Reactions)
76 }
77 if len(d.Comments) != 1 || d.Comments[0].ID != cid ||
78 len(d.Comments[0].Reactions) != 1 || d.Comments[0].Reactions[0] != (ReactionOut{"hooray", 1, true}) {
79 t.Errorf("comment reactions: %+v", d.Comments)
80 }
81 if d := showReactIssue(t, alice, repo); d.Comments[0].Reactions[0].Me || !d.Reactions[0].Me {
82 t.Errorf("me flags as alice: %+v", d)
83 }
84
85 for i := 0; i < 2; i++ { // removing twice is a no-op
86 if code := runReact(bob, []string{p, "1", "--remove", "+1"}, "issue"); code != 0 {
87 t.Fatalf("remove: %d", code)
88 }
89 }
90 if d := showReactIssue(t, alice, repo); len(d.Reactions) != 1 || d.Reactions[0].Count != 1 {
91 t.Errorf("after remove: %+v", d.Reactions)
92 }
93 if code := runReact(bob, []string{p, "1", "--remove", "rocket"}, "issue"); code != 0 {
94 t.Errorf("removing an absent reaction: %d", code)
95 }
96
97 // Reacting files nothing.
98 var n int
99 alice.Store.DB.QueryRow("SELECT COUNT(*) FROM events WHERE kind LIKE '%react%'").Scan(&n)
100 if n != 0 {
101 t.Errorf("%d events for reactions", n)
102 }
103 rows, _ := alice.Store.Inbox(alice.User.ID, false, 20, 0)
104 if len(rows) != 0 {
105 t.Errorf("alice notified: %+v", rows)
106 }
107}
108
109func TestIssueReactRefusals(t *testing.T) {
110 _, bob, repo, cid := reactFixture(t, "public")
111 p := repo.Path()
112 for name, tc := range map[string]struct {
113 args []string
114 want int
115 }{
116 "unknown reaction": {[]string{p, "1", "thumbsup"}, protocol.ExitUsage},
117 "missing reaction": {[]string{p, "1"}, protocol.ExitUsage},
118 "unknown comment": {[]string{p, "1", "--comment", "999", "+1"}, protocol.ExitNotFound},
119 "bad comment id": {[]string{p, "1", "--comment", "x", "+1"}, protocol.ExitUsage},
120 "unknown issue": {[]string{p, "9", "+1"}, protocol.ExitNotFound},
121 "unknown repository": {[]string{"alice/nope", "1", "+1"}, protocol.ExitNotFound},
122 } {
123 if code := runReact(bob, tc.args, "issue"); code != tc.want {
124 t.Errorf("%s: exit %d, want %d", name, code, tc.want)
125 }
126 }
127 _ = cid
128}
129
130func TestIssueReactPrivateIsNotFound(t *testing.T) {
131 _, bob, repo, cid := reactFixture(t, "private")
132 for _, args := range [][]string{
133 {repo.Path(), "1", "+1"},
134 {repo.Path(), "1", "--comment", itoa(cid), "+1"},
135 } {
136 if code := runReact(bob, args, "issue"); code != protocol.ExitNotFound {
137 t.Errorf("%v: exit %d, want %d", args, code, protocol.ExitNotFound)
138 }
139 }
140 if code := runIssueShow(bob, []string{repo.Path(), "1"}); code != protocol.ExitNotFound {
141 t.Errorf("show: exit %d", code)
142 }
143}
144
145func TestReactRefusedOnArchived(t *testing.T) {
146 alice, bob, repo, _ := reactFixture(t, "public")
147 if _, err := alice.Store.UpdateRepoSettings(repo.ID, func(s *store.RepoSettings) { s.Archived = true }); err != nil {
148 t.Fatal(err)
149 }
150 if code := runReact(bob, []string{repo.Path(), "1", "+1"}, "issue"); code != protocol.ExitDenied {
151 t.Errorf("exit %d, want %d", code, protocol.ExitDenied)
152 }
153}
154
155func TestReactOnSystemCommentRefused(t *testing.T) {
156 alice, _, repo, _ := reactFixture(t, "public")
157 iss, _ := alice.Store.IssueByNumber(repo.ID, 1)
158 alice.Store.AddIssueSystemComment(iss.ID, alice.User.ID, "closed")
159 cs, _ := alice.Store.ListIssueComments(iss.ID)
160 if code := runReact(alice, []string{repo.Path(), "1", "--comment", itoa(cs[1].ID), "+1"}, "issue"); code != protocol.ExitNotFound {
161 t.Errorf("exit %d", code)
162 }
163}
164
165func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
166
167func TestMRReact(t *testing.T) {
168 alice, bob, repo, _ := reactFixture(t, "public")
169 if _, err := alice.Store.CreateMR(repo.ID, alice.User.ID, repo.ID, "f", "main", "t", "", "abc", "md", false); err != nil {
170 t.Fatal(err)
171 }
172 mr, _ := alice.Store.MRByNumber(repo.ID, 1)
173 if err := alice.Store.AddMRComment(mr.ID, alice.User.ID, "hi", "md"); err != nil {
174 t.Fatal(err)
175 }
176 cs, _ := alice.Store.ListMRComments(mr.ID)
177 p := repo.Path()
178 for _, args := range [][]string{{p, "1", "rocket"}, {p, "1", "--comment", itoa(cs[0].ID), "eyes"}} {
179 if code := runReact(bob, args, "mr"); code != 0 {
180 t.Fatalf("%v: %d", args, code)
181 }
182 }
183 if code := runReact(bob, []string{p, "1", "--comment", "999", "eyes"}, "mr"); code != protocol.ExitNotFound {
184 t.Errorf("unknown comment: %d", code)
185 }
186 got, _ := alice.Store.ReactionCounts("mr", mr.ID, bob.User.ID)
187 if len(got[0]) != 1 || got[0][0].Reaction != "rocket" || !got[0][0].Me || got[cs[0].ID][0].Reaction != "eyes" {
188 t.Errorf("counts: %+v", got)
189 }
190}
191
192// A comment id that belongs to another thread, or to a repository the
193// caller cannot read, is not found under a readable repository and
194// issue or merge request, and nothing is stored.
195func TestReactCommentFromElsewhereNotFound(t *testing.T) {
196 alice, bob, repo, _ := reactFixture(t, "public")
197 st := alice.Store
198
199 // Another issue and MR in the same repository, each with a comment.
200 if code := runIssueCreate(alice, []string{repo.Path(), "--title", "two"}); code != 0 {
201 t.Fatal(code)
202 }
203 if code := runIssueComment(alice, []string{repo.Path(), "2", "--message", "other"}); code != 0 {
204 t.Fatal(code)
205 }
206 iss2, _ := st.IssueByNumber(repo.ID, 2)
207 ic2, _ := st.ListIssueComments(iss2.ID)
208 for i := 0; i < 2; i++ {
209 if _, err := st.CreateMR(repo.ID, alice.User.ID, repo.ID, "f", "main", "t", "", "abc", "md", false); err != nil {
210 t.Fatal(err)
211 }
212 }
213 mr1, _ := st.MRByNumber(repo.ID, 1)
214 mr2, _ := st.MRByNumber(repo.ID, 2)
215 st.AddMRComment(mr1.ID, alice.User.ID, "one", "md")
216 st.AddMRComment(mr2.ID, alice.User.ID, "two", "md")
217 mc1, _ := st.ListMRComments(mr1.ID)
218 mc2, _ := st.ListMRComments(mr2.ID)
219
220 // Another user's private repository, with an issue comment and an MR comment.
221 carolID, _ := st.CreateUser("carol", false)
222 pid, err := st.CreateRepo("user", carolID, "secret", "private")
223 if err != nil {
224 t.Fatal(err)
225 }
226 priv, _ := st.RepoByID(pid)
227 carol := *alice
228 carol.User = store.User{ID: carolID, Username: "carol"}
229 carol.Stdout, carol.Stderr = &bytes.Buffer{}, &bytes.Buffer{}
230 if code := runIssueCreate(&carol, []string{priv.Path(), "--title", "s"}); code != 0 {
231 t.Fatal(code)
232 }
233 if code := runIssueComment(&carol, []string{priv.Path(), "1", "--message", "s"}); code != 0 {
234 t.Fatal(code)
235 }
236 pi, _ := st.IssueByNumber(priv.ID, 1)
237 pic, _ := st.ListIssueComments(pi.ID)
238 if _, err := st.CreateMR(priv.ID, carolID, priv.ID, "f", "main", "t", "", "abc", "md", false); err != nil {
239 t.Fatal(err)
240 }
241 pm, _ := st.MRByNumber(priv.ID, 1)
242 st.AddMRComment(pm.ID, carolID, "s", "md")
243 pmc, _ := st.ListMRComments(pm.ID)
244
245 p := repo.Path()
246 cases := []struct {
247 noun string
248 n string
249 id int64
250 }{
251 {"issue", "1", ic2[0].ID},
252 {"issue", "1", pic[0].ID},
253 {"mr", "1", mc2[0].ID},
254 {"mr", "1", pmc[0].ID},
255 }
256 _ = mc1
257 for _, tc := range cases {
258 if code := runReact(bob, []string{p, tc.n, "--comment", itoa(tc.id), "+1"}, tc.noun); code != protocol.ExitNotFound {
259 t.Errorf("%s comment %d: exit %d, want %d", tc.noun, tc.id, code, protocol.ExitNotFound)
260 }
261 }
262 for _, table := range []string{"issue_reactions", "mr_reactions"} {
263 var n int
264 st.DB.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n)
265 if n != 0 {
266 t.Errorf("%s holds %d rows", table, n)
267 }
268 }
269}
internal/control/thread.go +7 −5
@@ -50,11 +50,13 @@ func authorOrWrite(c *Ctx, repo store.Repo, author, what string) int {
50 50
51// commentOut is one comment as show emits it, for both nouns. 51// commentOut is one comment as show emits it, for both nouns.
52type commentOut struct { 52type commentOut struct {
53 Author string `json:"author"` 53 ID int64 `json:"id"`
54 Body string `json:"body"` 54 Author string `json:"author"`
55 BodyFormat string `json:"body_format,omitempty"` 55 Body string `json:"body"`
56 CreatedAt string `json:"created_at"` 56 BodyFormat string `json:"body_format,omitempty"`
57 Kind string `json:"-"` // "comment" or "system"; not part of the wire shape 57 CreatedAt string `json:"created_at"`
58 Kind string `json:"-"` // "comment" or "system"; not part of the wire shape
59 Reactions []ReactionOut `json:"reactions,omitempty"`
58} 60}
59 61
60// thread is what a comment command needs to know about its noun. 62// thread is what a comment command needs to know about its noun.
internal/control/view.go +21 −2
@@ -1,9 +1,11 @@
1package control 1package control
2 2
3import ( 3import (
4 "fmt"
4 "io" 5 "io"
5 "strings" 6 "strings"
6 7
8 "gitbay.org/gitbay/internal/store"
7 "gitbay.org/gitbay/internal/termtext" 9 "gitbay.org/gitbay/internal/termtext"
8) 10)
9 11
@@ -183,8 +185,8 @@ func (v *view) event(text, format, ts string) {
183 v.wrote = true 185 v.wrote = true
184} 186}
185 187
186func (v *view) comment(author, ts, body, format string) { 188func (v *view) comment(id int64, author, ts, body, format string) {
187 when := v.c.Term.safe(v.c.when(ts)) 189 when := v.c.Term.safe(v.c.when(ts)) + fmt.Sprintf(" (comment %d)", id)
188 author = v.c.Term.safe(author) 190 author = v.c.Term.safe(author)
189 cols := v.c.Term.Cols 191 cols := v.c.Term.Cols
190 if cols > 0 { 192 if cols > 0 {
@@ -199,3 +201,20 @@ func (v *view) comment(author, ts, body, format string) {
199 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n") 201 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
200 v.body(body, format) 202 v.body(body, format)
201} 203}
204
205// reactions prints one line of counts under an item, the caller's own
206// marked "(you)". Nothing when nobody has reacted.
207func (v *view) reactions(rs []ReactionOut) {
208 if len(rs) == 0 {
209 return
210 }
211 var parts []string
212 for _, r := range rs {
213 p := fmt.Sprintf("%s %d", store.ReactionEmoji(r.Reaction), r.Count)
214 if r.Me {
215 p += " (you)"
216 }
217 parts = append(parts, p)
218 }
219 io.WriteString(v.w, "reactions: "+strings.Join(parts, ", ")+"\n")
220}
internal/httpd/issuecreate_test.go +2 −1
@@ -31,7 +31,8 @@ func TestIssuePageHasDiscussionHeading(t *testing.T) {
31 Notice string 31 Notice string
32 LabelColors map[string]template.CSS 32 LabelColors map[string]template.CSS
33 Draft *draft 33 Draft *draft
34 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil { 34 Reactions map[int64]reactionBar
35 }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}, Reactions: map[int64]reactionBar{0: {}}}); err != nil {
35 t.Fatalf("render: %v", err) 36 t.Fatalf("render: %v", err)
36 } 37 }
37 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") { 38 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
internal/httpd/mrpage_test.go +1
@@ -40,6 +40,7 @@ type mrPageData struct {
40 Base string 40 Base string
41 Draft *draft 41 Draft *draft
42 Layout diffLayout 42 Layout diffLayout
43 Reactions map[int64]reactionBar
43} 44}
44 45
45// A pruned head has no diff to show; the page must say the head is gone 46// A pruned head has no diff to show; the page must say the head is gone
internal/httpd/reactions.go added +84
@@ -0,0 +1,84 @@
1package httpd
2
3import (
4 "net/http"
5 "strings"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// reactionView is one reaction button (or count) under an item.
11type reactionView struct {
12 Name string
13 Emoji string
14 Count int
15 Me bool
16 Label string
17}
18
19// reactionBar is what the reactions partial renders for one item:
20// Comment is 0 for the issue or merge request body.
21type reactionBar struct {
22 Action string
23 Comment int64
24 Signed bool
25 Items []reactionView
26}
27
28// reactionBars builds the bars for a thread body (key 0) and its
29// comments. A signed-in viewer is offered every reaction; anyone else
30// sees only the ones somebody gave.
31func (s *Server) reactionBars(r *http.Request, noun string, threadID int64, comments []store.IssueComment, action string) map[int64]reactionBar {
32 viewer := s.webViewer(r)
33 counts, _ := s.st.ReactionCounts(noun, threadID, viewer.ID)
34 bar := func(id int64) reactionBar {
35 b := reactionBar{Action: action, Comment: id, Signed: viewer.ID != 0}
36 given := map[string]store.ReactionCount{}
37 for _, rc := range counts[id] {
38 given[rc.Reaction] = rc
39 }
40 for _, r := range store.Reactions {
41 rc := given[r.Name]
42 if rc.Count == 0 && !b.Signed {
43 continue
44 }
45 label := r.Name
46 if rc.Me {
47 label = "remove " + r.Name
48 }
49 b.Items = append(b.Items, reactionView{r.Name, r.Emoji, rc.Count, rc.Me, label})
50 }
51 return b
52 }
53 out := map[int64]reactionBar{0: bar(0)}
54 for _, c := range comments {
55 if c.Kind != "system" {
56 out[c.ID] = bar(c.ID)
57 }
58 }
59 return out
60}
61
62// reactArgs turns the form into the react command's arguments. The
63// button pressed is named add or remove and carries the reaction.
64func reactArgs(r *http.Request, noun string) []string {
65 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
66 args := []string{noun, "react", repo, r.PathValue("n")}
67 if c := strings.TrimSpace(r.FormValue("comment")); c != "" {
68 args = append(args, "--comment", c)
69 }
70 if v := r.FormValue("remove"); v != "" {
71 return append(args, "--remove", v)
72 }
73 return append(args, r.FormValue("add"))
74}
75
76func (s *Server) issueReactSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
77 _, msg, code := s.runControlCode(u, reactArgs(r, "issue"))
78 s.done(w, r, code, msg, s.issueRedirect)
79}
80
81func (s *Server) mrReactSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
82 _, msg, code := s.runControlCode(u, reactArgs(r, "mr"))
83 s.done(w, r, code, msg, s.mrRedirect)
84}
internal/httpd/reactions_test.go added +106
@@ -0,0 +1,106 @@
1package httpd
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/store"
12)
13
14func reactServer(t *testing.T) (*Server, *store.Store, store.User, store.User) {
15 t.Helper()
16 st, err := store.Open(":memory:")
17 if err != nil {
18 t.Fatal(err)
19 }
20 t.Cleanup(func() { st.Close() })
21 if err := st.MigrateUp(); err != nil {
22 t.Fatal(err)
23 }
24 aid, _ := st.CreateUser("alice", false)
25 bid, _ := st.CreateUser("bob", false)
26 rid, err := st.CreateRepo("user", aid, "app", "public")
27 if err != nil {
28 t.Fatal(err)
29 }
30 n, _ := st.CreateIssue(rid, aid, "bug", "body", "md")
31 iss, _ := st.IssueByNumber(rid, n)
32 st.AddIssueComment(iss.ID, aid, "a comment", "md")
33 cfg := config.Default()
34 cfg.Web.Mode = "accounts"
35 return New(cfg, st, nil), st, store.User{ID: aid, Username: "alice"}, store.User{ID: bid, Username: "bob"}
36}
37
38func issueReq(method, target string, form url.Values) *http.Request {
39 var r *http.Request
40 if form != nil {
41 r = httptest.NewRequest(method, target, strings.NewReader(form.Encode()))
42 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
43 } else {
44 r = httptest.NewRequest(method, target, nil)
45 }
46 r.SetPathValue("owner", "alice")
47 r.SetPathValue("repo", "app")
48 r.SetPathValue("n", "1")
49 return r
50}
51
52func TestIssueReactionsOnPage(t *testing.T) {
53 s, st, _, bob := reactServer(t)
54
55 // Bob reacts to the body and to the comment through the form.
56 for _, form := range []url.Values{
57 {"add": {"+1"}},
58 {"add": {"rocket"}, "comment": {"1"}},
59 } {
60 rr := httptest.NewRecorder()
61 s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", form), bob)
62 if rr.Code != http.StatusSeeOther {
63 t.Fatalf("%v: status %d: %s", form, rr.Code, rr.Body)
64 }
65 }
66
67 // Signed in: a button for every reaction, his own marked pressed.
68 req := issueReq("GET", "/alice/app/issues/1", nil)
69 req.AddCookie(sessionCookieFor(t, s, st, bob.ID))
70 rr := httptest.NewRecorder()
71 s.issue(rr, req)
72 body := rr.Body.String()
73 if n := strings.Count(body, `class="react"`) + strings.Count(body, `class="react mine"`); n != 16 {
74 t.Errorf("%d reaction buttons, want 16 (8 on the body, 8 on the comment)", n)
75 }
76 if !strings.Contains(body, `name="remove" value="&#43;1" class="react mine" aria-pressed="true"`) {
77 t.Errorf("own reaction not marked:\n%s", body)
78 }
79 if !strings.Contains(body, `name="add" value="eyes"`) || !strings.Contains(body, `<input type="hidden" name="comment" value="1">`) {
80 t.Error("add buttons or comment field missing")
81 }
82
83 // Signed out: counts only, no forms or buttons.
84 rr = httptest.NewRecorder()
85 s.issue(rr, issueReq("GET", "/alice/app/issues/1", nil))
86 body = rr.Body.String()
87 if strings.Contains(body, `class="react"`) && strings.Contains(body, "<button type=\"submit\" name=\"add\"") {
88 t.Error("buttons shown to a signed-out viewer")
89 }
90 if strings.Contains(body, `/react"`) {
91 t.Error("react form shown to a signed-out viewer")
92 }
93 if n := strings.Count(body, `<span class="react"`); n != 2 {
94 t.Errorf("%d counts, want 2:\n%s", n, body)
95 }
96 if !strings.Contains(body, "👍 1") || !strings.Contains(body, "🚀 1") {
97 t.Error("counts missing")
98 }
99
100 // Pressing a pressed button takes it back.
101 rr = httptest.NewRecorder()
102 s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", url.Values{"remove": {"+1"}}), bob)
103 if got, _ := st.ReactionCounts("issue", 1, bob.ID); len(got[0]) != 0 {
104 t.Errorf("reaction not removed: %+v", got)
105 }
106}
internal/httpd/routes.go +4
@@ -160,6 +160,8 @@ func (s *Server) Routes() []Route {
160 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, 160 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
161 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, 161 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
162 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, 162 Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))},
163 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/react", Mutating: true,
164 Handler: s.checkOrigin(s.requireUser(s.issueReactSubmit))},
163 // Triage: each runs the matching issue command. 165 // Triage: each runs the matching issue command.
164 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true, 166 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true,
165 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))}, 167 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))},
@@ -206,6 +208,8 @@ func (s *Server) Routes() []Route {
206 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, 208 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))},
207 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, 209 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
208 Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, 210 Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))},
211 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/react", Mutating: true,
212 Handler: s.checkOrigin(s.requireUser(s.mrReactSubmit))},
209 // Review loop: each runs the matching mr command. 213 // Review loop: each runs the matching mr command.
210 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true, 214 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true,
211 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, 215 Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))},
internal/httpd/web.go +8 −3
@@ -1324,6 +1324,7 @@ func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1324 1324
1325// renderedComment pairs a comment with its rendered body for templates. 1325// renderedComment pairs a comment with its rendered body for templates.
1326type renderedComment struct { 1326type renderedComment struct {
1327 ID int64
1327 Author string 1328 Author string
1328 CreatedAt string 1329 CreatedAt string
1329 Kind string 1330 Kind string
@@ -1333,7 +1334,7 @@ type renderedComment struct {
1333func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment { 1334func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1334 var out []renderedComment 1335 var out []renderedComment
1335 for _, c := range cs { 1336 for _, c := range cs {
1336 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)}) 1337 out = append(out, renderedComment{ID: c.ID, Author: c.Author, CreatedAt: c.CreatedAt, Kind: c.Kind, BodyHTML: ugc(c.Body, c.BodyFormat)})
1337 } 1338 }
1338 return out 1339 return out
1339} 1340}
@@ -2024,6 +2025,7 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
2024 } 2025 }
2025 // nil readable: the picker lists titles, never the progress counts. 2026 // nil readable: the picker lists titles, never the progress counts.
2026 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) 2027 milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
2028 bars := s.reactionBars(r, "issue", iss.ID, comments, fmt.Sprintf("/%s/%s/issues/%d/react", p.Repo.OwnerName, p.Repo.Name, iss.Number))
2027 s.render(w, "issue.html", struct { 2029 s.render(w, "issue.html", struct {
2028 repoPage 2030 repoPage
2029 Issue store.Issue 2031 Issue store.Issue
@@ -2035,9 +2037,10 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
2035 Notice string 2037 Notice string
2036 LabelColors map[string]template.CSS 2038 LabelColors map[string]template.CSS
2037 Draft *draft 2039 Draft *draft
2040 Reactions map[int64]reactionBar
2038 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), 2041 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
2039 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), 2042 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
2040 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d}) 2043 milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d, bars})
2041} 2044}
2042 2045
2043// canEditItem: the author or anyone with write access may edit. 2046// canEditItem: the author or anyone with write access may edit.
@@ -2328,6 +2331,7 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
2328 } 2331 }
2329 d = s.draftFor(r, p.Repo, previewForm, "body", format) 2332 d = s.draftFor(r, p.Repo, previewForm, "body", format)
2330 } 2333 }
2334 bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number))
2331 s.render(w, "mr.html", struct { 2335 s.render(w, "mr.html", struct {
2332 repoPage 2336 repoPage
2333 MR store.MR 2337 MR store.MR
@@ -2360,10 +2364,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
2360 LabelColors map[string]template.CSS 2364 LabelColors map[string]template.CSS
2361 Draft *draft 2365 Draft *draft
2362 Layout diffLayout 2366 Layout diffLayout
2367 Reactions map[int64]reactionBar
2363 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 2368 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2364 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 2369 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2365 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, 2370 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2366 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout}) 2371 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout, bars})
2367} 2372}
2368 2373
2369// sourceGone reports whether an MR's source branch no longer exists: the 2374// sourceGone reports whether an MR's source branch no longer exists: the
internal/store/issues.go +3 −2
@@ -23,6 +23,7 @@ type Issue struct {
23} 23}
24 24
25type IssueComment struct { 25type IssueComment struct {
26 ID int64
26 Author string 27 Author string
27 Body string 28 Body string
28 BodyFormat string // md | org 29 BodyFormat string // md | org
@@ -240,7 +241,7 @@ func (s *Store) AddIssueComment(issueID, authorID int64, body, format string) er
240 241
241func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { 242func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
242 rows, err := s.DB.Query(` 243 rows, err := s.DB.Query(`
243 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, 244 SELECT c.id, CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
244 c.body, c.body_format, c.created_at, c.kind 245 c.body, c.body_format, c.created_at, c.kind
245 FROM issue_comments c JOIN users u ON u.id = c.author_id 246 FROM issue_comments c JOIN users u ON u.id = c.author_id
246 WHERE c.issue_id = ? ORDER BY c.id`, issueID) 247 WHERE c.issue_id = ? ORDER BY c.id`, issueID)
@@ -251,7 +252,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
251 var out []IssueComment 252 var out []IssueComment
252 for rows.Next() { 253 for rows.Next() {
253 var c IssueComment 254 var c IssueComment
254 if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { 255 if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
255 return nil, err 256 return nil, err
256 } 257 }
257 out = append(out, c) 258 out = append(out, c)
internal/store/migrations/0070_reactions.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE mr_reactions;
2DROP TABLE issue_reactions;
internal/store/migrations/0070_reactions.up.sql added +30
@@ -0,0 +1,30 @@
1-- Reactions on issues, merge requests and their conversation comments
2-- (#291). comment_id NULL is the reaction on the thread's own body. The
3-- unique indexes are partial because NULLs never collide in a plain one.
4CREATE TABLE issue_reactions (
5 id INTEGER PRIMARY KEY,
6 issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
7 comment_id INTEGER REFERENCES issue_comments(id) ON DELETE CASCADE,
8 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
9 reaction TEXT NOT NULL
10 CHECK (reaction IN ('+1','-1','laugh','hooray','confused','heart','rocket','eyes'))
11);
12CREATE UNIQUE INDEX issue_reactions_body ON issue_reactions(issue_id, user_id, reaction)
13 WHERE comment_id IS NULL;
14CREATE UNIQUE INDEX issue_reactions_comment ON issue_reactions(comment_id, user_id, reaction)
15 WHERE comment_id IS NOT NULL;
16CREATE INDEX issue_reactions_issue ON issue_reactions(issue_id);
17
18CREATE TABLE mr_reactions (
19 id INTEGER PRIMARY KEY,
20 mr_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
21 comment_id INTEGER REFERENCES mr_comments(id) ON DELETE CASCADE,
22 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
23 reaction TEXT NOT NULL
24 CHECK (reaction IN ('+1','-1','laugh','hooray','confused','heart','rocket','eyes'))
25);
26CREATE UNIQUE INDEX mr_reactions_body ON mr_reactions(mr_id, user_id, reaction)
27 WHERE comment_id IS NULL;
28CREATE UNIQUE INDEX mr_reactions_comment ON mr_reactions(comment_id, user_id, reaction)
29 WHERE comment_id IS NOT NULL;
30CREATE INDEX mr_reactions_mr ON mr_reactions(mr_id);
internal/store/mrs.go +2 −2
@@ -507,7 +507,7 @@ func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
507 507
508func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { 508func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
509 rows, err := s.DB.Query(` 509 rows, err := s.DB.Query(`
510 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, 510 SELECT c.id, CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
511 c.body, c.body_format, c.created_at, c.kind 511 c.body, c.body_format, c.created_at, c.kind
512 FROM mr_comments c JOIN users u ON u.id = c.author_id 512 FROM mr_comments c JOIN users u ON u.id = c.author_id
513 WHERE c.mr_id = ? ORDER BY c.id`, mrID) 513 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
@@ -518,7 +518,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
518 var out []IssueComment 518 var out []IssueComment
519 for rows.Next() { 519 for rows.Next() {
520 var c IssueComment 520 var c IssueComment
521 if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { 521 if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
522 return nil, err 522 return nil, err
523 } 523 }
524 out = append(out, c) 524 out = append(out, c)
internal/store/reactions.go added +148
@@ -0,0 +1,148 @@
1package store
2
3import "fmt"
4
5// Reaction is one entry of the fixed reaction set (#291).
6type Reaction struct {
7 Name string // what is stored and what the commands take
8 Emoji string // what surfaces render
9}
10
11// Reactions is the whole set, in display order.
12var Reactions = []Reaction{
13 {"+1", "👍"}, {"-1", "👎"}, {"laugh", "😄"}, {"hooray", "🎉"},
14 {"confused", "😕"}, {"heart", "❤️"}, {"rocket", "🚀"}, {"eyes", "👀"},
15}
16
17// ParseReaction maps a name or its emoji to the stored name.
18func ParseReaction(v string) (string, bool) {
19 for _, r := range Reactions {
20 if v == r.Name || v == r.Emoji {
21 return r.Name, true
22 }
23 }
24 if v == "❤" { // heart without the emoji variation selector
25 return "heart", true
26 }
27 return "", false
28}
29
30// ReactionEmoji is the emoji for a stored name.
31func ReactionEmoji(name string) string {
32 for _, r := range Reactions {
33 if r.Name == name {
34 return r.Emoji
35 }
36 }
37 return name
38}
39
40// ReactionCount is how many people gave one reaction to one item, and
41// whether the viewer is among them.
42type ReactionCount struct {
43 Reaction string
44 Count int
45 Me bool
46}
47
48// reactionTables names the tables for a noun, "issue" or "mr".
49func reactionTables(noun string) (reactions, threadCol, comments string, err error) {
50 switch noun {
51 case "issue":
52 return "issue_reactions", "issue_id", "issue_comments", nil
53 case "mr":
54 return "mr_reactions", "mr_id", "mr_comments", nil
55 }
56 return "", "", "", fmt.Errorf("no reactions on %q", noun)
57}
58
59// ReactionTarget checks that commentID is a conversation comment of the
60// thread and not a system entry. commentID 0 is the thread itself.
61func (s *Store) ReactionTarget(noun string, threadID, commentID int64) error {
62 if commentID == 0 {
63 return nil
64 }
65 _, threadCol, comments, err := reactionTables(noun)
66 if err != nil {
67 return err
68 }
69 var n int
70 if err := s.DB.QueryRow("SELECT COUNT(*) FROM "+comments+
71 " WHERE id = ? AND "+threadCol+" = ? AND kind != 'system'",
72 commentID, threadID).Scan(&n); err != nil {
73 return err
74 }
75 if n == 0 {
76 return ErrNotFound
77 }
78 return nil
79}
80
81// SetReaction adds or removes one user's reaction. Doing either twice is
82// not an error. commentID 0 targets the thread's own body.
83func (s *Store) SetReaction(noun string, threadID, commentID, userID int64, reaction string, on bool) error {
84 table, threadCol, _, err := reactionTables(noun)
85 if err != nil {
86 return err
87 }
88 var cid any
89 cond := "comment_id IS NULL"
90 if commentID != 0 {
91 cid, cond = commentID, "comment_id = ?"
92 }
93 if !on {
94 args := []any{threadID, userID, reaction}
95 if commentID != 0 {
96 args = append(args, cid)
97 }
98 _, err := s.DB.Exec("DELETE FROM "+table+" WHERE "+threadCol+" = ? AND user_id = ? AND reaction = ? AND "+cond, args...)
99 return err
100 }
101 _, err = s.DB.Exec("INSERT INTO "+table+" ("+threadCol+", comment_id, user_id, reaction) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING",
102 threadID, cid, userID, reaction)
103 return err
104}
105
106// ReactionCounts returns the counts for a thread and its comments, keyed
107// by comment id, with 0 for the thread itself. Each list is in the
108// order of Reactions and holds only reactions somebody gave. viewerID 0
109// marks none as the viewer's.
110func (s *Store) ReactionCounts(noun string, threadID, viewerID int64) (map[int64][]ReactionCount, error) {
111 table, threadCol, _, err := reactionTables(noun)
112 if err != nil {
113 return nil, err
114 }
115 rows, err := s.DB.Query(`SELECT COALESCE(comment_id, 0), reaction, COUNT(*),
116 COALESCE(MAX(user_id = ?), 0) FROM `+table+` WHERE `+threadCol+` = ?
117 GROUP BY comment_id, reaction`, viewerID, threadID)
118 if err != nil {
119 return nil, err
120 }
121 defer rows.Close()
122 by := map[int64]map[string]ReactionCount{}
123 for rows.Next() {
124 var cid int64
125 var rc ReactionCount
126 var me int
127 if err := rows.Scan(&cid, &rc.Reaction, &rc.Count, &me); err != nil {
128 return nil, err
129 }
130 rc.Me = me != 0 && viewerID != 0
131 if by[cid] == nil {
132 by[cid] = map[string]ReactionCount{}
133 }
134 by[cid][rc.Reaction] = rc
135 }
136 if err := rows.Err(); err != nil {
137 return nil, err
138 }
139 out := map[int64][]ReactionCount{}
140 for cid, m := range by {
141 for _, r := range Reactions {
142 if rc, ok := m[r.Name]; ok {
143 out[cid] = append(out[cid], rc)
144 }
145 }
146 }
147 return out, nil
148}
internal/store/reactions_test.go added +112
@@ -0,0 +1,112 @@
1package store
2
3import (
4 "errors"
5 "testing"
6)
7
8func TestReactions(t *testing.T) {
9 s, repoID, uid := mrFixture(t)
10 bob, err := s.CreateUser("bob", false)
11 if err != nil {
12 t.Fatal(err)
13 }
14 num, err := s.CreateIssue(repoID, uid, "t", "b", "md")
15 if err != nil {
16 t.Fatal(err)
17 }
18 iss, err := s.IssueByNumber(repoID, num)
19 if err != nil {
20 t.Fatal(err)
21 }
22 if err := s.AddIssueComment(iss.ID, uid, "c", "md"); err != nil {
23 t.Fatal(err)
24 }
25 if err := s.AddIssueSystemComment(iss.ID, uid, "sys"); err != nil {
26 t.Fatal(err)
27 }
28 cs, _ := s.ListIssueComments(iss.ID)
29 if len(cs) != 2 || cs[0].ID == 0 {
30 t.Fatalf("comments: %+v", cs)
31 }
32 cid, sysID := cs[0].ID, cs[1].ID
33
34 if err := s.ReactionTarget("issue", iss.ID, cid); err != nil {
35 t.Fatal(err)
36 }
37 if err := s.ReactionTarget("issue", iss.ID, sysID); !errors.Is(err, ErrNotFound) {
38 t.Fatalf("system comment: %v", err)
39 }
40 if err := s.ReactionTarget("issue", iss.ID+1, cid); !errors.Is(err, ErrNotFound) {
41 t.Fatalf("comment of another thread: %v", err)
42 }
43
44 for i := 0; i < 2; i++ { // idempotent
45 if err := s.SetReaction("issue", iss.ID, 0, uid, "+1", true); err != nil {
46 t.Fatal(err)
47 }
48 if err := s.SetReaction("issue", iss.ID, cid, uid, "+1", true); err != nil {
49 t.Fatal(err)
50 }
51 }
52 s.SetReaction("issue", iss.ID, 0, bob, "+1", true)
53 s.SetReaction("issue", iss.ID, 0, bob, "eyes", true)
54 got, err := s.ReactionCounts("issue", iss.ID, uid)
55 if err != nil {
56 t.Fatal(err)
57 }
58 if b := got[0]; len(b) != 2 || b[0] != (ReactionCount{"+1", 2, true}) || b[1] != (ReactionCount{"eyes", 1, false}) {
59 t.Errorf("body: %+v", b)
60 }
61 if c := got[cid]; len(c) != 1 || c[0] != (ReactionCount{"+1", 1, true}) {
62 t.Errorf("comment: %+v", c)
63 }
64 anon, _ := s.ReactionCounts("issue", iss.ID, 0)
65 if anon[0][0].Me {
66 t.Errorf("anonymous viewer marked: %+v", anon[0])
67 }
68
69 // Removing is scoped to the item and idempotent.
70 for i := 0; i < 2; i++ {
71 if err := s.SetReaction("issue", iss.ID, 0, uid, "+1", false); err != nil {
72 t.Fatal(err)
73 }
74 }
75 got, _ = s.ReactionCounts("issue", iss.ID, uid)
76 if got[0][0] != (ReactionCount{"+1", 1, false}) || len(got[cid]) != 1 {
77 t.Errorf("after remove: %+v", got)
78 }
79
80 // Deleting the issue takes its reactions with it.
81 if _, err := s.DB.Exec("DELETE FROM issues WHERE id = ?", iss.ID); err != nil {
82 t.Fatal(err)
83 }
84 var n int
85 s.DB.QueryRow("SELECT COUNT(*) FROM issue_reactions").Scan(&n)
86 if n != 0 {
87 t.Errorf("%d reactions left after issue delete", n)
88 }
89
90 mr, _ := s.MRByNumber(repoID, 1)
91 if err := s.SetReaction("mr", mr.ID, 0, uid, "rocket", true); err != nil {
92 t.Fatal(err)
93 }
94 m, _ := s.ReactionCounts("mr", mr.ID, uid)
95 if len(m[0]) != 1 || m[0][0].Reaction != "rocket" {
96 t.Errorf("mr: %+v", m)
97 }
98 if err := s.SetReaction("issue", 1, 0, uid, "bogus", true); err == nil {
99 t.Error("unknown reaction stored")
100 }
101}
102
103func TestParseReaction(t *testing.T) {
104 for in, want := range map[string]string{"+1": "+1", "👍": "+1", "❤️": "heart", "❤": "heart", "eyes": "eyes"} {
105 if got, ok := ParseReaction(in); !ok || got != want {
106 t.Errorf("%q: %q %v", in, got, ok)
107 }
108 }
109 if _, ok := ParseReaction("thumbsup"); ok {
110 t.Error("accepted an unknown name")
111 }
112}
internal/web/static/style.css +18
@@ -1917,3 +1917,21 @@ svg.icon { vertical-align: -0.125em; }
1917 /* the mark is a link home too, so it is a target like the rest */ 1917 /* the mark is a link home too, so it is a target like the rest */
1918 a.brand { width: var(--rail-hit); } 1918 a.brand { width: var(--rail-hit); }
1919} 1919}
1920
1921/* ---- reactions ---- */
1922.reactions { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0; padding: var(--sp-2) var(--sp-4); }
1923p.reactions { color: var(--muted); font-size: var(--fs-1); }
1924article.comment .reactions { border-top: 1px solid var(--faint); }
1925button.react, span.react {
1926 font: inherit;
1927 font-size: var(--fs-1);
1928 padding: 0 var(--sp-2);
1929 min-height: 24px;
1930 border: 1px solid var(--line);
1931 border-radius: var(--r-sm);
1932 background: transparent;
1933 color: var(--muted);
1934}
1935button.react { cursor: pointer; }
1936button.react:hover { background: var(--surface); }
1937button.react.mine { border-color: var(--accent); background: var(--surface); color: var(--fg); }
internal/web/templates/issue.html +3 −1
@@ -22,12 +22,14 @@
22{{if .BodyHTML}}<article class="comment"> 22{{if .BodyHTML}}<article class="comment">
23 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> 23 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
24 <div class="rendered">{{.BodyHTML}}</div> 24 <div class="rendered">{{.BodyHTML}}</div>
25</article>{{end}} 25 {{template "reactions" index .Reactions 0}}
26</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
26{{range .Comments}} 27{{range .Comments}}
27{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> 28{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
28{{else}}<article class="comment"> 29{{else}}<article class="comment">
29 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> 30 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
30 <div class="rendered">{{.BodyHTML}}</div> 31 <div class="rendered">{{.BodyHTML}}</div>
32 {{template "reactions" index $.Reactions .ID}}
31</article>{{end}} 33</article>{{end}}
32{{end}} 34{{end}}
33{{if .Viewer}} 35{{if .Viewer}}
internal/web/templates/layout.html +6
@@ -182,6 +182,12 @@
182{{/* previewbtn is the Preview submit beside a markup form's own submit. 182{{/* previewbtn is the Preview submit beside a markup form's own submit.
183 It posts the form to its usual action, which renders the body and 183 It posts the form to its usual action, which renders the body and
184 hands the page back instead of writing (#235). */}} 184 hands the page back instead of writing (#235). */}}
185{{/* reactions is one item's reaction bar (reactionBar in httpd). A
186 signed-in viewer gets a button per reaction; anyone else the counts. */}}
187{{define "reactions"}}{{if .Items}}{{if .Signed}}<form method="post" action="{{.Action}}" class="reactions">{{if .Comment}}<input type="hidden" name="comment" value="{{.Comment}}">{{end}}
188{{range .Items}}<button type="submit" name="{{if .Me}}remove{{else}}add{{end}}" value="{{.Name}}" class="react{{if .Me}} mine{{end}}" aria-pressed="{{.Me}}" aria-label="{{.Label}}">{{.Emoji}}{{if .Count}} <span>{{.Count}}</span>{{end}}</button>
189{{end}}</form>{{else}}<p class="reactions">{{range .Items}}<span class="react" aria-label="{{.Label}}">{{.Emoji}} {{.Count}}</span> {{end}}</p>{{end}}{{end}}{{end}}
190
185{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}} 191{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}}
186 192
187{{/* previewblock shows a draft as it will read. The argument is the 193{{/* previewblock shows a draft as it will read. The argument is the
internal/web/templates/mr.html +3 −1
@@ -37,12 +37,14 @@
37{{if .BodyHTML}}<article class="comment"> 37{{if .BodyHTML}}<article class="comment">
38 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> 38 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
39 <div class="rendered">{{.BodyHTML}}</div> 39 <div class="rendered">{{.BodyHTML}}</div>
40</article>{{end}} 40 {{template "reactions" index .Reactions 0}}
41</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
41{{range .Comments}} 42{{range .Comments}}
42{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> 43{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
43{{else}}<article class="comment"> 44{{else}}<article class="comment">
44 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> 45 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
45 <div class="rendered">{{.BodyHTML}}</div> 46 <div class="rendered">{{.BodyHTML}}</div>
47 {{template "reactions" index $.Reactions .ID}}
46</article>{{end}} 48</article>{{end}}
47{{end}} 49{{end}}
48{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> 50{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>