From 940c8c0ba19eae050e5cd27dcbfcdda5c9ea8df0 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/3] 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 b21629b529..376efddf6f 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 18571e3aaa..9eb5552018 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 3682191be5..9b740bb6ff 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 5cd08a3618..f7b22517ac 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 0000000000..bce51bbc43 --- /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 aa5990eb38..d5e042f8fa 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 1d5fc8a5f3..05a2ff4f2a 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 d20220b784..35d336a2f7 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 2c5a8e2c0f..a0c67fe0f8 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 d0e41e8094..e9b8dfbe34 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -9992,6 +9992,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": [ @@ -23672,6 +23786,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", @@ -27568,7 +27693,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 0000000000..c4877f8768 --- /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 8efc9d41f9ea9eb7dcfa8e7e8d3735a57d4974e6 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/3] 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 9eb5552018..e2abc3de88 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 9b740bb6ff..6a6b74c34e 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 f7b22517ac..c2e0e1aa34 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 bce51bbc43..9608021403 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 - } + if !issue.IsLocked { + opt := &issues_model.IssueLockOptions{ + Doer: ctx.ContextUser, + Issue: issue, + Reason: reason, + } - 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 - } + if issue.IsLocked { + opt := &issues_model.IssueLockOptions{ + Doer: ctx.ContextUser, + Issue: issue, + } - 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 e9b8dfbe34..ec41c3fe85 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -9993,7 +9993,7 @@ } }, "/repos/{owner}/{repo}/issues/{index}/lock": { - "post": { + "put": { "consumes": [ "application/json" ], @@ -10040,9 +10040,6 @@ "204": { "$ref": "#/responses/empty" }, - "208": { - "$ref": "#/responses/empty" - }, "400": { "$ref": "#/responses/error" }, @@ -10094,9 +10091,6 @@ "204": { "$ref": "#/responses/empty" }, - "208": { - "$ref": "#/responses/empty" - }, "403": { "$ref": "#/responses/forbidden" }, @@ -23790,7 +23784,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 c4877f8768..6cdffb704d 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 aa0b2436f317986992cfbb7ab37771826a67f89a 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/3] 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 c2e0e1aa34..af3192aea8 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() {