Commit 245c368edd

245c368eddd2cb9dead14d34ef0daf3c836588f6

parent: 3f4afd2210

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

cmc <hello@cleberg.net> · 2026-09-11 19:10 UTC

store, control, web, wiki: notifications settings watch on|off

A per-account preference that makes the account a recipient of every
issue and merge request on the repositories it can write to. Migration
0053 adds users.notify_watch, off by default. NotifyRecipients consults
it when widening: accounts with it on that hold write or admin through
EffectiveAccess join the watchers, so no grant path writes a row and a
revoke needs no cleanup. An explicit watch or mute row and a direct
notice behave as before. The account page carries the switch beside
the mail one.

Closes #194
.gitbay/wiki/Parity.org +5 −2
@@ -298,6 +298,7 @@ client has no use for one (krz/gitbay#57).
298298| dashboard aggregate | yes | yes | yes |
299299| notification inbox | yes | yes | yes |
300300| activity mail on, off | yes | yes | yes |
301| watch writable repos | yes | yes | no |
301302| API token mint | yes | no | no |
302303| account export bundle | yes | yes | n/a |
303304| profile set | yes | yes | yes |
@@ -311,8 +312,10 @@ configured. =notifications list= reads it, unread by default;
311312web rail carry the unread count. =repo watch= adds you to a
312313repository's notifications, =repo mute= silences it, and =repo unwatch=
313314returns you to the default from either — told about work you are part
314of, nothing more. A mute wins over owning the repository or having
315written the thread.
315of, nothing more. =notifications settings watch on= widens that default
316to every repository you can write to, without a row per repository. A
317mute wins over owning the repository, having written the thread, or the
318preference.
316319
317320A login link is requested from the login page by username or verified
318321address, and arrives by mail: it works once and expires in fifteen
.gitbay/wiki/Users.org +7 −1
@@ -612,7 +612,13 @@ filing the inbox row: someone opens an issue or MR on your repository,
612612comments where you are a participant (author, commenter, reviewer, or
613613mentioned), reviews, closes, or merges. =notifications settings mail
614614off= keeps the inbox and stops that mail (login links are not activity
615and still arrive); the account page has the same switch. Writing =@name= in an issue, merge request or
615and still arrive); the account page has the same switch.
616=notifications settings watch on= makes you a recipient of every issue
617and merge request on the repositories you can write to, as if you had
618run =repo watch= on each: it is consulted when a notice is delivered,
619so a grant or a revoke needs no watch row, and an explicit watch or
620mute on a repository still wins. Off by default; the account page has
621this switch too. Writing =@name= in an issue, merge request or
616622comment files "mentioned you" in that account's inbox and makes them a
617623participant of the thread, provided they can read the repository and
618624have not muted it; watchers are not told about a mention addressed to
cmd/gitbay/main.go +1
@@ -69,6 +69,7 @@ func newRoot() *cobra.Command {
6969 group("settings", "notification preferences",
7070 pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}),
7171 pass("mail", "activity by mail as well as the inbox: on|off", passOpts{server: []string{"notifications", "settings", "mail"}}),
72 pass("watch", "every issue and merge request on repositories you can write to: on|off", passOpts{server: []string{"notifications", "settings", "watch"}}),
7273 ),
7374 ),
7475 group("wiki", "a repository's wiki pages",
e2e/watchpref_test.go added +77
@@ -0,0 +1,77 @@
1package e2e
2
3import (
4 "net/url"
5 "strings"
6 "testing"
7)
8
9// notifications settings watch on makes an account a recipient of every
10// issue on the repositories it can write to, with no repo watch row;
11// read access is not enough, and off returns it to the default. The
12// account page carries the same switch (#194).
13func TestWatchPreference(t *testing.T) {
14 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
15 aliceKey := inst.newKey(t, "alice")
16 bobKey := inst.newKey(t, "bob")
17 carolKey := inst.newKey(t, "carol")
18 daveKey := inst.newKey(t, "dave")
19 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
20 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
21 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
22 inst.admin(t, "admin", "user", "create", "dave", "--key", daveKey+".pub")
23 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
24 t.Fatalf("repo create: %s", errOut)
25 }
26 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
27 t.Fatalf("grant bob: %s", errOut)
28 }
29 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "carol", "read"); code != 0 {
30 t.Fatalf("grant carol: %s", errOut)
31 }
32
33 out, _, code := inst.ssh(t, bobKey, "", "notifications", "settings", "show", "--json")
34 if code != 0 || !strings.Contains(out, `"watch":false`) {
35 t.Fatalf("settings show: %d %s", code, out)
36 }
37 if _, _, code := inst.ssh(t, bobKey, "", "notifications", "settings", "watch", "maybe"); code != 2 {
38 t.Fatalf("bad value accepted: %d", code)
39 }
40 for _, key := range []string{bobKey, carolKey} {
41 if out, _, code := inst.ssh(t, key, "", "notifications", "settings", "watch", "on", "--json"); code != 0 || !strings.Contains(out, `"watch":true`) {
42 t.Fatalf("watch on: %d %s", code, out)
43 }
44 }
45
46 if _, errOut, code := inst.ssh(t, daveKey, "", "issue", "create", "alice/app", "--title", "'first'"); code != 0 {
47 t.Fatalf("issue create: %s", errOut)
48 }
49 if rows := notices(t, inst, bobKey); len(rows) != 1 || !strings.Contains(rows[0].Summary, "opened issue #1") {
50 t.Fatalf("writer's inbox: %+v", rows)
51 }
52 if rows := notices(t, inst, carolKey); len(rows) != 0 {
53 t.Fatalf("reader's inbox: %+v", rows)
54 }
55 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"watch":"watching"`) {
56 t.Fatalf("preference wrote a watch row: %s", out)
57 }
58
59 // The account page shows it on and turns it off.
60 bob := inst.login(t, bobKey)
61 set := inst.base() + "/settings"
62 if _, body := browserGet(t, bob, set); !strings.Contains(body, `name="watch" value="on" checked`) {
63 t.Fatalf("account page does not show watch on:\n%s", body)
64 }
65 if status, _ := browserPost(t, bob, set, url.Values{"field": {"notify-watch"}}); status != 200 {
66 t.Fatalf("settings post: %d", status)
67 }
68 if out, _, _ := inst.ssh(t, bobKey, "", "notifications", "settings", "show", "--json"); !strings.Contains(out, `"watch":false`) {
69 t.Fatalf("web toggle did not turn watch off: %s", out)
70 }
71 if _, errOut, code := inst.ssh(t, daveKey, "", "issue", "create", "alice/app", "--title", "'second'"); code != 0 {
72 t.Fatalf("second issue: %s", errOut)
73 }
74 if rows := notices(t, inst, bobKey); len(rows) != 1 {
75 t.Fatalf("inbox after watch off: %+v", rows)
76 }
77}
internal/control/notifications.go +28 −6
@@ -27,6 +27,9 @@ func init() {
2727 register(Command{Path: []string{"notifications", "settings", "mail"},
2828 Summary: "activity by mail as well as the inbox (login links are unaffected)",
2929 Usage: "notifications settings mail on|off", Run: runNotificationsSettingsMail})
30 register(Command{Path: []string{"notifications", "settings", "watch"},
31 Summary: "every issue and merge request on repositories you can write to",
32 Usage: "notifications settings watch on|off", Run: runNotificationsSettingsWatch})
3033 register(Command{Path: []string{"repo", "watch"},
3134 Summary: "hear about all activity on a repository",
3235 Usage: "repo watch <owner/name>", Run: runRepoWatch})
@@ -144,16 +147,22 @@ func mrSubject(repo store.Repo, number int64, title string) string {
144147}
145148
146149func emitNotificationSettings(c *Ctx) int {
147 on, err := c.Store.MailEnabled(c.User.ID)
150 mail, err := c.Store.MailEnabled(c.User.ID)
148151 if err != nil {
149152 return c.fail(protocol.ExitFailure, "%v", err)
150153 }
151 return c.emit(map[string]bool{"mail": on}, func(w io.Writer) {
152 state := "off"
153 if on {
154 state = "on"
154 watch, err := c.Store.WatchEnabled(c.User.ID)
155 if err != nil {
156 return c.fail(protocol.ExitFailure, "%v", err)
157 }
158 return c.emit(map[string]bool{"mail": mail, "watch": watch}, func(w io.Writer) {
159 onOff := func(on bool) string {
160 if on {
161 return "on"
162 }
163 return "off"
155164 }
156 fmt.Fprintf(w, "mail: %s\n", state)
165 fmt.Fprintf(w, "mail: %s\nwatch: %s\n", onOff(mail), onOff(watch))
157166 })
158167}
159168
@@ -176,6 +185,19 @@ func runNotificationsSettingsMail(c *Ctx, args []string) int {
176185 return emitNotificationSettings(c)
177186}
178187
188// runNotificationsSettingsWatch is the default watch state for
189// repositories the account can write to: consulted when a notice is
190// delivered, so a grant or a revoke needs no watch row of its own (#194).
191func runNotificationsSettingsWatch(c *Ctx, args []string) int {
192 if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
193 return c.fail(protocol.ExitUsage, "usage: notifications settings watch on|off")
194 }
195 if err := c.Store.SetWatchEnabled(c.User.ID, args[0] == "on"); err != nil {
196 return c.fail(protocol.ExitFailure, "%v", err)
197 }
198 return emitNotificationSettings(c)
199}
200
179201// noticesDefaultLimit caps a bare list; pagination reaches further back.
180202const noticesDefaultLimit = 50
181203
internal/httpd/account.go +7 −4
@@ -54,6 +54,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
5454 var profile control.ProfileOut
5555 s.runControlInto(u, []string{"profile", "show"}, &profile)
5656 mailOn, _ := s.st.MailEnabled(u.ID)
57 watchOn, _ := s.st.WatchEnabled(u.ID)
5758
5859 s.render(w, "account.html", struct {
5960 basePage
@@ -67,8 +68,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
6768 Notice string
6869 Message string
6970 MailOn bool
71 WatchOn bool
7072 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
71 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn})
73 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn})
7274}
7375
7476// accountExport hands the browser the same bundle `account export`
@@ -197,12 +199,13 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
197199 return
198200 }
199201 back("", "primary address changed")
200 case "notify-mail":
202 case "notify-mail", "notify-watch":
203 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
201204 state := "off"
202 if r.FormValue("mail") == "on" {
205 if r.FormValue(pref) == "on" {
203206 state = "on"
204207 }
205 if _, msg, ok := s.runControl(u, []string{"notifications", "settings", "mail", state}); !ok {
208 if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
206209 back(msg, "")
207210 return
208211 }
internal/store/inbox.go +45 −3
@@ -128,9 +128,10 @@ func (s *Store) RepoWatchState(repoID, userID int64) string {
128128
129129// NotifyRecipients is who actually hears about something on a repository:
130130// the callers targets — owners, or a thread's participants — widened by
131// the repository's watchers, minus the actor and minus anyone who muted
132// it. Muting wins over every other reason to be told, including owning
133// the repository or having written the thread.
131// the repository's watchers and by accounts whose watch preference covers
132// it, minus the actor and minus anyone who muted it. Muting wins over
133// every other reason to be told, including owning the repository or
134// having written the thread.
134135func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen bool) ([]int64, error) {
135136 rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID)
136137 if err != nil {
@@ -158,6 +159,12 @@ func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen b
158159 seen := map[int64]bool{}
159160 if !widen {
160161 watching = nil
162 } else {
163 byPref, err := s.defaultWatchers(repoID)
164 if err != nil {
165 return nil, err
166 }
167 watching = append(watching, byPref...)
161168 }
162169 for _, id := range append(append([]int64{}, targets...), watching...) {
163170 if skip[id] || seen[id] {
@@ -168,3 +175,38 @@ func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen b
168175 }
169176 return out, nil
170177}
178
179// defaultWatchers is every account with the watch preference on that can
180// write to the repository (#194). The preference is read first because
181// it is one column and usually off everywhere, which keeps the access
182// fold off the path of most notices.
183func (s *Store) defaultWatchers(repoID int64) ([]int64, error) {
184 rows, err := s.DB.Query("SELECT id, username FROM users WHERE notify_watch = 1")
185 if err != nil {
186 return nil, err
187 }
188 defer rows.Close()
189 ids := map[string]int64{}
190 for rows.Next() {
191 var id int64
192 var name string
193 if err := rows.Scan(&id, &name); err != nil {
194 return nil, err
195 }
196 ids[name] = id
197 }
198 if err := rows.Err(); err != nil || len(ids) == 0 {
199 return nil, err
200 }
201 access, err := s.EffectiveAccess(repoID)
202 if err != nil {
203 return nil, err
204 }
205 var out []int64
206 for _, e := range access {
207 if id, ok := ids[e.Username]; ok && (e.Role == "write" || e.Role == "admin") {
208 out = append(out, id)
209 }
210 }
211 return out, nil
212}
internal/store/inbox_test.go +65
@@ -172,3 +172,68 @@ func TestNotifyRecipients(t *testing.T) {
172172 t.Fatal("watch did not replace mute")
173173 }
174174}
175
176// With the watch preference on, an account that can write to a
177// repository is a recipient without a repo_watchers row; read access,
178// the preference off, a mute, and a direct notice each leave it out.
179func TestNotifyRecipientsDefaultWatch(t *testing.T) {
180 s, repoID, owner, other := inboxFixture(t)
181 writer, err := s.CreateUser("lee", false)
182 if err != nil {
183 t.Fatal(err)
184 }
185 reader, err := s.CreateUser("pat", false)
186 if err != nil {
187 t.Fatal(err)
188 }
189 if err := s.GrantAccess(repoID, writer, "write"); err != nil {
190 t.Fatal(err)
191 }
192 if err := s.GrantAccess(repoID, reader, "read"); err != nil {
193 t.Fatal(err)
194 }
195 for _, id := range []int64{writer, reader} {
196 if on, err := s.WatchEnabled(id); err != nil || on {
197 t.Fatalf("default preference = %v, %v", on, err)
198 }
199 if err := s.SetWatchEnabled(id, true); err != nil {
200 t.Fatal(err)
201 }
202 }
203 if on, _ := s.WatchEnabled(writer); !on {
204 t.Fatal("preference not recorded")
205 }
206
207 got, err := s.NotifyRecipients(repoID, other, []int64{owner}, true)
208 if err != nil {
209 t.Fatal(err)
210 }
211 if len(got) != 2 || got[0] != owner || got[1] != writer {
212 t.Fatalf("recipients = %v, want [%d %d]", got, owner, writer)
213 }
214 // The preference does not turn a direct notice into a broadcast.
215 if got, _ := s.NotifyRecipients(repoID, other, []int64{owner}, false); len(got) != 1 {
216 t.Fatalf("direct notice widened: %v", got)
217 }
218 // The writer acting is not told about their own action.
219 if got, _ := s.NotifyRecipients(repoID, writer, []int64{owner}, true); len(got) != 1 || got[0] != owner {
220 t.Fatalf("actor notified: %v", got)
221 }
222 // A mute on the repository beats the preference.
223 if err := s.SetRepoWatch(repoID, writer, "muted"); err != nil {
224 t.Fatal(err)
225 }
226 if got, _ := s.NotifyRecipients(repoID, other, []int64{owner}, true); len(got) != 1 {
227 t.Fatalf("muted writer notified: %v", got)
228 }
229 if err := s.ClearRepoWatch(repoID, writer); err != nil {
230 t.Fatal(err)
231 }
232 // Turning it off returns the writer to the default.
233 if err := s.SetWatchEnabled(writer, false); err != nil {
234 t.Fatal(err)
235 }
236 if got, _ := s.NotifyRecipients(repoID, other, []int64{owner}, true); len(got) != 1 {
237 t.Fatalf("preference off still widens: %v", got)
238 }
239}
internal/store/migrations/0053_notify_watch.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users DROP COLUMN notify_watch;
internal/store/migrations/0053_notify_watch.up.sql added +5
@@ -0,0 +1,5 @@
1-- Whether the account hears about every issue and merge request on the
2-- repositories it can write to, as if it had run repo watch on each.
3-- Consulted when a notice is delivered, not written on grant; an explicit
4-- watch or mute row on the repository wins (#194).
5ALTER TABLE users ADD COLUMN notify_watch INTEGER NOT NULL DEFAULT 0;
internal/store/users.go +21
@@ -216,6 +216,27 @@ func (s *Store) SetMailEnabled(userID int64, on bool) error {
216216 return err
217217}
218218
219// WatchEnabled reports whether the account hears about every issue and
220// merge request on the repositories it can write to, without a
221// repo_watchers row on each (#194).
222func (s *Store) WatchEnabled(userID int64) (bool, error) {
223 var on int
224 err := s.DB.QueryRow("SELECT notify_watch FROM users WHERE id = ?", userID).Scan(&on)
225 if errors.Is(err, sql.ErrNoRows) {
226 return false, ErrNotFound
227 }
228 return on != 0, err
229}
230
231func (s *Store) SetWatchEnabled(userID int64, on bool) error {
232 v := 0
233 if on {
234 v = 1
235 }
236 _, err := s.DB.Exec("UPDATE users SET notify_watch = ? WHERE id = ?", v, userID)
237 return err
238}
239
219240func (s *Store) UserByID(id int64) (User, error) {
220241 var u User
221242 var admin, pending, disabled int
internal/web/templates/account.html +7
@@ -109,6 +109,13 @@ account, and where notifications go.</p>
109109 <button type="submit">Save</button>
110110</form>
111111<p class="meta">The inbox is filed either way; this is the mail half. Login links are not activity and still arrive.</p>
112<form method="post" action="/settings" class="setform">
113 <input type="hidden" name="field" value="notify-watch">
114 <label for="notify-watch">Watch repositories you can write to</label>
115 <input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}>
116 <button type="submit">Save</button>
117</form>
118<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p>
112119
113120<h2>Export</h2>
114121<p class="meta">Your profile, repositories, issues and merge requests as one