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.
142142| fork | yes | yes | yes |
143143| pin | yes | yes | yes |
144144| bookmark, bookmark list | yes | yes | yes |
145| watch, mute | yes | yes | yes |
145| watch, unwatch | yes | yes | yes |
146| mute | yes | no | yes |
146147| settings, protection | yes | yes | yes |
147148| topics, website | yes | yes | yes |
148149| visibility | yes | yes | yes |
@@ -263,8 +264,10 @@ instance sends mail, and mail is the second half when SMTP is
263264configured. =notifications list= reads it, unread by default;
264265=notifications read <id>... | --all= clears it; the dashboard and the
265266web rail carry the unread count. =repo watch= adds you to a
266repository's notifications and =repo unwatch= mutes it, and a mute wins
267over owning the repository or having written the thread.
267repository's notifications, =repo mute= silences it, and =repo unwatch=
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.
268271
269272A login link is requested from the login page by username or verified
270273address, 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
162162#+end_src
163163
164164=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
166167fork whose parent you can read, so a client draws a toggle rather than
167168two blind buttons. Absent means none.
168169
cmd/gitbay/main.go +2 −1
@@ -418,7 +418,8 @@ func repoCmd() *cobra.Command {
418418 pass("unbookmark", "remove a bookmark", passOpts{server: []string{"repo", "unbookmark"}, needsRepo: true}),
419419 pass("bookmarks", "list the repositories you have bookmarked", passOpts{server: []string{"repo", "bookmarks"}}),
420420 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}),
422423 pass("archive", "archive a repository (read-only)", passOpts{server: []string{"repo", "archive"}, needsRepo: true}),
423424 pass("unarchive", "unarchive a repository", passOpts{server: []string{"repo", "unarchive"}, needsRepo: true}),
424425 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) {
9191
9292 // Muting beats being a participant: bob wrote the issue and still
9393 // 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)
94 if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "mute", "alice/app"); code != 0 {
95 t.Fatalf("repo mute: %s", errOut)
9696 }
9797 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'more'"); code != 0 {
9898 t.Fatal("alice comment failed")
e2e/reposhowstate_test.go +15
@@ -32,6 +32,21 @@ func TestRepoShowCarriesViewerState(t *testing.T) {
3232 if !strings.Contains(out, `"watch":"watching"`) || !strings.Contains(out, `"bookmarked":true`) {
3333 t.Fatalf("bob's state not reported:\n%s", out)
3434 }
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")
3550 // It is the caller's state: alice sees her own, not bob's.
3651 if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json"); strings.Contains(out, `"bookmarked"`) {
3752 t.Errorf("alice sees bob's bookmark:\n%s", out)
internal/control/notifications.go +18 −9
@@ -23,8 +23,11 @@ func init() {
2323 Summary: "hear about all activity on a repository",
2424 Usage: "repo watch <owner/name>", Run: runRepoWatch})
2525 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",
2727 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})
2831}
2932
3033// notice is one thing that happened, in the shape both delivery routes
@@ -171,14 +174,14 @@ func runNotificationsRead(c *Ctx, args []string) int {
171174 })
172175}
173176
174func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watching") }
175func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "muted") }
177func runRepoWatch(c *Ctx, args []string) int { return setWatch(c, args, "watch", "watching") }
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") }
176180
177func setWatch(c *Ctx, args []string, state string) int {
178 verb := "watch"
179 if state == "muted" {
180 verb = "unwatch"
181 }
181// setWatch records the caller's state on a repository. "default" deletes
182// the row: watch then unwatch leaves no trace, and a mute is undone the
183// same way.
184func setWatch(c *Ctx, args []string, verb, state string) int {
182185 if len(args) != 1 {
183186 return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
184187 }
@@ -186,7 +189,13 @@ func setWatch(c *Ctx, args []string, state string) int {
186189 if code >= 0 {
187190 return code
188191 }
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 {
190199 return c.fail(protocol.ExitFailure, "%v", err)
191200 }
192201 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
6363 if !ok {
6464 return
6565 }
66 state := "watching"
6766 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")
6970 }
70 s.st.SetRepoWatch(repo.ID, u.ID, state)
7171 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
7272}
internal/store/inbox.go +7
@@ -111,6 +111,13 @@ func (s *Store) SetRepoWatch(repoID, userID int64, state string) error {
111111 return err
112112}
113113
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
114121// RepoWatchState returns "watching", "muted", or "" for the default.
115122func (s *Store) RepoWatchState(repoID, userID int64) string {
116123 var state string
internal/store/inbox_test.go +13
@@ -146,6 +146,19 @@ func TestNotifyRecipients(t *testing.T) {
146146 if s.RepoWatchState(repoID, owner) != "muted" {
147147 t.Fatal("watch state not recorded")
148148 }
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 }
149162
150163 // Watching after muting replaces the row rather than adding one.
151164 if err := s.SetRepoWatch(repoID, owner, "watching"); err != nil {