chore: fix repo watch (#38921)

This commit is contained in:
wxiaoguang
2026-08-16 11:00:59 +08:00
committed by GitHub
parent 56ad4689ad
commit 5e4d21acd5
26 changed files with 156 additions and 137 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) {
// 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.WatchIgnoreRepo(t.Context(), user, repo))
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeDont}))
notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0)
assert.NoError(t, err)
+1 -1
View File
@@ -82,7 +82,7 @@ func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (
if err != nil {
return false, err
}
if repo_model.IsWatchMode(w.Mode) && util.Iif(issue.IsPull, w.PullRequests, w.Issues) {
if repo_model.IsWatchModeWatching(w.Mode) && util.Iif(issue.IsPull, w.IncludePullRequests, w.IncludeIssues) {
return true, nil
}
return IsUserParticipantsOfIssue(ctx, user, issue), nil
+2 -2
View File
@@ -67,11 +67,11 @@ func TestWatchRepo(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 3})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
assert.NoError(t, WatchRepo(t.Context(), user, repo, true))
assert.NoError(t, WatchRepoAuto(t.Context(), user, repo, true))
unittest.AssertExistsAndLoadBean(t, &Watch{RepoID: repo.ID, UserID: user.ID})
unittest.CheckConsistencyFor(t, &Repository{ID: repo.ID})
assert.NoError(t, WatchRepo(t.Context(), user, repo, false))
assert.NoError(t, WatchRepoAuto(t.Context(), user, repo, false))
unittest.AssertNotExistsBean(t, &Watch{RepoID: repo.ID, UserID: user.ID})
unittest.CheckConsistencyFor(t, &Repository{ID: repo.ID})
}
+83 -71
View File
@@ -18,14 +18,11 @@ import (
type WatchMode int8
const (
// WatchModeNone don't watch
WatchModeNone WatchMode = iota // 0
// WatchModeNormal watch repository (from other sources)
WatchModeNormal // 1
// WatchModeDont explicit don't auto-watch
WatchModeDont // 2
// WatchModeAuto watch repository (from AutoWatchOnChanges)
WatchModeAuto // 3
WatchModeNone WatchMode = iota // 0 watch nothing unless mentioned
WatchModeNormal // 1 proactively watching (all or custom)
WatchModeDont // 2 ignore the repo
WatchModeAuto // 3 automatically watching (from AutoWatchOnChanges)
)
// WatchType is the `watch` column gating one kind of notification
@@ -39,15 +36,16 @@ const (
// 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"`
PullRequests bool `xorm:"NOT NULL DEFAULT true"`
Issues bool `xorm:"NOT NULL DEFAULT true"`
Releases bool `xorm:"NOT NULL DEFAULT true"`
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"`
IncludePullRequests bool `xorm:"NOT NULL DEFAULT true"`
IncludeIssues bool `xorm:"NOT NULL DEFAULT true"`
IncludeReleases bool `xorm:"NOT NULL DEFAULT true"`
}
func init() {
@@ -61,7 +59,7 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
return watch, err
}
if watch == nil { // the dummy record must mirror the column defaults
watch = &Watch{UserID: userID, RepoID: repoID, PullRequests: true, Issues: true, Releases: true}
watch = &Watch{UserID: userID, RepoID: repoID, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}
}
if !has {
watch.Mode = WatchModeNone
@@ -76,12 +74,16 @@ func (w *Watch) IsIgnoring() bool {
// IsWatching reports whether the watch counts the user as a watcher of the repository
func (w *Watch) IsWatching() bool {
return IsWatchMode(w.Mode)
return IsWatchModeWatching(w.Mode)
}
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
func (w *Watch) IsWatchingAll() bool {
return w.PullRequests && w.Issues && w.Releases
return w.IncludePullRequests && w.IncludeIssues && w.IncludeReleases
}
func (w *Watch) IsWatchingAny() bool {
return w.IncludePullRequests || w.IncludeIssues || w.IncludeReleases
}
// SelectedMode returns the mode the user picked in the watch menu
@@ -89,7 +91,7 @@ func (w *Watch) SelectedMode() string {
switch {
case w.IsIgnoring():
return "ignore"
case !IsWatchMode(w.Mode), !(w.PullRequests || w.Issues || w.Releases):
case !IsWatchModeWatching(w.Mode), !w.IsWatchingAny():
return "participate" // also the default while there is no watch row
case w.IsWatchingAll():
return "all"
@@ -97,110 +99,105 @@ func (w *Watch) SelectedMode() string {
return "custom"
}
// IsWatchMode Decodes watchability of WatchMode
func IsWatchMode(mode WatchMode) bool {
// IsWatchModeWatching Decodes watchability of WatchMode
func IsWatchModeWatching(mode WatchMode) bool {
return mode != WatchModeNone && mode != WatchModeDont
}
// IsWatching checks if user has watched given repository.
func IsWatching(ctx context.Context, userID, repoID int64) bool {
// IsWatchingRepo checks if user has watched given repository.
func IsWatchingRepo(ctx context.Context, userID, repoID int64) bool {
watch, err := GetWatch(ctx, userID, repoID)
return err == nil && IsWatchMode(watch.Mode)
return err == nil && IsWatchModeWatching(watch.Mode)
}
func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
func watchRepoByMode(ctx context.Context, watch *Watch, mode WatchMode) (err error) {
if watch.Mode == mode {
return nil
}
if mode == WatchModeAuto && (watch.Mode == WatchModeDont || IsWatchMode(watch.Mode)) {
if mode == WatchModeAuto && (watch.Mode == WatchModeDont || IsWatchModeWatching(watch.Mode)) {
// Don't auto watch if already watching or deliberately not watching
return nil
}
hadrec := watch.Mode != WatchModeNone
needsrec := mode != WatchModeNone
repodiff := 0
hadWatchModeSet := watch.Mode != WatchModeNone
needSetWatchMode := mode != WatchModeNone
repoWatchDelta := 0
if IsWatchMode(mode) && !IsWatchMode(watch.Mode) {
repodiff = 1
} else if !IsWatchMode(mode) && IsWatchMode(watch.Mode) {
repodiff = -1
if IsWatchModeWatching(mode) && !IsWatchModeWatching(watch.Mode) {
repoWatchDelta = 1
} else if !IsWatchModeWatching(mode) && IsWatchModeWatching(watch.Mode) {
repoWatchDelta = -1
}
if repodiff == 1 { // starting to watch resets the options, otherwise a custom selection survives
watch.PullRequests, watch.Issues, watch.Releases = true, true, true
if repoWatchDelta == 1 { // starting to watch resets the options, otherwise a custom selection survives
watch.IncludePullRequests, watch.IncludeIssues, watch.IncludeReleases = true, true, true
}
watch.Mode = mode
if !hadrec && needsrec {
if !hadWatchModeSet && needSetWatchMode {
if err = db.Insert(ctx, watch); err != nil {
return err
}
} else if needsrec {
} else if needSetWatchMode {
if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil {
return err
}
} else if _, err = db.DeleteByID[Watch](ctx, watch.ID); err != nil {
return err
}
if repodiff != 0 {
_, err = db.GetEngine(ctx).Exec("UPDATE `repository` SET num_watches = num_watches + ? WHERE id = ?", repodiff, watch.RepoID)
if repoWatchDelta != 0 {
_, err = db.GetEngine(ctx).Exec("UPDATE `repository` SET num_watches = num_watches + ? WHERE id = ?", repoWatchDelta, watch.RepoID)
}
return err
}
// WatchRepo watch or unwatch repository.
func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doWatch bool) error {
// WatchRepoAuto watch or unwatch repository.
func WatchRepoAuto(ctx context.Context, doer *user_model.User, repo *Repository, doWatch bool) error {
watch, err := GetWatch(ctx, doer.ID, repo.ID)
if err != nil {
return err
}
if !doWatch && watch.Mode == WatchModeAuto {
return watchRepoMode(ctx, watch, WatchModeDont)
return watchRepoByMode(ctx, watch, WatchModeDont)
} else if !doWatch {
return watchRepoMode(ctx, watch, WatchModeNone)
return watchRepoByMode(ctx, watch, WatchModeNone)
}
if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) {
return user_model.ErrBlockedUser
}
return watchRepoMode(ctx, watch, WatchModeNormal)
}
// WatchIgnoreRepo mutes the repository (unwatch), so nothing about it reaches the user.
func WatchIgnoreRepo(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)
return watchRepoByMode(ctx, watch, WatchModeNormal)
}
type WatchOptions struct {
PullRequests bool
Issues bool
Releases bool
Mode WatchMode
WatchPullRequests bool
WatchIssues bool
WatchReleases bool
}
// WatchRepoWithOptions starts watching the repository and subscribes to the given events
func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repository, opts WatchOptions) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := WatchRepo(ctx, doer, repo, true); err != nil {
watch, err := GetWatch(ctx, doer.ID, repo.ID)
if err != nil {
return err
}
return SetWatchOptions(ctx, doer.ID, repo.ID, opts)
err = watchRepoByMode(ctx, watch, opts.Mode)
if err != nil {
return err
}
if opts.Mode == WatchModeNormal {
_, 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})
}
return err
})
}
// 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 {
@@ -225,7 +222,11 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
watches := make([]*Watch, 0, 10)
return watches, db.GetEngine(ctx).Where("`watch`.repo_id=?", repoID).
And("`watch`.mode<>?", WatchModeDont).
And(builder.Or(builder.Eq{"`watch`.pull_requests": true}, builder.Eq{"`watch`.issues": true}, builder.Eq{"`watch`.releases": true})).
And(builder.Or(
builder.Eq{"`watch`.include_pull_requests": true},
builder.Eq{"`watch`.include_issues": true},
builder.Eq{"`watch`.include_releases": true},
)).
And("`user`.is_active=?", true).
And("`user`.prohibit_login=?", false).
Join("INNER", "`user`", "`user`.id = `watch`.user_id").
@@ -247,10 +248,21 @@ func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) {
// User permissions must be verified elsewhere if required
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
ids := make([]int64, 0, 64)
var watchColName string
switch watchType {
case WatchPullRequests:
watchColName = "include_pull_requests"
case WatchIssues:
watchColName = "include_issues"
case WatchReleases:
watchColName = "include_releases"
default:
panic("invalid WatchType")
}
return ids, db.GetEngine(ctx).Table("watch").
Where("watch.repo_id=?", repoID).
And("watch.mode<>?", WatchModeDont).
And(builder.Eq{"watch." + string(watchType): true}).
And(builder.Eq{watchColName: true}).
Select("user_id").
Find(&ids)
}
@@ -283,7 +295,7 @@ func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error
if watch.Mode != WatchModeNone {
return nil
}
return watchRepoMode(ctx, watch, WatchModeAuto)
return watchRepoByMode(ctx, watch, WatchModeAuto)
}
// ClearRepoWatches clears all watches for a repository and from the user that watched it.
+14 -14
View File
@@ -19,13 +19,13 @@ import (
func TestIsWatching(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
assert.True(t, repo_model.IsWatching(t.Context(), 1, 1))
assert.True(t, repo_model.IsWatching(t.Context(), 4, 1))
assert.True(t, repo_model.IsWatching(t.Context(), 11, 1))
assert.True(t, repo_model.IsWatchingRepo(t.Context(), 1, 1))
assert.True(t, repo_model.IsWatchingRepo(t.Context(), 4, 1))
assert.True(t, repo_model.IsWatchingRepo(t.Context(), 11, 1))
assert.False(t, repo_model.IsWatching(t.Context(), 1, 5))
assert.False(t, repo_model.IsWatching(t.Context(), 8, 1))
assert.False(t, repo_model.IsWatching(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
assert.False(t, repo_model.IsWatchingRepo(t.Context(), 1, 5))
assert.False(t, repo_model.IsWatchingRepo(t.Context(), 8, 1))
assert.False(t, repo_model.IsWatchingRepo(t.Context(), unittest.NonexistentID, unittest.NonexistentID))
}
func TestGetWatchers(t *testing.T) {
@@ -109,7 +109,7 @@ func TestWatchIfAuto(t *testing.T) {
assert.Len(t, watchers, prevCount+1)
// Should remove watch, inhibit from adding auto
assert.NoError(t, repo_model.WatchRepo(t.Context(), user12, repo, false))
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user12, repo, false))
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Len(t, watchers, prevCount)
@@ -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.SetWatchOptions(t.Context(), user.ID, repo.ID, repo_model.WatchOptions{PullRequests: true}))
assert.NoError(t, repo_model.WatchRepoWithOptions(t.Context(), user, repo, repo_model.WatchOptions{Mode: repo_model.WatchModeNormal, WatchPullRequests: true}))
for watchType, expected := range map[repo_model.WatchType][]int64{
repo_model.WatchPullRequests: {1, 4, 9, 11},
@@ -160,11 +160,11 @@ func TestWatchOptions(t *testing.T) {
// 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)
assert.True(t, watches[repo.ID].IncludeIssues)
// 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))
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user, repo, false))
assert.NoError(t, repo_model.WatchRepoAuto(t.Context(), user, repo, true))
watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID)
assert.NoError(t, err)
assert.True(t, watch.IsWatchingAll())
@@ -172,9 +172,9 @@ func TestWatchOptions(t *testing.T) {
func TestWatchSelectedMode(t *testing.T) {
// a user without a watch row gets the dummy record, whose flags are the column defaults
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNone, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNone, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
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, Issues: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, IncludeIssues: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, IncludePullRequests: true, IncludeIssues: true, IncludeReleases: true}).SelectedMode())
}