Commit c3f59fc38f

c3f59fc38fe7993273f9a888dcdcdf3a222476aa

parent: ec2d05573b

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

cmc <hello@cleberg.net> · 2026-09-07 05:45 UTC

control: repo unwatch clears the row, repo mute silences

`repo unwatch` set the muted state, so watch then unwatch left a user
below the default with no way back. Now `repo unwatch` deletes the row
from either state and `repo mute` is the explicit silence. The web
header toggle follows: Watching clears rather than mutes. The CLI gains
`repo mute`.

Closes #180

Layout: unified · split

.gitbay/wiki/Parity.org +6 −3
@@ -142,7 +142,8 @@ always markdown.
142| fork | yes | yes | yes | 142| fork | yes | yes | yes |
143| pin | yes | yes | yes | 143| pin | yes | yes | yes |
144| bookmark, bookmark list | yes | yes | yes | 144| bookmark, bookmark list | yes | yes | yes |
145| watch, mute | yes | yes | yes | 145| watch, unwatch | yes | yes | yes |
146| mute | yes | no | yes |
146| settings, protection | yes | yes | yes | 147| settings, protection | yes | yes | yes |
147| topics, website | yes | yes | yes | 148| topics, website | yes | yes | yes |
148| visibility | yes | yes | yes | 149| visibility | yes | yes | yes |
@@ -263,8 +264,10 @@ instance sends mail, and mail is the second half when SMTP is
263configured. =notifications list= reads it, unread by default; 264configured. =notifications list= reads it, unread by default;
264=notifications read <id>... | --all= clears it; the dashboard and the 265=notifications read <id>... | --all= clears it; the dashboard and the
265web rail carry the unread count. =repo watch= adds you to a 266web rail carry the unread count. =repo watch= adds you to a
266repository's notifications and =repo unwatch= mutes it, and a mute wins 267repository's notifications, =repo mute= silences it, and =repo unwatch=
267over owning the repository or having written the thread. 268returns you to the default from either — told about work you are part
269of, nothing more. A mute wins over owning the repository or having
270written the thread.
268 271
269A login link is requested from the login page by username or verified 272A login link is requested from the login page by username or verified
270address, and arrives by mail: it works once and expires in fifteen 273address, and arrives by mail: it works once and expires in fifteen
.gitbay/wiki/Users.org +2 −1
@@ -162,7 +162,8 @@ gitbay repo delete you/project --yes
162#+end_src 162#+end_src
163 163
164=repo show= also reports your own state on the repository — =watch= 164=repo show= also reports your own state on the repository — =watch=
165(=watching= or =muted=) and =bookmarked= — and =fork_of= when it is a 165(=watching= or =muted=; =repo unwatch= clears either) and =bookmarked= —
166and =fork_of= when it is a
166fork whose parent you can read, so a client draws a toggle rather than 167fork whose parent you can read, so a client draws a toggle rather than
167two blind buttons. Absent means none. 168two blind buttons. Absent means none.
168 169
cmd/gitbay/main.go +2 −1
@@ -418,7 +418,8 @@ func repoCmd() *cobra.Command {
418 pass("unbookmark", "remove a bookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}), 418 pass("unbookmark", "remove a bookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}),
419 pass("bookmarks", "list the repositories you have bookmarked", passOpts{server: []string{"repo", "bookmarks"}}), 419 pass("bookmarks", "list the repositories you have bookmarked", passOpts{server: []string{"repo", "bookmarks"}}),
420 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}), 420 pass("watch", "hear about all activity on a repository", passOpts{server: []string{"repo", "watch"}, needsRepo: true}),
421 pass("unwatch", "mute a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}), 421 pass("unwatch", "stop watching a repository", passOpts{server: []string{"repo", "unwatch"}, needsRepo: true}),
422 pass("mute", "mute a repository, including work you are part of", passOpts{server: []string{"repo", "mute"}, needsRepo: true}),
422 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}), 423 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
423 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}), 424 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
424 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone), 425 local("clone", "clone via ssh: gitbay repo clone <owner/name> [dir]", cmdRepoClone),
e2e/inbox_test.go +2 −2
@@ -91,8 +91,8 @@ func TestNotificationInbox(t *testing.T) {
91 91
92 // Muting beats being a participant: bob wrote the issue and still 92 // Muting beats being a participant: bob wrote the issue and still
93 // hears nothing more, while the watcher does. 93 // hears nothing more, while the watcher does.
94 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "unwatch", "alice/app"); code != 0 { 94 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "mute", "alice/app"); code != 0 {
95 t.Fatalf("repo unwatch: %s", errOut) 95 t.Fatalf("repo mute: %s", errOut)
96 } 96 }
97 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'more'"); code != 0 { 97 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'more'"); code != 0 {
98 t.Fatal("alice comment failed") 98 t.Fatal("alice comment failed")
e2e/reposhowstate_test.go +15
@@ -32,6 +32,21 @@ func TestRepoShowCarriesViewerState(t *testing.T) {
32 if !strings.Contains(out, `"watch":"watching"`) || !strings.Contains(out, `"bookmarked":true`) { 32 if !strings.Contains(out, `"watch":"watching"`) || !strings.Contains(out, `"bookmarked":true`) {
33 t.Fatalf("bob's state not reported:\n%s", out) 33 t.Fatalf("bob's state not reported:\n%s", out)
34 } 34 }
35 // unwatch is the way back to the default from either state, and
36 // mute is its own state, not what unwatch means (#180).
37 inst.ssh(t, bobKey, "", "repo", "unwatch", "alice/app")
38 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"watch"`) {
39 t.Errorf("unwatch did not clear the state:\n%s", out)
40 }
41 inst.ssh(t, bobKey, "", "repo", "mute", "alice/app")
42 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json"); !strings.Contains(out, `"watch":"muted"`) {
43 t.Errorf("mute not reported:\n%s", out)
44 }
45 inst.ssh(t, bobKey, "", "repo", "unwatch", "alice/app")
46 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"watch"`) {
47 t.Errorf("unwatch did not clear a mute:\n%s", out)
48 }
49 inst.ssh(t, bobKey, "", "repo", "watch", "alice/app")
35 // It is the caller's state: alice sees her own, not bob's. 50 // It is the caller's state: alice sees her own, not bob's.
36 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"bookmarked"`) { 51 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"bookmarked"`) {
37 t.Errorf("alice sees bob's bookmark:\n%s", out) 52 t.Errorf("alice sees bob's bookmark:\n%s", out)
internal/control/notifications.go +18 −9
@@ -23,8 +23,11 @@ func init() {
23 Summary: "hear about all activity on a repository", 23 Summary: "hear about all activity on a repository",
24 Usage: "repo watch <owner/name>", Run: runRepoWatch}) 24 Usage: "repo watch <owner/name>", Run: runRepoWatch})
25 register(Command{Path: []string{"repo", "unwatch"}, 25 register(Command{Path: []string{"repo", "unwatch"},
26 Summary: "mute a repository, including work you are part of", 26 Summary: "back to the default: only work you are part of",
27 Usage: "repo unwatch <owner/name>", Run: runRepoUnwatch}) 27 Usage: "repo unwatch <owner/name>", Run: runRepoUnwatch})
28 register(Command{Path: []string{"repo", "mute"},
29 Summary: "mute a repository, including work you are part of",
30 Usage: "repo mute <owner/name>", Run: runRepoMute})
28} 31}
29 32
30// notice is one thing that happened, in the shape both delivery routes 33// notice is one thing that happened, in the shape both delivery routes
@@ -171,14 +174,14 @@ func runNotificationsRead(c *Ctx, args []string) int {
171 }) 174 })
172} 175}
173 176
174func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watching") } 177func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watch", "watching") }
175func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "muted") } 178func runRepoMute(c *Ctx, args []string) int { return setWatch(c, args, "mute", "muted") }
179func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
176 180
177func setWatch(c *Ctx, args []string, state string) int { 181// setWatch records the caller's state on a repository. "default" deletes
178 verb := "watch" 182// the row: watch then unwatch leaves no trace, and a mute is undone the
179 if state == "muted" { 183// same way.
180 verb = "unwatch" 184func setWatch(c *Ctx, args []string, verb, state string) int {
181 }
182 if len(args) != 1 { 185 if len(args) != 1 {
183 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb) 186 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
184 } 187 }
@@ -186,7 +189,13 @@ func setWatch(c *Ctx, args []string, state string) int {
186 if code >= 0 { 189 if code >= 0 {
187 return code 190 return code
188 } 191 }
189 if err := c.Store.SetRepoWatch(repo.ID, c.User.ID, state); err != nil { 192 var err error
193 if state == "default" {
194 err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
195 } else {
196 err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
197 }
198 if err != nil {
190 return c.fail(protocol.ExitFailure, "%v", err) 199 return c.fail(protocol.ExitFailure, "%v", err)
191 } 200 }
192 return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) { 201 return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
internal/httpd/notifyweb.go +3 −3
@@ -63,10 +63,10 @@ func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.Use
63 if !ok { 63 if !ok {
64 return 64 return
65 } 65 }
66 state := "watching"
67 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" { 66 if s.st.RepoWatchState(repo.ID, u.ID) == "watching" {
68 state = "muted" 67 s.st.ClearRepoWatch(repo.ID, u.ID)
68 } else {
69 s.st.SetRepoWatch(repo.ID, u.ID, "watching")
69 } 70 }
70 s.st.SetRepoWatch(repo.ID, u.ID, state)
71 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther) 71 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
72} 72}
internal/store/inbox.go +7
@@ -111,6 +111,13 @@ func (s *Store) SetRepoWatch(repoID, userID int64, state string) error {
111 return err 111 return err
112} 112}
113 113
114// ClearRepoWatch returns the user to the default: notified about work
115// they are part of, nothing more.
116func (s *Store) ClearRepoWatch(repoID, userID int64) error {
117 _, err := s.DB.Exec("DELETE FROM repo_watchers WHERE repo_id = ? AND user_id = ?", repoID, userID)
118 return err
119}
120
114// RepoWatchState returns "watching", "muted", or "" for the default. 121// RepoWatchState returns "watching", "muted", or "" for the default.
115func (s *Store) RepoWatchState(repoID, userID int64) string { 122func (s *Store) RepoWatchState(repoID, userID int64) string {
116 var state string 123 var state string
internal/store/inbox_test.go +13
@@ -146,6 +146,19 @@ func TestNotifyRecipients(t *testing.T) {
146 if s.RepoWatchState(repoID, owner) != "muted" { 146 if s.RepoWatchState(repoID, owner) != "muted" {
147 t.Fatal("watch state not recorded") 147 t.Fatal("watch state not recorded")
148 } 148 }
149 // Clearing returns the owner to the default and they are told again.
150 if err := s.ClearRepoWatch(repoID, owner); err != nil {
151 t.Fatal(err)
152 }
153 if s.RepoWatchState(repoID, owner) != "" {
154 t.Fatal("clear left a state")
155 }
156 if got, _ := s.NotifyRecipients(repoID, other, []int64{owner}); len(got) != 2 {
157 t.Fatalf("cleared owner not notified: %v", got)
158 }
159 if err := s.SetRepoWatch(repoID, owner, "muted"); err != nil {
160 t.Fatal(err)
161 }
149 162
150 // Watching after muting replaces the row rather than adding one. 163 // Watching after muting replaces the row rather than adding one.
151 if err := s.SetRepoWatch(repoID, owner, "watching"); err != nil { 164 if err := s.SetRepoWatch(repoID, owner, "watching"); err != nil {