Skip to content

Commit

Permalink
Add the ability to pin Issues (#24406)
Browse files Browse the repository at this point in the history
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 <me@silverwind.io>
Co-authored-by: Giteabot <teabot@gitea.io>
  • Loading branch information
3 people authored May 25, 2023
1 parent 79087bd commit aaa1094
Show file tree
Hide file tree
Showing 27 changed files with 1,331 additions and 13 deletions.
3 changes: 3 additions & 0 deletions custom/conf/app.example.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,9 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; List of reasons why a Pull Request or Issue can be locked
;LOCK_REASONS = Too heated,Off-topic,Resolved,Spam
;; Maximum number of pinned Issues
;; Set to 0 to disable pinning Issues
;MAX_PINNED = 3

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ In addition there is _`StaticRootPath`_ which can be set as a built-in at build
### Repository - Issue (`repository.issue`)

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

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

Expand Down
4 changes: 4 additions & 0 deletions models/issues/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ const (
CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
CommentTypePRUnScheduledToAutoMerge // 35 pr was un scheduled to auto merge when checks succeed

CommentTypePin // 36 pin Issue
CommentTypeUnpin // 37 unpin Issue
)

var commentStrings = []string{
Expand Down Expand Up @@ -146,6 +148,8 @@ var commentStrings = []string{
"change_issue_ref",
"pull_scheduled_merge",
"pull_cancel_scheduled_merge",
"pin",
"unpin",
}

func (t CommentType) String() string {
Expand Down
179 changes: 179 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
Expand Down Expand Up @@ -116,6 +117,7 @@ type Issue struct {
PullRequest *PullRequest `xorm:"-"`
NumComments int
Ref string
PinOrder int `xorm:"DEFAULT 0"`

DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`

Expand Down Expand Up @@ -684,3 +686,180 @@ func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
func (issue *Issue) HasOriginalAuthor() bool {
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
}

// IsPinned returns if a Issue is pinned
func (issue *Issue) IsPinned() bool {
return issue.PinOrder != 0
}

// Pin pins a Issue
func (issue *Issue) Pin(ctx context.Context, user *user_model.User) error {
// If the Issue is already pinned, we don't need to pin it twice
if issue.IsPinned() {
return nil
}

var maxPin int
_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
if err != nil {
return err
}

// Check if the maximum allowed Pins reached
if maxPin >= setting.Repository.Issue.MaxPinned {
return fmt.Errorf("You have reached the max number of pinned Issues")
}

_, err = db.GetEngine(ctx).Table("issue").
Where("id = ?", issue.ID).
Update(map[string]interface{}{
"pin_order": maxPin + 1,
})
if err != nil {
return err
}

// Add the pin event to the history
opts := &CreateCommentOptions{
Type: CommentTypePin,
Doer: user,
Repo: issue.Repo,
Issue: issue,
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}

return nil
}

// UnpinIssue unpins a Issue
func (issue *Issue) Unpin(ctx context.Context, user *user_model.User) error {
// If the Issue is not pinned, we don't need to unpin it
if !issue.IsPinned() {
return nil
}

// This sets the Pin for all Issues that come after the unpined Issue to the correct value
_, 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)
if err != nil {
return err
}

_, err = db.GetEngine(ctx).Table("issue").
Where("id = ?", issue.ID).
Update(map[string]interface{}{
"pin_order": 0,
})
if err != nil {
return err
}

// Add the unpin event to the history
opts := &CreateCommentOptions{
Type: CommentTypeUnpin,
Doer: user,
Repo: issue.Repo,
Issue: issue,
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}

return nil
}

// PinOrUnpin pins or unpins a Issue
func (issue *Issue) PinOrUnpin(ctx context.Context, user *user_model.User) error {
if !issue.IsPinned() {
return issue.Pin(ctx, user)
}

return issue.Unpin(ctx, user)
}

// MovePin moves a Pinned Issue to a new Position
func (issue *Issue) MovePin(ctx context.Context, newPosition int) error {
// If the Issue is not pinned, we can't move them
if !issue.IsPinned() {
return nil
}

if newPosition < 1 {
return fmt.Errorf("The Position can't be lower than 1")
}

dbctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()

var maxPin int
_, err = db.GetEngine(dbctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
if err != nil {
return err
}

// If the new Position bigger than the current Maximum, set it to the Maximum
if newPosition > maxPin+1 {
newPosition = maxPin + 1
}

// Lower the Position of all Pinned Issue that came after the current Position
_, 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)
if err != nil {
return err
}

// Higher the Position of all Pinned Issues that comes after the new Position
_, 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)
if err != nil {
return err
}

_, err = db.GetEngine(dbctx).Table("issue").
Where("id = ?", issue.ID).
Update(map[string]interface{}{
"pin_order": newPosition,
})
if err != nil {
return err
}

return committer.Commit()
}

// GetPinnedIssues returns the pinned Issues for the given Repo and type
func GetPinnedIssues(ctx context.Context, repoID int64, isPull bool) ([]*Issue, error) {
issues := make([]*Issue, 0)

err := db.GetEngine(ctx).
Table("issue").
Where("repo_id = ?", repoID).
And("is_pull = ?", isPull).
And("pin_order > 0").
OrderBy("pin_order").
Find(&issues)
if err != nil {
return nil, err
}

err = IssueList(issues).LoadAttributes()
if err != nil {
return nil, err
}

return issues, nil
}

// IsNewPinnedAllowed returns if a new Issue or Pull request can be pinned
func IsNewPinAllowed(ctx context.Context, repoID int64, isPull bool) (bool, error) {
var maxPin int
_, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", repoID, isPull).Get(&maxPin)
if err != nil {
return false, err
}

return maxPin < setting.Repository.Issue.MaxPinned, nil
}
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,8 @@ var migrations = []Migration{
NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage),
// v257 -> v258
NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable),
// v258 -> 259
NewMigration("Add PinOrder Column", v1_20.AddPinOrderToIssue),
}

// GetCurrentDBVersion returns the current db version
Expand Down
16 changes: 16 additions & 0 deletions models/migrations/v1_20/v258.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_20 //nolint

import (
"xorm.io/xorm"
)

func AddPinOrderToIssue(x *xorm.Engine) error {
type Issue struct {
PinOrder int `xorm:"DEFAULT 0"`
}

return x.Sync(new(Issue))
}
3 changes: 3 additions & 0 deletions modules/setting/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ var (
// Issue Setting
Issue struct {
LockReasons []string
MaxPinned int
} `ini:"repository.issue"`

Release struct {
Expand Down Expand Up @@ -227,8 +228,10 @@ var (
// Issue settings
Issue: struct {
LockReasons []string
MaxPinned int
}{
LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
MaxPinned: 3,
},

Release: struct {
Expand Down
2 changes: 2 additions & 0 deletions modules/structs/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type Issue struct {

PullRequest *PullRequestMeta `json:"pull_request"`
Repo *RepositoryMeta `json:"repository"`

PinOrder int `json:"pin_order"`
}

// CreateIssueOption options to create one issue
Expand Down
2 changes: 2 additions & 0 deletions modules/structs/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type PullRequest struct {
Updated *time.Time `json:"updated_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at"`

PinOrder int `json:"pin_order"`
}

// PRBranchInfo information about a branch
Expand Down
6 changes: 6 additions & 0 deletions modules/structs/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,9 @@ type RepoTransfer struct {
Recipient *User `json:"recipient"`
Teams []*Team `json:"teams"`
}

// NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed
type NewIssuePinsAllowed struct {
Issues bool `json:"issues"`
PullRequests bool `json:"pull_requests"`
}
7 changes: 7 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ unknown = Unknown

rss_feed = RSS Feed

pin = Pin
unpin = Unpin

artifacts = Artifacts

concept_system_global = Global
Expand Down Expand Up @@ -1482,6 +1485,10 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab`
issues.attachment.download = `Click to download "%s"`
issues.subscribe = Subscribe
issues.unsubscribe = Unsubscribe
issues.unpin_issue = Unpin Issue
issues.max_pinned = "You can't pin more issues"
issues.pin_comment = "pinned this %s"
issues.unpin_comment = "unpinned this %s"
issues.lock = Lock conversation
issues.unlock = Unlock conversation
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
Expand Down
9 changes: 9 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Group("/issues", func() {
m.Combo("").Get(repo.ListIssues).
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue)
m.Get("/pinned", repo.ListPinnedIssues)
m.Group("/comments", func() {
m.Get("", repo.ListRepoIssueComments)
m.Group("/{id}", func() {
Expand Down Expand Up @@ -1047,6 +1048,12 @@ func Routes(ctx gocontext.Context) *web.Route {
Get(repo.GetIssueBlocks).
Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking).
Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking)
m.Group("/pin", func() {
m.Combo("").
Post(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.PinIssue).
Delete(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.UnpinIssue)
m.Patch("/{position}", reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.MoveIssuePin)
})
})
}, mustEnableIssuesOrPulls)
m.Group("/labels", func() {
Expand Down Expand Up @@ -1109,6 +1116,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Group("/pulls", func() {
m.Combo("").Get(repo.ListPullRequests).
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest)
m.Get("/pinned", repo.ListPinnedPullRequests)
m.Group("/{index}", func() {
m.Combo("").Get(repo.GetPullRequest).
Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditPullRequestOption{}), repo.EditPullRequest)
Expand Down Expand Up @@ -1186,6 +1194,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
}, repoAssignment())
})

Expand Down
Loading

0 comments on commit aaa1094

Please sign in to comment.