aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJakobDev <jakobdev@gmx.de>2023-05-25 15:17:19 +0200
committerGitHub <noreply@github.com>2023-05-25 15:17:19 +0200
commitaaa109466350c531b9238a61115b2877daca57d3 (patch)
tree4f5759d3591d6424d80e44e5b8ee97bdd0c7c9ac
parent79087bdb2676ac383f4bd21137d4454f7a26c8c4 (diff)
downloadgitea-aaa109466350c531b9238a61115b2877daca57d3.tar.gz
gitea-aaa109466350c531b9238a61115b2877daca57d3.zip
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>
-rw-r--r--custom/conf/app.example.ini3
-rw-r--r--docs/content/doc/administration/config-cheat-sheet.en-us.md1
-rw-r--r--models/issues/comment.go4
-rw-r--r--models/issues/issue.go179
-rw-r--r--models/migrations/migrations.go2
-rw-r--r--models/migrations/v1_20/v258.go16
-rw-r--r--modules/setting/repository.go3
-rw-r--r--modules/structs/issue.go2
-rw-r--r--modules/structs/pull.go2
-rw-r--r--modules/structs/repo.go6
-rw-r--r--options/locale/locale_en-US.ini7
-rw-r--r--routers/api/v1/api.go9
-rw-r--r--routers/api/v1/repo/issue_pin.go301
-rw-r--r--routers/api/v1/swagger/repo.go7
-rw-r--r--routers/web/repo/issue.go21
-rw-r--r--routers/web/repo/issue_pin.go88
-rw-r--r--routers/web/web.go3
-rw-r--r--services/convert/issue.go1
-rw-r--r--services/convert/pull.go1
-rw-r--r--services/issue/issue.go7
-rw-r--r--templates/repo/issue/list.tmpl64
-rw-r--r--templates/repo/issue/view_content/comments.tmpl12
-rw-r--r--templates/repo/issue/view_content/sidebar.tmpl38
-rw-r--r--templates/swagger/v1_json.tmpl268
-rw-r--r--tests/integration/api_issue_pin_test.go205
-rw-r--r--web_src/css/repo.css34
-rw-r--r--web_src/js/features/repo-issue-list.js60
27 files changed, 1331 insertions, 13 deletions
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index c2b721b0cf..44445579bd 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -1048,6 +1048,9 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; List of reasons why a Pull Request or Issue can be locked
;LOCK_REASONS = Too heated,Off-topic,Resolved,Spam
+;; Maximum number of pinned Issues
+;; Set to 0 to disable pinning Issues
+;MAX_PINNED = 3
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/docs/content/doc/administration/config-cheat-sheet.en-us.md b/docs/content/doc/administration/config-cheat-sheet.en-us.md
index 9616be586d..1fa4abcef2 100644
--- a/docs/content/doc/administration/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/administration/config-cheat-sheet.en-us.md
@@ -141,6 +141,7 @@ In addition there is _`StaticRootPath`_ which can be set as a built-in at build
### Repository - Issue (`repository.issue`)
- `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked
+- `MAX_PINNED`: **3**: Maximum number of pinned Issues. Set to 0 to disable pinning Issues.
### Repository - Upload (`repository.upload`)
diff --git a/models/issues/comment.go b/models/issues/comment.go
index bf2bbfa414..e5c90f265e 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -107,6 +107,8 @@ const (
CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
CommentTypePRUnScheduledToAutoMerge // 35 pr was un scheduled to auto merge when checks succeed
+ CommentTypePin // 36 pin Issue
+ CommentTypeUnpin // 37 unpin Issue
)
var commentStrings = []string{
@@ -146,6 +148,8 @@ var commentStrings = []string{
"change_issue_ref",
"pull_scheduled_merge",
"pull_cancel_scheduled_merge",
+ "pin",
+ "unpin",
}
func (t CommentType) String() string {
diff --git a/models/issues/issue.go b/models/issues/issue.go
index fc046d273c..5015824e9b 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -14,6 +14,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"
@@ -116,6 +117,7 @@ type Issue struct {
PullRequest *PullRequest `xorm:"-"`
NumComments int
Ref string
+ PinOrder int `xorm:"DEFAULT 0"`
DeadlineUnix timeutil.TimeStamp `xorm:"INDEX"`
@@ -684,3 +686,180 @@ func (issue *Issue) GetExternalID() int64 { return issue.OriginalAuthorID }
func (issue *Issue) HasOriginalAuthor() bool {
return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0
}
+
+// IsPinned returns if a Issue is pinned
+func (issue *Issue) IsPinned() bool {
+ return issue.PinOrder != 0
+}
+
+// Pin pins a Issue
+func (issue *Issue) Pin(ctx context.Context, user *user_model.User) error {
+ // If the Issue is already pinned, we don't need to pin it twice
+ if issue.IsPinned() {
+ return nil
+ }
+
+ var maxPin int
+ _, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
+ if err != nil {
+ return err
+ }
+
+ // Check if the maximum allowed Pins reached
+ if maxPin >= setting.Repository.Issue.MaxPinned {
+ return fmt.Errorf("You have reached the max number of pinned Issues")
+ }
+
+ _, err = db.GetEngine(ctx).Table("issue").
+ Where("id = ?", issue.ID).
+ Update(map[string]interface{}{
+ "pin_order": maxPin + 1,
+ })
+ if err != nil {
+ return err
+ }
+
+ // Add the pin event to the history
+ opts := &CreateCommentOptions{
+ Type: CommentTypePin,
+ Doer: user,
+ Repo: issue.Repo,
+ Issue: issue,
+ }
+ if _, err = CreateComment(ctx, opts); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// UnpinIssue unpins a Issue
+func (issue *Issue) Unpin(ctx context.Context, user *user_model.User) error {
+ // If the Issue is not pinned, we don't need to unpin it
+ if !issue.IsPinned() {
+ return nil
+ }
+
+ // This sets the Pin for all Issues that come after the unpined Issue to the correct value
+ _, err := db.GetEngine(ctx).Exec("UPDATE issue SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ?", issue.RepoID, issue.IsPull, issue.PinOrder)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.GetEngine(ctx).Table("issue").
+ Where("id = ?", issue.ID).
+ Update(map[string]interface{}{
+ "pin_order": 0,
+ })
+ if err != nil {
+ return err
+ }
+
+ // Add the unpin event to the history
+ opts := &CreateCommentOptions{
+ Type: CommentTypeUnpin,
+ Doer: user,
+ Repo: issue.Repo,
+ Issue: issue,
+ }
+ if _, err = CreateComment(ctx, opts); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// PinOrUnpin pins or unpins a Issue
+func (issue *Issue) PinOrUnpin(ctx context.Context, user *user_model.User) error {
+ if !issue.IsPinned() {
+ return issue.Pin(ctx, user)
+ }
+
+ return issue.Unpin(ctx, user)
+}
+
+// MovePin moves a Pinned Issue to a new Position
+func (issue *Issue) MovePin(ctx context.Context, newPosition int) error {
+ // If the Issue is not pinned, we can't move them
+ if !issue.IsPinned() {
+ return nil
+ }
+
+ if newPosition < 1 {
+ return fmt.Errorf("The Position can't be lower than 1")
+ }
+
+ dbctx, committer, err := db.TxContext(ctx)
+ if err != nil {
+ return err
+ }
+ defer committer.Close()
+
+ var maxPin int
+ _, err = db.GetEngine(dbctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", issue.RepoID, issue.IsPull).Get(&maxPin)
+ if err != nil {
+ return err
+ }
+
+ // If the new Position bigger than the current Maximum, set it to the Maximum
+ if newPosition > maxPin+1 {
+ newPosition = maxPin + 1
+ }
+
+ // Lower the Position of all Pinned Issue that came after the current Position
+ _, err = db.GetEngine(dbctx).Exec("UPDATE issue SET pin_order = pin_order - 1 WHERE repo_id = ? AND is_pull = ? AND pin_order > ?", issue.RepoID, issue.IsPull, issue.PinOrder)
+ if err != nil {
+ return err
+ }
+
+ // Higher the Position of all Pinned Issues that comes after the new Position
+ _, err = db.GetEngine(dbctx).Exec("UPDATE issue SET pin_order = pin_order + 1 WHERE repo_id = ? AND is_pull = ? AND pin_order >= ?", issue.RepoID, issue.IsPull, newPosition)
+ if err != nil {
+ return err
+ }
+
+ _, err = db.GetEngine(dbctx).Table("issue").
+ Where("id = ?", issue.ID).
+ Update(map[string]interface{}{
+ "pin_order": newPosition,
+ })
+ if err != nil {
+ return err
+ }
+
+ return committer.Commit()
+}
+
+// GetPinnedIssues returns the pinned Issues for the given Repo and type
+func GetPinnedIssues(ctx context.Context, repoID int64, isPull bool) ([]*Issue, error) {
+ issues := make([]*Issue, 0)
+
+ err := db.GetEngine(ctx).
+ Table("issue").
+ Where("repo_id = ?", repoID).
+ And("is_pull = ?", isPull).
+ And("pin_order > 0").
+ OrderBy("pin_order").
+ Find(&issues)
+ if err != nil {
+ return nil, err
+ }
+
+ err = IssueList(issues).LoadAttributes()
+ if err != nil {
+ return nil, err
+ }
+
+ return issues, nil
+}
+
+// IsNewPinnedAllowed returns if a new Issue or Pull request can be pinned
+func IsNewPinAllowed(ctx context.Context, repoID int64, isPull bool) (bool, error) {
+ var maxPin int
+ _, err := db.GetEngine(ctx).SQL("SELECT MAX(pin_order) FROM issue WHERE repo_id = ? AND is_pull = ?", repoID, isPull).Get(&maxPin)
+ if err != nil {
+ return false, err
+ }
+
+ return maxPin < setting.Repository.Issue.MaxPinned, nil
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 49bc0be4e5..231c93cc74 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -493,6 +493,8 @@ var migrations = []Migration{
NewMigration("Add is_internal column to package", v1_20.AddIsInternalColumnToPackage),
// v257 -> v258
NewMigration("Add Actions Artifact table", v1_20.CreateActionArtifactTable),
+ // v258 -> 259
+ NewMigration("Add PinOrder Column", v1_20.AddPinOrderToIssue),
}
// GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_20/v258.go b/models/migrations/v1_20/v258.go
new file mode 100644
index 0000000000..47174ce805
--- /dev/null
+++ b/models/migrations/v1_20/v258.go
@@ -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))
+}
diff --git a/modules/setting/repository.go b/modules/setting/repository.go
index 5520c992b9..406068b59d 100644
--- a/modules/setting/repository.go
+++ b/modules/setting/repository.go
@@ -90,6 +90,7 @@ var (
// Issue Setting
Issue struct {
LockReasons []string
+ MaxPinned int
} `ini:"repository.issue"`
Release struct {
@@ -227,8 +228,10 @@ var (
// Issue settings
Issue: struct {
LockReasons []string
+ MaxPinned int
}{
LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
+ MaxPinned: 3,
},
Release: struct {
diff --git a/modules/structs/issue.go b/modules/structs/issue.go
index 04e169df84..a9fb6c6e79 100644
--- a/modules/structs/issue.go
+++ b/modules/structs/issue.go
@@ -75,6 +75,8 @@ type Issue struct {
PullRequest *PullRequestMeta `json:"pull_request"`
Repo *RepositoryMeta `json:"repository"`
+
+ PinOrder int `json:"pin_order"`
}
// CreateIssueOption options to create one issue
diff --git a/modules/structs/pull.go b/modules/structs/pull.go
index a4a6f60b05..05a8d59633 100644
--- a/modules/structs/pull.go
+++ b/modules/structs/pull.go
@@ -49,6 +49,8 @@ type PullRequest struct {
Updated *time.Time `json:"updated_at"`
// swagger:strfmt date-time
Closed *time.Time `json:"closed_at"`
+
+ PinOrder int `json:"pin_order"`
}
// PRBranchInfo information about a branch
diff --git a/modules/structs/repo.go b/modules/structs/repo.go
index 01239188c2..fc4ed03de5 100644
--- a/modules/structs/repo.go
+++ b/modules/structs/repo.go
@@ -374,3 +374,9 @@ type RepoTransfer struct {
Recipient *User `json:"recipient"`
Teams []*Team `json:"teams"`
}
+
+// NewIssuePinsAllowed represents an API response that says if new Issue Pins are allowed
+type NewIssuePinsAllowed struct {
+ Issues bool `json:"issues"`
+ PullRequests bool `json:"pull_requests"`
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 458c99f7fe..1026a13e33 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -115,6 +115,9 @@ unknown = Unknown
rss_feed = RSS Feed
+pin = Pin
+unpin = Unpin
+
artifacts = Artifacts
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.subscribe = Subscribe
issues.unsubscribe = Unsubscribe
+issues.unpin_issue = Unpin Issue
+issues.max_pinned = "You can't pin more issues"
+issues.pin_comment = "pinned this %s"
+issues.unpin_comment = "unpinned this %s"
issues.lock = Lock conversation
issues.unlock = Unlock conversation
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 53c58c4b9b..fccfc5792c 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -967,6 +967,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Group("/issues", func() {
m.Combo("").Get(repo.ListIssues).
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue)
+ m.Get("/pinned", repo.ListPinnedIssues)
m.Group("/comments", func() {
m.Get("", repo.ListRepoIssueComments)
m.Group("/{id}", func() {
@@ -1047,6 +1048,12 @@ func Routes(ctx gocontext.Context) *web.Route {
Get(repo.GetIssueBlocks).
Post(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.CreateIssueBlocking).
Delete(reqToken(auth_model.AccessTokenScopeRepo), bind(api.IssueMeta{}), repo.RemoveIssueBlocking)
+ m.Group("/pin", func() {
+ m.Combo("").
+ Post(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.PinIssue).
+ Delete(reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.UnpinIssue)
+ m.Patch("/{position}", reqToken(auth_model.AccessTokenScopeRepo), reqAdmin(), repo.MoveIssuePin)
+ })
})
}, mustEnableIssuesOrPulls)
m.Group("/labels", func() {
@@ -1109,6 +1116,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Group("/pulls", func() {
m.Combo("").Get(repo.ListPullRequests).
Post(reqToken(auth_model.AccessTokenScopeRepo), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest)
+ m.Get("/pinned", repo.ListPinnedPullRequests)
m.Group("/{index}", func() {
m.Combo("").Get(repo.GetPullRequest).
Patch(reqToken(auth_model.AccessTokenScopeRepo), bind(api.EditPullRequestOption{}), repo.EditPullRequest)
@@ -1186,6 +1194,7 @@ func Routes(ctx gocontext.Context) *web.Route {
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
m.Get("/activities/feeds", repo.ListRepoActivityFeeds)
+ m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed)
}, repoAssignment())
})
diff --git a/routers/api/v1/repo/issue_pin.go b/routers/api/v1/repo/issue_pin.go
new file mode 100644
index 0000000000..c96ede45f5
--- /dev/null
+++ b/routers/api/v1/repo/issue_pin.go
@@ -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)
+}
diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go
index e0418e99dc..10056ac8cb 100644
--- a/routers/api/v1/swagger/repo.go
+++ b/routers/api/v1/swagger/repo.go
@@ -400,3 +400,10 @@ type swaggerRepoIssueConfigValidation struct {
// in:body
Body api.IssueConfigValidation `json:"body"`
}
+
+// RepoNewIssuePinsAllowed
+// swagger:response RepoNewIssuePinsAllowed
+type swaggerRepoNewIssuePinsAllowed struct {
+ // in:body
+ Body api.NewIssuePinsAllowed `json:"body"`
+}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 1511716c1e..1448b772bc 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -388,6 +388,14 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
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["SelLabelIDs"] = labelIDs
ctx.Data["SelectLabels"] = selectLabels
@@ -1854,6 +1862,17 @@ func ViewIssue(ctx *context.Context) {
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["NumParticipants"] = len(participants)
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["LockReasons"] = setting.Repository.Issue.LockReasons
ctx.Data["RefEndName"] = git.RefEndName(issue.Ref)
+ ctx.Data["NewPinAllowed"] = pinAllowed
+ ctx.Data["PinEnabled"] = setting.Repository.Issue.MaxPinned != 0
var hiddenCommentTypes *big.Int
if ctx.IsSigned {
diff --git a/routers/web/repo/issue_pin.go b/routers/web/repo/issue_pin.go
new file mode 100644
index 0000000000..13f2d02fe8
--- /dev/null
+++ b/routers/web/repo/issue_pin.go
@@ -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)
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 395fc9425f..a38638c483 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -987,6 +987,7 @@ func registerRoutes(m *web.Route) {
m.Post("/deadline", web.Bind(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline)
m.Post("/watch", repo.IssueWatch)
m.Post("/ref", repo.UpdateIssueRef)
+ m.Post("/pin", reqRepoAdmin, repo.IssuePinOrUnpin)
m.Post("/viewed-files", repo.UpdateViewedFiles)
m.Group("/dependency", func() {
m.Post("/add", repo.AddDependency)
@@ -1024,6 +1025,8 @@ func registerRoutes(m *web.Route) {
m.Post("/resolve_conversation", reqRepoIssuesOrPullsReader, repo.UpdateResolveConversation)
m.Post("/attachments", repo.UploadIssueAttachment)
m.Post("/attachments/remove", repo.DeleteAttachment)
+ m.Delete("/unpin/{id}", reqRepoAdmin, repo.IssueUnpin)
+ m.Post("/pin_move", reqRepoAdmin, repo.IssuePinMove)
}, context.RepoMustNotBeArchived())
m.Group("/comments/{id}", func() {
m.Post("", repo.UpdateCommentContent)
diff --git a/services/convert/issue.go b/services/convert/issue.go
index 3d1b21c6bf..bcb1618e8f 100644
--- a/services/convert/issue.go
+++ b/services/convert/issue.go
@@ -47,6 +47,7 @@ func ToAPIIssue(ctx context.Context, issue *issues_model.Issue) *api.Issue {
Comments: issue.NumComments,
Created: issue.CreatedUnix.AsTime(),
Updated: issue.UpdatedUnix.AsTime(),
+ PinOrder: issue.PinOrder,
}
if issue.Repo != nil {
diff --git a/services/convert/pull.go b/services/convert/pull.go
index 598187ca6e..1ac0f4e96f 100644
--- a/services/convert/pull.go
+++ b/services/convert/pull.go
@@ -72,6 +72,7 @@ func ToAPIPullRequest(ctx context.Context, pr *issues_model.PullRequest, doer *u
Deadline: apiIssue.Deadline,
Created: pr.Issue.CreatedUnix.AsTimePtr(),
Updated: pr.Issue.UpdatedUnix.AsTimePtr(),
+ PinOrder: apiIssue.PinOrder,
AllowMaintainerEdit: pr.AllowMaintainerEdit,
diff --git a/services/issue/issue.go b/services/issue/issue.go
index d4f827e99a..06da47152c 100644
--- a/services/issue/issue.go
+++ b/services/issue/issue.go
@@ -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)
return nil
diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl
index 7c2f73ca59..dab6652d21 100644
--- a/templates/repo/issue/list.tmpl
+++ b/templates/repo/issue/list.tmpl
@@ -2,6 +2,70 @@
<div role="main" aria-label="{{.Title}}" class="page-content repository issue-list">
{{template "repo/header" .}}
<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">
{{template "repo/issue/navbar" .}}
{{template "repo/issue/search" .}}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index b65ebc68a2..5091201dde 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -11,7 +11,7 @@
26 = DELETE_TIME_MANUAL, 27 = REVIEW_REQUEST, 28 = MERGE_PULL_REQUEST,
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,
- 35 = CANCEL_SCHEDULED_AUTO_MERGE_PR -->
+ 35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE -->
{{if eq .Type 0}}
<div class="timeline-item comment" id="{{.HashTag}}">
{{if .OriginalAuthor}}
@@ -835,6 +835,16 @@
{{else}}{{$.locale.Tr "repo.pulls.auto_merge_canceled_schedule_comment" $createdStr | Safe}}{{end}}
</span>
</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}}
diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl
index c8e65d0900..60d2f4a561 100644
--- a/templates/repo/issue/view_content/sidebar.tmpl
+++ b/templates/repo/issue/view_content/sidebar.tmpl
@@ -530,17 +530,31 @@
{{if and .IsRepoAdmin (not .Repository.IsArchived)}}
<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 .Issue.IsLocked}}
- {{svg "octicon-key"}}
- {{.locale.Tr "repo.issues.unlock"}}
- {{else}}
- {{svg "octicon-lock"}}
- {{.locale.Tr "repo.issues.lock"}}
- {{end}}
- </button>
- </div>
+
+ {{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}}
+ {{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="header">
{{if .Issue.IsLocked}}
@@ -605,7 +619,7 @@
</form>
</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"}}
{{.locale.Tr "repo.issues.delete"}}
</button>
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 03a65184c3..15043e465f 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -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}": {
"get": {
"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": {
"get": {
"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": {
"get": {
"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}": {
"get": {
"produces": [
@@ -18664,6 +18901,11 @@
"format": "int64",
"x-go-name": "OriginalAuthorID"
},
+ "pin_order": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "PinOrder"
+ },
"pull_request": {
"$ref": "#/definitions/PullRequestMeta"
},
@@ -19224,6 +19466,21 @@
},
"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": {
"description": "NodeInfo contains standardized way of exposing metadata about a server running one of the distributed social networks",
"type": "object",
@@ -19934,6 +20191,11 @@
"type": "string",
"x-go-name": "PatchURL"
},
+ "pin_order": {
+ "type": "integer",
+ "format": "int64",
+ "x-go-name": "PinOrder"
+ },
"requested_reviewers": {
"type": "array",
"items": {
@@ -22176,6 +22438,12 @@
"$ref": "#/definitions/IssueConfigValidation"
}
},
+ "RepoNewIssuePinsAllowed": {
+ "description": "RepoNewIssuePinsAllowed",
+ "schema": {
+ "$ref": "#/definitions/NewIssuePinsAllowed"
+ }
+ },
"Repository": {
"description": "Repository",
"schema": {
diff --git a/tests/integration/api_issue_pin_test.go b/tests/integration/api_issue_pin_test.go
new file mode 100644
index 0000000000..65be1d74f2
--- /dev/null
+++ b/tests/integration/api_issue_pin_test.go
@@ -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)
+}
diff --git a/web_src/css/repo.css b/web_src/css/repo.css
index dbf9bf79bd..069bf014b8 100644
--- a/web_src/css/repo.css
+++ b/web_src/css/repo.css
@@ -3387,3 +3387,37 @@ tbody.commit-list {
.search-fullname {
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;
+}
diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.js
index af0e80af81..cc50ec5f88 100644
--- a/web_src/js/features/repo-issue-list.js
+++ b/web_src/js/features/repo-issue-list.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import {updateIssuesMeta} from './repo-issue.js';
import {toggleElem} from '../utils/dom.js';
import {htmlEscape} from 'escape-goat';
+import {Sortable} from 'sortablejs';
function initRepoIssueListCheckboxes() {
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() {
if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
initRepoIssueListCheckboxes();
initRepoIssueListAuthorDropdown();
+ initIssuePinSort();
}