diff --git a/models/issues/issue_lock.go b/models/issues/issue_lock.go index b21629b529..fa0d128f74 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/modules/structs/issue.go b/modules/structs/issue.go index 3682191be5..6a6b74c34e 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:"lock_reason"` +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 54089be24a..508b4cff37 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 <strong>%s</strong> and limited conversation to collaborators %s" diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e77118f4ff..58d0891ea5 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1530,6 +1530,11 @@ func Routes() *web.Router { Delete(reqToken(), reqAdmin(), repo.UnpinIssue) m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin) }) + m.Group("/lock", func() { + m.Combo(""). + Put(bind(api.LockIssueOption{}), repo.LockIssue). + Delete(repo.UnlockIssue) + }, reqToken(), reqAdmin()) }) }, 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..b9e5bcf6eb --- /dev/null +++ b/routers/api/v1/repo/issue_lock.go @@ -0,0 +1,152 @@ +// Copyright 2025 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 PUT /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" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + reason := web.GetForm(ctx).(*api.LockIssueOption).Reason + 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 { + 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" + // "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 { + 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..bc8aabd90b 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 !form.HasValidReason() { - 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/services/forms/repo_form.go b/services/forms/repo_form.go index 434274c174..a2827e516a 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 97438aced9..99d3c994f9 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -10484,6 +10484,111 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/lock": { + "put": { + "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" + }, + "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" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/pin": { "post": { "tags": [ @@ -24338,6 +24443,17 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "LockIssueOption": { + "description": "LockIssueOption options to lock an issue", + "type": "object", + "properties": { + "lock_reason": { + "type": "string", + "x-go-name": "Reason" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "MarkdownOption": { "description": "MarkdownOption markdown options", "type": "object", @@ -28247,7 +28363,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..47b1f2cf0d --- /dev/null +++ b/tests/integration/api_issue_lock_test.go @@ -0,0 +1,74 @@ +// Copyright 2025 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)() + + 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) + }) +}