diff --git a/models/issues/comment.go b/models/issues/comment.go index e78193126183b..db9f3f6c1f063 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1254,3 +1254,8 @@ func FixCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) { func (c *Comment) HasOriginalAuthor() bool { return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 } + +func (c *Comment) GetIssueClosedStatus() int { + n, _ := strconv.Atoi(c.Content) + return n +} diff --git a/models/issues/dependency_test.go b/models/issues/dependency_test.go index cdc8e3182d361..dac7af3e826f1 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 f000f4c66029a..bd9ce7779d586 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -92,6 +92,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 +) + // Issue represents an issue or pull request of repository. type Issue struct { ID int64 `xorm:"pk autoincr"` @@ -110,12 +119,13 @@ type Issue struct { Milestone *Milestone `xorm:"-"` Project *project_model.Project `xorm:"-"` Priority int - AssigneeID int64 `xorm:"-"` - Assignee *user_model.User `xorm:"-"` - IsClosed bool `xorm:"INDEX"` - IsRead bool `xorm:"-"` - IsPull bool `xorm:"INDEX"` // Indicates whether is a pull request or not. - PullRequest *PullRequest `xorm:"-"` + AssigneeID int64 `xorm:"-"` + Assignee *user_model.User `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:"-"` NumComments int Ref string PinOrder int `xorm:"DEFAULT 0"` diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 9607b21a67585..f571e70208fc8 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. @@ -472,10 +480,19 @@ func UpdateIssueByAPI(issue *Issue, doer *user_model.User) (statusChangeComment } } - if currentIssue.IsClosed != issue.IsClosed { - statusChangeComment, err = doChangeIssueStatus(ctx, issue, doer, false) - if err != nil { - return nil, false, 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 + } } } diff --git a/models/issues/issue_xref_test.go b/models/issues/issue_xref_test.go index 6e94c262723eb..42727af1bdb5c 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 676224a3d6982..7f632d8755f34 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -517,7 +517,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 b2140a1eb1327..bc83050c6cb16 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -523,6 +523,8 @@ var migrations = []Migration{ NewMigration("Drop deleted branch table", v1_21.DropDeletedBranchTable), // v270 -> v271 NewMigration("Fix PackageProperty typo", v1_21.FixPackagePropertyTypo), + // v271 -> v272 + NewMigration("Add column of closed_status to issue table", v1_21.AddClosedStatusToIssue), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_21/v271.go b/models/migrations/v1_21/v271.go new file mode 100644 index 0000000000000..20f417a7e9094 --- /dev/null +++ b/models/migrations/v1_21/v271.go @@ -0,0 +1,18 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_21 //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 1aec5cc6b86c8..6da33de046853 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -24,6 +24,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"` @@ -110,6 +116,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 2023dade3fa46..613b12c3f0a30 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -455,6 +455,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. @@ -1465,6 +1468,9 @@ issues.reopen_issue = Reopen issues.reopen_comment_issue = Comment and Reopen issues.create_comment = Comment 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` @@ -1474,6 +1480,16 @@ issues.ref_reopening_from = `referenced a pull request %[4]s tha issues.ref_closed_from = `closed this issue %[4]s %[2]s` issues.ref_reopened_from = `reopened this issue %[4]s %[2]s` issues.ref_from = `from %[1]s` +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 @@ -1762,6 +1778,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.merge_instruction_hint = `You can also view command line instructions.` diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 861e63a9b82f8..0f8db67c0e391 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -687,7 +687,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 @@ -843,6 +844,19 @@ func EditIssue(ctx *context.APIContext) { } } issue.IsClosed = api.StateClosed == api.StateType(*form.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 { diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 488c97b0eb6ea..9e0f21f6bbfab 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1286,6 +1286,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.Params(":type") == "issues" { @@ -1346,6 +1380,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) { @@ -2840,7 +2889,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), @@ -2902,133 +2952,142 @@ func NewComment(ctx *context.Context) { } var comment *issues_model.Comment - defer func() { - // 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 + defer closeOrReopenIssue(ctx, form, issue, comment) - 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 - } - } + // Fix #321: Allow empty comments, as long as we have attachments. + if len(form.Content) == 0 && len(attachments) == 0 { + return + } - // Regenerate patch and test conflict. - if pr == nil { - issue.PullRequest.HeadCommitID = "" - pull_service.AddToTaskQueue(ctx, issue.PullRequest) - } + comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) + if err != nil { + ctx.ServerError("CreateIssueComment", err) + return + } - // 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 - } + log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) +} - // 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 - } +// 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) { - err = pull.LoadIssue(ctx) - if err != nil { - ctx.ServerError("load the issue of pull request error", err) + // 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 } + } - 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 - } - } + // Regenerate patch and test conflict. + if pr == nil { + issue.PullRequest.HeadCommitID = "" + pull_service.AddToTaskQueue(ctx, issue.PullRequest) } - if pr != nil { - ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) - } else { - isClosed := form.Status == "close" - if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); 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 - } + // 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 + } - log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) + 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 } } } - // 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())) + if pr != nil { + ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) } else { - ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) + 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) + } } - }() - // Fix #321: Allow empty comments, as long as we have attachments. - if len(form.Content) == 0 && len(attachments) == 0 { - return } - comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) - if err != nil { - ctx.ServerError("CreateIssueComment", err) - return + // 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)) } - - log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) } // UpdateCommentContent change comment of issue's content diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index a26a2d89c5d65..62f605818dc80 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -455,9 +455,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 e493a032114af..322f4ec6c31d5 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -193,7 +193,8 @@ func UpdateIssuesCommit(ctx context.Context, doer *user_model.User, repo *repo_m } if close != refIssue.IsClosed { refIssue.Repo = refRepo - if err := ChangeStatus(ctx, refIssue, doer, c.Sha1, close); err != nil { + refIssue.IsClosed = close + if err := ChangeStatus(ctx, refIssue, doer, c.Sha1); err != nil { return err } } diff --git a/services/issue/status.go b/services/issue/status.go index 3718a5048f93f..15bc19370b263 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -13,10 +13,10 @@ import ( ) // ChangeStatus changes issue status to open or closed. -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) +func ChangeStatus(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error { + comment, err := issues_model.ChangeIssueStatus(ctx, issue, doer) 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) } @@ -24,13 +24,13 @@ 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 } } - notification.NotifyIssueChangeStatus(ctx, doer, commitID, issue, comment, closed) + notification.NotifyIssueChangeStatus(ctx, doer, commitID, issue, comment, issue.IsClosed) return nil } diff --git a/services/pull/merge.go b/services/pull/merge.go index 7051fd9eda24d..a22c114cfee6c 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -230,7 +230,8 @@ func Merge(ctx context.Context, pr *issues_model.PullRequest, doer *user_model.U } close := ref.RefAction == references.XRefActionCloses if close != ref.Issue.IsClosed { - if err = issue_service.ChangeStatus(ctx, ref.Issue, doer, pr.MergedCommitID, close); err != nil { + ref.Issue.IsClosed = close + if err = issue_service.ChangeStatus(ctx, ref.Issue, doer, pr.MergedCommitID); err != nil { // Allow ErrDependenciesLeft if !issues_model.IsErrDependenciesLeft(err) { return err diff --git a/services/pull/pull.go b/services/pull/pull.go index 0b6194b1436d8..e9022488175a0 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -562,7 +562,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) } } @@ -596,7 +597,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 422a4f0461108..0974fcbbd044a 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" (Escape .Doer.Name) .Issue.Index | Str2html}} + {{$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" (Escape .Doer.Name) .Issue.Index | Str2html}} {{else if eq .ActionName "merge"}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index f6572d49653a4..51d325a2bf624 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -102,21 +102,42 @@

diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 8cf5332bafc48..8388605082ce1 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -17954,6 +17954,10 @@ "type": "string", "x-go-name": "Body" }, + "closed_status": { + "type": "string", + "x-go-name": "ClosedStatus" + }, "due_date": { "type": "string", "format": "date-time", diff --git a/web_src/css/base.css b/web_src/css/base.css index d44f949318a73..23488760cd587 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1302,10 +1302,6 @@ img.ui.avatar, margin-right: 6px; } -.ui.status.buttons .svg { - margin-right: 4px; -} - .ui.inline.delete-button { padding: 8px 15px; font-weight: var(--font-weight-normal); diff --git a/web_src/css/modules/button.css b/web_src/css/modules/button.css index 373b815d5c936..aca59455301d0 100644 --- a/web_src/css/modules/button.css +++ b/web_src/css/modules/button.css @@ -54,6 +54,10 @@ a.btn:hover { /* other button styles */ +.ui.buttons { + vertical-align: middle; +} + .ui.buttons .button:first-child { border-left: 1px solid var(--color-light-border); } diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 34fa2a0052fc7..33b90f7788c07 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -983,16 +983,6 @@ border-radius: var(--border-radius); } -@media (max-width: 767.98px) { - .repository.view.issue .comment-list .comment .content .form .button { - width: 100%; - margin: 0; - } - .repository.view.issue .comment-list .comment .content .form .button:not(:last-child) { - margin-bottom: 1rem; - } -} - .repository.view.issue .comment-list .comment .merge-section { background-color: var(--color-box-body); } @@ -1062,10 +1052,6 @@ clear: none; } -.repository.view.issue .comment-list .comment .ui.form .field.footer { - overflow: hidden; -} - .repository.view.issue .comment-list .comment .ui.form .field .tab.markup { min-height: 5rem; } @@ -1074,11 +1060,6 @@ margin-top: 10px; } -.repository.view.issue .comment-list .code-comment { - border: 1px solid transparent; - margin: 0; -} - /* fix fomantic's border-radius via :first-child with hidden elements */ .collapsible-comment-box:has(.gt-hidden) { border-radius: var(--border-radius) !important; diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 194ffca57c4a5..f55268f5aacd9 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -633,15 +633,42 @@ export function initRepoIssueBranchSelect() { export function initSingleCommentEditor($commentForm) { // pages: // * normal new issue/pr page, no status-button - // * issue/pr view page, with comment form, has status-button - const opts = {}; - const $statusButton = $('#status-button'); - if ($statusButton.length) { - opts.onContentChanged = (editor) => { - $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); - }; + // * issue/pr view page, with comment form, has status button, and/or status dropdown + const $statusButton = $commentForm.find('.status-button'); + const $statusDropdown = $commentForm.find('.status-dropdown'); + let comboMarkdownEditor; + + // update the status-button's text according to the editor's content + const updateStatusButtonText = () => { + if (!comboMarkdownEditor || !$statusButton.length) return; + $statusButton.text($statusButton.attr(comboMarkdownEditor.value().trim() ? 'data-status-and-comment' : 'data-status')); + }; + + // update the status-button's text and value according to the selected dropdown's value (close status) + const updateStatusButtonByCloseStatus = (val) => { + if (!$statusButton.length) return; + const $item = $statusDropdown.dropdown('get item'); + $statusButton.attr('data-status', $item.attr('data-status')); + $statusButton.attr('data-status-and-comment', $item.attr('data-status-and-comment')); + $statusButton.value = val === '-1' ? 'reopen' : 'close'; + updateStatusButtonText(); + }; + + if ($statusDropdown.length) { + $statusDropdown.dropdown('setting', { + selectOnKeydown: false, + allowReselection: true, + onChange: updateStatusButtonByCloseStatus, + }); + const selectedValue = $statusDropdown.find('input[type=hidden]').val(); + $statusDropdown.dropdown('set selected', selectedValue); + updateStatusButtonByCloseStatus(selectedValue); } - initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts); + + const editorOpts = {onContentChanged: updateStatusButtonText}; + initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), editorOpts).then((editor) => { + comboMarkdownEditor = editor; + }); } export function initIssueTemplateCommentEditors($commentForm) {