Compare commits

...

16 Commits

Author SHA1 Message Date
Carlos Felgueiras
6e08f68940
Merge 805dbc7ed4 into 821d2fc2a3 2024-05-17 17:02:53 +00:00
Daniel Carvalho
805dbc7ed4 fix(transaction): changed the use of the transaction to WithTx instead of TxContext 2024-05-17 17:02:35 +00:00
Daniel Carvalho
3704d233eb fix(view): Added a missing return function 2024-05-17 16:22:54 +00:00
Daniel Carvalho
a019e443c4 fix(pin db): added a function to get the count of pinned repos 2024-05-17 16:14:04 +00:00
Daniel Carvalho
b941b04269 fix(user pin service): fixed CanPin function to return an error 2024-05-17 16:08:20 +00:00
wxiaoguang
821d2fc2a3
Simplify mirror repository API logic (#30963)
Fix #30921
2024-05-17 16:07:41 +00:00
Daniel Carvalho
21400623ec fix(user pin service): converted max number of pins to a constant 2024-05-17 15:38:26 +00:00
Carlos Felgueiras
dae3c8ab28 feat(pin): made the gap between pin card half the previous size 2024-05-15 14:53:14 +00:00
Carlos Felgueiras
c9d2f492b9 fix(pin): removed octicon-custom-pin-off and replaced the places that used it with the already present octicon-pin-slash 2024-05-15 12:47:02 +00:00
Carlos Felgueiras
088727770e
Update templates/repo/pin_unpin.tmpl
Co-authored-by: silverwind <me@silverwind.io>
2024-05-15 14:28:22 +02:00
Daniel Carvalho
5fba27cbbc fix(table name): Changed repository pins database name 2024-05-13 16:06:00 +00:00
Carlos Felgueiras
f8b17560bd fix(lint): fixed and added copyright and license comment on top of files 2024-05-13 10:44:12 +00:00
Carlos Felgueiras
0754ed7c18 test(pin): created basic tests for the pin/unpin feature
- Created unit tests that check the basic functionality of the model
  functions
- Created integration tests that check the functionality of the POST
  routes, verifying the effects directly on the database.

Co-authored-by: Daniel Carvalho <daniel.m.carvalho@tecnico.ulisboa.pt>
2024-05-10 17:37:08 +00:00
Carlos Felgueiras
e8dca1ecd4 feat(pin): implemented the pin/unpin button on repo main page
- Added the unpin octicon svg from a previous attempt to implement this
  feature.
- Added the template for the button/set of buttons for pinning/unpinning
  a repo.
- Added the use of said template in the header of a repo main page.
- Added the routes for the POST requests for pinning/unpinning to
  user/org.

Co-authored-by: Daniel Carvalho <daniel.m.carvalho@tecnico.ulisboa.pt>
2024-05-10 17:36:48 +00:00
Carlos Felgueiras
fd47c82077 feat(pin): implemented the list of pinned repositories on the profile
- Added list and count of pinned repos to the context data
  of the routes of a user and org profile.
- Added a template for the list of pinned repo cards.
- Included said template in the user and org profile templates.

Co-authored-by: Daniel Carvalho <daniel.m.carvalho@tecnico.ulisboa.pt>
2024-05-10 17:36:27 +00:00
Carlos Felgueiras
5f890b55ca feat(pin): implemented base code for pins on the backend
- Created the pin model, and basic CRUD operations.
- Implemented checks for lost of visibility of owner, to automatically
  delete the repository.
- Implemented check for the lack of visibility of a viewer, when
  requesting the repos of another user, excluding those repositories
  from the list that is returned.
- Added the deletion of all the pins of a repository when it is deleted.
- Implemented the ability for a user to pin on the profile of an org
  user, checking if the user is admin of the org, and the org is owner
  of the repo.

Co-authored-by: Daniel Carvalho <daniel.m.carvalho@tecnico.ulisboa.pt>
2024-05-10 17:35:49 +00:00
20 changed files with 579 additions and 18 deletions

61
models/repo/pin.go Normal file
View File

@ -0,0 +1,61 @@
// 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).Get(&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
})
}

48
models/repo/pin_test.go Normal file
View File

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

View File

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

View File

@ -217,7 +217,7 @@ type EditRepoOption struct {
Archived *bool `json:"archived,omitempty"`
// set to a string like `8h30m0s` to set the mirror interval time
MirrorInterval *string `json:"mirror_interval,omitempty"`
// enable prune - remove obsolete remote-tracking references
// enable prune - remove obsolete remote-tracking references when mirroring
EnablePrune *bool `json:"enable_prune,omitempty"`
}

View File

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

View File

@ -1062,16 +1062,10 @@ func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) e
func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error {
repo := ctx.Repo.Repository
// only update mirror if interval or enable prune are provided
if opts.MirrorInterval == nil && opts.EnablePrune == nil {
return nil
}
// these values only make sense if the repo is a mirror
// Skip this update if the repo is not a mirror, do not return error.
// Because reporting errors only makes the logic more complex&fragile, it doesn't really help end users.
if !repo.IsMirror {
err := fmt.Errorf("repo is not a mirror, can not change mirror interval")
ctx.Error(http.StatusUnprocessableEntity, err.Error(), err)
return err
return nil
}
// get the mirror from the repo

View File

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

View File

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

View File

@ -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"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
user_model "code.gitea.io/gitea/models/user"
@ -50,6 +51,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"
@ -791,6 +793,14 @@ func Home(ctx *context.Context) {
return
}
if ctx.IsSigned {
err := loadPinData(ctx)
if err != nil {
ctx.ServerError("loadPinData", err)
return
}
}
renderHomeCode(ctx)
}
@ -1168,3 +1178,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
}

View File

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

View File

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

169
services/user/pin.go Normal file
View File

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

View File

@ -8,6 +8,9 @@
{{if .ProfileReadme}}
<div id="readme_profile" class="markup">{{.ProfileReadme}}</div>
{{end}}
{{if .PinnedRepos}}
{{template "shared/pinned_repo_cards" .}}
{{end}}
{{template "shared/repo_search" .}}
{{template "explore/repo_list" .}}
{{template "base/paginate" .}}

View File

@ -60,6 +60,7 @@
{{svg "octicon-rss" 16}}
</a>
{{end}}
{{template "repo/pin_unpin" $}}
{{template "repo/watch_unwatch" $}}
{{if not $.DisableStars}}
{{template "repo/star_unstar" $}}

View File

@ -0,0 +1,30 @@
<div class="ui buttons">
<form hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if .IsPinningRepo}}unpin{{else}}pin{{end}}">
<div class="ui labeled item" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.pin_guest_user"}}"{{end}}>
{{$buttonText := ctx.Locale.Tr "repo.pin"}}
{{if $.IsPinningRepo}}{{$buttonText = ctx.Locale.Tr "repo.unpin"}}{{end}}
<button type="submit" class="ui compact small basic button"{{if or (not $.IsSigned) (and (not $.IsPinningRepo) (not .CanPinRepo))}} disabled{{end}} aria-label="{{$buttonText}}">
{{if $.IsPinningRepo}}{{svg "octicon-pin-slash"}}{{else}}{{svg "octicon-pin"}}{{end}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
</div>
</form>
{{if .CanUserPinToOrg}}
<div class="ui floating dropdown icon button">
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="compact menu">
<form hx-boost="true" hx-target="this" method="post" action="{{$.RepoLink}}/action/{{if .IsOrgPinningRepo}}unpin{{else}}pin{{end}}-org">
<div class="ui labeled button item" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.pin_guest_user"}}"{{end}}>
{{$buttonText = ctx.Locale.Tr "repo.pin-org" .Owner.Name}}
{{if $.IsOrgPinningRepo}}{{$buttonText = ctx.Locale.Tr "repo.unpin-org" .Owner.Name}}{{end}}
<button type="submit" class="ui compact small basic button"{{if or (not $.IsSigned) (and (not $.IsOrgPinningRepo) (not .CanOrgPinRepo))}} disabled{{end}} aria-label="{{$buttonText}}">
{{if $.IsOrgPinningRepo}}{{svg "octicon-pin-slash"}}{{else}}{{svg "octicon-pin"}}{{end}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</button>
</div>
</form>
</div>
</div>
{{end}}
</div>

View File

@ -0,0 +1,62 @@
<div class="ui three stackable cards">
{{range .PinnedRepos}}
<div class="ui card pin-repo-item">
<div class="content">
<div class="header">
<div class="flex-item tw-items-center">
<div class="flex-item-main">
<a class="name" href="{{.Link}}">
{{if or $.PageIsExplore $.PageIsProfileStarList}}{{if .Owner}}{{.Owner.Name}} / {{end}}{{end}}{{.Name}}
</a>
</div>
<div class="flex-item-trailing">
{{if .IsArchived}}
<span class="ui basic label">{{ctx.Locale.Tr "repo.desc.archived"}}</span>
{{end}}
{{if .IsTemplate}}
{{if .IsPrivate}}
<span class="ui basic label">{{ctx.Locale.Tr "repo.desc.private_template"}}</span>
{{end}}
{{else}}
{{if .IsPrivate}}
<span class="ui basic label">{{ctx.Locale.Tr "repo.desc.private"}}</span>
{{end}}
{{end}}
{{if .IsFork}}
{{svg "octicon-repo-forked"}}
{{else if .IsMirror}}
{{svg "octicon-mirror"}}
{{end}}
</div>
</div>
</div>
<div class="extra content">
<div class="metas df ac">
{{if .PrimaryLanguage}}
<span class="text grey df ac mr-3"><i class="color-icon mr-3" style="background-color: {{.PrimaryLanguage.Color}}"></i>{{.PrimaryLanguage.Language}}</span>
{{end}}
{{if not $.DisableStars}}
<span class="text grey df ac mr-3">{{svg "octicon-star" 16 "mr-3"}}{{.NumStars}}</span>
{{end}}
<span class="text grey df ac mr-3">{{svg "octicon-git-branch" 16 "mr-3"}}{{.NumForks}}</span>
</div>
</div>
<div class="description">
{{$description := .DescriptionHTML $.Context}}
{{if $description}}<p>{{$description}}</p>{{end}}
{{if .Topics}}
<div class="ui tags">
{{range .Topics}}
{{if ne . ""}}
<a href="{{AppSubUrl}}/explore/repos?q={{.}}&topic=1">
<div class="ui small label topic">{{.}}</div>
</a>
{{end}}
{{end}}
</div>
{{end}}
</div>
</div>
</div>
{{end}}
</div>

View File

@ -20753,7 +20753,7 @@
"x-go-name": "Description"
},
"enable_prune": {
"description": "enable prune - remove obsolete remote-tracking references",
"description": "enable prune - remove obsolete remote-tracking references when mirroring",
"type": "boolean",
"x-go-name": "EnablePrune"
},

View File

@ -28,6 +28,9 @@
{{else if eq .TabName "overview"}}
<div id="readme_profile" class="markup">{{.ProfileReadme}}</div>
{{else}}
{{if .PinnedRepos}}
{{template "shared/pinned_repo_cards" .}}
{{end}}
{{template "shared/repo_search" .}}
{{template "explore/repo_list" .}}
{{template "base/paginate" .}}

View File

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

View File

@ -138,3 +138,8 @@
.notifications-item:hover .notifications-updated {
display: none;
}
.pin-repo-item {
width: calc(33.33333333333333% - 1em) !important;
margin: 0.4375em 0.5em !important;
}