Add star lists

This commit is contained in:
JakobDev 2024-05-03 13:45:04 +02:00
parent 27fa12427c
commit 9309ce5612
No known key found for this signature in database
GPG key ID: 39DEF62C3ED6DC4C
38 changed files with 2184 additions and 5 deletions

View file

@ -997,6 +997,9 @@ LEVEL = Info
;; Disable stars feature.
;DISABLE_STARS = false
;;
;; Disable star lists feature.
;DISABLE_STAR_LISTS = false
;;
;; Disable repository forking.
;DISABLE_FORKS = false
;;

View file

@ -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

View file

@ -0,0 +1,4 @@
-
id: 1
star_list_id: 1
repo_id: 1

View file

@ -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})))

View file

@ -60,6 +60,10 @@ func StarRepo(ctx context.Context, userID, repoID int64, star bool) error {
if _, err := db.Exec(ctx, "UPDATE `user` SET num_stars = num_stars - 1 WHERE id = ?", userID); 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 = ?)", repoID, userID); err != nil {
return err
}
}
return committer.Commit()

351
models/repo/star_list.go Normal file
View file

@ -0,0 +1,351 @@
// Copyright 2024 The Forgejo 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.ID, repo.ID, 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
}

View file

@ -0,0 +1,162 @@
// Copyright 2024 The Forgejo 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)))
}

View file

@ -9,6 +9,7 @@ import (
"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"
)
@ -69,3 +70,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.ID, repo.ID, 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))
}

View file

@ -487,6 +487,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,

View file

@ -591,6 +591,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())

View file

@ -50,6 +50,7 @@ var (
PrefixArchiveFiles bool
DisableMigrations bool
DisableStars bool
DisableStarLists bool
DisableForks bool
DefaultBranch string
AllowAdoptionOfUnadoptedRepositories bool
@ -172,6 +173,7 @@ var (
PrefixArchiveFiles: true,
DisableMigrations: false,
DisableStars: false,
DisableStarLists: false,
DisableForks: false,
DefaultBranch: "main",
AllowForkWithoutMaximumLimit: true,

View file

@ -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"`
ForksDisabled bool `json:"forks_disabled"`
TimeTrackingDisabled bool `json:"time_tracking_disabled"`
LFSDisabled bool `json:"lfs_disabled"`

View file

@ -116,3 +116,26 @@ type UpdateUserAvatarOption struct {
// image must be base64 encoded
Image string `json:"image" 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"`
}

View file

@ -145,6 +145,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 filters
filter.is_archived = Archived
@ -3773,3 +3776,17 @@ submodule = Submodule
filepreview.line = Line %[1]d in %[2]s
filepreview.lines = Lines %[1]d to %[2]d in %[3]s
filepreview.truncated = Preview has been truncated
[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.

View file

@ -716,6 +716,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) {
@ -828,6 +862,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))
@ -963,8 +1002,27 @@ func Routes() *web.Route {
m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar)
m.Delete("", user.DeleteAvatar)
}, reqToken())
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),

View file

@ -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,
ForksDisabled: setting.Repository.DisableForks,
TimeTrackingDisabled: !setting.Service.EnableTimetracking,
LFSDisabled: !setting.LFS.StartServer,

View file

@ -205,4 +205,7 @@ type swaggerParameterBodies struct {
// in:body
UpdateVariableOption api.UpdateVariableOption
// in:body
CreateEditStarListOptions api.CreateEditStarListOptions
}

View file

@ -48,3 +48,24 @@ type swaggerResponseUserSettings struct {
// in:body
Body []api.UserSettings `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"`
}

View file

@ -0,0 +1,594 @@
// Copyright 2024 The Forgejo 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))
}

View file

@ -0,0 +1,47 @@
// Copyright 2024 The Forgejo 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())
}

View file

@ -237,6 +237,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{

View file

@ -0,0 +1,191 @@
// Copyright 2024 The Forgejo 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)
}
}

View file

