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 <me@silverwind.io> Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
parent
79087bdb26
commit
aaa1094663
27 changed files with 1331 additions and 13 deletions
|
@ -1048,6 +1048,9 @@ LEVEL = Info
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; List of reasons why a Pull Request or Issue can be locked
|
;; List of reasons why a Pull Request or Issue can be locked
|
||||||
;LOCK_REASONS = Too heated,Off-topic,Resolved,Spam
|
;LOCK_REASONS = Too heated,Off-topic,Resolved,Spam
|
||||||
|
;; Maximum number of pinned Issues
|
||||||
|
;; Set to 0 to disable pinning Issues
|
||||||
|
;MAX_PINNED = 3
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
|
@ -141,6 +141,7 @@ In addition there is _`StaticRootPath`_ which can be set as a built-in at build
|
||||||
### Repository - Issue (`repository.issue`)
|
### 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
|
- `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`)
|
### Repository - Upload (`repository.upload`)
|
||||||
|
|
||||||
|
|
|
@ -107,6 +107,8 @@ const (
|
||||||
CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
|
CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
|
||||||
CommentTypePRUnScheduledToAutoMerge // 35 pr was un 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{
|
var commentStrings = []string{
|
||||||
|
@ -146,6 +148,8 @@ var commentStrings = []string{
|
||||||
"change_issue_ref",
|
"change_issue_ref",
|
||||||
"pull_scheduled_merge",
|
"pull_scheduled_merge",
|
||||||
"pull_cancel_scheduled_merge",
|
"pull_cancel_scheduled_merge",
|
||||||
|
"pin",
|
||||||
|
"unpin",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t CommentType) String() string {
|
func (t CommentType) String() string {
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/timeutil"
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
|
@ -116,6 +117,7 @@ type Issue struct {
|
||||||
PullRequest *PullRequest `xorm:"-"`
|
PullRequest *PullRequest `xorm:"-"`
|
||||||
NumComments int
|
NumComments int
|
||||||
Ref string
|
Ref string
|
||||||
|
PinOrder int `xorm:"DEFAULT 0"`
|
||||||
|
|
||||||
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
|
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
|
||||||
|
|
||||||
|
@ -684,3 +686,180 @@ func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
|
||||||
func (issue *Issue) HasOriginalAuthor() bool {
|
func (issue *Issue) HasOriginalAuthor() bool {
|
||||||
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -493,6 +493,8 @@ var migrations = []Migration{
|
||||||
NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage),
|
NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage),
|
||||||
// v257 -> v258
|
// v257 -> v258
|
||||||
NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable),
|
NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable),
|
||||||
|
// v258 -> 259
|
||||||
|
NewMigration("Add PinOrder Column", v1_20.AddPinOrderToIssue),
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentDBVersion returns the current db version
|
// GetCurrentDBVersion returns the current db version
|
||||||
|
|
16
models/migrations/v1_20/v258.go
Normal file
16
models/migrations/v1_20/v258.go
Normal file
|
@ -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))
|
||||||
|
}
|
|
@ -90,6 +90,7 @@ var (
|
||||||
// Issue Setting
|
// Issue Setting
|
||||||
Issue struct {
|
Issue struct {
|
||||||
LockReasons []string
|
LockReasons []string
|
||||||
|
MaxPinned int
|
||||||
} `ini:"repository.issue"`
|
} `ini:"repository.issue"`
|
||||||
|
|
||||||
Release struct {
|
Release struct {
|
||||||
|
@ -227,8 +228,10 @@ var (
|
||||||
// Issue settings
|
// Issue settings
|
||||||
Issue: struct {
|
Issue: struct {
|
||||||
LockReasons []string
|
LockReasons []string
|
||||||
|
MaxPinned int
|
||||||
}{
|
}{
|
||||||
LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
|
LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
|
||||||
|
MaxPinned: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
Release: struct {
|
Release: struct {
|
||||||
|
|
|
@ -75,6 +75,8 @@ type Issue struct {
|
||||||
|
|
||||||
PullRequest *PullRequestMeta `json:"pull_request"`
|
PullRequest *PullRequestMeta `json:"pull_request"`
|
||||||
Repo *RepositoryMeta `json:"repository"`
|
Repo *RepositoryMeta `json:"repository"`
|
||||||
|
|
||||||
|
PinOrder int `json:"pin_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateIssueOption options to create one issue
|
// CreateIssueOption options to create one issue
|
||||||
|
|
|
@ -49,6 +49,8 @@ type PullRequest struct {
|
||||||
Updated *time.Time `json:"updated_at"`
|
Updated *time.Time `json:"updated_at"`
|
||||||
// swagger:strfmt date-time
|
// swagger:strfmt date-time
|
||||||
Closed *time.Time `json:"closed_at"`
|
Closed *time.Time `json:"closed_at"`
|
||||||
|
|
||||||
|
PinOrder int `json:"pin_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRBranchInfo information about a branch
|
// PRBranchInfo information about a branch
|
||||||
|
|
|
@ -374,3 +374,9 @@ type RepoTransfer struct {
|
||||||
Recipient *User `json:"recipient"`
|
Recipient *User `json:"recipient"`
|
||||||
Teams []*Team `json:"teams"`
|
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"`
|
||||||
|
}
|
||||||
|
|
|
@ -115,6 +115,9 @@ unknown = Unknown
|
||||||
|
|
||||||
rss_feed = RSS Feed
|
rss_feed = RSS Feed
|
||||||
|
|
||||||
|
pin = Pin
|
||||||
|
unpin = Unpin
|
||||||
|
|
||||||
artifacts = Artifacts
|
artifacts = Artifacts
|
||||||
|
|
||||||
concept_system_global = Global
|
concept_system_global = Global
|
||||||
|
@ -1482,6 +1485,10 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab`
|
||||||
issues.attachment.download = `Click to download "%s"`
|
issues.attachment.download = `Click to download "%s"`
|
||||||
issues.subscribe = Subscribe
|
issues.subscribe = Subscribe
|
||||||
issues.unsubscribe = Unsubscribe
|
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.lock = Lock conversation
|
||||||
issues.unlock = Unlock conversation
|
issues.unlock = Unlock conversation
|
||||||
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
|
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
|
||||||
|
|
|
@ -967,6 +967,7 @@ func Routes(ctx gocontext.Context) *web.Route {
|
||||||
m.Group("/issues", func() {
|
m.Group("/issues", func() {
|
||||||
m.Combo("").Get(repo.ListIssues).
|
m.Combo("").Get(repo.ListIssues).
|
||||||
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue)
|
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue)
|
||||||
|
m.Get("/pinned", repo.ListPinnedIssues)
|
||||||
m.Group("/comments", func() {
|
m.Group("/comments", func() {
|
||||||
m.Get("", repo.ListRepoIssueComments)
|
m.Get("", repo.ListRepoIssueComments)
|
||||||
m.Group("/{id}", func() {
|
m.Group("/{id}", func() {
|
||||||
|
@ -1047,6 +1048,12 @@ func Routes(ctx gocontext.Context) *web.Route {
|
||||||
Get(repo.GetIssueBlocks).
|
Get(repo.GetIssueBlocks).
|
||||||
Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking).
|
Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking).
|
||||||
Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking)
|
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)
|
}, mustEnableIssuesOrPulls)
|
||||||
m.Group("/labels", func() {
|
m.Group("/labels", func() {
|
||||||
|
@ -1109,6 +1116,7 @@ func Routes(ctx gocontext.Context) *web.Route {
|
||||||
m.Group("/pulls", func() {
|
m.Group("/pulls", func() {
|
||||||
m.Combo("").Get(repo.ListPullRequests).
|
m.Combo("").Get(repo.ListPullRequests).
|
||||||
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest)
|
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest)
|
||||||
|
m.Get("/pinned", repo.ListPinnedPullRequests)
|
||||||
m.Group("/{index}", func() {
|
m.Group("/{index}", func() {
|
||||||
m.Combo("").Get(repo.GetPullRequest).
|
m.Combo("").Get(repo.GetPullRequest).
|
||||||
Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditPullRequestOption{}), repo.EditPullRequest)
|
Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditPullRequestOption{}), repo.EditPullRequest)
|
||||||
|
@ -1186,6 +1194,7 @@ func Routes(ctx gocontext.Context) *web.Route {
|
||||||
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
|
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
|
||||||
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
|
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
|
||||||
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
|
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
|
||||||
|
m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
|
||||||
}, repoAssignment())
|
}, repoAssignment())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
301
routers/api/v1/repo/issue_pin.go
Normal file
301
routers/api/v1/repo/issue_pin.go
Normal file
|
@ -0,0 +1,301 @@
|
||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/services/convert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PinIssue pins a issue
|
||||||
|
func PinIssue(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/pin issue pinIssue
|
||||||
|
// ---
|
||||||
|
// summary: Pin an Issue
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: index
|
||||||
|
// in: path
|
||||||
|
// description: index of issue to pin
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||||
|
if err != nil {
|
||||||
|
if issues_model.IsErrIssueNotExist(err) {
|
||||||
|
ctx.NotFound()
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't do this, it will crash when trying to add the pin event to the comment history
|
||||||
|
err = issue.LoadRepo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadRepo", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = issue.Pin(ctx, ctx.Doer)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "PinIssue", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnpinIssue unpins a Issue
|
||||||
|
func UnpinIssue(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/pin issue unpinIssue
|
||||||
|
// ---
|
||||||
|
// summary: Unpin an Issue
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: index
|
||||||
|
// in: path
|
||||||
|
// description: index of issue to unpin
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||||
|
if err != nil {
|
||||||
|
if issues_model.IsErrIssueNotExist(err) {
|
||||||
|
ctx.NotFound()
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't do this, it will crash when trying to add the unpin event to the comment history
|
||||||
|
err = issue.LoadRepo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadRepo", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = issue.Unpin(ctx, ctx.Doer)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "UnpinIssue", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveIssuePin moves a pinned Issue to a new Position
|
||||||
|
func MoveIssuePin(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/pin/{position} issue moveIssuePin
|
||||||
|
// ---
|
||||||
|
// summary: Moves the Pin to the given Position
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: index
|
||||||
|
// in: path
|
||||||
|
// description: index of issue
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// - name: position
|
||||||
|
// in: path
|
||||||
|
// description: the new position
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||||
|
if err != nil {
|
||||||
|
if issues_model.IsErrIssueNotExist(err) {
|
||||||
|
ctx.NotFound()
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = issue.MovePin(ctx, int(ctx.ParamsInt64(":position")))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "MovePin", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPinnedIssues returns a list of all pinned Issues
|
||||||
|
func ListPinnedIssues(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/issues/pinned repository repoListPinnedIssues
|
||||||
|
// ---
|
||||||
|
// summary: List a repo's pinned issues
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/IssueList"
|
||||||
|
issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, false)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, issues))
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadPinnedIssues", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPinnedPullRequests returns a list of all pinned PRs
|
||||||
|
func ListPinnedPullRequests(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/pulls/pinned repository repoListPinnedPullRequests
|
||||||
|
// ---
|
||||||
|
// summary: List a repo's pinned pull requests
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/PullRequestList"
|
||||||
|
issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadPinnedPullRequests", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiPrs := make([]*api.PullRequest, len(issues))
|
||||||
|
for i, currentIssue := range issues {
|
||||||
|
pr, err := currentIssue.GetPullRequest()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetPullRequest", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = pr.LoadIssue(ctx); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadIssue", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = pr.LoadAttributes(ctx); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadAttributes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = pr.LoadBaseRepo(ctx); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = pr.LoadHeadRepo(ctx); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiPrs[i] = convert.ToAPIPullRequest(ctx, pr, ctx.Doer)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &apiPrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AreNewIssuePinsAllowed returns if new issues pins are allowed
|
||||||
|
func AreNewIssuePinsAllowed(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/new_pin_allowed repository repoNewPinAllowed
|
||||||
|
// ---
|
||||||
|
// summary: Returns if new Issue Pins are allowed
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/RepoNewIssuePinsAllowed"
|
||||||
|
pinsAllowed := api.NewIssuePinsAllowed{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
pinsAllowed.Issues, err = issues_model.IsNewPinAllowed(ctx, ctx.Repo.Repository.ID, false)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsNewIssuePinAllowed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pinsAllowed.PullRequests, err = issues_model.IsNewPinAllowed(ctx, ctx.Repo.Repository.ID, true)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsNewPullRequestPinAllowed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, pinsAllowed)
|
||||||
|
}
|
|
@ -400,3 +400,10 @@ type swaggerRepoIssueConfigValidation struct {
|
||||||
// in:body
|
// in:body
|
||||||
Body api.IssueConfigValidation `json:"body"`
|
Body api.IssueConfigValidation `json:"body"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RepoNewIssuePinsAllowed
|
||||||
|
// swagger:response RepoNewIssuePinsAllowed
|
||||||
|
type swaggerRepoNewIssuePinsAllowed struct {
|
||||||
|
// in:body
|
||||||
|
Body api.NewIssuePinsAllowed `json:"body"`
|
||||||
|
}
|
||||||
|
|
|
@ -388,6 +388,14 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pinned, err := issues_model.GetPinnedIssues(ctx, repo.ID, isPullOption.IsTrue())
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetPinnedIssues", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Data["PinnedIssues"] = pinned
|
||||||
|
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
|
||||||
ctx.Data["IssueStats"] = issueStats
|
ctx.Data["IssueStats"] = issueStats
|
||||||
ctx.Data["SelLabelIDs"] = labelIDs
|
ctx.Data["SelLabelIDs"] = labelIDs
|
||||||
ctx.Data["SelectLabels"] = selectLabels
|
ctx.Data["SelectLabels"] = selectLabels
|
||||||
|
@ -1854,6 +1862,17 @@ func ViewIssue(ctx *context.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pinAllowed bool
|
||||||
|
if !issue.IsPinned() {
|
||||||
|
pinAllowed, err = issues_model.IsNewPinAllowed(ctx, issue.RepoID, issue.IsPull)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("IsNewPinAllowed", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pinAllowed = true
|
||||||
|
}
|
||||||
|
|
||||||
ctx.Data["Participants"] = participants
|
ctx.Data["Participants"] = participants
|
||||||
ctx.Data["NumParticipants"] = len(participants)
|
ctx.Data["NumParticipants"] = len(participants)
|
||||||
ctx.Data["Issue"] = issue
|
ctx.Data["Issue"] = issue
|
||||||
|
@ -1865,6 +1884,8 @@ func ViewIssue(ctx *context.Context) {
|
||||||
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
|
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin)
|
||||||
ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons
|
ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons
|
||||||
ctx.Data["RefEndName"] = git.RefEndName(issue.Ref)
|
ctx.Data["RefEndName"] = git.RefEndName(issue.Ref)
|
||||||
|
ctx.Data["NewPinAllowed"] = pinAllowed
|
||||||
|
ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0
|
||||||
|
|
||||||
var hiddenCommentTypes *big.Int
|
var hiddenCommentTypes *big.Int
|
||||||
if ctx.IsSigned {
|
if ctx.IsSigned {
|
||||||
|
|
88
routers/web/repo/issue_pin.go
Normal file
88
routers/web/repo/issue_pin.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
"code.gitea.io/gitea/modules/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssuePinOrUnpin pin or unpin a Issue
|
||||||
|
func IssuePinOrUnpin(ctx *context.Context) {
|
||||||
|
issue := GetActionIssue(ctx)
|
||||||
|
|
||||||
|
// If we don't do this, it will crash when trying to add the pin event to the comment history
|
||||||
|
err := issue.LoadRepo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = issue.PinOrUnpin(ctx, ctx.Doer)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Redirect(issue.Link())
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueUnpin unpins a Issue
|
||||||
|
func IssueUnpin(ctx *context.Context) {
|
||||||
|
issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id"))
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't do this, it will crash when trying to add the pin event to the comment history
|
||||||
|
err = issue.LoadRepo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = issue.Unpin(ctx, ctx.Doer)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuePinMove moves a pinned Issue
|
||||||
|
func IssuePinMove(ctx *context.Context) {
|
||||||
|
if ctx.Doer == nil {
|
||||||
|
ctx.JSON(http.StatusForbidden, "Only signed in users are allowed to perform this action.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type movePinIssueForm struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
form := &movePinIssueForm{}
|
||||||
|
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := issues_model.GetIssueByID(ctx, form.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = issue.MovePin(ctx, form.Position)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
|
@ -987,6 +987,7 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
|
m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
|
||||||
m.Post("/watch", repo.IssueWatch)
|
m.Post("/watch", repo.IssueWatch)
|
||||||
m.Post("/ref", repo.UpdateIssueRef)
|
m.Post("/ref", repo.UpdateIssueRef)
|
||||||
|
m.Post("/pin", reqRepoAdmin, repo.IssuePinOrUnpin)
|
||||||
m.Post("/viewed-files", repo.UpdateViewedFiles)
|
m.Post("/viewed-files", repo.UpdateViewedFiles)
|
||||||
m.Group("/dependency", func() {
|
m.Group("/dependency", func() {
|
||||||
m.Post("/add", repo.AddDependency)
|
m.Post("/add", repo.AddDependency)
|
||||||
|
@ -1024,6 +1025,8 @@ func registerRoutes(m *web.Route) {
|
||||||
m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
|
m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
|
||||||
m.Post("/attachments", repo.UploadIssueAttachment)
|
m.Post("/attachments", repo.UploadIssueAttachment)
|
||||||
m.Post("/attachments/remove", repo.DeleteAttachment)
|
m.Post("/attachments/remove", repo.DeleteAttachment)
|
||||||
|
m.Delete("/unpin/{id}", reqRepoAdmin, repo.IssueUnpin)
|
||||||
|
m.Post("/pin_move", reqRepoAdmin, repo.IssuePinMove)
|
||||||
}, context.RepoMustNotBeArchived())
|
}, context.RepoMustNotBeArchived())
|
||||||
m.Group("/comments/{id}", func() {
|
m.Group("/comments/{id}", func() {
|
||||||
m.Post("", repo.UpdateCommentContent)
|
m.Post("", repo.UpdateCommentContent)
|
||||||
|
|
|
@ -47,6 +47,7 @@ func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue {
|
||||||
Comments: issue.NumComments,
|
Comments: issue.NumComments,
|
||||||
Created: issue.CreatedUnix.AsTime(),
|
Created: issue.CreatedUnix.AsTime(),
|
||||||
Updated: issue.UpdatedUnix.AsTime(),
|
Updated: issue.UpdatedUnix.AsTime(),
|
||||||
|
PinOrder: issue.PinOrder,
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.Repo != nil {
|
if issue.Repo != nil {
|
||||||
|
|
|
@ -72,6 +72,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
|
||||||
Deadline: apiIssue.Deadline,
|
Deadline: apiIssue.Deadline,
|
||||||
Created: pr.Issue.CreatedUnix.AsTimePtr(),
|
Created: pr.Issue.CreatedUnix.AsTimePtr(),
|
||||||
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
|
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
|
||||||
|
PinOrder: apiIssue.PinOrder,
|
||||||
|
|
||||||
AllowMaintainerEdit: pr.AllowMaintainerEdit,
|
AllowMaintainerEdit: pr.AllowMaintainerEdit,
|
||||||
|
|
||||||
|
|
|
@ -153,6 +153,13 @@ func DeleteIssue(ctx context.Context, doer *user_model.User, gitRepo *git.Reposi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the Issue is pinned, we should unpin it before deletion to avoid problems with other pinned Issues
|
||||||
|
if issue.IsPinned() {
|
||||||
|
if err := issue.Unpin(ctx, doer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
notification.NotifyDeleteIssue(ctx, doer, issue)
|
notification.NotifyDeleteIssue(ctx, doer, issue)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -2,6 +2,70 @@
|
||||||
<div role="main" aria-label="{{.Title}}" class="page-content repository issue-list">
|
<div role="main" aria-label="{{.Title}}" class="page-content repository issue-list">
|
||||||
{{template "repo/header" .}}
|
{{template "repo/header" .}}
|
||||||
<div class="ui container">
|
<div class="ui container">
|
||||||
|
|
||||||
|
{{if .PinnedIssues}}
|
||||||
|
<div id="issue-pins" {{if .IsRepoAdmin}}data-is-repo-admin{{end}}>
|
||||||
|
{{range .PinnedIssues}}
|
||||||
|
<div class="pinned-issue-card gt-word-break" data-move-url="{{$.Link}}/pin_move" data-issue-id="{{.ID}}">
|
||||||
|
{{if eq $.Project.CardType 1}}
|
||||||
|
<div class="card-attachment-images">
|
||||||
|
{{range (index $.issuesAttachmentMap .ID)}}
|
||||||
|
<img src="{{.DownloadURL}}" alt="{{.Name}}">
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="content gt-p-0">
|
||||||
|
<div class="header gt-df gt-items-start">
|
||||||
|
<div class="pinned-issue-icon">
|
||||||
|
{{template "shared/issueicon" .}}
|
||||||
|
</div>
|
||||||
|
<a class="pinned-issue-title muted issue-title" href="{{.Link}}">{{.Title | RenderEmoji $.Context | RenderCodeBlock}}</a>
|
||||||
|
{{if $.IsRepoAdmin}}
|
||||||
|
<a role="button" class="pinned-issue-unpin muted gt-df gt-ac" data-tooltip-content={{$.locale.Tr "repo.issues.unpin_issue"}} data-issue-id="{{.ID}}" data-unpin-url="{{$.Link}}/unpin/{{.ID}}">
|
||||||
|
{{svg "octicon-x" 16}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="meta gt-my-2">
|
||||||
|
<span class="text light grey">
|
||||||
|
#{{.Index}}
|
||||||
|
{{$timeStr := TimeSinceUnix .GetLastEventTimestamp $.locale}}
|
||||||
|
{{if .OriginalAuthor}}
|
||||||
|
{{$.locale.Tr .GetLastEventLabelFake $timeStr (.OriginalAuthor|Escape) | Safe}}
|
||||||
|
{{else if gt .Poster.ID 0}}
|
||||||
|
{{$.locale.Tr .GetLastEventLabel $timeStr (.Poster.HomeLink|Escape) (.Poster.GetDisplayName | Escape) | Safe}}
|
||||||
|
{{else}}
|
||||||
|
{{$.locale.Tr .GetLastEventLabelFake $timeStr (.Poster.GetDisplayName | Escape) | Safe}}
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{- if .MilestoneID}}
|
||||||
|
<div class="meta gt-my-2">
|
||||||
|
<a class="milestone" href="{{$.RepoLink}}/milestone/{{.MilestoneID}}">
|
||||||
|
{{svg "octicon-milestone" 16 "gt-mr-2 gt-vm"}}
|
||||||
|
<span class="gt-vm">{{.Milestone.Name}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if or .Labels .Assignees}}
|
||||||
|
<div class="extra content labels-list gt-p-0 gt-pt-2">
|
||||||
|
{{range .Labels}}
|
||||||
|
<a href="{{$.RepoLink}}/issues?labels={{.ID}}">{{RenderLabel $.Context .}}</a>
|
||||||
|
{{end}}
|
||||||
|
<div class="right floated">
|
||||||
|
{{range .Assignees}}
|
||||||
|
<a href="{{.HomeLink}}" data-tooltip-content="{{$.locale.Tr "repo.projects.column.assigned_to"}} {{.Name}}">{{avatar $.Context . 28 "mini gt-mr-3"}}</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
{{template "repo/issue/navbar" .}}
|
{{template "repo/issue/navbar" .}}
|
||||||
{{template "repo/issue/search" .}}
|
{{template "repo/issue/search" .}}
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST,
|
26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST,
|
||||||
29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED
|
29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED
|
||||||
32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE,
|
32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE,
|
||||||
35 = CANCEL_SCHEDULED_AUTO_MERGE_PR -->
|
35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE -->
|
||||||
{{if eq .Type 0}}
|
{{if eq .Type 0}}
|
||||||
<div class="timeline-item comment" id="{{.HashTag}}">
|
<div class="timeline-item comment" id="{{.HashTag}}">
|
||||||
{{if .OriginalAuthor}}
|
{{if .OriginalAuthor}}
|
||||||
|
@ -835,6 +835,16 @@
|
||||||
{{else}}{{$.locale.Tr "repo.pulls.auto_merge_canceled_schedule_comment" $createdStr | Safe}}{{end}}
|
{{else}}{{$.locale.Tr "repo.pulls.auto_merge_canceled_schedule_comment" $createdStr | Safe}}{{end}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{{else if or (eq .Type 36) (eq .Type 37)}}
|
||||||
|
<div class="timeline-item event" id="{{.HashTag}}">
|
||||||
|
<span class="badge">{{svg "octicon-pin" 16}}</span>
|
||||||
|
{{template "shared/user/avatarlink" dict "Context" $.Context "user" .Poster}}
|
||||||
|
<span class="text grey muted-links">
|
||||||
|
{{template "shared/user/authorlink" .Poster}}
|
||||||
|
{{if eq .Type 36}}{{$.locale.Tr "repo.issues.pin_comment" $createdStr | Safe}}
|
||||||
|
{{else}}{{$.locale.Tr "repo.issues.unpin_comment" $createdStr | Safe}}{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -530,17 +530,31 @@
|
||||||
|
|
||||||
{{if and .IsRepoAdmin (not .Repository.IsArchived)}}
|
{{if and .IsRepoAdmin (not .Repository.IsArchived)}}
|
||||||
<div class="ui divider"></div>
|
<div class="ui divider"></div>
|
||||||
<div class="ui watching">
|
|
||||||
<button class="fluid ui show-modal button {{if .Issue.IsLocked}} negative {{end}}" data-modal="#lock">
|
{{if or .PinEnabled .Issue.IsPinned}}
|
||||||
{{if .Issue.IsLocked}}
|
<form class="gt-mt-2" method="POST" {{if $.NewPinAllowed}}action="{{.Issue.Link}}/pin"{{else}}data-tooltip-content="{{.locale.Tr "repo.issues.max_pinned"}}"{{end}}>
|
||||||
{{svg "octicon-key"}}
|
{{$.CsrfTokenHtml}}
|
||||||
{{.locale.Tr "repo.issues.unlock"}}
|
<button class="fluid ui button gt-df gt-jc {{if not $.NewPinAllowed}}disabled{{end}}">
|
||||||
{{else}}
|
{{if not .Issue.IsPinned}}
|
||||||
{{svg "octicon-lock"}}
|
{{svg "octicon-pin" 16 "gt-mr-3"}}
|
||||||
{{.locale.Tr "repo.issues.lock"}}
|
{{.locale.Tr "pin"}}
|
||||||
{{end}}
|
{{else}}
|
||||||
</button>
|
{{svg "octicon-pin-slash" 16 "gt-mr-3"}}
|
||||||
</div>
|
{{.locale.Tr "unpin"}}
|
||||||
|
{{end}}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<button class="gt-mt-2 fluid ui show-modal button {{if .Issue.IsLocked}} negative {{end}}" data-modal="#lock">
|
||||||
|
{{if .Issue.IsLocked}}
|
||||||
|
{{svg "octicon-key"}}
|
||||||
|
{{.locale.Tr "repo.issues.unlock"}}
|
||||||
|
{{else}}
|
||||||
|
{{svg "octicon-lock"}}
|
||||||
|
{{.locale.Tr "repo.issues.lock"}}
|
||||||
|
{{end}}
|
||||||
|
</button>
|
||||||
<div class="ui tiny modal" id="lock">
|
<div class="ui tiny modal" id="lock">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
{{if .Issue.IsLocked}}
|
{{if .Issue.IsLocked}}
|
||||||
|
@ -605,7 +619,7 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="fluid ui show-modal button negative gt-mt-3" data-modal="#delete">
|
<button class="gt-mt-2 fluid ui show-modal button negative" data-modal="#delete">
|
||||||
{{svg "octicon-trash"}}
|
{{svg "octicon-trash"}}
|
||||||
{{.locale.Tr "repo.issues.delete"}}
|
{{.locale.Tr "repo.issues.delete"}}
|
||||||
</button>
|
</button>
|
||||||
|
|
268
templates/swagger/v1_json.tmpl
generated
268
templates/swagger/v1_json.tmpl
generated
|
@ -6191,6 +6191,39 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/issues/pinned": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "List a repo's pinned issues",
|
||||||
|
"operationId": "repoListPinnedIssues",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/IssueList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/issues/{index}": {
|
"/repos/{owner}/{repo}/issues/{index}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -7419,6 +7452,144 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/issues/{index}/pin": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"issue"
|
||||||
|
],
|
||||||
|
"summary": "Pin an Issue",
|
||||||
|
"operationId": "pinIssue",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "index of issue to pin",
|
||||||
|
"name": "index",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"$ref": "#/responses/empty"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"issue"
|
||||||
|
],
|
||||||
|
"summary": "Unpin an Issue",
|
||||||
|
"operationId": "unpinIssue",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "index of issue to unpin",
|
||||||
|
"name": "index",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"$ref": "#/responses/empty"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/repos/{owner}/{repo}/issues/{index}/pin/{position}": {
|
||||||
|
"patch": {
|
||||||
|
"tags": [
|
||||||
|
"issue"
|
||||||
|
],
|
||||||
|
"summary": "Moves the Pin to the given Position",
|
||||||
|
"operationId": "moveIssuePin",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "index of issue",
|
||||||
|
"name": "index",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "the new position",
|
||||||
|
"name": "position",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"$ref": "#/responses/empty"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/forbidden"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/notFound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/issues/{index}/reactions": {
|
"/repos/{owner}/{repo}/issues/{index}/reactions": {
|
||||||
"get": {
|
"get": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
@ -9010,6 +9181,39 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/new_pin_allowed": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "Returns if new Issue Pins are allowed",
|
||||||
|
"operationId": "repoNewPinAllowed",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/RepoNewIssuePinsAllowed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/notifications": {
|
"/repos/{owner}/{repo}/notifications": {
|
||||||
"get": {
|
"get": {
|
||||||
"consumes": [
|
"consumes": [
|
||||||
|
@ -9302,6 +9506,39 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{owner}/{repo}/pulls/pinned": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"summary": "List a repo's pinned pull requests",
|
||||||
|
"operationId": "repoListPinnedPullRequests",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "owner of the repo",
|
||||||
|
"name": "owner",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "name of the repo",
|
||||||
|
"name": "repo",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/PullRequestList"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{owner}/{repo}/pulls/{index}": {
|
"/repos/{owner}/{repo}/pulls/{index}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -18664,6 +18901,11 @@
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"x-go-name": "OriginalAuthorID"
|
"x-go-name": "OriginalAuthorID"
|
||||||
},
|
},
|
||||||
|
"pin_order": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"x-go-name": "PinOrder"
|
||||||
|
},
|
||||||
"pull_request": {
|
"pull_request": {
|
||||||
"$ref": "#/definitions/PullRequestMeta"
|
"$ref": "#/definitions/PullRequestMeta"
|
||||||
},
|
},
|
||||||
|
@ -19224,6 +19466,21 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
},
|
},
|
||||||
|
"NewIssuePinsAllowed": {
|
||||||
|
"description": "NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"issues": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "Issues"
|
||||||
|
},
|
||||||
|
"pull_requests": {
|
||||||
|
"type": "boolean",
|
||||||
|
"x-go-name": "PullRequests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
||||||
|
},
|
||||||
"NodeInfo": {
|
"NodeInfo": {
|
||||||
"description": "NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks",
|
"description": "NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -19934,6 +20191,11 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"x-go-name": "PatchURL"
|
"x-go-name": "PatchURL"
|
||||||
},
|
},
|
||||||
|
"pin_order": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"x-go-name": "PinOrder"
|
||||||
|
},
|
||||||
"requested_reviewers": {
|
"requested_reviewers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
@ -22176,6 +22438,12 @@
|
||||||
"$ref": "#/definitions/IssueConfigValidation"
|
"$ref": "#/definitions/IssueConfigValidation"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"RepoNewIssuePinsAllowed": {
|
||||||
|
"description": "RepoNewIssuePinsAllowed",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/NewIssuePinsAllowed"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Repository": {
|
"Repository": {
|
||||||
"description": "Repository",
|
"description": "Repository",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
205
tests/integration/api_issue_pin_test.go
Normal file
205
tests/integration/api_issue_pin_test.go
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
auth_model "code.gitea.io/gitea/models/auth"
|
||||||
|
issues_model "code.gitea.io/gitea/models/issues"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPIPinIssue(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
|
||||||
|
session := loginUser(t, owner.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
|
||||||
|
|
||||||
|
// Pin the Issue
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue.Index, token)
|
||||||
|
req := NewRequest(t, "POST", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Check if the Issue is pinned
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
var issueAPI api.Issue
|
||||||
|
DecodeJSON(t, resp, &issueAPI)
|
||||||
|
assert.Equal(t, 1, issueAPI.PinOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIUnpinIssue(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
|
||||||
|
session := loginUser(t, owner.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
|
||||||
|
|
||||||
|
// Pin the Issue
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue.Index, token)
|
||||||
|
req := NewRequest(t, "POST", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Check if the Issue is pinned
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
var issueAPI api.Issue
|
||||||
|
DecodeJSON(t, resp, &issueAPI)
|
||||||
|
assert.Equal(t, 1, issueAPI.PinOrder)
|
||||||
|
|
||||||
|
// Unpin the Issue
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue.Index, token)
|
||||||
|
req = NewRequest(t, "DELETE", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Check if the Issue is no longer pinned
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
DecodeJSON(t, resp, &issueAPI)
|
||||||
|
assert.Equal(t, 0, issueAPI.PinOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIMoveIssuePin(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
||||||
|
issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, RepoID: repo.ID})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
|
||||||
|
session := loginUser(t, owner.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
|
||||||
|
|
||||||
|
// Pin the first Issue
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue.Index, token)
|
||||||
|
req := NewRequest(t, "POST", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Check if the first Issue is pinned at position 1
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
var issueAPI api.Issue
|
||||||
|
DecodeJSON(t, resp, &issueAPI)
|
||||||
|
assert.Equal(t, 1, issueAPI.PinOrder)
|
||||||
|
|
||||||
|
// Pin the second Issue
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue2.Index, token)
|
||||||
|
req = NewRequest(t, "POST", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Move the first Issue to position 2
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin/2?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue.Index, token)
|
||||||
|
req = NewRequest(t, "PATCH", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Check if the first Issue is pinned at position 2
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
var issueAPI3 api.Issue
|
||||||
|
DecodeJSON(t, resp, &issueAPI3)
|
||||||
|
assert.Equal(t, 2, issueAPI3.PinOrder)
|
||||||
|
|
||||||
|
// Check if the second Issue is pinned at position 1
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue2.Index)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp = MakeRequest(t, req, http.StatusOK)
|
||||||
|
var issueAPI4 api.Issue
|
||||||
|
DecodeJSON(t, resp, &issueAPI4)
|
||||||
|
assert.Equal(t, 1, issueAPI4.PinOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIListPinnedIssues(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
|
||||||
|
session := loginUser(t, owner.Name)
|
||||||
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeRepo)
|
||||||
|
|
||||||
|
// Pin the Issue
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin?token=%s",
|
||||||
|
repo.OwnerName, repo.Name, issue.Index, token)
|
||||||
|
req := NewRequest(t, "POST", urlStr)
|
||||||
|
MakeRequest(t, req, http.StatusNoContent)
|
||||||
|
|
||||||
|
// Check if the Issue is in the List
|
||||||
|
urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/pinned", repo.OwnerName, repo.Name)
|
||||||
|
req = NewRequest(t, "GET", urlStr)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
var issueList []api.Issue
|
||||||
|
DecodeJSON(t, resp, &issueList)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, len(issueList))
|
||||||
|
assert.Equal(t, issue.ID, issueList[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIListPinnedPullrequests(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
assert.NoError(t, unittest.LoadFixtures())
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/pinned", repo.OwnerName, repo.Name)
|
||||||
|
req := NewRequest(t, "GET", urlStr)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
var prList []api.PullRequest
|
||||||
|
DecodeJSON(t, resp, &prList)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, len(prList))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPINewPinAllowed(t *testing.T) {
|
||||||
|
defer tests.PrepareTestEnv(t)()
|
||||||
|
|
||||||
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
|
||||||
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
|
||||||
|
|
||||||
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/new_pin_allowed", owner.Name, repo.Name)
|
||||||
|
req := NewRequest(t, "GET", urlStr)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
var newPinsAllowed api.NewIssuePinsAllowed
|
||||||
|
DecodeJSON(t, resp, &newPinsAllowed)
|
||||||
|
|
||||||
|
assert.True(t, newPinsAllowed.Issues)
|
||||||
|
assert.True(t, newPinsAllowed.PullRequests)
|
||||||
|
}
|
|
@ -3387,3 +3387,37 @@ tbody.commit-list {
|
||||||
.search-fullname {
|
.search-fullname {
|
||||||
color: var(--color-text-light-2);
|
color: var(--color-text-light-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#issue-pins {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-issue-card {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
background: var(--color-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-issue-card .meta a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-issue-card .meta a:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-issue-icon,
|
||||||
|
.pinned-issue-unpin {
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-issue-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import $ from 'jquery';
|
||||||
import {updateIssuesMeta} from './repo-issue.js';
|
import {updateIssuesMeta} from './repo-issue.js';
|
||||||
import {toggleElem} from '../utils/dom.js';
|
import {toggleElem} from '../utils/dom.js';
|
||||||
import {htmlEscape} from 'escape-goat';
|
import {htmlEscape} from 'escape-goat';
|
||||||
|
import {Sortable} from 'sortablejs';
|
||||||
|
|
||||||
function initRepoIssueListCheckboxes() {
|
function initRepoIssueListCheckboxes() {
|
||||||
const $issueSelectAll = $('.issue-checkbox-all');
|
const $issueSelectAll = $('.issue-checkbox-all');
|
||||||
|
@ -119,8 +120,67 @@ function initRepoIssueListAuthorDropdown() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initPinRemoveButton() {
|
||||||
|
for (const button of document.getElementsByClassName('pinned-issue-unpin')) {
|
||||||
|
button.addEventListener('click', async (event) => {
|
||||||
|
const el = event.currentTarget;
|
||||||
|
const id = Number(el.getAttribute('data-issue-id'));
|
||||||
|
|
||||||
|
// Send the unpin request
|
||||||
|
const response = await fetch(el.getAttribute('data-unpin-url'), {
|
||||||
|
method: 'delete',
|
||||||
|
headers: {
|
||||||
|
'X-Csrf-Token': window.config.csrfToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// Delete the tooltip
|
||||||
|
el._tippy.destroy();
|
||||||
|
// Remove the Card
|
||||||
|
el.closest(`div.pinned-issue-card[data-issue-id="${id}"]`).remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinMoveEnd(e) {
|
||||||
|
const url = e.item.getAttribute('data-move-url');
|
||||||
|
const id = Number(e.item.getAttribute('data-issue-id'));
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'post',
|
||||||
|
body: JSON.stringify({id, position: e.newIndex + 1}),
|
||||||
|
headers: {
|
||||||
|
'X-Csrf-Token': window.config.csrfToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initIssuePinSort() {
|
||||||
|
const pinDiv = document.getElementById('issue-pins');
|
||||||
|
|
||||||
|
if (pinDiv === null) return;
|
||||||
|
|
||||||
|
// If the User is not a Repo Admin, we don't need to proceed
|
||||||
|
if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
|
||||||
|
|
||||||
|
initPinRemoveButton();
|
||||||
|
|
||||||
|
// If only one issue pinned, we don't need to make this Sortable
|
||||||
|
if (pinDiv.children.length < 2) return;
|
||||||
|
|
||||||
|
new Sortable(pinDiv, {
|
||||||
|
group: 'shared',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'card-ghost',
|
||||||
|
onEnd: pinMoveEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function initRepoIssueList() {
|
export function initRepoIssueList() {
|
||||||
if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
|
if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
|
||||||
initRepoIssueListCheckboxes();
|
initRepoIssueListCheckboxes();
|
||||||
initRepoIssueListAuthorDropdown();
|
initRepoIssueListAuthorDropdown();
|
||||||
|
initIssuePinSort();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue