Commit f7e43e4e28

f7e43e4e280fcdcb3eed0cb6769f1276530ff931

parent: a75d0485bc

Verified · cmc ci/build: success ci/test: success

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

Reactions: e2e test, Parity, Users, changelog

Closes #291

Layout: unified · split

.gitbay/wiki/Parity.org +2
@@ -35,6 +35,7 @@ browser-only and the iOS build screen unable to say more than the log.
35| review and check times | yes | yes | yes | 35| review and check times | yes | yes | yes |
36| who resolved it, when | yes | yes | yes | 36| who resolved it, when | yes | yes | yes |
37| comment | yes | yes | yes | 37| comment | yes | yes | yes |
38| react to it or a comment | yes | yes | no |
38| edit title and body | yes | yes | yes | 39| edit title and body | yes | yes | yes |
39| review (approve etc.) | yes | yes | yes | 40| review (approve etc.) | yes | yes | yes |
40| resolve a thread | yes | yes | yes | 41| resolve a thread | yes | yes | yes |
@@ -115,6 +116,7 @@ reviews, since an approval was of the diff against the old branch.
115| search title and body | yes | yes | yes | 116| search title and body | yes | yes | yes |
116| create | yes | yes | yes | 117| create | yes | yes | yes |
117| comment | yes | yes | yes | 118| comment | yes | yes | yes |
119| react to it or a comment | yes | yes | no |
118| edit title and body | yes | yes | yes | 120| edit title and body | yes | yes | yes |
119| close and reopen | yes | yes | yes | 121| close and reopen | yes | yes | yes |
120| labels, assignees | yes | yes | yes | 122| labels, assignees | yes | yes | yes |
.gitbay/wiki/Users.org +8
@@ -335,6 +335,7 @@ gitbay issue create --title "it breaks" [--body "..." | --file -]
335gitbay issue list [--state open|closed|all] 335gitbay issue list [--state open|closed|all]
336gitbay issue show 4 336gitbay issue show 4
337gitbay issue comment 4 --message "same here" 337gitbay issue comment 4 --message "same here"
338gitbay issue react 4 +1 [--comment 12] [--remove] # the same on mr
338gitbay issue edit 4 --title "better title" [--body|--file -] # author or write 339gitbay issue edit 4 --title "better title" [--body|--file -] # author or write
339gitbay issue close 4 / reopen 4 340gitbay issue close 4 / reopen 4
340gitbay issue label 4 --add bug --remove wontfix 341gitbay issue label 4 --add bug --remove wontfix
@@ -342,6 +343,13 @@ gitbay issue assign 4 --add alice
342gitbay issue milestone 4 v1.0 # or "none" to clear 343gitbay issue milestone 4 v1.0 # or "none" to clear
343#+end_src 344#+end_src
344 345
346A reaction is one of =+1 -1 laugh hooray confused heart rocket eyes=,
347given by name or as the emoji itself, on an issue or merge request or on
348one of its comments (=show= prints comment ids). Anyone who may comment
349may react; reacting twice or removing an absent reaction does nothing.
350=show= carries the counts and which are yours. A reaction sends no
351notification and appears in no activity feed.
352
345Inside a clone, the repository is inferred from the =origin= remote — 353Inside a clone, the repository is inferred from the =origin= remote —
346that is why no =owner/name= appears above. Anywhere else, pass it as the 354that is why no =owner/name= appears above. Anywhere else, pass it as the
347first argument. Long text: =--body= inline, =--file -= from stdin, or 355first argument. Long text: =--body= inline, =--file -= from stdin, or
CHANGELOG.org +3
@@ -26,6 +26,9 @@ anything beyond "replace the binary and restart" is needed.
26 the clone instead. =repo commit-file= and applying a suggestion 26 the clone instead. =repo commit-file= and applying a suggestion
27 refuse once the owner's storage quota is used up, as a push does. 27 refuse once the owner's storage quota is used up, as a push does.
28 Migration 0069 (#288). 28 Migration 0069 (#288).
29- Reactions on issues, merge requests and their comments: =issue react=
30 and =mr react= with a fixed set of eight, counts and your own in
31 =show=, comment ids in =show=, and buttons on the web pages (#291).
29 32
30* v1.38.0 — 2026-09-29 33* v1.38.0 — 2026-09-29
31 34
e2e/issue_test.go +1 −1
@@ -75,7 +75,7 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) {
75 // --format carries, and what everything written before formats existed has. 75 // --format carries, and what everything written before formats existed has.
76 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` + 76 wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` +
77 `"body_format":"md",` + 77 `"body_format":"md",` +
78 `"comments":[{"author":"bob","body":"me too","body_format":"md","created_at":"TS"}],` + 78 `"comments":[{"author":"bob","body":"me too","body_format":"md","created_at":"TS","id":1}],` +
79 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` + 79 `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` +
80 `"title":"first bug"},"protocol_version":1}` 80 `"title":"first bug"},"protocol_version":1}`
81 if g := golden(t, out); g != wantShow { 81 if g := golden(t, out); g != wantShow {
e2e/reactions_test.go added +156
@@ -0,0 +1,156 @@
1package e2e
2
3import (
4 "encoding/json"
5 "net/url"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// Reactions on issues, merge requests and their comments (#291): the
13// commands, the counts in show, the web buttons, and a private
14// repository staying invisible.
15func TestReactions(t *testing.T) {
16 t.Parallel()
17 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
18 aliceKey := inst.newKey(t, "alice")
19 bobKey := inst.newKey(t, "bob")
20 carolKey := inst.newKey(t, "carol")
21 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
22 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
23 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
24 for _, args := range [][]string{{"repo", "create", "alice/app"}, {"repo", "create", "alice/secret", "--private"}} {
25 if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 {
26 t.Fatalf("%v: %s", args, errOut)
27 }
28 }
29 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/secret", "bob", "read"); code != 0 {
30 t.Fatal("grant failed")
31 }
32 env := inst.gitEnv(aliceKey)
33 work := t.TempDir()
34 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
35 dir := filepath.Join(work, "w")
36 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
37 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
38 mustGit(t, dir, env, "add", ".")
39 mustGit(t, dir, env, "commit", "-q", "-m", "base")
40 mustGit(t, dir, env, "push", "-q", "origin", "main")
41 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
42 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("b\n"), 0o644)
43 mustGit(t, dir, env, "commit", "-q", "-am", "change")
44 mustGit(t, dir, env, "push", "-q", "origin", "feat")
45
46 must := func(key string, args ...string) string {
47 t.Helper()
48 out, errOut, code := inst.ssh(t, key, "", args...)
49 if code != 0 {
50 t.Fatalf("%v: exit %d: %s%s", args, code, out, errOut)
51 }
52 return out
53 }
54 must(aliceKey, "issue", "create", "alice/app", "--title", "bug", "--body", "'it breaks'")
55 must(aliceKey, "issue", "comment", "alice/app", "1", "--message", "first")
56 must(aliceKey, "mr", "create", "alice/app", "--source", "feat", "--target", "main", "--title", "'change'")
57 must(aliceKey, "mr", "comment", "alice/app", "1", "--message", "'looks fine'")
58 must(aliceKey, "issue", "create", "alice/secret", "--title", "hidden")
59
60 type show struct {
61 Data struct {
62 Reactions []struct {
63 Reaction string
64 Count int
65 Me bool
66 }
67 Comments []struct {
68 ID int64
69 Reactions []struct {
70 Reaction string
71 Count int
72 Me bool
73 }
74 }
75 }
76 }
77 read := func(key, noun string) show {
78 t.Helper()
79 var s show
80 if err := json.Unmarshal([]byte(must(key, noun, "show", "alice/app", "1", "--json")), &s); err != nil {
81 t.Fatal(err)
82 }
83 return s
84 }
85
86 for _, noun := range []string{"issue", "mr"} {
87 cid := read(bobKey, noun).Data.Comments[0].ID
88 if cid == 0 {
89 t.Fatalf("%s show carries no comment id", noun)
90 }
91 must(bobKey, noun, "react", "alice/app", "1", "+1")
92 must(bobKey, noun, "react", "alice/app", "1", "+1") // idempotent
93 must(aliceKey, noun, "react", "alice/app", "1", "👍")
94 must(bobKey, noun, "react", "alice/app", "1", "--comment", itoa64(cid), "-1")
95 must(bobKey, noun, "react", "alice/app", "1", "--remove", "rocket") // absent: no-op
96
97 s := read(bobKey, noun)
98 if len(s.Data.Reactions) != 1 || s.Data.Reactions[0].Reaction != "+1" || s.Data.Reactions[0].Count != 2 || !s.Data.Reactions[0].Me {
99 t.Errorf("%s body reactions: %+v", noun, s.Data.Reactions)
100 }
101 if c := s.Data.Comments[0].Reactions; len(c) != 1 || c[0].Reaction != "-1" || c[0].Count != 1 || !c[0].Me {
102 t.Errorf("%s comment reactions: %+v", noun, c)
103 }
104 if a := read(aliceKey, noun); a.Data.Comments[0].Reactions[0].Me {
105 t.Errorf("%s: alice marked on bob's reaction", noun)
106 }
107 if out := must(bobKey, noun, "show", "alice/app", "1"); !strings.Contains(out, "👍 2 (you)") || !strings.Contains(out, "(comment ") {
108 t.Errorf("%s show text:\n%s", noun, out)
109 }
110 if _, _, code := inst.ssh(t, bobKey, "", noun, "react", "alice/app", "1", "nope"); code != 2 {
111 t.Errorf("%s: unknown reaction exited %d, want 2", noun, code)
112 }
113 if _, _, code := inst.ssh(t, bobKey, "", noun, "react", "alice/app", "1", "--comment", "9999", "+1"); code != 3 {
114 t.Errorf("%s: unknown comment exited %d, want 3", noun, code)
115 }
116 }
117
118 // Private: readable by bob (granted), not found for carol.
119 must(bobKey, "issue", "react", "alice/secret", "1", "eyes")
120 if _, _, code := inst.ssh(t, carolKey, "", "issue", "react", "alice/secret", "1", "eyes"); code != 3 {
121 t.Errorf("private repository: exit %d, want 3", code)
122 }
123
124 // Reacting files no activity.
125 if out := must(aliceKey, "feed", "--json"); strings.Contains(out, "react") {
126 t.Errorf("a reaction reached the feed:\n%s", out)
127 }
128
129 // Web: buttons for a signed-in viewer, counts only for anyone else.
130 bob := inst.login(t, bobKey)
131 if status, page := browserGet(t, bob, inst.base()+"/alice/app/issues/1"); status != 200 ||
132 !strings.Contains(page, `class="react mine"`) || !strings.Contains(page, `name="add" value="eyes"`) {
133 t.Errorf("signed-in issue page: %d\n%s", status, page)
134 }
135 if status, page := browserGet(t, bob, inst.base()+"/alice/app/mrs/1"); status != 200 || !strings.Contains(page, `class="react mine"`) {
136 t.Errorf("signed-in MR page: %d", status)
137 }
138 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/issues/1/react", url.Values{"add": {"rocket"}}); status != 200 {
139 t.Fatalf("web react: %d", status)
140 }
141 if status, _ := browserPost(t, bob, inst.base()+"/alice/app/mrs/1/react", url.Values{"remove": {"+1"}}); status != 200 {
142 t.Fatalf("web unreact: %d", status)
143 }
144 if s := read(bobKey, "issue"); len(s.Data.Reactions) != 2 {
145 t.Errorf("web reaction not stored: %+v", s.Data.Reactions)
146 }
147 if s := read(bobKey, "mr"); s.Data.Reactions[0].Count != 1 || s.Data.Reactions[0].Me {
148 t.Errorf("web removal: %+v", s.Data.Reactions)
149 }
150 _, anon := inst.get(t, "/alice/app/issues/1")
151 if strings.Contains(anon, "/issues/1/react") || !strings.Contains(anon, "👍 2") {
152 t.Errorf("signed-out issue page:\n%s", anon)
153 }
154}
155
156func itoa64(n int64) string { b, _ := json.Marshal(n); return string(b) }
internal/httpd/reactions_test.go +1 −1
@@ -70,7 +70,7 @@ func TestIssueReactionsOnPage(t *testing.T) {
70 rr := httptest.NewRecorder() 70 rr := httptest.NewRecorder()
71 s.issue(rr, req) 71 s.issue(rr, req)
72 body := rr.Body.String() 72 body := rr.Body.String()
73 if n := strings.Count(body, `class="react"`)+strings.Count(body, `class="react mine"`); n != 16 { 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) 74 t.Errorf("%d reaction buttons, want 16 (8 on the body, 8 on the comment)", n)
75 } 75 }
76 if !strings.Contains(body, `name="remove" value="&#43;1" class="react mine" aria-pressed="true"`) { 76 if !strings.Contains(body, `name="remove" value="&#43;1" class="react mine" aria-pressed="true"`) {