Reactions on issues, merge requests and comments !527
30 files changed, +1174 −31
Layout: unified · split
.gitbay/wiki/Parity.org +2
| @@ -35,6 +35,7 @@ browser-only and the iOS build screen unable to say more than the log. | ||
| 35 | 35 | | review and check times | yes | yes | yes | |
| 36 | 36 | | who resolved it, when | yes | yes | yes | |
| 37 | 37 | | comment | yes | yes | yes | |
| 38 | | react to it or a comment | yes | yes | no | | |
| 38 | 39 | | edit title and body | yes | yes | yes | |
| 39 | 40 | | review (approve etc.) | yes | yes | yes | |
| 40 | 41 | | resolve a thread | yes | yes | yes | |
| @@ -115,6 +116,7 @@ reviews, since an approval was of the diff against the old branch. | ||
| 115 | 116 | | search title and body | yes | yes | yes | |
| 116 | 117 | | create | yes | yes | yes | |
| 117 | 118 | | comment | yes | yes | yes | |
| 119 | | react to it or a comment | yes | yes | no | | |
| 118 | 120 | | edit title and body | yes | yes | yes | |
| 119 | 121 | | close and reopen | yes | yes | yes | |
| 120 | 122 | | labels, assignees | yes | yes | yes | |
.gitbay/wiki/Users.org +8
| @@ -335,6 +335,7 @@ gitbay issue create --title "it breaks" [--body "..." | --file -] | ||
| 335 | 335 | gitbay issue list [--state open|closed|all] |
| 336 | 336 | gitbay issue show 4 |
| 337 | 337 | gitbay issue comment 4 --message "same here" |
| 338 | gitbay issue react 4 +1 [--comment 12] [--remove] # the same on mr | |
| 338 | 339 | gitbay issue edit 4 --title "better title" [--body|--file -] # author or write |
| 339 | 340 | gitbay issue close 4 / reopen 4 |
| 340 | 341 | gitbay issue label 4 --add bug --remove wontfix |
| @@ -342,6 +343,13 @@ gitbay issue assign 4 --add alice | ||
| 342 | 343 | gitbay issue milestone 4 v1.0 # or "none" to clear |
| 343 | 344 | #+end_src |
| 344 | 345 | |
| 346 | A reaction is one of =+1 -1 laugh hooray confused heart rocket eyes=, | |
| 347 | given by name or as the emoji itself, on an issue or merge request or on | |
| 348 | one of its comments (=show= prints comment ids). Anyone who may comment | |
| 349 | may react; reacting twice or removing an absent reaction does nothing. | |
| 350 | =show= carries the counts and which are yours. A reaction sends no | |
| 351 | notification and appears in no activity feed. | |
| 352 | ||
| 345 | 353 | Inside a clone, the repository is inferred from the =origin= remote — |
| 346 | 354 | that is why no =owner/name= appears above. Anywhere else, pass it as the |
| 347 | 355 | first 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 | 26 | the clone instead. =repo commit-file= and applying a suggestion |
| 27 | 27 | refuse once the owner's storage quota is used up, as a push does. |
| 28 | 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 | 33 | * v1.38.0 — 2026-09-29 |
| 31 | 34 | |
cmd/gitbay/main.go +2
| @@ -639,6 +639,7 @@ func issueCmd() *cobra.Command { | ||
| 639 | 639 | pass("reopen", passOpts{server: []string{"issue", "reopen"}, needsRepo: true}), |
| 640 | 640 | pass("label", passOpts{server: []string{"issue", "label"}, needsRepo: true}), |
| 641 | 641 | pass("assign", passOpts{server: []string{"issue", "assign"}, needsRepo: true}), |
| 642 | pass("react", passOpts{server: []string{"issue", "react"}, needsRepo: true}), | |
| 642 | 643 | pass("edit", passOpts{server: []string{"issue", "edit"}, needsRepo: true, stdinOK: true}), |
| 643 | 644 | pass("milestone", passOpts{server: []string{"issue", "milestone"}, needsRepo: true}), |
| 644 | 645 | pass("templates", passOpts{server: []string{"issue", "templates"}, needsRepo: true}), |
| @@ -680,6 +681,7 @@ func mrCmd() *cobra.Command { | ||
| 680 | 681 | local("checkout", "fetch and check out the MR head locally: gitbay mr checkout <n>", cmdMRCheckout), |
| 681 | 682 | local("rebase", "replay the MR's branch onto its target and re-push: gitbay mr rebase <n>", cmdMRRebase), |
| 682 | 683 | pass("comment", passOpts{server: []string{"mr", "comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 684 | pass("react", passOpts{server: []string{"mr", "react"}, needsRepo: true}), | |
| 683 | 685 | pass("diff-comment", passOpts{server: []string{"mr", "diff-comment"}, needsRepo: true, stdinOK: true, editor: "comment"}), |
| 684 | 686 | pass("threads", passOpts{server: []string{"mr", "threads"}, needsRepo: true}), |
| 685 | 687 | pass("resolve", passOpts{server: []string{"mr", "resolve"}, needsRepo: true}), |
cmd/gitbay/summaries_gen.go +2
| @@ -50,6 +50,7 @@ var summaries = map[string]string{ | ||
| 50 | 50 | "issue label": "add or remove labels", |
| 51 | 51 | "issue list": "list issues", |
| 52 | 52 | "issue milestone": "set or clear an issue's milestone", |
| 53 | "issue react": "react to an issue or one of its comments", | |
| 53 | 54 | "issue reopen": "reopen an issue", |
| 54 | 55 | "issue show": "show an issue with comments", |
| 55 | 56 | "issue templates": "list issue templates (.gitbay/issue-template*.md)", |
| @@ -77,6 +78,7 @@ var summaries = map[string]string{ | ||
| 77 | 78 | "mr merge": "merge", |
| 78 | 79 | "mr milestone": "set or clear an MR's milestone", |
| 79 | 80 | "mr range-diff": "what changed between two revisions of a merge request", |
| 81 | "mr react": "react to a merge request or one of its comments", | |
| 80 | 82 | "mr ready": "take the draft mark off, so it can merge", |
| 81 | 83 | "mr resolve": "resolve a review thread", |
| 82 | 84 | "mr retarget": "retarget onto another branch", |
e2e/issue_test.go +1 −1
| @@ -75,7 +75,7 @@ func TestIssueLifecycleOverBareSSH(t *testing.T) { | ||
| 75 | 75 | // --format carries, and what everything written before formats existed has. |
| 76 | 76 | wantShow := `{"data":{"assignees":["bob"],"author":"alice","body":"it is broken",` + |
| 77 | 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 | 79 | `"created_at":"TS","labels":["bug","urgent"],"number":1,"state":"open",` + |
| 80 | 80 | `"title":"first bug"},"protocol_version":1}` |
| 81 | 81 | if g := golden(t, out); g != wantShow { |
e2e/reactions_test.go added +156
| @@ -0,0 +1,156 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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 | ||
| 156 | func itoa64(n int64) string { b, _ := json.Marshal(n); return string(b) } | |
internal/control/issue.go +9 −3
| @@ -339,11 +339,15 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 339 | 339 | if err != nil { |
| 340 | 340 | return c.fail(protocol.ExitFailure, "%v", err) |
| 341 | 341 | } |
| 342 | rx, err := c.Store.ReactionCounts("issue", issue.ID, c.User.ID) | |
| 343 | if err != nil { | |
| 344 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 345 | } | |
| 342 | 346 | var cs []commentOut |
| 343 | 347 | for _, cm := range comments { |
| 344 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind}) | |
| 348 | cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) | |
| 345 | 349 | } |
| 346 | d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs} | |
| 350 | d := IssueShow{issueOut: issueToOut(issue, true), Reactions: reactionsOut(rx[0]), Comments: cs} | |
| 347 | 351 | return c.emit(d, func(w io.Writer) { |
| 348 | 352 | v := c.view(w) |
| 349 | 353 | v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) |
| @@ -355,6 +359,7 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 355 | 359 | "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)), |
| 356 | 360 | ) |
| 357 | 361 | v.body(d.Body, d.BodyFormat) |
| 362 | v.reactions(d.Reactions) | |
| 358 | 363 | events := false |
| 359 | 364 | for _, cm := range cs { |
| 360 | 365 | if cm.Kind != "system" { |
| @@ -370,7 +375,8 @@ func runIssueShow(c *Ctx, args []string) int { | ||
| 370 | 375 | if cm.Kind == "system" { |
| 371 | 376 | continue |
| 372 | 377 | } |
| 373 | v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | |
| 378 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | |
| 379 | v.reactions(cm.Reactions) | |
| 374 | 380 | } |
| 375 | 381 | }) |
| 376 | 382 | } |
internal/control/mr.go +9 −3
| @@ -719,9 +719,13 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 719 | 719 | } |
| 720 | 720 | checks = append(checks, out) |
| 721 | 721 | } |
| 722 | rx, err := c.Store.ReactionCounts("mr", mr.ID, c.User.ID) | |
| 723 | if err != nil { | |
| 724 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 725 | } | |
| 722 | 726 | var cs []commentOut |
| 723 | 727 | for _, cm := range comments { |
| 724 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind}) | |
| 728 | cs = append(cs, commentOut{cm.ID, cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind, reactionsOut(rx[cm.ID])}) | |
| 725 | 729 | } |
| 726 | 730 | var rs []ReviewOut |
| 727 | 731 | counts := ReviewersWhoCount(c.Store, repo, reviews) |
| @@ -751,7 +755,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 751 | 755 | } |
| 752 | 756 | } |
| 753 | 757 | d := MRShow{mrOut: mrToOut(repo, mr, true), Checks: checks, Combined: combined, |
| 754 | UnresolvedThreads: unresolved, Commits: commits, Comments: cs, Reviews: rs} | |
| 758 | UnresolvedThreads: unresolved, Commits: commits, Reactions: reactionsOut(rx[0]), Comments: cs, Reviews: rs} | |
| 755 | 759 | d.StackedOn, d.Stacked = stackOf(c, repo, mr) |
| 756 | 760 | if mr.State == "open" || mr.State == "source_gone" { |
| 757 | 761 | if targetSHA, err := gitutil.ResolveRef(dir, "refs/heads/"+mr.TargetRef); err == nil { |
| @@ -857,6 +861,7 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 857 | 861 | v.fields(kv...) |
| 858 | 862 | |
| 859 | 863 | v.body(d.Body, d.BodyFormat) |
| 864 | v.reactions(d.Reactions) | |
| 860 | 865 | |
| 861 | 866 | if len(commits) > 1 { |
| 862 | 867 | v.section(fmt.Sprintf("commits (%d)", len(commits))) |
| @@ -907,7 +912,8 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 907 | 912 | if cm.Kind == "system" { |
| 908 | 913 | continue |
| 909 | 914 | } |
| 910 | v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | |
| 915 | v.comment(cm.ID, cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | |
| 916 | v.reactions(cm.Reactions) | |
| 911 | 917 | } |
| 912 | 918 | }) |
| 913 | 919 | } |
internal/control/output.go +9 −7
| @@ -21,18 +21,20 @@ type MRCreated struct { | ||
| 21 | 21 | // IssueShow is issue show's payload: the issue and its comments. |
| 22 | 22 | type IssueShow struct { |
| 23 | 23 | issueOut |
| 24 | Comments []commentOut `json:"comments,omitempty"` | |
| 24 | Reactions []ReactionOut `json:"reactions,omitempty"` | |
| 25 | Comments []commentOut `json:"comments,omitempty"` | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | // MRShow is mr show's payload. |
| 28 | 29 | type MRShow struct { |
| 29 | 30 | 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"` | |
| 36 | 38 | // Gates is set while the merge request is open. |
| 37 | 39 | Gates *GatesOut `json:"gates,omitempty"` |
| 38 | 40 | } |
internal/control/react.go added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 18 | type 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. | |
| 25 | type Reacted struct { | |
| 26 | Reaction string `json:"reaction"` | |
| 27 | Comment int64 `json:"comment,omitempty"` | |
| 28 | Removed bool `json:"removed,omitempty"` | |
| 29 | } | |
| 30 | ||
| 31 | func 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 | ||
| 39 | func 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. | |
| 70 | func 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 | ||
| 135 | func 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 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | func 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 | ||
| 41 | func 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 | ||
| 56 | func 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 | ||
| 109 | func 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 | ||
| 130 | func 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 | ||
| 145 | func 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 | ||
| 155 | func 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 | ||
| 165 | func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) } | |
| 166 | ||
| 167 | func 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. | |
| 195 | func TestReactCommentFromElsewhereNotFound(t *testing.T) { | |
| 196 | alice, bob, repo, _ := reactFixture(t, "public") | |
| 197 | st := alice.Store | |
| 198 | ||
| 199 | // Another issue and MR in the same repository, each with a comment. | |
| 200 | if code := runIssueCreate(alice, []string{repo.Path(), "--title", "two"}); code != 0 { | |
| 201 | t.Fatal(code) | |
| 202 | } | |
| 203 | if code := runIssueComment(alice, []string{repo.Path(), "2", "--message", "other"}); code != 0 { | |
| 204 | t.Fatal(code) | |
| 205 | } | |
| 206 | iss2, _ := st.IssueByNumber(repo.ID, 2) | |
| 207 | ic2, _ := st.ListIssueComments(iss2.ID) | |
| 208 | for i := 0; i < 2; i++ { | |
| 209 | if _, err := st.CreateMR(repo.ID, alice.User.ID, repo.ID, "f", "main", "t", "", "abc", "md", false); err != nil { | |
| 210 | t.Fatal(err) | |
| 211 | } | |
| 212 | } | |
| 213 | mr1, _ := st.MRByNumber(repo.ID, 1) | |
| 214 | mr2, _ := st.MRByNumber(repo.ID, 2) | |
| 215 | st.AddMRComment(mr1.ID, alice.User.ID, "one", "md") | |
| 216 | st.AddMRComment(mr2.ID, alice.User.ID, "two", "md") | |
| 217 | mc1, _ := st.ListMRComments(mr1.ID) | |
| 218 | mc2, _ := st.ListMRComments(mr2.ID) | |
| 219 | ||
| 220 | // Another user's private repository, with an issue comment and an MR comment. | |
| 221 | carolID, _ := st.CreateUser("carol", false) | |
| 222 | pid, err := st.CreateRepo("user", carolID, "secret", "private") | |
| 223 | if err != nil { | |
| 224 | t.Fatal(err) | |
| 225 | } | |
| 226 | priv, _ := st.RepoByID(pid) | |
| 227 | carol := *alice | |
| 228 | carol.User = store.User{ID: carolID, Username: "carol"} | |
| 229 | carol.Stdout, carol.Stderr = &bytes.Buffer{}, &bytes.Buffer{} | |
| 230 | if code := runIssueCreate(&carol, []string{priv.Path(), "--title", "s"}); code != 0 { | |
| 231 | t.Fatal(code) | |
| 232 | } | |
| 233 | if code := runIssueComment(&carol, []string{priv.Path(), "1", "--message", "s"}); code != 0 { | |
| 234 | t.Fatal(code) | |
| 235 | } | |
| 236 | pi, _ := st.IssueByNumber(priv.ID, 1) | |
| 237 | pic, _ := st.ListIssueComments(pi.ID) | |
| 238 | if _, err := st.CreateMR(priv.ID, carolID, priv.ID, "f", "main", "t", "", "abc", "md", false); err != nil { | |
| 239 | t.Fatal(err) | |
| 240 | } | |
| 241 | pm, _ := st.MRByNumber(priv.ID, 1) | |
| 242 | st.AddMRComment(pm.ID, carolID, "s", "md") | |
| 243 | pmc, _ := st.ListMRComments(pm.ID) | |
| 244 | ||
| 245 | p := repo.Path() | |
| 246 | cases := []struct { | |
| 247 | noun string | |
| 248 | n string | |
| 249 | id int64 | |
| 250 | }{ | |
| 251 | {"issue", "1", ic2[0].ID}, | |
| 252 | {"issue", "1", pic[0].ID}, | |
| 253 | {"mr", "1", mc2[0].ID}, | |
| 254 | {"mr", "1", pmc[0].ID}, | |
| 255 | } | |
| 256 | _ = mc1 | |
| 257 | for _, tc := range cases { | |
| 258 | if code := runReact(bob, []string{p, tc.n, "--comment", itoa(tc.id), "+1"}, tc.noun); code != protocol.ExitNotFound { | |
| 259 | t.Errorf("%s comment %d: exit %d, want %d", tc.noun, tc.id, code, protocol.ExitNotFound) | |
| 260 | } | |
| 261 | } | |
| 262 | for _, table := range []string{"issue_reactions", "mr_reactions"} { | |
| 263 | var n int | |
| 264 | st.DB.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n) | |
| 265 | if n != 0 { | |
| 266 | t.Errorf("%s holds %d rows", table, n) | |
| 267 | } | |
| 268 | } | |
| 269 | } | |
internal/control/thread.go +7 −5
| @@ -50,11 +50,13 @@ func authorOrWrite(c *Ctx, repo store.Repo, author, what string) int { | ||
| 50 | 50 | |
| 51 | 51 | // commentOut is one comment as show emits it, for both nouns. |
| 52 | 52 | type 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"` | |
| 58 | 60 | } |
| 59 | 61 | |
| 60 | 62 | // thread is what a comment command needs to know about its noun. |
internal/control/view.go +21 −2
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 4 | 5 | "io" |
| 5 | 6 | "strings" |
| 6 | 7 | |
| 8 | "gitbay.org/gitbay/internal/store" | |
| 7 | 9 | "gitbay.org/gitbay/internal/termtext" |
| 8 | 10 | ) |
| 9 | 11 | |
| @@ -183,8 +185,8 @@ func (v *view) event(text, format, ts string) { | ||
| 183 | 185 | v.wrote = true |
| 184 | 186 | } |
| 185 | 187 | |
| 186 | func (v *view) comment(author, ts, body, format string) { | |
| 187 | when := v.c.Term.safe(v.c.when(ts)) | |
| 188 | func (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) | |
| 188 | 190 | author = v.c.Term.safe(author) |
| 189 | 191 | cols := v.c.Term.Cols |
| 190 | 192 | if cols > 0 { |
| @@ -199,3 +201,20 @@ func (v *view) comment(author, ts, body, format string) { | ||
| 199 | 201 | io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n") |
| 200 | 202 | v.body(body, format) |
| 201 | 203 | } |
| 204 | ||
| 205 | // reactions prints one line of counts under an item, the caller's own | |
| 206 | // marked "(you)". Nothing when nobody has reacted. | |
| 207 | func (v *view) reactions(rs []ReactionOut) { | |
| 208 | if len(rs) == 0 { | |
| 209 | return | |
| 210 | } | |
| 211 | var parts []string | |
| 212 | for _, r := range rs { | |
| 213 | p := fmt.Sprintf("%s %d", store.ReactionEmoji(r.Reaction), r.Count) | |
| 214 | if r.Me { | |
| 215 | p += " (you)" | |
| 216 | } | |
| 217 | parts = append(parts, p) | |
| 218 | } | |
| 219 | io.WriteString(v.w, "reactions: "+strings.Join(parts, ", ")+"\n") | |
| 220 | } | |
internal/httpd/issuecreate_test.go +2 −1
| @@ -31,7 +31,8 @@ func TestIssuePageHasDiscussionHeading(t *testing.T) { | ||
| 31 | 31 | Notice string |
| 32 | 32 | LabelColors map[string]template.CSS |
| 33 | 33 | Draft *draft |
| 34 | }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil { | |
| 34 | Reactions map[int64]reactionBar | |
| 35 | }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}, Reactions: map[int64]reactionBar{0: {}}}); err != nil { | |
| 35 | 36 | t.Fatalf("render: %v", err) |
| 36 | 37 | } |
| 37 | 38 | if !strings.Contains(sb.String(), "<h2>Discussion</h2>") { |
internal/httpd/mrpage_test.go +1
| @@ -40,6 +40,7 @@ type mrPageData struct { | ||
| 40 | 40 | Base string |
| 41 | 41 | Draft *draft |
| 42 | 42 | Layout diffLayout |
| 43 | Reactions map[int64]reactionBar | |
| 43 | 44 | } |
| 44 | 45 | |
| 45 | 46 | // A pruned head has no diff to show; the page must say the head is gone |
internal/httpd/reactions.go added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 11 | type 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. | |
| 21 | type 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. | |
| 31 | func (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. | |
| 64 | func 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 | ||
| 76 | func (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 | ||
| 81 | func (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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 14 | func 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 | ||
| 38 | func 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 | ||
| 52 | func 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="+1" class="react mine" aria-pressed="true"`) { | |
| 77 | t.Errorf("own reaction not marked:\n%s", body) | |
| 78 | } | |
| 79 | if !strings.Contains(body, `name="add" value="eyes"`) || !strings.Contains(body, `<input type="hidden" name="comment" value="1">`) { | |
| 80 | t.Error("add buttons or comment field missing") | |
| 81 | } | |
| 82 | ||
| 83 | // Signed out: counts only, no forms or buttons. | |
| 84 | rr = httptest.NewRecorder() | |
| 85 | s.issue(rr, issueReq("GET", "/alice/app/issues/1", nil)) | |
| 86 | body = rr.Body.String() | |
| 87 | if strings.Contains(body, `class="react"`) && strings.Contains(body, "<button type=\"submit\" name=\"add\"") { | |
| 88 | t.Error("buttons shown to a signed-out viewer") | |
| 89 | } | |
| 90 | if strings.Contains(body, `/react"`) { | |
| 91 | t.Error("react form shown to a signed-out viewer") | |
| 92 | } | |
| 93 | if n := strings.Count(body, `<span class="react"`); n != 2 { | |
| 94 | t.Errorf("%d counts, want 2:\n%s", n, body) | |
| 95 | } | |
| 96 | if !strings.Contains(body, "👍 1") || !strings.Contains(body, "🚀 1") { | |
| 97 | t.Error("counts missing") | |
| 98 | } | |
| 99 | ||
| 100 | // Pressing a pressed button takes it back. | |
| 101 | rr = httptest.NewRecorder() | |
| 102 | s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", url.Values{"remove": {"+1"}}), bob) | |
| 103 | if got, _ := st.ReactionCounts("issue", 1, bob.ID); len(got[0]) != 0 { | |
| 104 | t.Errorf("reaction not removed: %+v", got) | |
| 105 | } | |
| 106 | } | |
internal/httpd/routes.go +4
| @@ -160,6 +160,8 @@ func (s *Server) Routes() []Route { | ||
| 160 | 160 | Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, |
| 161 | 161 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, |
| 162 | 162 | Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, |
| 163 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/react", Mutating: true, | |
| 164 | Handler: s.checkOrigin(s.requireUser(s.issueReactSubmit))}, | |
| 163 | 165 | // Triage: each runs the matching issue command. |
| 164 | 166 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true, |
| 165 | 167 | Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))}, |
| @@ -206,6 +208,8 @@ func (s *Server) Routes() []Route { | ||
| 206 | 208 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, |
| 207 | 209 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, |
| 208 | 210 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, |
| 211 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/react", Mutating: true, | |
| 212 | Handler: s.checkOrigin(s.requireUser(s.mrReactSubmit))}, | |
| 209 | 213 | // Review loop: each runs the matching mr command. |
| 210 | 214 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true, |
| 211 | 215 | Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, |
internal/httpd/web.go +8 −3
| @@ -1324,6 +1324,7 @@ func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer { | ||
| 1324 | 1324 | |
| 1325 | 1325 | // renderedComment pairs a comment with its rendered body for templates. |
| 1326 | 1326 | type renderedComment struct { |
| 1327 | ID int64 | |
| 1327 | 1328 | Author string |
| 1328 | 1329 | CreatedAt string |
| 1329 | 1330 | Kind string |
| @@ -1333,7 +1334,7 @@ type renderedComment struct { | ||
| 1333 | 1334 | func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment { |
| 1334 | 1335 | var out []renderedComment |
| 1335 | 1336 | for _, c := range cs { |
| 1336 | out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)}) | |
| 1337 | out = append(out, renderedComment{ID: c.ID, Author: c.Author, CreatedAt: c.CreatedAt, Kind: c.Kind, BodyHTML: ugc(c.Body, c.BodyFormat)}) | |
| 1337 | 1338 | } |
| 1338 | 1339 | return out |
| 1339 | 1340 | } |
| @@ -2024,6 +2025,7 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s | ||
| 2024 | 2025 | } |
| 2025 | 2026 | // nil readable: the picker lists titles, never the progress counts. |
| 2026 | 2027 | milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) |
| 2028 | bars := s.reactionBars(r, "issue", iss.ID, comments, fmt.Sprintf("/%s/%s/issues/%d/react", p.Repo.OwnerName, p.Repo.Name, iss.Number)) | |
| 2027 | 2029 | s.render(w, "issue.html", struct { |
| 2028 | 2030 | repoPage |
| 2029 | 2031 | Issue store.Issue |
| @@ -2035,9 +2037,10 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s | ||
| 2035 | 2037 | Notice string |
| 2036 | 2038 | LabelColors map[string]template.CSS |
| 2037 | 2039 | Draft *draft |
| 2040 | Reactions map[int64]reactionBar | |
| 2038 | 2041 | }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), |
| 2039 | 2042 | s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), |
| 2040 | milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d}) | |
| 2043 | milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d, bars}) | |
| 2041 | 2044 | } |
| 2042 | 2045 | |
| 2043 | 2046 | // canEditItem: the author or anyone with write access may edit. |
| @@ -2328,6 +2331,7 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri | ||
| 2328 | 2331 | } |
| 2329 | 2332 | d = s.draftFor(r, p.Repo, previewForm, "body", format) |
| 2330 | 2333 | } |
| 2334 | bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number)) | |
| 2331 | 2335 | s.render(w, "mr.html", struct { |
| 2332 | 2336 | repoPage |
| 2333 | 2337 | MR store.MR |
| @@ -2360,10 +2364,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri | ||
| 2360 | 2364 | LabelColors map[string]template.CSS |
| 2361 | 2365 | Draft *draft |
| 2362 | 2366 | Layout diffLayout |
| 2367 | Reactions map[int64]reactionBar | |
| 2363 | 2368 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 2364 | 2369 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 2365 | 2370 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, |
| 2366 | sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout}) | |
| 2371 | sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout, bars}) | |
| 2367 | 2372 | } |
| 2368 | 2373 | |
| 2369 | 2374 | // sourceGone reports whether an MR's source branch no longer exists: the |
internal/store/issues.go +3 −2
| @@ -23,6 +23,7 @@ type Issue struct { | ||
| 23 | 23 | } |
| 24 | 24 | |
| 25 | 25 | type IssueComment struct { |
| 26 | ID int64 | |
| 26 | 27 | Author string |
| 27 | 28 | Body string |
| 28 | 29 | BodyFormat string // md | org |
| @@ -240,7 +241,7 @@ func (s *Store) AddIssueComment(issueID, authorID int64, body, format string) er | ||
| 240 | 241 | |
| 241 | 242 | func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { |
| 242 | 243 | rows, err := s.DB.Query(` |
| 243 | SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, | |
| 244 | SELECT c.id, CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, | |
| 244 | 245 | c.body, c.body_format, c.created_at, c.kind |
| 245 | 246 | FROM issue_comments c JOIN users u ON u.id = c.author_id |
| 246 | 247 | WHERE c.issue_id = ? ORDER BY c.id`, issueID) |
| @@ -251,7 +252,7 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { | ||
| 251 | 252 | var out []IssueComment |
| 252 | 253 | for rows.Next() { |
| 253 | 254 | var c IssueComment |
| 254 | if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { | |
| 255 | if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { | |
| 255 | 256 | return nil, err |
| 256 | 257 | } |
| 257 | 258 | out = append(out, c) |
internal/store/migrations/0070_reactions.down.sql added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | DROP TABLE mr_reactions; | |
| 2 | DROP 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. | |
| 4 | CREATE 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 | ); | |
| 12 | CREATE UNIQUE INDEX issue_reactions_body ON issue_reactions(issue_id, user_id, reaction) | |
| 13 | WHERE comment_id IS NULL; | |
| 14 | CREATE UNIQUE INDEX issue_reactions_comment ON issue_reactions(comment_id, user_id, reaction) | |
| 15 | WHERE comment_id IS NOT NULL; | |
| 16 | CREATE INDEX issue_reactions_issue ON issue_reactions(issue_id); | |
| 17 | ||
| 18 | CREATE 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 | ); | |
| 26 | CREATE UNIQUE INDEX mr_reactions_body ON mr_reactions(mr_id, user_id, reaction) | |
| 27 | WHERE comment_id IS NULL; | |
| 28 | CREATE UNIQUE INDEX mr_reactions_comment ON mr_reactions(comment_id, user_id, reaction) | |
| 29 | WHERE comment_id IS NOT NULL; | |
| 30 | CREATE INDEX mr_reactions_mr ON mr_reactions(mr_id); | |
internal/store/mrs.go +2 −2
| @@ -507,7 +507,7 @@ func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error { | ||
| 507 | 507 | |
| 508 | 508 | func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { |
| 509 | 509 | rows, err := s.DB.Query(` |
| 510 | SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, | |
| 510 | SELECT c.id, CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, | |
| 511 | 511 | c.body, c.body_format, c.created_at, c.kind |
| 512 | 512 | FROM mr_comments c JOIN users u ON u.id = c.author_id |
| 513 | 513 | WHERE c.mr_id = ? ORDER BY c.id`, mrID) |
| @@ -518,7 +518,7 @@ func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { | ||
| 518 | 518 | var out []IssueComment |
| 519 | 519 | for rows.Next() { |
| 520 | 520 | var c IssueComment |
| 521 | if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { | |
| 521 | if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { | |
| 522 | 522 | return nil, err |
| 523 | 523 | } |
| 524 | 524 | out = append(out, c) |
internal/store/reactions.go added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "fmt" | |
| 4 | ||
| 5 | // Reaction is one entry of the fixed reaction set (#291). | |
| 6 | type 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. | |
| 12 | var 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. | |
| 18 | func 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. | |
| 31 | func 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. | |
| 42 | type ReactionCount struct { | |
| 43 | Reaction string | |
| 44 | Count int | |
| 45 | Me bool | |
| 46 | } | |
| 47 | ||
| 48 | // reactionTables names the tables for a noun, "issue" or "mr". | |
| 49 | func 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. | |
| 61 | func (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. | |
| 83 | func (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. | |
| 110 | func (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 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func 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 | ||
| 103 | func TestParseReaction(t *testing.T) { | |
| 104 | for in, want := range map[string]string{"+1": "+1", "👍": "+1", "❤️": "heart", "❤": "heart", "eyes": "eyes"} { | |
| 105 | if got, ok := ParseReaction(in); !ok || got != want { | |
| 106 | t.Errorf("%q: %q %v", in, got, ok) | |
| 107 | } | |
| 108 | } | |
| 109 | if _, ok := ParseReaction("thumbsup"); ok { | |
| 110 | t.Error("accepted an unknown name") | |
| 111 | } | |
| 112 | } | |
internal/web/static/style.css +18
| @@ -1917,3 +1917,21 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1917 | 1917 | /* the mark is a link home too, so it is a target like the rest */ |
| 1918 | 1918 | a.brand { width: var(--rail-hit); } |
| 1919 | 1919 | } |
| 1920 | ||
| 1921 | /* ---- reactions ---- */ | |
| 1922 | .reactions { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0; padding: var(--sp-2) var(--sp-4); } | |
| 1923 | p.reactions { color: var(--muted); font-size: var(--fs-1); } | |
| 1924 | article.comment .reactions { border-top: 1px solid var(--faint); } | |
| 1925 | button.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 | } | |
| 1935 | button.react { cursor: pointer; } | |
| 1936 | button.react:hover { background: var(--surface); } | |
| 1937 | button.react.mine { border-color: var(--accent); background: var(--surface); color: var(--fg); } | |
internal/web/templates/issue.html +3 −1
| @@ -22,12 +22,14 @@ | ||
| 22 | 22 | {{if .BodyHTML}}<article class="comment"> |
| 23 | 23 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> |
| 24 | 24 | <div class="rendered">{{.BodyHTML}}</div> |
| 25 | </article>{{end}} | |
| 25 | {{template "reactions" index .Reactions 0}} | |
| 26 | </article>{{else}}{{template "reactions" index .Reactions 0}}{{end}} | |
| 26 | 27 | {{range .Comments}} |
| 27 | 28 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 28 | 29 | {{else}}<article class="comment"> |
| 29 | 30 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 30 | 31 | <div class="rendered">{{.BodyHTML}}</div> |
| 32 | {{template "reactions" index $.Reactions .ID}} | |
| 31 | 33 | </article>{{end}} |
| 32 | 34 | {{end}} |
| 33 | 35 | {{if .Viewer}} |
internal/web/templates/layout.html +6
| @@ -182,6 +182,12 @@ | ||
| 182 | 182 | {{/* previewbtn is the Preview submit beside a markup form's own submit. |
| 183 | 183 | It posts the form to its usual action, which renders the body and |
| 184 | 184 | hands the page back instead of writing (#235). */}} |
| 185 | {{/* reactions is one item's reaction bar (reactionBar in httpd). A | |
| 186 | signed-in viewer gets a button per reaction; anyone else the counts. */}} | |
| 187 | {{define "reactions"}}{{if .Items}}{{if .Signed}}<form method="post" action="{{.Action}}" class="reactions">{{if .Comment}}<input type="hidden" name="comment" value="{{.Comment}}">{{end}} | |
| 188 | {{range .Items}}<button type="submit" name="{{if .Me}}remove{{else}}add{{end}}" value="{{.Name}}" class="react{{if .Me}} mine{{end}}" aria-pressed="{{.Me}}" aria-label="{{.Label}}">{{.Emoji}}{{if .Count}} <span>{{.Count}}</span>{{end}}</button> | |
| 189 | {{end}}</form>{{else}}<p class="reactions">{{range .Items}}<span class="react" aria-label="{{.Label}}">{{.Emoji}} {{.Count}}</span> {{end}}</p>{{end}}{{end}}{{end}} | |
| 190 | ||
| 185 | 191 | {{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}} |
| 186 | 192 | |
| 187 | 193 | {{/* previewblock shows a draft as it will read. The argument is the |
internal/web/templates/mr.html +3 −1
| @@ -37,12 +37,14 @@ | ||
| 37 | 37 | {{if .BodyHTML}}<article class="comment"> |
| 38 | 38 | <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> |
| 39 | 39 | <div class="rendered">{{.BodyHTML}}</div> |
| 40 | </article>{{end}} | |
| 40 | {{template "reactions" index .Reactions 0}} | |
| 41 | </article>{{else}}{{template "reactions" index .Reactions 0}}{{end}} | |
| 41 | 42 | {{range .Comments}} |
| 42 | 43 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 43 | 44 | {{else}}<article class="comment"> |
| 44 | 45 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 45 | 46 | <div class="rendered">{{.BodyHTML}}</div> |
| 47 | {{template "reactions" index $.Reactions .ID}} | |
| 46 | 48 | </article>{{end}} |
| 47 | 49 | {{end}} |
| 48 | 50 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> |