Commit 67d2c52b58
Verified · cmc
Layout: unified · split
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", |
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/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 | } | |