{{.CurrentStarList.Name}}
+{{.CurrentStarList.Description}}
+diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 918252044be62..ec37d4337e1e6 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -984,6 +984,9 @@ LEVEL = Info ;; Disable stars feature. ;DISABLE_STARS = false ;; +;; Disable star lists feature. +;DISABLE_STAR_LISTS = false +;; ;; The default branch name of new repositories ;DEFAULT_BRANCH = main ;; diff --git a/docs/content/administration/config-cheat-sheet.en-us.md b/docs/content/administration/config-cheat-sheet.en-us.md index 9de7511964fc1..227f1e8f60cbc 100644 --- a/docs/content/administration/config-cheat-sheet.en-us.md +++ b/docs/content/administration/config-cheat-sheet.en-us.md @@ -107,6 +107,7 @@ In addition, there is _`StaticRootPath`_ which can be set as a built-in at build - `PREFIX_ARCHIVE_FILES`: **true**: Prefix archive files by placing them in a directory named after the repository. - `DISABLE_MIGRATIONS`: **false**: Disable migrating feature. - `DISABLE_STARS`: **false**: Disable stars feature. +- `DISABLE_STAR_LISTS`: **false**: Disable star lists feature. - `DEFAULT_BRANCH`: **main**: Default branch name of all repositories. - `ALLOW_ADOPTION_OF_UNADOPTED_REPOSITORIES`: **false**: Allow non-admin users to adopt unadopted repositories - `ALLOW_DELETION_OF_UNADOPTED_REPOSITORIES`: **false**: Allow non-admin users to delete unadopted repositories diff --git a/models/fixtures/star_list.yml b/models/fixtures/star_list.yml new file mode 100644 index 0000000000000..5a6f8626d4ca3 --- /dev/null +++ b/models/fixtures/star_list.yml @@ -0,0 +1,20 @@ +- + id: 1 + user_id: 1 + name: "First List" + description: "Description for first List" + is_private: false + +- + id: 2 + user_id: 1 + name: "Second List" + description: "This is private" + is_private: true + +- + id: 3 + user_id: 2 + name: "Third List" + description: "It's a Secret to Everybody" + is_private: false diff --git a/models/fixtures/star_list_repos.yml b/models/fixtures/star_list_repos.yml new file mode 100644 index 0000000000000..cf95469240428 --- /dev/null +++ b/models/fixtures/star_list_repos.yml @@ -0,0 +1,4 @@ +- + id: 1 + star_list_id: 1 + repo_id: 1 diff --git a/models/repo/repo_list.go b/models/repo/repo_list.go index 987c7df9b0eb0..c0214fa0b0333 100644 --- a/models/repo/repo_list.go +++ b/models/repo/repo_list.go @@ -156,6 +156,7 @@ type SearchRepoOptions struct { OrderBy db.SearchOrderBy Private bool // Include private repositories in results StarredByID int64 + StarListID int64 WatchedByID int64 AllPublic bool // Include also all public repositories of users and public organisations AllLimited bool // Include also all public repositories of limited organisations @@ -409,6 +410,11 @@ func SearchRepositoryCondition(opts *SearchRepoOptions) builder.Cond { cond = cond.And(builder.In("id", builder.Select("repo_id").From("star").Where(builder.Eq{"uid": opts.StarredByID}))) } + // Restrict to repos in a star list + if opts.StarListID > 0 { + cond = cond.And(builder.In("id", builder.Select("repo_id").From("star_list_repos").Where(builder.Eq{"star_list_id": opts.StarListID}))) + } + // Restrict to watched repositories if opts.WatchedByID > 0 { cond = cond.And(builder.In("id", builder.Select("repo_id").From("watch").Where(builder.Eq{"user_id": opts.WatchedByID}))) diff --git a/models/repo/star.go b/models/repo/star.go index 4c66855525fa6..40e535333c5f9 100644 --- a/models/repo/star.go +++ b/models/repo/star.go @@ -64,6 +64,10 @@ func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", doer.ID); err != nil { return err } + // Delete the repo from all star lists of this user + if _, err := db.Exec(ctx, "DELETE FROM star_list_repos WHERE repo_id = ? AND star_list_id IN (SELECT id FROM star_list WHERE user_id = ?)", repo.ID, doer.ID); err != nil { + return err + } } return committer.Commit() diff --git a/models/repo/star_list.go b/models/repo/star_list.go new file mode 100644 index 0000000000000..e3f0915ad6f26 --- /dev/null +++ b/models/repo/star_list.go @@ -0,0 +1,351 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + "fmt" + "net/url" + "slices" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" +) + +type ErrStarListNotFound struct { + Name string + ID int64 +} + +func (err ErrStarListNotFound) Error() string { + if err.Name == "" { + return fmt.Sprintf("A star list with the ID %d was not found", err.ID) + } + + return fmt.Sprintf("A star list with the name %s was not found", err.Name) +} + +// IsErrStarListNotFound returns if the error is, that the star is not found +func IsErrStarListNotFound(err error) bool { + _, ok := err.(ErrStarListNotFound) + return ok +} + +type ErrStarListExists struct { + Name string +} + +func (err ErrStarListExists) Error() string { + return fmt.Sprintf("A star list with the name %s exists", err.Name) +} + +// IsErrIssueMaxPinReached returns if the error is, that the star list exists +func IsErrStarListExists(err error) bool { + _, ok := err.(ErrStarListExists) + return ok +} + +type StarList struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX UNIQUE(name)"` + Name string `xorm:"INDEX UNIQUE(name)"` + Description string + IsPrivate bool + RepositoryCount int64 `xorm:"-"` + User *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + RepoIDs *[]int64 `xorm:"-"` +} + +type StarListRepos struct { + ID int64 `xorm:"pk autoincr"` + StarListID int64 `xorm:"INDEX UNIQUE(repo)"` + RepoID int64 `xorm:"INDEX UNIQUE(repo)"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +type StarListSlice []*StarList + +func init() { + db.RegisterModel(new(StarList)) + db.RegisterModel(new(StarListRepos)) +} + +// GetStarListByID returne the star list for the given ID. +// If the ID do not exists, it returns a ErrStarListNotFound error. +func GetStarListByID(ctx context.Context, id int64) (*StarList, error) { + var starList StarList + + found, err := db.GetEngine(ctx).Table("star_list").ID(id).Get(&starList) + if err != nil { + return nil, err + } + + if !found { + return nil, ErrStarListNotFound{ID: id} + } + + return &starList, nil +} + +// GetStarListByID returne the star list of the given user with the given name. +// If the name do not exists, it returns a ErrStarListNotFound error. +func GetStarListByName(ctx context.Context, userID int64, name string) (*StarList, error) { + var starList StarList + + found, err := db.GetEngine(ctx).Table("star_list").Where("user_id = ?", userID).And("LOWER(name) = ?", strings.ToLower(name)).Get(&starList) + if err != nil { + return nil, err + } + + if !found { + return nil, ErrStarListNotFound{Name: name} + } + + return &starList, nil +} + +// GetStarListsByUserID retruns all star lists for the given user +func GetStarListsByUserID(ctx context.Context, userID int64, includePrivate bool) (StarListSlice, error) { + cond := builder.NewCond().And(builder.Eq{"user_id": userID}) + + if !includePrivate { + cond = cond.And(builder.Eq{"is_private": false}) + } + + starLists := make(StarListSlice, 0) + err := db.GetEngine(ctx).Table("star_list").Where(cond).Asc("created_unix").Asc("id").Find(&starLists) + if err != nil { + return nil, err + } + + return starLists, nil +} + +// CreateStarLists creates a new star list +// It returns a ErrStarListExists if the user already have a star list with this name +func CreateStarList(ctx context.Context, userID int64, name, description string, isPrivate bool) (*StarList, error) { + _, err := GetStarListByName(ctx, userID, name) + if err != nil { + if !IsErrStarListNotFound(err) { + return nil, err + } + } else { + return nil, ErrStarListExists{Name: name} + } + + starList := StarList{ + UserID: userID, + Name: name, + Description: description, + IsPrivate: isPrivate, + } + + _, err = db.GetEngine(ctx).Insert(starList) + if err != nil { + return nil, err + } + + return &starList, nil +} + +// DeleteStarListByID deletes the star list with the given ID +func DeleteStarListByID(ctx context.Context, id int64) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + + _, err = db.GetEngine(ctx).Exec("DELETE FROM star_list_repos WHERE star_list_id = ?", id) + if err != nil { + return err + } + + _, err = db.GetEngine(ctx).Exec("DELETE FROM star_list WHERE id = ?", id) + if err != nil { + return err + } + + return committer.Commit() +} + +// LoadRepositoryCount loads just the RepositoryCount. +// The count checks if how many repos in the list the actor is able to see. +func (starList *StarList) LoadRepositoryCount(ctx context.Context, actor *user_model.User) error { + count, err := CountRepository(ctx, &SearchRepoOptions{Actor: actor, StarListID: starList.ID}) + if err != nil { + return err + } + + starList.RepositoryCount = count + + return nil +} + +// LoadUser loads the User field +func (starList *StarList) LoadUser(ctx context.Context) error { + user, err := user_model.GetUserByID(ctx, starList.UserID) + if err != nil { + return err + } + + starList.User = user + return nil +} + +// LoadRepoIDs loads all repo ids which are in the list +func (starList *StarList) LoadRepoIDs(ctx context.Context) error { + repoIDs := make([]int64, 0) + err := db.GetEngine(ctx).Table("star_list_repos").Where("star_list_id = ?", starList.ID).Cols("repo_id").Find(&repoIDs) + if err != nil { + return err + } + starList.RepoIDs = &repoIDs + return nil +} + +// Retruns if the list contains the given repo id. +// This function needs the repo ids loaded to work. +func (starList *StarList) ContainsRepoID(repoID int64) bool { + return slices.Contains(*starList.RepoIDs, repoID) +} + +// AddRepo adds the given repo to the list +func (starList *StarList) AddRepo(ctx context.Context, repoID int64) error { + err := starList.LoadRepoIDs(ctx) + if err != nil { + return err + } + + if starList.ContainsRepoID(repoID) { + return nil + } + + err = starList.LoadUser(ctx) + if err != nil { + return err + } + + repo, err := GetRepositoryByID(ctx, repoID) + if err != nil { + return err + } + + err = StarRepo(ctx, starList.User, repo, true) + if err != nil { + return err + } + + starListRepo := StarListRepos{ + StarListID: starList.ID, + RepoID: repoID, + } + + _, err = db.GetEngine(ctx).Insert(starListRepo) + return err +} + +// RemoveRepo removes the given repo from the list +func (starList *StarList) RemoveRepo(ctx context.Context, repoID int64) error { + _, err := db.GetEngine(ctx).Exec("DELETE FROM star_list_repos WHERE star_list_id = ? AND repo_id = ?", starList.ID, repoID) + return err +} + +// EditData edits the star list and save it to the database +// It returns a ErrStarListExists if the user already have a star list with this name +func (starList *StarList) EditData(ctx context.Context, name, description string, isPrivate bool) error { + if !strings.EqualFold(starList.Name, name) { + _, err := GetStarListByName(ctx, starList.UserID, name) + if err != nil { + if !IsErrStarListNotFound(err) { + return err + } + } else { + return ErrStarListExists{Name: name} + } + } + + oldName := starList.Name + oldDescription := starList.Description + oldIsPrivate := starList.IsPrivate + + starList.Name = name + starList.Description = description + starList.IsPrivate = isPrivate + + _, err := db.GetEngine(ctx).Table("star_list").ID(starList.ID).Cols("name", "description", "is_private").Update(starList) + if err != nil { + starList.Name = oldName + starList.Description = oldDescription + starList.IsPrivate = oldIsPrivate + + return err + } + + return nil +} + +// HasAccess retruns if the given user has access to this star list +func (starList *StarList) HasAccess(user *user_model.User) bool { + if !starList.IsPrivate { + return true + } + + if user == nil { + return false + } + + return starList.UserID == user.ID +} + +// MustHaveAccess returns a ErrStarListNotFound if the given user has no access to the star list +func (starList *StarList) MustHaveAccess(user *user_model.User) error { + if !starList.HasAccess(user) { + return ErrStarListNotFound{ID: starList.ID, Name: starList.Name} + } + return nil +} + +// Returns a Link to the star list. +// This function needs the user loaded to work. +func (starList *StarList) Link() string { + return fmt.Sprintf("%s/-/starlist/%s", starList.User.HomeLink(), url.PathEscape(starList.Name)) +} + +// LoadUser calls LoadUser on all elements of the list +func (starLists StarListSlice) LoadUser(ctx context.Context) error { + for _, list := range starLists { + err := list.LoadUser(ctx) + if err != nil { + return err + } + } + return nil +} + +// LoadRepositoryCount calls LoadRepositoryCount on all elements of the list +func (starLists StarListSlice) LoadRepositoryCount(ctx context.Context, actor *user_model.User) error { + for _, list := range starLists { + err := list.LoadRepositoryCount(ctx, actor) + if err != nil { + return err + } + } + return nil +} + +// LoadRepoIDs calls LoadRepoIDs on all elements of the list +func (starLists StarListSlice) LoadRepoIDs(ctx context.Context) error { + for _, list := range starLists { + err := list.LoadRepoIDs(ctx) + if err != nil { + return err + } + } + return nil +} diff --git a/models/repo/star_list_test.go b/models/repo/star_list_test.go new file mode 100644 index 0000000000000..71fe7334eb5f0 --- /dev/null +++ b/models/repo/star_list_test.go @@ -0,0 +1,162 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestGetStarListByID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + starList, err := repo_model.GetStarListByID(db.DefaultContext, 1) + assert.NoError(t, err) + + assert.Equal(t, "First List", starList.Name) + assert.Equal(t, "Description for first List", starList.Description) + assert.False(t, starList.IsPrivate) + + // Check if ErrStarListNotFound is returned on an not existing ID + starList, err = repo_model.GetStarListByID(db.DefaultContext, -1) + assert.True(t, repo_model.IsErrStarListNotFound(err)) + assert.Nil(t, starList) +} + +func TestGetStarListByName(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + starList, err := repo_model.GetStarListByName(db.DefaultContext, 1, "First List") + assert.NoError(t, err) + + assert.Equal(t, int64(1), starList.ID) + assert.Equal(t, "Description for first List", starList.Description) + assert.False(t, starList.IsPrivate) + + // Check if ErrStarListNotFound is returned on an not existing Name + starList, err = repo_model.GetStarListByName(db.DefaultContext, 1, "NotExistingList") + assert.True(t, repo_model.IsErrStarListNotFound(err)) + assert.Nil(t, starList) +} + +func TestGetStarListByUserID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // Get only public lists + starLists, err := repo_model.GetStarListsByUserID(db.DefaultContext, 1, false) + assert.NoError(t, err) + + assert.Len(t, starLists, 1) + + assert.Equal(t, int64(1), starLists[0].ID) + assert.Equal(t, "First List", starLists[0].Name) + assert.Equal(t, "Description for first List", starLists[0].Description) + assert.False(t, starLists[0].IsPrivate) + + // Get also private lists + starLists, err = repo_model.GetStarListsByUserID(db.DefaultContext, 1, true) + assert.NoError(t, err) + + assert.Len(t, starLists, 2) + + assert.Equal(t, int64(1), starLists[0].ID) + assert.Equal(t, "First List", starLists[0].Name) + assert.Equal(t, "Description for first List", starLists[0].Description) + assert.False(t, starLists[0].IsPrivate) + + assert.Equal(t, int64(2), starLists[1].ID) + assert.Equal(t, "Second List", starLists[1].Name) + assert.Equal(t, "This is private", starLists[1].Description) + assert.True(t, starLists[1].IsPrivate) +} + +func TestCreateStarList(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // Check that you can't create two list with the same name for the same user + starList, err := repo_model.CreateStarList(db.DefaultContext, 1, "First List", "Test", false) + assert.True(t, repo_model.IsErrStarListExists(err)) + assert.Nil(t, starList) + + // Now create the star list for real + starList, err = repo_model.CreateStarList(db.DefaultContext, 1, "My new List", "Test", false) + assert.NoError(t, err) + + assert.Equal(t, "My new List", starList.Name) + assert.Equal(t, "Test", starList.Description) + assert.False(t, starList.IsPrivate) +} + +func TestStarListRepositoryCount(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + starList := unittest.AssertExistsAndLoadBean(t, &repo_model.StarList{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + + assert.NoError(t, starList.LoadRepositoryCount(db.DefaultContext, user)) + + assert.Equal(t, int64(1), starList.RepositoryCount) +} + +func TestStarListAddRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const repoID = 4 + + starList := unittest.AssertExistsAndLoadBean(t, &repo_model.StarList{ID: 1}) + + assert.NoError(t, starList.AddRepo(db.DefaultContext, repoID)) + + assert.NoError(t, starList.LoadRepoIDs(db.DefaultContext)) + + assert.True(t, starList.ContainsRepoID(repoID)) +} + +func TestStarListRemoveRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + const repoID = 1 + + starList := unittest.AssertExistsAndLoadBean(t, &repo_model.StarList{ID: 1}) + + assert.NoError(t, starList.RemoveRepo(db.DefaultContext, repoID)) + + assert.NoError(t, starList.LoadRepoIDs(db.DefaultContext)) + + assert.False(t, starList.ContainsRepoID(repoID)) +} + +func TestStarListEditData(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + starList := unittest.AssertExistsAndLoadBean(t, &repo_model.StarList{ID: 1}) + + assert.True(t, repo_model.IsErrStarListExists(starList.EditData(db.DefaultContext, "Second List", "New Description", false))) + + assert.NoError(t, starList.EditData(db.DefaultContext, "First List", "New Description", false)) + + assert.Equal(t, "First List", starList.Name) + assert.Equal(t, "New Description", starList.Description) + assert.False(t, starList.IsPrivate) +} + +func TestStarListHasAccess(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + starList := unittest.AssertExistsAndLoadBean(t, &repo_model.StarList{ID: 2}) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + assert.True(t, starList.HasAccess(user1)) + assert.False(t, starList.HasAccess(user2)) + + assert.NoError(t, starList.MustHaveAccess(user1)) + assert.True(t, repo_model.IsErrStarListNotFound(starList.MustHaveAccess(user2))) +} diff --git a/models/repo/star_test.go b/models/repo/star_test.go index aaac89d975d7c..3b639882f46e4 100644 --- a/models/repo/star_test.go +++ b/models/repo/star_test.go @@ -73,3 +73,22 @@ func TestClearRepoStars(t *testing.T) { assert.NoError(t, err) assert.Len(t, gazers, 0) } + +func TestUnstarRepo(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + assert.NoError(t, repo_model.StarRepo(db.DefaultContext, user, repo, false)) + + assert.False(t, repo_model.IsStaring(db.DefaultContext, user.ID, repo.ID)) + + // Check if the repo is removed from the star list + starList, err := repo_model.GetStarListByID(db.DefaultContext, 1) + assert.NoError(t, err) + + assert.NoError(t, starList.LoadRepoIDs(db.DefaultContext)) + + assert.False(t, starList.ContainsRepoID(repo.ID)) +} diff --git a/models/user/user.go b/models/user/user.go index d459ec239e978..42c8df0920dca 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -470,6 +470,15 @@ func (u *User) IsMailable() bool { return u.IsActive } +// IsSameUser checks if both user are the same +func (u *User) IsSameUser(user *User) bool { + if user == nil { + return false + } + + return u.ID == user.ID +} + // IsUserExist checks if given user name exist, // the user name should be noncased unique. // If uid is presented, then check will rule out that one, diff --git a/models/user/user_test.go b/models/user/user_test.go index a4550fa655d4b..d933c3c09a1ca 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -528,6 +528,17 @@ func Test_NormalizeUserFromEmail(t *testing.T) { } } +func TestIsSameUser(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + + assert.False(t, user1.IsSameUser(nil)) + assert.False(t, user1.IsSameUser(user4)) + assert.True(t, user1.IsSameUser(user1)) +} + func TestDisabledUserFeatures(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 8656ebc7ecfd0..a3db7ad9dd6c9 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -46,6 +46,7 @@ var ( PrefixArchiveFiles bool DisableMigrations bool DisableStars bool `ini:"DISABLE_STARS"` + DisableStarLists bool `ini:"DISABLE_STAR_LISTS"` DefaultBranch string AllowAdoptionOfUnadoptedRepositories bool AllowDeleteOfUnadoptedRepositories bool @@ -164,6 +165,7 @@ var ( PrefixArchiveFiles: true, DisableMigrations: false, DisableStars: false, + DisableStarLists: false, DefaultBranch: "main", AllowForkWithoutMaximumLimit: true, diff --git a/modules/structs/settings.go b/modules/structs/settings.go index e48b1a493db29..429bde5f46ef4 100644 --- a/modules/structs/settings.go +++ b/modules/structs/settings.go @@ -9,6 +9,7 @@ type GeneralRepoSettings struct { HTTPGitDisabled bool `json:"http_git_disabled"` MigrationsDisabled bool `json:"migrations_disabled"` StarsDisabled bool `json:"stars_disabled"` + StarListsDisabled bool `json:"star_lists_disabled"` TimeTrackingDisabled bool `json:"time_tracking_disabled"` LFSDisabled bool `json:"lfs_disabled"` } diff --git a/modules/structs/user.go b/modules/structs/user.go index 21ecc1479e2b5..b44b915d85b9b 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -132,3 +132,26 @@ type UserBadgeOption struct { // example: ["badge1","badge2"] BadgeSlugs []string `json:"badge_slugs" binding:"Required"` } + +// StarList represents a star list +type StarList struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` + RepositoryCount int64 `json:"repository_count"` + User *User `json:"user"` +} + +// CreateEditStarListOptions when creating or editing a star list +type CreateEditStarListOptions struct { + Name string `json:"name" binding:"Required"` + Description string `json:"description"` + IsPrivate bool `json:"is_private"` +} + +// StarListRepoInfo represents a star list and if the repo contains this star list +type StarListRepoInfo struct { + StarList *StarList `json:"star_list"` + Contains bool `json:"contains"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 0a3d12d7a40ff..8431701dcf3d4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -144,6 +144,9 @@ confirm_delete_selected = Confirm to delete all selected items? name = Name value = Value +repository_count_1 = 1 repository +repository_count_n = %d repositories + filter = Filter filter.clear = Clear Filter filter.is_archived = Archived @@ -3677,3 +3680,17 @@ normal_file = Normal file executable_file = Executable file symbolic_link = Symbolic link submodule = Submodule + +[starlist] +list_header = Lists +edit_header = Edit list +add_header = Add list +delete_header = Delete list +name_label = Name: +name_placeholder = The name of your starlist +description_label = Description: +description_placeholder = A Description of your list +private = This list is private +name_exists_error = You already have a list with the name %s +delete_success_message = List %s was successfully deleted +no_star_lists_text = It looks like you have no star lists yet. Try to create one. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e870378c4b247..3438d1564b798 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -684,6 +684,40 @@ func mustEnableAttachments(ctx *context.APIContext) { } } +func mustEnableStarLists(ctx *context.APIContext) { + if setting.Repository.DisableStarLists { + ctx.Error(http.StatusNotImplemented, "StarListsDisabled", fmt.Errorf("star lists are disabled on this instance")) + return + } +} + +func starListAssignment(ctx *context.APIContext) { + var owner *user_model.User + if ctx.ContextUser == nil { + owner = ctx.Doer + } else { + owner = ctx.ContextUser + } + + starList, err := repo_model.GetStarListByName(ctx, owner.ID, ctx.Params("starlist")) + if err != nil { + if repo_model.IsErrStarListNotFound(err) { + ctx.NotFound("GetStarListByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetStarListByName", err) + } + return + } + + err = starList.MustHaveAccess(ctx.Doer) + if err != nil { + ctx.NotFound("GetStarListByName", err) + return + } + + ctx.Starlist = starList +} + // bind binding an obj to a func(ctx *context.APIContext) func bind[T any](_ T) any { return func(ctx *context.APIContext) { @@ -914,6 +948,11 @@ func Routes() *web.Route { }, reqSelfOrAdmin(), reqBasicOrRevProxyAuth()) m.Get("/activities/feeds", user.ListUserActivityFeeds) + m.Get("/starlists", mustEnableStarLists, user.ListUserStarLists) + m.Group("/starlist/{starlist}", func() { + m.Get("", user.GetUserStarListByName) + m.Get("/repos", user.GetUserStarListRepos) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), mustEnableStarLists, starListAssignment) }, context.UserAssignmentAPI(), individualPermsChecker) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) @@ -1046,8 +1085,27 @@ func Routes() *web.Route { m.Delete("", user.UnblockUser) }, context.UserAssignmentAPI()) }) + + m.Group("/starlists", func() { + m.Get("", user.ListOwnStarLists) + m.Post("", bind(api.CreateEditStarListOptions{}), user.CreateStarList) + m.Get("/repoinfo/{username}/{reponame}", repoAssignment(), user.GetStarListRepoInfo) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), mustEnableStarLists) + + m.Group("/starlist/{starlist}", func() { + m.Get("", user.GetOwnStarListByName) + m.Patch("", bind(api.CreateEditStarListOptions{}), user.EditStarList) + m.Delete("", user.DeleteStarList) + m.Get("/repos", user.GetOwnStarListRepos) + m.Group("/{username}/{reponame}", func() { + m.Put("", user.AddRepoToStarList) + m.Delete("", user.RemoveRepoFromStarList) + }, repoAssignment()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), mustEnableStarLists, starListAssignment) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + m.Get("/starlist/{id}", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), mustEnableStarLists, user.GetStarListByID) + // Repositories (requires repo scope, org scope) m.Post("/org/{org}/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), diff --git a/routers/api/v1/settings/settings.go b/routers/api/v1/settings/settings.go index 0ee81b96d5bb9..2f597e13950af 100644 --- a/routers/api/v1/settings/settings.go +++ b/routers/api/v1/settings/settings.go @@ -61,6 +61,7 @@ func GetGeneralRepoSettings(ctx *context.APIContext) { HTTPGitDisabled: setting.Repository.DisableHTTPGit, MigrationsDisabled: setting.Repository.DisableMigrations, StarsDisabled: setting.Repository.DisableStars, + StarListsDisabled: setting.Repository.DisableStarLists, TimeTrackingDisabled: !setting.Service.EnableTimetracking, LFSDisabled: !setting.LFS.StartServer, }) diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index cd551cbdfa923..b9e0890709072 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -195,6 +195,9 @@ type swaggerParameterBodies struct { UserBadgeOption api.UserBadgeOption // in:body + CreateEditStarListOptions api.CreateEditStarListOptions + + // in:body CreateVariableOption api.CreateVariableOption // in:body diff --git a/routers/api/v1/swagger/user.go b/routers/api/v1/swagger/user.go index e2ad511d2b966..0f8a75485876c 100644 --- a/routers/api/v1/swagger/user.go +++ b/routers/api/v1/swagger/user.go @@ -55,3 +55,24 @@ type swaggerResponseBadgeList struct { // in:body Body []api.Badge `json:"body"` } + +// StarList +// swagger:response StarList +type swaggerResponseStarList struct { + // in:body + Body api.StarList `json:"body"` +} + +// StarListSlice +// swagger:response StarListSlice +type swaggerResponseStarListSlice struct { + // in:body + Body []api.StarList `json:"body"` +} + +// StarListRepoInfo +// swagger:response StarListRepoInfo +type swaggerResponseStarListRepoInfo struct { + // in:body + Body []api.StarListRepoInfo `json:"body"` +} diff --git a/routers/api/v1/user/star_list.go b/routers/api/v1/user/star_list.go new file mode 100644 index 0000000000000..562786bb06d79 --- /dev/null +++ b/routers/api/v1/user/star_list.go @@ -0,0 +1,594 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func listUserStarListsInternal(ctx *context.APIContext, user *user_model.User) { + starLists, err := repo_model.GetStarListsByUserID(ctx, user.ID, user.IsSameUser(ctx.Doer)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserStarListsByUserID", err) + return + } + + err = starLists.LoadUser(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUser", err) + return + } + + err = starLists.LoadRepositoryCount(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositoryCount", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToStarLists(ctx, starLists, ctx.Doer)) +} + +// ListUserStarLists list the given user's star lists +func ListUserStarLists(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/starlists user userGetUserStarLists + // --- + // summary: List the given user's star lists + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/StarListSlice" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + listUserStarListsInternal(ctx, ctx.ContextUser) +} + +// ListOwnStarLists list the authenticated user's star lists +func ListOwnStarLists(ctx *context.APIContext) { + // swagger:operation GET /user/starlists user userGetOwnStarLists + // --- + // summary: List the authenticated user's star lists + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/StarListSlice" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "501": + // "$ref": "#/responses/featureDisabled" + listUserStarListsInternal(ctx, ctx.Doer) +} + +// GetStarListRepoInfo gets all star lists of the user together with the information, if the given repo is in the list +func GetStarListRepoInfo(ctx *context.APIContext) { + // swagger:operation GET /user/starlists/repoinfo/{owner}/{repo} user userGetStarListRepoInfo + // --- + // summary: Gets all star lists of the user together with the information, if the given repo is in the list + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to star + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to star + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/StarListRepoInfo" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "501": + // "$ref": "#/responses/featureDisabled" + starLists, err := repo_model.GetStarListsByUserID(ctx, ctx.Doer.ID, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetStarListsByUserID", err) + return + } + + err = starLists.LoadUser(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUser", err) + return + } + + err = starLists.LoadRepositoryCount(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositoryCount", err) + return + } + + err = starLists.LoadRepoIDs(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepoIDs", err) + return + } + + repoInfo := make([]api.StarListRepoInfo, len(starLists)) + for i, list := range starLists { + repoInfo[i] = api.StarListRepoInfo{StarList: convert.ToStarList(ctx, list, ctx.Doer), Contains: list.ContainsRepoID(ctx.Repo.Repository.ID)} + } + + ctx.JSON(http.StatusOK, repoInfo) +} + +// CreateStarList creates a star list +func CreateStarList(ctx *context.APIContext) { + // swagger:operation POST /user/starlists user userCreateStarList + // --- + // summary: Creates a star list + // parameters: + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateEditStarListOptions" + // produces: + // - application/json + // responses: + // "201": + // "$ref": "#/responses/StarList" + // "400": + // "$ref": "#/responses/error" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "501": + // "$ref": "#/responses/featureDisabled" + opts := web.GetForm(ctx).(*api.CreateEditStarListOptions) + + starList, err := repo_model.CreateStarList(ctx, ctx.Doer.ID, opts.Name, opts.Description, opts.IsPrivate) + if err != nil { + if repo_model.IsErrStarListExists(err) { + ctx.Error(http.StatusBadRequest, "CreateStarList", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateStarList", err) + } + return + } + + err = starList.LoadUser(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUser", err) + return + } + + ctx.JSON(http.StatusCreated, starList) +} + +func getStarListByNameInternal(ctx *context.APIContext) { + err := ctx.Starlist.LoadUser(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUser", err) + return + } + + err = ctx.Starlist.LoadRepositoryCount(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositoryCount", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToStarList(ctx, ctx.Starlist, ctx.Doer)) +} + +// GetUserStarListByName get the star list of the given user with the given name +func GetUserStarListByName(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/starlist/{name} user userGetUserStarListByName + // --- + // summary: Get the star list of the given user with the given name + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/StarList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + getStarListByNameInternal(ctx) +} + +// GetOwnStarListByName get the star list of the authenticated user with the given name +func GetOwnStarListByName(ctx *context.APIContext) { + // swagger:operation GET /user/starlist/{name} user userGetOwnStarListByName + // --- + // summary: Get the star list of the authenticated user with the given name + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/StarList" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + getStarListByNameInternal(ctx) +} + +// EditStarList edits a star list +func EditStarList(ctx *context.APIContext) { + // swagger:operation PATCH /user/starlist/{name} user userEditStarList + // --- + // summary: Edits a star list + // parameters: + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateEditStarListOptions" + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/StarList" + // "400": + // "$ref": "#/responses/error" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + opts := web.GetForm(ctx).(*api.CreateEditStarListOptions) + + err := ctx.Starlist.EditData(ctx, opts.Name, opts.Description, opts.IsPrivate) + if err != nil { + if repo_model.IsErrStarListExists(err) { + ctx.Error(http.StatusBadRequest, "EditData", err) + } else { + ctx.Error(http.StatusInternalServerError, "EditData", err) + } + return + } + + err = ctx.Starlist.LoadUser(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUser", err) + return + } + + err = ctx.Starlist.LoadRepositoryCount(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositoryCount", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToStarList(ctx, ctx.Starlist, ctx.Doer)) +} + +// DeleteStarList deletes a star list +func DeleteStarList(ctx *context.APIContext) { + // swagger:operation DELETE /user/starlist/{name} user userDeleteStarList + // --- + // summary: Deletes a star list + // parameters: + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + err := repo_model.DeleteStarListByID(ctx, ctx.Starlist.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositoryCount", err) + return + } + ctx.Status(http.StatusNoContent) +} + +func getStarListReposInternal(ctx *context.APIContext) { + opts := utils.GetListOptions(ctx) + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{Actor: ctx.Doer, StarListID: ctx.Starlist.ID}) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchRepository", err) + return + } + + err = repos.LoadAttributes(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + apiRepos := make([]*api.Repository, 0, len(repos)) + for i := range repos { + permission, err := access_model.GetUserRepoPermission(ctx, repos[i], ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + if ctx.IsSigned && ctx.Doer.IsAdmin || permission.UnitAccessMode(unit_model.TypeCode) >= perm.AccessModeRead { + apiRepos = append(apiRepos, convert.ToRepo(ctx, repos[i], permission)) + } + } + + ctx.SetLinkHeader(int(count), opts.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiRepos) +} + +// GetUserStarListRepos get the repos of the star list of the given user with the given name +func GetUserStarListRepos(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/starlist/{name}/repos user userGetUserStarListRepos + // --- + // summary: Get the repos of the star list of the given user with the given name + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/RepositoryList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + getStarListReposInternal(ctx) +} + +// GetOwnStarListRepos get the repos of the star list of the authenticated user with the given name +func GetOwnStarListRepos(ctx *context.APIContext) { + // swagger:operation GET /user/starlist/{name}/repos user userGetOwnStarListRepos + // --- + // summary: Get the repos of the star list of the authenticated user with the given name + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/RepositoryList" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + getStarListReposInternal(ctx) +} + +// AddRepoToStarList adds a Repo to a Star List +func AddRepoToStarList(ctx *context.APIContext) { + // swagger:operation PUT /user/starlist/{name}/{owner}/{repo} user userAddRepoToStarList + // --- + // summary: Adds a Repo to a Star List + // parameters: + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // - name: owner + // in: path + // description: owner of the repo to star + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to star + // type: string + // required: true + // produces: + // - application/json + // responses: + // "201": + // "$ref": "#/responses/empty" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + err := ctx.Starlist.AddRepo(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "AddRepo", err) + return + } + ctx.Status(http.StatusCreated) +} + +// RemoveReoFromStarList removes a Repo from a Star List +func RemoveRepoFromStarList(ctx *context.APIContext) { + // swagger:operation DELETE /user/starlist/{name}/{owner}/{repo} user userRemoveRepoFromStarList + // --- + // summary: Removes a Repo from a Star List + // parameters: + // - name: name + // in: path + // description: name of the star list + // type: string + // required: true + // - name: owner + // in: path + // description: owner of the repo to star + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to star + // type: string + // required: true + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + // "401": + // "$ref": "#/responses/unauthorized" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + err := ctx.Starlist.RemoveRepo(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "RemoveRepo", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// GetStarListByID get a star list by id +func GetStarListByID(ctx *context.APIContext) { + // swagger:operation GET /starlist/{id} user userGetStarListByID + // --- + // summary: Get a star list by id + // parameters: + // - name: id + // in: path + // description: id of the star list to get + // type: integer + // format: int64 + // required: true + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/StarList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "501": + // "$ref": "#/responses/featureDisabled" + starList, err := repo_model.GetStarListByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if repo_model.IsErrStarListNotFound(err) { + ctx.NotFound("GetStarListByID", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetStarListByID", err) + } + return + } + + if !starList.HasAccess(ctx.Doer) { + ctx.NotFound("GetStarListByID", repo_model.ErrStarListNotFound{ID: ctx.ParamsInt64(":id")}) + return + } + + err = starList.LoadUser(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUser", err) + return + } + + err = starList.LoadRepositoryCount(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositoryCount", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToStarList(ctx, starList, ctx.Doer)) +} diff --git a/routers/web/repo/star_list.go b/routers/web/repo/star_list.go new file mode 100644 index 0000000000000..bf967c79b49f5 --- /dev/null +++ b/routers/web/repo/star_list.go @@ -0,0 +1,47 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "slices" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +func StarListPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.StarListRepoEditForm) + + starListSlice, err := repo_model.GetStarListsByUserID(ctx, ctx.Doer.ID, true) + if err != nil { + ctx.ServerError("GetStarListsByUserID", err) + return + } + + err = starListSlice.LoadRepoIDs(ctx) + if err != nil { + ctx.ServerError("LoadRepoIDs", err) + return + } + + for _, starList := range starListSlice { + if slices.Contains(form.StarListID, starList.ID) { + err = starList.AddRepo(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("StarListAddRepo", err) + return + } + } else { + err = starList.RemoveRepo(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("StarListRemoveRepo", err) + return + } + } + } + + ctx.Redirect(ctx.Repo.Repository.Link()) +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index f0749e10216ee..17dd8f5d4234c 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -235,6 +235,30 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb } total = int(count) + + if !setting.Repository.DisableStarLists { + starLists, err := repo_model.GetStarListsByUserID(ctx, ctx.ContextUser.ID, ctx.ContextUser.IsSameUser(ctx.Doer)) + if err != nil { + ctx.ServerError("GetUserStarListsByUserID", err) + return + } + + for _, list := range starLists { + list.User = ctx.ContextUser + } + + err = starLists.LoadRepositoryCount(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("StarListsLoadRepositoryCount", err) + return + } + + ctx.Data["StarLists"] = starLists + ctx.Data["StarListEditRedirect"] = fmt.Sprintf("%s?tab=stars", ctx.ContextUser.HomeLink()) + ctx.Data["EditStarListURL"] = fmt.Sprintf("%s/-/starlist_edit", ctx.ContextUser.HomeLink()) + } + + ctx.Data["StarListsEnabled"] = !setting.Repository.DisableStarLists case "watching": repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ diff --git a/routers/web/user/star_list.go b/routers/web/user/star_list.go new file mode 100644 index 0000000000000..d2046ffb8eb02 --- /dev/null +++ b/routers/web/user/star_list.go @@ -0,0 +1,191 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + shared_user "code.gitea.io/gitea/routers/web/shared/user" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" +) + +const ( + tplStarListRepos base.TplName = "user/starlist/repos" +) + +func ShowStarList(ctx *context.Context) { + if setting.Repository.DisableStarLists { + ctx.NotFound("", fmt.Errorf("")) + return + } + + shared_user.PrepareContextForProfileBigAvatar(ctx) + + err := shared_user.LoadHeaderCount(ctx) + if err != nil { + ctx.ServerError("LoadHeaderCount", err) + return + } + + name := ctx.Params("name") + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + keyword := ctx.FormTrim("q") + ctx.Data["Keyword"] = keyword + + language := ctx.FormTrim("language") + ctx.Data["Language"] = language + + pagingNum := setting.UI.User.RepoPagingNum + + starList, err := repo_model.GetStarListByName(ctx, ctx.ContextUser.ID, name) + if err != nil { + if repo_model.IsErrStarListNotFound(err) { + ctx.NotFound("", fmt.Errorf("")) + } else { + ctx.ServerError("GetStarListByName", err) + } + return + } + + if !starList.HasAccess(ctx.Doer) { + ctx.NotFound("", fmt.Errorf("")) + return + } + + err = starList.LoadUser(ctx) + if err != nil { + ctx.ServerError("LoadUser", err) + return + } + + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{Actor: ctx.Doer, StarListID: starList.ID, Keyword: keyword, Language: language}) + if err != nil { + ctx.ServerError("SearchRepository", err) + return + } + + ctx.Data["Repos"] = repos + ctx.Data["Total"] = count + + pager := context.NewPagination(int(count), pagingNum, page, 5) + pager.SetDefaultParams(ctx) + ctx.Data["Page"] = pager + + ctx.Data["TabName"] = "stars" + ctx.Data["Title"] = starList.Name + ctx.Data["CurrentStarList"] = starList + ctx.Data["PageIsProfileStarList"] = true + ctx.Data["StarListEditRedirect"] = starList.Link() + ctx.Data["ShowStarListEditButtons"] = ctx.ContextUser.IsSameUser(ctx.Doer) + ctx.Data["EditStarListURL"] = fmt.Sprintf("%s/-/starlist_edit", ctx.ContextUser.HomeLink()) + + ctx.HTML(http.StatusOK, tplStarListRepos) +} + +func editStarList(ctx *context.Context, form forms.EditStarListForm) { + starList, err := repo_model.GetStarListByID(ctx, form.ID) + if err != nil { + ctx.ServerError("GetStarListByID", err) + return + } + + err = starList.LoadUser(ctx) + if err != nil { + ctx.ServerError("LoadUser", err) + return + } + + // Check if the doer is the owner of the list + if ctx.Doer.ID != starList.UserID { + ctx.Flash.Error("Not the same user") + ctx.Redirect(starList.Link()) + return + } + + err = starList.EditData(ctx, form.Name, form.Description, form.Private) + if err != nil { + if repo_model.IsErrStarListExists(err) { + ctx.Flash.Error(ctx.Tr("starlist.name_exists_error", form.Name)) + ctx.Redirect(starList.Link()) + } else { + ctx.ServerError("EditData", err) + } + return + } + + ctx.Redirect(starList.Link()) +} + +func addStarList(ctx *context.Context, form forms.EditStarListForm) { + starList, err := repo_model.CreateStarList(ctx, ctx.Doer.ID, form.Name, form.Description, form.Private) + if err != nil { + if repo_model.IsErrStarListExists(err) { + ctx.Flash.Error(ctx.Tr("starlist.name_exists_error", form.Name)) + ctx.Redirect(form.CurrentURL) + } else { + ctx.ServerError("CreateStarList", err) + } + return + } + + err = starList.LoadUser(ctx) + if err != nil { + ctx.ServerError("LoadUser", err) + return + } + + ctx.Redirect(starList.Link()) +} + +func deleteStarList(ctx *context.Context, form forms.EditStarListForm) { + starList, err := repo_model.GetStarListByID(ctx, form.ID) + if err != nil { + ctx.ServerError("GetStarListByID", err) + return + } + + // Check if the doer is the owner of the list + if ctx.Doer.ID != starList.UserID { + ctx.Flash.Error("Not the same user") + ctx.Redirect(form.CurrentURL) + return + } + + err = repo_model.DeleteStarListByID(ctx, starList.ID) + if err != nil { + ctx.ServerError("GetStarListByID", err) + return + } + + ctx.Flash.Success(ctx.Tr("starlist.delete_success_message", starList.Name)) + + ctx.Redirect(fmt.Sprintf("%s?tab=stars", ctx.ContextUser.HomeLink())) +} + +func EditStarListPost(ctx *context.Context) { + form := *web.GetForm(ctx).(*forms.EditStarListForm) + + switch form.Action { + case "edit": + editStarList(ctx, form) + case "add": + addStarList(ctx, form) + case "delete": + deleteStarList(ctx, form) + default: + ctx.Flash.Error(fmt.Sprintf("Unknown action %s", form.Action)) + ctx.Redirect(form.CurrentURL) + } +} diff --git a/routers/web/web.go b/routers/web/web.go index 4fff994e42474..c6fef2a01bca6 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -790,6 +790,12 @@ func registerRoutes(m *web.Route) { // ***** END: Admin ***** m.Group("", func() { + m.Group("/{username}", func() { + m.Get("", user.UsernameSubRoute) + m.Get("/-/starlist/{name}", user.ShowStarList) + m.Post("/-/starlist_edit", web.Bind(forms.EditStarListForm{}), user.EditStarListPost) + }, context.UserAssignmentWeb()) + m.Get("/attachments/{uuid}", repo.GetAttachment) m.Get("/{username}", user.UsernameSubRoute) m.Methods("GET, OPTIONS", "/attachments/{uuid}", optionsCorsHandler(), repo.GetAttachment) }, ignSignIn) @@ -1142,6 +1148,7 @@ func registerRoutes(m *web.Route) { // Grouping for those endpoints that do require authentication m.Group("/{username}/{reponame}", func() { + m.Post("/starlistedit", web.Bind(forms.StarListRepoEditForm{}), repo.StarListPost) m.Group("/issues", func() { m.Group("/new", func() { m.Combo("").Get(context.RepoRef(), repo.NewIssue). diff --git a/services/context/api.go b/services/context/api.go index b18a206b5e28a..8298c049d321c 100644 --- a/services/context/api.go +++ b/services/context/api.go @@ -11,6 +11,7 @@ import ( "net/url" "strings" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" mc "code.gitea.io/gitea/modules/cache" @@ -37,9 +38,10 @@ type APIContext struct { ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer - Repo *Repository - Org *APIOrganization - Package *Package + Repo *Repository + Org *APIOrganization + Package *Package + Starlist *repo_model.StarList } func init() { @@ -100,6 +102,18 @@ type APIRedirect struct{} // swagger:response string type APIString string +// APIUnauthorizedError is a unauthorized error response +// swagger:response unauthorized +type APIUnauthorizedError struct { + APIError +} + +// APIFeatureDisabledError is a error that is retruned when the given feature is disabled +// swagger:response featureDisabled +type APIFeatureDisabledError struct { + APIError +} + // APIRepoArchivedError is an error that is raised when an archived repo should be modified // swagger:response repoArchivedError type APIRepoArchivedError struct { diff --git a/services/context/repo.go b/services/context/repo.go index 56e9fada0e935..ba59af37f449b 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -593,6 +593,20 @@ func RepoAssignment(ctx *Context) context.CancelFunc { if ctx.IsSigned { ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, repo.ID) ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID) + + if !setting.Repository.DisableStarLists { + starLists, err := repo_model.GetStarListsByUserID(ctx, ctx.Doer.ID, true) + if err != nil { + ctx.ServerError("GetStarListsByUserID", err) + return nil + } + err = starLists.LoadRepoIDs(ctx) + if err != nil { + ctx.ServerError("LoadRepoIDs", err) + return nil + } + ctx.Data["StarLists"] = starLists + } } if repo.IsFork { diff --git a/services/convert/user.go b/services/convert/user.go index 3521dd2f905c3..c375f680a6758 100644 --- a/services/convert/user.go +++ b/services/convert/user.go @@ -7,6 +7,7 @@ import ( "context" "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" ) @@ -106,3 +107,24 @@ func ToUserAndPermission(ctx context.Context, user, doer *user_model.User, acces RoleName: accessMode.String(), } } + +// ToStarList convert repo_model.StarList to api.StarList +func ToStarList(ctx context.Context, starList *repo_model.StarList, doer *user_model.User) *api.StarList { + return &api.StarList{ + ID: starList.ID, + Name: starList.Name, + Description: starList.Description, + IsPrivate: starList.IsPrivate, + RepositoryCount: starList.RepositoryCount, + User: ToUser(ctx, starList.User, doer), + } +} + +// ToStarLists convert repo_model.StarListSLice to list of api.StarList +func ToStarLists(ctx context.Context, starLists repo_model.StarListSlice, doer *user_model.User) []*api.StarList { + apiList := make([]*api.StarList, len(starLists)) + for i, list := range starLists { + apiList[i] = ToStarList(ctx, list, doer) + } + return apiList +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e45a2a1695522..d3030c4178055 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -901,3 +901,7 @@ func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding. ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +type StarListRepoEditForm struct { + StarListID []int64 +} diff --git a/services/forms/user_form.go b/services/forms/user_form.go index e2e6c208f7dab..7d942dd6313ef 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -446,6 +446,16 @@ func (f *PackageSettingForm) Validate(req *http.Request, errs binding.Errors) bi return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +// EditStarListForm form forediting/creating star lists +type EditStarListForm struct { + CurrentURL string + Action string + ID int64 + Name string + Description string + Private bool +} + type BlockUserForm struct { Action string `binding:"Required;In(block,unblock,note)"` Blockee string `binding:"Required"` diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 5e2774dfa1ad5..630af6eb4501e 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -62,7 +62,7 @@ {{end}} {{template "repo/watch_unwatch" $}} {{if not $.DisableStars}} - {{template "repo/star_unstar" $}} + {{template "repo/star_unstar" $}} {{end}} {{if and (not .IsEmpty) ($.Permission.CanRead $.UnitTypeCode)}}
+ + {{if not $.DisableStars}} + {{template "repo/starlistmodal" .}} + {{end}} diff --git a/templates/repo/starlistmodal.tmpl b/templates/repo/starlistmodal.tmpl new file mode 100644 index 0000000000000..fd7966bb4bcb7 --- /dev/null +++ b/templates/repo/starlistmodal.tmpl @@ -0,0 +1,30 @@ +{{.CurrentStarList.Description}}
+