Commit 5ccfa71112
Verified · cmc ci/build: success ci/test: success ci/vuln: success
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 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 12 | func 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 | |||
| 43 | func 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 | ||
| 3 | import ( | 3 | import ( |
| 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 | 14 | func 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>]", |
| 14 | func 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. | ||
| 33 | type 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. | ||
| 50 | func 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. |
| 34 | func notifyBody(c *Ctx, action, excerpt, path string) string { | 72 | func 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 { | |||
| 51 | func mrSubject(repo store.Repo, number int64, title string) string { | 92 | func 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. | ||
| 97 | const noticesDefaultLimit = 50 | ||
| 98 | |||
| 99 | func 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 | |||
| 146 | func 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 | |||
| 174 | func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watching") } | ||
| 175 | func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "muted") } | ||
| 176 | |||
| 177 | func 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 | ||
| 66 | var ( | 67 | var ( |
| 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. | ||
| 231 | func (w *Worker) notify(repo store.Repo, number int64, action, body string) { | 232 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 11 | type 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. | ||
| 24 | const 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. | ||
| 35 | func (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. | ||
| 45 | func (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. | ||
| 77 | func (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. | ||
| 87 | func (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. | ||
| 106 | func (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. | ||
| 115 | func (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. | ||
| 127 | func (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 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func 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 | |||
| 26 | func 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. | ||
| 69 | func 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 | |||
| 86 | func 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. | ||
| 111 | func 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 @@ | |||
| 1 | DROP TABLE repo_watchers; | ||
| 2 | DROP 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. | ||
| 5 | CREATE 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. | ||
| 18 | CREATE 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. | ||
| 23 | CREATE 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 | ); | ||