| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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)}} | ||||