feat: Pin repos to user profile or organization
Some checks failed
Build and Release / Create Release (push) Has been skipped
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 1m33s
Build and Release / Lint (push) Failing after 1m54s
Build and Release / Build Binaries (amd64, darwin) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin) (push) Has been skipped
Build and Release / Build Binaries (arm64, linux) (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 2m4s

- Add UserPinnedRepo model for pinning repos to user profiles
- Add Pin dropdown in repo header with options for profile/org
- Add pin/unpin routes and handlers
- Update user profile to show pinned repos with nice cards
- User overview tab always visible (like org overview)
- Shows empty state with instructions when no pinned repos
- Limit of 6 pinned repos per user
- Org members can pin repos to organization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
David H Friedel Jr 2026-01-11 02:09:07 +00:00
parent 5b0442d357
commit 1986d90df0
10 changed files with 429 additions and 18 deletions

137
models/user/user_pinned.go Normal file
View File

@ -0,0 +1,137 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package user
import (
"context"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/timeutil"
)
// UserPinnedRepo represents a pinned repository for a user's profile
type UserPinnedRepo struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
DisplayOrder int `xorm:"DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
Repo any `xorm:"-"` // Will be loaded by caller to avoid import cycle
}
// TableName returns the table name for UserPinnedRepo
func (p *UserPinnedRepo) TableName() string {
return "user_pinned_repo"
}
func init() {
db.RegisterModel(new(UserPinnedRepo))
}
// MaxUserPinnedRepos is the maximum number of repos a user can pin
const MaxUserPinnedRepos = 6
// GetUserPinnedRepos returns all pinned repos for a user
func GetUserPinnedRepos(ctx context.Context, userID int64) ([]*UserPinnedRepo, error) {
pinnedRepos := make([]*UserPinnedRepo, 0, MaxUserPinnedRepos)
err := db.GetEngine(ctx).
Where("user_id = ?", userID).
OrderBy("display_order ASC, id ASC").
Find(&pinnedRepos)
return pinnedRepos, err
}
// CountUserPinnedRepos returns the count of pinned repos for a user
func CountUserPinnedRepos(ctx context.Context, userID int64) (int64, error) {
return db.GetEngine(ctx).Where("user_id = ?", userID).Count(new(UserPinnedRepo))
}
// IsRepoPinnedByUser checks if a repo is pinned by a user
func IsRepoPinnedByUser(ctx context.Context, userID, repoID int64) (bool, error) {
return db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Exist(new(UserPinnedRepo))
}
// PinRepoToUser pins a repo to a user's profile
func PinRepoToUser(ctx context.Context, userID, repoID int64) error {
// Check if already pinned
exists, err := IsRepoPinnedByUser(ctx, userID, repoID)
if err != nil {
return err
}
if exists {
return nil // Already pinned
}
// Check max limit
count, err := CountUserPinnedRepos(ctx, userID)
if err != nil {
return err
}
if count >= MaxUserPinnedRepos {
return ErrUserPinnedRepoLimit{UserID: userID, Limit: MaxUserPinnedRepos}
}
// Get next display order
var maxOrder int
_, err = db.GetEngine(ctx).
Table("user_pinned_repo").
Where("user_id = ?", userID).
Select("COALESCE(MAX(display_order), 0)").
Get(&maxOrder)
if err != nil {
return err
}
pinnedRepo := &UserPinnedRepo{
UserID: userID,
RepoID: repoID,
DisplayOrder: maxOrder + 1,
}
_, err = db.GetEngine(ctx).Insert(pinnedRepo)
return err
}
// UnpinRepoFromUser unpins a repo from a user's profile
func UnpinRepoFromUser(ctx context.Context, userID, repoID int64) error {
_, err := db.GetEngine(ctx).Where("user_id = ? AND repo_id = ?", userID, repoID).Delete(new(UserPinnedRepo))
return err
}
// UpdateUserPinnedRepoOrder updates the display order of pinned repos
func UpdateUserPinnedRepoOrder(ctx context.Context, userID int64, repoIDs []int64) error {
for i, repoID := range repoIDs {
_, err := db.GetEngine(ctx).
Where("user_id = ? AND repo_id = ?", userID, repoID).
Cols("display_order").
Update(&UserPinnedRepo{DisplayOrder: i})
if err != nil {
return err
}
}
return nil
}
// DeleteUserPinnedReposByRepoID deletes all pins for a repo (when repo is deleted)
func DeleteUserPinnedReposByRepoID(ctx context.Context, repoID int64) error {
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(UserPinnedRepo))
return err
}
// ErrUserPinnedRepoLimit represents an error when user has reached pin limit
type ErrUserPinnedRepoLimit struct {
UserID int64
Limit int
}
func (err ErrUserPinnedRepoLimit) Error() string {
return "user has reached the maximum number of pinned repositories"
}
// IsErrUserPinnedRepoLimit checks if error is ErrUserPinnedRepoLimit
func IsErrUserPinnedRepoLimit(err error) bool {
_, ok := err.(ErrUserPinnedRepoLimit)
return ok
}

View File

@ -3791,5 +3791,26 @@
"org.profile_readme_empty_desc": "Create a .profile repository with a README.md to introduce your organization.",
"org.create_profile_repo": "Create Profile Repository",
"org.activity": "Activity",
"org.repositories": "Repositories"
"org.repositories": "Repositories",
"repo.pin": "Pin",
"repo.pin.tooltip": "Pin this repository",
"repo.pin.pin_to_profile": "Pin to your profile",
"repo.pin.unpin_from_profile": "Unpin from profile",
"repo.pin.pin_to_org": "Pin to organization",
"repo.pin.unpin_from_org": "Unpin from organization",
"repo.pin.success_profile": "Repository pinned to your profile",
"repo.pin.success_org": "Repository pinned to organization",
"repo.pin.unpin_success_profile": "Repository unpinned from your profile",
"repo.pin.unpin_success_org": "Repository unpinned from organization",
"repo.pin.already_pinned_org": "Repository is already pinned",
"repo.pin.error_limit": "You have reached the maximum number of pinned repositories (6)",
"repo.pin.error_org_limit": "Organization has reached the maximum number of pinned repositories",
"repo.pin.error_not_org": "This repository does not belong to an organization",
"repo.pin.error_not_member": "You must be a member of the organization to pin repositories",
"repo.pin.error_generic": "Failed to update pin status",
"repo.pin.error_invalid_type": "Invalid pin type",
"user.pinned_repos": "Pinned Repositories",
"user.pinned_repos_hint": "Pin repos from the repo page",
"user.pinned_repos_empty_title": "No pinned repositories",
"user.pinned_repos_empty_desc": "Pin repositories to showcase your best work. Visit a repository and use the Pin dropdown."
}

128
routers/web/repo/pin.go Normal file
View File

@ -0,0 +1,128 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/services/context"
)
// Pin handles pinning a repo to user profile or organization
func Pin(ctx *context.Context) {
pinType := ctx.FormString("type")
redirectTo := ctx.FormString("redirect_to")
if redirectTo == "" {
redirectTo = ctx.Repo.RepoLink
}
switch pinType {
case "user":
if err := user_model.PinRepoToUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil {
if user_model.IsErrUserPinnedRepoLimit(err) {
ctx.Flash.Error(ctx.Tr("repo.pin.error_limit"))
} else {
log.Error("PinRepoToUser failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
}
} else {
ctx.Flash.Success(ctx.Tr("repo.pin.success_profile"))
}
case "org":
if !ctx.Repo.Repository.Owner.IsOrganization() {
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_org"))
ctx.Redirect(redirectTo)
return
}
// Check if user is a member of the org
isMember, err := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID)
if err != nil {
log.Error("IsOrganizationMember failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
ctx.Redirect(redirectTo)
return
}
if !isMember {
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_member"))
ctx.Redirect(redirectTo)
return
}
// Use CreateOrgPinnedRepo
pinnedRepo := &organization.OrgPinnedRepo{
OrgID: ctx.Repo.Repository.OwnerID,
RepoID: ctx.Repo.Repository.ID,
}
if err := organization.CreateOrgPinnedRepo(ctx, pinnedRepo); err != nil {
if _, ok := err.(organization.ErrOrgPinnedRepoAlreadyExist); ok {
ctx.Flash.Info(ctx.Tr("repo.pin.already_pinned_org"))
} else {
log.Error("CreateOrgPinnedRepo failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
}
} else {
ctx.Flash.Success(ctx.Tr("repo.pin.success_org"))
}
default:
ctx.Flash.Error(ctx.Tr("repo.pin.error_invalid_type"))
}
ctx.Redirect(redirectTo)
}
// Unpin handles unpinning a repo from user profile or organization
func Unpin(ctx *context.Context) {
pinType := ctx.FormString("type")
redirectTo := ctx.FormString("redirect_to")
if redirectTo == "" {
redirectTo = ctx.Repo.RepoLink
}
switch pinType {
case "user":
if err := user_model.UnpinRepoFromUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID); err != nil {
log.Error("UnpinRepoFromUser failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
} else {
ctx.Flash.Success(ctx.Tr("repo.pin.unpin_success_profile"))
}
case "org":
if !ctx.Repo.Repository.Owner.IsOrganization() {
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_org"))
ctx.Redirect(redirectTo)
return
}
// Check if user is a member of the org
isMember, err := organization.IsOrganizationMember(ctx, ctx.Repo.Repository.OwnerID, ctx.Doer.ID)
if err != nil {
log.Error("IsOrganizationMember failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
ctx.Redirect(redirectTo)
return
}
if !isMember {
ctx.Flash.Error(ctx.Tr("repo.pin.error_not_member"))
ctx.Redirect(redirectTo)
return
}
if err := organization.DeleteOrgPinnedRepo(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID); err != nil {
log.Error("DeleteOrgPinnedRepo failed: %v", err)
ctx.Flash.Error(ctx.Tr("repo.pin.error_generic"))
} else {
ctx.Flash.Success(ctx.Tr("repo.pin.unpin_success_org"))
}
default:
ctx.Flash.Error(ctx.Tr("repo.pin.error_invalid_type"))
}
ctx.Redirect(redirectTo)
}

View File

@ -79,15 +79,10 @@ func userProfile(ctx *context.Context) {
}
func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.Repository, profileReadme *git.Blob) {
// if there is a profile readme, default to "overview" page, otherwise, default to "repositories" page
// if there is not a profile readme, the overview tab should be treated as the repositories tab
// Default to overview page for users
tab := ctx.FormString("tab")
if tab == "" || tab == "overview" {
if profileReadme != nil {
tab = "overview"
} else {
tab = "repositories"
}
if tab == "" {
tab = "overview"
}
ctx.Data["TabName"] = tab
ctx.Data["HasUserProfileReadme"] = profileReadme != nil
@ -252,16 +247,35 @@ func prepareUserProfileTabData(ctx *context.Context, profileDbRepo *repo_model.R
total = int(count)
case "overview":
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("failed to GetBlobContent: %v", err)
// Load pinned repositories
pinnedRepos, err := user_model.GetUserPinnedRepos(ctx, ctx.ContextUser.ID)
if err != nil {
log.Error("GetUserPinnedRepos: %v", err)
} else {
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
})
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
log.Error("failed to RenderString: %v", err)
// Load repo details for each pinned repo
for _, p := range pinnedRepos {
repo, err := repo_model.GetRepositoryByID(ctx, p.RepoID)
if err == nil {
p.Repo = repo
}
}
}
ctx.Data["UserPinnedRepos"] = pinnedRepos
ctx.Data["IsContextUserProfile"] = ctx.Doer != nil && ctx.Doer.ID == ctx.ContextUser.ID
// Load profile README
if profileReadme != nil {
if bytes, err := profileReadme.GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("failed to GetBlobContent: %v", err)
} else {
ctx.Data["ProfileReadmeContent"] = profileContent
rctx := renderhelper.NewRenderContextRepoFile(ctx, profileDbRepo, renderhelper.RepoFileOptions{
CurrentRefPath: path.Join("branch", util.PathEscapeSegments(profileDbRepo.DefaultBranch)),
})
if profileContent, err := markdown.RenderString(rctx, bytes); err != nil {
log.Error("failed to RenderString: %v", err)
} else {
ctx.Data["ProfileReadmeContent"] = profileContent
}
}
}
case "organizations":

View File

@ -1710,6 +1710,8 @@ func registerWebRoutes(m *web.Router) {
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch)
m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
m.Get("/action/pin", reqSignIn, repo.Pin)
m.Get("/action/unpin", reqSignIn, repo.Unpin)
}, optSignIn, context.RepoAssignment)
common.AddOwnerRepoGitLFSRoutes(m, lfsServerEnabled, repo.CorsHandler(), optSignInFromAnyOrigin) // "/{username}/{reponame}/{lfs-paths}": git-lfs support, see also addOwnerRepoGitHTTPRouters

View File

@ -19,6 +19,7 @@ import (
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
"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"
@ -415,6 +416,16 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) {
ctx.Repo.Repository = repo
ctx.Data["RepoName"] = ctx.Repo.Repository.Name
// Check if repo is pinned (for pin dropdown)
if ctx.Doer != nil {
isPinnedToUser, _ := user_model.IsRepoPinnedByUser(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
ctx.Data["IsRepoPinnedToUser"] = isPinnedToUser
}
if ctx.Repo.Repository.Owner.IsOrganization() {
isPinnedToOrg, _ := organization.IsRepoPinned(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.ID)
ctx.Data["IsRepoPinnedToOrg"] = isPinnedToOrg
}
ctx.Data["IsEmptyRepo"] = ctx.Repo.Repository.IsEmpty
}

View File

@ -65,6 +65,7 @@
{{if not $.DisableStars}}
{{template "repo/star_unstar" $}}
{{end}}
{{template "repo/pin_unpin" $}}
{{if and (not .IsEmpty) ($.Permission.CanRead ctx.Consts.RepoUnitTypeCode)}}
<div class="ui labeled button
{{if or (not $.IsSigned) (and (not $.CanSignedUserFork) (not $.UserAndOrgForks))}}

View File

@ -0,0 +1,33 @@
{{if $.IsSigned}}
<div class="ui labeled button" id="pin-repo-dropdown">
<div class="ui compact small basic button dropdown" data-tooltip-content="{{ctx.Locale.Tr "repo.pin.tooltip"}}">
{{svg "octicon-pin" 16}}<span class="text not-mobile">{{ctx.Locale.Tr "repo.pin"}}</span>
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu">
{{/* Pin to user profile */}}
{{if $.IsRepoPinnedToUser}}
<a class="item" href="{{$.RepoLink}}/action/unpin?type=user&redirect_to={{$.Link}}">
{{svg "octicon-pin-slash" 16}} {{ctx.Locale.Tr "repo.pin.unpin_from_profile"}}
</a>
{{else}}
<a class="item" href="{{$.RepoLink}}/action/pin?type=user&redirect_to={{$.Link}}">
{{svg "octicon-person" 16}} {{ctx.Locale.Tr "repo.pin.pin_to_profile"}}
</a>
{{end}}
{{/* Pin to organization (if applicable) */}}
{{if and .Repository.Owner.IsOrganization $.IsOrganizationMember}}
{{if $.IsRepoPinnedToOrg}}
<a class="item" href="{{$.RepoLink}}/action/unpin?type=org&redirect_to={{$.Link}}">
{{svg "octicon-pin-slash" 16}} {{ctx.Locale.Tr "repo.pin.unpin_from_org"}}
</a>
{{else}}
<a class="item" href="{{$.RepoLink}}/action/pin?type=org&redirect_to={{$.Link}}">
{{svg "octicon-organization" 16}} {{ctx.Locale.Tr "repo.pin.pin_to_org"}}
</a>
{{end}}
{{end}}
</div>
</div>
</div>
{{end}}

View File

@ -1,6 +1,6 @@
<overflow-menu class="ui secondary pointing tabular borderless menu">
<div class="overflow-menu-items">
{{if and .HasUserProfileReadme .ContextUser.IsIndividual}}
{{if .ContextUser.IsIndividual}}
<a class="{{if eq .TabName "overview"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=overview">
{{svg "octicon-info"}} {{ctx.Locale.Tr "user.overview"}}
</a>

View File

@ -26,7 +26,71 @@
{{else if eq .TabName "followers"}}
{{template "repo/user_cards" .}}
{{else if eq .TabName "overview"}}
{{/* Pinned Repositories Section */}}
{{if or .UserPinnedRepos .IsContextUserProfile}}
<div class="ui segment pinned-repos-section tw-mb-4">
<h4 class="ui header tw-flex tw-items-center">
{{svg "octicon-pin" 16}} {{ctx.Locale.Tr "user.pinned_repos"}}
{{if .IsContextUserProfile}}
<span class="tw-ml-auto text grey tw-text-sm">{{ctx.Locale.Tr "user.pinned_repos_hint"}}</span>
{{end}}
</h4>
{{if .UserPinnedRepos}}
<div class="ui three stackable cards pinned-repos">
{{range .UserPinnedRepos}}
{{if .Repo}}
<a class="ui card" href="{{.Repo.Link}}">
<div class="content">
<div class="header text truncate">
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 16}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 16}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 16}}{{else}}{{svg "octicon-repo" 16}}{{end}}
{{if ne .Repo.OwnerID $.ContextUser.ID}}
<span class="text grey">{{.Repo.Owner.Name}}/</span>
{{end}}
{{.Repo.Name}}
</div>
{{if .Repo.Description}}
<div class="description text truncate">{{.Repo.Description}}</div>
{{end}}
</div>
<div class="extra content">
{{if .Repo.PrimaryLanguage}}
<span class="tw-mr-2">
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
{{.Repo.PrimaryLanguage.Language}}
</span>
{{end}}
{{if .Repo.NumStars}}
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
{{end}}
{{if .Repo.NumForks}}
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
{{end}}
</div>
</a>
{{end}}
{{end}}
</div>
{{else if .IsContextUserProfile}}
<div class="ui placeholder segment tw-text-center">
<div class="ui icon header">
{{svg "octicon-pin" 32}}
<div class="content">
{{ctx.Locale.Tr "user.pinned_repos_empty_title"}}
<div class="sub header">
{{ctx.Locale.Tr "user.pinned_repos_empty_desc"}}
</div>
</div>
</div>
</div>
{{end}}
</div>
{{end}}
{{/* Profile README */}}
{{if .ProfileReadmeContent}}
<div id="readme_profile" class="render-content markup">{{.ProfileReadmeContent}}</div>
{{end}}
{{else if eq .TabName "organizations"}}
{{template "repo/user_cards" .}}
{{else}}