diff --git a/models/repo/pin.go b/models/repo/pin.go new file mode 100644 index 0000000000000..fb892b1959771 --- /dev/null +++ b/models/repo/pin.go @@ -0,0 +1,62 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "context" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" +) + +type Pin struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"UNIQUE(s)"` + RepoID int64 `xorm:"UNIQUE(s)"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +// TableName sets the table name for the pin struct +func (s *Pin) TableName() string { + return "repository_pin" +} + +func init() { + db.RegisterModel(new(Pin)) +} + +func IsPinned(ctx context.Context, userID, repoID int64) bool { + exists, _ := db.GetEngine(ctx).Exist(&Pin{UID: userID, RepoID: repoID}) + + return exists +} + +func PinRepo(ctx context.Context, doer *user_model.User, repo *Repository, pin bool) error { + return db.WithTx(ctx, func(ctx context.Context) error { + pinned := IsPinned(ctx, doer.ID, repo.ID) + + if pin { + // Already pinned, nothing to do + if pinned { + return nil + } + + if err := db.Insert(ctx, &Pin{UID: doer.ID, RepoID: repo.ID}); err != nil { + return err + } + } else { + // Not pinned, nothing to do + if !pinned { + return nil + } + + if _, err := db.DeleteByBean(ctx, &Pin{UID: doer.ID, RepoID: repo.ID}); err != nil { + return err + } + } + + return nil + }) +} diff --git a/models/repo/pin_test.go b/models/repo/pin_test.go new file mode 100644 index 0000000000000..d6d9b2e765f85 --- /dev/null +++ b/models/repo/pin_test.go @@ -0,0 +1,48 @@ +// Copyright 2024 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 TestPinRepoFunctionality(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}) + + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) +} + +func TestIsPinned(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}) + + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, true)) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: user.ID, RepoID: repo.ID}) + + assert.True(t, repo_model.IsPinned(db.DefaultContext, user.ID, repo.ID)) + + assert.NoError(t, repo_model.PinRepo(db.DefaultContext, user, repo, false)) + unittest.AssertNotExistsBean(t, &repo_model.Star{UID: user.ID, RepoID: repo.ID}) + + assert.False(t, repo_model.IsPinned(db.DefaultContext, user.ID, repo.ID)) +} diff --git a/models/repo/user_repo.go b/models/repo/user_repo.go index c305603e02070..72149cdea875f 100644 --- a/models/repo/user_repo.go +++ b/models/repo/user_repo.go @@ -54,6 +54,41 @@ func GetStarredRepos(ctx context.Context, opts *StarredReposOptions) ([]*Reposit return db.Find[Repository](ctx, opts) } +type PinnedReposOptions struct { + db.ListOptions + PinnerID int64 + RepoOwnerID int64 +} + +func (opts *PinnedReposOptions) ToConds() builder.Cond { + var cond builder.Cond = builder.Eq{ + "repository_pin.uid": opts.PinnerID, + } + if opts.RepoOwnerID != 0 { + cond = cond.And(builder.Eq{ + "repository.owner_id": opts.RepoOwnerID, + }) + } + return cond +} + +func (opts *PinnedReposOptions) ToJoins() []db.JoinFunc { + return []db.JoinFunc{ + func(e db.Engine) error { + e.Join("INNER", "repository_pin", "`repository`.id=`repository_pin`.repo_id") + return nil + }, + } +} + +func GetPinnedRepos(ctx context.Context, opts *PinnedReposOptions) (RepositoryList, error) { + return db.Find[Repository](ctx, opts) +} + +func CountPinnedRepos(ctx context.Context, opts *PinnedReposOptions) (int64, error) { + return db.Count[Repository](ctx, opts) +} + type WatchedReposOptions struct { db.ListOptions WatcherID int64 diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 539715b3f9c91..ce8a4efefa6b4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1102,6 +1102,7 @@ transfer.no_permission_to_reject = You do not have permission to reject this tra desc.private = Private desc.public = Public desc.template = Template +desc.private_template = Private Template desc.internal = Internal desc.archived = Archived desc.sha256 = SHA256 @@ -1187,10 +1188,15 @@ fork_from_self = You cannot fork a repository you own. fork_guest_user = Sign in to fork this repository. watch_guest_user = Sign in to watch this repository. star_guest_user = Sign in to star this repository. +pin_guest_user = Sign in to pin this repository. unwatch = Unwatch watch = Watch unstar = Unstar star = Star +pin = Pin +unpin = Unpin +pin-org = Pin to %s +unpin-org = Unpin from %s fork = Fork action.blocked_user = Cannot perform action because you are blocked by the repository owner. download_archive = Download Repository diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 846b1de18ab13..b23bbeefa04f5 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/util" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -101,9 +102,11 @@ func Home(ctx *context.Context) { ctx.Data["IsPrivate"] = private var ( - repos []*repo_model.Repository - count int64 - err error + repos []*repo_model.Repository + count int64 + pinnedRepos []*repo_model.Repository + pinnedCount int64 + err error ) repos, count, err = repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ ListOptions: db.ListOptions{ @@ -139,8 +142,19 @@ func Home(ctx *context.Context) { return } + // Get pinned repos + pinnedRepos, err = user_service.GetUserPinnedRepos(ctx, org.AsUser(), ctx.Doer) + if err != nil { + ctx.ServerError("GetUserPinnedRepos", err) + return + } + + pinnedCount = int64(len(pinnedRepos)) + ctx.Data["Repos"] = repos ctx.Data["Total"] = count + ctx.Data["PinnedRepos"] = pinnedRepos + ctx.Data["PinnedTotal"] = pinnedCount ctx.Data["Members"] = members ctx.Data["Teams"] = ctx.Org.Teams ctx.Data["DisableNewPullMirrors"] = setting.Mirror.DisableNewPull diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 5a74971827901..d55ec8579b784 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -36,6 +36,7 @@ import ( repo_service "code.gitea.io/gitea/services/repository" archiver_service "code.gitea.io/gitea/services/repository/archiver" commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -321,6 +322,14 @@ func Action(ctx *context.Context) { err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, true) case "unstar": err = repo_model.StarRepo(ctx, ctx.Doer, ctx.Repo.Repository, false) + case "pin": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, true, false) + case "unpin": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, false, false) + case "pin-org": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, true, true) + case "unpin-org": + err = user_service.PinRepo(ctx, ctx.Doer, ctx.Repo.Repository, false, true) case "accept_transfer": err = acceptOrRejectRepoTransfer(ctx, true) case "reject_transfer": diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 386ef7be5ce87..a491c7fe06e1d 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -29,6 +29,7 @@ import ( "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" issue_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" 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" @@ -51,6 +52,7 @@ import ( "code.gitea.io/gitea/services/context" issue_service "code.gitea.io/gitea/services/issue" files_service "code.gitea.io/gitea/services/repository/files" + user_service "code.gitea.io/gitea/services/user" "github.com/nektos/act/pkg/model" @@ -792,6 +794,14 @@ func Home(ctx *context.Context) { return } + if ctx.IsSigned { + err := loadPinData(ctx) + if err != nil { + ctx.ServerError("loadPinData", err) + return + } + } + renderHomeCode(ctx) } @@ -1179,3 +1189,37 @@ func Forks(ctx *context.Context) { ctx.HTML(http.StatusOK, tplForks) } + +func loadPinData(ctx *context.Context) error { + // First, cleanup any pins that are no longer valid + err := user_service.CleanupPins(ctx, ctx.Doer) + if err != nil { + return err + } + + ctx.Data["IsPinningRepo"] = repo_model.IsPinned(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + ctx.Data["CanPinRepo"], err = user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) + if err != nil { + return err + } + + if ctx.Repo.Repository.Owner.IsOrganization() { + org := organization.OrgFromUser(ctx.Repo.Repository.Owner) + + isAdmin, err := org.IsOrgAdmin(ctx, ctx.Doer.ID) + if err != nil { + return err + } + + if isAdmin { + ctx.Data["CanUserPinToOrg"] = true + ctx.Data["IsOrgPinningRepo"] = repo_model.IsPinned(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID) + ctx.Data["CanOrgPinRepo"], err = user_service.CanPin(ctx, ctx.Repo.Repository.Owner, ctx.Repo.Repository) + if err != nil { + return err + } + } + } + + return nil +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index f0749e10216ee..ef8072f8cf065 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/routers/web/org" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -104,10 +105,12 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb pagingNum := setting.UI.User.RepoPagingNum topicOnly := ctx.FormBool("topic") var ( - repos []*repo_model.Repository - count int64 - total int - orderBy db.SearchOrderBy + repos []*repo_model.Repository + pinnedRepos []*repo_model.Repository + count int64 + pinnedCount int64 + total int + orderBy db.SearchOrderBy ) ctx.Data["SortType"] = ctx.FormString("sort") @@ -312,9 +315,19 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb } total = int(count) + + pinnedRepos, err = user_service.GetUserPinnedRepos(ctx, ctx.ContextUser, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserPinnedRepos", err) + return + } + + pinnedCount = int64(len(pinnedRepos)) } ctx.Data["Repos"] = repos ctx.Data["Total"] = total + ctx.Data["PinnedRepos"] = pinnedRepos + ctx.Data["PinnedCount"] = pinnedCount err = shared_user.LoadHeaderCount(ctx) if err != nil { diff --git a/services/repository/delete.go b/services/repository/delete.go index cd779b05c3501..2805eed3a53f0 100644 --- a/services/repository/delete.go +++ b/services/repository/delete.go @@ -151,6 +151,7 @@ func DeleteRepositoryDirectly(ctx context.Context, doer *user_model.User, repoID &repo_model.Redirect{RedirectRepoID: repoID}, &repo_model.RepoUnit{RepoID: repoID}, &repo_model.Star{RepoID: repoID}, + &repo_model.Pin{RepoID: repoID}, &admin_model.Task{RepoID: repoID}, &repo_model.Watch{RepoID: repoID}, &webhook.Webhook{RepoID: repoID}, diff --git a/services/user/delete.go b/services/user/delete.go index 39c6ef052dca7..3bc92c3d9aebf 100644 --- a/services/user/delete.go +++ b/services/user/delete.go @@ -95,6 +95,7 @@ func deleteUser(ctx context.Context, u *user_model.User, purge bool) (err error) &user_model.Blocking{BlockerID: u.ID}, &user_model.Blocking{BlockeeID: u.ID}, &actions_model.ActionRunnerToken{OwnerID: u.ID}, + &repo_model.Pin{UID: u.ID}, ); err != nil { return fmt.Errorf("deleteBeans: %w", err) } diff --git a/services/user/pin.go b/services/user/pin.go new file mode 100644 index 0000000000000..6610f3bb8960b --- /dev/null +++ b/services/user/pin.go @@ -0,0 +1,169 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/context" +) + +const maxPins = 6 + +// Check if a user have a new pinned repo in it's profile, meaning that it +// has permissions to pin said repo and also has enough space on the pinned list. +func CanPin(ctx *context.Context, u *user_model.User, r *repo_model.Repository) (bool, error) { + count, err := repo_model.CountPinnedRepos(*ctx, &repo_model.PinnedReposOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + PinnerID: u.ID, + }) + if err != nil { + ctx.ServerError("CountPinnedRepos", err) + return false, err + } + + if count >= maxPins { + return false, nil + } + + return HasPermsToPin(ctx, u, r), nil +} + +// Checks if the user has permission to have the repo pinned in it's profile. +func HasPermsToPin(ctx *context.Context, u *user_model.User, r *repo_model.Repository) bool { + // If user is an organization, it can only pin its own repos + if u.IsOrganization() { + return r.OwnerID == u.ID + } + + // For normal users, anyone that has read access to the repo can pin it + return canSeePin(ctx, u, r) +} + +// Check if a user can see a pin +// A user can see a pin if he has read access to the repo +func canSeePin(ctx *context.Context, u *user_model.User, r *repo_model.Repository) bool { + perm, err := access_model.GetUserRepoPermission(ctx, r, u) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return false + } + return perm.HasAnyUnitAccess() +} + +// CleanupPins iterates over the repos pinned by a user and removes +// the invalid pins. (Needs to be called everytime before we read/write a pin) +func CleanupPins(ctx *context.Context, u *user_model.User) error { + pinnedRepos, err := repo_model.GetPinnedRepos(*ctx, &repo_model.PinnedReposOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + PinnerID: u.ID, + }) + if err != nil { + return err + } + + for _, repo := range pinnedRepos { + if !HasPermsToPin(ctx, u, repo) { + if err := repo_model.PinRepo(*ctx, u, repo, false); err != nil { + return err + } + } + } + + return nil +} + +// Returns the pinned repos of a user that the viewer can see +func GetUserPinnedRepos(ctx *context.Context, user, viewer *user_model.User) ([]*repo_model.Repository, error) { + // Start by cleaning up the invalid pins + err := CleanupPins(ctx, user) + if err != nil { + return nil, err + } + + // Get all of the user's pinned repos + pinnedRepos, err := repo_model.GetPinnedRepos(*ctx, &repo_model.PinnedReposOptions{ + ListOptions: db.ListOptions{ + ListAll: true, + }, + PinnerID: user.ID, + }) + if err != nil { + return nil, err + } + + var repos []*repo_model.Repository + + // Only include the repos that the viewer can see + for _, repo := range pinnedRepos { + if canSeePin(ctx, viewer, repo) { + repos = append(repos, repo) + } + } + + return repos, nil +} + +func PinRepo(ctx *context.Context, doer *user_model.User, repo *repo_model.Repository, pin, toOrg bool) error { + // Determine the user which profile is the target for the pin + var targetUser *user_model.User + if toOrg { + targetUser = repo.Owner + } else { + targetUser = doer + } + + // Start by cleaning up the invalid pins + err := CleanupPins(ctx, targetUser) + if err != nil { + return err + } + + // If target is org profile, need to check if the doer can pin the repo + // on said org profile + if toOrg { + err = assertUserOrgPerms(ctx, doer, repo) + if err != nil { + return err + } + } + + if pin { + canPin, err := CanPin(ctx, targetUser, repo) + if err != nil { + return err + } + if !canPin { + return errors.New("user cannot pin this repository") + } + } + + return repo_model.PinRepo(*ctx, targetUser, repo, pin) +} + +func assertUserOrgPerms(ctx *context.Context, doer *user_model.User, repo *repo_model.Repository) error { + if !ctx.Repo.Owner.IsOrganization() { + return errors.New("owner is not an organization") + } + + isAdmin, err := organization.OrgFromUser(repo.Owner).IsOrgAdmin(ctx, doer.ID) + if err != nil { + return err + } + + if !isAdmin { + return errors.New("user is not an admin of this organization") + } + + return nil +} diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 4851b6997967b..1f83dddda68c7 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -8,6 +8,9 @@ {{if .ProfileReadme}}
{{.ProfileReadme}}
{{end}} + {{if .PinnedRepos}} + {{template "shared/pinned_repo_cards" .}} + {{end}} {{template "shared/repo_search" .}} {{template "explore/repo_list" .}} {{template "base/paginate" .}} diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 22daaab4bc1ba..0c3bd7a6daae7 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -60,6 +60,7 @@ {{svg "octicon-rss" 16}} {{end}} + {{template "repo/pin_unpin" $}} {{template "repo/watch_unwatch" $}} {{if not $.DisableStars}} {{template "repo/star_unstar" $}} diff --git a/templates/repo/pin_unpin.tmpl b/templates/repo/pin_unpin.tmpl new file mode 100644 index 0000000000000..80f1a353dc480 --- /dev/null +++ b/templates/repo/pin_unpin.tmpl @@ -0,0 +1,30 @@ +
+
+
+ {{$buttonText := ctx.Locale.Tr "repo.pin"}} + {{if $.IsPinningRepo}}{{$buttonText = ctx.Locale.Tr "repo.unpin"}}{{end}} + +
+
+ + {{if .CanUserPinToOrg}} + + {{end}} +
diff --git a/templates/shared/pinned_repo_cards.tmpl b/templates/shared/pinned_repo_cards.tmpl new file mode 100644 index 0000000000000..1ccee6cb63eec --- /dev/null +++ b/templates/shared/pinned_repo_cards.tmpl @@ -0,0 +1,62 @@ +
+ {{range .PinnedRepos}} +
+
+
+
+ +
+ {{if .IsArchived}} + {{ctx.Locale.Tr "repo.desc.archived"}} + {{end}} + {{if .IsTemplate}} + {{if .IsPrivate}} + {{ctx.Locale.Tr "repo.desc.private_template"}} + {{end}} + {{else}} + {{if .IsPrivate}} + {{ctx.Locale.Tr "repo.desc.private"}} + {{end}} + {{end}} + {{if .IsFork}} + {{svg "octicon-repo-forked"}} + {{else if .IsMirror}} + {{svg "octicon-mirror"}} + {{end}} +
+
+
+
+
+ {{if .PrimaryLanguage}} + {{.PrimaryLanguage.Language}} + {{end}} + {{if not $.DisableStars}} + {{svg "octicon-star" 16 "mr-3"}}{{.NumStars}} + {{end}} + {{svg "octicon-git-branch" 16 "mr-3"}}{{.NumForks}} +
+
+
+ {{$description := .DescriptionHTML $.Context}} + {{if $description}}

{{$description}}

{{end}} + {{if .Topics}} +
+ {{range .Topics}} + {{if ne . ""}} + +
{{.}}
+
+ {{end}} + {{end}} +
+ {{end}} +
+
+
+ {{end}} +
diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index cf61bb906a172..d38292077d297 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -28,6 +28,9 @@ {{else if eq .TabName "overview"}}
{{.ProfileReadme}}
{{else}} + {{if .PinnedRepos}} + {{template "shared/pinned_repo_cards" .}} + {{end}} {{template "shared/repo_search" .}} {{template "explore/repo_list" .}} {{template "base/paginate" .}} diff --git a/tests/integration/repo_pin_test.go b/tests/integration/repo_pin_test.go new file mode 100644 index 0000000000000..9d659d385c610 --- /dev/null +++ b/tests/integration/repo_pin_test.go @@ -0,0 +1,63 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "path" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" +) + +func TestUserRepoPin(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user2") + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: 2, RepoID: 3}) + testUserPinRepo(t, session, "org3", "repo3", true, false) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: 2, RepoID: 3}) + testUserPinRepo(t, session, "org3", "repo3", false, false) + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: 2, RepoID: 3}) + }) +} + +func TestOrgRepoPin(t *testing.T) { + onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) { + session := loginUser(t, "user2") + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: 3, RepoID: 3}) + testUserPinRepo(t, session, "org3", "repo3", true, true) + unittest.AssertExistsAndLoadBean(t, &repo_model.Pin{UID: 3, RepoID: 3}) + testUserPinRepo(t, session, "org3", "repo3", false, true) + unittest.AssertNotExistsBean(t, &repo_model.Pin{UID: 3, RepoID: 3}) + }) +} + +func testUserPinRepo(t *testing.T, session *TestSession, user, repo string, pin, org bool) error { + var action string + if pin { + action = "pin" + } else { + action = "unpin" + } + + if org { + action += "-org" + } + + // Get repo page to get the CSRF token + reqPage := NewRequest(t, "GET", path.Join(user, repo)) + respPage := session.MakeRequest(t, reqPage, http.StatusOK) + + htmlDoc := NewHTMLParser(t, respPage.Body) + + reqPath := path.Join(user, repo, "action", action) + req := NewRequestWithValues(t, "POST", reqPath, map[string]string{ + "_csrf": htmlDoc.GetCSRF(), + }) + session.MakeRequest(t, req, http.StatusSeeOther) + + return nil +} diff --git a/web_src/css/user.css b/web_src/css/user.css index caabf1834cbb2..29df2a99ee934 100644 --- a/web_src/css/user.css +++ b/web_src/css/user.css @@ -134,3 +134,8 @@ .notifications-item:hover .notifications-updated { display: none; } + +.pin-repo-item { + width: calc(33.33333333333333% - 1em) !important; + margin: 0.4375em 0.5em !important; +}