Skip to content

Pin Repositories on user page (Fixes #10375) #19831

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 43 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e3fe53f
Add checks for pinned repositories
May 28, 2022
fa46bfd
Working pin and unpin in repository.
May 28, 2022
11faab0
Merge branch 'go-gitea:main' into feature/Pinning
Eekle May 28, 2022
9f87fd6
Fixes unpin typo
Eekle May 29, 2022
5c22473
Simplify canPin check
Eekle May 29, 2022
8464a09
Remove disabled pin button for anons
Eekle May 29, 2022
0140a49
Refactor based on advice in PR and implement cards
May 29, 2022
30a2076
Working and styled carded pinned repos
May 29, 2022
cf5ef19
Revert weird permissions changes
May 29, 2022
33c5db9
Set correct initial pinnedRepo capacity
Eekle May 29, 2022
a0f3148
Move CanPin to services
May 29, 2022
bfc0a3b
Merge branch 'feature/Pinning' of https://github.com/Eekle/gitea into…
May 29, 2022
b6d4403
Pin/Unpin takes batch args. Max limit imposed.
May 29, 2022
0cfd468
Adds better error reporting when too many repos are pinned.
May 29, 2022
ef80b11
Add signin check to pin button
Eekle May 30, 2022
ca30e0c
Tidier empty array code
Eekle May 30, 2022
4e2e304
Merge branch 'go-gitea:main' into feature/Pinning
Eekle May 30, 2022
d7cc870
Linter pleasing
Eekle May 30, 2022
2e94d4e
Linter pleasing
Eekle May 30, 2022
65c1c26
Copyright typo
Eekle May 30, 2022
3bcfaa2
Better logs for pinning repos without permission
Eekle May 30, 2022
f0e8a25
Topics and metas at bottom of pin card
Eekle May 30, 2022
8be963a
Move pinned_repos.tmpl to shared. Reverts whitespace issues in home.tmpl
Eekle May 30, 2022
f489c68
Remove Pinned option in repo search and associated IsPinned check
Eekle May 30, 2022
6cbdb44
Pinned repos on user profiles
Eekle May 30, 2022
0d11561
Add docs to pin funcs
Eekle May 31, 2022
94396a6
make fmt
Eekle May 31, 2022
3ee1ade
Backend lint fixes
Eekle May 31, 2022
22f2956
Document GetPinnedRepositoryIDs
Eekle May 31, 2022
332c8fa
Merge branch 'main' into feature/Pinning
Eekle Jun 1, 2022
9eeb53b
Add tests for pinning
Jun 1, 2022
16a9906
Make fmt
Eekle Jun 1, 2022
5655553
Pin button correctly rounded and unpin svg
Eekle Jun 2, 2022
d8d7a40
Fix svg formatting
Eekle Jun 2, 2022
70274d5
Fix svg issues with frontend check
Eekle Jun 2, 2022
791122a
Merge branch 'main' into feature/Pinning
Eekle Jun 2, 2022
3e566e0
Change pin access to org owners, not repo admins
Eekle Jun 3, 2022
6e65d34
Merge branch 'feature/Pinning' of github.com:Eekle/gitea into feature…
Eekle Jun 3, 2022
0e50a7c
Fix backend linting
Eekle Jun 3, 2022
58f5754
Fix admin pin permission
Eekle Jun 3, 2022
cb10529
Typo in canPin
Eekle Jun 3, 2022
826d921
Merge branch 'main' into feature/Pinning
Eekle Jun 14, 2022
b2a7270
Apply suggestions from code review - Delvh
Eekle Oct 9, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions models/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,20 @@ func (repo *Repository) IsBroken() bool {
return repo.Status == RepositoryBroken
}

// IsPinned indicates that repository is pinned
func (repo *Repository) IsPinned() bool {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the function should be IsPinned(userID int64). One could pin another public repository?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A repo is either pinned to its owner's profile, or not at all.

This is maybe the same confusion I had with @delvh ? This is not an implementation for pinning any repo you have access to to your profile - it's an implementation for pinning a repo to its owner's profile.

Copy link

@ell1e ell1e Oct 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, the most interesting thing I happen to work on is in an org I own, not in my user namespace. So I couldn't pin that then? I'd assume that's a common use case, but maybe that's just me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like that's everyone's else's view too. This may need reimplementing with that in mind.

pinned, err := user_model.GetPinnedRepositoryIDs(repo.OwnerID)
if err != nil {
return false
}
for _, r := range pinned {
if r == repo.ID {
return true
}
}
return false
}

// AfterLoad is invoked from XORM after setting the values of all fields of this object.
func (repo *Repository) AfterLoad() {
// FIXME: use models migration to solve all at once.
Expand Down
105 changes: 105 additions & 0 deletions models/user/pin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package user

import (
"fmt"

"code.gitea.io/gitea/modules/json"
)

const maxPinnedRepos = 3

// GetPinnedRepositoryIDs returns all the repository IDs pinned by the given user or org.
// If they've never pinned a repository, an empty array is returned.
func GetPinnedRepositoryIDs(ownerID int64) ([]int64, error) {
pinnedstring, err := GetUserSetting(ownerID, PinnedRepositories)
if err != nil {
return nil, err
}

var parsedValues []int64
if pinnedstring == "" {
return parsedValues, nil
}

err = json.Unmarshal([]byte(pinnedstring), &parsedValues)

if err != nil {
return nil, err
}

return parsedValues, nil
}

func setPinnedRepositories(ownerID int64, repos []int64) error {
stringed, err := json.Marshal(repos)
if err != nil {
return err
}

return SetUserSetting(ownerID, PinnedRepositories, string(stringed))
}

type TooManyPinnedReposError struct {
count int
}

func (e *TooManyPinnedReposError) Error() string {
return fmt.Sprintf("can pin at most %d repositories, %d pinned repositories is too much", maxPinnedRepos, e.count)
}

// PinRepos pin the specified repos for the given user or organization.
// The caller must ensure all repos belong to the owner.
func PinRepos(ownerID int64, repoIDs ...int64) error {
repos, err := GetPinnedRepositoryIDs(ownerID)
if err != nil {
return err
}
newrepos := make([]int64, 0, len(repoIDs)+len(repos))

repos = append(repos, repoIDs...)

for _, toadd := range repos {
alreadypresent := false
for _, present := range newrepos {
if toadd == present {
alreadypresent = true
break
}
}
if !alreadypresent {
newrepos = append(newrepos, toadd)
}
}
if len(newrepos) > maxPinnedRepos {
return &TooManyPinnedReposError{count: len(newrepos)}
}
return setPinnedRepositories(ownerID, newrepos)
}

// UnpinRepos unpin the given repositories for the given user or organization
func UnpinRepos(ownerID int64, repoIDs ...int64) error {
prevRepos, err := GetPinnedRepositoryIDs(ownerID)
if err != nil {
return err
}
var nextRepos []int64

for _, r := range prevRepos {
keep := true
for _, unp := range repoIDs {
if r == unp {
keep = false
break
}
}
if keep {
nextRepos = append(nextRepos, r)
}
}

return setPinnedRepositories(ownerID, nextRepos)
}
37 changes: 37 additions & 0 deletions models/user/pin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package user

import (
"testing"

"code.gitea.io/gitea/models/unittest"

"github.com/stretchr/testify/assert"
)

func TestPinAndUnpinRepos(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// User:2 pins repositories 1 and 2
userID, repoID1, repoID2 := 2, 1, 2
{
assert.NoError(t, PinRepos(userID, repoID1, repoID2))
pinned, err := GetPinnedRepositoryIDs(userID)

assert.NoError(t, err)
expected := []int64{repoID1, repoID2}
assert.Equal(t, pinned, expected)
}
// User 2 unpins repository 2, leaving just 1
{
assert.NoError(t, UnpinRepos(userID, repoID2))

pinned, err := GetPinnedRepositoryIDs(userID)

assert.NoError(t, err)
expected := []int64{repoID1}
assert.Equal(t, pinned, expected)
}
}
1 change: 1 addition & 0 deletions models/user/setting_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ const (
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
// SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
PinnedRepositories = "pinned_repos"
)
1 change: 1 addition & 0 deletions public/img/svg/octicon-custom-pin-off.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions routers/web/org/home.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import (

"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/organization"
access "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/modules/base"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting"
Expand Down Expand Up @@ -149,8 +152,41 @@ func Home(ctx *context.Context) {
return
}

pinnedRepoIDs, err := user_model.GetPinnedRepositoryIDs(org.ID)
if err != nil {
ctx.ServerError("GetPinnedRepositoryIDs", err)
return
}
pinnedRepos := make([]*repo_model.Repository, 0, len(pinnedRepoIDs))
for _, id := range pinnedRepoIDs {
repo, err := repo_model.GetRepositoryByID(id)
if err != nil {
ctx.ServerError("GetRepositoryByID", err)
return
}
if err = repo.LoadAttributes(ctx); err != nil {
ctx.ServerError("LoadAttributes", err)
return
}
if repo.OwnerID != org.ID {
log.Warn("Ignoring pinned repo %v because it's not owned by %v", repo.FullName(), org.Name)
} else {
perm, err := access.GetUserRepoPermission(ctx, repo, ctx.Doer)
if err != nil {
ctx.ServerError("GetUserRepoPermission", err)
return
}
if !perm.HasAccess() {
log.Info("Ignoring pinned repo %v because user %v has no access to it.", repo.FullName(), ctx.Doer)
} else {
pinnedRepos = append(pinnedRepos, repo)
}
}
}

ctx.Data["Owner"] = org
ctx.Data["Repos"] = repos
ctx.Data["PinnedRepos"] = pinnedRepos
ctx.Data["Total"] = count
ctx.Data["MembersTotal"] = membersCount
ctx.Data["Members"] = members
Expand Down
121 changes: 121 additions & 0 deletions routers/web/repo/pin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package repo

import (
"testing"

"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/test"

"github.com/stretchr/testify/assert"
)

const (
pin = true
unpin = false
)

func TestUserPinUnpin(t *testing.T) {
unittest.PrepareTestEnv(t)
// These test cases run sequentially since they modify state
testcases := []struct {
uid int64
rid int64
action bool
endstate bool
failmesssage string
}{
{
uid: 2,
rid: 2,
action: pin,
endstate: pin,
failmesssage: "user cannot pin repos they own",
},
{
uid: 2,
rid: 2,
action: unpin,
endstate: unpin,
failmesssage: "user cannot unpin repos they own",
},

{
uid: 2,
rid: 5,
action: pin,
endstate: pin,
failmesssage: "user cannot pin repos they have admin access to",
},
{
uid: 2,
rid: 5,
action: unpin,
endstate: unpin,
failmesssage: "user cannot unpin repos they have admin access to",
},

{
uid: 2,
rid: 4,
action: pin,
endstate: unpin,
failmesssage: "user can pin repos they don't have access to",
},

{
uid: 5,
rid: 4,
action: pin,
endstate: pin,
failmesssage: "user cannot pin repos they own (this should never fail)",
},
{
uid: 2,
rid: 4,
action: unpin,
endstate: pin,
failmesssage: "user can unpin repos they don't have access to",
},
{
uid: 1,
rid: 4,
action: unpin,
endstate: unpin,
failmesssage: "admin can't unpin repos",
},
{
uid: 1,
rid: 4,
action: pin,
endstate: pin,
failmesssage: "admin can't pin repos",
},
}

for _, c := range testcases {
ctx := test.MockContext(t, "")
test.LoadUser(t, ctx, c.uid)
test.LoadRepo(t, ctx, c.rid)

switch c.action {
case pin:
ctx.SetParams(":action", "pin")
case unpin:
ctx.SetParams(":action", "unpin")
}

Action(ctx)
ispinned := getRepository(ctx, c.rid).IsPinned()

assert.Equal(t, ispinned, c.endstate, c.failmesssage)

if c.endstate != ispinned {
// We have to stop at first failure, state won't be coherent afterwards.
return
}
}
}
17 changes: 17 additions & 0 deletions routers/web/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"code.gitea.io/gitea/services/forms"
repo_service "code.gitea.io/gitea/services/repository"
archiver_service "code.gitea.io/gitea/services/repository/archiver"
user_service "code.gitea.io/gitea/services/user"
)

const (
Expand Down Expand Up @@ -292,6 +293,22 @@ func Action(ctx *context.Context) {
err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, true)
case "unstar":
err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, false)
case "pin":
if user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) {
err = user_model.PinRepos(ctx.Repo.Owner.ID, ctx.Repo.Repository.ID)
if _, ok := err.(*user_model.TooManyPinnedReposError); ok {
ctx.Error(http.StatusBadRequest, err.Error())
return
}
} else {
err = errors.New("user does not have permission to pin")
}
case "unpin":
if user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository) {
err = user_model.UnpinRepos(ctx.Repo.Owner.ID, ctx.Repo.Repository.ID)
} else {
err = errors.New("user does not have permission to unpin")
}
case "accept_transfer":
err = acceptOrRejectRepoTransfer(ctx, true)
case "reject_transfer":
Expand Down
2 changes: 2 additions & 0 deletions routers/web/repo/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import (
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/routers/web/feed"
user_service "code.gitea.io/gitea/services/user"
)

const (
Expand Down Expand Up @@ -733,6 +734,7 @@ func Home(ctx *context.Context) {
}

ctx.Data["FeedURL"] = ctx.Repo.Repository.HTMLURL()
ctx.Data["CanPinRepos"] = ctx.IsSigned && user_service.CanPin(ctx, ctx.Doer, ctx.Repo.Repository)

checkHomeCodeViewable(ctx)
if ctx.Written() {
Expand Down
Loading