{{template "repo/header" .}}
+ + {{if .PinnedIssues}} +
+ {{range .PinnedIssues}} +
+ {{if eq $.Project.CardType 1}} +
+ {{range (index $.issuesAttachmentMap .ID)}} + {{.Name}} + {{end}} +
+ {{end}} +
+
+
+ {{template "shared/issueicon" .}} +
+ {{.Title | RenderEmoji $.Context | RenderCodeBlock}} + {{if $.IsRepoAdmin}} + + {{svg "octicon-x" 16}} + + {{end}} +
+
+ + #{{.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}} + +
+ {{- if .MilestoneID}} + + {{- end}} +
+ + {{if or .Labels .Assignees}} +
+ {{range .Labels}} + {{RenderLabel $.Context .}} + {{end}} +
+ {{range .Assignees}} + {{avatar $.Context . 28 "mini gt-mr-3"}} + {{end}} +
+
+ {{end}} +
+ {{end}} +
+ {{end}} +
{{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 b65ebc68a24f5..5091201ddecc2 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}}
{{if .OriginalAuthor}} @@ -835,6 +835,16 @@ {{else}}{{$.locale.Tr "repo.pulls.auto_merge_canceled_schedule_comment" $createdStr | Safe}}{{end}}
+ {{else if or (eq .Type 36) (eq .Type 37)}} +
+ {{svg "octicon-pin" 16}} + {{template "shared/user/avatarlink" dict "Context" $.Context "user" .Poster}} + + {{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}} + +
{{end}} {{end}} {{end}} diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index c8e65d0900b5b..60d2f4a5613d5 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)}}
-
- -
+ + {{if or .PinEnabled .Issue.IsPinned}} +
+ {{$.CsrfTokenHtml}} + +
+ {{end}} + + - diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 03a65184c3e2e..15043e465f90e 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 0000000000000..65be1d74f2d33 --- /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 dbf9bf79bd561..069bf014b8729 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 af0e80af81099..cc50ec5f88178 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(); }