Implement "conversation lock" for issue comments (#5073)
This commit is contained in:
		
							parent
							
								
									64ce159a6e
								
							
						
					
					
						commit
						44114b38e6
					
				
					 19 changed files with 435 additions and 4 deletions
				
			
		|  | @ -69,6 +69,10 @@ MAX_FILES = 5 | ||||||
| ; List of prefixes used in Pull Request title to mark them as Work In Progress | ; List of prefixes used in Pull Request title to mark them as Work In Progress | ||||||
| WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP] | WORK_IN_PROGRESS_PREFIXES=WIP:,[WIP] | ||||||
| 
 | 
 | ||||||
|  | [repository.issue] | ||||||
|  | ; List of reasons why a Pull Request or Issue can be locked | ||||||
|  | LOCK_REASONS=Too heated,Off-topic,Resolved,Spam | ||||||
|  | 
 | ||||||
| [ui] | [ui] | ||||||
| ; Number of repositories that are displayed on one explore page | ; Number of repositories that are displayed on one explore page | ||||||
| EXPLORE_PAGING_NUM = 20 | EXPLORE_PAGING_NUM = 20 | ||||||
|  |  | ||||||
|  | @ -71,6 +71,9 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. | ||||||
| - `WORK_IN_PROGRESS_PREFIXES`: **WIP:,\[WIP\]**: List of prefixes used in Pull Request | - `WORK_IN_PROGRESS_PREFIXES`: **WIP:,\[WIP\]**: List of prefixes used in Pull Request | ||||||
|  title to mark them as Work In Progress |  title to mark them as Work In Progress | ||||||
| 
 | 
 | ||||||
|  | ### Repository - Issue (`repository.issue`) | ||||||
|  | - `LOCK_REASONS`: **Too heated,Off-topic,Resolved,Spam**: A list of reasons why a Pull Request or Issue can be locked | ||||||
|  | 
 | ||||||
| ## UI (`ui`) | ## UI (`ui`) | ||||||
| 
 | 
 | ||||||
