mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 08:30:25 +08:00 
			
		
		
		
	Implement "conversation lock" for issue comments (#5073)
This commit is contained in:
		@@ -69,6 +69,10 @@ MAX_FILES = 5
 | 
			
		||||
; List of prefixes used in Pull Request title to mark them as Work In Progress
 | 
			
		||||
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]
 | 
			
		||||
; Number of repositories that are displayed on one explore page
 | 
			
		||||
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
 | 
			
		||||
 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`)
 | 
			
		||||
 | 
			
		||||
- `EXPLORE_PAGING_NUM`: **20**: Number of repositories that are shown in one explore page.
 | 
			
		||||
 
 | 
			
		||||
@@ -81,7 +81,7 @@ _Symbols used in table:_
 | 
			
		||||
| Related issues | ✘ | ✘ | ⁄ | ✘ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Confidential issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Comment reactions | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Lock Discussion | ✘ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Lock Discussion | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Batch issue handling | ✓ | ✘ | ✓ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Issue Boards | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
| Create new branches from issues | ✘ | ✘ | ✘ | ✓ | ✓ | ✘ | ✘ |
 | 
			
		||||
 
 | 
			
		||||
@@ -57,6 +57,10 @@ type Issue struct {
 | 
			
		||||
	Reactions        ReactionList  `xorm:"-"`
 | 
			
		||||
	TotalTrackedTime int64         `xorm:"-"`
 | 
			
		||||
	Assignees        []*User       `xorm:"-"`
 | 
			
		||||
 | 
			
		||||
	// IsLocked limits commenting abilities to users on an issue
 | 
			
		||||
	// with write access
 | 
			
		||||
	IsLocked bool `xorm:"NOT NULL DEFAULT false"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
 
 | 
			
		||||
@@ -80,6 +80,10 @@ const (
 | 
			
		||||
	CommentTypeCode
 | 
			
		||||
	// Reviews a pull request by giving general feedback
 | 
			
		||||
	CommentTypeReview
 | 
			
		||||
	// Lock an issue, giving only collaborators access
 | 
			
		||||
	CommentTypeLock
 | 
			
		||||
	// Unlocks a previously locked issue
 | 
			
		||||
	CommentTypeUnlock
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// 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),
 | 
			
		||||
	// v79 -> v80
 | 
			
		||||
	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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/routers/utils"
 | 
			
		||||
 | 
			
		||||
	"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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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 (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@@ -39,3 +40,27 @@ 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())
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -227,6 +227,11 @@ var (
 | 
			
		||||
		PullRequest struct {
 | 
			
		||||
			WorkInProgressPrefixes []string
 | 
			
		||||
		} `ini:"repository.pull-request"`
 | 
			
		||||
 | 
			
		||||
		// Issue Setting
 | 
			
		||||
		Issue struct {
 | 
			
		||||
			LockReasons []string
 | 
			
		||||
		} `ini:"repository.issue"`
 | 
			
		||||
	}{
 | 
			
		||||
		AnsiCharset:                             "",
 | 
			
		||||
		ForcePrivate:                            false,
 | 
			
		||||
@@ -279,6 +284,13 @@ var (
 | 
			
		||||
		}{
 | 
			
		||||
			WorkInProgressPrefixes: []string{"WIP:", "[WIP]"},
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		// Issue settings
 | 
			
		||||
		Issue: struct {
 | 
			
		||||
			LockReasons []string
 | 
			
		||||
		}{
 | 
			
		||||
			LockReasons: strings.Split("Too heated,Off-topic,Spam,Resolved", ","),
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	RepoRootPath string
 | 
			
		||||
	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.subscribe = Subscribe
 | 
			
		||||
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.start_tracking_short = Start
 | 
			
		||||
issues.start_tracking = Start Time Tracking
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@
 | 
			
		||||
package repo
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models"
 | 
			
		||||
@@ -169,6 +170,11 @@ func CreateIssueComment(ctx *context.APIContext, form api.CreateIssueCommentOpti
 | 
			
		||||
		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)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		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
 | 
			
		||||
func MustEnableIssues(ctx *context.Context) {
 | 
			
		||||
	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["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.User.ID)
 | 
			
		||||
	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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -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)) {
 | 
			
		||||
		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
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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)
 | 
			
		||||
	reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(models.UnitTypeIssues, models.UnitTypePullRequests)
 | 
			
		||||
 | 
			
		||||
	reqRepoIssueWriter := func(ctx *context.Context) {
 | 
			
		||||
		if !ctx.Repo.CanWrite(models.UnitTypeIssues) {
 | 
			
		||||
			ctx.Error(403)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// ***** START: Organization *****
 | 
			
		||||
	m.Group("/org", func() {
 | 
			
		||||
		m.Group("", func() {
 | 
			
		||||
@@ -574,7 +581,7 @@ func RegisterRoutes(m *macaron.Macaron) {
 | 
			
		||||
					m.Post("/add", repo.AddDependency)
 | 
			
		||||
					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.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually)
 | 
			
		||||
					m.Group("/stopwatch", func() {
 | 
			
		||||
@@ -583,6 +590,8 @@ func RegisterRoutes(m *macaron.Macaron) {
 | 
			
		||||
					})
 | 
			
		||||
				})
 | 
			
		||||
				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())
 | 
			
		||||
 | 
			
		||||
			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
 | 
			
		||||
 
 | 
			
		||||
@@ -69,7 +69,38 @@
 | 
			
		||||
			{{if and .Issue.IsPull (not $.Repository.IsArchived)}}
 | 
			
		||||
				{{ template "repo/issue/view_content/pull". }}
 | 
			
		||||
			{{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}}
 | 
			
		||||
				<div class="ui warning message">
 | 
			
		||||
					{{if .Issue.IsPull}}
 | 
			
		||||
@@ -114,6 +145,7 @@
 | 
			
		||||
					</div>
 | 
			
		||||
				{{end}}
 | 
			
		||||
			{{end}}
 | 
			
		||||
		{{end}}
 | 
			
		||||
		</ui>
 | 
			
		||||
	</div>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,11 @@
 | 
			
		||||
{{range .Issue.Comments}}
 | 
			
		||||
	{{ $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}}
 | 
			
		||||
		<div class="comment" id="{{.HashTag}}">
 | 
			
		||||
			<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}>
 | 
			
		||||
@@ -355,5 +359,35 @@
 | 
			
		||||
				{{end}}
 | 
			
		||||
			{{end}}
 | 
			
		||||
	    </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}}
 | 
			
		||||
 
 | 
			
		||||
@@ -335,6 +335,91 @@
 | 
			
		||||
					</div>
 | 
			
		||||
				{{end}}
 | 
			
		||||
			</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>
 | 
			
		||||
{{if and .CanCreateIssueDependencies (not .Repository.IsArchived)}}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user