Commit c89859bef9

c89859bef95d6b80473765066094d332ed92f525

parent: 4b8e4d61f0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 10:05 UTC

control: push as the third route in notify

Queued in the same loop as the inbox row and the mail, above the SMTP
check so an instance without a relay still pushes. The alert body is
the inbox summary, so the surfaces cannot disagree.

Ref #89
internal/control/notifications.go +9
@@ -90,6 +90,7 @@ func notify(c *Ctx, userIDs []int64, n notice) {
9090 body := noticeBody(c, n)
9191 for _, id := range recipients {
9292 c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
93 c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path)
9394 if !sendMail {
9495 continue
9596 }
@@ -155,6 +156,14 @@ func noticeBody(c *Ctx, n notice) string {
155156 return b.String()
156157}
157158
159// pushTitle and pushBody are the alert's two lines. The body is built
160// from the same two values AddNotice files, so the alert and the inbox
161// row cannot disagree about what happened. The title is the repository,
162// which also groups a repository's notices in Notification Center.
163func pushTitle(n notice) string { return n.repo.Path() }
164
165func pushBody(actor string, n notice) string { return actor + " " + n.action }
166
158167func issueSubject(repo store.Repo, number int64, title string) string {
159168 return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title)
160169}
internal/control/notifications_test.go +67
@@ -36,6 +36,73 @@ func notifTestCtx(t *testing.T, username string) *Ctx {
3636 }
3737}
3838
39// testRepoWithWatcher returns a Ctx acting as alice, a repository she
40// owns, and bob's user id with a watch row on it — the shared setup for
41// notify's recipient-widening tests.
42func testRepoWithWatcher(t *testing.T) (*Ctx, store.Repo, int64) {
43 t.Helper()
44 c := notifTestCtx(t, "alice")
45 repoID, err := c.Store.CreateRepo("user", c.User.ID, "app", "public")
46 if err != nil {
47 t.Fatal(err)
48 }
49 repo, err := c.Store.RepoByID(repoID)
50 if err != nil {
51 t.Fatal(err)
52 }
53 bob, err := c.Store.CreateUser("bob", false)
54 if err != nil {
55 t.Fatal(err)
56 }
57 if err := c.Store.SetRepoWatch(repo.ID, bob, "watching"); err != nil {
58 t.Fatal(err)
59 }
60 return c, repo, bob
61}
62
63func TestNotifyQueuesPush(t *testing.T) {
64 c, repo, bob := testRepoWithWatcher(t) // alice acts, bob watches
65 c.Store.AddPushDevice(bob, "tok-b", "iphone")
66
67 notify(c, []int64{bob}, notice{repo: repo, kind: "issue",
68 subject: "[alice/app] #1: title",
69 action: "opened issue #1",
70 path: "alice/app/issues/1"})
71
72 due, err := c.Store.DuePush(20)
73 if err != nil {
74 t.Fatalf("DuePush: %v", err)
75 }
76 if len(due) != 1 {
77 t.Fatalf("want one queued push, got %d", len(due))
78 }
79 // The push body is the inbox row's summary, so the two surfaces
80 // cannot disagree about what happened.
81 if due[0].Title != "alice/app" {
82 t.Fatalf("title = %q", due[0].Title)
83 }
84 if due[0].Body != "alice opened issue #1" {
85 t.Fatalf("body = %q", due[0].Body)
86 }
87 if due[0].Path != "alice/app/issues/1" {
88 t.Fatalf("path = %q", due[0].Path)
89 }
90}
91
92func TestNotifyQueuesNoPushForTheActor(t *testing.T) {
93 c, repo, _ := testRepoWithWatcher(t)
94 c.Store.AddPushDevice(c.User.ID, "tok-self", "iphone")
95
96 notify(c, []int64{c.User.ID}, notice{repo: repo, kind: "issue",
97 subject: "s", action: "opened issue #1", path: "alice/app/issues/1"})
98
99 // NotifyRecipients already drops the actor; push inherits that and
100 // must not find its own way around it.
101 if due, _ := c.Store.DuePush(20); len(due) != 0 {
102 t.Fatalf("queued a push to the actor")
103 }
104}
105
39106func TestNotificationsDeviceAddReadsStdin(t *testing.T) {
40107 c := notifTestCtx(t, "alice")
41108 c.Stdin = strings.NewReader("DEVTOKEN\n")