diff options
author | JakobDev <jakobdev@gmx.de> | 2023-05-25 15:17:19 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-05-25 15:17:19 +0200 |
commit | aaa109466350c531b9238a61115b2877daca57d3 (patch) | |
tree | 4f5759d3591d6424d80e44e5b8ee97bdd0c7c9ac | |
parent | 79087bdb2676ac383f4bd21137d4454f7a26c8c4 (diff) | |
download | gitea-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>
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(); } |