From 098e422dd8346ffdc35ba299b6d639c45b2a8c19 Mon Sep 17 00:00:00 2001 From: YaFou <33806646+YaFou@users.noreply.github.com> Date: Wed, 9 Apr 2025 17:49:34 +0200 Subject: [PATCH 1/8] Add an API endpoint to lock issues --- models/issues/issue_lock.go | 13 ++ models/issues/issue_test.go | 23 +++ modules/structs/issue.go | 5 + routers/api/v1/api.go | 4 + routers/api/v1/repo/issue_lock.go | 169 +++++++++++++++++++++++ routers/api/v1/swagger/options.go | 3 + routers/web/repo/issue_lock.go | 2 +- services/forms/repo_form.go | 17 --- services/forms/repo_form_test.go | 25 ---- templates/swagger/v1_json.tmpl | 127 ++++++++++++++++- tests/integration/api_issue_lock_test.go | 84 +++++++++++ 11 files changed, 428 insertions(+), 44 deletions(-) create mode 100644 routers/api/v1/repo/issue_lock.go create mode 100644 tests/integration/api_issue_lock_test.go diff --git a/models/issues/issue_lock.go b/models/issues/issue_lock.go index b21629b529a33..376efddf6f3ba 100644 --- a/models/issues/issue_lock.go +++ b/models/issues/issue_lock.go @@ -5,9 +5,12 @@ package issues import ( "context" + "slices" + "strings" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" ) // IssueLockOptions defines options for locking and/or unlocking an issue/PR @@ -64,3 +67,13 @@ func updateIssueLock(ctx context.Context, opts *IssueLockOptions, lock bool) err return committer.Commit() } + +// IsValidReason checks to make sure that the reason submitted +// matches any of the values in the config +func IsValidReason(reason string) bool { + if strings.TrimSpace(reason) == "" { + return true + } + + return slices.Contains(setting.Repository.Issue.LockReasons, reason) +} diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 18571e3aaa1bc..9eb5552018a3f 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -466,3 +466,26 @@ func TestMigrate_CreateIssuesIsPullFalse(t *testing.T) { func TestMigrate_CreateIssuesIsPullTrue(t *testing.T) { assertCreateIssues(t, true) } + +func TestIssueLock_IsValidReason(t *testing.T) { + // Init settings + _ = setting.Repository + + cases := []struct { + reason string + expected bool + }{ + {"", true}, // an empty reason is accepted + {"Off-topic", true}, + {"Too heated", true}, + {"Spam", true}, + {"Resolved", true}, + + {"ZZZZ", false}, + {"I want to lock this issue", false}, + } + + for _, v := range cases { + assert.Equal(t, v.expected, issues_model.IsValidReason(v.reason)) + } +} diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 3682191be5751..9b740bb6ff4c8 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -266,3 +266,8 @@ type IssueMeta struct { Owner string `json:"owner"` Name string `json:"repo"` } + +// LockIssueOption options to lock an issue +type LockIssueOption struct { + Reason string `json:"reason"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b9b590725b42b..e8e810fb4e11c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1522,6 +1522,10 @@ func Routes() *web.Router { Delete(reqToken(), reqAdmin(), repo.UnpinIssue) m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin) }) + m.Group("/lock", func() { + m.Combo("").Post(bind(api.LockIssueOption{}), repo.LockIssue). + Delete(repo.UnlockIssue) + }, reqToken()) }) }, mustEnableIssuesOrPulls) m.Group("/labels", func() { diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go new file mode 100644 index 0000000000000..bce51bbc437af --- /dev/null +++ b/routers/api/v1/repo/issue_lock.go @@ -0,0 +1,169 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +// LockIssue lock an issue +func LockIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/lock issue issueLockIssue + // --- + // summary: Lock an issue + // consumes: + // - application/json + // 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 + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/LockIssueOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "208": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + reason := web.GetForm(ctx).(*api.LockIssueOption).Reason + if !issues_model.IsValidReason(reason) { + ctx.APIError(http.StatusBadRequest, errors.New("reason not valid")) + return + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.APIError(http.StatusForbidden, errors.New("no permission to lock this issue")) + return + } + + if issue.IsLocked { + ctx.Status(http.StatusAlreadyReported) + return + } + + opt := &issues_model.IssueLockOptions{ + Doer: ctx.ContextUser, + Issue: issue, + Reason: reason, + } + + issue.Repo = ctx.Repo.Repository + err = issues_model.LockIssue(ctx, opt) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// UnlockIssue unlock an issue +func UnlockIssue(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/lock issue issueUnlockIssue + // --- + // summary: Unlock an issue + // consumes: + // - application/json + // 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 + // - name: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "208": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.APIErrorNotFound(err) + } else { + ctx.APIErrorInternal(err) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.APIError(http.StatusForbidden, errors.New("no permission to unlock this issue")) + return + } + + if !issue.IsLocked { + ctx.Status(http.StatusAlreadyReported) + return + } + + opt := &issues_model.IssueLockOptions{ + Doer: ctx.ContextUser, + Issue: issue, + } + + issue.Repo = ctx.Repo.Repository + err = issues_model.UnlockIssue(ctx, opt) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index aa5990eb38452..d5e042f8fada9 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -216,4 +216,7 @@ type swaggerParameterBodies struct { // in:body UpdateVariableOption api.UpdateVariableOption + + // in:body + LockIssueOption api.LockIssueOption } diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go index 1d5fc8a5f396c..05a2ff4f2a583 100644 --- a/routers/web/repo/issue_lock.go +++ b/routers/web/repo/issue_lock.go @@ -24,7 +24,7 @@ func LockIssue(ctx *context.Context) { return } - if !form.HasValidReason() { + if !issues_model.IsValidReason(form.Reason) { ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason")) return } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 434274c174bed..a2827e516a5d8 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -10,7 +10,6 @@ import ( issues_model "code.gitea.io/gitea/models/issues" project_model "code.gitea.io/gitea/models/project" - "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/context" @@ -473,22 +472,6 @@ func (i *IssueLockForm) Validate(req *http.Request, errs binding.Errors) binding return middleware.Validate(errs, ctx.Data, i, ctx.Locale) } -// HasValidReason checks to make sure that the reason submitted in -// the form matches any of the values in the config -func (i IssueLockForm) HasValidReason() bool { - if strings.TrimSpace(i.Reason) == "" { - return true - } - - for _, v := range setting.Repository.Issue.LockReasons { - if v == i.Reason { - return true - } - } - - return false -} - // CreateProjectForm form for creating a project type CreateProjectForm struct { Title string `binding:"Required;MaxSize(100)"` diff --git a/services/forms/repo_form_test.go b/services/forms/repo_form_test.go index 2c5a8e2c0fc52..a0c67fe0f8813 100644 --- a/services/forms/repo_form_test.go +++ b/services/forms/repo_form_test.go @@ -6,8 +6,6 @@ package forms import ( "testing" - "code.gitea.io/gitea/modules/setting" - "github.com/stretchr/testify/assert" ) @@ -39,26 +37,3 @@ func TestSubmitReviewForm_IsEmpty(t *testing.T) { assert.Equal(t, v.expected, v.form.HasEmptyContent()) } } - -func TestIssueLock_HasValidReason(t *testing.T) { - // Init settings - _ = setting.Repository - - cases := []struct { - form IssueLockForm - expected bool - }{ - {IssueLockForm{""}, true}, // an empty reason is accepted - {IssueLockForm{"Off-topic"}, true}, - {IssueLockForm{"Too heated"}, true}, - {IssueLockForm{"Spam"}, true}, - {IssueLockForm{"Resolved"}, true}, - - {IssueLockForm{"ZZZZ"}, false}, - {IssueLockForm{"I want to lock this issue"}, false}, - } - - for _, v := range cases { - assert.Equal(t, v.expected, v.form.HasValidReason()) - } -} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 244bc9f9c0eb2..db57d52437c85 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -10090,6 +10090,120 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/lock": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Lock an issue", + "operationId": "issueLockIssue", + "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 the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/LockIssueOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "208": { + "$ref": "#/responses/empty" + }, + "400": { + "$ref": "#/responses/error" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Unlock an issue", + "operationId": "issueUnlockIssue", + "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 the issue", + "name": "index", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "208": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/pin": { "post": { "tags": [ @@ -23770,6 +23884,17 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "LockIssueOption": { + "description": "LockIssueOption options to lock an issue", + "type": "object", + "properties": { + "reason": { + "type": "string", + "x-go-name": "Reason" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "MarkdownOption": { "description": "MarkdownOption markdown options", "type": "object", @@ -27667,7 +27792,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/UpdateVariableOption" + "$ref": "#/definitions/LockIssueOption" } }, "redirect": { diff --git a/tests/integration/api_issue_lock_test.go b/tests/integration/api_issue_lock_test.go new file mode 100644 index 0000000000000..c4877f87680d9 --- /dev/null +++ b/tests/integration/api_issue_lock_test.go @@ -0,0 +1,84 @@ +// Copyright 2017 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 TestAPILockIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.False(t, issueBefore.IsLocked) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // check invalid reason + req := NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Not valid"}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusBadRequest) + + // check lock issue + req = NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.True(t, issueAfter.IsLocked) + + // check locking a second time + MakeRequest(t, req, http.StatusAlreadyReported) + + // check with other user + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + session34 := loginUser(t, user34.Name) + token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) + req = NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34) + MakeRequest(t, req, http.StatusForbidden) +} + +func TestAPIUnlockIssue(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + lockReq := NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + MakeRequest(t, lockReq, http.StatusNoContent) + + // check unlock issue + req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.False(t, issueAfter.IsLocked) + + // check unlocking a second time + MakeRequest(t, req, http.StatusAlreadyReported) + + // check with other user + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + session34 := loginUser(t, user34.Name) + token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) + req = NewRequest(t, "DELETE", urlStr).AddTokenAuth(token34) + MakeRequest(t, req, http.StatusForbidden) +} From 0cc45233a5fb44f63bc0061f98dad4e0ae7295e7 Mon Sep 17 00:00:00 2001 From: YaFou <33806646+YaFou@users.noreply.github.com> Date: Wed, 9 Apr 2025 23:22:11 +0200 Subject: [PATCH 2/8] Change lock issue API endpoint to fit GitHub specifications --- models/issues/issue_test.go | 3 - modules/structs/issue.go | 2 +- routers/api/v1/api.go | 3 +- routers/api/v1/repo/issue_lock.go | 71 ++++++++++++------------ templates/swagger/v1_json.tmpl | 10 +--- tests/integration/api_issue_lock_test.go | 25 +++++---- 6 files changed, 56 insertions(+), 58 deletions(-) diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index 9eb5552018a3f..e2abc3de88803 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -468,9 +468,6 @@ func TestMigrate_CreateIssuesIsPullTrue(t *testing.T) { } func TestIssueLock_IsValidReason(t *testing.T) { - // Init settings - _ = setting.Repository - cases := []struct { reason string expected bool diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 9b740bb6ff4c8..6a6b74c34e978 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -269,5 +269,5 @@ type IssueMeta struct { // LockIssueOption options to lock an issue type LockIssueOption struct { - Reason string `json:"reason"` + Reason string `json:"lock_reason"` } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e8e810fb4e11c..5a4ce26b2e8bc 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1523,7 +1523,8 @@ func Routes() *web.Router { m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin) }) m.Group("/lock", func() { - m.Combo("").Post(bind(api.LockIssueOption{}), repo.LockIssue). + m.Combo(""). + Put(bind(api.LockIssueOption{}), repo.LockIssue). Delete(repo.UnlockIssue) }, reqToken()) }) diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go index bce51bbc437af..9608021403102 100644 --- a/routers/api/v1/repo/issue_lock.go +++ b/routers/api/v1/repo/issue_lock.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -6,6 +6,8 @@ package repo import ( "errors" "net/http" + "strings" + "unicode" issues_model "code.gitea.io/gitea/models/issues" api "code.gitea.io/gitea/modules/structs" @@ -15,7 +17,7 @@ import ( // LockIssue lock an issue func LockIssue(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/lock issue issueLockIssue + // swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/lock issue issueLockIssue // --- // summary: Lock an issue // consumes: @@ -46,8 +48,6 @@ func LockIssue(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" - // "208": - // "$ref": "#/responses/empty" // "400": // "$ref": "#/responses/error" // "403": @@ -56,6 +56,15 @@ func LockIssue(ctx *context.APIContext) { // "$ref": "#/responses/notFound" reason := web.GetForm(ctx).(*api.LockIssueOption).Reason + reason = strings.ToLower(reason) + + if reason != "" { + // make the first character uppercase + runes := []rune(reason) + runes[0] = unicode.ToUpper(runes[0]) + reason = string(runes) + } + if !issues_model.IsValidReason(reason) { ctx.APIError(http.StatusBadRequest, errors.New("reason not valid")) return @@ -76,22 +85,19 @@ func LockIssue(ctx *context.APIContext) { return } - if issue.IsLocked { - ctx.Status(http.StatusAlreadyReported) - return - } - - opt := &issues_model.IssueLockOptions{ - Doer: ctx.ContextUser, - Issue: issue, - Reason: reason, - } + if !issue.IsLocked { + opt := &issues_model.IssueLockOptions{ + Doer: ctx.ContextUser, + Issue: issue, + Reason: reason, + } - issue.Repo = ctx.Repo.Repository - err = issues_model.LockIssue(ctx, opt) - if err != nil { - ctx.APIErrorInternal(err) - return + issue.Repo = ctx.Repo.Repository + err = issues_model.LockIssue(ctx, opt) + if err != nil { + ctx.APIErrorInternal(err) + return + } } ctx.Status(http.StatusNoContent) @@ -126,8 +132,6 @@ func UnlockIssue(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" - // "208": - // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" // "404": @@ -148,21 +152,18 @@ func UnlockIssue(ctx *context.APIContext) { return } - if !issue.IsLocked { - ctx.Status(http.StatusAlreadyReported) - return - } - - opt := &issues_model.IssueLockOptions{ - Doer: ctx.ContextUser, - Issue: issue, - } + if issue.IsLocked { + opt := &issues_model.IssueLockOptions{ + Doer: ctx.ContextUser, + Issue: issue, + } - issue.Repo = ctx.Repo.Repository - err = issues_model.UnlockIssue(ctx, opt) - if err != nil { - ctx.APIErrorInternal(err) - return + issue.Repo = ctx.Repo.Repository + err = issues_model.UnlockIssue(ctx, opt) + if err != nil { + ctx.APIErrorInternal(err) + return + } } ctx.Status(http.StatusNoContent) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index db57d52437c85..8cf66a51ab700 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -10091,7 +10091,7 @@ } }, "/repos/{owner}/{repo}/issues/{index}/lock": { - "post": { + "put": { "consumes": [ "application/json" ], @@ -10138,9 +10138,6 @@ "204": { "$ref": "#/responses/empty" }, - "208": { - "$ref": "#/responses/empty" - }, "400": { "$ref": "#/responses/error" }, @@ -10192,9 +10189,6 @@ "204": { "$ref": "#/responses/empty" }, - "208": { - "$ref": "#/responses/empty" - }, "403": { "$ref": "#/responses/forbidden" }, @@ -23888,7 +23882,7 @@ "description": "LockIssueOption options to lock an issue", "type": "object", "properties": { - "reason": { + "lock_reason": { "type": "string", "x-go-name": "Reason" } diff --git a/tests/integration/api_issue_lock_test.go b/tests/integration/api_issue_lock_test.go index c4877f87680d9..6cdffb704db50 100644 --- a/tests/integration/api_issue_lock_test.go +++ b/tests/integration/api_issue_lock_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2025 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package integration @@ -32,23 +32,31 @@ func TestAPILockIssue(t *testing.T) { token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) // check invalid reason - req := NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Not valid"}).AddTokenAuth(token) + req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Not valid"}).AddTokenAuth(token) MakeRequest(t, req, http.StatusBadRequest) // check lock issue - req = NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) assert.True(t, issueAfter.IsLocked) - // check locking a second time - MakeRequest(t, req, http.StatusAlreadyReported) + // check reason is case insensitive + unlockReq := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token) + MakeRequest(t, unlockReq, http.StatusNoContent) + issueAfter = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.False(t, issueAfter.IsLocked) + + req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "too heated"}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + issueAfter = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.True(t, issueAfter.IsLocked) // check with other user user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) session34 := loginUser(t, user34.Name) token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) - req = NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34) + req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34) MakeRequest(t, req, http.StatusForbidden) } @@ -63,7 +71,7 @@ func TestAPIUnlockIssue(t *testing.T) { session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - lockReq := NewRequestWithJSON(t, "POST", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + lockReq := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) MakeRequest(t, lockReq, http.StatusNoContent) // check unlock issue @@ -72,9 +80,6 @@ func TestAPIUnlockIssue(t *testing.T) { issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) assert.False(t, issueAfter.IsLocked) - // check unlocking a second time - MakeRequest(t, req, http.StatusAlreadyReported) - // check with other user user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) session34 := loginUser(t, user34.Name) From 0b134aa49fb40f81e6ad41d2311665a6008d2787 Mon Sep 17 00:00:00 2001 From: YaFou <33806646+YaFou@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:02:54 +0200 Subject: [PATCH 3/8] Add admin permission to lock issues via API --- routers/api/v1/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5a4ce26b2e8bc..712f7f2335cc6 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1526,7 +1526,7 @@ func Routes() *web.Router { m.Combo(""). Put(bind(api.LockIssueOption{}), repo.LockIssue). Delete(repo.UnlockIssue) - }, reqToken()) + }, reqToken(), reqAdmin()) }) }, mustEnableIssuesOrPulls) m.Group("/labels", func() { From ee862f4e93bf31c4396465c730575520001fc2fe Mon Sep 17 00:00:00 2001 From: YaFou <33806646+YaFou@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:16:30 +0200 Subject: [PATCH 4/8] Change method to normalize lock reason in the API endpoint --- routers/api/v1/repo/issue_lock.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go index 9608021403102..6605bea8df546 100644 --- a/routers/api/v1/repo/issue_lock.go +++ b/routers/api/v1/repo/issue_lock.go @@ -7,12 +7,14 @@ import ( "errors" "net/http" "strings" - "unicode" issues_model "code.gitea.io/gitea/models/issues" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" + + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // LockIssue lock an issue @@ -55,15 +57,12 @@ func LockIssue(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" + caser := cases.Title(language.English) reason := web.GetForm(ctx).(*api.LockIssueOption).Reason reason = strings.ToLower(reason) - - if reason != "" { - // make the first character uppercase - runes := []rune(reason) - runes[0] = unicode.ToUpper(runes[0]) - reason = string(runes) - } + reason_parts := strings.Split(reason, " ") + reason_parts[0] = caser.String(reason_parts[0]) + reason = strings.Join(reason_parts, " ") if !issues_model.IsValidReason(reason) { ctx.APIError(http.StatusBadRequest, errors.New("reason not valid")) From baf6217a1d90e72751d29e3e4c3cb3e268f98a3c Mon Sep 17 00:00:00 2001 From: YaFou <33806646+YaFou@users.noreply.github.com> Date: Tue, 15 Apr 2025 17:59:31 +0200 Subject: [PATCH 5/8] Fix code style --- routers/api/v1/repo/issue_lock.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go index 6605bea8df546..9dda69ba59fd9 100644 --- a/routers/api/v1/repo/issue_lock.go +++ b/routers/api/v1/repo/issue_lock.go @@ -60,9 +60,9 @@ func LockIssue(ctx *context.APIContext) { caser := cases.Title(language.English) reason := web.GetForm(ctx).(*api.LockIssueOption).Reason reason = strings.ToLower(reason) - reason_parts := strings.Split(reason, " ") - reason_parts[0] = caser.String(reason_parts[0]) - reason = strings.Join(reason_parts, " ") + reasonParts := strings.Split(reason, " ") + reasonParts[0] = caser.String(reasonParts[0]) + reason = strings.Join(reasonParts, " ") if !issues_model.IsValidReason(reason) { ctx.APIError(http.StatusBadRequest, errors.New("reason not valid")) From 40e533c39da6d785bad8fa1c3361208e124c510a Mon Sep 17 00:00:00 2001 From: YaFou <33806646+YaFou@users.noreply.github.com> Date: Sun, 20 Apr 2025 12:12:04 +0200 Subject: [PATCH 6/8] Remove all lock reason checks --- models/issues/issue_lock.go | 13 ------------- models/issues/issue_test.go | 20 -------------------- routers/api/v1/repo/issue_lock.go | 7 ------- routers/web/repo/issue_lock.go | 5 ----- templates/swagger/v1_json.tmpl | 3 --- tests/integration/api_issue_lock_test.go | 6 +----- 6 files changed, 1 insertion(+), 53 deletions(-) diff --git a/models/issues/issue_lock.go b/models/issues/issue_lock.go index 376efddf6f3ba..b21629b529a33 100644 --- a/models/issues/issue_lock.go +++ b/models/issues/issue_lock.go @@ -5,12 +5,9 @@ package issues import ( "context" - "slices" - "strings" "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/setting" ) // IssueLockOptions defines options for locking and/or unlocking an issue/PR @@ -67,13 +64,3 @@ func updateIssueLock(ctx context.Context, opts *IssueLockOptions, lock bool) err return committer.Commit() } - -// IsValidReason checks to make sure that the reason submitted -// matches any of the values in the config -func IsValidReason(reason string) bool { - if strings.TrimSpace(reason) == "" { - return true - } - - return slices.Contains(setting.Repository.Issue.LockReasons, reason) -} diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index e2abc3de88803..18571e3aaa1bc 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -466,23 +466,3 @@ func TestMigrate_CreateIssuesIsPullFalse(t *testing.T) { func TestMigrate_CreateIssuesIsPullTrue(t *testing.T) { assertCreateIssues(t, true) } - -func TestIssueLock_IsValidReason(t *testing.T) { - cases := []struct { - reason string - expected bool - }{ - {"", true}, // an empty reason is accepted - {"Off-topic", true}, - {"Too heated", true}, - {"Spam", true}, - {"Resolved", true}, - - {"ZZZZ", false}, - {"I want to lock this issue", false}, - } - - for _, v := range cases { - assert.Equal(t, v.expected, issues_model.IsValidReason(v.reason)) - } -} diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go index 9dda69ba59fd9..038abf066ebac 100644 --- a/routers/api/v1/repo/issue_lock.go +++ b/routers/api/v1/repo/issue_lock.go @@ -50,8 +50,6 @@ func LockIssue(ctx *context.APIContext) { // responses: // "204": // "$ref": "#/responses/empty" - // "400": - // "$ref": "#/responses/error" // "403": // "$ref": "#/responses/forbidden" // "404": @@ -64,11 +62,6 @@ func LockIssue(ctx *context.APIContext) { reasonParts[0] = caser.String(reasonParts[0]) reason = strings.Join(reasonParts, " ") - if !issues_model.IsValidReason(reason) { - ctx.APIError(http.StatusBadRequest, errors.New("reason not valid")) - return - } - issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrIssueNotExist(err) { diff --git a/routers/web/repo/issue_lock.go b/routers/web/repo/issue_lock.go index 05a2ff4f2a583..bc8aabd90b6f7 100644 --- a/routers/web/repo/issue_lock.go +++ b/routers/web/repo/issue_lock.go @@ -24,11 +24,6 @@ func LockIssue(ctx *context.Context) { return } - if !issues_model.IsValidReason(form.Reason) { - ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason")) - return - } - if err := issues_model.LockIssue(ctx, &issues_model.IssueLockOptions{ Doer: ctx.Doer, Issue: issue, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8cf66a51ab700..b9d45bc7be485 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -10138,9 +10138,6 @@ "204": { "$ref": "#/responses/empty" }, - "400": { - "$ref": "#/responses/error" - }, "403": { "$ref": "#/responses/forbidden" }, diff --git a/tests/integration/api_issue_lock_test.go b/tests/integration/api_issue_lock_test.go index 6cdffb704db50..c826a6634f188 100644 --- a/tests/integration/api_issue_lock_test.go +++ b/tests/integration/api_issue_lock_test.go @@ -31,12 +31,8 @@ func TestAPILockIssue(t *testing.T) { session := loginUser(t, owner.Name) token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - // check invalid reason - req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Not valid"}).AddTokenAuth(token) - MakeRequest(t, req, http.StatusBadRequest) - // check lock issue - req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) MakeRequest(t, req, http.StatusNoContent) issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) assert.True(t, issueAfter.IsLocked) From f7959bcb8b942e4b46f71b9e8e5e5967b1e8e9eb Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 20 Apr 2025 18:58:03 +0800 Subject: [PATCH 7/8] improve comments and tests --- models/issues/issue_lock.go | 10 ++- routers/api/v1/repo/issue_lock.go | 10 --- tests/integration/api_issue_lock_test.go | 109 ++++++++++------------- 3 files changed, 57 insertions(+), 72 deletions(-) diff --git a/models/issues/issue_lock.go b/models/issues/issue_lock.go index b21629b529a33..fa0d128f747d6 100644 --- a/models/issues/issue_lock.go +++ b/models/issues/issue_lock.go @@ -12,8 +12,14 @@ import ( // IssueLockOptions defines options for locking and/or unlocking an issue/PR type IssueLockOptions struct { - Doer *user_model.User - Issue *Issue + Doer *user_model.User + Issue *Issue + + // Reason is the doer-provided comment message for the locked issue + // GitHub doesn't support changing the "reasons" by config file, so GitHub has pre-defined "reason" enum values. + // Gitea is not like GitHub, it allows site admin to define customized "reasons" in the config file. + // So the API caller might not know what kind of "reasons" are valid, and the customized reasons are not translatable. + // To make things clear and simple: doer have the chance to use any reason they like, we do not do validation. Reason string } diff --git a/routers/api/v1/repo/issue_lock.go b/routers/api/v1/repo/issue_lock.go index 038abf066ebac..b9e5bcf6eba44 100644 --- a/routers/api/v1/repo/issue_lock.go +++ b/routers/api/v1/repo/issue_lock.go @@ -6,15 +6,11 @@ package repo import ( "errors" "net/http" - "strings" issues_model "code.gitea.io/gitea/models/issues" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/context" - - "golang.org/x/text/cases" - "golang.org/x/text/language" ) // LockIssue lock an issue @@ -55,13 +51,7 @@ func LockIssue(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - caser := cases.Title(language.English) reason := web.GetForm(ctx).(*api.LockIssueOption).Reason - reason = strings.ToLower(reason) - reasonParts := strings.Split(reason, " ") - reasonParts[0] = caser.String(reasonParts[0]) - reason = strings.Join(reasonParts, " ") - issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrIssueNotExist(err) { diff --git a/tests/integration/api_issue_lock_test.go b/tests/integration/api_issue_lock_test.go index c826a6634f188..47b1f2cf0da6f 100644 --- a/tests/integration/api_issue_lock_test.go +++ b/tests/integration/api_issue_lock_test.go @@ -22,64 +22,53 @@ import ( func TestAPILockIssue(t *testing.T) { defer tests.PrepareTestEnv(t)() - issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - assert.False(t, issueBefore.IsLocked) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - - // check lock issue - req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - assert.True(t, issueAfter.IsLocked) - - // check reason is case insensitive - unlockReq := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token) - MakeRequest(t, unlockReq, http.StatusNoContent) - issueAfter = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - assert.False(t, issueAfter.IsLocked) - - req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "too heated"}).AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) - issueAfter = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - assert.True(t, issueAfter.IsLocked) - - // check with other user - user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) - session34 := loginUser(t, user34.Name) - token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) - req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34) - MakeRequest(t, req, http.StatusForbidden) -} - -func TestAPIUnlockIssue(t *testing.T) { - defer tests.PrepareTestEnv(t)() - - issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) - owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index) - - session := loginUser(t, owner.Name) - token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) - - lockReq := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) - MakeRequest(t, lockReq, http.StatusNoContent) - - // check unlock issue - req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token) - MakeRequest(t, req, http.StatusNoContent) - issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) - assert.False(t, issueAfter.IsLocked) - - // check with other user - user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) - session34 := loginUser(t, user34.Name) - token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) - req = NewRequest(t, "DELETE", urlStr).AddTokenAuth(token34) - MakeRequest(t, req, http.StatusForbidden) + t.Run("Lock", func(t *testing.T) { + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.False(t, issueBefore.IsLocked) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + // check lock issue + req := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.True(t, issueAfter.IsLocked) + + // check with other user + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + session34 := loginUser(t, user34.Name) + token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) + req = NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token34) + MakeRequest(t, req, http.StatusForbidden) + }) + + t.Run("Unlock", func(t *testing.T) { + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/lock", owner.Name, repo.Name, issueBefore.Index) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + + lockReq := NewRequestWithJSON(t, "PUT", urlStr, api.LockIssueOption{Reason: "Spam"}).AddTokenAuth(token) + MakeRequest(t, lockReq, http.StatusNoContent) + + // check unlock issue + req := NewRequest(t, "DELETE", urlStr).AddTokenAuth(token) + MakeRequest(t, req, http.StatusNoContent) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + assert.False(t, issueAfter.IsLocked) + + // check with other user + user34 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 34}) + session34 := loginUser(t, user34.Name) + token34 := getTokenForLoggedInUser(t, session34, auth_model.AccessTokenScopeAll) + req = NewRequest(t, "DELETE", urlStr).AddTokenAuth(token34) + MakeRequest(t, req, http.StatusForbidden) + }) } From ac53e8b48f7bd49bb9c543c34da1c4cf8342e542 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sun, 20 Apr 2025 19:23:42 +0800 Subject: [PATCH 8/8] remove unused translation --- options/locale/locale_en-US.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 54089be24a1c2..508b4cff374f2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1681,7 +1681,6 @@ 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. issues.lock_duplicate = An issue cannot be locked twice. issues.unlock_error = Cannot unlock an issue that is not locked. issues.lock_with_reason = "locked as %s and limited conversation to collaborators %s"