diff --git a/models/issues/comment.go b/models/issues/comment.go index 48b8e335d48ef..3683d005cc8f6 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1297,6 +1297,11 @@ func (c *Comment) HasOriginalAuthor() bool { return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 } +func (c *Comment) GetIssueClosedStatus() int { + n, _ := strconv.Atoi(c.Content) + return n +} + // InsertIssueComments inserts many comments of issues. func InsertIssueComments(ctx context.Context, comments []*Comment) error { if len(comments) == 0 { diff --git a/models/issues/dependency_test.go b/models/issues/dependency_test.go index 6eed483cc9be7..ca5122328e7a9 100644 --- a/models/issues/dependency_test.go +++ b/models/issues/dependency_test.go @@ -49,7 +49,8 @@ func TestCreateIssueDependency(t *testing.T) { assert.False(t, left) // Close #2 and check again - _, err = issues_model.ChangeIssueStatus(db.DefaultContext, issue2, user1, true) + issue2.IsClosed = true + _, err = issues_model.ChangeIssueStatus(db.DefaultContext, issue2, user1) assert.NoError(t, err) left, err = issues_model.IssueNoDependenciesLeft(db.DefaultContext, issue1) diff --git a/models/issues/issue.go b/models/issues/issue.go index 40462ed09dfd6..781b8cf1b57c8 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -94,6 +94,15 @@ func (err ErrIssueWasClosed) Error() string { return fmt.Sprintf("Issue [%d] %d was already closed", err.ID, err.Index) } +type IssueClosedStatus int8 + +const ( + IssueClosedStatusCommon IssueClosedStatus = iota // 0 close issue without any state. + IssueClosedStatusArchived // 1 + IssueClosedStatusResolved // 2 + IssueClosedStatusStale // 3 +) + var ErrIssueAlreadyChanged = util.NewInvalidArgumentErrorf("the issue is already changed") // Issue represents an issue or pull request of repository. @@ -121,6 +130,7 @@ type Issue struct { Assignee *user_model.User `xorm:"-"` isAssigneeLoaded bool `xorm:"-"` IsClosed bool `xorm:"INDEX"` + ClosedStatus IssueClosedStatus `xorm:"NOT NULL DEFAULT 0"` IsRead bool `xorm:"-"` IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not. PullRequest *PullRequest `xorm:"-"` diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 31d76be5e0aea..a348ba7d2d266 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -6,6 +6,7 @@ package issues import ( "context" "fmt" + "strconv" "strings" "code.gitea.io/gitea/models/db" @@ -33,7 +34,7 @@ func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error { return nil } -func changeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed, isMergePull bool) (*Comment, error) { +func changeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isMergePull bool) (*Comment, error) { // Reload the issue currentIssue, err := GetIssueByID(ctx, issue.ID) if err != nil { @@ -41,18 +42,19 @@ func changeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, } // Nothing should be performed if current status is same as target status - if currentIssue.IsClosed == isClosed { - if !issue.IsPull { - return nil, ErrIssueWasClosed{ + if currentIssue.IsClosed == issue.IsClosed { + if issue.IsPull { + return nil, ErrPullWasClosed{ ID: issue.ID, } } - return nil, ErrPullWasClosed{ - ID: issue.ID, + if currentIssue.ClosedStatus == issue.ClosedStatus { + return nil, ErrIssueWasClosed{ + ID: issue.ID, + } } } - issue.IsClosed = isClosed return doChangeIssueStatus(ctx, issue, doer, isMergePull) } @@ -76,7 +78,7 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use issue.ClosedUnix = 0 } - if err := UpdateIssueCols(ctx, issue, "is_closed", "closed_unix"); err != nil { + if err := UpdateIssueCols(ctx, issue, "is_closed", "closed_status", "closed_unix"); err != nil { return nil, err } @@ -104,6 +106,11 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use // New action comment cmtType := CommentTypeClose + var content string + if !issue.IsPull && issue.IsClosed { + content = strconv.Itoa(int(issue.ClosedStatus)) + } + if !issue.IsClosed { cmtType = CommentTypeReopen } else if isMergePull { @@ -111,15 +118,16 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } return CreateComment(ctx, &CreateCommentOptions{ - Type: cmtType, - Doer: doer, - Repo: issue.Repo, - Issue: issue, + Type: cmtType, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Content: content, }) } // ChangeIssueStatus changes issue status to open or closed. -func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, isClosed bool) (*Comment, error) { +func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) { if err := issue.LoadRepo(ctx); err != nil { return nil, err } @@ -127,7 +135,7 @@ func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, return nil, err } - return changeIssueStatus(ctx, issue, doer, isClosed, false) + return changeIssueStatus(ctx, issue, doer, false) } // ChangeIssueTitle changes the title of this issue, as the given user. @@ -434,6 +442,71 @@ func UpdateIssueMentions(ctx context.Context, issueID int64, mentions []*user_mo return nil } +// UpdateIssueByAPI updates all allowed fields of given issue. +// If the issue status is changed a statusChangeComment is returned +// similarly if the title is changed the titleChanged bool is set to true +func UpdateIssueByAPI(issue *Issue, doer *user_model.User) (statusChangeComment *Comment, titleChanged bool, err error) { + ctx, committer, err := db.TxContext(db.DefaultContext) + if err != nil { + return nil, false, err + } + defer committer.Close() + + if err := issue.LoadRepo(ctx); err != nil { + return nil, false, fmt.Errorf("loadRepo: %w", err) + } + + // Reload the issue + currentIssue, err := GetIssueByID(ctx, issue.ID) + if err != nil { + return nil, false, err + } + + if _, err := db.GetEngine(ctx).ID(issue.ID).Cols( + "name", "content", "milestone_id", "priority", + "deadline_unix", "updated_unix", "is_locked"). + Update(issue); err != nil { + return nil, false, err + } + + titleChanged = currentIssue.Title != issue.Title + if titleChanged { + opts := &CreateCommentOptions{ + Type: CommentTypeChangeTitle, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + OldTitle: currentIssue.Title, + NewTitle: issue.Title, + } + _, err := CreateComment(ctx, opts) + if err != nil { + return nil, false, fmt.Errorf("createComment: %w", err) + } + } + + if issue.IsPull { + if currentIssue.IsClosed != issue.IsClosed { + statusChangeComment, err = doChangeIssueStatus(ctx, issue, doer, false) + if err != nil { + return nil, false, err + } + } + } else { + if currentIssue.IsClosed != issue.IsClosed || currentIssue.ClosedStatus != issue.ClosedStatus { + statusChangeComment, err = doChangeIssueStatus(ctx, issue, doer, false) + if err != nil { + return nil, false, err + } + } + } + + if err := issue.AddCrossReferences(ctx, doer, true); err != nil { + return nil, false, err + } + return statusChangeComment, titleChanged, committer.Commit() +} + // UpdateIssueDeadline updates an issue deadline and adds comments. Setting a deadline to 0 means deleting it. func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeutil.TimeStamp, doer *user_model.User) (err error) { // if the deadline hasn't changed do nothing diff --git a/models/issues/issue_xref_test.go b/models/issues/issue_xref_test.go index f1b1bb2a6b1b3..2cfd2c13541f2 100644 --- a/models/issues/issue_xref_test.go +++ b/models/issues/issue_xref_test.go @@ -98,7 +98,8 @@ func TestXRef_ResolveCrossReferences(t *testing.T) { i1 := testCreateIssue(t, 1, 2, "title1", "content1", false) i2 := testCreateIssue(t, 1, 2, "title2", "content2", false) i3 := testCreateIssue(t, 1, 2, "title3", "content3", false) - _, err := issues_model.ChangeIssueStatus(db.DefaultContext, i3, d, true) + i3.IsClosed = true + _, err := issues_model.ChangeIssueStatus(db.DefaultContext, i3, d) assert.NoError(t, err) pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index)) diff --git a/models/issues/pull.go b/models/issues/pull.go index b327ebc625c73..63a5919bba372 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -543,7 +543,8 @@ func (pr *PullRequest) SetMerged(ctx context.Context) (bool, error) { return false, err } - if _, err := changeIssueStatus(ctx, pr.Issue, pr.Merger, true, true); err != nil { + pr.Issue.IsClosed = true + if _, err := changeIssueStatus(ctx, pr.Issue, pr.Merger, true); err != nil { return false, fmt.Errorf("Issue.changeStatus: %w", err) } diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 13551423ce470..41ccc33d66412 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -601,6 +601,8 @@ var migrations = []Migration{ NewMigration("Add metadata column for comment table", v1_23.AddCommentMetaDataColumn), // v304 -> v305 NewMigration("Add index for release sha1", v1_23.AddIndexForReleaseSha1), + // v305 -> v306 + NewMigration("Add column of closed_status to issue table", v1_23.AddClosedStatusToIssue), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_23/v305.go b/models/migrations/v1_23/v305.go new file mode 100644 index 0000000000000..09deedb471cac --- /dev/null +++ b/models/migrations/v1_23/v305.go @@ -0,0 +1,18 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_23 //nolint + +import ( + issues_model "code.gitea.io/gitea/models/issues" + + "xorm.io/xorm" +) + +func AddClosedStatusToIssue(x *xorm.Engine) error { + type Issue struct { + ClosedStatus issues_model.IssueClosedStatus `xorm:"NOT NULL DEFAULT 0"` + } + + return x.Sync(new(Issue)) +} diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 3682191be5751..5f42521ed7789 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -25,6 +25,12 @@ const ( StateAll StateType = "all" ) +const ( + ClosedStatusArchived = "archived" + ClosedStatusResolved = "resolved" + ClosedStatusStale = "stale" +) + // PullRequestMeta PR info if an issue is a PR type PullRequestMeta struct { HasMerged bool `json:"merged"` @@ -113,6 +119,7 @@ type EditIssueOption struct { // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` RemoveDeadline *bool `json:"unset_due_date"` + ClosedStatus *string `json:"closed_status"` } // EditDeadlineOption options for creating a deadline diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index f77fd203a2d6b..209fa51675c6a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -498,6 +498,9 @@ issue.action.force_push = %[1]s force-pushed the %[2]s from %[3]s issue.action.push_1 = @%[1]s pushed %[3]d commit to %[2]s issue.action.push_n = @%[1]s pushed %[3]d commits to %[2]s issue.action.close = @%[1]s closed #%[2]d. +issue.action.close_as_archived = @%[1]s closed as archived #%[2]d. +issue.action.close_as_resolved = @%[1]s closed as resolved #%[2]d. +issue.action.close_as_stale = @%[1]s closed as stale #%[2]d. issue.action.reopen = @%[1]s reopened #%[2]d. issue.action.merge = @%[1]s merged #%[2]d into %[3]s. issue.action.approve = @%[1]s approved this pull request. @@ -1575,6 +1578,9 @@ issues.reopen_comment_issue = Reopen with Comment issues.create_comment = Comment issues.comment.blocked_user = Cannot create or edit comment because you are blocked by the poster or repository owner. issues.closed_at = `closed this issue %[2]s` +issues.closed_as_archived_at = `closed this issue as archived %[2]s` +issues.closed_as_resolved_at = `closed this issue as resolved %[2]s` +issues.closed_as_stale_at = `closed this issue as stale %[2]s` issues.reopened_at = `reopened this issue %[2]s` issues.commit_ref_at = `referenced this issue from a commit %[2]s` issues.ref_issue_from = `referenced this issue %[4]s %[2]s` @@ -1596,6 +1602,19 @@ issues.role.first_time_contributor = First-time contributor issues.role.first_time_contributor_helper = This is the first contribution of this user to the repository. issues.role.contributor = Contributor issues.role.contributor_helper = This user has previously committed to the repository. +issues.close_as.reopen = Reopen +issues.close_as.common = Close Issue +issues.close_as.archived = Close as archived +issues.close_as.resolved = Close as resolved +issues.close_as.stale = Close as stale +issues.comment_and_close_as.reopen = Comment and Reopen +issues.comment_and_close_as.common = Comment and Close Issue +issues.comment_and_close_as.archived = Comment and Close as archived +issues.comment_and_close_as.resolved = Comment and Close as resolved +issues.comment_and_close_as.stale = Comment and Close as stale +issues.poster = Poster +issues.collaborator = Collaborator +issues.owner = Owner issues.re_request_review=Re-request review issues.is_stale = There have been changes to this PR since this review issues.remove_request_review=Remove review request @@ -1898,6 +1917,7 @@ pulls.update_branch_success = Branch update was successful pulls.update_not_allowed = You are not allowed to update branch pulls.outdated_with_base_branch = This branch is out-of-date with the base branch pulls.close = Close Pull Request +pulls.comment_and_close = Comment and close Pull Request pulls.closed_at = `closed this pull request %[2]s` pulls.reopened_at = `reopened this pull request %[2]s` pulls.cmd_instruction_hint = `View command line instructions.` diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index c1218440e5958..acff05b03df1d 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -719,7 +719,8 @@ func CreateIssue(ctx *context.APIContext) { } if form.Closed { - if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil { + issue.IsClosed = form.Closed + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, ""); err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") return @@ -906,6 +907,26 @@ func EditIssue(ctx *context.APIContext) { isClosed = true default: ctx.Error(http.StatusPreconditionFailed, "UnknownIssueStateError", fmt.Sprintf("unknown state: %s", state)) + } + + issue.ClosedStatus = issues_model.IssueClosedStatusCommon + if issue.IsClosed && form.ClosedStatus != nil { + switch *form.ClosedStatus { + case api.ClosedStatusArchived: + issue.ClosedStatus = issues_model.IssueClosedStatusArchived + case api.ClosedStatusResolved: + issue.ClosedStatus = issues_model.IssueClosedStatusResolved + case api.ClosedStatusStale: + issue.ClosedStatus = issues_model.IssueClosedStatusStale + default: + issue.ClosedStatus = issues_model.IssueClosedStatusCommon + } + } + } + statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(issue, ctx.Doer) + if err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") return } diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 507b5af9d904a..0bb1495ac889b 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1381,6 +1381,40 @@ func getBranchData(ctx *context.Context, issue *issues_model.Issue) { } } +type IssueCloseBtnItem struct { + Value issues_model.IssueClosedStatus + Status string + StatusAndComment string +} + +var issueCloseBtnItems = []IssueCloseBtnItem{ + { + Value: -1, + Status: "repo.issues.close_as.reopen", + StatusAndComment: "repo.issues.comment_and_close_as.reopen", + }, + { + Value: issues_model.IssueClosedStatusCommon, + Status: "repo.issues.close_as.common", + StatusAndComment: "repo.issues.comment_and_close_as.common", + }, + { + Value: issues_model.IssueClosedStatusArchived, + Status: "repo.issues.close_as.archived", + StatusAndComment: "repo.issues.comment_and_close_as.archived", + }, + { + Value: issues_model.IssueClosedStatusResolved, + Status: "repo.issues.close_as.resolved", + StatusAndComment: "repo.issues.comment_and_close_as.resolved", + }, + { + Value: issues_model.IssueClosedStatusStale, + Status: "repo.issues.close_as.stale", + StatusAndComment: "repo.issues.comment_and_close_as.stale", + }, +} + // ViewIssue render issue view page func ViewIssue(ctx *context.Context) { if ctx.PathParam(":type") == "issues" { @@ -1441,6 +1475,21 @@ func ViewIssue(ctx *context.Context) { } ctx.Data["PageIsIssueList"] = true ctx.Data["NewIssueChooseTemplate"] = issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) + + // assemble data for close/reopen issue dropdown. + var btnItems []IssueCloseBtnItem + for _, item := range issueCloseBtnItems { + if !issue.IsClosed && item.Value == -1 { + // if issue is open, do not append "reopen" btn item. + continue + } + if issue.IsClosed && item.Value == issue.ClosedStatus { + // if issue is closed and the status of issue is equal to this item, skip it. + continue + } + btnItems = append(btnItems, item) + } + ctx.Data["IssueCloseBtnItems"] = btnItems } if issue.IsPull && !ctx.Repo.CanRead(unit.TypeIssues) { @@ -2971,7 +3020,8 @@ func UpdateIssueStatus(ctx *context.Context) { continue } if issue.IsClosed != isClosed { - if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { + issue.IsClosed = isClosed + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, ""); err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.JSON(http.StatusPreconditionFailed, map[string]any{ "error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index), @@ -3149,6 +3199,8 @@ func NewComment(ctx *context.Context) { } }() + defer closeOrReopenIssue(ctx, form, issue, comment) + // Fix #321: Allow empty comments, as long as we have attachments. if len(form.Content) == 0 && len(attachments) == 0 { return @@ -3167,6 +3219,127 @@ func NewComment(ctx *context.Context) { log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) } +// closeOrReopenIssue close or reopen Issue(including PR) after creating comment. +func closeOrReopenIssue(ctx *context.Context, form *forms.CreateCommentForm, issue *issues_model.Issue, comment *issues_model.Comment) { + // Check if issue admin/poster changes the status of issue. + if (ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) || (ctx.IsSigned && issue.IsPoster(ctx.Doer.ID))) && + (form.Status == "reopen" || form.Status == "close") && + !(issue.IsPull && issue.PullRequest.HasMerged) { + + // Duplication and conflict check should apply to reopen pull request. + var pr *issues_model.PullRequest + + if form.Status == "reopen" && issue.IsPull { + pull := issue.PullRequest + var err error + pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) + if err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + return + } + } + + // Regenerate patch and test conflict. + if pr == nil { + issue.PullRequest.HeadCommitID = "" + pull_service.AddToTaskQueue(ctx, issue.PullRequest) + } + + // check whether the ref of PR in base repo is consistent with the head commit of head branch in the head repo + // get head commit of PR + prHeadRef := pull.GetGitRefName() + if err := pull.LoadBaseRepo(ctx); err != nil { + ctx.ServerError("Unable to load base repo", err) + return + } + prHeadCommitID, err := git.GetFullCommitID(ctx, pull.BaseRepo.RepoPath(), prHeadRef) + if err != nil { + ctx.ServerError("Get head commit Id of pr fail", err) + return + } + + // get head commit of branch in the head repo + if err := pull.LoadHeadRepo(ctx); err != nil { + ctx.ServerError("Unable to load head repo", err) + return + } + if ok := git.IsBranchExist(ctx, pull.HeadRepo.RepoPath(), pull.BaseBranch); !ok { + // todo localize + ctx.JSONError("The origin branch is delete, cannot reopen.") + return + } + headBranchRef := pull.GetGitHeadBranchRefName() + headBranchCommitID, err := git.GetFullCommitID(ctx, pull.HeadRepo.RepoPath(), headBranchRef) + if err != nil { + ctx.ServerError("Get head commit Id of head branch fail", err) + return + } + + err = pull.LoadIssue(ctx) + if err != nil { + ctx.ServerError("load the issue of pull request error", err) + return + } + + if prHeadCommitID != headBranchCommitID { + // force push to base repo + err := git.Push(ctx, pull.HeadRepo.RepoPath(), git.PushOptions{ + Remote: pull.BaseRepo.RepoPath(), + Branch: pull.HeadBranch + ":" + prHeadRef, + Force: true, + Env: repo_module.InternalPushingEnvironment(pull.Issue.Poster, pull.BaseRepo), + }) + if err != nil { + ctx.ServerError("force push error", err) + return + } + } + } + + if pr != nil { + ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) + } else { + issue.IsClosed = form.Status == "close" + issue.ClosedStatus = issues_model.IssueClosedStatus(0) + if issue.IsClosed { + issue.ClosedStatus = form.ClosedStatus + } + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, ""); err != nil { + log.Error("ChangeStatus: %v", err) + + if issues_model.IsErrDependenciesLeft(err) { + if issue.IsPull { + ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + } else { + ctx.JSONError(ctx.Tr("repo.issues.dependency.issue_close_blocked")) + } + return + } + } else { + if err := stopTimerIfAvailable(ctx.Doer, issue); err != nil { + ctx.ServerError("CreateOrStopIssueStopwatch", err) + return + } + + log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) + } + } + + } + + // Redirect to comment hashtag if there is any actual content. + typeName := "issues" + if issue.IsPull { + typeName = "pulls" + } + if comment != nil { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) + } else { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) + } +} + // UpdateCommentContent change comment of issue's content func UpdateCommentContent(ctx *context.Context) { comment, err := issues_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 988e479a48138..5eceab25eac2c 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -463,9 +463,10 @@ func (f *CreateIssueForm) Validate(req *http.Request, errs binding.Errors) bindi // CreateCommentForm form for creating comment type CreateCommentForm struct { - Content string - Status string `binding:"OmitEmpty;In(reopen,close)"` - Files []string + Content string + Status string `binding:"OmitEmpty;In(reopen,close)"` + Files []string + ClosedStatus issues_model.IssueClosedStatus } // Validate validates the fields diff --git a/services/issue/commit.go b/services/issue/commit.go index 0579e0f5c53e6..ad910ce565e7d 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -196,6 +196,7 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m } if isClosed != refIssue.IsClosed { refIssue.Repo = refRepo + refIssue.IsClosed = close if err := ChangeStatus(ctx, refIssue, doer, c.Sha1, isClosed); err != nil { return err } diff --git a/services/issue/status.go b/services/issue/status.go index 967c29bd22230..fea7535e3005b 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -19,7 +19,7 @@ import ( func ChangeStatus(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string, closed bool) error { comment, err := issues_model.ChangeIssueStatus(ctx, issue, doer, closed) if err != nil { - if issues_model.IsErrDependenciesLeft(err) && closed { + if issues_model.IsErrDependenciesLeft(err) && issue.IsClosed { if err := issues_model.FinishIssueStopwatchIfPossible(ctx, doer, issue); err != nil { log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err) } @@ -27,7 +27,7 @@ func ChangeStatus(ctx context.Context, issue *issues_model.Issue, doer *user_mod return err } - if closed { + if issue.IsClosed { if err := issues_model.FinishIssueStopwatchIfPossible(ctx, doer, issue); err != nil { return err } diff --git a/services/pull/merge.go b/services/pull/merge.go index a3fbe4f627b00..b77dc2e70f8c2 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -242,8 +242,9 @@ func handleCloseCrossReferences(ctx context.Context, pr *issues_model.PullReques if err = ref.Issue.LoadRepo(ctx); err != nil { return err } - isClosed := ref.RefAction == references.XRefActionCloses - if isClosed != ref.Issue.IsClosed { + close := ref.RefAction == references.XRefActionCloses + if close != ref.Issue.IsClosed { + ref.Issue.IsClosed = close if err = issue_service.ChangeStatus(ctx, ref.Issue, doer, pr.MergedCommitID, isClosed); err != nil { // Allow ErrDependenciesLeft if !issues_model.IsErrDependenciesLeft(err) { diff --git a/services/pull/pull.go b/services/pull/pull.go index bab4e49998e15..27aef100f3f8d 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -662,7 +662,8 @@ func CloseBranchPulls(ctx context.Context, doer *user_model.User, repoID int64, var errs errlist for _, pr := range prs { - if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) { + pr.Issue.IsClosed = true + if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) { errs = append(errs, err) } } @@ -696,7 +697,8 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re if pr.BaseRepoID == repo.ID { continue } - if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) { + pr.Issue.IsClosed = true + if err = issue_service.ChangeStatus(ctx, pr.Issue, doer, ""); err != nil && !issues_model.IsErrPullWasClosed(err) { errs = append(errs, err) } } diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl index 395b118d3ef22..6f1624779c185 100644 --- a/templates/mail/issue/default.tmpl +++ b/templates/mail/issue/default.tmpl @@ -36,7 +36,15 @@ {{end}}

{{if eq .ActionName "close"}} - {{.locale.Tr "mail.issue.action.close" .Doer.Name .Issue.Index}} + {{$closeTrans := "mail.issue.action.close"}} + {{if eq .Issue.ClosedStatus 1}} + {{$closeTrans = "mail.issue.action.close_as_archived"}} + {{else if eq .Issue.ClosedStatus 2}} + {{$closeTrans = "mail.issue.action.close_as_resolved"}} + {{else if eq .Issue.ClosedStatus 3}} + {{$closeTrans = "mail.issue.action.close_as_stale"}} + {{end}} + {{.locale.Tr $closeTrans (Escape .Doer.Name) .Issue.Index | Str2html}} {{else if eq .ActionName "reopen"}} {{.locale.Tr "mail.issue.action.reopen" .Doer.Name .Issue.Index}} {{else if eq .ActionName "merge"}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index e4213b8fcd6b3..ab00a798e4c40 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -92,20 +92,41 @@