mirror of
				https://github.com/go-gitea/gitea
				synced 2025-09-28 03:28:13 +00:00 
			
		
		
		
	Add API routes to lock and unlock issues (#34165)
This pull request adds a GitHub-compatible API endpoint to lock and
unlock an issue.
The following routes exist now:
- `PUT /api/v1/repos/{owner}/{repo}/issues/{id}/lock` to lock an issue
- `DELETE /api/v1/repos/{owner}/{repo}/issues/{id}/lock` to unlock an issue
Fixes #33677
Fixes #20012
---------
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
			
			
This commit is contained in:
		| @@ -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 | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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"` | ||||
| } | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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() { | ||||
|   | ||||
							
								
								
									
										152
									
								
								routers/api/v1/repo/issue_lock.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								routers/api/v1/repo/issue_lock.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
| @@ -216,4 +216,7 @@ type swaggerParameterBodies struct { | ||||
|  | ||||
| 	// in:body | ||||
| 	UpdateVariableOption api.UpdateVariableOption | ||||
|  | ||||
| 	// in:body | ||||
| 	LockIssueOption api.LockIssueOption | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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)"` | ||||
|   | ||||
| @@ -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()) | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										118
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										118
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							| @@ -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": { | ||||
|   | ||||
							
								
								
									
										74
									
								
								tests/integration/api_issue_lock_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								tests/integration/api_issue_lock_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	}) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user