@ -763,6 +763,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)
@ -1136,6 +1142,9 @@ func registerRoutes(m *web.Route) {
m.Combo("/fork", reqRepoCodeReader).Get(repo.Fork).
Post(web.Bind(forms.CreateRepoForm{}), repo.ForkPost)
}
if !setting.Repository.DisableStarLists {
m.Post("/starlistedit", web.Bind(forms.StarListRepoEditForm{}), repo.StarListPost)
}
m.Group("/issues", func() {
m.Group("/new", func() {
m.Combo("").Get(context.RepoRef(), repo.NewIssue).

View file

@ -12,6 +12,7 @@ import (
"strings"
issues_model "code.gitea.io/gitea/models/issues"
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"
@ -38,10 +39,11 @@ type APIContext struct {
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
Repo *Repository
Comment *issues_model.Comment
Org *APIOrganization
Package *Package
Repo *Repository
Comment *issues_model.Comment
Org *APIOrganization
Package *Package
Starlist *repo_model.StarList
}
func init() {
@ -102,6 +104,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 {

View file

@ -614,6 +614,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 {

View file

@ -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"
)
@ -110,3 +111,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
}

View file

@ -771,3 +771,8 @@ func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding.
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// Edit the star lits for a repo
type StarListRepoEditForm struct {
StarListID []int64
}

View file

@ -451,3 +451,13 @@ func (f *PackageSettingForm) Validate(req *http.Request, errs binding.Errors) bi
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// EditStarListForm form for editing/creating star lists
type EditStarListForm struct {
CurrentURL string
Action string
ID int64
Name string
Description string
Private bool
}

View file

@ -10,5 +10,12 @@
<a hx-boost="false" class="ui basic label" href="{{$.RepoLink}}/stars">
{{CountFmt .Repository.NumStars}}
</a>
{{if $.StarLists}}
<button type="button" class="ui compact small basic button show-modal no-border-radius-left no-margin-left" data-modal="#repo-star-list-modal">{{svg "octicon-pencil"}}</button>
{{end}}
</div>
</form>
{{if not $.DisableStars}}
{{template "repo/starlistmodal" .}}
{{end}}

View file

@ -0,0 +1,30 @@
<div class="ui small modal" id="repo-star-list-modal">
<div class="header">
{{ctx.Locale.Tr "starlist.list_header"}}
</div>
<div class="content">
<form class="ui form" action="{{$.RepoLink}}/starlistedit" method="POST">
{{.CsrfTokenHtml}}
{{range .StarLists}}
<div class="field">
<div class="ui checkbox">
<label for="star_list_id">{{.Name}}</label>
<input name="star_list_id" value="{{.ID}}" type="checkbox" {{if .ContainsRepoID $.Repository.ID}}checked{{end}}>
</div>
</div>
{{end}}
<div class="text right actions">
<button class="ui small basic cancel button">
{{svg "octicon-x"}}
{{ctx.Locale.Tr "cancel"}}
</button>
<button class="ui primary small approve button">
{{svg "fontawesome-save"}}
{{ctx.Locale.Tr "save"}}
</button>
</div>
</form>
</div>
</div>

View file

@ -8,6 +8,9 @@
</div>
<div class="ui twelve wide column tw-mb-4">
{{template "user/overview/header" .}}
{{template "base/alert" .}}
{{if eq .TabName "activity"}}
{{if .ContextUser.KeepActivityPrivate}}
<div class="ui info message">
@ -17,6 +20,9 @@
{{template "user/heatmap" .}}
{{template "user/dashboard/feeds" .}}
{{else if eq .TabName "stars"}}
{{if and .StarListsEnabled (or .StarLists (.ContextUser.IsSameUser .SignedUser))}}
{{template "user/starlist/list" .}}
{{end}}
<div class="stars">
{{template "shared/repo_search" .}}
{{template "explore/repo_list" .}}
@ -54,3 +60,7 @@
</div>
{{template "base/footer" .}}
{{if and .StarListsEnabled (.ContextUser.IsSameUser .SignedUser)}}
{{template "user/starlist/edit" .}}
{{end}}

View file

@ -0,0 +1,51 @@
<div class="ui small modal" id="edit-star-list-modal">
<div class="header">
{{if .CurrentStarList}}
{{ctx.Locale.Tr "starlist.edit_header"}}
{{else}}
{{ctx.Locale.Tr "starlist.add_header"}}
{{end}}
</div>
<div class="content">
<form class="ui form" action="{{.EditStarListURL}}" method="post">
{{.CsrfTokenHtml}}
<input name="current_url" type="hidden" value="{{.StarListEditRedirect}}">
{{if .CurrentStarList}}
<input name="action" type="hidden" value="edit">
<input name="id" type="hidden" value="{{.CurrentStarList.ID}}">
{{else}}
<input name="action" type="hidden" value="add">
{{end}}
<div class="field">
<label for="name">{{ctx.Locale.Tr "starlist.name_label"}}</label>
<input id="name" name="name" placeholder="{{ctx.Locale.Tr "starlist.name_placeholder"}}" {{if .CurrentStarList}}value="{{.CurrentStarList.Name}}"{{end}} maxlength="50" required>
</div>
<div class="field">
<label for="description">{{ctx.Locale.Tr "starlist.description_label"}}</label>
<textarea id="description" name="description" rows="2" placeholder="{{ctx.Locale.Tr "starlist.description_placeholder"}}" maxlength="255">{{if .CurrentStarList}}{{.CurrentStarList.Description}}{{end}}</textarea>
</div>
<div class="field">
<div class="ui checkbox">
<label for="private">{{ctx.Locale.Tr "starlist.private"}}</label>
<input name="private" type="checkbox" {{if .CurrentStarList}}{{if .CurrentStarList.IsPrivate}}checked{{end}}{{end}}>
</div>
</div>
<div class="text right actions">
<button class="ui small basic cancel button">
{{svg "octicon-x"}}
{{ctx.Locale.Tr "cancel"}}
</button>
<button class="ui primary small approve button">
{{svg "fontawesome-save"}}
{{if .CurrentStarList}}{{ctx.Locale.Tr "save"}}{{else}}{{ctx.Locale.Tr "add"}}{{end}}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,37 @@
<div class="star-list-box">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "starlist.list_header"}}
{{if .ContextUser.IsSameUser .SignedUser}}
<div class="ui right">
<button class="ui tiny button green show-modal" data-modal="#edit-star-list-modal">{{ctx.Locale.Tr "add"}}</button>
</div>
{{end}}
</h4>
<div class="ui attached segment">
{{if .StarLists}}
<div class="flex-list">
{{range .StarLists}}
<div class="flex-item">
<a class="flex-item-header star-list-item" href="{{.Link}}">
<div class="flex-item-title">
{{.Name}}
{{if .IsPrivate}}
<span class="ui basic label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
</div>
<div class="flex-item-trailing">
{{if eq .RepositoryCount 1}}
<span class="text grey flex-text-inline">{{ctx.Locale.Tr "repository_count_1"}}</span>
{{else}}
<span class="text grey flex-text-inline">{{ctx.Locale.Tr "repository_count_n" .RepositoryCount}}</span>
{{end}}
</div>
</a>
</div>
{{end}}
</div>
{{else}}
{{ctx.Locale.Tr "starlist.no_star_lists_text"}}
{{end}}
</div>
</div>

View file

@ -0,0 +1,62 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content user profile">
<div class="ui container">
<div class="ui stackable grid">
<div class="ui four wide column">
{{template "shared/user/profile_big_avatar" .}}
</div>
<div class="ui twelve wide column">
<div class="gt-mb-4">
{{template "user/overview/header" .}}
</div>
{{template "base/alert" .}}
<div class="list-header">
<div class="star-list-header-info">
<h1>{{.CurrentStarList.Name}}</h1>
<p>{{.CurrentStarList.Description}}</p>
</div>
{{if .ContextUser.IsSameUser .SignedUser}}
<div>
<button class="ui button green show-modal" data-modal="#edit-star-list-modal">{{ctx.Locale.Tr "edit"}}</button>
<button class="ui button red show-modal" data-modal="#delete-star-list-modal">{{ctx.Locale.Tr "remove"}}</button>
</div>
{{end}}
</div>
{{template "shared/repo_search" .}}
{{template "explore/repo_list" .}}
{{template "base/paginate" .}}
</div>
</div>
</div>
</div>
{{template "base/footer" .}}
{{if .ContextUser.IsSameUser .SignedUser}}
{{template "user/starlist/edit" .}}
<div class="ui small modal" id="delete-star-list-modal">
<div class="header">
{{ctx.Locale.Tr "starlist.delete_header"}}
</div>
<div class="content">
<div class="ui warning message">
{{ctx.Locale.Tr "repo.settings.delete_notices_1" | SafeHTML}}
</div>
<form class="ui form" action="{{.EditStarListURL}}" method="post">
{{.CsrfTokenHtml}}
<input name="current_url" type="hidden" value="{{.StarListEditRedirect}}">
<input name="id" type="hidden" value="{{.CurrentStarList.ID}}">
<input name="action" type="hidden" value="delete">
<div class="text right actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui red button">{{ctx.Locale.Tr "remove"}}</button>
</div>
</form>
</div>
</div>
{{end}}

View file

@ -46,6 +46,8 @@ func TestAPIExposedSettings(t *testing.T) {
MirrorsDisabled: !setting.Mirror.Enabled,
HTTPGitDisabled: setting.Repository.DisableHTTPGit,
MigrationsDisabled: setting.Repository.DisableMigrations,
StarsDisabled: setting.Repository.DisableStars,
StarListsDisabled: setting.Repository.DisableStarLists,
TimeTrackingDisabled: false,
LFSDisabled: !setting.LFS.StartServer,
}, repo)

View file

@ -0,0 +1,303 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"fmt"
"net/http"
"net/url"
"testing"
auth_model "code.gitea.io/gitea/models/auth"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
)
func TestAPIGetStarLists(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
t.Run("CurrentUser", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starlists?token=%s", token)), http.StatusOK)
var starLists []api.StarList
DecodeJSON(t, resp, &starLists)
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)
})
t.Run("OtherUser", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/starlists", user.Name)), http.StatusOK)
var starLists []api.StarList
DecodeJSON(t, resp, &starLists)
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)
})
}
func TestAPIGetStarListRepoInfo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
urlStr := fmt.Sprintf("/api/v1/user/starlists/repoinfo/%s/%s?token=%s", repo.OwnerName, repo.Name, token)
resp := MakeRequest(t, NewRequest(t, "GET", urlStr), http.StatusOK)
var repoInfo []api.StarListRepoInfo
DecodeJSON(t, resp, &repoInfo)
assert.Len(t, repoInfo, 2)
assert.True(t, repoInfo[0].Contains)
assert.Equal(t, int64(1), repoInfo[0].StarList.ID)
assert.False(t, repoInfo[1].Contains)
assert.Equal(t, int64(2), repoInfo[1].StarList.ID)
}
func TestAPICreateStarList(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
t.Run("Success", func(t *testing.T) {
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/starlists?token=%s", token), &api.CreateEditStarListOptions{
Name: "New Name",
Description: "Hello",
IsPrivate: false,
})
resp := MakeRequest(t, req, http.StatusCreated)
var starList api.StarList
DecodeJSON(t, resp, &starList)
assert.Equal(t, "New Name", starList.Name)
assert.Equal(t, "Hello", starList.Description)
assert.False(t, starList.IsPrivate)
})
t.Run("ExistingName", func(t *testing.T) {
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/starlists?token=%s", token), &api.CreateEditStarListOptions{
Name: "First List",
Description: "Hello",
IsPrivate: false,
})
MakeRequest(t, req, http.StatusBadRequest)
})
}
func TestAPIGetStarListByName(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
t.Run("CurrentUserPublic", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starlist/%s?token=%s", url.PathEscape("First List"), token)), http.StatusOK)
var starList api.StarList
DecodeJSON(t, resp, &starList)
assert.Equal(t, int64(1), starList.ID)
assert.Equal(t, "First List", starList.Name)
assert.Equal(t, "Description for first List", starList.Description)
assert.False(t, starList.IsPrivate)
})
t.Run("OtherUserPublic", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/starlist/%s", user.Name, url.PathEscape("First List"))), http.StatusOK)
var starList api.StarList
DecodeJSON(t, resp, &starList)
assert.Equal(t, int64(1), starList.ID)
assert.Equal(t, "First List", starList.Name)
assert.Equal(t, "Description for first List", starList.Description)
assert.False(t, starList.IsPrivate)
})
t.Run("CurrentUserPrivate", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starlist/%s?token=%s", url.PathEscape("Second List"), token)), http.StatusOK)
var starList api.StarList
DecodeJSON(t, resp, &starList)
assert.Equal(t, int64(2), starList.ID)
assert.Equal(t, "Second List", starList.Name)
assert.Equal(t, "This is private", starList.Description)
assert.True(t, starList.IsPrivate)
})
t.Run("OtherUserPublic", func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/starlist/%s", user.Name, url.PathEscape("Second List"))), http.StatusNotFound)
})
}
func TestAPIEditStarList(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/user/starlist/%s?token=%s", url.PathEscape("First List"), token), &api.CreateEditStarListOptions{
Name: "New Name",
Description: "Hello",
IsPrivate: false,
})
resp := MakeRequest(t, req, http.StatusOK)
var starList api.StarList
DecodeJSON(t, resp, &starList)
assert.Equal(t, "New Name", starList.Name)
assert.Equal(t, "Hello", starList.Description)
assert.False(t, starList.IsPrivate)
}
func TestAPIDeleteStarList(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
MakeRequest(t, NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/starlist/%s?token=%s", url.PathEscape("First List"), token)), http.StatusNoContent)
}
func TestAPIStarListGetRepos(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadRepository)
t.Run("CurrentUser", func(t *testing.T) {
urlStr := fmt.Sprintf("/api/v1/user/starlist/%s/repos?token=%s", url.PathEscape("First List"), token)
resp := MakeRequest(t, NewRequest(t, "GET", urlStr), http.StatusOK)
var repoList []*api.Repository
DecodeJSON(t, resp, &repoList)
assert.Len(t, repoList, 1)
assert.Equal(t, int64(1), repoList[0].ID)
})
t.Run("OtherUser", func(t *testing.T) {
urlStr := fmt.Sprintf("/api/v1/users/%s/starlist/%s/repos", user.Name, url.PathEscape("First List"))
resp := MakeRequest(t, NewRequest(t, "GET", urlStr), http.StatusOK)
var repoList []*api.Repository
DecodeJSON(t, resp, &repoList)
assert.Len(t, repoList, 1)
assert.Equal(t, int64(1), repoList[0].ID)
})
}
func TestAPIStarListAddRepo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
urlStr := fmt.Sprintf("/api/v1/user/starlist/%s/%s/%s?token=%s", url.PathEscape("First List"), repo.OwnerName, repo.Name, token)
MakeRequest(t, NewRequest(t, "PUT", urlStr), http.StatusCreated)
}
func TestAPIStarListRemoveRepo(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
session := loginUser(t, user.Name)
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
urlStr := fmt.Sprintf("/api/v1/user/starlist/%s/%s/%s?token=%s", url.PathEscape("First List"), repo.OwnerName, repo.Name, token)
MakeRequest(t, NewRequest(t, "DELETE", urlStr), http.StatusNoContent)
}
func TestAPIGetStarListByID(t *testing.T) {
defer tests.PrepareTestEnv(t)()
assert.NoError(t, unittest.LoadFixtures())
t.Run("PublicList", func(t *testing.T) {
resp := MakeRequest(t, NewRequest(t, "GET", "/api/v1/starlist/1"), http.StatusOK)
var starList api.StarList
DecodeJSON(t, resp, &starList)
assert.Equal(t, int64(1), starList.ID)
assert.Equal(t, "First List", starList.Name)
assert.Equal(t, "Description for first List", starList.Description)
assert.False(t, starList.IsPrivate)
})
t.Run("PrivateList", func(t *testing.T) {
MakeRequest(t, NewRequest(t, "GET", "/api/v1/starlist/2"), http.StatusNotFound)
})
}

View file

@ -754,3 +754,21 @@ It needs some tricks to tweak the left/right borders with active state */
color: var(--color-green-dark-2);
border-color: var(--color-green-dark-2);
}
.no-border-radius {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.no-border-radius-left {
border-top-right-radius: var(--border-radius) !important;
border-bottom-right-radius: var(--border-radius) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.no-margin-left {
margin-left: -1px !important;
}

View file

@ -160,4 +160,17 @@
#pronouns-dropdown, #pronouns-custom {
width: 140px;
}
}
.star-list-box {
margin-bottom: 10px;
}
.star-list-item {
width: 100%;
color: inherit;
}
.star-list-header-info {
flex-grow: 1;
}