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:
JakobDev 2023-05-25 15:17:19 +02:00 committed by GitHub
parent 79087bdb26
commit aaa1094663
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1331 additions and 13 deletions

View file

@ -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
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -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`)

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View 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))
}

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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"`
}

View file

@ -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.

View file

@ -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())
}) })

View 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)
}

View file

@ -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"`
}

View file

@ -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 {

View 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)
}

View file

@ -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)

View file

@ -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 {

View file

@ -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,

View file

@ -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

View file

@ -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" .}}

View file

@ -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}}

View file

@ -530,8 +530,23 @@
{{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}}
<form class="gt-mt-2" method="POST" {{if $.NewPinAllowed}}action="{{.Issue.Link}}/pin"{{else}}data-tooltip-content="{{.locale.Tr "repo.issues.max_pinned"}}"{{end}}>
{{$.CsrfTokenHtml}}
<button class="fluid ui button gt-df gt-jc {{if not $.NewPinAllowed}}disabled{{end}}">
{{if not .Issue.IsPinned}}
{{svg "octicon-pin" 16 "gt-mr-3"}}
{{.locale.Tr "pin"}}
{{else}}
{{svg "octicon-pin-slash" 16 "gt-mr-3"}}
{{.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}} {{if .Issue.IsLocked}}
{{svg "octicon-key"}} {{svg "octicon-key"}}
{{.locale.Tr "repo.issues.unlock"}} {{.locale.Tr "repo.issues.unlock"}}
@ -540,7 +555,6 @@
{{.locale.Tr "repo.issues.lock"}} {{.locale.Tr "repo.issues.lock"}}
{{end}} {{end}}
</button> </button>
</div>
<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>

View file

@ -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": {

View 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)
}

View file

@ -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;
}

View file

@ -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();
} }