Commit 245c368edd
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Parity.org +5 −2
| @@ -298,6 +298,7 @@ client has no use for one (krz/gitbay#57). | ||
| 298 | 298 | | dashboard aggregate | yes | yes | yes | |
| 299 | 299 | | notification inbox | yes | yes | yes | |
| 300 | 300 | | activity mail on, off | yes | yes | yes | |
| 301 | | watch writable repos | yes | yes | no | | |
| 301 | 302 | | API token mint | yes | no | no | |
| 302 | 303 | | account export bundle | yes | yes | n/a | |
| 303 | 304 | | profile set | yes | yes | yes | |
| @@ -311,8 +312,10 @@ configured. =notifications list= reads it, unread by default; | ||
| 311 | 312 | web rail carry the unread count. =repo watch= adds you to a |
| 312 | 313 | repository's notifications, =repo mute= silences it, and =repo unwatch= |
| 313 | 314 | returns you to the default from either — told about work you are part |
| 314 | of, nothing more. A mute wins over owning the repository or having | |
| 315 | written the thread. | |
| 315 | of, nothing more. =notifications settings watch on= widens that default | |
| 316 | to every repository you can write to, without a row per repository. A | |
| 317 | mute wins over owning the repository, having written the thread, or the | |
| 318 | preference. | |
| 316 | 319 | |
| 317 | 320 | A login link is requested from the login page by username or verified |
| 318 | 321 | address, 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, | ||
| 612 | 612 | comments where you are a participant (author, commenter, reviewer, or |
| 613 | 613 | mentioned), reviews, closes, or merges. =notifications settings mail |
| 614 | 614 | off= keeps the inbox and stops that mail (login links are not activity |
| 615 | and still arrive); the account page has the same switch. Writing =@name= in an issue, merge request or | |
| 615 | and still arrive); the account page has the same switch. | |
| 616 | =notifications settings watch on= makes you a recipient of every issue | |
| 617 | and merge request on the repositories you can write to, as if you had | |
| 618 | run =repo watch= on each: it is consulted when a notice is delivered, | |
| 619 | so a grant or a revoke needs no watch row, and an explicit watch or | |
| 620 | mute on a repository still wins. Off by default; the account page has | |
| 621 | this switch too. Writing =@name= in an issue, merge request or | |
| 616 | 622 | comment files "mentioned you" in that account's inbox and makes them a |
| 617 | 623 | participant of the thread, provided they can read the repository and |
| 618 | 624 | have 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 | 69 | group("settings", "notification preferences", |
| 70 | 70 | pass("show", "your notification preferences", passOpts{server: []string{"notifications", "settings", "show"}}), |
| 71 | 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 | 75 | group("wiki", "a repository's wiki pages", |
e2e/watchpref_test.go added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 13 | func 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 | 27 | register(Command{Path: []string{"notifications", "settings", "mail"}, |
| 28 | 28 | Summary: "activity by mail as well as the inbox (login links are unaffected)", |
| 29 | 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 | 33 | register(Command{Path: []string{"repo", "watch"}, |
| 31 | 34 | Summary: "hear about all activity on a repository", |
| 32 | 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 | |
| 146 | 149 | func emitNotificationSettings(c *Ctx) int { |
| 147 | on, err := c.Store.MailEnabled(c.User.ID) | |
| 150 | mail, err := c.Store.MailEnabled(c.User.ID) | |
| 148 | 151 | if err != nil { |
| 149 | 152 | return c.fail(protocol.ExitFailure, "%v", err) |
| 150 | 153 | } |
| 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" | |
| 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 | 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). | |
| 191 | func 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 | 201 | // noticesDefaultLimit caps a bare list; pagination reaches further back. |
| 180 | 202 | const 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 | 54 | var profile control.ProfileOut |
| 55 | 55 | s.runControlInto(u, []string{"profile", "show"}, &profile) |
| 56 | 56 | mailOn, _ := s.st.MailEnabled(u.ID) |
| 57 | watchOn, _ := s.st.WatchEnabled(u.ID) | |
| 57 | 58 | |
| 58 | 59 | s.render(w, "account.html", struct { |
| 59 | 60 | basePage |
| @@ -67,8 +68,9 @@ func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.Use | ||
| 67 | 68 | Notice string |
| 68 | 69 | Message string |
| 69 | 70 | MailOn bool |
| 71 | WatchOn bool | |
| 70 | 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 | 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 | 199 | return |
| 198 | 200 | } |
| 199 | 201 | back("", "primary address changed") |
| 200 | case "notify-mail": | |
| 202 | case "notify-mail", "notify-watch": | |
| 203 | pref := strings.TrimPrefix(r.FormValue("field"), "notify-") | |
| 201 | 204 | state := "off" |
| 202 | if r.FormValue("mail") == "on" { | |
| 205 | if r.FormValue(pref) == "on" { | |
| 203 | 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 | 209 | back(msg, "") |
| 207 | 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 | 129 | // NotifyRecipients is who actually hears about something on a repository: |
| 130 | 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 | |
| 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. | |
| 134 | 135 | func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen bool) ([]int64, error) { |
| 135 | 136 | rows, err := s.DB.Query("SELECT user_id, state FROM repo_watchers WHERE repo_id = ?", repoID) |
| 136 | 137 | if err != nil { |
| @@ -158,6 +159,12 @@ func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen b | ||
| 158 | 159 | seen := map[int64]bool{} |
| 159 | 160 | if !widen { |
| 160 | 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 | 169 | for _, id := range append(append([]int64{}, targets...), watching...) { |
| 163 | 170 | if skip[id] || seen[id] { |
| @@ -168,3 +175,38 @@ func (s *Store) NotifyRecipients(repoID, actorID int64, targets []int64, widen b | ||
| 168 | 175 | } |
| 169 | 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. | |
| 183 | func (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 | 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. | |
| 179 | func 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 @@ | ||
| 1 | ALTER 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). | |
| 5 | ALTER 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 | 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). | |
| 222 | func (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 | ||
| 231 | func (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 | ||
| 219 | 240 | func (s *Store) UserByID(id int64) (User, error) { |
| 220 | 241 | var u User |
| 221 | 242 | var admin, pending, disabled int |
internal/web/templates/account.html +7
| @@ -109,6 +109,13 @@ account, and where notifications go.</p> | ||
| 109 | 109 | <button type="submit">Save</button> |
| 110 | 110 | </form> |
| 111 | 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 | 120 | <h2>Export</h2> |
| 114 | 121 | <p class="meta">Your profile, repositories, issues and merge requests as one |