Skip to content

Commit 44114b3

Browse files
adelowolafriks
authored andcommitted
Implement "conversation lock" for issue comments (#5073)
1 parent 64ce159 commit 44114b3

File tree

19 files changed

+435
-4
lines changed

19 files changed

+435
-4
lines changed

custom/conf/app.ini.sample

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ MAX_FILES = 5
6969
; List of prefixes used in Pull Request title to mark them as Work In Progress
7070
WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP]
7171

72+
[repository.issue]
73+
; List of reasons why a Pull Request or Issue can be locked
74+
LOCK_REASONS=Too heated,Off-topic,Resolved,Spam
75+
7276
[ui]
7377
; Number of repositories that are displayed on one explore page
7478
EXPLORE_PAGING_NUM = 20

docs/content/doc/advanced/config-cheat-sheet.en-us.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
7171
- `WORK_IN_PROGRESS_PREFIXES`: **WIP:,\[WIP\]**: List of prefixes used in Pull Request
7272
title to mark them as Work In Progress
7373

74+
### Repository - Issue (`repository.issue`)
75+
- `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked
76+
7477
## UI (`ui`)
7578

7679
- `EXPLORE_PAGING_NUM`: **20**: Number of repositories that are shown in one explore page.

docs/content/doc/features/comparison.en-us.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ _Symbols used in table:_
8181
| Related issues ||||||||
8282
| Confidential issues ||||||||
8383
| Comment reactions ||||||||
84-
| Lock Discussion | |||||||
84+
| Lock Discussion | |||||||
8585
| Batch issue handling ||||||||
8686
| Issue Boards ||||||||
8787
| Create new branches from issues ||||||||

models/issue.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ type Issue struct {
5757
Reactions ReactionList `xorm:"-"`
5858
TotalTrackedTime int64 `xorm:"-"`
5959
Assignees []*User `xorm:"-"`
60+
61+
// IsLocked limits commenting abilities to users on an issue
62+
// with write access
63+
IsLocked bool `xorm:"NOT NULL DEFAULT false"`
6064
}
6165

6266
var (

models/issue_comment.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ const (
8080
CommentTypeCode
8181
// Reviews a pull request by giving general feedback
8282
CommentTypeReview
83+
// Lock an issue, giving only collaborators access
84+
CommentTypeLock
85+
// Unlocks a previously locked issue
86+
CommentTypeUnlock
8387
)
8488

8589
// CommentTag defines comment tag type

models/issue_lock.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
// IssueLockOptions defines options for locking and/or unlocking an issue/PR
8+
type IssueLockOptions struct {
9+
Doer *User
10+
Issue *Issue
11+
Reason string
12+
}
13+
14+
// LockIssue locks an issue. This would limit commenting abilities to
15+
// users with write access to the repo
16+
func LockIssue(opts *IssueLockOptions) error {
17+
return updateIssueLock(opts, true)
18+
}
19+
20+
// UnlockIssue unlocks a previously locked issue.
21+
func UnlockIssue(opts *IssueLockOptions) error {
22+
return updateIssueLock(opts, false)
23+
}
24+
25+
func updateIssueLock(opts *IssueLockOptions, lock bool) error {
26+
if opts.Issue.IsLocked == lock {
27+
return nil
28+
}
29+
30+
opts.Issue.IsLocked = lock
31+
32+
var commentType CommentType
33+
if opts.Issue.IsLocked {
34+
commentType = CommentTypeLock
35+
} else {
36+
commentType = CommentTypeUnlock
37+
}
38+
39+
if err := UpdateIssueCols(opts.Issue, "is_locked"); err != nil {
40+
return err
41+
}
42+
43+
_, err := CreateComment(&CreateCommentOptions{
44+
Doer: opts.Doer,
45+
Issue: opts.Issue,
46+
Repo: opts.Issue.Repo,
47+
Type: commentType,
48+
Content: opts.Reason,
49+
})
50+
return err
51+
}

models/migrations/migrations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ var migrations = []Migration{
213213
NewMigration("rename repo is_bare to repo is_empty", renameRepoIsBareToIsEmpty),
214214
// v79 -> v80
215215
NewMigration("add can close issues via commit in any branch", addCanCloseIssuesViaCommitInAnyBranch),
216+
// v80 -> v81
217+
NewMigration("add is locked to issues", addIsLockedToIssues),
216218
}
217219

218220
// Migrate database to current version

models/migrations/v80.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import "github.com/go-xorm/xorm"
8+
9+
func addIsLockedToIssues(x *xorm.Engine) error {
10+
// Issue see models/issue.go
11+
type Issue struct {
12+
ID int64 `xorm:"pk autoincr"`
13+
IsLocked bool `xorm:"NOT NULL DEFAULT false"`
14+
}
15+
16+
return x.Sync2(new(Issue))
17+
18+
}

modules/auth/repo_form.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
"code.gitea.io/gitea/models"
13+
"code.gitea.io/gitea/modules/setting"
1314
"code.gitea.io/gitea/routers/utils"
1415

1516
"github.com/Unknwon/com"
@@ -308,6 +309,32 @@ func (f *ReactionForm) Validate(ctx *macaron.Context, errs binding.Errors) bindi
308309
return validate(errs, ctx.Data, f, ctx.Locale)
309310
}
310311

312+
// IssueLockForm form for locking an issue
313+
type IssueLockForm struct {
314+
Reason string `binding:"Required"`
315+
}
316+
317+
// Validate validates the fields
318+
func (i *IssueLockForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
319+
return validate(errs, ctx.Data, i, ctx.Locale)
320+
}
321+
322+
// HasValidReason checks to make sure that the reason submitted in
323+
// the form matches any of the values in the config
324+
func (i IssueLockForm) HasValidReason() bool {
325+
if strings.TrimSpace(i.Reason) == "" {
326+
return true
327+
}
328+
329+
for _, v := range setting.Repository.Issue.LockReasons {
330+
if v == i.Reason {
331+
return true
332+
}
333+
}
334+
335+
return false
336+
}
337+
311338
// _____ .__.__ __
312339
// / \ |__| | ____ _______/ |_ ____ ____ ____
313340
// / \ / \| | | _/ __ \ / ___/\ __\/ _ \ / \_/ __ \

modules/auth/repo_form_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package auth
77
import (
88
"testing"
99

10+
"code.gitea.io/gitea/modules/setting"
1011
"github.com/stretchr/testify/assert"
1112
)
1213

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

modules/setting/setting.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,11 @@ var (
227227
PullRequest struct {
228228
WorkInProgressPrefixes []string
229229
} `ini:"repository.pull-request"`
230+
231+
// Issue Setting
232+
Issue struct {
233+
LockReasons []string
234+
} `ini:"repository.issue"`
230235
}{
231236
AnsiCharset: "",
232237
ForcePrivate: false,
@@ -279,6 +284,13 @@ var (
279284
}{
280285
WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
281286
},
287+
288+
// Issue settings
289+
Issue: struct {
290+
LockReasons []string
291+
}{
292+
LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
293+
},
282294
}
283295
RepoRootPath string
284296
ScriptType = "bash"

options/locale/locale_en-US.ini

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,25 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab`
780780
issues.attachment.download = `Click to download "%s"`
781781
issues.subscribe = Subscribe
782782
issues.unsubscribe = Unsubscribe
783+
issues.lock = Lock conversation
784+
issues.unlock = Unlock conversation
785+
issues.lock.unknown_reason = Cannot lock an issue with an unknown reason.
786+
issues.lock_duplicate = An issue cannot be locked twice.
787+
issues.unlock_error = Cannot unlock an issue that is not locked.
788+
issues.lock_with_reason = "locked as <strong>%s</strong> and limited conversation to collaborators %s"
789+
issues.lock_no_reason = "locked and limited conversation to collaborators %s"
790+
issues.unlock_comment = "unlocked this conversation %s"
791+
issues.lock_confirm = Lock
792+
issues.unlock_confirm = Unlock
793+
issues.lock.notice_1 = - Other users can’t add new comments to this issue.
794+
issues.lock.notice_2 = - You and other collaborators with access to this repository can still leave comments that others can see.
795+
issues.lock.notice_3 = - You can always unlock this issue again in the future.
796+
issues.unlock.notice_1 = - Everyone would be able to comment on this issue once more.
797+
issues.unlock.notice_2 = - You can always lock this issue again in the future.
798+
issues.lock.reason = Reason for locking
799+
issues.lock.title = Lock conversation on this issue.
800+
issues.unlock.title = Unlock conversation on this issue.
801+
issues.comment_on_locked = You cannot comment on a locked issue.
783802
issues.tracker = Time Tracker
784803
issues.start_tracking_short = Start
785804
issues.start_tracking = Start Time Tracking