| - `EXPLORE_PAGING_NUM`: **20**: Number of repositories that are shown in one explore page. | - `EXPLORE_PAGING_NUM`: **20**: Number of repositories that are shown in one explore page. | ||||||
|  |  | ||||||
|  | @ -81,7 +81,7 @@ _Symbols used in table:_ | ||||||
| | Related issues | ✘ | ✘ | ⁄ | ✘ | ✓ | ✘ | ✘ | | | Related issues | ✘ | ✘ | ⁄ | ✘ | ✓ | ✘ | ✘ | | ||||||
| | Confidential issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | | Confidential issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | ||||||
| | Comment reactions | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | | Comment reactions | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | ||||||
| | Lock Discussion | ✘ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | | Lock Discussion | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | ||||||
| | Batch issue handling | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | | Batch issue handling | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ | | ||||||
| | Issue Boards | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | | Issue Boards | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | ||||||
| | Create new branches from issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | | Create new branches from issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ | | ||||||
|  |  | ||||||
|  | @ -57,6 +57,10 @@ type Issue struct { | ||||||
| 	Reactions        ReactionList  `xorm:"-"` | 	Reactions        ReactionList  `xorm:"-"` | ||||||
| 	TotalTrackedTime int64         `xorm:"-"` | 	TotalTrackedTime int64         `xorm:"-"` | ||||||
| 	Assignees        []*User       `xorm:"-"` | 	Assignees        []*User       `xorm:"-"` | ||||||
|  | 
 | ||||||
|  | 	// IsLocked limits commenting abilities to users on an issue
 | ||||||
|  | 	// with write access
 | ||||||
|  | 	IsLocked bool `xorm:"NOT NULL DEFAULT false"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| var ( | var ( | ||||||
|  |  | ||||||
|  | @ -80,6 +80,10 @@ const ( | ||||||
| 	CommentTypeCode | 	CommentTypeCode | ||||||
| 	// Reviews a pull request by giving general feedback
 | 	// Reviews a pull request by giving general feedback
 | ||||||
| 	CommentTypeReview | 	CommentTypeReview | ||||||
|  | 	// Lock an issue, giving only collaborators access
 | ||||||
|  | 	CommentTypeLock | ||||||
|  | 	// Unlocks a previously locked issue
 | ||||||
|  | 	CommentTypeUnlock | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // CommentTag defines comment tag type
 | // CommentTag defines comment tag type
 | ||||||
|  |  | ||||||
							
								
								
									
										51
									
								
								models/issue_lock.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								models/issue_lock.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | ||||||
|  | // Copyright 2019 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package models | ||||||
|  | 
 | ||||||
|  | // IssueLockOptions defines options for locking and/or unlocking an issue/PR
 | ||||||
|  | type IssueLockOptions struct { | ||||||
|  | 	Doer   *User | ||||||
|  | 	Issue  *Issue | ||||||
|  | 	Reason string | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // LockIssue locks an issue. This would limit commenting abilities to
 | ||||||
|  | // users with write access to the repo
 | ||||||
|  | func LockIssue(opts *IssueLockOptions) error { | ||||||
|  | 	return updateIssueLock(opts, true) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // UnlockIssue unlocks a previously locked issue.
 | ||||||
|  | func UnlockIssue(opts *IssueLockOptions) error { | ||||||
|  | 	return updateIssueLock(opts, false) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func updateIssueLock(opts *IssueLockOptions, lock bool) error { | ||||||
|  | 	if opts.Issue.IsLocked == lock { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	opts.Issue.IsLocked = lock | ||||||
|  | 
 | ||||||
|  | 	var commentType CommentType | ||||||
|  | 	if opts.Issue.IsLocked { | ||||||
|  | 		commentType = CommentTypeLock | ||||||
|  | 	} else { | ||||||
|  | 		commentType = CommentTypeUnlock | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := UpdateIssueCols(opts.Issue, "is_locked"); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	_, err := CreateComment(&CreateCommentOptions{ | ||||||
|  | 		Doer:    opts.Doer, | ||||||
|  | 		Issue:   opts.Issue, | ||||||
|  | 		Repo:    opts.Issue.Repo, | ||||||
|  | 		Type:    commentType, | ||||||
|  | 		Content: opts.Reason, | ||||||
|  | 	}) | ||||||
|  | 	return err | ||||||
|  | } | ||||||
|  | @ -213,6 +213,8 @@ var migrations = []Migration{ | ||||||
| 	NewMigration("rename repo is_bare to repo is_empty", renameRepoIsBareToIsEmpty), | 	NewMigration("rename repo is_bare to repo is_empty", renameRepoIsBareToIsEmpty), | ||||||
| 	// v79 -> v80
 | 	// v79 -> v80
 | ||||||
| 	NewMigration("add can close issues via commit in any branch", addCanCloseIssuesViaCommitInAnyBranch), | 	NewMigration("add can close issues via commit in any branch", addCanCloseIssuesViaCommitInAnyBranch), | ||||||
|  | 	// v80 -> v81
 | ||||||
|  | 	NewMigration("add is locked to issues", addIsLockedToIssues), | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Migrate database to current version
 | // Migrate database to current version
 | ||||||
|  |  | ||||||
							
								
								
									
										18
									
								
								models/migrations/v80.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								models/migrations/v80.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | ||||||
|  | // Copyright 2019 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package migrations | ||||||
|  | 
 | ||||||
|  | import "github.com/go-xorm/xorm" | ||||||
|  | 
 | ||||||
|  | func addIsLockedToIssues(x *xorm.Engine) error { | ||||||
|  | 	// Issue see models/issue.go
 | ||||||
|  | 	type Issue struct { | ||||||
|  | 		ID       int64 `xorm:"pk autoincr"` | ||||||
|  | 		IsLocked bool  `xorm:"NOT NULL DEFAULT false"` | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return x.Sync2(new(Issue)) | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -10,6 +10,7 @@ import ( | ||||||
| 	"strings" | 	"strings" | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/models" | 	"code.gitea.io/gitea/models" | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"code.gitea.io/gitea/routers/utils" | 	"code.gitea.io/gitea/routers/utils" | ||||||
| 
 | 
 | ||||||
| 	"github.com/Unknwon/com" | 	"github.com/Unknwon/com" | ||||||
|  | @ -308,6 +309,32 @@ func (f *ReactionForm) Validate(ctx *macaron.Context, errs binding.Errors) bindi | ||||||
| 	return validate(errs, ctx.Data, f, ctx.Locale) | 	return validate(errs, ctx.Data, f, ctx.Locale) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // IssueLockForm form for locking an issue
 | ||||||
|  | type IssueLockForm struct { | ||||||
|  | 	Reason string `binding:"Required"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Validate validates the fields
 | ||||||
|  | func (i *IssueLockForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { | ||||||
|  | 	return 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 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| //    _____  .__.__                   __
 | //    _____  .__.__                   __
 | ||||||
| //   /     \ |__|  |   ____   _______/  |_  ____   ____   ____
 | //   /     \ |__|  |   ____   _______/  |_  ____   ____   ____
 | ||||||
| //  /  \ /  \|  |  | _/ __ \ /  ___/\   __\/  _ \ /    \_/ __ \
 | //  /  \ /  \|  |  | _/ __ \ /  ___/\   __\/  _ \ /    \_/ __ \
 | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ package auth | ||||||
| import ( | import ( | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
|  | 	"code.gitea.io/gitea/modules/setting" | ||||||
| 	"github.com/stretchr/testify/assert" | 	"github.com/stretchr/testify/assert" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -39,3 +40,27 @@ func TestSubmitReviewForm_IsEmpty(t *testing.T) { | ||||||
| 		assert.Equal(t, v.expected, v.form.HasEmptyContent()) | 		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()) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -227,6 +227,11 @@ var ( | ||||||
| 		PullRequest struct { | 		PullRequest struct { | ||||||
| 			WorkInProgressPrefixes []string | 			WorkInProgressPrefixes []string | ||||||
| 		} `ini:"repository.pull-request"` | 		} `ini:"repository.pull-request"` | ||||||
|  | 
 | ||||||
|  | 		// Issue Setting
 | ||||||
|  | 		Issue struct { | ||||||
|  | 			LockReasons []string | ||||||
|  | 		} `ini:"repository.issue"` | ||||||
| 	}{ | 	}{ | ||||||
| 		AnsiCharset:                             "", | 		AnsiCharset:                             "", | ||||||
| 		ForcePrivate:                            false, | 		ForcePrivate:                            false, | ||||||
|  | @ -279,6 +284,13 @@ var ( | ||||||
| 		}{ | 		}{ | ||||||
| 			WorkInProgressPrefixes: []string{"WIP:", "[WIP]"}, | 			WorkInProgressPrefixes: []string{"WIP:", "[WIP]"}, | ||||||
| 		}, | 		}, | ||||||
|  | 
 | ||||||
|  | 		// Issue settings
 | ||||||
|  | 		Issue: struct { | ||||||
|  | 			LockReasons []string | ||||||
|  | 		}{ | ||||||
|  | 			LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","), | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| 	RepoRootPath string | 	RepoRootPath string | ||||||
| 	ScriptType   = "bash" | 	ScriptType   = "bash" | ||||||
|  |  | ||||||
|  | @ -780,6 +780,25 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab` | ||||||
| issues.attachment.download = `Click to download "%s"` | issues.attachment.download = `Click to download "%s"` | ||||||
| issues.subscribe = Subscribe | issues.subscribe = Subscribe | ||||||
| issues.unsubscribe = Unsubscribe | issues.unsubscribe = Unsubscribe | ||||||
|  | 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" | ||||||
|  | issues.lock_no_reason = "locked and limited conversation to collaborators %s" | ||||||
|  | issues.unlock_comment = "unlocked this conversation %s" | ||||||
|  | issues.lock_confirm = Lock | ||||||
|  | issues.unlock_confirm = Unlock | ||||||
|  | issues.lock.notice_1 = - Other users can’t add new comments to this issue. | ||||||
|  | issues.lock.notice_2 = - You and other collaborators with access to this repository can still leave comments that others can see. | ||||||
|  | issues.lock.notice_3 = - You can always unlock this issue again in the future. | ||||||
|  | issues.unlock.notice_1 = - Everyone would be able to comment on this issue once more. | ||||||
|  | issues.unlock.notice_2 = - You can always lock this issue again in the future. | ||||||
|  | issues.lock.reason = Reason for locking | ||||||
|  | issues.lock.title = Lock conversation on this issue. | ||||||
|  | issues.unlock.title = Unlock conversation on this issue. | ||||||
|  | issues.comment_on_locked = You cannot comment on a locked issue. | ||||||
| issues.tracker = Time Tracker | issues.tracker = Time Tracker | ||||||
| issues.start_tracking_short = Start | issues.start_tracking_short = Start | ||||||
| issues.start_tracking = Start Time Tracking | issues.start_tracking = Start Time Tracking | ||||||
|  |  | ||||||
|  | @ -5,6 +5,7 @@ | ||||||
| package repo | package repo | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"errors" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/models" | 	"code.gitea.io/gitea/models" | ||||||
|  | @ -169,6 +170,11 @@ func CreateIssueComment(ctx *context.APIContext, form api.CreateIssueCommentOpti | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin { | ||||||
|  | 		ctx.Error(403, "CreateIssueComment", errors.New(ctx.Tr("repo.issues.comment_on_locked"))) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	comment, err := models.CreateIssueComment(ctx.User, ctx.Repo.Repository, issue, form.Body, nil) | 	comment, err := models.CreateIssueComment(ctx.User, ctx.Repo.Repository, issue, form.Body, nil) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		ctx.Error(500, "CreateIssueComment", err) | 		ctx.Error(500, "CreateIssueComment", err) | ||||||
|  |  | ||||||
|  | @ -57,6 +57,23 @@ var ( | ||||||
| 	} | 	} | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // MustAllowUserComment checks to make sure if an issue is locked.
 | ||||||
|  | // If locked and user has permissions to write to the repository,
 | ||||||
|  | // then the comment is allowed, else it is blocked
 | ||||||
|  | func MustAllowUserComment(ctx *context.Context) { | ||||||
|  | 
 | ||||||
|  | 	issue := GetActionIssue(ctx) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin { | ||||||
|  | 		ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) | ||||||
|  | 		ctx.Redirect(issue.HTMLURL()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // MustEnableIssues check if repository enable internal issues
 | // MustEnableIssues check if repository enable internal issues
 | ||||||
| func MustEnableIssues(ctx *context.Context) { | func MustEnableIssues(ctx *context.Context) { | ||||||
| 	if !ctx.Repo.CanRead(models.UnitTypeIssues) && | 	if !ctx.Repo.CanRead(models.UnitTypeIssues) && | ||||||
|  | @ -898,6 +915,9 @@ func ViewIssue(ctx *context.Context) { | ||||||
| 	ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + ctx.Data["Link"].(string) | 	ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + ctx.Data["Link"].(string) | ||||||
| 	ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) | 	ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID) | ||||||
| 	ctx.Data["IsIssueWriter"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) | 	ctx.Data["IsIssueWriter"] = ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) | ||||||
|  | 	ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.User.IsAdmin) | ||||||
|  | 	ctx.Data["IsRepoIssuesWriter"] = ctx.IsSigned && (ctx.Repo.CanWrite(models.UnitTypeIssues) || ctx.User.IsAdmin) | ||||||
|  | 	ctx.Data["LockReasons"] = setting.Repository.Issue.LockReasons | ||||||
| 	ctx.HTML(200, tplIssueView) | 	ctx.HTML(200, tplIssueView) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -1118,6 +1138,11 @@ func NewComment(ctx *context.Context, form auth.CreateCommentForm) { | ||||||
| 
 | 
 | ||||||
| 	if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { | 	if !ctx.IsSigned || (ctx.User.ID != issue.PosterID && !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull)) { | ||||||
| 		ctx.Error(403) | 		ctx.Error(403) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin { | ||||||
|  | 		ctx.Flash.Error(ctx.Tr("repo.issues.comment_on_locked")) | ||||||
|  | 		ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) | ||||||
| 		return | 		return | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										71
									
								
								routers/repo/issue_lock.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								routers/repo/issue_lock.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,71 @@ | ||||||
|  | // Copyright 2019 The Gitea Authors. All rights reserved.
 | ||||||
|  | // Use of this source code is governed by a MIT-style
 | ||||||
|  | // license that can be found in the LICENSE file.
 | ||||||
|  | 
 | ||||||
|  | package repo | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"net/http" | ||||||
|  | 
 | ||||||
|  | 	"code.gitea.io/gitea/models" | ||||||
|  | 	"code.gitea.io/gitea/modules/auth" | ||||||
|  | 	"code.gitea.io/gitea/modules/context" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // LockIssue locks an issue. This would limit commenting abilities to
 | ||||||
|  | // users with write access to the repo.
 | ||||||
|  | func LockIssue(ctx *context.Context, form auth.IssueLockForm) { | ||||||
|  | 
 | ||||||
|  | 	issue := GetActionIssue(ctx) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if issue.IsLocked { | ||||||
|  | 		ctx.Flash.Error(ctx.Tr("repo.issues.lock_duplicate")) | ||||||
|  | 		ctx.Redirect(issue.HTMLURL()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if !form.HasValidReason() { | ||||||
|  | 		ctx.Flash.Error(ctx.Tr("repo.issues.lock.unknown_reason")) | ||||||
|  | 		ctx.Redirect(issue.HTMLURL()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := models.LockIssue(&models.IssueLockOptions{ | ||||||
|  | 		Doer:   ctx.User, | ||||||
|  | 		Issue:  issue, | ||||||
|  | 		Reason: form.Reason, | ||||||
|  | 	}); err != nil { | ||||||
|  | 		ctx.ServerError("LockIssue", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // UnlockIssue unlocks a previously locked issue.
 | ||||||
|  | func UnlockIssue(ctx *context.Context) { | ||||||
|  | 
 | ||||||
|  | 	issue := GetActionIssue(ctx) | ||||||
|  | 	if ctx.Written() { | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if !issue.IsLocked { | ||||||
|  | 		ctx.Flash.Error(ctx.Tr("repo.issues.unlock_error")) | ||||||
|  | 		ctx.Redirect(issue.HTMLURL()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := models.UnlockIssue(&models.IssueLockOptions{ | ||||||
|  | 		Doer:  ctx.User, | ||||||
|  | 		Issue: issue, | ||||||
|  | 	}); err != nil { | ||||||
|  | 		ctx.ServerError("UnlockIssue", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	ctx.Redirect(issue.HTMLURL(), http.StatusSeeOther) | ||||||
|  | } | ||||||
|  | @ -432,6 +432,13 @@ func RegisterRoutes(m *macaron.Macaron) { | ||||||
| 	reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(models.UnitTypeIssues, models.UnitTypePullRequests) | 	reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(models.UnitTypeIssues, models.UnitTypePullRequests) | ||||||
| 	reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(models.UnitTypeIssues, models.UnitTypePullRequests) | 	reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(models.UnitTypeIssues, models.UnitTypePullRequests) | ||||||
| 
 | 
 | ||||||
|  | 	reqRepoIssueWriter := func(ctx *context.Context) { | ||||||
|  | 		if !ctx.Repo.CanWrite(models.UnitTypeIssues) { | ||||||
|  | 			ctx.Error(403) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// ***** START: Organization *****
 | 	// ***** START: Organization *****
 | ||||||
| 	m.Group("/org", func() { | 	m.Group("/org", func() { | ||||||
| 		m.Group("", func() { | 		m.Group("", func() { | ||||||
|  | @ -574,7 +581,7 @@ func RegisterRoutes(m *macaron.Macaron) { | ||||||
| 					m.Post("/add", repo.AddDependency) | 					m.Post("/add", repo.AddDependency) | ||||||
| 					m.Post("/delete", repo.RemoveDependency) | 					m.Post("/delete", repo.RemoveDependency) | ||||||
| 				}) | 				}) | ||||||
| 				m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment) | 				m.Combo("/comments").Post(repo.MustAllowUserComment, bindIgnErr(auth.CreateCommentForm{}), repo.NewComment) | ||||||
| 				m.Group("/times", func() { | 				m.Group("/times", func() { | ||||||
| 					m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually) | 					m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually) | ||||||
| 					m.Group("/stopwatch", func() { | 					m.Group("/stopwatch", func() { | ||||||
|  | @ -583,6 +590,8 @@ func RegisterRoutes(m *macaron.Macaron) { | ||||||
| 					}) | 					}) | ||||||
| 				}) | 				}) | ||||||
| 				m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction) | 				m.Post("/reactions/:action", bindIgnErr(auth.ReactionForm{}), repo.ChangeIssueReaction) | ||||||
|  | 				m.Post("/lock", reqRepoIssueWriter, bindIgnErr(auth.IssueLockForm{}), repo.LockIssue) | ||||||
|  | 				m.Post("/unlock", reqRepoIssueWriter, repo.UnlockIssue) | ||||||
| 			}, context.RepoMustNotBeArchived()) | 			}, context.RepoMustNotBeArchived()) | ||||||
| 
 | 
 | ||||||
| 			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) | 			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel) | ||||||
|  |  | ||||||
|  | @ -69,7 +69,38 @@ | ||||||
| 			{{if and .Issue.IsPull (not $.Repository.IsArchived)}} | 			{{if and .Issue.IsPull (not $.Repository.IsArchived)}} | ||||||
| 				{{ template "repo/issue/view_content/pull". }} | 				{{ template "repo/issue/view_content/pull". }} | ||||||
| 			{{end}} | 			{{end}} | ||||||
| 
 | 			{{if .IsSigned}} | ||||||
|  | 				{{ if or .IsRepoAdmin .IsRepoIssuesWriter (or (not .Issue.IsLocked)) }} | ||||||
|  | 				<div class="comment form"> | ||||||
|  | 					<a class="avatar" href="{{.SignedUser.HomeLink}}"> | ||||||
|  | 						<img src="{{.SignedUser.RelAvatarLink}}"> | ||||||
|  | 					</a> | ||||||
|  | 					<div class="content"> | ||||||
|  | 						<form class="ui segment form" id="comment-form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/comments" method="post"> | ||||||
|  | 							{{template "repo/issue/comment_tab" .}} | ||||||
|  | 							{{.CsrfTokenHtml}} | ||||||
|  | 							<input id="status" name="status" type="hidden"> | ||||||
|  | 							<div class="text right"> | ||||||
|  | 								{{if and (or .IsIssueWriter .IsIssuePoster) (not .DisableStatusChange)}} | ||||||
|  | 									{{if .Issue.IsClosed}} | ||||||
|  | 										<div id="status-button" class="ui green basic button" tabindex="6" data-status="{{.i18n.Tr "repo.issues.reopen_issue"}}" data-status-and-comment="{{.i18n.Tr "repo.issues.reopen_comment_issue"}}" data-status-val="reopen"> | ||||||
|  | 											{{.i18n.Tr "repo.issues.reopen_issue"}} | ||||||
|  | 										</div> | ||||||
|  | 									{{else}} | ||||||
|  | 										<div id="status-button" class="ui red basic button" tabindex="6" data-status="{{.i18n.Tr "repo.issues.close_issue"}}" data-status-and-comment="{{.i18n.Tr "repo.issues.close_comment_issue"}}" data-status-val="close"> | ||||||
|  | 											{{.i18n.Tr "repo.issues.close_issue"}} | ||||||
|  | 										</div> | ||||||
|  | 									{{end}} | ||||||
|  | 								{{end}} | ||||||
|  | 								<button class="ui green button" tabindex="5"> | ||||||
|  | 									{{.i18n.Tr "repo.issues.create_comment"}} | ||||||
|  | 								</button> | ||||||
|  | 							</div> | ||||||
|  | 						</form> | ||||||
|  | 					</div> | ||||||
|  | 				</div> | ||||||
|  | 				{{ end }} | ||||||
|  | 			{{else}} | ||||||
| 			{{if .Repository.IsArchived}} | 			{{if .Repository.IsArchived}} | ||||||
| 				<div class="ui warning message"> | 				<div class="ui warning message"> | ||||||
| 					{{if .Issue.IsPull}} | 					{{if .Issue.IsPull}} | ||||||
|  | @ -114,6 +145,7 @@ | ||||||
| 					</div> | 					</div> | ||||||
| 				{{end}} | 				{{end}} | ||||||
| 			{{end}} | 			{{end}} | ||||||
|  | 		{{end}} | ||||||
| 		</ui> | 		</ui> | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,7 +2,11 @@ | ||||||
| {{range .Issue.Comments}} | {{range .Issue.Comments}} | ||||||
| 	{{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | 	{{ $createdStr:= TimeSinceUnix .CreatedUnix $.Lang }} | ||||||
| 
 | 
 | ||||||
| 	<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, 18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY, 21 = CODE, 22 = REVIEW --> | 	<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, | ||||||
|  | 	 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, | ||||||
|  | 	 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL, 16 = ADDED_DEADLINE, 17 = MODIFIED_DEADLINE, | ||||||
|  | 	 18 = REMOVED_DEADLINE, 19 = ADD_DEPENDENCY, 20 = REMOVE_DEPENDENCY, 21 = CODE, | ||||||
|  | 	 22 = REVIEW, 23 = ISSUE_LOCKED, 24 = ISSUE_UNLOCKED --> | ||||||
| 	{{if eq .Type 0}} | 	{{if eq .Type 0}} | ||||||
| 		<div class="comment" id="{{.HashTag}}"> | 		<div class="comment" id="{{.HashTag}}"> | ||||||
| 			<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | 			<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}> | ||||||
|  | @ -355,5 +359,35 @@ | ||||||
| 				{{end}} | 				{{end}} | ||||||
| 			{{end}} | 			{{end}} | ||||||
| 	    </div> | 	    </div> | ||||||
|  | 	{{else if eq .Type 23}} | ||||||
|  | 		<div class="event"> | ||||||
|  | 			<span class="octicon octicon-lock" | ||||||
|  | 				style="font-size:20px;margin-left:-28.5px; margin-right: -1px"></span> | ||||||
|  | 			<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | ||||||
|  | 				<img src="{{.Poster.RelAvatarLink}}"> | ||||||
|  | 			</a> | ||||||
|  | 
 | ||||||
|  | 			{{ if .Content }} | ||||||
|  | 	    		<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> | ||||||
|  | 				{{$.i18n.Tr "repo.issues.lock_with_reason" .Content $createdStr | Safe}} | ||||||
|  | 	    		</span> | ||||||
|  | 			{{ else }} | ||||||
|  | 	    		<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> | ||||||
|  | 				{{$.i18n.Tr "repo.issues.lock_no_reason" $createdStr | Safe}} | ||||||
|  | 	    		</span> | ||||||
|  | 			{{ end }} | ||||||
|  | 		</div> | ||||||
|  | 	{{else if eq .Type 24}} | ||||||
|  | 		<div class="event"> | ||||||
|  | 			<span class="octicon octicon-key" | ||||||
|  | 				style="font-size:20px;margin-left:-28.5px; margin-right: -1px"></span> | ||||||
|  | 			<a class="ui avatar image" href="{{.Poster.HomeLink}}"> | ||||||
|  | 				<img src="{{.Poster.RelAvatarLink}}"> | ||||||
|  | 			</a> | ||||||
|  | 
 | ||||||
|  | 	    		<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> | ||||||
|  | 	    			{{$.i18n.Tr "repo.issues.unlock_comment" $createdStr | Safe}} | ||||||
|  | 	    		</span> | ||||||
|  | 		</div> | ||||||
| 	{{end}} | 	{{end}} | ||||||
| {{end}} | {{end}} | ||||||
|  |  | ||||||
|  | @ -335,6 +335,91 @@ | ||||||
| 					</div> | 					</div> | ||||||
| 				{{end}} | 				{{end}} | ||||||
| 			</div> | 			</div> | ||||||
|  | 
 | ||||||
|  | 			{{ if .IsRepoAdmin }} | ||||||
|  | 			<div class="ui divider"></div> | ||||||
|  | 			<div class="ui watching"> | ||||||
|  | 				<div> | ||||||
|  | 					<button class="fluid ui  show-modal button {{if .Issue.IsLocked }} negative {{ end }}" data-modal="#lock"> | ||||||
|  | 							{{if .Issue.IsLocked}} | ||||||
|  | 								<i class="octicon octicon-key"></i> | ||||||
|  | 								{{.i18n.Tr "repo.issues.unlock"}} | ||||||
|  | 							{{else}} | ||||||
|  | 								<i class="octicon octicon-lock"></i> | ||||||
|  | 								{{.i18n.Tr "repo.issues.lock"}} | ||||||
|  | 							{{end}} | ||||||
|  | 						</button> | ||||||
|  | 					</form> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 			<div class="ui tiny modal" id="lock"> | ||||||
|  | 				<div class="header"> | ||||||
|  | 					{{ if .Issue.IsLocked }} | ||||||
|  | 						{{.i18n.Tr "repo.issues.unlock.title"}} | ||||||
|  | 					{{ else }} | ||||||
|  | 						{{.i18n.Tr "repo.issues.lock.title"}} | ||||||
|  | 					{{ end }} | ||||||
|  | 				</div> | ||||||
|  | 			<div class="content"> | ||||||
|  | 				<div class="ui warning message text left"> | ||||||
|  | 					{{ if .Issue.IsLocked }} | ||||||
|  | 						{{.i18n.Tr "repo.issues.unlock.notice_1"}}<br> | ||||||
|  | 						{{.i18n.Tr "repo.issues.unlock.notice_2"}}<br> | ||||||
|  | 					{{ else }} | ||||||
|  | 						{{.i18n.Tr "repo.issues.lock.notice_1"}}<br> | ||||||
|  | 						{{.i18n.Tr "repo.issues.lock.notice_2"}}<br> | ||||||
|  | 						{{.i18n.Tr "repo.issues.lock.notice_3"}}<br> | ||||||
|  | 					{{ end }} | ||||||
|  | 				</div> | ||||||
|  | 
 | ||||||
|  | 				<form class="ui form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}{{ if .Issue.IsLocked }}/unlock{{ else }}/lock{{ end }}" | ||||||
|  | 					method="post"> | ||||||
|  | 					{{.CsrfTokenHtml}} | ||||||
|  | 
 | ||||||
|  | 					{{ if not .Issue.IsLocked }} | ||||||
|  | 					<div class="field"> | ||||||
|  | 						<strong> {{ .i18n.Tr "repo.issues.lock.reason" }} </strong> | ||||||
|  | 					</div> | ||||||
|  | 
 | ||||||
|  | 					<div class="field"> | ||||||
|  | 						<div class="ui fluid dropdown selection" tabindex="0"> | ||||||
|  | 
 | ||||||
|  | 							<select name="reason"> | ||||||
|  | 								<option value=""> </option> | ||||||
|  | 								{{range .LockReasons}} | ||||||
|  | 									<option value="{{.}}">{{.}}</option> | ||||||
|  | 								{{end}} | ||||||
|  | 							</select> | ||||||
|  | 							<i class="dropdown icon"></i> | ||||||
|  | 
 | ||||||
|  | 							<div class="default text"> </div> | ||||||
|  | 
 | ||||||
|  | 							<div class="menu transition hidden" tabindex="-1" style="display: block !important;"> | ||||||
|  | 								{{range .LockReasons}} | ||||||
|  | 									<div class="item" data-value="{{.}}">{{.}}</div> | ||||||
|  | 								{{end}} | ||||||
|  | 							</div> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 					{{ end }} | ||||||
|  | 
 | ||||||
|  | 					<div class="text right actions"> | ||||||
|  | 						<div class="ui cancel button">{{.i18n.Tr "settings.cancel"}}</div> | ||||||
|  | 						<button class="ui red button"> | ||||||
|  | 							{{ if .Issue.IsLocked }} | ||||||
|  | 								{{.i18n.Tr "repo.issues.unlock_confirm"}} | ||||||
|  | 							{{ else }} | ||||||
|  | 								{{.i18n.Tr "repo.issues.lock_confirm"}} | ||||||
|  | 							{{ end }} | ||||||
|  | 						</button> | ||||||
|  | 					</div> | ||||||
|  | 				</form> | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  | 		{{ end }} | ||||||
|  | 
 | ||||||
| 	</div> | 	</div> | ||||||
| </div> | </div> | ||||||
| {{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}} | {{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}} | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue