mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 11:35:43 +02:00
feat: add watch options (#37571)
Adds per-event watch options, so a user can choose which repository notifications they receive. The watch button is now a menu with four modes: participating and mentions (the unwatched state, as on GitHub), all activity, ignore, and custom, which opens a dialog to pick issues, pull requests and releases. The same dialog is reachable from the gear icon on the watched repository list. Options apply to UI notifications and to mail. The watcher, star and fork counts moved inside their buttons, so all three share one shape, and the links to those lists moved to the repository sidebar. Assignees and requested reviewers now also receive UI notifications without having commented first. Buttons and dropdown: <img width="688" height="398" alt="image" src="https://github.com/user-attachments/assets/889b567d-086f-4a06-ac67-3a4205fc63b2" /> Modal: <img width="841" height="327" alt="Screenshot 2026-08-07 at 21 10 59" src="https://github.com/user-attachments/assets/3bedbff5-9011-4f1b-91a9-e8f5c212fb5d" /> New entries in sidebar: <img width="170" height="207" alt="Screenshot 2026-08-07 at 21 13 28" src="https://github.com/user-attachments/assets/5f5150cd-f42d-4543-bd0b-7334cf4aab0e" /> Fixes: https://github.com/go-gitea/gitea/issues/17238 Ref: https://github.com/go-gitea/gitea/issues/35492 --------- Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
co-authored by
bircni
silverwind
parent
2087d4a1a5
commit
7dbfed37eb
@@ -106,7 +106,8 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
}
|
||||
toNotify.AddMultiple(issueWatches...)
|
||||
if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {
|
||||
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID)
|
||||
watchType := util.Iif(issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues)
|
||||
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID, watchType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,6 +118,18 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
return nil, err
|
||||
}
|
||||
toNotify.AddMultiple(issueParticipants...)
|
||||
issueAssignees, err := issues_model.GetAssigneeIDsByIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toNotify.AddMultiple(issueAssignees...)
|
||||
if issue.IsPull {
|
||||
issueReviewers, err := issues_model.GetPullRequestRequestedReviewerIDs(ctx, issueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toNotify.AddMultiple(issueReviewers...)
|
||||
}
|
||||
|
||||
// don't notify user who cause notification
|
||||
delete(toNotify, notificationAuthorID)
|
||||
@@ -130,6 +143,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
|
||||
}
|
||||
}
|
||||
|
||||
// muting the repository outranks every other source, including mentions
|
||||
ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, issue.RepoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ignorers {
|
||||
toNotify.Remove(id)
|
||||
}
|
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
activities_model "gitea.dev/models/activities"
|
||||
"gitea.dev/models/db"
|
||||
issues_model "gitea.dev/models/issues"
|
||||
repo_model "gitea.dev/models/repo"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
@@ -32,6 +33,39 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
|
||||
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status)
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user 13 neither watches repo 1 nor participates in PR 3
|
||||
assert.NoError(t, db.Insert(t.Context(), &issues_model.IssueAssignees{AssigneeID: 13, IssueID: 3}))
|
||||
_, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 3, 0, 1, 0)
|
||||
assert.NoError(t, err)
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 13, IssueID: 3})
|
||||
|
||||
// user 1 is a requested reviewer of PR 12 and does not participate in it
|
||||
_, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 12, 0, 2, 0)
|
||||
assert.NoError(t, err)
|
||||
unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: 12})
|
||||
}
|
||||
|
||||
func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// user 4 watches repo 1 and would be notified about issue 1
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
|
||||
assert.NoError(t, repo_model.IgnoreRepo(t.Context(), user, repo))
|
||||
|
||||
notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, notified, user.ID)
|
||||
|
||||
// muting outranks a direct receiver too
|
||||
notified, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, notified)
|
||||
}
|
||||
|
||||
func TestNotificationsForUser(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
repo_model "gitea.dev/models/repo"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// IssueWatch is connection request for receiving issue notification.
|
||||
@@ -81,7 +82,10 @@ func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return repo_model.IsWatchMode(w.Mode) || IsUserParticipantsOfIssue(ctx, user, issue), nil
|
||||
if repo_model.IsWatchMode(w.Mode) && util.Iif(issue.IsPull, w.PullRequests, w.Issues) {
|
||||
return true, nil
|
||||
}
|
||||
return IsUserParticipantsOfIssue(ctx, user, issue), nil
|
||||
}
|
||||
|
||||
// GetIssueWatchersIDs returns IDs of subscribers or explicit unsubscribers to a given issue id
|
||||
|
||||
@@ -1012,3 +1012,16 @@ func GetPullRequestByMergedCommit(ctx context.Context, repoID int64, sha string)
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// GetPullRequestRequestedReviewerIDs returns IDs of reviewers currently requested for the given pull request.
|
||||
func GetPullRequestRequestedReviewerIDs(ctx context.Context, issueID int64) ([]int64, error) {
|
||||
userIDs := make([]int64, 0, 5)
|
||||
return userIDs, db.GetEngine(ctx).
|
||||
Table("review").
|
||||
Cols("reviewer_id").
|
||||
Where("issue_id=?", issueID).
|
||||
And("type=?", ReviewTypeRequest).
|
||||
And("reviewer_id > 0").
|
||||
Distinct("reviewer_id").
|
||||
Find(&userIDs)
|
||||
}
|
||||
|
||||
+83
-12
@@ -28,14 +28,26 @@ const (
|
||||
WatchModeAuto // 3
|
||||
)
|
||||
|
||||
// WatchType is the `watch` column gating one kind of notification
|
||||
type WatchType string
|
||||
|
||||
const (
|
||||
WatchPullRequests WatchType = "pull_requests"
|
||||
WatchIssues WatchType = "issues"
|
||||
WatchReleases WatchType = "releases"
|
||||
)
|
||||
|
||||
// Watch is connection request for receiving repository notification.
|
||||
type Watch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
UserID int64 `xorm:"UNIQUE(watch)"`
|
||||
RepoID int64 `xorm:"UNIQUE(watch)"`
|
||||
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
|
||||
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
|
||||
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||
PullRequests bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Issues bool `xorm:"NOT NULL DEFAULT true"`
|
||||
Releases bool `xorm:"NOT NULL DEFAULT true"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -48,8 +60,8 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
|
||||
if err != nil {
|
||||
return watch, err
|
||||
}
|
||||
if watch == nil {
|
||||
watch = &Watch{UserID: userID, RepoID: repoID}
|
||||
if watch == nil { // the dummy record must mirror the column defaults
|
||||
watch = &Watch{UserID: userID, RepoID: repoID, PullRequests: true, Issues: true, Releases: true}
|
||||
}
|
||||
if !has {
|
||||
watch.Mode = WatchModeNone
|
||||
@@ -57,6 +69,11 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
|
||||
return watch, nil
|
||||
}
|
||||
|
||||
// IsIgnoring reports whether the user muted the repository entirely
|
||||
func (w *Watch) IsIgnoring() bool {
|
||||
return w.Mode == WatchModeDont
|
||||
}
|
||||
|
||||
// IsWatchMode Decodes watchability of WatchMode
|
||||
func IsWatchMode(mode WatchMode) bool {
|
||||
return mode != WatchModeNone && mode != WatchModeDont
|
||||
@@ -87,15 +104,16 @@ func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error
|
||||
repodiff = -1
|
||||
}
|
||||
|
||||
if repodiff == 1 { // starting to watch resets the options, otherwise a custom selection survives
|
||||
watch.PullRequests, watch.Issues, watch.Releases = true, true, true
|
||||
}
|
||||
watch.Mode = mode
|
||||
|
||||
if !hadrec && needsrec {
|
||||
watch.Mode = mode
|
||||
if err = db.Insert(ctx, watch); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if needsrec {
|
||||
watch.Mode = mode
|
||||
if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,6 +145,48 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW
|
||||
return watchRepoMode(ctx, watch, WatchModeNormal)
|
||||
}
|
||||
|
||||
// IgnoreRepo mutes the repository, so nothing about it reaches the user.
|
||||
func IgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error {
|
||||
watch, err := GetWatch(ctx, doer.ID, repo.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return watchRepoMode(ctx, watch, WatchModeDont)
|
||||
}
|
||||
|
||||
type WatchOptions struct {
|
||||
PullRequests bool
|
||||
Issues bool
|
||||
Releases bool
|
||||
}
|
||||
|
||||
// SetWatchOptions updates the per-event options of a watch, callers must run WatchRepo first
|
||||
func SetWatchOptions(ctx context.Context, userID, repoID int64, opts WatchOptions) error {
|
||||
_, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID).
|
||||
Cols(string(WatchPullRequests), string(WatchIssues), string(WatchReleases)).
|
||||
Update(&Watch{PullRequests: opts.PullRequests, Issues: opts.Issues, Releases: opts.Releases})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserWatches returns the watches of one user, keyed by repository ID
|
||||
func GetUserWatches(ctx context.Context, userID int64, repoIDs []int64) (map[int64]*Watch, error) {
|
||||
if len(repoIDs) == 0 {
|
||||
return map[int64]*Watch{}, nil
|
||||
}
|
||||
watches := make([]*Watch, 0, len(repoIDs))
|
||||
if err := db.GetEngine(ctx).Where("user_id=?", userID).
|
||||
In("repo_id", repoIDs).
|
||||
And("mode<>?", WatchModeDont).
|
||||
Find(&watches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
watchesByRepo := make(map[int64]*Watch, len(watches))
|
||||
for _, watch := range watches {
|
||||
watchesByRepo[watch.RepoID] = watch
|
||||
}
|
||||
return watchesByRepo, nil
|
||||
}
|
||||
|
||||
// GetWatchers returns all watchers of given repository.
|
||||
func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
|
||||
watches := make([]*Watch, 0, 10)
|
||||
@@ -138,14 +198,25 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
|
||||
Find(&watches)
|
||||
}
|
||||
|
||||
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
|
||||
// GetRepoIgnorersIDs returns IDs of users who muted the given repo ID
|
||||
func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
||||
ids := make([]int64, 0, 8)
|
||||
return ids, db.GetEngine(ctx).Table("watch").
|
||||
Where("repo_id=?", repoID).
|
||||
And("mode=?", WatchModeDont).
|
||||
Select("user_id").
|
||||
Find(&ids)
|
||||
}
|
||||
|
||||
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID that opted into watchType
|
||||
// but avoids joining with `user` for performance reasons
|
||||
// User permissions must be verified elsewhere if required
|
||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64) ([]int64, error) {
|
||||
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
|
||||
ids := make([]int64, 0, 64)
|
||||
return ids, db.GetEngine(ctx).Table("watch").
|
||||
Where("watch.repo_id=?", repoID).
|
||||
And("watch.mode<>?", WatchModeDont).
|
||||
And(builder.Eq{"watch." + string(watchType): true}).
|
||||
Select("user_id").
|
||||
Find(&ids)
|
||||
}
|
||||
|
||||
@@ -125,16 +125,47 @@ func TestClearRepoWatches(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
const repoID int64 = 1
|
||||
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID)
|
||||
watchers, err := repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, watchers)
|
||||
|
||||
assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID))
|
||||
|
||||
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID)
|
||||
watchers, err = repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, watchers)
|
||||
|
||||
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
|
||||
assert.Zero(t, repo.NumWatches)
|
||||
}
|
||||
|
||||
func TestWatchOptions(t *testing.T) {
|
||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
// 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.SetWatchOptions(t.Context(), user.ID, repo.ID, repo_model.WatchOptions{PullRequests: true}))
|
||||
|
||||
for watchType, expected := range map[repo_model.WatchType][]int64{
|
||||
repo_model.WatchPullRequests: {1, 4, 9, 11},
|
||||
repo_model.WatchIssues: {4, 9, 11},
|
||||
repo_model.WatchReleases: {4, 9, 11},
|
||||
} {
|
||||
ids, err := repo_model.GetRepoWatchersIDs(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)
|
||||
assert.True(t, watches[repo.ID].Issues)
|
||||
|
||||
// watching again resets a custom selection
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, false))
|
||||
assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true))
|
||||
watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, watch.PullRequests && watch.Issues && watch.Releases)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user