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.
3535| review and check times | yes | yes | yes |
3636| who resolved it, when | yes | yes | yes |
3737| comment | yes | yes | yes |
38| react to it or a comment | yes | yes | no |
3839| edit title and body | yes | yes | yes |
3940| review (approve etc.) | yes | yes | yes |
4041| resolve a thread | yes | yes | yes |
@@ -115,6 +116,7 @@ reviews, since an approval was of the diff against the old branch.
115116| search title and body | yes | yes | yes |
116117| create | yes | yes | yes |
117118| comment | yes | yes | yes |
119| react to it or a comment | yes | yes | no |
118120| edit title and body | yes | yes | yes |
119121| close and reopen | yes | yes | yes |
120122| labels, assignees | yes | yes | yes |
.gitbay/wiki/Users.org +8
@@ -335,6 +335,7 @@ gitbay issue create --title "it breaks" [--body "..." | --file -]
335335gitbay issue list [--state open|closed|all]
336336gitbay issue show 4
337337gitbay issue comment 4 --message "same here"
338gitbay issue react 4 +1 [--comment 12] [--remove] # the same on mr
338339gitbay issue edit 4 --title "better title" [--body|--file -] # author or write
339340gitbay issue close 4 / reopen 4
340341gitbay issue label 4 --add bug --remove wontfix
@@ -342,6 +343,13 @@ gitbay issue assign 4 --add alice
342343gitbay issue milestone 4 v1.0 # or "none" to clear
343344#+end_src
344345
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
345353Inside a clone, the repository is inferred from the =origin= remote —
346354that is why no =owner/name= appears above. Anywhere else, pass it as the
347355first 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.
2626 the clone instead. =repo commit-file= and applying a suggestion
2727 refuse once the owner's storage quota is used up, as a push does.
2828 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).
2932
3033* v1.38.0 — 2026-09-29
3134
cmd/gitbay/main.go +2
@@ -639,6 +639,7 @@ func issueCmd() *cobra.Command {
639639 pass("reopen", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}),
640640 pass("label", passOpts{server: []string{"issue", "label"}, needsRepo: true}),
641641 pass("assign", passOpts{server: []string{"issue", "assign"}, needsRepo: true}),
642 pass("react", passOpts{server: []string{"issue", "react"}, needsRepo: true}),
642643 pass("edit", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}),
643644 pass("milestone", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}),
644645 pass("templates", passOpts{server: []string{"issue", "templates"}, needsRepo: true}),
@@ -680,6 +681,7 @@ func mrCmd() *cobra.Command {
680681 local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout),
681682 local("rebase", "replay the MR's branch onto its target and re-push: gitbay mr rebase <n>", cmdMRRebase),
682683 pass("comment", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
684 pass("react", passOpts{server: []string{"mr", "react"}, needsRepo: true}),
683685 pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}),
684686 pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}),
685687 pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}),
cmd/gitbay/summaries_gen.go +2
@@ -50,6 +50,7 @@ var summaries = map[string]string{
5050 "issue label": "add or remove labels",
5151 "issue list": "list issues",
5252 "issue milestone": "set or clear an issue's milestone",
53 "issue react": "react to an issue or one of its comments",
5354 "issue reopen": "reopen an issue",
5455 "issue show": "show an issue with comments",
5556 "issue templates": "list issue templates (.gitbay/issue-template*.md)",
@@ -77,6 +78,7 @@ var summaries = map[string]string{
7778 "mr merge": "merge",
7879 "mr milestone": "set or clear an MR's milestone",
7980 "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",
8082 "mr ready": "take the draft mark off, so it can merge",
8183 "mr resolve": "resolve a review thread",
8284 "mr retarget": "retarget onto another branch",
e2e/issue_test.go +1 −1
@@ -75,7 +75,7 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
7575 // --format carries, and what everything written before formats existed has.
7676 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` +
7777 `"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}],` +
7979 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` +
8080 `"title":"first bug"},"protocol_version":1}`
8181 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 {
339339 if err != nil {
340340 return c.fail(protocol.ExitFailure, "%v", err)
341341 }
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 }
342346 var cs []commentOut
343347 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])})
345349 }
346 d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs}
350 d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs}
347351 return c.emit(d, func(w io.Writer) {
348352 v := c.view(w)
349353 v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State)
@@ -355,6 +359,7 @@ func runIssueShow(c *Ctx, args []string) int {
355359 "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)),
356360 )
357361 v.body(d.Body, d.BodyFormat)
362 v.reactions(d.Reactions)
358363 events := false
359364 for _, cm := range cs {
360365 if cm.Kind != "system" {
@@ -370,7 +375,8 @@ func runIssueShow(c *Ctx, args []string) int {
370375 if cm.Kind == "system" {
371376 continue
372377 }
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)
374380 }
375381 })
376382}
internal/control/mr.go +9 −3
@@ -719,9 +719,13 @@ func runMRShow(c *Ctx, args []string) int {
719719 }
720720 checks = append(checks, out)
721721 }
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 }
722726 var cs []commentOut
723727 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])})
725729 }
726730 var rs []ReviewOut
727731 counts := ReviewersWhoCount(c.Store, repo, reviews)
@@ -751,7 +755,7 @@ func runMRShow(c *Ctx, args []string) int {
751755 }
752756 }
753757 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}
755759 d.StackedOn, d.Stacked = stackOf(c, repo, mr)
756760 if mr.State == "open" || mr.State == "source_gone" {
757761 if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil {
@@ -857,6 +861,7 @@ func runMRShow(c *Ctx, args []string) int {
857861 v.fields(kv...)
858862
859863 v.body(d.Body, d.BodyFormat)
864 v.reactions(d.Reactions)
860865
861866 if len(commits) > 1 {
862867 v.section(fmt.Sprintf("commits (%d)", len(commits)))
@@ -907,7 +912,8 @@ func runMRShow(c *Ctx, args []string) int {
907912 if cm.Kind == "system" {
908913 continue
909914 }
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)
911917 }
912918 })
913919}
internal/control/output.go +9 −7
@@ -21,18 +21,20 @@ type MRCreated struct {
2121// IssueShow is issue show's payload: the issue and its comments.
2222type IssueShow struct {
2323 issueOut
24 Comments []commentOut `json:"comments,omitempty"`
24 Reactions []ReactionOut `json:"reactions,omitempty"`
25 Comments []commentOut `json:"comments,omitempty"`
2526}
2627
2728// MRShow is mr show's payload.
2829type MRShow struct {
2930 mrOut
30 Checks []CheckOut `json:"checks,omitempty"`
31 Combined string `json:"checks_combined,omitempty"`
32 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
33 Commits []CommitOut `json:"commits,omitempty"`
34 Comments []commentOut `json:"comments,omitempty"`
35 Reviews []ReviewOut `json:"reviews,omitempty"`
31 Checks []CheckOut `json:"checks,omitempty"`
32 Combined string `json:"checks_combined,omitempty"`
33 UnresolvedThreads int `json:"unresolved_threads,omitempty"`
34 Commits []CommitOut `json:"commits,omitempty"`
35 Reactions []ReactionOut `json:"reactions,omitempty"`
36 Comments []commentOut `json:"comments,omitempty"`
37 Reviews []ReviewOut `json:"reviews,omitempty"`
3638 // Gates is set while the merge request is open.
3739 Gates *GatesOut `json:"gates,omitempty"`
3840}
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 {
5050
5151// commentOut is one comment as show emits it, for both nouns.
5252type commentOut struct {
53 Author string `json:"author"`
54 Body string `json:"body"`
55 BodyFormat string `json:"body_format,omitempty"`
56 CreatedAt string `json:"created_at"`
57 Kind string `json:"-"` // "comment" or "system"; not part of the wire shape
53 ID int64 `json:"id"`
54 Author string `json:"author"`
55 Body string `json:"body"`
56 BodyFormat string `json:"body_format,omitempty"`
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"`
5860}
5961
6062// thread is what a comment command needs to know about its noun.
internal/control/view.go +21 −2
@@ -1,9 +1,11 @@
11package control
22
33import (
4 "fmt"
45 "io"
56 "strings"
67
8 "gitbay.org/gitbay/internal/store"
79 "gitbay.org/gitbay/internal/termtext"
810)
911
@@ -183,8 +185,8 @@ func (v *view) event(text, format, ts string) {
183185 v.wrote = true
184186}
185187
186func (v *view) comment(author, ts, body, format string) {
187 when := v.c.Term.safe(v.c.when(ts))
188func (v *view) comment(id int64, author, ts, body, format string) {
189 when := v.c.Term.safe(v.c.when(ts)) + fmt.Sprintf(" (comment %d)", id)
188190 author = v.c.Term.safe(author)
189191 cols := v.c.Term.Cols
190192 if cols > 0 {
@@ -199,3 +201,20 @@ func (v *view) comment(author, ts, body, format string) {
199201 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
200202 v.body(body, format)
201203}
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) {
3131 Notice string
3232 LabelColors map[string]template.CSS
3333 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 {
3536 t.Fatalf("render: %v", err)
3637 }
3738 if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
internal/httpd/mrpage_test.go +1
@@ -40,6 +40,7 @@ type mrPageData struct {
4040 Base string
4141 Draft *draft
4242 Layout diffLayout
43 Reactions map[int64]reactionBar
4344}
4445
4546// 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 {
160160 Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))},
161161 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true,
162162 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))},
163165 // Triage: each runs the matching issue command.
164166 Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true,
165167 Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))},
@@ -206,6 +208,8 @@ func (s *Server) Routes() []Route {
206208 Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))},
207209 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true,
208210 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))},
209213 // Review loop: each runs the matching mr command.
210214 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true,
211215 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 {
13241324
13251325// renderedComment pairs a comment with its rendered body for templates.
13261326type renderedComment struct {
1327 ID int64
13271328 Author string
13281329 CreatedAt string
13291330 Kind string
@@ -1333,7 +1334,7 @@ type renderedComment struct {
13331334func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
13341335 var out []renderedComment
13351336 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)})
13371338 }
13381339 return out
13391340}
@@ -2024,6 +2025,7 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
20242025 }
20252026 // nil readable: the picker lists titles, never the progress counts.
20262027 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))
20272029 s.render(w, "issue.html", struct {
20282030 repoPage
20292031 Issue store.Issue
@@ -2035,9 +2037,10 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s
20352037 Notice string
20362038 LabelColors map[string]template.CSS
20372039 Draft *draft
2040 Reactions map[int64]reactionBar
20382041 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
20392042 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})
20412044}
20422045
20432046// 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
23282331 }
23292332 d = s.draftFor(r, p.Repo, previewForm, "body", format)
23302333 }
2334 bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number))
23312335 s.render(w, "mr.html", struct {
23322336 repoPage
23332337 MR store.MR
@@ -2360,10 +2364,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
23602364 LabelColors map[string]template.CSS
23612365 Draft *draft
23622366 Layout diffLayout
2367 Reactions map[int64]reactionBar
23632368 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
23642369 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
23652370 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})
23672372}
23682373
23692374// 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 {
2323}
2424
2525type IssueComment struct {
26 ID int64
2627 Author string
2728 Body string
2829 BodyFormat string // md | org
@@ -240,7 +241,7 @@ func (s *Store) AddIssueComment(issueID, authorID int64, body, format string) er
240241
241242func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
242243 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,
244245 c.body, c.body_format, c.created_at, c.kind
245246 FROM issue_comments c JOIN users u ON u.id = c.author_id
246247 WHERE c.issue_id = ? ORDER BY c.id`, issueID)
@@ -251,7 +252,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
251252 var out []IssueComment
252253 for rows.Next() {
253254 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 {
255256 return nil, err
256257 }
257258 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 {
507507
508508func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
509509 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,
511511 c.body, c.body_format, c.created_at, c.kind
512512 FROM mr_comments c JOIN users u ON u.id = c.author_id
513513 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
@@ -518,7 +518,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
518518 var out []IssueComment
519519 for rows.Next() {
520520 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 {
522522 return nil, err
523523 }
524524 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; }
19171917 /* the mark is a link home too, so it is a target like the rest */
19181918 a.brand { width: var(--rail-hit); }
19191919}
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 @@
2222{{if .BodyHTML}}<article class="comment">
2323 <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header>
2424 <div class="rendered">{{.BodyHTML}}</div>
25</article>{{end}}
25 {{template "reactions" index .Reactions 0}}
26</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
2627{{range .Comments}}
2728{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
2829{{else}}<article class="comment">
2930 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
3031 <div class="rendered">{{.BodyHTML}}</div>
32 {{template "reactions" index $.Reactions .ID}}
3133</article>{{end}}
3234{{end}}
3335{{if .Viewer}}
internal/web/templates/layout.html +6
@@ -182,6 +182,12 @@
182182{{/* previewbtn is the Preview submit beside a markup form's own submit.
183183 It posts the form to its usual action, which renders the body and
184184 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
185191{{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}}
186192
187193{{/* previewblock shows a draft as it will read. The argument is the
internal/web/templates/mr.html +3 −1
@@ -37,12 +37,14 @@
3737{{if .BodyHTML}}<article class="comment">
3838 <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header>
3939 <div class="rendered">{{.BodyHTML}}</div>
40</article>{{end}}
40 {{template "reactions" index .Reactions 0}}
41</article>{{else}}{{template "reactions" index .Reactions 0}}{{end}}
4142{{range .Comments}}
4243{{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div>
4344{{else}}<article class="comment">
4445 <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header>
4546 <div class="rendered">{{.BodyHTML}}</div>
47 {{template "reactions" index $.Reactions .ID}}
4648</article>{{end}}
4749{{end}}
4850{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>