routers/api/v1/repo/issue_comment.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package repo
66

77
import (
8+
"errors"
89
"time"
910

1011
"code.gitea.io/gitea/models"
@@ -169,6 +170,11 @@ func CreateIssueComment(ctx *context.APIContext, form api.CreateIssueCommentOpti
169170
return
170171
}
171172

173+
if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin {
174+
ctx.Error(403, "CreateIssueComment", errors.New(ctx.Tr("repo.issues.comment_on_locked")))
175+
return
176+
}
177+
172178
comment, err := models.CreateIssueComment(ctx.User, ctx.Repo.Repository, issue, form.Body, nil)
173179
if err != nil {
174180
ctx.Error(500, "CreateIssueComment", err)

routers/repo/issue.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ var (
5757
}
5858
)
5959

60+
// MustAllowUserComment checks to make sure if an issue is locked.
61+
// If locked and user has permissions to write to the repository,
62+
// then the comment is allowed, else it is blocked
63+
func MustAllowUserComment(ctx *context.Context) {
64+
65+
issue := GetActionIssue(ctx)
66+
if ctx.Written() {
67+
return
68+
}
69+
70+
if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin {
71+
ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked"))
72+
ctx.Redirect(issue.HTMLURL())
73+
return
74+
}
75+
}
76+
6077
// MustEnableIssues check if repository enable internal issues
6178
func MustEnableIssues(ctx *context.Context) {
6279
if !ctx.Repo.CanRead(models.UnitTypeIssues) &&
@@ -898,6 +915,9 @@ func ViewIssue(ctx *context.Context) {
898915
ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + ctx.Data["Link"].(string)
899916
ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID)
900917
ctx.Data["IsIssueWriter"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)
918+
ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.User.IsAdmin)
919+
ctx.Data["IsRepoIssuesWriter"] = ctx.IsSigned && (ctx.Repo.CanWrite(models.UnitTypeIssues) || ctx.User.IsAdmin)
920+
ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons
901921
ctx.HTML(200, tplIssueView)
902922
}
903923

@@ -1118,6 +1138,11 @@ func NewComment(ctx *context.Context, form auth.CreateCommentForm) {
11181138

11191139
if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) {
11201140
ctx.Error(403)
1141+
}
1142+
1143+
if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin {
1144+
ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked"))
1145+
ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther)
11211146
return
11221147
}
11231148

routers/repo/issue_lock.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2019 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package repo
6+
7+
import (
8+
"net/http"
9+
10+
"code.gitea.io/gitea/models"
11+
"code.gitea.io/gitea/modules/auth"
12+
"code.gitea.io/gitea/modules/context"
13+
)
14+
15+
// LockIssue locks an issue. This would limit commenting abilities to
16+
// users with write access to the repo.
17+
func LockIssue(ctx *context.Context, form auth.IssueLockForm) {
18+
19+
issue := GetActionIssue(ctx)
20+
if ctx.Written() {
21+
return
22+
}
23+
24+
if issue.IsLocked {
25+
ctx.Flash.Error(ctx.Tr("repo.issues.lock_duplicate"))
26+
ctx.Redirect(issue.HTMLURL())
27+
return
28+
}
29+
30+
if !form.HasValidReason() {
31+
ctx.Flash.Error(ctx.Tr("repo.issues.lock.unknown_reason"))
32+
ctx.Redirect(issue.HTMLURL())
33+
return
34+
}
35+
36+
if err := models.LockIssue(&models.IssueLockOptions{
37+
Doer: ctx.User,
38+
Issue: issue,
39+
Reason: form.Reason,
40+
}); err != nil {
41+
ctx.ServerError("LockIssue", err)
42+
return
43+
}
44+
45+
ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther)
46+
}
47+
48+
// UnlockIssue unlocks a previously locked issue.
49+
func UnlockIssue(ctx *context.Context) {
50+
51+
issue := GetActionIssue(ctx)
52+
if ctx.Written() {
53+
return
54+
}
55+
56+
if !issue.IsLocked {
57+
ctx.Flash.Error(ctx.Tr("repo.issues.unlock_error"))
58+
ctx.Redirect(issue.HTMLURL())
59+
return
60+
}
61+
62+
if err := models.UnlockIssue(&models.IssueLockOptions{
63+
Doer: ctx.User,
64+
Issue: issue,
65+
}); err != nil {
66+
ctx.ServerError("UnlockIssue", err)
67+
return
68+
}
69+
70+
ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther)
71+
}

0 commit comments

Comments
 (0)