Skip to content

Commit 80a09e1

Browse files
committed
Add an API endpoint to lock issues
1 parent f8edc29 commit 80a09e1

File tree

11 files changed

+428
-44
lines changed

11 files changed

+428
-44
lines changed

models/issues/issue_lock.go

+13
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ package issues
55

66
import (
77
"context"
8+
"slices"
9+
"strings"
810

911
"code.gitea.io/gitea/models/db"
1012
user_model "code.gitea.io/gitea/models/user"
13+
"code.gitea.io/gitea/modules/setting"
1114
)
1215

1316
// 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
6467

6568
return committer.Commit()
6669
}
70+
71+
// IsValidReason checks to make sure that the reason submitted
72+
// matches any of the values in the config
73+
func IsValidReason(reason string) bool {
74+
if strings.TrimSpace(reason) == "" {
75+
return true
76+
}
77+
78+
return slices.Contains(setting.Repository.Issue.LockReasons, reason)
79+
}

models/issues/issue_test.go

+23
Original file line numberDiff line numberDiff line change
@@ -466,3 +466,26 @@ func TestMigrate_CreateIssuesIsPullFalse(t *testing.T) {
466466
func TestMigrate_CreateIssuesIsPullTrue(t *testing.T) {
467467
assertCreateIssues(t, true)
468468
}
469+
470+
func TestIssueLock_IsValidReason(t *testing.T) {
471+
// Init settings
472+
_ = setting.Repository
473+
474+
cases := []struct {
475+
reason string
476+
expected bool
477+
}{
478+
{"", true}, // an empty reason is accepted
479+
{"Off-topic", true},
480+
{"Too heated", true},
481+
{"Spam", true},
482+
{"Resolved", true},
483+
484+
{"ZZZZ", false},
485+
{"I want to lock this issue", false},
486+
}
487+
488+
for _, v := range cases {
489+
assert.Equal(t, v.expected, issues_model.IsValidReason(v.reason))
490+
}
491+
}

modules/structs/issue.go

+5
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,8 @@ type IssueMeta struct {
266266
Owner string `json:"owner"`
267267
Name string `json:"repo"`
268268
}
269+
270+
// LockIssueOption options to lock an issue
271+
type LockIssueOption struct {
272+
Reason string `json:"reason"`
273+
}

routers/api/v1/api.go

+4
Original file line numberDiff line numberDiff line change
@@ -1522,6 +1522,10 @@ func Routes() *web.Router {
15221522
Delete(reqToken(), reqAdmin(), repo.UnpinIssue)
15231523
m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin)
15241524
})
1525+
m.Group("/lock", func() {
1526+
m.Combo("").Post(bind(api.LockIssueOption{}), repo.LockIssue).
1527+
Delete(repo.UnlockIssue)
1528+
}, reqToken())
15251529
})
15261530
}, mustEnableIssuesOrPulls)
15271531
m.Group("/labels", func() {

routers/api/v1/repo/issue_lock.go

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package repo
5+
6+
import (
7+
"errors"
8+
"net/http"
9+
10+
issues_model "code.gitea.io/gitea/models/issues"
11+
api "code.gitea.io/gitea/modules/structs"
12+
"code.gitea.io/gitea/modules/web"
13+
"code.gitea.io/gitea/services/context"
14+
)
15+
16+
// LockIssue lock an issue
17+
func LockIssue(ctx *context.APIContext) {
18+
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/lock issue issueLockIssue
19+
// ---
20+
// summary: Lock an issue
21+
// consumes:
22+
// - application/json
23+
// produces:
24+
// - application/json
25+
// parameters:
26+
// - name: owner
27+
// in: path
28+
// description: owner of the repo
29+
// type: string
30+
// required: true
31+
// - name: repo
32+
// in: path
33+
// description: name of the repo
34+
// type: string
35+
// required: true
36+
// - name: index
37+
// in: path
38+
// description: index of the issue
39+
// type: integer
40+
// format: int64
41+
// required: true
42+
// - name: body
43+
// in: body
44+
// schema:
45+
// "$ref": "#/definitions/LockIssueOption"
46+
// responses:
47+
// "204":
48+
// "$ref": "#/responses/empty"
49+
// "208":
50+
// "$ref": "#/responses/empty"
51+
// "400":
52+
// "$ref": "#/responses/error"
53+
// "403":
54+
// "$ref": "#/responses/forbidden"
55+
// "404":
56+
// "$ref": "#/responses/notFound"
57+
58+
reason := web.GetForm(ctx).(*api.LockIssueOption).Reason
59+
if !issues_model.IsValidReason(reason) {
60+
ctx.APIError(http.StatusBadRequest, errors.New("reason not valid"))
61+
return
62+
}
63+
64+
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
65+
if err != nil {
66+
if issues_model.IsErrIssueNotExist(err) {
67+
ctx.APIErrorNotFound(err)
68+
} else {
69+
ctx.APIErrorInternal(err)
70+
}
71+
return
72+
}
73+
74+
if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
75+
ctx.APIError(http.StatusForbidden, errors.New("no permission to lock this issue"))
76+
return
77+
}
78+
79+
if issue.IsLocked {
80+
ctx.Status(http.StatusAlreadyReported)
81+
return
82+
}
83+
84+
opt := &issues_model.IssueLockOptions{
85+
Doer: ctx.ContextUser,
86+
Issue: issue,
87+
Reason: reason,
88+
}
89+
90+
issue.Repo = ctx.Repo.Repository
91+
err = issues_model.LockIssue(ctx, opt)
92+
if err != nil {
93+
ctx.APIErrorInternal(err)
94+
return
95+
}
96+
97+
ctx.Status(http.StatusNoContent)
98+
}
99+
100+
// UnlockIssue unlock an issue
101+
func UnlockIssue(ctx *context.APIContext) {
102+
// swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/lock issue issueUnlockIssue
103+
// ---
104+
// summary: Unlock an issue
105+
// consumes:
106+
// - application/json
107+
// produces:
108+
// - application/json
109+
// parameters:
110+
// - name: owner
111+
// in: path
112+
// description: owner of the repo
113+
// type: string
114+
// required: true
115+
// - name: repo
116+
// in: path
117+
// description: name of the repo
118+
// type: string
119+
// required: true
120+
// - name: index
121+
// in: path
122+
// description: index of the issue
123+
// type: integer
124+
// format: int64
125+
// required: true
126+
// responses:
127+
// "204":
128+
// "$ref": "#/responses/empty"
129+
// "208":
130+
// "$ref": "#/responses/empty"
131+
// "403":
132+
// "$ref": "#/responses/forbidden"
133+
// "404":
134+
// "$ref": "#/responses/notFound"
135+
136+
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index"))
137+
if err != nil {
138+
if issues_model.IsErrIssueNotExist(err) {
139+
ctx.APIErrorNotFound(err)
140+
} else {
141+
ctx.APIErrorInternal(err)
142+
}
143+
return
144+
}
145+
146+
if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) {
147+
ctx.APIError(http.StatusForbidden, errors.New("no permission to unlock this issue"))
148+
return
149+
}
150+
151+
if !issue.IsLocked {
152+
ctx.Status(http.StatusAlreadyReported)
153+
return
154+
}
155+
156+
opt := &issues_model.IssueLockOptions{
157+
Doer: ctx.ContextUser,
158+
Issue: issue,
159+
}
160+
161+
issue.Repo = ctx.Repo.Repository
162+
err = issues_model.UnlockIssue(ctx, opt)
163+
if err != nil {
164+
ctx.APIErrorInternal(err)
165+
return
166+
}
167+
168+
ctx.Status(http.StatusNoContent)
169+
}

