Commit 67d2c52b58

67d2c52b5837317898500f581597f173b99e1d8e

parent: 415c793921

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:20 UTC

Add reactions to issues, merge requests and comments

issue react and mr react take a name or its emoji; show carries counts,
the caller's own, and comment ids.

Ref #291

Layout: unified · split

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",
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/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}