notifications settings watch on|off !377

merged merged by cmc on 2026-09-11 19:21 UTC · krz/gitbay:watch-default into main

12 files changed, +269 −16

Layout: unified · split

.gitbay/wiki/Parity.org +5 −2
@@ -298,6 +298,7 @@ client has no use for one (krz/gitbay#57).
298| dashboard aggregate | yes | yes | yes | 298| dashboard aggregate | yes | yes | yes |
299| notification inbox | yes | yes | yes | 299| notification inbox | yes | yes | yes |
300| activity mail on, off | yes | yes | yes | 300| activity mail on, off | yes | yes | yes |
301| watch writable repos | yes | yes | no |
301| API token mint | yes | no | no | 302| API token mint | yes | no | no |
302| account export bundle | yes | yes | n/a | 303| account export bundle | yes | yes | n/a |
303| profile set | yes | yes | yes | 304| profile set | yes | yes | yes |
@@ -311,8 +312,10 @@ configured. =notifications list= reads it, unread by default;
311web rail carry the unread count. =repo watch= adds you to a 312web rail carry the unread count. =repo watch= adds you to a
312repository's notifications, =repo mute= silences it, and =repo unwatch= 313repository's notifications, =repo mute= silences it, and =repo unwatch=
313returns you to the default from either — told about work you are part 314returns you to the default from either — told about work you are part
314of, nothing more. A mute wins over owning the repository or having 315of, nothing more. =notifications settings watch on= widens that default
315written the thread. 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.
316 319
317A login link is requested from the login page by username or verified 320A login link is requested from the login page by username or verified
318address, and arrives by mail: it works once and expires in fifteen 321address, 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,
612comments where you are a participant (author, commenter, reviewer, or 612comments where you are a participant (author, commenter, reviewer, or
613mentioned), reviews, closes, or merges. =notifications settings mail 613mentioned), reviews, closes, or merges. =notifications settings mail
614off= keeps the inbox and stops that mail (login links are not activity 614off= 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
616comment files "mentioned you" in that account's inbox and makes them a 622comment files "mentioned you" in that account's inbox and makes them a
617participant of the thread, provided they can read the repository and 623participant of the thread, provided they can read the repository and
618have not muted it; watchers are not told about a mention addressed to 624have 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 {
69 group("settings", "notification preferences", 69 group("settings", "notification preferences",
70 pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}), 70 pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}),
71 pass("mail", "activity by mail as well as the inbox: on|off", passOpts{server: []string{"notifications", "settings", "mail"}}), 71 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"}}),
72 ), 73 ),
73 ), 74 ),
74 group("wiki", "a repository's wiki pages", 75 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() {
27 register(Command{Path: []string{"notifications", "settings", "mail"}, 27 register(Command{Path: []string{"notifications", "settings", "mail"},
28 Summary: "activity by mail as well as the inbox (login links are unaffected)", 28 Summary: "activity by mail as well as the inbox (login links are unaffected)",
29 Usage: "notifications settings mail on|off", Run: runNotificationsSettingsMail}) 29 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})
30 register(Command{Path: []string{"repo", "watch"}, 33 register(Command{Path: []string{"repo", "watch"},
31 Summary: "hear about all activity on a repository", 34 Summary: "hear about all activity on a repository",
32 Usage: "repo watch <owner/name>", Run: runRepoWatch}) 35 Usage: "repo watch <owner/name>", Run: runRepoWatch})
@@ -144,16 +147,22 @@ func mrSubject(repo store.Repo, number int64, title string) string {
144} 147}
145 148
146func emitNotificationSettings(c *Ctx) int { 149func emitNotificationSettings(c *Ctx) int {
147 on, err := c.Store.MailEnabled(c.User.ID) 150 mail, err := c.Store.MailEnabled(c.User.ID)
148 if err != nil { 151 if err != nil {
149 return c.fail(protocol.ExitFailure, "%v", err) 152 return c.fail(protocol.ExitFailure, "%v", err)
150 } 153 }
151 return c.emit(map[string]bool{"mail": on}, func(w io.Writer) { 154 watch, err := c.Store.WatchEnabled(c.User.ID)
152 state := "off" 155 if err != nil {
153 if on { 156 return c.fail(protocol.ExitFailure, "%v", err)
154 state = "on" 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"
155 } 164 }
156 fmt.Fprintf(w, "mail: %s\n", state) 165 fmt.Fprintf(w, "mail: %s\nwatch: %s\n", onOff(mail), onOff(watch))
157 }) 166 })
158} 167}
159 168
@@ -176,6 +185,19 @@ func runNotificationsSettingsMail(c *Ctx, args []string) int {
176 return emitNotificationSettings(c) 185 return emitNotificationSettings(c)
177} 186}
178 187
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
179// noticesDefaultLimit caps a bare list; pagination reaches further back. 201// noticesDefaultLimit caps a bare list; pagination reaches further back.
180const noticesDefaultLimit = 50 202const noticesDefaultLimit = 50
181 203
internal/httpd/account.go +7 −4
@@ -54,6 +54,7 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
54 var profile control.ProfileOut 54 var profile control.ProfileOut
55 s.runControlInto(u, []string{"profile", "show"}, &profile) 55 s.runControlInto(u, []string{"profile", "show"}, &profile)
56 mailOn, _ := s.st.MailEnabled(u.ID) 56 mailOn, _ := s.st.MailEnabled(u.ID)
57 watchOn, _ := s.st.WatchEnabled(u.ID)
57 58
58 s.render(w, "account.html", struct { 59 s.render(w, "account.html", struct {
59 basePage 60 basePage
@@ -67,8 +68,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use
67 Notice string 68 Notice string
68 Message string 69 Message string
69 MailOn bool 70 MailOn bool
71 WatchOn bool
70 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(), 72 }{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})
72} 74}
73 75
74// accountExport hands the browser the same bundle `account export` 76// 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
197 return 199 return
198 } 200 }
199 back("", "primary address changed") 201 back("", "primary address changed")
200 case "notify-mail": 202 case "notify-mail", "notify-watch":
203 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
201 state := "off" 204 state := "off"
202 if r.FormValue("mail") == "on" { 205 if r.FormValue(pref) == "on" {
203 state = "on" 206 state = "on"
204 } 207 }
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 {
206 back(msg, "") 209 back(msg, "")
207 return 210 return
208 } 211 }
internal/store/inbox.go +45 −3
@@ -128,9 +128,10 @@ func (s *Store) RepoWatchState(repoID, userID int64) string {
128 128
129// NotifyRecipients is who actually hears about something on a repository: 129// NotifyRecipients is who actually hears about something on a repository:
130// the callers targets — owners, or a thread's participants — widened by 130// the callers targets — owners, or a thread's participants — widened by
131// the repository's watchers, minus the actor and minus anyone who muted 131// the repository's watchers and by accounts whose watch preference covers
132// it. Muting wins over every other reason to be told, including owning 132// it, minus the actor and minus anyone who muted it. Muting wins over
133// the repository or having written the thread. 133// every other reason to be told, including owning the repository or
134// having written the thread.
134func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen bool) ([]int64, error) { 135func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen bool) ([]int64, error) {
135 rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID) 136 rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID)
136 if err != nil { 137 if err != nil {
@@ -158,6 +159,12 @@ func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen b
158 seen := map[int64]bool{} 159 seen := map[int64]bool{}
159 if !widen { 160 if !widen {
160 watching = nil 161 watching = nil
162 } else {
163 byPref, err := s.defaultWatchers(repoID)
164 if err != nil {
165 return nil, err
166 }
167 watching = append(watching, byPref...)
161 } 168 }
162 for _, id := range append(append([]int64{}, targets...), watching...) { 169 for _, id := range append(append([]int64{}, targets...), watching...) {
163 if skip[id] || seen[id] { 170 if skip[id] || seen[id] {
@@ -168,3 +175,38 @@ func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen b
168 } 175 }
169 return out, nil 176 return out, nil
170} 177}
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) {
172 t.Fatal("watch did not replace mute") 172 t.Fatal("watch did not replace mute")
173 } 173 }
174} 174}
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 {
216 return err 216 return err
217} 217}
218 218
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
219func (s *Store) UserByID(id int64) (User, error) { 240func (s *Store) UserByID(id int64) (User, error) {
220 var u User 241 var u User
221 var admin, pending, disabled int 242 var admin, pending, disabled int
internal/web/templates/account.html +7
@@ -109,6 +109,13 @@ account, and where notifications go.</p>
109 <button type="submit">Save</button> 109 <button type="submit">Save</button>
110</form> 110</form>
111<p class="meta">The inbox is filed either way; this is the mail half. Login links are not activity and still arrive.</p> 111<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>
112 119
113<h2>Export</h2> 120<h2>Export</h2>
114<p class="meta">Your profile, repositories, issues and merge requests as one 121<p class="meta">Your profile, repositories, issues and merge requests as one