summaryrefslogtreecommitdiffstats
path: root/models/repo
diff options
context:
space:
mode:
Diffstat (limited to 'models/repo')
-rw-r--r--models/repo/archiver.go7
-rw-r--r--models/repo/fork.go69
-rw-r--r--models/repo/fork_test.go32
-rw-r--r--models/repo/main_test.go6
-rw-r--r--models/repo/redirect.go82
-rw-r--r--models/repo/redirect_test.go77
-rw-r--r--models/repo/repo.go36
-rw-r--r--models/repo/repo_test.go7
-rw-r--r--models/repo/repo_unit.go26
-rw-r--r--models/repo/star.go90
-rw-r--r--models/repo/star_test.go53
-rw-r--r--models/repo/topic.go369
-rw-r--r--models/repo/topic_test.go80
-rw-r--r--models/repo/update.go179
-rw-r--r--models/repo/watch.go196
-rw-r--r--models/repo/watch_test.go139
16 files changed, 1448 insertions, 0 deletions
diff --git a/models/repo/archiver.go b/models/repo/archiver.go
index cee6013ca3..c29891397f 100644
--- a/models/repo/archiver.go
+++ b/models/repo/archiver.go
@@ -107,3 +107,10 @@ func FindRepoArchives(opts FindRepoArchiversOption) ([]*RepoArchiver, error) {
Find(&archivers)
return archivers, err
}
+
+// SetArchiveRepoState sets if a repo is archived
+func SetArchiveRepoState(repo *Repository, isArchived bool) (err error) {
+ repo.IsArchived = isArchived
+ _, err = db.GetEngine(db.DefaultContext).Where("id = ?", repo.ID).Cols("is_archived").NoAutoTime().Update(repo)
+ return
+}
diff --git a/models/repo/fork.go b/models/repo/fork.go
new file mode 100644
index 0000000000..570a5b68ab
--- /dev/null
+++ b/models/repo/fork.go
@@ -0,0 +1,69 @@
+// Copyright 2021 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 repo
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+)
+
+func getRepositoriesByForkID(e db.Engine, forkID int64) ([]*Repository, error) {
+ repos := make([]*Repository, 0, 10)
+ return repos, e.
+ Where("fork_id=?", forkID).
+ Find(&repos)
+}
+
+// GetRepositoriesByForkID returns all repositories with given fork ID.
+func GetRepositoriesByForkID(ctx context.Context, forkID int64) ([]*Repository, error) {
+ return getRepositoriesByForkID(db.GetEngine(ctx), forkID)
+}
+
+// GetForkedRepo checks if given user has already forked a repository with given ID.
+func GetForkedRepo(ownerID, repoID int64) *Repository {
+ repo := new(Repository)
+ has, _ := db.GetEngine(db.DefaultContext).
+ Where("owner_id=? AND fork_id=?", ownerID, repoID).
+ Get(repo)
+ if has {
+ return repo
+ }
+ return nil
+}
+
+// HasForkedRepo checks if given user has already forked a repository with given ID.
+func HasForkedRepo(ownerID, repoID int64) bool {
+ has, _ := db.GetEngine(db.DefaultContext).
+ Table("repository").
+ Where("owner_id=? AND fork_id=?", ownerID, repoID).
+ Exist()
+ return has
+}
+
+// GetUserFork return user forked repository from this repository, if not forked return nil
+func GetUserFork(repoID, userID int64) (*Repository, error) {
+ var forkedRepo Repository
+ has, err := db.GetEngine(db.DefaultContext).Where("fork_id = ?", repoID).And("owner_id = ?", userID).Get(&forkedRepo)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return &forkedRepo, nil
+}
+
+// GetForks returns all the forks of the repository
+func GetForks(repo *Repository, listOptions db.ListOptions) ([]*Repository, error) {
+ if listOptions.Page == 0 {
+ forks := make([]*Repository, 0, repo.NumForks)
+ return forks, db.GetEngine(db.DefaultContext).Find(&forks, &Repository{ForkID: repo.ID})
+ }
+
+ sess := db.GetPaginatedSession(&listOptions)
+ forks := make([]*Repository, 0, listOptions.PageSize)
+ return forks, sess.Find(&forks, &Repository{ForkID: repo.ID})
+}
diff --git a/models/repo/fork_test.go b/models/repo/fork_test.go
new file mode 100644
index 0000000000..bf6b90b388
--- /dev/null
+++ b/models/repo/fork_test.go
@@ -0,0 +1,32 @@
+// Copyright 2021 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 repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetUserFork(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ // User13 has repo 11 forked from repo10
+ repo, err := GetRepositoryByID(10)
+ assert.NoError(t, err)
+ assert.NotNil(t, repo)
+ repo, err = GetUserFork(repo.ID, 13)
+ assert.NoError(t, err)
+ assert.NotNil(t, repo)
+
+ repo, err = GetRepositoryByID(9)
+ assert.NoError(t, err)
+ assert.NotNil(t, repo)
+ repo, err = GetUserFork(repo.ID, 13)
+ assert.NoError(t, err)
+ assert.Nil(t, repo)
+}
diff --git a/models/repo/main_test.go b/models/repo/main_test.go
index f40a976281..fdd6c3f4d3 100644
--- a/models/repo/main_test.go
+++ b/models/repo/main_test.go
@@ -18,5 +18,11 @@ func TestMain(m *testing.M) {
"repository.yml",
"repo_unit.yml",
"repo_indexer_status.yml",
+ "repo_redirect.yml",
+ "watch.yml",
+ "star.yml",
+ "topic.yml",
+ "repo_topic.yml",
+ "user.yml",
)
}
diff --git a/models/repo/redirect.go b/models/repo/redirect.go
new file mode 100644
index 0000000000..88fad6f3e3
--- /dev/null
+++ b/models/repo/redirect.go
@@ -0,0 +1,82 @@
+// Copyright 2017 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 repo
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+)
+
+// ErrRedirectNotExist represents a "RedirectNotExist" kind of error.
+type ErrRedirectNotExist struct {
+ OwnerID int64
+ RepoName string
+}
+
+// IsErrRedirectNotExist check if an error is an ErrRepoRedirectNotExist.
+func IsErrRedirectNotExist(err error) bool {
+ _, ok := err.(ErrRedirectNotExist)
+ return ok
+}
+
+func (err ErrRedirectNotExist) Error() string {
+ return fmt.Sprintf("repository redirect does not exist [uid: %d, name: %s]", err.OwnerID, err.RepoName)
+}
+
+// Redirect represents that a repo name should be redirected to another
+type Redirect struct {
+ ID int64 `xorm:"pk autoincr"`
+ OwnerID int64 `xorm:"UNIQUE(s)"`
+ LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
+ RedirectRepoID int64 // repoID to redirect to
+}
+
+// TableName represents real table name in database
+func (Redirect) TableName() string {
+ return "repo_redirect"
+}
+
+func init() {
+ db.RegisterModel(new(Redirect))
+}
+
+// LookupRedirect look up if a repository has a redirect name
+func LookupRedirect(ownerID int64, repoName string) (int64, error) {
+ repoName = strings.ToLower(repoName)
+ redirect := &Redirect{OwnerID: ownerID, LowerName: repoName}
+ if has, err := db.GetEngine(db.DefaultContext).Get(redirect); err != nil {
+ return 0, err
+ } else if !has {
+ return 0, ErrRedirectNotExist{OwnerID: ownerID, RepoName: repoName}
+ }
+ return redirect.RedirectRepoID, nil
+}
+
+// NewRedirect create a new repo redirect
+func NewRedirect(ctx context.Context, ownerID, repoID int64, oldRepoName, newRepoName string) error {
+ oldRepoName = strings.ToLower(oldRepoName)
+ newRepoName = strings.ToLower(newRepoName)
+
+ if err := DeleteRedirect(ctx, ownerID, newRepoName); err != nil {
+ return err
+ }
+
+ return db.Insert(ctx, &Redirect{
+ OwnerID: ownerID,
+ LowerName: oldRepoName,
+ RedirectRepoID: repoID,
+ })
+}
+
+// DeleteRedirect delete any redirect from the specified repo name to
+// anything else
+func DeleteRedirect(ctx context.Context, ownerID int64, repoName string) error {
+ repoName = strings.ToLower(repoName)
+ _, err := db.GetEngine(ctx).Delete(&Redirect{OwnerID: ownerID, LowerName: repoName})
+ return err
+}
diff --git a/models/repo/redirect_test.go b/models/repo/redirect_test.go
new file mode 100644
index 0000000000..2dca2cbbfd
--- /dev/null
+++ b/models/repo/redirect_test.go
@@ -0,0 +1,77 @@
+// Copyright 2017 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 repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLookupRedirect(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repoID, err := LookupRedirect(2, "oldrepo1")
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, repoID)
+
+ _, err = LookupRedirect(unittest.NonexistentID, "doesnotexist")
+ assert.True(t, IsErrRedirectNotExist(err))
+}
+
+func TestNewRedirect(t *testing.T) {
+ // redirect to a completely new name
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
+ assert.NoError(t, NewRedirect(db.DefaultContext, repo.OwnerID, repo.ID, repo.Name, "newreponame"))
+
+ unittest.AssertExistsAndLoadBean(t, &Redirect{
+ OwnerID: repo.OwnerID,
+ LowerName: repo.LowerName,
+ RedirectRepoID: repo.ID,
+ })
+ unittest.AssertExistsAndLoadBean(t, &Redirect{
+ OwnerID: repo.OwnerID,
+ LowerName: "oldrepo1",
+ RedirectRepoID: repo.ID,
+ })
+}
+
+func TestNewRedirect2(t *testing.T) {
+ // redirect to previously used name
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
+ assert.NoError(t, NewRedirect(db.DefaultContext, repo.OwnerID, repo.ID, repo.Name, "oldrepo1"))
+
+ unittest.AssertExistsAndLoadBean(t, &Redirect{
+ OwnerID: repo.OwnerID,
+ LowerName: repo.LowerName,
+ RedirectRepoID: repo.ID,
+ })
+ unittest.AssertNotExistsBean(t, &Redirect{
+ OwnerID: repo.OwnerID,
+ LowerName: "oldrepo1",
+ RedirectRepoID: repo.ID,
+ })
+}
+
+func TestNewRedirect3(t *testing.T) {
+ // redirect for a previously-unredirected repo
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 2}).(*Repository)
+ assert.NoError(t, NewRedirect(db.DefaultContext, repo.OwnerID, repo.ID, repo.Name, "newreponame"))
+
+ unittest.AssertExistsAndLoadBean(t, &Redirect{
+ OwnerID: repo.OwnerID,
+ LowerName: repo.LowerName,
+ RedirectRepoID: repo.ID,
+ })
+}
diff --git a/models/repo/repo.go b/models/repo/repo.go
index 9353e813bc..8907691dde 100644
--- a/models/repo/repo.go
+++ b/models/repo/repo.go
@@ -25,6 +25,20 @@ import (
"code.gitea.io/gitea/modules/util"
)
+var (
+ reservedRepoNames = []string{".", ".."}
+ reservedRepoPatterns = []string{"*.git", "*.wiki", "*.rss", "*.atom"}
+)
+
+// IsUsableRepoName returns true when repository is usable
+func IsUsableRepoName(name string) error {
+ if db.AlphaDashDotPattern.MatchString(name) {
+ // Note: usually this error is normally caught up earlier in the UI
+ return db.ErrNameCharsNotAllowed{Name: name}
+ }
+ return db.IsUsableName(reservedRepoNames, reservedRepoPatterns, name)
+}
+
// TrustModelType defines the types of trust model for this repository
type TrustModelType int
@@ -734,3 +748,25 @@ func GetPublicRepositoryCount(u *user_model.User) (int64, error) {
func GetPrivateRepositoryCount(u *user_model.User) (int64, error) {
return getPrivateRepositoryCount(db.GetEngine(db.DefaultContext), u)
}
+
+// IterateRepository iterate repositories
+func IterateRepository(f func(repo *Repository) error) error {
+ var start int
+ batchSize := setting.Database.IterateBufferSize
+ for {
+ repos := make([]*Repository, 0, batchSize)
+ if err := db.GetEngine(db.DefaultContext).Limit(batchSize, start).Find(&repos); err != nil {
+ return err
+ }
+ if len(repos) == 0 {
+ return nil
+ }
+ start += len(repos)
+
+ for _, repo := range repos {
+ if err := f(repo); err != nil {
+ return err
+ }
+ }
+ }
+}
diff --git a/models/repo/repo_test.go b/models/repo/repo_test.go
index 6f48a22e49..92b95f1d41 100644
--- a/models/repo/repo_test.go
+++ b/models/repo/repo_test.go
@@ -42,3 +42,10 @@ func TestGetPrivateRepositoryCount(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, int64(2), count)
}
+
+func TestRepoAPIURL(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)
+
+ assert.Equal(t, "https://try.gitea.io/api/v1/repos/user12/repo10", repo.APIURL())
+}
diff --git a/models/repo/repo_unit.go b/models/repo/repo_unit.go
index 5f6c43f02f..1957f88ff3 100644
--- a/models/repo/repo_unit.go
+++ b/models/repo/repo_unit.go
@@ -242,3 +242,29 @@ func UpdateRepoUnit(unit *RepoUnit) error {
_, err := db.GetEngine(db.DefaultContext).ID(unit.ID).Update(unit)
return err
}
+
+// UpdateRepositoryUnits updates a repository's units
+func UpdateRepositoryUnits(repo *Repository, units []RepoUnit, deleteUnitTypes []unit.Type) (err error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ // Delete existing settings of units before adding again
+ for _, u := range units {
+ deleteUnitTypes = append(deleteUnitTypes, u.Type)
+ }
+
+ if _, err = db.GetEngine(ctx).Where("repo_id = ?", repo.ID).In("type", deleteUnitTypes).Delete(new(RepoUnit)); err != nil {
+ return err
+ }
+
+ if len(units) > 0 {
+ if err = db.Insert(ctx, units); err != nil {
+ return err
+ }
+ }
+
+ return committer.Commit()
+}
diff --git a/models/repo/star.go b/models/repo/star.go
new file mode 100644
index 0000000000..8db297e3b4
--- /dev/null
+++ b/models/repo/star.go
@@ -0,0 +1,90 @@
+// Copyright 2016 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 repo
+
+import (
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// Star represents a starred repo by an user.
+type Star struct {
+ ID int64 `xorm:"pk autoincr"`
+ UID int64 `xorm:"UNIQUE(s)"`
+ RepoID int64 `xorm:"UNIQUE(s)"`
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+}
+
+func init() {
+ db.RegisterModel(new(Star))
+}
+
+// StarRepo or unstar repository.
+func StarRepo(userID, repoID int64, star bool) error {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ staring := isStaring(db.GetEngine(ctx), userID, repoID)
+
+ if star {
+ if staring {
+ return nil
+ }
+
+ if err := db.Insert(ctx, &Star{UID: userID, RepoID: repoID}); err != nil {
+ return err
+ }
+ if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars + 1 WHERE id = ?", repoID); err != nil {
+ return err
+ }
+ if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars + 1 WHERE id = ?", userID); err != nil {
+ return err
+ }
+ } else {
+ if !staring {
+ return nil
+ }
+
+ if _, err := db.DeleteByBean(ctx, &Star{UID: userID, RepoID: repoID}); err != nil {
+ return err
+ }
+ if _, err := db.Exec(ctx, "UPDATE `repository` SET num_stars = num_stars - 1 WHERE id = ?", repoID); err != nil {
+ return err
+ }
+ if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", userID); err != nil {
+ return err
+ }
+ }
+
+ return committer.Commit()
+}
+
+// IsStaring checks if user has starred given repository.
+func IsStaring(userID, repoID int64) bool {
+ return isStaring(db.GetEngine(db.DefaultContext), userID, repoID)
+}
+
+func isStaring(e db.Engine, userID, repoID int64) bool {
+ has, _ := e.Get(&Star{UID: userID, RepoID: repoID})
+ return has
+}
+
+// GetStargazers returns the users that starred the repo.
+func GetStargazers(repo *Repository, opts db.ListOptions) ([]*user_model.User, error) {
+ sess := db.GetEngine(db.DefaultContext).Where("star.repo_id = ?", repo.ID).
+ Join("LEFT", "star", "`user`.id = star.uid")
+ if opts.Page > 0 {
+ sess = db.SetSessionPagination(sess, &opts)
+
+ users := make([]*user_model.User, 0, opts.PageSize)
+ return users, sess.Find(&users)
+ }
+
+ users := make([]*user_model.User, 0, 8)
+ return users, sess.Find(&users)
+}
diff --git a/models/repo/star_test.go b/models/repo/star_test.go
new file mode 100644
index 0000000000..20c4b6bef4
--- /dev/null
+++ b/models/repo/star_test.go
@@ -0,0 +1,53 @@
+// Copyright 2017 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 repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestStarRepo(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ const userID = 2
+ const repoID = 1
+ unittest.AssertNotExistsBean(t, &Star{UID: userID, RepoID: repoID})
+ assert.NoError(t, StarRepo(userID, repoID, true))
+ unittest.AssertExistsAndLoadBean(t, &Star{UID: userID, RepoID: repoID})
+ assert.NoError(t, StarRepo(userID, repoID, true))
+ unittest.AssertExistsAndLoadBean(t, &Star{UID: userID, RepoID: repoID})
+ assert.NoError(t, StarRepo(userID, repoID, false))
+ unittest.AssertNotExistsBean(t, &Star{UID: userID, RepoID: repoID})
+}
+
+func TestIsStaring(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ assert.True(t, IsStaring(2, 4))
+ assert.False(t, IsStaring(3, 4))
+}
+
+func TestRepository_GetStargazers(t *testing.T) {
+ // repo with stargazers
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 4}).(*Repository)
+ gazers, err := GetStargazers(repo, db.ListOptions{Page: 0})
+ assert.NoError(t, err)
+ if assert.Len(t, gazers, 1) {
+ assert.Equal(t, int64(2), gazers[0].ID)
+ }
+}
+
+func TestRepository_GetStargazers2(t *testing.T) {
+ // repo with stargazers
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 3}).(*Repository)
+ gazers, err := GetStargazers(repo, db.ListOptions{Page: 0})
+ assert.NoError(t, err)
+ assert.Len(t, gazers, 0)
+}
diff --git a/models/repo/topic.go b/models/repo/topic.go
new file mode 100644
index 0000000000..121863519b
--- /dev/null
+++ b/models/repo/topic.go
@@ -0,0 +1,369 @@
+// Copyright 2018 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 repo
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/timeutil"
+
+ "xorm.io/builder"
+)
+
+func init() {
+ db.RegisterModel(new(Topic))
+ db.RegisterModel(new(RepoTopic))
+}
+
+var topicPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
+
+// Topic represents a topic of repositories
+type Topic struct {
+ ID int64 `xorm:"pk autoincr"`
+ Name string `xorm:"UNIQUE VARCHAR(50)"`
+ RepoCount int
+ CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
+ UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
+}
+
+// RepoTopic represents associated repositories and topics
+type RepoTopic struct { //revive:disable-line:exported
+ RepoID int64 `xorm:"pk"`
+ TopicID int64 `xorm:"pk"`
+}
+
+// ErrTopicNotExist represents an error that a topic is not exist
+type ErrTopicNotExist struct {
+ Name string
+}
+
+// IsErrTopicNotExist checks if an error is an ErrTopicNotExist.
+func IsErrTopicNotExist(err error) bool {
+ _, ok := err.(ErrTopicNotExist)
+ return ok
+}
+
+// Error implements error interface
+func (err ErrTopicNotExist) Error() string {
+ return fmt.Sprintf("topic is not exist [name: %s]", err.Name)
+}
+
+// ValidateTopic checks a topic by length and match pattern rules
+func ValidateTopic(topic string) bool {
+ return len(topic) <= 35 && topicPattern.MatchString(topic)
+}
+
+// SanitizeAndValidateTopics sanitizes and checks an array or topics
+func SanitizeAndValidateTopics(topics []string) (validTopics, invalidTopics []string) {
+ validTopics = make([]string, 0)
+ mValidTopics := make(map[string]struct{})
+ invalidTopics = make([]string, 0)
+
+ for _, topic := range topics {
+ topic = strings.TrimSpace(strings.ToLower(topic))
+ // ignore empty string
+ if len(topic) == 0 {
+ continue
+ }
+ // ignore same topic twice
+ if _, ok := mValidTopics[topic]; ok {
+ continue
+ }
+ if ValidateTopic(topic) {
+ validTopics = append(validTopics, topic)
+ mValidTopics[topic] = struct{}{}
+ } else {
+ invalidTopics = append(invalidTopics, topic)
+ }
+ }
+
+ return validTopics, invalidTopics
+}
+
+// GetTopicByName retrieves topic by name
+func GetTopicByName(name string) (*Topic, error) {
+ var topic Topic
+ if has, err := db.GetEngine(db.DefaultContext).Where("name = ?", name).Get(&topic); err != nil {
+ return nil, err
+ } else if !has {
+ return nil, ErrTopicNotExist{name}
+ }
+ return &topic, nil
+}
+
+// addTopicByNameToRepo adds a topic name to a repo and increments the topic count.
+// Returns topic after the addition
+func addTopicByNameToRepo(e db.Engine, repoID int64, topicName string) (*Topic, error) {
+ var topic Topic
+ has, err := e.Where("name = ?", topicName).Get(&topic)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ topic.Name = topicName
+ topic.RepoCount = 1
+ if _, err := e.Insert(&topic); err != nil {
+ return nil, err
+ }
+ } else {
+ topic.RepoCount++
+ if _, err := e.ID(topic.ID).Cols("repo_count").Update(&topic); err != nil {
+ return nil, err
+ }
+ }
+
+ if _, err := e.Insert(&RepoTopic{
+ RepoID: repoID,
+ TopicID: topic.ID,
+ }); err != nil {
+ return nil, err
+ }
+
+ return &topic, nil
+}
+
+// removeTopicFromRepo remove a topic from a repo and decrements the topic repo count
+func removeTopicFromRepo(e db.Engine, repoID int64, topic *Topic) error {
+ topic.RepoCount--
+ if _, err := e.ID(topic.ID).Cols("repo_count").Update(topic); err != nil {
+ return err
+ }
+
+ if _, err := e.Delete(&RepoTopic{
+ RepoID: repoID,
+ TopicID: topic.ID,
+ }); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// RemoveTopicsFromRepo remove all topics from the repo and decrements respective topics repo count
+func RemoveTopicsFromRepo(ctx context.Context, repoID int64) error {
+ e := db.GetEngine(ctx)
+ _, err := e.Where(
+ builder.In("id",
+ builder.Select("topic_id").From("repo_topic").Where(builder.Eq{"repo_id": repoID}),
+ ),
+ ).Cols("repo_count").SetExpr("repo_count", "repo_count-1").Update(&Topic{})
+ if err != nil {
+ return err
+ }
+
+ if _, err = e.Delete(&RepoTopic{RepoID: repoID}); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// FindTopicOptions represents the options when fdin topics
+type FindTopicOptions struct {
+ db.ListOptions
+ RepoID int64
+ Keyword string
+}
+
+func (opts *FindTopicOptions) toConds() builder.Cond {
+ cond := builder.NewCond()
+ if opts.RepoID > 0 {
+ cond = cond.And(builder.Eq{"repo_topic.repo_id": opts.RepoID})
+ }
+
+ if opts.Keyword != "" {
+ cond = cond.And(builder.Like{"topic.name", opts.Keyword})
+ }
+
+ return cond
+}
+
+// FindTopics retrieves the topics via FindTopicOptions
+func FindTopics(opts *FindTopicOptions) ([]*Topic, int64, error) {
+ sess := db.GetEngine(db.DefaultContext).Select("topic.*").Where(opts.toConds())
+ if opts.RepoID > 0 {
+ sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
+ }
+ if opts.PageSize != 0 && opts.Page != 0 {
+ sess = db.SetSessionPagination(sess, opts)
+ }
+ topics := make([]*Topic, 0, 10)
+ total, err := sess.Desc("topic.repo_count").FindAndCount(&topics)
+ return topics, total, err
+}
+
+// CountTopics counts the number of topics matching the FindTopicOptions
+func CountTopics(opts *FindTopicOptions) (int64, error) {
+ sess := db.GetEngine(db.DefaultContext).Where(opts.toConds())
+ if opts.RepoID > 0 {
+ sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
+ }
+ return sess.Count(new(Topic))
+}
+
+// GetRepoTopicByName retrieves topic from name for a repo if it exist
+func GetRepoTopicByName(repoID int64, topicName string) (*Topic, error) {
+ return getRepoTopicByName(db.GetEngine(db.DefaultContext), repoID, topicName)
+}
+
+func getRepoTopicByName(e db.Engine, repoID int64, topicName string) (*Topic, error) {
+ cond := builder.NewCond()
+ var topic Topic
+ cond = cond.And(builder.Eq{"repo_topic.repo_id": repoID}).And(builder.Eq{"topic.name": topicName})
+ sess := e.Table("topic").Where(cond)
+ sess.Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id")
+ has, err := sess.Get(&topic)
+ if has {
+ return &topic, err
+ }
+ return nil, err
+}
+
+// AddTopic adds a topic name to a repository (if it does not already have it)
+func AddTopic(repoID int64, topicName string) (*Topic, error) {
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return nil, err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ topic, err := getRepoTopicByName(sess, repoID, topicName)
+ if err != nil {
+ return nil, err
+ }
+ if topic != nil {
+ // Repo already have topic
+ return topic, nil
+ }
+
+ topic, err = addTopicByNameToRepo(sess, repoID, topicName)
+ if err != nil {
+ return nil, err
+ }
+
+ topicNames := make([]string, 0, 25)
+ if err := sess.Select("name").Table("topic").
+ Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id").
+ Where("repo_topic.repo_id = ?", repoID).Desc("topic.repo_count").Find(&topicNames); err != nil {
+ return nil, err
+ }
+
+ if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{
+ Topics: topicNames,
+ }); err != nil {
+ return nil, err
+ }
+
+ return topic, committer.Commit()
+}
+
+// DeleteTopic removes a topic name from a repository (if it has it)
+func DeleteTopic(repoID int64, topicName string) (*Topic, error) {
+ topic, err := GetRepoTopicByName(repoID, topicName)
+ if err != nil {
+ return nil, err
+ }
+ if topic == nil {
+ // Repo doesn't have topic, can't be removed
+ return nil, nil
+ }
+
+ err = removeTopicFromRepo(db.GetEngine(db.DefaultContext), repoID, topic)
+
+ return topic, err
+}
+
+// SaveTopics save topics to a repository
+func SaveTopics(repoID int64, topicNames ...string) error {
+ topics, _, err := FindTopics(&FindTopicOptions{
+ RepoID: repoID,
+ })
+ if err != nil {
+ return err
+ }
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+ sess := db.GetEngine(ctx)
+
+ var addedTopicNames []string
+ for _, topicName := range topicNames {
+ if strings.TrimSpace(topicName) == "" {
+ continue
+ }
+
+ var found bool
+ for _, t := range topics {
+ if strings.EqualFold(topicName, t.Name) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ addedTopicNames = append(addedTopicNames, topicName)
+ }
+ }
+
+ var removeTopics []*Topic
+ for _, t := range topics {
+ var found bool
+ for _, topicName := range topicNames {
+ if strings.EqualFold(topicName, t.Name) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ removeTopics = append(removeTopics, t)
+ }
+ }
+
+ for _, topicName := range addedTopicNames {
+ _, err := addTopicByNameToRepo(sess, repoID, topicName)
+ if err != nil {
+ return err
+ }
+ }
+
+ for _, topic := range removeTopics {
+ err := removeTopicFromRepo(sess, repoID, topic)
+ if err != nil {
+ return err
+ }
+ }
+
+ topicNames = make([]string, 0, 25)
+ if err := sess.Table("topic").Cols("name").
+ Join("INNER", "repo_topic", "repo_topic.topic_id = topic.id").
+ Where("repo_topic.repo_id = ?", repoID).Desc("topic.repo_count").Find(&topicNames); err != nil {
+ return err
+ }
+
+ if _, err := sess.ID(repoID).Cols("topics").Update(&Repository{
+ Topics: topicNames,
+ }); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// GenerateTopics generates topics from a template repository
+func GenerateTopics(ctx context.Context, templateRepo, generateRepo *Repository) error {
+ for _, topic := range templateRepo.Topics {
+ if _, err := addTopicByNameToRepo(db.GetEngine(ctx), generateRepo.ID, topic); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/models/repo/topic_test.go b/models/repo/topic_test.go
new file mode 100644
index 0000000000..353d96ef3e
--- /dev/null
+++ b/models/repo/topic_test.go
@@ -0,0 +1,80 @@
+// Copyright 2018 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 repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAddTopic(t *testing.T) {
+ totalNrOfTopics := 6
+ repo1NrOfTopics := 3
+
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ topics, _, err := FindTopics(&FindTopicOptions{})
+ assert.NoError(t, err)
+ assert.Len(t, topics, totalNrOfTopics)
+
+ topics, total, err := FindTopics(&FindTopicOptions{
+ ListOptions: db.ListOptions{Page: 1, PageSize: 2},
+ })
+ assert.NoError(t, err)
+ assert.Len(t, topics, 2)
+ assert.EqualValues(t, 6, total)
+
+ topics, _, err = FindTopics(&FindTopicOptions{
+ RepoID: 1,
+ })
+ assert.NoError(t, err)
+ assert.Len(t, topics, repo1NrOfTopics)
+
+ assert.NoError(t, SaveTopics(2, "golang"))
+ repo2NrOfTopics := 1
+ topics, _, err = FindTopics(&FindTopicOptions{})
+ assert.NoError(t, err)
+ assert.Len(t, topics, totalNrOfTopics)
+
+ topics, _, err = FindTopics(&FindTopicOptions{
+ RepoID: 2,
+ })
+ assert.NoError(t, err)
+ assert.Len(t, topics, repo2NrOfTopics)
+
+ assert.NoError(t, SaveTopics(2, "golang", "gitea"))
+ repo2NrOfTopics = 2
+ totalNrOfTopics++
+ topic, err := GetTopicByName("gitea")
+ assert.NoError(t, err)
+ assert.EqualValues(t, 1, topic.RepoCount)
+
+ topics, _, err = FindTopics(&FindTopicOptions{})
+ assert.NoError(t, err)
+ assert.Len(t, topics, totalNrOfTopics)
+
+ topics, _, err = FindTopics(&FindTopicOptions{
+ RepoID: 2,
+ })
+ assert.NoError(t, err)
+ assert.Len(t, topics, repo2NrOfTopics)
+}
+
+func TestTopicValidator(t *testing.T) {
+ assert.True(t, ValidateTopic("12345"))
+ assert.True(t, ValidateTopic("2-test"))
+ assert.True(t, ValidateTopic("test-3"))
+ assert.True(t, ValidateTopic("first"))
+ assert.True(t, ValidateTopic("second-test-topic"))
+ assert.True(t, ValidateTopic("third-project-topic-with-max-length"))
+
+ assert.False(t, ValidateTopic("$fourth-test,topic"))
+ assert.False(t, ValidateTopic("-fifth-test-topic"))
+ assert.False(t, ValidateTopic("sixth-go-project-topic-with-excess-length"))
+}
diff --git a/models/repo/update.go b/models/repo/update.go
new file mode 100644
index 0000000000..efc562a405
--- /dev/null
+++ b/models/repo/update.go
@@ -0,0 +1,179 @@
+// Copyright 2021 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 repo
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/util"
+)
+
+// UpdateRepositoryOwnerNames updates repository owner_names (this should only be used when the ownerName has changed case)
+func UpdateRepositoryOwnerNames(ownerID int64, ownerName string) error {
+ if ownerID == 0 {
+ return nil
+ }
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if _, err := db.GetEngine(ctx).Where("owner_id = ?", ownerID).Cols("owner_name").Update(&Repository{
+ OwnerName: ownerName,
+ }); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// UpdateRepositoryUpdatedTime updates a repository's updated time
+func UpdateRepositoryUpdatedTime(repoID int64, updateTime time.Time) error {
+ _, err := db.GetEngine(db.DefaultContext).Exec("UPDATE repository SET updated_unix = ? WHERE id = ?", updateTime.Unix(), repoID)
+ return err
+}
+
+// UpdateRepositoryColsCtx updates repository's columns
+func UpdateRepositoryColsCtx(ctx context.Context, repo *Repository, cols ...string) error {
+ _, err := db.GetEngine(ctx).ID(repo.ID).Cols(cols...).Update(repo)
+ return err
+}
+
+// UpdateRepositoryCols updates repository's columns
+func UpdateRepositoryCols(repo *Repository, cols ...string) error {
+ return UpdateRepositoryColsCtx(db.DefaultContext, repo, cols...)
+}
+
+// ErrReachLimitOfRepo represents a "ReachLimitOfRepo" kind of error.
+type ErrReachLimitOfRepo struct {
+ Limit int
+}
+
+// IsErrReachLimitOfRepo checks if an error is a ErrReachLimitOfRepo.
+func IsErrReachLimitOfRepo(err error) bool {
+ _, ok := err.(ErrReachLimitOfRepo)
+ return ok
+}
+
+func (err ErrReachLimitOfRepo) Error() string {
+ return fmt.Sprintf("user has reached maximum limit of repositories [limit: %d]", err.Limit)
+}
+
+// ErrRepoAlreadyExist represents a "RepoAlreadyExist" kind of error.
+type ErrRepoAlreadyExist struct {
+ Uname string
+ Name string
+}
+
+// IsErrRepoAlreadyExist checks if an error is a ErrRepoAlreadyExist.
+func IsErrRepoAlreadyExist(err error) bool {
+ _, ok := err.(ErrRepoAlreadyExist)
+ return ok
+}
+
+func (err ErrRepoAlreadyExist) Error() string {
+ return fmt.Sprintf("repository already exists [uname: %s, name: %s]", err.Uname, err.Name)
+}
+
+// ErrRepoFilesAlreadyExist represents a "RepoFilesAlreadyExist" kind of error.
+type ErrRepoFilesAlreadyExist struct {
+ Uname string
+ Name string
+}
+
+// IsErrRepoFilesAlreadyExist checks if an error is a ErrRepoAlreadyExist.
+func IsErrRepoFilesAlreadyExist(err error) bool {
+ _, ok := err.(ErrRepoFilesAlreadyExist)
+ return ok
+}
+
+func (err ErrRepoFilesAlreadyExist) Error() string {
+ return fmt.Sprintf("repository files already exist [uname: %s, name: %s]", err.Uname, err.Name)
+}
+
+// CheckCreateRepository check if could created a repository
+func CheckCreateRepository(doer, u *user_model.User, name string, overwriteOrAdopt bool) error {
+ if !doer.CanCreateRepo() {
+ return ErrReachLimitOfRepo{u.MaxRepoCreation}
+ }
+
+ if err := IsUsableRepoName(name); err != nil {
+ return err
+ }
+
+ has, err := IsRepositoryExist(u, name)
+ if err != nil {
+ return fmt.Errorf("IsRepositoryExist: %v", err)
+ } else if has {
+ return ErrRepoAlreadyExist{u.Name, name}
+ }
+
+ repoPath := RepoPath(u.Name, name)
+ isExist, err := util.IsExist(repoPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
+ return err
+ }
+ if !overwriteOrAdopt && isExist {
+ return ErrRepoFilesAlreadyExist{u.Name, name}
+ }
+ return nil
+}
+
+// ChangeRepositoryName changes all corresponding setting from old repository name to new one.
+func ChangeRepositoryName(doer *user_model.User, repo *Repository, newRepoName string) (err error) {
+ oldRepoName := repo.Name
+ newRepoName = strings.ToLower(newRepoName)
+ if err = IsUsableRepoName(newRepoName); err != nil {
+ return err
+ }
+
+ if err := repo.GetOwner(db.DefaultContext); err != nil {
+ return err
+ }
+
+ has, err := IsRepositoryExist(repo.Owner, newRepoName)
+ if err != nil {
+ return fmt.Errorf("IsRepositoryExist: %v", err)
+ } else if has {
+ return ErrRepoAlreadyExist{repo.Owner.Name, newRepoName}
+ }
+
+ newRepoPath := RepoPath(repo.Owner.Name, newRepoName)
+ if err = util.Rename(repo.RepoPath(), newRepoPath); err != nil {
+ return fmt.Errorf("rename repository directory: %v", err)
+ }
+
+ wikiPath := repo.WikiPath()
+ isExist, err := util.IsExist(wikiPath)
+ if err != nil {
+ log.Error("Unable to check if %s exists. Error: %v", wikiPath, err)
+ return err
+ }
+ if isExist {
+ if err = util.Rename(wikiPath, WikiPath(repo.Owner.Name, newRepoName)); err != nil {
+ return fmt.Errorf("rename repository wiki: %v", err)
+ }
+ }
+
+ ctx, committer, err := db.TxContext()
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ if err := NewRedirect(ctx, repo.Owner.ID, repo.ID, oldRepoName, newRepoName); err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
diff --git a/models/repo/watch.go b/models/repo/watch.go
new file mode 100644
index 0000000000..8e54f0970d
--- /dev/null
+++ b/models/repo/watch.go
@@ -0,0 +1,196 @@
+// Copyright 2017 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 repo
+
+import (
+ "context"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// WatchMode specifies what kind of watch the user has on a repository
+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
+)
+
+// 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"`
+}
+
+func init() {
+ db.RegisterModel(new(Watch))
+}
+
+// GetWatch gets what kind of subscription a user has on a given repository; returns dummy record if none found
+func GetWatch(ctx context.Context, userID, repoID int64) (Watch, error) {
+ watch := Watch{UserID: userID, RepoID: repoID}
+ has, err := db.GetEngine(ctx).Get(&watch)
+ if err != nil {
+ return watch, err
+ }
+ if !has {
+ watch.Mode = WatchModeNone
+ }
+ return watch, nil
+}
+
+// IsWatchMode Decodes watchability of WatchMode
+func IsWatchMode(mode WatchMode) bool {
+ return mode != WatchModeNone && mode != WatchModeDont
+}
+
+// IsWatching checks if user has watched given repository.
+func IsWatching(userID, repoID int64) bool {
+ watch, err := GetWatch(db.DefaultContext, userID, repoID)
+ return err == nil && IsWatchMode(watch.Mode)
+}
+
+func watchRepoMode(ctx context.Context, watch Watch, mode WatchMode) (err error) {
+ if watch.Mode == mode {
+ return nil
+ }
+ if mode == WatchModeAuto && (watch.Mode == WatchModeDont || IsWatchMode(watch.Mode)) {
+ // Don't auto watch if already watching or deliberately not watching
+ return nil
+ }
+
+ hadrec := watch.Mode != WatchModeNone
+ needsrec := mode != WatchModeNone
+ repodiff := 0
+
+ if IsWatchMode(mode) && !IsWatchMode(watch.Mode) {
+ repodiff = 1
+ } else if !IsWatchMode(mode) && IsWatchMode(watch.Mode) {
+ repodiff = -1
+ }
+
+ watch.Mode = mode
+
+ e := db.GetEngine(ctx)
+
+ if !hadrec && needsrec {
+ watch.Mode = mode
+ if _, err = e.Insert(watch); err != nil {
+ return err
+ }
+ } else if needsrec {
+ watch.Mode = mode
+ if _, err := e.ID(watch.ID).AllCols().Update(watch); err != nil {
+ return err
+ }
+ } 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 WatchMode) (err error) {
+ var watch Watch
+ if watch, err = GetWatch(db.DefaultContext, userID, repoID); err != nil {
+ return err
+ }
+ return watchRepoMode(db.DefaultContext, watch, mode)
+}
+
+// WatchRepoCtx watch or unwatch repository.
+func WatchRepoCtx(ctx context.Context, userID, repoID int64, doWatch bool) (err error) {
+ var watch Watch
+ if watch, err = GetWatch(ctx, userID, repoID); err != nil {
+ return err
+ }
+ if !doWatch && watch.Mode == WatchModeAuto {
+ err = watchRepoMode(ctx, watch, WatchModeDont)
+ } else if !doWatch {
+ err = watchRepoMode(ctx, watch, WatchModeNone)
+ } else {
+ err = watchRepoMode(ctx, watch, WatchModeNormal)
+ }
+ return err
+}
+
+// WatchRepo watch or unwatch repository.
+func WatchRepo(userID, repoID int64, watch bool) (err error) {
+ return WatchRepoCtx(db.DefaultContext, userID, repoID, watch)
+}
+
+// GetWatchers returns all watchers of given repository.
+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("`user`.is_active=?", true).
+ And("`user`.prohibit_login=?", false).
+ Join("INNER", "`user`", "`user`.id = `watch`.user_id").
+ Find(&watches)
+}
+
+// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
+// 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) {
+ ids := make([]int64, 0, 64)
+ return ids, db.GetEngine(ctx).Table("watch").
+ Where("watch.repo_id=?", repoID).
+ And("watch.mode<>?", WatchModeDont).
+ Select("user_id").
+ Find(&ids)
+}
+
+// GetRepoWatchers returns range of users watching given repository.
+func GetRepoWatchers(repoID int64, opts db.ListOptions) ([]*user_model.User, error) {
+ sess := db.GetEngine(db.DefaultContext).Where("watch.repo_id=?", repoID).
+ Join("LEFT", "watch", "`user`.id=`watch`.user_id").
+ And("`watch`.mode<>?", WatchModeDont)
+ if opts.Page > 0 {
+ sess = db.SetSessionPagination(sess, &opts)
+ users := make([]*user_model.User, 0, opts.PageSize)
+
+ return users, sess.Find(&users)
+ }
+
+ users := make([]*user_model.User, 0, 8)
+ return users, sess.Find(&users)
+}
+
+func watchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error {
+ if !isWrite || !setting.Service.AutoWatchOnChanges {
+ return nil
+ }
+ watch, err := GetWatch(ctx, userID, repoID)
+ if err != nil {
+ return err
+ }
+ if watch.Mode != WatchModeNone {
+ return nil
+ }
+ return watchRepoMode(ctx, watch, WatchModeAuto)
+}
+
+// WatchIfAuto subscribes to repo if AutoWatchOnChanges is set
+func WatchIfAuto(userID, repoID int64, isWrite bool) error {
+ return watchIfAuto(db.DefaultContext, userID, repoID, isWrite)
+}
diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go
new file mode 100644
index 0000000000..2ff3ced2dc
--- /dev/null
+++ b/models/repo/watch_test.go
@@ -0,0 +1,139 @@
+// Copyright 2017 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 repo
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsWatching(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ 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(unittest.NonexistentID, unittest.NonexistentID))
+}
+
+func TestGetWatchers(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
+ watches, err := GetWatchers(db.DefaultContext, repo.ID)
+ assert.NoError(t, err)
+ // One watchers are inactive, thus minus 1
+ assert.Len(t, watches, repo.NumWatches-1)
+ for _, watch := range watches {
+ assert.EqualValues(t, repo.ID, watch.RepoID)
+ }
+
+ watches, err = GetWatchers(db.DefaultContext, unittest.NonexistentID)
+ assert.NoError(t, err)
+ assert.Len(t, watches, 0)
+}
+
+func TestRepository_GetWatchers(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
+ watchers, err := GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, repo.NumWatches)
+ for _, watcher := range watchers {
+ unittest.AssertExistsAndLoadBean(t, &Watch{UserID: watcher.ID, RepoID: repo.ID})
+ }
+
+ repo = unittest.AssertExistsAndLoadBean(t, &Repository{ID: 9}).(*Repository)
+ watchers, err = GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, 0)
+}
+
+func TestWatchIfAuto(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &Repository{ID: 1}).(*Repository)
+ watchers, err := GetRepoWatchers(repo.ID, db.ListOptions{Page: 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 = GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, prevCount)
+
+ // Should not add watch
+ assert.NoError(t, WatchIfAuto(10, 1, true))
+ watchers, err = GetRepoWatchers(repo.ID, db.ListOptions{Page: 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 = GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, prevCount)
+
+ // Should not add watch
+ assert.NoError(t, WatchIfAuto(12, 1, false))
+ watchers, err = GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, prevCount)
+
+ // Should add watch
+ assert.NoError(t, WatchIfAuto(12, 1, true))
+ watchers, err = GetRepoWatchers(repo.ID, db.ListOptions{Page: 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 = GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, prevCount)
+
+ // Must not add watch
+ assert.NoError(t, WatchIfAuto(12, 1, true))
+ watchers, err = GetRepoWatchers(repo.ID, db.ListOptions{Page: 1})
+ assert.NoError(t, err)
+ assert.Len(t, watchers, prevCount)
+}
+
+func TestWatchRepoMode(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 0)
+
+ assert.NoError(t, WatchRepoMode(12, 1, WatchModeAuto))
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 1)
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1, Mode: WatchModeAuto}, 1)
+
+ assert.NoError(t, WatchRepoMode(12, 1, WatchModeNormal))
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 1)
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1, Mode: WatchModeNormal}, 1)
+
+ assert.NoError(t, WatchRepoMode(12, 1, WatchModeDont))
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 1)
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1, Mode: WatchModeDont}, 1)
+
+ assert.NoError(t, WatchRepoMode(12, 1, WatchModeNone))
+ unittest.AssertCount(t, &Watch{UserID: 12, RepoID: 1}, 0)
+}