Skip to content

Commit aaa1094

Browse files
JakobDevsilverwindGiteaBot
authored
Add the ability to pin Issues (#24406)
This adds the ability to pin important Issues and Pull Requests. You can also move pinned Issues around to change their Position. Resolves #2175. ## Screenshots ![grafik](https://user-images.githubusercontent.com/15185051/235123207-0aa39869-bb48-45c3-abe2-ba1e836046ec.png) ![grafik](https://user-images.githubusercontent.com/15185051/235123297-152a16ea-a857-451d-9a42-61f2cd54dd75.png) ![grafik](https://user-images.githubusercontent.com/15185051/235640782-cbfe25ec-6254-479a-a3de-133e585d7a2d.png) The Design was mostly copied from the Projects Board. ## Implementation This uses a new `pin_order` Column in the `issue` table. If the value is set to 0, the Issue is not pinned. If it's set to a bigger value, the value is the Position. 1 means it's the first pinned Issue, 2 means it's the second one etc. This is dived into Issues and Pull requests for each Repo. ## TODO - [x] You can currently pin as many Issues as you want. Maybe we should add a Limit, which is configurable. GitHub uses 3, but I prefer 6, as this is better for bigger Projects, but I'm open for suggestions. - [x] Pin and Unpin events need to be added to the Issue history. - [x] Tests - [x] Migration **The feature itself is currently fully working, so tester who may find weird edge cases are very welcome!** --------- Co-authored-by: silverwind <[email protected]> Co-authored-by: Giteabot <[email protected]>
1 parent 79087bd commit aaa1094

File tree

27 files changed

+1331
-13
lines changed

27 files changed

+1331
-13
lines changed

custom/conf/app.example.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,9 @@ LEVEL = Info
10481048
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
10491049
;; List of reasons why a Pull Request or Issue can be locked
10501050
;LOCK_REASONS = Too heated,Off-topic,Resolved,Spam
1051+
;; Maximum number of pinned Issues
1052+
;; Set to 0 to disable pinning Issues
1053+
;MAX_PINNED = 3
10511054

10521055
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
10531056
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

docs/content/doc/administration/config-cheat-sheet.en-us.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ In addition there is _`StaticRootPath`_ which can be set as a built-in at build
141141
### Repository - Issue (`repository.issue`)
142142

143143
- `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked
144+
- `MAX_PINNED`: **3**: Maximum number of pinned Issues. Set to 0 to disable pinning Issues.
144145

145146
### Repository - Upload (`repository.upload`)
146147

models/issues/comment.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ const (
107107
CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
108108
CommentTypePRUnScheduledToAutoMerge // 35 pr was un scheduled to auto merge when checks succeed
109109

110+
CommentTypePin // 36 pin Issue
111+
CommentTypeUnpin // 37 unpin Issue
110112
)
111113

112114
var commentStrings = []string{
@@ -146,6 +148,8 @@ var commentStrings = []string{
146148
"change_issue_ref",
147149
"pull_scheduled_merge",
148150
"pull_cancel_scheduled_merge",
151+
"pin",
152+
"unpin",
149153
}
150154

151155
func (t CommentType) String() string {

models/issues/issue.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
repo_model "code.gitea.io/gitea/models/repo"
1515
user_model "code.gitea.io/gitea/models/user"
1616
"code.gitea.io/gitea/modules/log"
17+
"code.gitea.io/gitea/modules/setting"
1718
api "code.gitea.io/gitea/modules/structs"
1819
"code.gitea.io/gitea/modules/timeutil"
1920
"code.gitea.io/gitea/modules/util"
@@ -116,6 +117,7 @@ type Issue struct {
116117
PullRequest *PullRequest `xorm:"-"`
117118
NumComments int
118119
Ref string
120+
PinOrder int `xorm:"DEFAULT 0"`
119121

120122
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
121123

@@ -684,3 +686,180 @@ func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
684686
func (issue *Issue) HasOriginalAuthor() bool {
685687
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
686688
}
689+
690+
// IsPinned returns if a Issue is pinned
691+
func (issue *Issue) IsPinned() bool {
692+
return issue.PinOrder != 0
693+
}
694+
695+
// Pin pins a Issue
696+
func (issue *Issue) Pin(ctx context.Context, user *user_model.User) error {
697+
// If the Issue is already pinned, we don't need to pin it twice
698+
if issue.IsPinned() {
699+
return nil
700+
}
701+
702+
var maxPin int
703+
_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
704+
if err != nil {
705+
return err
706+
}
707+
708+
// Check if the maximum allowed Pins reached
709+
if maxPin >= setting.Repository.Issue.MaxPinned {
710+
return fmt.Errorf("You have reached the max number of pinned Issues")
711+
}
712+
713+
_, err = db.GetEngine(ctx).Table("issue").
714+
Where("id = ?", issue.ID).
715+
Update(map[string]interface{}{
716+
"pin_order": maxPin + 1,
717+
})
718+
if err != nil {
719+
return err
720+
}
721+
722+
// Add the pin event to the history
723+
opts := &CreateCommentOptions{
724+
Type: CommentTypePin,
725+
Doer: user,
726+
Repo: issue.Repo,
727+
Issue: issue,
728+
}
729+
if _, err = CreateComment(ctx, opts); err != nil {
730+
return err
731+
}
732+
733+
return nil
734+
}
735+
736+
// UnpinIssue unpins a Issue
737+
func (issue *Issue) Unpin(ctx context.Context, user *user_model.User) error {
738+
// If the Issue is not pinned, we don't need to unpin it
739+
if !issue.IsPinned() {
740+
return nil
741+
}
742+
743+
// This sets the Pin for all Issues that come after the unpined Issue to the correct value
744+
_, err := db.GetEngine(ctx).Exec("UPDATE issue SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ?", issue.RepoID, issue.IsPull, issue.PinOrder)
745+
if err != nil {
746+
return err
747+
}
748+
749+
_, err = db.GetEngine(ctx).Table("issue").
750+
Where("id = ?", issue.ID).
751+
Update(map[string]interface{}{
752+
"pin_order": 0,
753+
})
754+
if err != nil {
755+
return err
756+
}
757+
758+
// Add the unpin event to the history
759+
opts := &CreateCommentOptions{
760+
Type: CommentTypeUnpin,
761+
Doer: user,
762+
Repo: issue.Repo,
763+
Issue: issue,
764+
}
765+
if _, err = CreateComment(ctx, opts); err != nil {
766+
return err
767+
}
768+
769+
return nil
770+
}
771+
772+
// PinOrUnpin pins or unpins a Issue
773+
func (issue *Issue) PinOrUnpin(ctx context.Context, user *user_model.User) error {
774+
if !issue.IsPinned() {
775+
return issue.Pin(ctx, user)
776+
}
777+
778+
return issue.Unpin(ctx, user)
779+
}
780+
781+
// MovePin moves a Pinned Issue to a new Position
782+
func (issue *Issue) MovePin(ctx context.Context, newPosition int) error {
783+
// If the Issue is not pinned, we can't move them
784+
if !issue.IsPinned() {
785+
return nil
786+
}
787+
788+
if newPosition < 1 {
789+
return fmt.Errorf("The Position can't be lower than 1")
790+
}
791+
792+
dbctx, committer, err := db.TxContext(ctx)
793+
if err != nil {
794+
return err
795+
}
796+
defer committer.Close()
797+
798+
var maxPin int
799+
_, err = db.GetEngine(dbctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
800+
if err != nil {
801+
return err
802+
}
803+
804+
// If the new Position bigger than the current Maximum, set it to the Maximum
805+
if newPosition > maxPin+1 {
806+
newPosition = maxPin + 1
807+
}
808+
809+
// Lower the Position of all Pinned Issue that came after the current Position
810+
_, err = db.GetEngine(dbctx).Exec("UPDATE issue SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ?", issue.RepoID, issue.IsPull, issue.PinOrder)
811+
if err != nil {
812+
return err
813+
}
814+
815+
// Higher the Position of all Pinned Issues that comes after the new Position
816+
_, err = db.GetEngine(dbctx).Exec("UPDATE issue SET pin_order = pin_order + 1 WHERE repo_id = ? AND is_pull = ? AND pin_order >= ?", issue.RepoID, issue.IsPull, newPosition)
817+
if err != nil {
818+
return err
819+
}
820+
821+
_, err = db.GetEngine(dbctx).Table("issue").
822+
Where("id = ?", issue.ID).
823+
Update(map[string]interface{}{
824+
"pin_order": newPosition,
825+
})
826+
if err != nil {
827+
return err
828+
}
829+
830+
return committer.Commit()
831+
}
832+
833+
// GetPinnedIssues returns the pinned Issues for the given Repo and type
834+
func GetPinnedIssues(ctx context.Context, repoID int64, isPull bool) ([]*Issue, error) {
835+
issues := make([]*Issue, 0)
836+
837+
err := db.GetEngine(ctx).
838+
Table("issue").
839+
Where("repo_id = ?", repoID).
840+
And("is_pull = ?", isPull).
841+
And("pin_order > 0").
842+
OrderBy("pin_order").
843+
Find(&issues)
844+
if err != nil {
845+
return nil, err
846+
}
847+
848+
err = IssueList(issues).LoadAttributes()
849+
if err != nil {
850+
return nil, err
851+
}
852+
853+
return issues, nil
854+
}
855+
856+
// IsNewPinnedAllowed returns if a new Issue or Pull request can be pinned
857+
func IsNewPinAllowed(ctx context.Context, repoID int64, isPull bool) (bool, error) {
858+
var maxPin int
859+
_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", repoID, isPull).Get(&maxPin)
860+
if err != nil {
861+
return false, err
862+
}
863+
864+
return maxPin < setting.Repository.Issue.MaxPinned, nil
865+
}

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,8 @@ var migrations = []Migration{
493493
NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage),
494494
// v257 -> v258
495495
NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable),
496+
// v258 -> 259
497+
NewMigration("Add PinOrder Column", v1_20.AddPinOrderToIssue),
496498
}
497499

498500
// GetCurrentDBVersion returns the current db version

models/migrations/v1_20/v258.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2023 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package v1_20 //nolint
5+
6+
import (
7+
"xorm.io/xorm"
8+
)
9+
10+
func AddPinOrderToIssue(x *xorm.Engine) error {
11+
type Issue struct {
12+
PinOrder int `xorm:"DEFAULT 0"`
13+
}
14+
15+
return x.Sync(new(Issue))
16+
}

modules/setting/repository.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ var (
9090
// Issue Setting
9191
Issue struct {
9292
LockReasons []string
93+
MaxPinned int
9394
} `ini:"repository.issue"`
9495

9596
Release struct {
@@ -227,8 +228,10 @@ var (
227228
// Issue settings
228229
Issue: struct {
229230
LockReasons []string
231+
MaxPinned int
230232
}{
231233
LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
234+
MaxPinned: 3,
232235
},
233236

234237
Release: struct {

modules/structs/issue.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ type Issue struct {
7575

7676
PullRequest *PullRequestMeta `json:"pull_request"`
7777
Repo *RepositoryMeta `json:"repository"`
78+
79+
PinOrder int `json:"pin_order"`
7880
}
7981

8082
// CreateIssueOption options to create one issue

modules/structs/pull.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ type PullRequest struct {
4949
Updated *time.Time `json:"updated_at"`
5050
// swagger:strfmt date-time
5151
Closed *time.Time `json:"closed_at"`
52+
53+
PinOrder int `json:"pin_order"`
5254
}
5355

5456
// PRBranchInfo information about a branch

modules/structs/repo.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,3 +374,9 @@ type RepoTransfer struct {
374374
Recipient *User `json:"recipient"`
375375
Teams []*Team `json:"teams"`
376376
}
377+
378+
// NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed
379+
type NewIssuePinsAllowed struct {
380+
Issues bool `json:"issues"`
381+
PullRequests bool `json:"pull_requests"`
382+
}

options/locale/locale_en-US.ini

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ unknown = Unknown
115115

116116
rss_feed = RSS Feed
117117

118+
pin = Pin
119+
unpin = Unpin
120+
118121
artifacts = Artifacts
119122

120123
concept_system_global = Global
@@ -1482,6 +1485,10 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab`
14821485
issues.attachment.download = `Click to download "%s"`
14831486
issues.subscribe = Subscribe
14841487
issues.unsubscribe = Unsubscribe
1488+
issues.unpin_issue = Unpin Issue
1489+
issues.max_pinned = "You can't pin more issues"
1490+
issues.pin_comment = "pinned this %s"
1491+
issues.unpin_comment = "unpinned this %s"
14851492
issues.lock = Lock conversation
14861493
issues.unlock = Unlock conversation
14871494
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.

routers/api/v1/api.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,7 @@ func Routes(ctx gocontext.Context) *web.Route {
967967
m.Group("/issues", func() {
968968
m.Combo("").Get(repo.ListIssues).
969969
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue)
970+
m.Get("/pinned", repo.ListPinnedIssues)
970971
m.Group("/comments", func() {
971972
m.Get("", repo.ListRepoIssueComments)
972973
m.Group("/{id}", func() {
@@ -1047,6 +1048,12 @@ func Routes(ctx gocontext.Context) *web.Route {
10471048
Get(repo.GetIssueBlocks).
10481049
Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking).
10491050
Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking)
1051+
m.Group("/pin", func() {
1052+
m.Combo("").
1053+
Post(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.PinIssue).
1054+
Delete(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.UnpinIssue)
1055+
m.Patch("/{position}", reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.MoveIssuePin)
1056+
})
10501057
})
10511058
}, mustEnableIssuesOrPulls)
10521059
m.Group("/labels", func() {
@@ -1109,6 +1116,7 @@ func Routes(ctx gocontext.Context) *web.Route {
11091116
m.Group("/pulls", func() {
11101117
m.Combo("").Get(repo.ListPullRequests).
11111118
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest)
1119+
m.Get("/pinned", repo.ListPinnedPullRequests)
11121120
m.Group("/{index}", func() {
11131121
m.Combo("").Get(repo.GetPullRequest).
11141122
Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditPullRequestOption{}), repo.EditPullRequest)
@@ -1186,6 +1194,7 @@ func Routes(ctx gocontext.Context) *web.Route {
11861194
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
11871195
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
11881196
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
1197+
m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
11891198
}, repoAssignment())
11901199
})
11911200

0 commit comments

Comments
 (0)