Commit 5ccfa71112

5ccfa71112a03d5162ac703ce2db8e4a34dc3240

parent: 3f74a298ff

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

cmc <hello@cleberg.net> · 2026-09-04 13:48 UTC

notifications: an inbox, and repositories you can watch or mute

Notifications were mail or nothing: an instance without SMTP told nobody
anything, and an account with mail had no way to see what it had already
been sent.

An `inbox` table holds one row per notification — actor, summary, and the
web path it points at — filed whether or not the instance sends mail.
`notifications list` reads it, unread by default and `--all` for the rest;
`notifications read <id>... | --all` clears it. The dashboard carries the
unread count so a client's badge costs no second read.

`repo_watchers` widens or narrows who hears: `repo watch` adds you to a
repository's notifications, `repo unwatch` mutes it. A mute wins over
every other reason to be told, including owning the repository or having
written the thread.

notifyUsers and notifyBody become one `notify(c, ids, notice)`, since an
inbox row needs the repo, kind and link that the old pair only ever
composed into a mail body.

Ref #113

Layout: unified · split

cmd/gitbay/main.go +8
@@ -59,6 +59,12 @@ func newRoot() *cobra.Command {
59 passOpts{server: []string{"feed"}}), 59 passOpts{server: []string{"feed"}}),
60 pass("explore", "public repositories on this instance [--limit n] [--cursor c]", 60 pass("explore", "public repositories on this instance [--limit n] [--cursor c]",
61 passOpts{server: []string{"explore"}}), 61 passOpts{server: []string{"explore"}}),
62 group("notifications", "your notification inbox",
63 pass("list", "unread notifications, or [--all] [--limit n] [--cursor c]",
64 passOpts{server: []string{"notifications", "list"}}),
65 pass("read", "mark notifications read: <id>... | --all",
66 passOpts{server: []string{"notifications", "read"}}),
67 ),
62 group("wiki", "a repository's wiki pages", 68 group("wiki", "a repository's wiki pages",
63 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}), 69 pass("list", "list pages: [<owner/name>]", passOpts{server: []string{"wiki", "list"}, needsRepo: true}),
64 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}), 70 pass("show", "print a page: [<owner/name>] [<page>]", passOpts{server: []string{"wiki", "show"}, needsRepo: true}),
@@ -374,6 +380,8 @@ func repoCmd() *cobra.Command {
374 passOpts{server: []string{"repo", "download"}, needsRepo: true}), 380 passOpts{server: []string{"repo", "download"}, needsRepo: true}),
375 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}), 381 pass("pin", "pin a repository to your dashboard", passOpts{server: []string{"repo", "pin"}, needsRepo: true}),
376 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}), 382 pass("unpin", "unpin a repository", passOpts{server: []string{"repo", "unpin"}, needsRepo: true}),
383 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}),
384 pass("unwatch", "mute a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}),
377 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), 385 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
378 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), 386 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
379 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), 387 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
e2e/inbox_test.go added +144
@@ -0,0 +1,144 @@
1package e2e
2
3import (
4 "encoding/json"
5 "strconv"
6 "strings"
7 "testing"
8)
9
10// notices reads a user's inbox. The instance under test has no SMTP, so
11// this also asserts the inbox is filed independently of mail.
12func notices(t *testing.T, inst *instance, key string, args ...string) []struct {
13 ID int64 `json:"id"`
14 Repo string `json:"repo"`
15 Kind string `json:"kind"`
16 Actor string `json:"actor"`
17 Summary string `json:"summary"`
18 Path string `json:"path"`
19 ReadAt string `json:"read_at"`
20} {
21 t.Helper()
22 out, errOut, code := inst.ssh(t, key, "", append([]string{"notifications", "list"}, append(args, "--json")...)...)
23 if code != 0 {
24 t.Fatalf("notifications list: %s", errOut)
25 }
26 var env struct {
27 Data []struct {
28 ID int64 `json:"id"`
29 Repo string `json:"repo"`
30 Kind string `json:"kind"`
31 Actor string `json:"actor"`
32 Summary string `json:"summary"`
33 Path string `json:"path"`
34 ReadAt string `json:"read_at"`
35 } `json:"data"`
36 }
37 if err := json.Unmarshal([]byte(out), &env); err != nil {
38 t.Fatalf("not JSON: %v\n%s", err, out)
39 }
40 return env.Data
41}
42
43func TestNotificationInbox(t *testing.T) {
44 inst := startInstance(t)
45
46 aliceKey := inst.newKey(t, "alice")
47 bobKey := inst.newKey(t, "bob")
48 eveKey := inst.newKey(t, "eve")
49 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
50 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
51 inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub")
52
53 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
54 t.Fatalf("repo create: %s", errOut)
55 }
56
57 // Bob opens an issue. The owner hears about it; the actor does not.
58 if _, errOut, code := inst.ssh(t, bobKey, "", "issue", "create", "alice/app",
59 "--title", "'it leaks'", "--body", "'memory climbs'"); code != 0 {
60 t.Fatalf("issue create: %s", errOut)
61 }
62 got := notices(t, inst, aliceKey)
63 if len(got) != 1 || got[0].Actor != "bob" || got[0].Summary != "opened issue #1" ||
64 got[0].Repo != "alice/app" || got[0].Kind != "issue" || got[0].Path != "alice/app/issues/1" {
65 t.Fatalf("alice inbox = %+v", got)
66 }
67 if n := notices(t, inst, bobKey); len(n) != 0 {
68 t.Fatalf("actor notified about own action: %+v", n)
69 }
70
71 // Eve neither owns the repository nor took part in the issue, so
72 // nothing has reached her.
73 if n := notices(t, inst, eveKey); len(n) != 0 {
74 t.Fatalf("uninvolved user notified: %+v", n)
75 }
76
77 // Watching widens the recipients to her.
78 if _, errOut, code := inst.ssh(t, eveKey, "", "repo", "watch", "alice/app"); code != 0 {
79 t.Fatalf("repo watch: %s", errOut)
80 }
81 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'on it'"); code != 0 {
82 t.Fatal("alice comment failed")
83 }
84 eve := notices(t, inst, eveKey)
85 if len(eve) != 1 || eve[0].Summary != "commented on #1" || eve[0].Actor != "alice" {
86 t.Fatalf("watcher inbox = %+v", eve)
87 }
88 if n := len(notices(t, inst, bobKey)); n != 1 {
89 t.Fatalf("issue author inbox = %d rows, want 1", n)
90 }
91
92 // Muting beats being a participant: bob wrote the issue and still
93 // hears nothing more, while the watcher does.
94 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "unwatch", "alice/app"); code != 0 {
95 t.Fatalf("repo unwatch: %s", errOut)
96 }
97 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'more'"); code != 0 {
98 t.Fatal("alice comment failed")
99 }
100 if n := len(notices(t, inst, bobKey)); n != 1 {
101 t.Fatalf("muted participant inbox = %d rows, want 1", n)
102 }
103 if n := len(notices(t, inst, eveKey)); n != 2 {
104 t.Fatalf("watcher inbox = %d rows, want 2", n)
105 }
106
107 // Reading one drops it from the default list but not from --all.
108 eve = notices(t, inst, eveKey)
109 if _, errOut, code := inst.ssh(t, eveKey, "", "notifications", "read", strconv.FormatInt(eve[0].ID, 10)); code != 0 {
110 t.Fatalf("notifications read: %s", errOut)
111 }
112 if n := len(notices(t, inst, eveKey)); n != 1 {
113 t.Fatalf("unread list = %d rows, want 1", n)
114 }
115 all := notices(t, inst, eveKey, "--all")
116 if len(all) != 2 || all[0].ReadAt == "" {
117 t.Fatalf("--all = %+v", all)
118 }
119
120 // The dashboard carries the badge count, and --all sweeps it to zero.
121 out, _, code := inst.ssh(t, eveKey, "", "dashboard", "--json")
122 if code != 0 || !strings.Contains(out, `"unread":1`) {
123 t.Fatalf("dashboard unread: %s", out)
124 }
125 if _, errOut, code := inst.ssh(t, eveKey, "", "notifications", "read", "--all"); code != 0 {
126 t.Fatalf("read --all: %s", errOut)
127 }
128 if n := len(notices(t, inst, eveKey)); n != 0 {
129 t.Fatalf("unread after sweep = %d", n)
130 }
131
132 // One account cannot mark another's notifications read.
133 alice := notices(t, inst, aliceKey)
134 if len(alice) == 0 {
135 t.Fatal("alice has no notices to test with")
136 }
137 if out, _, code := inst.ssh(t, eveKey, "", "notifications", "read", strconv.FormatInt(alice[0].ID, 10), "--json"); code != 0 ||
138 !strings.Contains(out, `"read":0`) {
139 t.Fatalf("cross-account read: %s", out)
140 }
141 if n := len(notices(t, inst, aliceKey)); n != len(alice) {
142 t.Fatal("another account cleared alice's inbox")
143 }
144}
e2e/readonly_test.go +1
@@ -142,6 +142,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
142 "release list": {"alice/app"}, 142 "release list": {"alice/app"},
143 "release show": {"alice/app", "v1"}, 143 "release show": {"alice/app", "v1"},
144 "release asset get": {"alice/app", "v1", "a.txt"}, 144 "release asset get": {"alice/app", "v1", "a.txt"},
145 "notifications list": nil,
145 "webhook list": {"alice/app"}, 146 "webhook list": {"alice/app"},
146 "webhook deliveries": {"alice/app"}, 147 "webhook deliveries": {"alice/app"},
147 "wiki list": {"alice/app"}, 148 "wiki list": {"alice/app"},
internal/control/build.go +5 −3
@@ -482,9 +482,11 @@ func runRunnerDone(c *Ctx, args []string) int {
482 } 482 }
483 tail = string(log) 483 tail = string(log)
484 } 484 }
485 notifyUsers(c, targets, 485 notify(c, targets, notice{repo: repo, kind: "build",
486 fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref), 486 subject: fmt.Sprintf("[%s] build %d failed: %s on %s", repo.Path(), b.Number, b.Job, b.Ref),
487 fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url)) 487 action: fmt.Sprintf("build %d failed: %s on %s", b.Number, b.Job, b.Ref),
488 body: fmt.Sprintf("job %s failed at %.10s.\n\n…%s\n\n%s\n", b.Job, b.SHA, tail, url),
489 path: fmt.Sprintf("%s/builds/%d", repo.Path(), b.Number)})
488 } 490 }
489 } 491 }
490 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) { 492 return c.emit(map[string]any{"build": b.Number, "status": args[1]}, func(w io.Writer) {
internal/control/dashboard.go +8 −1
@@ -69,7 +69,10 @@ func runDashboard(c *Ctx, args []string) int {
69 Pinned []pinnedOut `json:"pinned"` 69 Pinned []pinnedOut `json:"pinned"`
70 Activity []feedOut `json:"recent_activity"` 70 Activity []feedOut `json:"recent_activity"`
71 Builds []buildOut `json:"builds"` 71 Builds []buildOut `json:"builds"`
72 Server *serverOut `json:"server,omitempty"` 72 // Unread is the notification inbox badge, so a client showing one
73 // does not need a second read to fill it.
74 Unread int `json:"unread"`
75 Server *serverOut `json:"server,omitempty"`
73 // Queues is admin-only: every background worker's backlog and 76 // Queues is admin-only: every background worker's backlog and
74 // failures, the operator's view of what is stuck. 77 // failures, the operator's view of what is stuck.
75 Queues *store.Queues `json:"queues,omitempty"` 78 Queues *store.Queues `json:"queues,omitempty"`
@@ -140,6 +143,7 @@ func runDashboard(c *Ctx, args []string) int {
140 for _, b := range builds { 143 for _, b := range builds {
141 d.Builds = append(d.Builds, buildOut{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}) 144 d.Builds = append(d.Builds, buildOut{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
142 } 145 }
146 d.Unread = c.Store.UnreadNotices(c.User.ID)
143 if c.User.IsAdmin { 147 if c.User.IsAdmin {
144 d.Server = &serverOut{Commit: buildinfo.String()} 148 d.Server = &serverOut{Commit: buildinfo.String()}
145 q, err := c.Store.QueueStatus() 149 q, err := c.Store.QueueStatus()
@@ -150,6 +154,9 @@ func runDashboard(c *Ctx, args []string) int {
150 } 154 }
151 155
152 return c.emit(d, func(w io.Writer) { 156 return c.emit(d, func(w io.Writer) {
157 if d.Unread > 0 {
158 fmt.Fprintf(w, "unread notifications: %d\n", d.Unread)
159 }
153 fmt.Fprintln(w, "waiting on your review:") 160 fmt.Fprintln(w, "waiting on your review:")
154 printDashboardItems(w, d.Reviews, "!") 161 printDashboardItems(w, d.Reviews, "!")
155 fmt.Fprintln(w, "assigned to you:") 162 fmt.Fprintln(w, "assigned to you:")
internal/control/diffcomment.go +4 −3
@@ -103,9 +103,10 @@ func runDiffComment(c *Ctx, args []string) int {
103 return c.failErr(err) 103 return c.failErr(err)
104 } 104 }
105 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 105 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
106 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 106 notify(c, parts, notice{repo: repo, kind: "mr",
107 notifyBody(c, fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number), body, 107 subject: mrSubject(repo, mr.Number, mr.Title),
108 fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) 108 action: fmt.Sprintf("commented on %s:%d in !%d", path, line, mr.Number),
109 excerpt: body, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
109 } 110 }
110 return c.emit(map[string]any{"id": id, "thread": firstNonZero(replyTo, id)}, func(w io.Writer) { 111 return c.emit(map[string]any{"id": id, "thread": firstNonZero(replyTo, id)}, func(w io.Writer) {
111 if replyTo != 0 { 112 if replyTo != 0 {
internal/control/issue.go +8 −4
@@ -150,8 +150,10 @@ func runIssueCreate(c *Ctx, args []string) int {
150 } 150 }
151 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) 151 c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n))
152 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 152 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
153 notifyUsers(c, targets, issueSubject(repo, n, title), 153 notify(c, targets, notice{repo: repo, kind: "issue",
154 notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n))) 154 subject: issueSubject(repo, n, title),
155 action: fmt.Sprintf("opened issue #%d", n),
156 excerpt: b, path: fmt.Sprintf("%s/issues/%d", repo.Path(), n)})
155 } 157 }
156 return c.emit(Created{Number: n}, func(w io.Writer) { 158 return c.emit(Created{Number: n}, func(w io.Writer) {
157 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) 159 fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n)
@@ -268,8 +270,10 @@ func setIssueState(c *Ctx, args []string, state string) int {
268 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) 270 c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number))
269 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { 271 if parts, err := c.Store.IssueParticipants(issue.ID); err == nil {
270 verb := map[string]string{"open": "reopened", "closed": "closed"}[state] 272 verb := map[string]string{"open": "reopened", "closed": "closed"}[state]
271 notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title), 273 notify(c, parts, notice{repo: repo, kind: "issue",
272 notifyBody(c, fmt.Sprintf("%s #%d", verb, issue.Number), "", fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number))) 274 subject: issueSubject(repo, issue.Number, issue.Title),
275 action: fmt.Sprintf("%s #%d", verb, issue.Number),
276 path: fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number)})
273 } 277 }
274 return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) { 278 return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) {
275 fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state) 279 fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state)
internal/control/mr.go +20 −12
@@ -278,8 +278,10 @@ func runMRCreate(c *Ctx, args []string) int {
278 } 278 }
279 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) 279 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n))
280 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { 280 if targets, err := c.Store.RepoNotifyTargets(repo); err == nil {
281 notifyUsers(c, targets, mrSubject(repo, n, title), 281 notify(c, targets, notice{repo: repo, kind: "mr",
282 notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n))) 282 subject: mrSubject(repo, n, title),
283 action: fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target),
284 excerpt: b, path: fmt.Sprintf("%s/mrs/%d", repo.Path(), n)})
283 } 285 }
284 out := MRCreated{Number: n, HeadSHA: headSHA} 286 out := MRCreated{Number: n, HeadSHA: headSHA}
285 if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok { 287 if p, ok, err := c.Store.OpenMRBySource(repo.ID, target); err == nil && ok {
@@ -626,9 +628,10 @@ func runMRRetarget(c *Ctx, args []string) int {
626 } 628 }
627 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target)) 629 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
628 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 630 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
629 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 631 notify(c, parts, notice{repo: repo, kind: "mr",
630 notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target), "", 632 subject: mrSubject(repo, mr.Number, mr.Title),
631 fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) 633 action: fmt.Sprintf("retargeted !%d from %s to %s", mr.Number, old, target),
634 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
632 } 635 }
633 return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) { 636 return c.emit(map[string]any{"number": mr.Number, "target_ref": target, "merge_base": base}, func(w io.Writer) {
634 fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base) 637 fmt.Fprintf(w, "retargeted %s!%d from %s to %s (base %.10s)\n", repo.Path(), mr.Number, old, target, base)
@@ -676,8 +679,10 @@ func runMRReview(c *Ctx, args []string) int {
676 return c.fail(protocol.ExitFailure, "%v", err) 679 return c.fail(protocol.ExitFailure, "%v", err)
677 } 680 }
678 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 681 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
679 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 682 notify(c, parts, notice{repo: repo, kind: "mr",
680 notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) 683 subject: mrSubject(repo, mr.Number, mr.Title),
684 action: fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict),
685 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
681 } 686 }
682 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) { 687 return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) {
683 fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict) 688 fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict)
@@ -978,9 +983,10 @@ func runMRMerge(c *Ctx, args []string) int {
978 } 983 }
979 c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number)) 984 c.Store.AddMRSystemComment(k.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s: !%d merged", mr.SourceRef, mr.TargetRef, mr.Number))
980 if parts, err := c.Store.MRParticipants(k.ID); err == nil { 985 if parts, err := c.Store.MRParticipants(k.ID); err == nil {
981 notifyUsers(c, parts, mrSubject(repo, k.Number, k.Title), 986 notify(c, parts, notice{repo: repo, kind: "mr",
982 notifyBody(c, fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number), "", 987 subject: mrSubject(repo, k.Number, k.Title),
983 fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number))) 988 action: fmt.Sprintf("retargeted !%d from %s to %s: !%d merged", k.Number, mr.SourceRef, mr.TargetRef, mr.Number),
989 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), k.Number)})
984 } 990 }
985 } 991 }
986 // Merges bypass receive-pack, so the commit-message issue actions 992 // Merges bypass receive-pack, so the commit-message issue actions
@@ -1002,8 +1008,10 @@ func runMRMerge(c *Ctx, args []string) int {
1002 repo, c.User.ID, mr.TargetRef, newSHA, time.Now()) 1008 repo, c.User.ID, mr.TargetRef, newSHA, time.Now())
1003 c.Store.MarkMirrorsDirty(repo.ID, "push") 1009 c.Store.MarkMirrorsDirty(repo.ID, "push")
1004 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1010 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1005 notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), 1011 notify(c, parts, notice{repo: repo, kind: "mr",
1006 notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) 1012 subject: mrSubject(repo, mr.Number, mr.Title),
1013 action: fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy),
1014 path: fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number)})
1007 } 1015 }
1008 return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) { 1016 return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) {
1009 fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA) 1017 fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA)
internal/control/notifications.go +159 −17
@@ -2,45 +2,86 @@ package control
2 2
3import ( 3import (
4 "fmt" 4 "fmt"
5 "io"
6 "strconv"
5 "strings" 7 "strings"
6 8
9 "gitbay.org/gitbay/internal/policy"
10 "gitbay.org/gitbay/internal/protocol"
7 "gitbay.org/gitbay/internal/store" 11 "gitbay.org/gitbay/internal/store"
8) 12)
9 13
10// notifyUsers enqueues activity mail for the given user ids, excluding the 14func init() {
11// acting user and anyone without a verified primary email. A best-effort 15 register(Command{Path: []string{"notifications", "list"},
12// side channel: failures are ignored, the action itself already succeeded. 16 Summary: "your notification inbox, newest first",
13// No-op when the instance has no SMTP. 17 Usage: "notifications list [--all] [--limit <n>] [--cursor <c>]",
14func notifyUsers(c *Ctx, userIDs []int64, subject, body string) { 18 ReadOnly: true, Run: runNotificationsList})
15 if c.Cfg.Mail.SMTPHost == "" { 19 register(Command{Path: []string{"notifications", "read"},
20 Summary: "mark notifications read",
21 Usage: "notifications read <id>... | --all", Run: runNotificationsRead})
22 register(Command{Path: []string{"repo", "watch"},
23 Summary: "hear about all activity on a repository",
24 Usage: "repo watch <owner/name>", Run: runRepoWatch})
25 register(Command{Path: []string{"repo", "unwatch"},
26 Summary: "mute a repository, including work you are part of",
27 Usage: "repo unwatch <owner/name>", Run: runRepoUnwatch})
28}
29
30// notice is one thing that happened, in the shape both delivery routes
31// need: a mail subject and body, and an inbox row. The inbox is filed
32// whether or not the instance has SMTP; mail is the optional half.
33type notice struct {
34 repo store.Repo
35 kind string // issue, mr, or build
36 subject string // mail subject
37 action string // "opened issue #12" — also the inbox summary
38 excerpt string // quoted into the mail, not the inbox
39 path string // web path, no leading slash
40 // body replaces the composed mail body outright, for a notice whose
41 // mail is not prose — a failed build's log tail is not an excerpt of
42 // something someone wrote, and is not cut to an excerpt's length.
43 body string
44}
45
46// notify delivers a notice to the given user ids widened by the
47// repository's watchers, minus anyone who muted it and minus the acting
48// user. A best-effort side channel: failures are ignored, the action
49// itself already succeeded.
50func notify(c *Ctx, userIDs []int64, n notice) {
51 recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs)
52 if err != nil {
16 return 53 return
17 } 54 }
18 seen := map[int64]bool{c.User.ID: true} 55 sendMail := c.Cfg.Mail.SMTPHost != ""
19 for _, id := range userIDs { 56 body := noticeBody(c, n)
20 if seen[id] { 57 for _, id := range recipients {
58 c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
59 if !sendMail {
21 continue 60 continue
22 } 61 }
23 seen[id] = true
24 email, err := c.Store.PrimaryVerifiedEmail(id) 62 email, err := c.Store.PrimaryVerifiedEmail(id)
25 if err != nil || email == "" { 63 if err != nil || email == "" {
26 continue 64 continue
27 } 65 }
28 c.Store.EnqueueMail(email, subject, body) 66 c.Store.EnqueueMail(email, n.subject, body)
29 } 67 }
30} 68}
31 69
32// notifyBody builds the standard notification body: who did what, an 70// noticeBody builds the standard mail body: who did what, an excerpt, and
33// excerpt, and the web link. 71// the web link.
34func notifyBody(c *Ctx, action, excerpt, path string) string { 72func noticeBody(c *Ctx, n notice) string {
73 if n.body != "" {
74 return n.body
75 }
35 var b strings.Builder 76 var b strings.Builder
36 fmt.Fprintf(&b, "%s %s\n", c.User.Username, action) 77 fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action)
37 if e := strings.TrimSpace(excerpt); e != "" { 78 if e := strings.TrimSpace(n.excerpt); e != "" {
38 if len(e) > 500 { 79 if len(e) > 500 {
39 e = e[:500] + "…" 80 e = e[:500] + "…"
40 } 81 }
41 fmt.Fprintf(&b, "\n%s\n", e) 82 fmt.Fprintf(&b, "\n%s\n", e)
42 } 83 }
43 fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), path) 84 fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path)
44 return b.String() 85 return b.String()
45} 86}
46 87
@@ -51,3 +92,104 @@ func issueSubject(repo store.Repo, number int64, title string) string {
51func mrSubject(repo store.Repo, number int64, title string) string { 92func mrSubject(repo store.Repo, number int64, title string) string {
52 return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title) 93 return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
53} 94}
95
96// noticesDefaultLimit caps a bare list; pagination reaches further back.
97const noticesDefaultLimit = 50
98
99func runNotificationsList(c *Ctx, args []string) int {
100 const usage = "notifications list [--all] [--limit <n>] [--cursor <c>]"
101 rest, p, code := parsePageFlags(c, args, "notifications", true)
102 if code >= 0 {
103 return code
104 }
105 fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: usage})
106 if err != nil {
107 return c.fail(protocol.ExitUsage, "%v", err)
108 }
109 all := fl.Has("--all")
110 if p.limit == 0 {
111 p.limit = noticesDefaultLimit
112 }
113 notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
114 if err != nil {
115 return c.fail(protocol.ExitFailure, "%v", err)
116 }
117 type out struct {
118 ID int64 `json:"id"`
119 Repo string `json:"repo"`
120 Kind string `json:"kind"`
121 Actor string `json:"actor"`
122 Summary string `json:"summary"`
123 Path string `json:"path"`
124 CreatedAt string `json:"created_at"`
125 ReadAt string `json:"read_at,omitempty"`
126 }
127 notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
128 return strconv.FormatInt(n.ID, 10)
129 })
130 ds := make([]out, 0, len(notices))
131 for _, n := range notices {
132 ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
133 }
134 return c.emitPage(p, ds, next, func(w io.Writer) {
135 for _, d := range ds {
136 mark := "*"
137 if d.ReadAt != "" {
138 mark = " "
139 }
140 fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n",
141 mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path)
142 }
143 })
144}
145
146func runNotificationsRead(c *Ctx, args []string) int {
147 const usage = "notifications read <id>... | --all"
148 fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: usage})
149 if err != nil {
150 return c.fail(protocol.ExitUsage, "%v", err)
151 }
152 // --all and a list of ids are two ways of saying which rows: taking
153 // both would leave which one won unstated.
154 if fl.Has("--all") == (len(fl.Pos) > 0) {
155 return c.fail(protocol.ExitUsage, "usage: %s", usage)
156 }
157 var ids []int64
158 for _, a := range fl.Pos {
159 n, err := strconv.ParseInt(a, 10, 64)
160 if err != nil {
161 return c.fail(protocol.ExitUsage, "bad notification id %q", a)
162 }
163 ids = append(ids, n)
164 }
165 n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
166 if err != nil {
167 return c.fail(protocol.ExitFailure, "%v", err)
168 }
169 return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
170 fmt.Fprintf(w, "marked %d read\n", n)
171 })
172}
173
174func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watching") }
175func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "muted") }
176
177func setWatch(c *Ctx, args []string, state string) int {
178 verb := "watch"
179 if state == "muted" {
180 verb = "unwatch"
181 }
182 if len(args) != 1 {
183 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
184 }
185 repo, code := resolveRepo(c, args[0], policy.CanRead)
186 if code >= 0 {
187 return code
188 }
189 if err := c.Store.SetRepoWatch(repo.ID, c.User.ID, state); err != nil {
190 return c.fail(protocol.ExitFailure, "%v", err)
191 }
192 return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
193 fmt.Fprintf(w, "%s %s\n", state, repo.Path())
194 })
195}
internal/control/thread.go +7 −5
@@ -61,11 +61,12 @@ type thread struct {
61 symbol string // "#" or "!" 61 symbol string // "#" or "!"
62 segment string // "issues" or "mrs" 62 segment string // "issues" or "mrs"
63 event string // issue.commented or mr.commented 63 event string // issue.commented or mr.commented
64 kind string // the noun an inbox row is filed under
64} 65}
65 66
66var ( 67var (
67 issueThread = thread{"#", "issues", "issue.commented"} 68 issueThread = thread{"#", "issues", "issue.commented", "issue"}
68 mrThread = thread{"!", "mrs", "mr.commented"} 69 mrThread = thread{"!", "mrs", "mr.commented", "mr"}
69) 70)
70 71
71// runComment is issue comment and mr comment: the noun's resolver hands 72// runComment is issue comment and mr comment: the noun's resolver hands
@@ -106,9 +107,10 @@ func runComment(c *Ctx, args []string, t thread, noun string,
106 } 107 }
107 c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number)) 108 c.Store.RecordEvent(repo.ID, c.User.ID, t.event, fmt.Sprintf(`{"number":%d}`, number))
108 if parts, err := participants(id); err == nil { 109 if parts, err := participants(id); err == nil {
109 subject := fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title) 110 notify(c, parts, notice{repo: repo, kind: t.kind,
110 notifyUsers(c, parts, subject, 111 subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
111 notifyBody(c, fmt.Sprintf("commented on %s%d", t.symbol, number), body, fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number))) 112 action: fmt.Sprintf("commented on %s%d", t.symbol, number),
113 excerpt: body, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
112 } 114 }
113 return c.emit(Created{Number: number}, func(w io.Writer) { 115 return c.emit(Created{Number: number}, func(w io.Writer) {
114 fmt.Fprintf(w, "commented on %s%s%d\n", repo.Path(), t.symbol, number) 116 fmt.Fprintf(w, "commented on %s%s%d\n", repo.Path(), t.symbol, number)
internal/deps/worker.go +16 −5
@@ -226,20 +226,31 @@ func (w *Worker) openIssue(repo store.Repo, number int64) (store.Issue, bool) {
226 return issue, true 226 return issue, true
227} 227}
228 228
229// notify mails the repo's owners, the same targets and shape as an issue 229// notify files an inbox row for the repo's owners and watchers and mails
230// filed over SSH. Best-effort, like every other notification. 230// them, the same targets and shape as an issue opened over SSH.
231// Best-effort, like every other notification.
231func (w *Worker) notify(repo store.Repo, number int64, action, body string) { 232func (w *Worker) notify(repo store.Repo, number int64, action, body string) {
232 if w.Cfg.Mail.SMTPHost == "" { 233 targets, err := w.St.RepoNotifyTargets(repo)
234 if err != nil {
233 return 235 return
234 } 236 }
235 targets, err := w.St.RepoNotifyTargets(repo) 237 author, err := w.St.UserByUsername(store.BotUsername)
238 if err != nil {
239 return
240 }
241 recipients, err := w.St.NotifyRecipients(repo.ID, author.ID, targets)
236 if err != nil { 242 if err != nil {
237 return 243 return
238 } 244 }
239 subject := fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, IssueTitle) 245 subject := fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, IssueTitle)
240 text := fmt.Sprintf("%s %s\n\n%s\n%s/%s/issues/%d\n", store.BotUsername, action, body, 246 text := fmt.Sprintf("%s %s\n\n%s\n%s/%s/issues/%d\n", store.BotUsername, action, body,
241 strings.TrimSuffix(w.Cfg.Server.SiteURL, "/"), repo.Path(), number) 247 strings.TrimSuffix(w.Cfg.Server.SiteURL, "/"), repo.Path(), number)
242 for _, id := range targets { 248 path := fmt.Sprintf("%s/issues/%d", repo.Path(), number)
249 for _, id := range recipients {
250 w.St.AddNotice(id, repo.ID, "issue", store.BotUsername, action, path)
251 if w.Cfg.Mail.SMTPHost == "" {
252 continue
253 }
243 email, err := w.St.PrimaryVerifiedEmail(id) 254 email, err := w.St.PrimaryVerifiedEmail(id)
244 if err != nil || email == "" { 255 if err != nil || email == "" {
245 continue 256 continue
internal/store/inbox.go added +160
@@ -0,0 +1,160 @@
1package store
2
3import (
4 "strconv"
5 "strings"
6)
7
8// Notice is one inbox row: what happened, where, and whether it has been
9// read. Path is the web path without a leading slash, so a client turns it
10// into a link by prefixing the site URL.
11type Notice struct {
12 ID int64
13 RepoPath string
14 Kind string
15 Actor string
16 Summary string
17 Path string
18 CreatedAt string
19 ReadAt string
20}
21
22// inboxSelect resolves the repository path the same way the dashboard
23// queries do, since repos.owner_id is polymorphic over users and orgs.
24const inboxSelect = `
25 SELECT n.id, COALESCE(u.username, o.name) || '/' || r.name,
26 n.kind, n.actor, n.summary, n.path, n.created_at, COALESCE(n.read_at, '')
27 FROM inbox n
28 JOIN repos r ON r.id = n.repo_id
29 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
30 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
31 WHERE n.user_id = ?1`
32
33// AddNotice files one inbox row. Best-effort like the mail it accompanies:
34// the action it reports has already succeeded.
35func (s *Store) AddNotice(userID, repoID int64, kind, actor, summary, path string) error {
36 _, err := s.DB.Exec(`
37 INSERT INTO inbox (user_id, repo_id, kind, actor, summary, path)
38 VALUES (?, ?, ?, ?, ?, ?)`,
39 userID, repoID, kind, actor, summary, path)
40 return err
41}
42
43// Inbox returns a user's notices, newest first. unreadOnly drops what has
44// been read; afterID pages backwards from a previous page's last row.
45func (s *Store) Inbox(userID int64, unreadOnly bool, limit int, afterID int64) ([]Notice, error) {
46 q, args := inboxSelect, []any{userID}
47 if unreadOnly {
48 q += " AND n.read_at IS NULL"
49 }
50 if afterID > 0 {
51 q += " AND n.id < ?2"
52 args = append(args, afterID)
53 }
54 q += " ORDER BY n.id DESC"
55 if limit > 0 {
56 q += " LIMIT ?" + strconv.Itoa(len(args)+1)
57 args = append(args, limit)
58 }
59 rows, err := s.DB.Query(q, args...)
60 if err != nil {
61 return nil, err
62 }
63 defer rows.Close()
64 var out []Notice
65 for rows.Next() {
66 var n Notice
67 if err := rows.Scan(&n.ID, &n.RepoPath, &n.Kind, &n.Actor, &n.Summary,
68 &n.Path, &n.CreatedAt, &n.ReadAt); err != nil {
69 return nil, err
70 }
71 out = append(out, n)
72 }
73 return out, rows.Err()
74}
75
76// UnreadNotices counts what the badge shows.
77func (s *Store) UnreadNotices(userID int64) int {
78 var n int
79 s.DB.QueryRow("SELECT COUNT(*) FROM inbox WHERE user_id = ? AND read_at IS NULL",
80 userID).Scan(&n)
81 return n
82}
83
84// MarkNoticesRead marks the given ids read, or every unread notice when
85// ids is empty. It returns how many rows changed. Ids belonging to another
86// user match nothing, so one user cannot touch another's inbox.
87func (s *Store) MarkNoticesRead(userID int64, ids []int64) (int64, error) {
88 const set = "UPDATE inbox SET read_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE user_id = ? AND read_at IS NULL"
89 args := []any{userID}
90 q := set
91 if len(ids) > 0 {
92 q += " AND id IN (" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")"
93 for _, id := range ids {
94 args = append(args, id)
95 }
96 }
97 res, err := s.DB.Exec(q, args...)
98 if err != nil {
99 return 0, err
100 }
101 return res.RowsAffected()
102}
103
104// SetRepoWatch records an explicit watch or mute. Re-running it with the
105// other state replaces the row.
106func (s *Store) SetRepoWatch(repoID, userID int64, state string) error {
107 _, err := s.DB.Exec(`
108 INSERT INTO repo_watchers (repo_id, user_id, state) VALUES (?, ?, ?)
109 ON CONFLICT (repo_id, user_id) DO UPDATE SET state = excluded.state`,
110 repoID, userID, state)
111 return err
112}
113
114// RepoWatchState returns "watching", "muted", or "" for the default.
115func (s *Store) RepoWatchState(repoID, userID int64) string {
116 var state string
117 s.DB.QueryRow("SELECT state FROM repo_watchers WHERE repo_id = ? AND user_id = ?",
118 repoID, userID).Scan(&state)
119 return state
120}
121
122// NotifyRecipients is who actually hears about something on a repository:
123// the callers targets — owners, or a thread's participants — widened by
124// the repository's watchers, minus the actor and minus anyone who muted
125// it. Muting wins over every other reason to be told, including owning
126// the repository or having written the thread.
127func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64) ([]int64, error) {
128 rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID)
129 if err != nil {
130 return nil, err
131 }
132 defer rows.Close()
133 skip := map[int64]bool{actorID: true}
134 var watching []int64
135 for rows.Next() {
136 var id int64
137 var state string
138 if err := rows.Scan(&id, &state); err != nil {
139 return nil, err
140 }
141 if state == "muted" {
142 skip[id] = true
143 } else {
144 watching = append(watching, id)
145 }
146 }
147 if err := rows.Err(); err != nil {
148 return nil, err
149 }
150 var out []int64
151 seen := map[int64]bool{}
152 for _, id := range append(append([]int64{}, targets...), watching...) {
153 if skip[id] || seen[id] {
154 continue
155 }
156 seen[id] = true
157 out = append(out, id)
158 }
159 return out, nil
160}
internal/store/inbox_test.go added +157
@@ -0,0 +1,157 @@
1package store
2
3import "testing"
4
5func inboxFixture(t *testing.T) (s *Store, repoID, owner, other int64) {
6 t.Helper()
7 s = open(t)
8 if err := s.MigrateUp(); err != nil {
9 t.Fatal(err)
10 }
11 owner, err := s.CreateUser("cmc", true)
12 if err != nil {
13 t.Fatal(err)
14 }
15 other, err = s.CreateUser("kim", false)
16 if err != nil {
17 t.Fatal(err)
18 }
19 repoID, err = s.CreateRepo("user", owner, "lib", "public")
20 if err != nil {
21 t.Fatal(err)
22 }
23 return s, repoID, owner, other
24}
25
26func TestInboxUnreadAndRead(t *testing.T) {
27 s, repoID, owner, _ := inboxFixture(t)
28 for _, n := range []string{"opened issue #1", "commented on #1", "closed #1"} {
29 if err := s.AddNotice(owner, repoID, "issue", "kim", n, "cmc/lib/issues/1"); err != nil {
30 t.Fatal(err)
31 }
32 }
33 if got := s.UnreadNotices(owner); got != 3 {
34 t.Fatalf("unread = %d, want 3", got)
35 }
36
37 // Newest first, and the repo path is resolved from the polymorphic owner.
38 got, err := s.Inbox(owner, true, 0, 0)
39 if err != nil {
40 t.Fatal(err)
41 }
42 if len(got) != 3 || got[0].Summary != "closed #1" || got[0].RepoPath != "cmc/lib" {
43 t.Fatalf("inbox = %+v", got)
44 }
45
46 // Marking one read drops it from the unread list but not from --all.
47 if n, err := s.MarkNoticesRead(owner, []int64{got[0].ID}); err != nil || n != 1 {
48 t.Fatalf("MarkNoticesRead = %d, %v", n, err)
49 }
50 if got := s.UnreadNotices(owner); got != 2 {
51 t.Fatalf("unread after read = %d, want 2", got)
52 }
53 all, _ := s.Inbox(owner, false, 0, 0)
54 if len(all) != 3 || all[0].ReadAt == "" {
55 t.Fatalf("all = %+v", all)
56 }
57
58 // The rest go in one sweep, and a second sweep changes nothing.
59 if n, _ := s.MarkNoticesRead(owner, nil); n != 2 {
60 t.Fatalf("sweep marked %d, want 2", n)
61 }
62 if n, _ := s.MarkNoticesRead(owner, nil); n != 0 {
63 t.Fatalf("second sweep marked %d, want 0", n)
64 }
65}
66
67// An id belonging to someone else matches nothing, so `notifications read
68// <id>` cannot reach into another account's inbox.
69func TestInboxIsPerUser(t *testing.T) {
70 s, repoID, owner, other := inboxFixture(t)
71 if err := s.AddNotice(owner, repoID, "issue", "kim", "opened issue #1", "cmc/lib/issues/1"); err != nil {
72 t.Fatal(err)
73 }
74 mine, _ := s.Inbox(owner, true, 0, 0)
75 if n, err := s.MarkNoticesRead(other, []int64{mine[0].ID}); err != nil || n != 0 {
76 t.Fatalf("cross-user read marked %d rows (%v)", n, err)
77 }
78 if s.UnreadNotices(owner) != 1 {
79 t.Fatal("another user's read cleared the owner's notice")
80 }
81 if got, _ := s.Inbox(other, true, 0, 0); len(got) != 0 {
82 t.Fatalf("other user sees %+v", got)
83 }
84}
85
86func TestInboxPaging(t *testing.T) {
87 s, repoID, owner, _ := inboxFixture(t)
88 for i := 0; i < 5; i++ {
89 if err := s.AddNotice(owner, repoID, "issue", "kim", "note", "cmc/lib/issues/1"); err != nil {
90 t.Fatal(err)
91 }
92 }
93 first, err := s.Inbox(owner, true, 2, 0)
94 if err != nil {
95 t.Fatal(err)
96 }
97 if len(first) != 2 {
98 t.Fatalf("first page %d rows, want 2", len(first))
99 }
100 next, err := s.Inbox(owner, true, 2, first[1].ID)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if len(next) != 2 || next[0].ID >= first[1].ID {
105 t.Fatalf("second page %+v does not follow %+v", next, first)
106 }
107}
108
109// Watching widens the recipients, muting removes a user who would
110// otherwise be told, and the actor is never notified of their own action.
111func TestNotifyRecipients(t *testing.T) {
112 s, repoID, owner, other := inboxFixture(t)
113 third, err := s.CreateUser("lee", false)
114 if err != nil {
115 t.Fatal(err)
116 }
117
118 got, err := s.NotifyRecipients(repoID, other, []int64{owner, other})
119 if err != nil {
120 t.Fatal(err)
121 }
122 if len(got) != 1 || got[0] != owner {
123 t.Fatalf("default recipients = %v, want [%d]", got, owner)
124 }
125
126 if err := s.SetRepoWatch(repoID, third, "watching"); err != nil {
127 t.Fatal(err)
128 }
129 if got, _ := s.NotifyRecipients(repoID, other, []int64{owner}); len(got) != 2 {
130 t.Fatalf("watcher not added: %v", got)
131 }
132
133 // A watcher who is also a target is listed once.
134 if got, _ := s.NotifyRecipients(repoID, other, []int64{owner, third}); len(got) != 2 {
135 t.Fatalf("watcher duplicated: %v", got)
136 }
137
138 // Muting beats owning the repository.
139 if err := s.SetRepoWatch(repoID, owner, "muted"); err != nil {
140 t.Fatal(err)
141 }
142 got, _ = s.NotifyRecipients(repoID, other, []int64{owner})
143 if len(got) != 1 || got[0] != third {
144 t.Fatalf("muted owner still notified: %v", got)
145 }
146 if s.RepoWatchState(repoID, owner) != "muted" {
147 t.Fatal("watch state not recorded")
148 }
149
150 // Watching after muting replaces the row rather than adding one.
151 if err := s.SetRepoWatch(repoID, owner, "watching"); err != nil {
152 t.Fatal(err)
153 }
154 if s.RepoWatchState(repoID, owner) != "watching" {
155 t.Fatal("watch did not replace mute")
156 }
157}
internal/store/migrations/0034_inbox.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TABLE repo_watchers;
2DROP TABLE inbox;
internal/store/migrations/0034_inbox.up.sql added +28
@@ -0,0 +1,28 @@
1-- The in-app notification inbox. The `notifications` table is the outbound
2-- mail queue and keeps that job; this is the per-user list a client reads.
3-- A row is a link plus enough text to decide whether to follow it, so the
4-- list renders without touching the issue or merge request it points at.
5CREATE TABLE inbox (
6 id INTEGER PRIMARY KEY,
7 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
9 kind TEXT NOT NULL,
10 actor TEXT NOT NULL,
11 summary TEXT NOT NULL,
12 path TEXT NOT NULL,
13 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
14 read_at TEXT
15);
16-- The unread list and the badge count are the only reads, both newest
17-- first per user.
18CREATE INDEX inbox_unread ON inbox(user_id, read_at, id DESC);
19
20-- Watching widens who hears about a repository beyond its owners and a
21-- thread's participants; muting narrows it, and wins over both. Absence of
22-- a row is the default: owners and participants, nobody else.
23CREATE TABLE repo_watchers (
24 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
25 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
26 state TEXT NOT NULL CHECK (state IN ('watching', 'muted')),
27 PRIMARY KEY (repo_id, user_id)
28);