Skip to content

Add API routes to lock and unlock issues #34165

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions models/issues/issue_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 5 additions & 0 deletions modules/structs/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
1 change: 0 additions & 1 deletion options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions routers/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
152 changes: 152 additions & 0 deletions routers/api/v1/repo/issue_lock.go
Original file line number Diff line number Diff line change
@@ -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)
}
3 changes: 3 additions & 0 deletions routers/api/v1/swagger/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,7 @@ type swaggerParameterBodies struct {

// in:body
UpdateVariableOption api.UpdateVariableOption

// in:body
LockIssueOption api.LockIssueOption
}
5 changes: 0 additions & 5 deletions routers/web/repo/issue_lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 0 additions & 17 deletions services/forms/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)"`
Expand Down
25 changes: 0 additions & 25 deletions services/forms/repo_form_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ package forms
import (
"testing"

"code.gitea.io/gitea/modules/setting"

"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -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())
}
}
Loading