* Add support for AUTO_WATCH_ON_CHANGES and AUTO_WATCH_ON_CLONE * Update models/repo_watch.go Co-Authored-By: Lauris BH <lauris@nix.lv> * Round up changes suggested by lafriks * Added changes suggested from automated tests * Updated deleteUser to take RepoWatchModeDont into account, corrected inverted DefaultWatchOnClone and DefaultWatchOnChanges behaviour, updated and added tests. * Reinsert import "github.com/Unknwon/com" on http.go * Add migration for new column `watch`.`mode` * Remove serv code * Remove WATCH_ON_CLONE; use hooks, add integrations * Renamed watch_test.go to repo_watch_test.go * Correct fmt * Add missing EOL * Correct name of test function * Reword cheat and ini descriptions * Add update to migration to ensure column value * Clarify comment Co-Authored-By: zeripath <art27@cantab.net> * Simplify if conditiontags/v1.21.12.1
| @@ -501,6 +501,9 @@ SHOW_REGISTRATION_BUTTON = true | |||
| ; When adding a repo to a team or creating a new repo all team members will watch the | |||
| ; repo automatically if enabled | |||
| AUTO_WATCH_NEW_REPOS = true | |||
| ; Default value for AutoWatchOnChanges | |||
| ; Make the user watch a repository When they commit for the first time | |||
| AUTO_WATCH_ON_CHANGES = false | |||
| [webhook] | |||
| ; Hook task queue length, increase if webhook shooting starts hanging | |||
| @@ -303,6 +303,7 @@ relation to port exhaustion. | |||
| on this instance. | |||
| - `SHOW_REGISTRATION_BUTTON`: **! DISABLE\_REGISTRATION**: Show Registration Button | |||
| - `AUTO_WATCH_NEW_REPOS`: **true**: Enable this to let all organisation users watch new repos when they are created | |||
| - `AUTO_WATCH_ON_CHANGES`: **false**: Enable this to make users watch a repository after their first commit to it | |||
| - `DEFAULT_ORG_VISIBILITY`: **public**: Set default visibility mode for organisations, either "public", "limited" or "private". | |||
| - `DEFAULT_ORG_MEMBER_VISIBLE`: **false** True will make the membership of the users visible when added to the organisation. | |||
| @@ -0,0 +1,24 @@ | |||
| // Copyright 2019 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package integrations | |||
| import ( | |||
| "net/url" | |||
| "testing" | |||
| "code.gitea.io/gitea/models" | |||
| "code.gitea.io/gitea/modules/setting" | |||
| ) | |||
| func TestRepoWatch(t *testing.T) { | |||
| onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { | |||
| // Test round-trip auto-watch | |||
| setting.Service.AutoWatchOnChanges = true | |||
| session := loginUser(t, "user2") | |||
| models.AssertNotExistsBean(t, &models.Watch{UserID: 2, RepoID: 3}) | |||
| testEditFile(t, session, "user3", "repo3", "master", "README.md", "Hello, World (Edited for watch)\n") | |||
| models.AssertExistsAndLoadBean(t, &models.Watch{UserID: 2, RepoID: 3, Mode: models.RepoWatchModeAuto}) | |||
| }) | |||
| } | |||
| @@ -84,14 +84,17 @@ func (user *User) checkForConsistency(t *testing.T) { | |||
| func (repo *Repository) checkForConsistency(t *testing.T) { | |||
| assert.Equal(t, repo.LowerName, strings.ToLower(repo.Name), "repo: %+v", repo) | |||
| assertCount(t, &Star{RepoID: repo.ID}, repo.NumStars) | |||
| assertCount(t, &Watch{RepoID: repo.ID}, repo.NumWatches) | |||
| assertCount(t, &Milestone{RepoID: repo.ID}, repo.NumMilestones) | |||
| assertCount(t, &Repository{ForkID: repo.ID}, repo.NumForks) | |||
| if repo.IsFork { | |||
| AssertExistsAndLoadBean(t, &Repository{ID: repo.ForkID}) | |||
| } | |||
| actual := getCount(t, x.Where("is_pull=?", false), &Issue{RepoID: repo.ID}) | |||
| actual := getCount(t, x.Where("Mode<>?", RepoWatchModeDont), &Watch{RepoID: repo.ID}) | |||
| assert.EqualValues(t, repo.NumWatches, actual, | |||
| "Unexpected number of watches for repo %+v", repo) | |||
| actual = getCount(t, x.Where("is_pull=?", false), &Issue{RepoID: repo.ID}) | |||
| assert.EqualValues(t, repo.NumIssues, actual, | |||
| "Unexpected number of issues for repo %+v", repo) | |||
| @@ -10,7 +10,7 @@ | |||
| num_closed_pulls: 0 | |||
| num_milestones: 3 | |||
| num_closed_milestones: 1 | |||
| num_watches: 3 | |||
| num_watches: 4 | |||
| status: 0 | |||
| - | |||
| @@ -2,13 +2,28 @@ | |||
| id: 1 | |||
| user_id: 1 | |||
| repo_id: 1 | |||
| mode: 1 # normal | |||
| - | |||
| id: 2 | |||
| user_id: 4 | |||
| repo_id: 1 | |||
| mode: 1 # normal | |||
| - | |||
| id: 3 | |||
| user_id: 9 | |||
| repo_id: 1 | |||
| mode: 1 # normal | |||
| - | |||
| id: 4 | |||
| user_id: 8 | |||
| repo_id: 1 | |||
| mode: 2 # don't watch | |||
| - | |||
| id: 5 | |||
| user_id: 11 | |||
| repo_id: 1 | |||
| mode: 3 # auto | |||
| @@ -266,6 +266,8 @@ var migrations = []Migration{ | |||
| NewMigration("remove unnecessary columns from label", removeLabelUneededCols), | |||
| // v105 -> v106 | |||
| NewMigration("add includes_all_repositories to teams", addTeamIncludesAllRepositories), | |||
| // v106 -> v107 | |||
| NewMigration("add column `mode` to table watch", addModeColumnToWatch), | |||
| } | |||
| // Migrate database to current version | |||
| @@ -0,0 +1,26 @@ | |||
| // Copyright 2019 The Gitea Authors. All rights reserved. | |||
| // Use of this source code is governed by a MIT-style | |||
| // license that can be found in the LICENSE file. | |||
| package migrations | |||
| import ( | |||
| "xorm.io/xorm" | |||
| ) | |||
| // RepoWatchMode specifies what kind of watch the user has on a repository | |||
| type RepoWatchMode int8 | |||
| // Watch is connection request for receiving repository notification. | |||
| type Watch struct { | |||
| ID int64 `xorm:"pk autoincr"` | |||
| Mode RepoWatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"` | |||
| } | |||
| func addModeColumnToWatch(x *xorm.Engine) (err error) { | |||
| if err = x.Sync2(new(Watch)); err != nil { | |||
| return | |||
| } | |||
| _, err = x.Exec("UPDATE `watch` SET `mode` = 1") | |||
| return err | |||
| } | |||
| @@ -2410,8 +2410,8 @@ func CheckRepoStats() { | |||
| checkers := []*repoChecker{ | |||
| // Repository.NumWatches | |||
| { | |||
| "SELECT repo.id FROM `repository` repo WHERE repo.num_watches!=(SELECT COUNT(*) FROM `watch` WHERE repo_id=repo.id)", | |||
| "UPDATE `repository` SET num_watches=(SELECT COUNT(*) FROM `watch` WHERE repo_id=?) WHERE id=?", | |||
| "SELECT repo.id FROM `repository` repo WHERE repo.num_watches!=(SELECT COUNT(*) FROM `watch` WHERE repo_id=repo.id AND mode<>2)", | |||
| "UPDATE `repository` SET num_watches=(SELECT COUNT(*) FROM `watch` WHERE repo_id=? AND mode<>2) WHERE id=?", | |||
| "repository count 'num_watches'", | |||
| }, | |||
| // Repository.NumStars | |||
| @@ -4,42 +4,118 @@ | |||
| package models | |||
| import "fmt" | |||
| import ( | |||
| "fmt" | |||
| "code.gitea.io/gitea/modules/setting" | |||
| ) | |||
| // RepoWatchMode specifies what kind of watch the user has on a repository | |||
| type RepoWatchMode int8 | |||
| const ( | |||
| // RepoWatchModeNone don't watch | |||
| RepoWatchModeNone RepoWatchMode = iota // 0 | |||
| // RepoWatchModeNormal watch repository (from other sources) | |||
| RepoWatchModeNormal // 1 | |||
| // RepoWatchModeDont explicit don't auto-watch | |||
| RepoWatchModeDont // 2 | |||
| // RepoWatchModeAuto watch repository (from AutoWatchOnChanges) | |||
| RepoWatchModeAuto // 3 | |||
| ) | |||
| // 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)"` | |||
| ID int64 `xorm:"pk autoincr"` | |||
| UserID int64 `xorm:"UNIQUE(watch)"` | |||
| RepoID int64 `xorm:"UNIQUE(watch)"` | |||
| Mode RepoWatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"` | |||
| } | |||
| func isWatching(e Engine, userID, repoID int64) bool { | |||
| has, _ := e.Get(&Watch{UserID: userID, RepoID: repoID}) | |||
| return has | |||
| // getWatch gets what kind of subscription a user has on a given repository; returns dummy record if none found | |||
| func getWatch(e Engine, userID, repoID int64) (Watch, error) { | |||
| watch := Watch{UserID: userID, RepoID: repoID} | |||
| has, err := e.Get(&watch) | |||
| if err != nil { | |||
| return watch, err | |||
| } | |||
| if !has { | |||
| watch.Mode = RepoWatchModeNone | |||
| } | |||
| return watch, nil | |||
| } | |||
| // Decodes watchability of RepoWatchMode | |||
| func isWatchMode(mode RepoWatchMode) bool { | |||
| return mode != RepoWatchModeNone && mode != RepoWatchModeDont | |||
| } | |||
| // IsWatching checks if user has watched given repository. | |||
| func IsWatching(userID, repoID int64) bool { | |||
| return isWatching(x, userID, repoID) | |||
| watch, err := getWatch(x, userID, repoID) | |||
| return err == nil && isWatchMode(watch.Mode) | |||
| } | |||
| func watchRepo(e Engine, userID, repoID int64, watch bool) (err error) { | |||
| if watch { | |||
| if isWatching(e, userID, repoID) { | |||
| return nil | |||
| } | |||
| if _, err = e.Insert(&Watch{RepoID: repoID, UserID: userID}); err != nil { | |||
| func watchRepoMode(e Engine, watch Watch, mode RepoWatchMode) (err error) { | |||
| if watch.Mode == mode { | |||
| return nil | |||
| } | |||
| if mode == RepoWatchModeAuto && (watch.Mode == RepoWatchModeDont || isWatchMode(watch.Mode)) { | |||
| // Don't auto watch if already watching or deliberately not watching | |||
| return nil | |||
| } | |||
| hadrec := watch.Mode != RepoWatchModeNone | |||
| needsrec := mode != RepoWatchModeNone | |||
| repodiff := 0 | |||
| if isWatchMode(mode) && !isWatchMode(watch.Mode) { | |||
| repodiff = 1 | |||
| } else if !isWatchMode(mode) && isWatchMode(watch.Mode) { | |||
| repodiff = -1 | |||
| } | |||
| watch.Mode = mode | |||
| if !hadrec && needsrec { | |||
| watch.Mode = mode | |||
| if _, err = e.Insert(watch); err != nil { | |||
| return err | |||
| } | |||
| _, err = e.Exec("UPDATE `repository` SET num_watches = num_watches + 1 WHERE id = ?", repoID) | |||
| } else { | |||
| if !isWatching(e, userID, repoID) { | |||
| return nil | |||
| } | |||
| if _, err = e.Delete(&Watch{0, userID, repoID}); err != nil { | |||
| } else if needsrec { | |||
| watch.Mode = mode | |||
| if _, err := e.ID(watch.ID).AllCols().Update(watch); err != nil { | |||
| return err | |||
| } | |||
| _, err = e.Exec("UPDATE `repository` SET num_watches = num_watches - 1 WHERE id = ?", repoID) | |||
| } else if _, err = e.Delete(Watch{ID: watch.ID}); err != nil { | |||
| return err | |||
| } | |||
| if repodiff != 0 { | |||
| _, err = e.Exec("UPDATE `repository` SET num_watches = num_watches + ? WHERE id = ?", repodiff, watch.RepoID) | |||
| } | |||
| return err | |||
| } | |||
| // WatchRepoMode watch repository in specific mode. | |||
| func WatchRepoMode(userID, repoID int64, mode RepoWatchMode) (err error) { | |||
| var watch Watch | |||
| if watch, err = getWatch(x, userID, repoID); err != nil { | |||
| return err | |||
| } | |||
| return watchRepoMode(x, watch, mode) | |||
| } | |||
| func watchRepo(e Engine, userID, repoID int64, doWatch bool) (err error) { | |||
| var watch Watch | |||
| if watch, err = getWatch(e, userID, repoID); err != nil { | |||
| return err | |||
| } | |||
| if !doWatch && watch.Mode == RepoWatchModeAuto { | |||
| err = watchRepoMode(e, watch, RepoWatchModeDont) | |||
| } else if !doWatch { | |||
| err = watchRepoMode(e, watch, RepoWatchModeNone) | |||
| } else { | |||
| err = watchRepoMode(e, watch, RepoWatchModeNormal) | |||
| } | |||
| return err | |||
| } | |||
| @@ -52,6 +128,7 @@ func WatchRepo(userID, repoID int64, watch bool) (err error) { | |||
| func getWatchers(e Engine, repoID int64) ([]*Watch, error) { | |||
| watches := make([]*Watch, 0, 10) | |||
| return watches, e.Where("`watch`.repo_id=?", repoID). | |||
| And("`watch`.mode<>?", RepoWatchModeDont). | |||
| And("`user`.is_active=?", true). | |||
| And("`user`.prohibit_login=?", false). | |||
| Join("INNER", "`user`", "`user`.id = `watch`.user_id"). | |||
| @@ -67,7 +144,8 @@ func GetWatchers(repoID int64) ([]*Watch, error) { | |||
| func (repo *Repository) GetWatchers(page int) ([]*User, error) { | |||
| users := make([]*User, 0, ItemsPerPage) | |||
| sess := x.Where("watch.repo_id=?", repo.ID). | |||
| Join("LEFT", "watch", "`user`.id=`watch`.user_id") | |||
| Join("LEFT", "watch", "`user`.id=`watch`.user_id"). | |||
| And("`watch`.mode<>?", RepoWatchModeDont) | |||
| if page > 0 { | |||
| sess = sess.Limit(ItemsPerPage, (page-1)*ItemsPerPage) | |||
| } | |||
| @@ -137,3 +215,22 @@ func notifyWatchers(e Engine, act *Action) error { | |||
| func NotifyWatchers(act *Action) error { | |||
| return notifyWatchers(x, act) | |||
| } | |||
| func watchIfAuto(e Engine, userID, repoID int64, isWrite bool) error { | |||
| if !isWrite || !setting.Service.AutoWatchOnChanges { | |||
| return nil | |||
| } | |||
| watch, err := getWatch(e, userID, repoID) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| if watch.Mode != RepoWatchModeNone { | |||
| return nil | |||
| } | |||
| return watchRepoMode(e, watch, RepoWatchModeAuto) | |||
| } | |||
| // WatchIfAuto subscribes to repo if AutoWatchOnChanges is set | |||
| func WatchIfAuto(userID int64, repoID int64, isWrite bool) error { | |||
| return watchIfAuto(x, userID, repoID, isWrite) | |||
| } | |||
| @@ -7,6 +7,8 @@ package models | |||
| import ( | |||
| "testing" | |||
| "code.gitea.io/gitea/modules/setting" | |||
| "github.com/stretchr/testify/assert" | |||
| ) | |||
| @@ -15,8 +17,10 @@ func TestIsWatching(t *testing.T) { | |||
| assert.True(t, IsWatching(1, 1)) | |||
| assert.True(t, IsWatching(4, 1)) | |||
| assert.True(t, IsWatching(11, 1)) | |||
| assert.False(t, IsWatching(1, 5)) | |||
| assert.False(t, IsWatching(8, 1)) | |||
| assert.False(t, IsWatching(NonexistentID, NonexistentID)) | |||
| } | |||
| @@ -78,7 +82,7 @@ func TestNotifyWatchers(t *testing.T) { | |||
| } | |||
| assert.NoError(t, NotifyWatchers(action)) | |||
| // One watchers are inactive, thus action is only created for user 8, 1, 4 | |||
| // One watchers are inactive, thus action is only created for user 8, 1, 4, 11 | |||
| AssertExistsAndLoadBean(t, &Action{ | |||
| ActUserID: action.ActUserID, | |||
| UserID: 8, | |||
| @@ -97,4 +101,88 @@ func TestNotifyWatchers(t *testing.T) { | |||
| RepoID: action.RepoID, | |||
| OpType: action.OpType, | |||
| }) | |||
| AssertExistsAndLoadBean(t, &Action{ | |||
| ActUserID: action.ActUserID, | |||
| UserID: 11, | |||
| RepoID: action.RepoID, | |||
| OpType: action.OpType, | |||
| }) | |||
| } | |||
| func TestWatchIfAuto(t *testing.T) { | |||
| assert.NoError(t, PrepareTestDatabase()) | |||
| repo := AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository) | |||
| watchers, err := repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, repo.NumWatches) | |||
| setting.Service.AutoWatchOnChanges = false | |||
| prevCount := repo.NumWatches | |||
| // Must not add watch | |||
| assert.NoError(t, WatchIfAuto(8, 1, true)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount) | |||
| // Should not add watch | |||
| assert.NoError(t, WatchIfAuto(10, 1, true)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount) | |||
| setting.Service.AutoWatchOnChanges = true | |||
| // Must not add watch | |||
| assert.NoError(t, WatchIfAuto(8, 1, true)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount) | |||
| // Should not add watch | |||
| assert.NoError(t, WatchIfAuto(12, 1, false)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount) | |||
| // Should add watch | |||
| assert.NoError(t, WatchIfAuto(12, 1, true)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount+1) | |||
| // Should remove watch, inhibit from adding auto | |||
| assert.NoError(t, WatchRepo(12, 1, false)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount) | |||
| // Must not add watch | |||
| assert.NoError(t, WatchIfAuto(12, 1, true)) | |||
| watchers, err = repo.GetWatchers(1) | |||
| assert.NoError(t, err) | |||
| assert.Len(t, watchers, prevCount) | |||
| } | |||
| func TestWatchRepoMode(t *testing.T) { | |||
| assert.NoError(t, PrepareTestDatabase()) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 0) | |||
| assert.NoError(t, WatchRepoMode(12, 1, RepoWatchModeAuto)) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 1) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1, Mode: RepoWatchModeAuto}, 1) | |||
| assert.NoError(t, WatchRepoMode(12, 1, RepoWatchModeNormal)) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 1) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1, Mode: RepoWatchModeNormal}, 1) | |||
| assert.NoError(t, WatchRepoMode(12, 1, RepoWatchModeDont)) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 1) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1, Mode: RepoWatchModeDont}, 1) | |||
| assert.NoError(t, WatchRepoMode(12, 1, RepoWatchModeNone)) | |||
| AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 0) | |||
| } | |||
| @@ -1082,7 +1082,7 @@ func deleteUser(e *xorm.Session, u *User) error { | |||
| // ***** START: Watch ***** | |||
| watchedRepoIDs := make([]int64, 0, 10) | |||
| if err = e.Table("watch").Cols("watch.repo_id"). | |||
| Where("watch.user_id = ?", u.ID).Find(&watchedRepoIDs); err != nil { | |||
| Where("watch.user_id = ?", u.ID).And("watch.mode <>?", RepoWatchModeDont).Find(&watchedRepoIDs); err != nil { | |||
| return fmt.Errorf("get all watches: %v", err) | |||
| } | |||
| if _, err = e.Decr("num_watches").In("id", watchedRepoIDs).NoAutoTime().Update(new(Repository)); err != nil { | |||
| @@ -1543,6 +1543,7 @@ func GetStarredRepos(userID int64, private bool) ([]*Repository, error) { | |||
| // GetWatchedRepos returns the repos watched by a particular user | |||
| func GetWatchedRepos(userID int64, private bool) ([]*Repository, error) { | |||
| sess := x.Where("watch.user_id=?", userID). | |||
| And("`watch`.mode<>?", RepoWatchModeDont). | |||
| Join("LEFT", "watch", "`repository`.id=`watch`.repo_id") | |||
| if !private { | |||
| sess = sess.And("is_private=?", false) | |||
| @@ -504,5 +504,10 @@ func PushUpdate(repo *models.Repository, branch string, opts models.PushUpdateOp | |||
| if opts.RefFullName == git.BranchPrefix+repo.DefaultBranch { | |||
| models.UpdateRepoIndexer(repo) | |||
| } | |||
| if err = models.WatchIfAuto(opts.PusherID, repo.ID, true); err != nil { | |||
| log.Warn("Fail to perform auto watch on user %v for repo %v: %v", opts.PusherID, repo.ID, err) | |||
| } | |||
| return nil | |||
| } | |||
| @@ -44,6 +44,7 @@ var Service struct { | |||
| NoReplyAddress string | |||
| EnableUserHeatmap bool | |||
| AutoWatchNewRepos bool | |||
| AutoWatchOnChanges bool | |||
| DefaultOrgMemberVisible bool | |||
| // OpenID settings | |||
| @@ -85,6 +86,7 @@ func newService() { | |||
| Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org") | |||
| Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true) | |||
| Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true) | |||
| Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false) | |||
| Service.DefaultOrgVisibility = sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes)) | |||
| Service.DefaultOrgVisibilityMode = structs.VisibilityModes[Service.DefaultOrgVisibility] | |||
| Service.DefaultOrgMemberVisible = sec.Key("DEFAULT_ORG_MEMBER_VISIBLE").MustBool() | |||