enhance: allow custom watches to bypass on-mention email restriction (#39652)

Users with "Only Email on Mention" can now still receive email for the
events they picked in a custom repository watch, covering issues, pull
requests and releases. The setting is renamed to "Only Email on Mention
and Custom Watches" to make this explicit, matching GitHub's
"Participating, @mentions and custom".

"All activity" watches don't bypass the setting, because they are also
created automatically by `AUTO_WATCH_NEW_REPOS` for repository creators
and team members. To keep picking every event distinct from "All
activity", the custom watch dialog now stores a new `WatchModeCustom`.
Existing custom watches keep working without a migration.

## Screenshots
### Before
<img width="1650" height="675" alt="grafik"
src="https://github.com/user-attachments/assets/ecc56e20-cd05-41e9-a840-607b352c50a8"
/>

### After
<img width="1657" height="671" alt="grafik"
src="https://github.com/user-attachments/assets/a406db94-3ac4-4587-b840-0cd472b135c5"
/>


Addresses https://github.com/openSUSE/openSUSE-git/issues/183
Ref: https://github.com/go-gitea/gitea/issues/35492

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
authored and GitHub committed 2026-10-18 09:23:12 +00:00
1 parent b0df5e4abe
commit 13ddad6e07
11 files changed
+123 -22

No files matched your search

+1 -1
View File
@@ -477,7 +477,7 @@ func applySubscribedCondition(sess db.Session, subscriberID int64) {
Select("repo_id").
From("watch").
Where(builder.And(builder.Eq{"user_id": subscriberID},
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))),
builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto, repo_model.WatchModeCustom))),
),
),
)
+28 -7
View File
@@ -20,9 +20,10 @@ type WatchMode int8
const (
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
WatchModeNormal // 1 proactively watching (all or custom)
WatchModeNormal // 1 proactively watching (all, or custom before WatchModeCustom)
WatchModeDont // 2 ignore the repo
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
WatchModeCustom // 4 watching the events picked in the custom watch options
)
// WatchType is the `watch` column gating one kind of notification
@@ -79,7 +80,7 @@ func (w *Watch) IsWatching() bool {
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
func (w *Watch) IsWatchingAll() bool {
return w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
return w.Mode != WatchModeCustom && w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
}
func (w *Watch) IsWatchingAny() bool {
@@ -166,6 +167,9 @@ func WatchRepoAuto(ctx context.Context, doer *user_model.User, repo *Repository,
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
return user_model.ErrBlockedUser
}
if watch.Mode == WatchModeCustom { // keep the events the user picked
return nil
}
return watchRepoByMode(ctx, watch, WatchModeNormal)
}
@@ -189,7 +193,7 @@ func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repo
if err != nil {
return err
}
if opts.Mode == WatchModeNormal {
if opts.Mode == WatchModeNormal || opts.Mode == WatchModeCustom {
_, err = db.GetEngine(ctx).Where("user_id=? AND repo_id=?", doer.ID, repo.ID).
Cols("include_pull_requests", "include_issues", "include_releases").
Update(&Watch{IncludePullRequests: opts.WatchPullRequests, IncludeIssues: opts.WatchIssues, IncludeReleases: opts.WatchReleases})
@@ -247,6 +251,16 @@ func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
// but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
return getRepoWatchersIDs(ctx, repoID, watchType, false)
}
// GetRepoCustomWatchersIDs is GetRepoWatchersIDs limited to custom watches, which unlike
// watching all activity are never set up automatically (e.g. by AutoWatchNewRepos)
func GetRepoCustomWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
return getRepoWatchersIDs(ctx, repoID, watchType, true)
}
func getRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType, customOnly bool) ([]int64, error) {
ids := make([]int64, 0, 64)
var watchColName string
switch watchType {
@@ -259,12 +273,19 @@ func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType)
default:
panic("invalid WatchType")
}
return ids, db.GetEngine(ctx).Table("watch").
sess := db.GetEngine(ctx).Table("watch").
Where("watch.repo_id=?", repoID).
And("watch.mode<>?", WatchModeDont).
And(builder.Eq{watchColName: true}).
Select("user_id").
Find(&ids)
And(builder.Eq{watchColName: true})
if customOnly { // matches SelectedMode() == "custom"
sess = sess.And(builder.Or(
builder.Eq{"watch.mode": WatchModeCustom},
builder.Eq{"watch.include_pull_requests": false},
builder.Eq{"watch.include_issues": false},
builder.Eq{"watch.include_releases": false},
))
}
return ids, sess.Select("user_id").Find(&ids)
}
// GetRepoWatchers returns range of users watching given repository.
+16 -1
View File
@@ -145,7 +145,7 @@ func TestWatchOptions(t *testing.T) {
// repo 1 is watched by users 1, 4, 9 and 11, all with every event enabled
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true}))
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true})) // custom watches were stored like this before WatchModeCustom
for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4, 9, 11},
@@ -157,6 +157,20 @@ func TestWatchOptions(t *testing.T) {
assert.ElementsMatch(t, expected, ids, watchType)
}
// a custom watch picking every event differs from all activity, which can be set up automatically
customUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), customUser, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeCustom, WatchPullRequests: true, WatchIssues: true, WatchReleases: true}))
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), customUser, repo, true)) // must keep the custom watch
for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4},
repo_model.WatchIssues: {4},
repo_model.WatchReleases: {4},
} {
ids, err := repo_model.GetRepoCustomWatchersIDs(t.Context(), repo.ID, watchType)
assert.NoError(t, err)
assert.ElementsMatch(t, expected, ids, watchType)
}
// the options of one user must not show up for another
watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID})
assert.NoError(t, err)
@@ -176,5 +190,6 @@ func TestWatchSelectedMode(t *testing.T) {
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeCustom, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
}