routers/api/v1/swagger/options.go

+3
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,7 @@ type swaggerParameterBodies struct {
216216

217217
// in:body
218218
UpdateVariableOption api.UpdateVariableOption
219+
220+
// in:body
221+
LockIssueOption api.LockIssueOption
219222
}

routers/web/repo/issue_lock.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func LockIssue(ctx *context.Context) {
2424
return
2525
}
2626

27-
if !form.HasValidReason() {
27+
if !issues_model.IsValidReason(form.Reason) {
2828
ctx.JSONError(ctx.Tr("repo.issues.lock.unknown_reason"))
2929
return
3030
}

services/forms/repo_form.go

-17
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010

1111
issues_model "code.gitea.io/gitea/models/issues"
1212
project_model "code.gitea.io/gitea/models/project"
13-
"code.gitea.io/gitea/modules/setting"
1413
"code.gitea.io/gitea/modules/structs"
1514
"code.gitea.io/gitea/modules/web/middleware"
1615
"code.gitea.io/gitea/services/context"
@@ -473,22 +472,6 @@ func (i *IssueLockForm) Validate(req *http.Request, errs binding.Errors) binding
473472
return middleware.Validate(errs, ctx.Data, i, ctx.Locale)
474473
}
475474

476-
// HasValidReason checks to make sure that the reason submitted in
477-
// the form matches any of the values in the config
478-
func (i IssueLockForm) HasValidReason() bool {
479-
if strings.TrimSpace(i.Reason) == "" {
480-
return true
481-
}
482-
483-
for _, v := range setting.Repository.Issue.LockReasons {
484-
if v == i.Reason {
485-
return true
486-
}
487-
}
488-
489-
return false
490-
}
491-
492475
// CreateProjectForm form for creating a project
493476
type CreateProjectForm struct {
494477
Title string `binding:"Required;MaxSize(100)"`

services/forms/repo_form_test.go

-25
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ package forms
66
import (
77
"testing"
88

9-
"code.gitea.io/gitea/modules/setting"
10-
119
"github.com/stretchr/testify/assert"
1210
)
1311

@@ -39,26 +37,3 @@ func TestSubmitReviewForm_IsEmpty(t *testing.T) {
3937
assert.Equal(t, v.expected, v.form.HasEmptyContent())
4038
}
4139
}
42-
43-
func TestIssueLock_HasValidReason(t *testing.T) {
44-
// Init settings
45-
_ = setting.Repository
46-
47-
cases := []struct {
48-
form IssueLockForm
49-
expected bool
50-
}{
51-
{IssueLockForm{""}, true}, // an empty reason is accepted
52-
{IssueLockForm{"Off-topic"}, true},
53-
{IssueLockForm{"Too heated"}, true},
54-
{IssueLockForm{"Spam"}, true},
55-
{IssueLockForm{"Resolved"}, true},
56-
57-
{IssueLockForm{"ZZZZ"}, false},
58-
{IssueLockForm{"I want to lock this issue"}, false},
59-
}
60-
61-
for _, v := range cases {
62-
assert.Equal(t, v.expected, v.form.HasValidReason())
63-
}
64-
}

0 commit comments

Comments
 (0)