diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index c2b721b0cfbd..44445579bdee 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 9616be586d35..1fa4abcef299 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 bf2bbfa4145b..e5c90f265e1f 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 fc046d273c98..5015824e9b34 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 49bc0be4e50f..231c93cc74bb 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 000000000000..47174ce8051a --- /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 5520c992b969..406068b59dd5 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 04e169df8419..a9fb6c6e797b 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 a4a6f60b0568..05a8d596338b 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 01239188c2cd..fc4ed03de553 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 458c99f7fe97..1026a13e3338 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 53c58c4b9b6f..fccfc5792ca7 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 000000000000..c96ede45f51b --- /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 e0418e99dc44..10056ac8cbb8 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 1511716c1e50..1448b772bce0 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 000000000000..13f2d02fe845 --- /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 395fc9425f22..a38638c48340 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 3d1b21c6bf86..bcb1618e8faf 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 598187ca6e0f..1ac0f4e96f1e 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 d4f827e99af5..06da47152c7d 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 7c2f73ca5929..dab6652d2172 100644 --- a/templates/repo/issue/list.tmpl +++ b/templates/repo/issue/list.tmpl @@ -2,6 +2,70 @@