From aadd437c413687d04b99a1aa9adf93969e825e04 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Thu, 8 Jun 2023 14:36:35 +0800 Subject: [PATCH 01/62] temp --- models/issues/issue.go | 24 +++++++++-- options/locale/locale_en-US.ini | 14 ++++++ routers/web/repo/issue.go | 59 ++++++++++++++++++++++++++ templates/repo/issue/view_content.tmpl | 14 ++++++ web_src/css/repo.css | 2 +- 5 files changed, 109 insertions(+), 4 deletions(-) diff --git a/models/issues/issue.go b/models/issues/issue.go index eab18f4892ce9..170b947d7e18b 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -91,6 +91,23 @@ func (err ErrIssueWasClosed) Error() string { return fmt.Sprintf("Issue [%d] %d was already closed", err.ID, err.Index) } +type IssueClosedState int8 + +const ( + // IssueClosedStateCommonClose close issue without any state. + IssueClosedStateCommonClose IssueClosedState = iota + // IssueClosedStateArchived close issue as archived. + IssueClosedStateArchived + // IssueClosedStateResolved close issue as resolved. + IssueClosedStateResolved + // IssueClosedStateMerged close issue as merged. + IssueClosedStateMerged + // IssueClosedStateDuplicate close issue as duplicate. + IssueClosedStateDuplicate + // IssueClosedStateStale close issue as stale. + IssueClosedStateStale +) + // Issue represents an issue or pull request of repository. type Issue struct { ID int64 `xorm:"pk autoincr"` @@ -112,9 +129,10 @@ type Issue struct { 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:"-"` + ClosedState IssueClosedState + 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/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 195252c47d176..98cdbfa147982 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1452,6 +1452,20 @@ 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.merged = Close as merged +issues.close_as.duplicate = Close as duplicate +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.merged = Comment and Close as merged +issues.comment_and_close_as.duplicate = Comment and Close as duplicate +issues.comment_and_close_as.stale = Comment and Close as stale issues.poster = Poster issues.collaborator = Collaborator issues.owner = Owner diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index cbb2e20314489..98bd0c7c679d4 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1249,6 +1249,50 @@ func getBranchData(ctx *context.Context, issue *issues_model.Issue) { } } +type IssueCloseBtnItem struct { + Value issues_model.IssueClosedState + State string + StateAndComment string +} + +var issueCloseBtnItems = []IssueCloseBtnItem{ + { + Value: issues_model.IssueClosedState(-1), + State: "repo.issues.close_as.reopen", + StateAndComment: "repo.issues.comment_and_close_as.reopen", + }, + { + Value: issues_model.IssueClosedStateCommonClose, + State: "repo.issues.close_as.common", + StateAndComment: "repo.issues.comment_and_close_as.common", + }, + { + Value: issues_model.IssueClosedStateArchived, + State: "repo.issues.close_as.archived", + StateAndComment: "repo.issues.comment_and_close_as.archived", + }, + { + Value: issues_model.IssueClosedStateResolved, + State: "repo.issues.close_as.resolved", + StateAndComment: "repo.issues.comment_and_close_as.resolved", + }, + { + Value: issues_model.IssueClosedStateMerged, + State: "repo.issues.close_as.merged", + StateAndComment: "repo.issues.comment_and_close_as.merged", + }, + { + Value: issues_model.IssueClosedStateDuplicate, + State: "repo.issues.close_as.duplicate", + StateAndComment: "repo.issues.comment_and_close_as.duplicate", + }, + { + Value: issues_model.IssueClosedStateStale, + State: "repo.issues.close_as.stale", + StateAndComment: "repo.issues.comment_and_close_as.stale", + }, +} + // ViewIssue render issue view page func ViewIssue(ctx *context.Context) { if ctx.Params(":type") == "issues" { @@ -1309,6 +1353,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 button + var btnItems []IssueCloseBtnItem + for _, item := range issueCloseBtnItems { + if !issue.IsClosed && item.Value == issues_model.IssueClosedState(-1) { + // if issue is open, do not append "reopen" btn item + continue + } + if issue.IsClosed && item.Value == issue.ClosedState { + // if issue is closed and the state 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) { diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 3669e00d5e6bd..e7051e712c1bf 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -103,6 +103,20 @@ {{/* end: new close / reopen button */}} - {{if .Issue.IsClosed}} - - {{else}} - {{$closeTranslationKey := "repo.issues.close"}} - {{if .Issue.IsPull}} - {{$closeTranslationKey = "repo.pulls.close"}} - {{end}} - - {{end}} {{end}} - diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 6a225c6f7dc4d..276713ddabff8 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1051,6 +1051,10 @@ margin-top: 10px; } +.repository.view.issue .comment-list .comment .ui.form .field .ui.buttons { + vertical-align: middle; +} + .repository.view.issue .comment-list .code-comment { border: 1px solid transparent; margin: 0; diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 0dc5728f58bc0..86a6aea22d067 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -635,17 +635,15 @@ export function initSingleCommentEditor($commentForm) { // * issue/pr view page, with comment form, has status-button const opts = {}; const $statusButton = $('#status-button'); + const $statusDropdown = $('#status-dropdown'); if ($statusButton.length) { - $statusButton.on('click', (e) => { - e.preventDefault(); - $('#status').val($statusButton.data('status-val')); - $('#comment-form').trigger('submit'); - }); opts.onContentChanged = (editor) => { - $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); + console.log('content changed', editor.value().trim()); + $statusButton.text($statusDropdown.dropdown('get item').data(editor.value().trim() ? 'status-and-comment': 'status')); }; } initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts); + initRepoIssueStateButton(); } export function initIssueTemplateCommentEditors($commentForm) { @@ -684,3 +682,33 @@ export function initIssueTemplateCommentEditors($commentForm) { initCombo($(el)); } } + +function initRepoIssueStateButton() { + const $statusButton = $('#status-button'); + if (!$statusButton.length) return; + + $statusButton.on('click', (e) => { + e.preventDefault(); + $('#status').val($statusButton.data('status-value') === -1 ? 'reopen' : 'close'); + $('#comment-form').trigger('submit'); + }) + + $('#comment-button').on('click', (e) => { + e.preventDefault(); + $('#status').val(''); + $('#comment-form').trigger('submit'); + }) + + const $statusDropdown = $('#status-dropdown'); + const selectedValue = $statusDropdown.find('input[type=hidden]').val(); + + const onCloseStatusChanged = (val) => { + $statusButton.attr('data-status-value', val); + const editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); + const buttonText = $statusDropdown.dropdown('get item').data(editor.value().trim() ? 'status-and-comment': 'status'); + $statusButton.text(buttonText); + } + $statusDropdown.dropdown('setting', { selectOnKeydown: false, onChange: onCloseStatusChanged }); + $statusDropdown.dropdown('set selected', selectedValue); + onCloseStatusChanged(selectedValue); +} From 8ffa87da18ce6ef6f3b7bd761b065225fd4309d8 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Fri, 16 Jun 2023 14:22:53 +0800 Subject: [PATCH 03/62] form struct add closed status --- services/forms/repo_form.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 8108a55f7a35d..c72430f6cdc29 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -454,9 +454,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)"` + ClosedStatus issues_model.IssueClosedStatus + Files []string } // Validate validates the fields From 8d0d28fbf4409c99881ba1509dd1eaf3d1dc2ec6 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Mon, 19 Jun 2023 15:18:47 +0800 Subject: [PATCH 04/62] delete console log --- web_src/js/features/repo-issue.js | 1 - 1 file changed, 1 deletion(-) diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 86a6aea22d067..681448f40770f 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -638,7 +638,6 @@ export function initSingleCommentEditor($commentForm) { const $statusDropdown = $('#status-dropdown'); if ($statusButton.length) { opts.onContentChanged = (editor) => { - console.log('content changed', editor.value().trim()); $statusButton.text($statusDropdown.dropdown('get item').data(editor.value().trim() ? 'status-and-comment': 'status')); }; } From dd715575b9510c6ef3883bc7777861774a7de71f Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Mon, 19 Jun 2023 18:36:12 +0800 Subject: [PATCH 05/62] update ChangeStatus --- models/issues/dependency_test.go | 3 +- models/issues/issue_update.go | 11 +- models/issues/issue_xref_test.go | 2 +- models/issues/pull.go | 3 +- routers/api/v1/repo/issue.go | 3 +- routers/web/repo/issue.go | 225 ++++++++++++++++--------------- services/issue/commit.go | 3 +- services/issue/status.go | 15 ++- services/pull/merge.go | 3 +- services/pull/pull.go | 6 +- 10 files changed, 143 insertions(+), 131 deletions(-) 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_update.go b/models/issues/issue_update.go index b6fd720fe5eb8..6bff373ea4ec3 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -33,7 +33,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,7 +41,7 @@ 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 currentIssue.IsClosed == issue.IsClosed { if !issue.IsPull { return nil, ErrIssueWasClosed{ ID: issue.ID, @@ -52,7 +52,6 @@ func changeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User, } } - issue.IsClosed = isClosed return doChangeIssueStatus(ctx, issue, doer, isMergePull) } @@ -76,7 +75,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 } @@ -119,7 +118,7 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } // 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 +126,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. diff --git a/models/issues/issue_xref_test.go b/models/issues/issue_xref_test.go index 6e94c262723eb..b7dac09338400 100644 --- a/models/issues/issue_xref_test.go +++ b/models/issues/issue_xref_test.go @@ -98,7 +98,7 @@ 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) + _, 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 2acc2b4226e0b..83fbe3ec505ee 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -519,7 +519,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/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 49252f7a4b49a..a3fb5387b8e0e 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -661,7 +661,8 @@ func CreateIssue(ctx *context.APIContext) { } if form.Closed { - if err := issue_service.ChangeStatus(issue, ctx.Doer, "", true); err != nil { + issue.IsClosed = form.Closed + if err := issue_service.ChangeStatus(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 diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index d5115cc477d71..fd42b828003ad 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2787,7 +2787,8 @@ func UpdateIssueStatus(ctx *context.Context) { } for _, issue := range issues { if issue.IsClosed != isClosed { - if err := issue_service.ChangeStatus(issue, ctx.Doer, "", isClosed); err != nil { + issue.IsClosed = isClosed + if err := issue_service.ChangeStatus(issue, ctx.Doer, ""); err != nil { if issues_model.IsErrDependenciesLeft(err) { ctx.JSON(http.StatusPreconditionFailed, map[string]interface{}{ "error": ctx.Tr("repo.issues.dependency.issue_batch_close_blocked", issue.Index), @@ -2853,138 +2854,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.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) - ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) - 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(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.Flash.Error("The origin branch is delete, cannot reopen.") +// 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.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) 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 - } + // Regenerate patch and test conflict. + if pr == nil { + issue.PullRequest.HeadCommitID = "" + pull_service.AddToTaskQueue(issue.PullRequest) + } - 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 - } - } + // 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 } - 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(issue, ctx.Doer, "", isClosed); err != nil { - log.Error("ChangeStatus: %v", err) - - if issues_model.IsErrDependenciesLeft(err) { - if issue.IsPull { - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) - ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) - } else { - ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked")) - ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index)) - } - return - } - } else { - if err := stopTimerIfAvailable(ctx.Doer, issue); err != nil { - ctx.ServerError("CreateOrStopIssueStopwatch", 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.Flash.Error("The origin branch is delete, cannot reopen.") + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, pull.Index)) + 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.Redirect(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.Redirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) + issue.IsClosed = form.Status == "close" + if err := issue_service.ChangeStatus(issue, ctx.Doer, ""); err != nil { + log.Error("ChangeStatus: %v", err) + + if issues_model.IsErrDependenciesLeft(err) { + if issue.IsPull { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.pr_close_blocked")) + ctx.Redirect(fmt.Sprintf("%s/pulls/%d", ctx.Repo.RepoLink, issue.Index)) + } else { + ctx.Flash.Error(ctx.Tr("repo.issues.dependency.issue_close_blocked")) + ctx.Redirect(fmt.Sprintf("%s/issues/%d", ctx.Repo.RepoLink, issue.Index)) + } + 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.Redirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, comment.HashTag())) + } else { + ctx.Redirect(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/issue/commit.go b/services/issue/commit.go index 7a8c71e609c4b..087fab170ad3d 100644 --- a/services/issue/commit.go +++ b/services/issue/commit.go @@ -193,7 +193,8 @@ func UpdateIssuesCommit(doer *user_model.User, repo *repo_model.Repository, comm } if close != refIssue.IsClosed { refIssue.Repo = refRepo - if err := ChangeStatus(refIssue, doer, c.Sha1, close); err != nil { + refIssue.IsClosed = close + if err := ChangeStatus(refIssue, doer, c.Sha1); err != nil { return err } } diff --git a/services/issue/status.go b/services/issue/status.go index d4a0fce3e586a..8859692967266 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -14,16 +14,16 @@ import ( ) // ChangeStatus changes issue status to open or closed. -func ChangeStatus(issue *issues_model.Issue, doer *user_model.User, commitID string, closed bool) error { - return changeStatusCtx(db.DefaultContext, issue, doer, commitID, closed) +func ChangeStatus(issue *issues_model.Issue, doer *user_model.User, commitID string) error { + return changeStatusCtx(db.DefaultContext, issue, doer, commitID) } // changeStatusCtx changes issue status to open or closed. // TODO: if context is not db.DefaultContext we get a deadlock!!! -func changeStatusCtx(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 changeStatusCtx(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) } @@ -31,13 +31,14 @@ func changeStatusCtx(ctx context.Context, issue *issues_model.Issue, doer *user_ 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) + // TBD: whether to notify if only closed_status is changed. + notification.NotifyIssueChangeStatus(ctx, doer, commitID, issue, comment, issue.IsClosed) return nil } diff --git a/services/pull/merge.go b/services/pull/merge.go index 85bb90b853899..4a967e94cf2f7 100644 --- a/services/pull/merge.go +++ b/services/pull/merge.go @@ -229,7 +229,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(ref.Issue, doer, pr.MergedCommitID, close); err != nil { + ref.Issue.IsClosed = close + if err = issue_service.ChangeStatus(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 f44e690ab7087..3c85c0a4fb897 100644 --- a/services/pull/pull.go +++ b/services/pull/pull.go @@ -521,7 +521,8 @@ func CloseBranchPulls(doer *user_model.User, repoID int64, branch string) error var errs errlist for _, pr := range prs { - if err = issue_service.ChangeStatus(pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) { + pr.Issue.IsClosed = true + if err = issue_service.ChangeStatus(pr.Issue, doer, ""); err != nil && !issues_model.IsErrPullWasClosed(err) && !issues_model.IsErrDependenciesLeft(err) { errs = append(errs, err) } } @@ -555,7 +556,8 @@ func CloseRepoBranchesPulls(ctx context.Context, doer *user_model.User, repo *re if pr.BaseRepoID == repo.ID { continue } - if err = issue_service.ChangeStatus(pr.Issue, doer, "", true); err != nil && !issues_model.IsErrPullWasClosed(err) { + pr.Issue.IsClosed = true + if err = issue_service.ChangeStatus(pr.Issue, doer, ""); err != nil && !issues_model.IsErrPullWasClosed(err) { errs = append(errs, err) } } From 74e2af84376f3de5c679412b7f4a150c617bcc3d Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Tue, 20 Jun 2023 15:42:19 +0800 Subject: [PATCH 06/62] update --- models/issues/issue.go | 9 ++++++++ models/issues/issue_update.go | 23 ++++++++++++------- models/issues/issue_xref_test.go | 1 + options/locale/locale_en-US.ini | 6 +++++ routers/web/repo/issue.go | 4 ++++ templates/repo/issue/view_content.tmpl | 12 +++++++--- .../repo/issue/view_content/comments.tmpl | 6 ++++- web_src/js/features/repo-issue.js | 3 ++- 8 files changed, 51 insertions(+), 13 deletions(-) diff --git a/models/issues/issue.go b/models/issues/issue.go index b0528b24ad921..f71148725162d 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -108,6 +108,15 @@ const ( IssueClosedStatusStale ) +var issueClosedCommentTrMap = map[IssueClosedStatus]string{ + IssueClosedStatusCommonClose: "repo.issues.closed_at", + IssueClosedStatusArchived: "repo.issues.closed_as_archived_at", + IssueClosedStatusResolved: "repo.issues.closed_as_resolved_at", + IssueClosedStatusMerged: "repo.issues.closed_as_merged_at", + IssueClosedStatusDuplicate: "repo.issues.closed_as_duplicate_at", + IssueClosedStatusStale: "repo.issues.closed_as_stale_at", +} + // Issue represents an issue or pull request of repository. type Issue struct { ID int64 `xorm:"pk autoincr"` diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 6bff373ea4ec3..c972d9d1ca721 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -42,13 +42,15 @@ 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 == issue.IsClosed { - if !issue.IsPull { - return nil, ErrIssueWasClosed{ + if issue.IsPull { + return nil, ErrPullWasClosed{ ID: issue.ID, } } - return nil, ErrPullWasClosed{ - ID: issue.ID, + if currentIssue.ClosedStatus == issue.ClosedStatus { + return nil, ErrIssueIsClosed{ + ID: issue.ID, + } } } @@ -103,6 +105,10 @@ 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 = issueClosedCommentTrMap[issue.ClosedStatus] + } if !issue.IsClosed { cmtType = CommentTypeReopen } else if isMergePull { @@ -110,10 +116,11 @@ 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, }) } diff --git a/models/issues/issue_xref_test.go b/models/issues/issue_xref_test.go index b7dac09338400..42727af1bdb5c 100644 --- a/models/issues/issue_xref_test.go +++ b/models/issues/issue_xref_test.go @@ -98,6 +98,7 @@ 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) + i3.IsClosed = true _, err := issues_model.ChangeIssueStatus(db.DefaultContext, i3, d) assert.NoError(t, err) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 1f43a3a68dd69..bb32523011ba8 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1447,6 +1447,11 @@ 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_merged_at = `closed this issue as merged %[2]s` +issues.closed_as_duplicate_at = `closed this issue as duplicate %[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` @@ -1748,6 +1753,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/web/repo/issue.go b/routers/web/repo/issue.go index fd42b828003ad..9e52ec41c0c24 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2955,6 +2955,10 @@ func closeOrReopenIssue(ctx *context.Context, form *forms.CreateCommentForm, iss 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(issue, ctx.Doer, ""); err != nil { log.Error("ChangeStatus: %v", err) diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index d7c73da86c7ed..6e21e54363744 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -103,19 +103,26 @@ diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 681448f40770f..de94d53683775 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -638,7 +638,8 @@ export function initSingleCommentEditor($commentForm) { const $statusDropdown = $('#status-dropdown'); if ($statusButton.length) { opts.onContentChanged = (editor) => { - $statusButton.text($statusDropdown.dropdown('get item').data(editor.value().trim() ? 'status-and-comment': 'status')); + const $source = $statusDropdown.length > 0 ? $statusDropdown.dropdown('get item') : $statusButton; + $statusButton.text($source.data(editor.value().trim() ? 'status-and-comment': 'status')); }; } initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts); From 23d466d50a2ab655783606d9763c479d7a02564a Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Wed, 21 Jun 2023 10:15:54 +0800 Subject: [PATCH 07/62] fix --- templates/repo/issue/view_content.tmpl | 2 +- templates/repo/issue/view_content/comments.tmpl | 2 +- web_src/css/repo.css | 9 --------- web_src/js/features/repo-issue.js | 12 ++++++------ 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index a22d1a1e4113c..05746abc48b80 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -115,7 +115,7 @@ {{$buttonCommentTranslationKey = "repo.issues.comment_and_close_as.common"}} {{end}} {{end}} -
+
{{end}} -
From d42d25f045575e324bae9dfe58e69071d1eba7ab Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Wed, 21 Jun 2023 16:38:41 +0800 Subject: [PATCH 10/62] duplicate ui modal --- options/locale/locale_en-US.ini | 2 + services/forms/repo_form.go | 9 ++-- templates/repo/issue/view_content.tmpl | 22 ++++++++ web_src/js/features/repo-issue.js | 75 +++++++++++++++----------- 4 files changed, 73 insertions(+), 35 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 3c689a6ff2416..8e025a80d6077 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1478,6 +1478,8 @@ issues.comment_and_close_as.resolved = Comment and Close as resolved issues.comment_and_close_as.merged = Comment and Close as merged issues.comment_and_close_as.duplicate = Comment and Close as duplicate issues.comment_and_close_as.stale = Comment and Close as stale +issues.duplicate.header = Select the duplicate issue +issues.duplicate.label = Duplicate Issue issues.poster = Poster issues.collaborator = Collaborator issues.owner = Owner diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index c72430f6cdc29..a7f060100cec4 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -454,10 +454,11 @@ 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)"` - ClosedStatus issues_model.IssueClosedStatus - Files []string + Content string + Status string `binding:"OmitEmpty;In(reopen,close)"` + Files []string + ClosedStatus issues_model.IssueClosedStatus + DuplicateIssueID int64 } // Validate validates the fields diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index d8dd96b99e477..9ea7c2a2ee658 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -227,3 +227,25 @@ {{template "base/modal_actions_confirm" .}} +}} + + \ No newline at end of file diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 308e912e91686..649c805d128ad 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -87,6 +87,39 @@ export function initRepoIssueDue() { } export function initRepoIssueSidebarList() { + initIssueSearchDropdown('#new-dependency-drop-list'); + + function excludeLabel(item) { + const href = $(item).attr('href'); + const id = $(item).data('label-id'); + + const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`; + const newStr = 'labels=$1-$2$3&'; + + window.location = href.replace(new RegExp(regStr), newStr); + } + + $('.menu a.label-filter-item').each(function () { + $(this).on('click', function (e) { + if (e.altKey) { + e.preventDefault(); + excludeLabel(this); + } + }); + }); + + $('.menu .ui.dropdown.label-filter').on('keydown', (e) => { + if (e.altKey && e.keyCode === 13) { + const selectedItems = $('.menu .ui.dropdown.label-filter .menu .item.selected'); + if (selectedItems.length > 0) { + excludeLabel($(selectedItems[0])); + } + } + }); + $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems'); +} + +function initIssueSearchDropdown(selector) { const repolink = $('#repolink').val(); const repoId = $('#repoId').val(); const crossRepoSearch = $('#crossRepoSearch').val(); @@ -95,13 +128,13 @@ export function initRepoIssueSidebarList() { if (crossRepoSearch === 'true') { issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`; } - $('#new-dependency-drop-list') + $(selector) .dropdown({ apiSettings: { url: issueSearchUrl, onResponse(response) { const filteredResponse = {success: true, results: []}; - const currIssueId = $('#new-dependency-drop-list').data('issue-id'); + const currIssueId = $(selector).data('issue-id'); // Parse the response from the api to work with our dropdown $.each(response, (_i, issue) => { // Don't list current issue in the dependency list. @@ -121,35 +154,6 @@ export function initRepoIssueSidebarList() { fullTextSearch: true, }); - - function excludeLabel(item) { - const href = $(item).attr('href'); - const id = $(item).data('label-id'); - - const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`; - const newStr = 'labels=$1-$2$3&'; - - window.location = href.replace(new RegExp(regStr), newStr); - } - - $('.menu a.label-filter-item').each(function () { - $(this).on('click', function (e) { - if (e.altKey) { - e.preventDefault(); - excludeLabel(this); - } - }); - }); - - $('.menu .ui.dropdown.label-filter').on('keydown', (e) => { - if (e.altKey && e.keyCode === 13) { - const selectedItems = $('.menu .ui.dropdown.label-filter .menu .item.selected'); - if (selectedItems.length > 0) { - excludeLabel($(selectedItems[0])); - } - } - }); - $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems'); } export function initRepoIssueCommentDelete() { @@ -691,6 +695,15 @@ function initRepoIssueStateButton() { const $statusDropdown = $('#status-dropdown'); const selectedValue = $statusDropdown.find('input[type=hidden]').val(); + $statusButton.on('click', function (e) { + if ($statusDropdown.find('input[type=hidden]').val() !== "4") return; + // if click the button of "close as duplicate", show modal to let users select issue firstly. + e.preventDefault(); + initIssueSearchDropdown('#duplicate-issues-list'); + const $duplicateModal = $('#duplicate-issue-modal'); + $duplicateModal.modal('show'); + }); + const onCloseStatusChanged = (val) => { const editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); const buttonText = $statusDropdown.dropdown('get item').data(editor.value().trim() ? 'status-and-comment' : 'status'); From 42de79547f4e0057df61cf990983442de9b260e2 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Wed, 21 Jun 2023 16:42:56 +0800 Subject: [PATCH 11/62] lint fix --- templates/repo/issue/view_content.tmpl | 2 +- web_src/js/features/repo-issue.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 9ea7c2a2ee658..7b911556e7782 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -248,4 +248,4 @@ {{template "base/modal_actions_confirm" (dict "locale" $.locale "ModalButtonTypes" "confirm")}} - \ No newline at end of file + diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 649c805d128ad..65059a2930f17 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -695,8 +695,8 @@ function initRepoIssueStateButton() { const $statusDropdown = $('#status-dropdown'); const selectedValue = $statusDropdown.find('input[type=hidden]').val(); - $statusButton.on('click', function (e) { - if ($statusDropdown.find('input[type=hidden]').val() !== "4") return; + $statusButton.on('click', (e) => { + if ($statusDropdown.find('input[type=hidden]').val() !== '4') return; // if click the button of "close as duplicate", show modal to let users select issue firstly. e.preventDefault(); initIssueSearchDropdown('#duplicate-issues-list'); From cebbe9a5e7fea080a664b34af545311acd09c5ac Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Wed, 21 Jun 2023 17:06:24 +0800 Subject: [PATCH 12/62] temp --- models/issues/issue.go | 15 ++++++++------- templates/repo/issue/view_content.tmpl | 1 - 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/models/issues/issue.go b/models/issues/issue.go index f71148725162d..b2d23463cceb7 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -135,13 +135,14 @@ 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"` - ClosedStatus IssueClosedStatus - 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:"INDEX NOT NULL"` + DuplicateIssueID int64 `xorm:"INDEX NOT NULL"` + 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/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 7b911556e7782..efe4363d77d8e 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -227,7 +227,6 @@ {{template "base/modal_actions_confirm" .}} -}} {{else if eq .Type 28}}
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 65059a2930f17..e17ea75d6ef1e 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -701,7 +701,11 @@ function initRepoIssueStateButton() { e.preventDefault(); initIssueSearchDropdown('#duplicate-issues-list'); const $duplicateModal = $('#duplicate-issue-modal'); - $duplicateModal.modal('show'); + $duplicateModal.modal({ + onHidden() { + $('#duplicate-issues-list').dropdown('set exactly', []); + }, + }).modal('show'); }); const onCloseStatusChanged = (val) => { From 183cd8239d2d181d5dfe22a6016542790a00e43f Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Sun, 25 Jun 2023 18:00:40 +0800 Subject: [PATCH 15/62] lint&fmt --- models/issues/comment.go | 3 +-- models/issues/issue_update.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/models/issues/comment.go b/models/issues/comment.go index 340f7933f99f3..040d89430680d 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -1266,6 +1266,5 @@ func (c *Comment) LoadClosedIssueCommentContent(ctx context.Context) (err error) if c.DuplicateIssue, err = GetIssueByID(ctx, ctnt.DuplicateIssueID); err != nil { return } - err = c.DuplicateIssue.LoadRepo((ctx)) - return + return c.DuplicateIssue.LoadRepo((ctx)) } diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index b411cb7bd43ae..7f6567aa14006 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -5,7 +5,6 @@ package issues import ( "context" - "encoding/json" "fmt" "strings" @@ -19,6 +18,7 @@ import ( "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/references" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" From a031a6e8f0307726cb77a52c23939a8bca3a191d Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Mon, 26 Jun 2023 15:41:40 +0800 Subject: [PATCH 16/62] transfer subscriber of original issue to duplicate issue --- models/issues/issue_update.go | 32 ++++++++++++++++++++ modules/util/slice.go | 20 +++++++++++++ modules/util/string_test.go | 55 +++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 7f6567aa14006..c2ec15837d894 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -5,6 +5,7 @@ package issues import ( "context" + "errors" "fmt" "strings" @@ -22,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/references" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) @@ -114,6 +116,9 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use if issue.ClosedStatus == IssueClosedStatusDuplicate { c.DuplicateIssueID = issue.DuplicateIssueID // TODO: Transfer the issue watchers to the duplicate issue + if err := transferWatchersToDuplicateIssue(ctx, issue); err != nil { + return nil, err + } } data, err := json.Marshal(c) if err != nil { @@ -137,6 +142,33 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use }) } +// transferWatchersToDuplicateIssue transfer the watchers (including users who participated in the comments) of the original issue to the duplicate issue. +func transferWatchersToDuplicateIssue(ctx context.Context, issue *Issue) error { + if issue.DuplicateIssueID <= 0 { + return errors.New("the ID of duplicate issue cannot be zero") + } + // participants of the origianl issue + participatingUserIDs, err := issue.GetParticipantIDsByIssue(ctx) + if err != nil { + return err + } + // watchers of the original issue + iws, err := GetIssueWatchersIDs(ctx, issue.ID, true) + if err != nil { + return err + } + subscribers := util.SliceUnion(participatingUserIDs, iws) + for _, sid := range subscribers { + if err := CreateOrUpdateIssueWatch(sid, issue.DuplicateIssueID, true); err != nil { + return err + } + if err := CreateOrUpdateIssueWatch(sid, issue.ID, false); err != nil { + return err + } + } + return nil +} + // ChangeIssueStatus changes issue status to open or closed. func ChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) { if err := issue.LoadRepo(ctx); err != nil { diff --git a/modules/util/slice.go b/modules/util/slice.go index 74356f5496205..48b71a7047749 100644 --- a/modules/util/slice.go +++ b/modules/util/slice.go @@ -88,3 +88,23 @@ func SliceRemoveAllFunc[T comparable](slice []T, targetFunc func(T) bool) []T { } return slice[:idx] } + +// SliceRemoveAll returns the union of the slices +func SliceUnion[T comparable](inputs ...[]T) []T { + m := make(map[T]struct{}) + + for idx1 := range inputs { + for idx2 := range inputs[idx1] { + m[inputs[idx1][idx2]] = struct{}{} + } + } + + output := make([]T, len(m)) + i := 0 + for k := range m { + output[i] = k + i++ + } + + return output +} diff --git a/modules/util/string_test.go b/modules/util/string_test.go index 0a4a8bbcfbf9d..e36287ecc32e9 100644 --- a/modules/util/string_test.go +++ b/modules/util/string_test.go @@ -4,6 +4,8 @@ package util import ( + "fmt" + "sort" "testing" "github.com/stretchr/testify/assert" @@ -45,3 +47,56 @@ func TestToSnakeCase(t *testing.T) { assert.Equal(t, expected, ToSnakeCase(input)) } } + +type testSliceUnionInput[T string | int] [][]T +type testSliceUnionOutput[T string | int] []T +type testUnionItem[T string | int] struct { + input testSliceUnionInput[T] + expected testSliceUnionOutput[T] +} + +func TestSliceUnion(t *testing.T) { + intTests := []testUnionItem[int]{ + { + input: testSliceUnionInput[int]{ + []int{1, 2, 2, 3}, + []int{2, 4, 7}, + }, + expected: []int{1, 2, 3, 4, 7}, + }, + { + input: testSliceUnionInput[int]{ + []int{7, 8, 1}, + []int{1, 2, 3}, + []int{3, 4, 5}, + }, + expected: []int{1, 2, 3, 4, 5, 7, 8}, + }, + } + for i, test := range intTests { + t.Run(fmt.Sprintf("int test: %d", i), func(t *testing.T) { + actual := SliceUnion(test.input...) + // sort + sort.Ints(actual) + assert.EqualValues(t, test.expected, actual, actual) + }) + } + + stringTests := []testUnionItem[string]{ + { + input: testSliceUnionInput[string]{ + []string{"a", "c"}, + []string{"c", "d", "a", "b"}, + }, + expected: []string{"a", "b", "c", "d"}, + }, + } + for i, test := range stringTests { + t.Run(fmt.Sprintf("string test: %d", i), func(t *testing.T) { + actual := SliceUnion(test.input...) + // sort + sort.Strings(actual) + assert.EqualValues(t, test.expected, actual, actual) + }) + } +} From 033e330bd54dcfeeb50ac419463707b3056dca98 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Mon, 26 Jun 2023 17:08:08 +0800 Subject: [PATCH 17/62] fix --- models/issues/issue_update.go | 9 +++------ services/issue/status.go | 7 ++++++- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index c2ec15837d894..a384e62b4e4f7 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -115,10 +115,6 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } if issue.ClosedStatus == IssueClosedStatusDuplicate { c.DuplicateIssueID = issue.DuplicateIssueID - // TODO: Transfer the issue watchers to the duplicate issue - if err := transferWatchersToDuplicateIssue(ctx, issue); err != nil { - return nil, err - } } data, err := json.Marshal(c) if err != nil { @@ -142,8 +138,9 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use }) } -// transferWatchersToDuplicateIssue transfer the watchers (including users who participated in the comments) of the original issue to the duplicate issue. -func transferWatchersToDuplicateIssue(ctx context.Context, issue *Issue) error { +// TransferWatchersToDuplicateIssue transfer the watchers (including users who participated in the comments) of the original issue to the duplicate issue. +// It can only be called after notification +func TransferWatchersToDuplicateIssue(ctx context.Context, issue *Issue) error { if issue.DuplicateIssueID <= 0 { return errors.New("the ID of duplicate issue cannot be zero") } diff --git a/services/issue/status.go b/services/issue/status.go index b207fb180e9c9..3a150443a3dbc 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -37,8 +37,13 @@ func changeStatusCtx(ctx context.Context, issue *issues_model.Issue, doer *user_ } } - // TBD: whether to notify if only `is_closed` is changed(). notification.NotifyIssueChangeStatus(ctx, doer, commitID, issue, comment, issue.IsClosed) + if issue.ClosedStatus == issues_model.IssueClosedStatusDuplicate && issue.DuplicateIssueID > 0 { + if err := issues_model.TransferWatchersToDuplicateIssue(ctx, issue); err != nil { + return err + } + } + return nil } From a4257c94ddfac6e2c00f0fa6420a50e52df7779b Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Mon, 26 Jun 2023 17:41:38 +0800 Subject: [PATCH 18/62] default issue email template --- options/locale/locale_en-US.ini | 5 +++++ templates/mail/issue/default.tmpl | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 7ec9b7da1ac28..9d449fee1e5ac 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -450,6 +450,11 @@ 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_merged = @%[1]s closed as merged #%[2]d. +issue.action.close_as_duplicate = @%[1]s closed as duplicate #%[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. diff --git a/templates/mail/issue/default.tmpl b/templates/mail/issue/default.tmpl index 422a4f0461108..399e403313303 100644 --- a/templates/mail/issue/default.tmpl +++ b/templates/mail/issue/default.tmpl @@ -36,7 +36,19 @@ {{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_merged"}} + {{else if eq .Issue.ClosedStatus 4}} + {{$closeTrans = "mail.issue.action.close_as_duplicate"}} + {{else if eq .Issue.ClosedStatus 5}} + {{$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"}} From 83463c7446f59acd5da73eeee8fbaee9bcce680e Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Mon, 26 Jun 2023 18:15:42 +0800 Subject: [PATCH 19/62] fix issue filter in duplicate modal --- web_src/js/features/repo-issue.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index e17ea75d6ef1e..3b09720d933f1 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -119,14 +119,15 @@ export function initRepoIssueSidebarList() { $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems'); } -function initIssueSearchDropdown(selector) { +function initIssueSearchDropdown(selector, isDuplicateModal) { const repolink = $('#repolink').val(); const repoId = $('#repoId').val(); const crossRepoSearch = $('#crossRepoSearch').val(); - const tp = $('#type').val(); - let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`; + const tp = isDuplicateModal ? 'issues' : $('#type').val(); + const state = isDuplicateModal ? 'all' : ''; + let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&state=${state}`; if (crossRepoSearch === 'true') { - issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`; + issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}&state=${state}`; } $(selector) .dropdown({ @@ -699,7 +700,7 @@ function initRepoIssueStateButton() { if ($statusDropdown.find('input[type=hidden]').val() !== '4') return; // if click the button of "close as duplicate", show modal to let users select issue firstly. e.preventDefault(); - initIssueSearchDropdown('#duplicate-issues-list'); + initIssueSearchDropdown('#duplicate-issues-list', true); const $duplicateModal = $('#duplicate-issue-modal'); $duplicateModal.modal({ onHidden() { From dace5ced954e67f8c56e31b84149942e2cc191e2 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Tue, 27 Jun 2023 10:41:01 +0800 Subject: [PATCH 20/62] update --- templates/repo/issue/view_content.tmpl | 2 ++ web_src/js/features/repo-issue.js | 20 +++++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index efe4363d77d8e..5a8658680af08 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -4,7 +4,9 @@ + + {{$createdStr:= TimeSinceUnix .Issue.CreatedUnix $.locale}}

diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js index 3b09720d933f1..4d7841a8c6bb0 100644 --- a/web_src/js/features/repo-issue.js +++ b/web_src/js/features/repo-issue.js @@ -119,12 +119,12 @@ export function initRepoIssueSidebarList() { $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems'); } -function initIssueSearchDropdown(selector, isDuplicateModal) { +function initIssueSearchDropdown(selector) { const repolink = $('#repolink').val(); const repoId = $('#repoId').val(); const crossRepoSearch = $('#crossRepoSearch').val(); - const tp = isDuplicateModal ? 'issues' : $('#type').val(); - const state = isDuplicateModal ? 'all' : ''; + const tp = $('#type').val(); + const state = $('#state').val(); let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}&state=${state}`; if (crossRepoSearch === 'true') { issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}&state=${state}`; @@ -700,11 +700,21 @@ function initRepoIssueStateButton() { if ($statusDropdown.find('input[type=hidden]').val() !== '4') return; // if click the button of "close as duplicate", show modal to let users select issue firstly. e.preventDefault(); - initIssueSearchDropdown('#duplicate-issues-list', true); + // save the original value of "type" and "state" input + const originalType = $('#type').val(); + const originalSate = $('#state').val(); + // temporarily reset the input value + $('#type').val('issue'); + $('#state').val('all'); + initIssueSearchDropdown('#duplicate-issues-list'); const $duplicateModal = $('#duplicate-issue-modal'); $duplicateModal.modal({ - onHidden() { + onHidden() { // close modal + // clear selected item in the dropdown $('#duplicate-issues-list').dropdown('set exactly', []); + // restore the value of "type" and "state" input + $('#type').val(originalType); + $('#state').val(originalSate); }, }).modal('show'); }); From fdb29770fa97c6f1fce9914c9881dad038102fe3 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Tue, 27 Jun 2023 11:27:15 +0800 Subject: [PATCH 21/62] api --- modules/structs/issue.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/structs/issue.go b/modules/structs/issue.go index a9fb6c6e797b2..99ef91c775758 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -108,8 +108,10 @@ type EditIssueOption struct { Milestone *int64 `json:"milestone"` State *string `json:"state"` // swagger:strfmt date-time - Deadline *time.Time `json:"due_date"` - RemoveDeadline *bool `json:"unset_due_date"` + Deadline *time.Time `json:"due_date"` + RemoveDeadline *bool `json:"unset_due_date"` + ClosedStatus int8 `json:"closed_status"` + DuplicateIssueID int64 `json:"duplicate_issue_id"` } // EditDeadlineOption options for creating a deadline From 76730d3caf38a5fe598f22debe987dc5bcb9dc55 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Tue, 27 Jun 2023 11:28:11 +0800 Subject: [PATCH 22/62] api --- routers/api/v1/repo/issue.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index a3fb5387b8e0e..e745a33b86f38 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -818,6 +818,11 @@ func EditIssue(ctx *context.APIContext) { } } issue.IsClosed = api.StateClosed == api.StateType(*form.State) + issue.ClosedStatus = issues_model.IssueClosedStatus(0) + issue.DuplicateIssueID = form.DuplicateIssueID + if issue.IsClosed { + issue.ClosedStatus = issues_model.IssueClosedStatus(form.ClosedStatus) + } } statusChangeComment, titleChanged, err := issues_model.UpdateIssueByAPI(issue, ctx.Doer) if err != nil { From 369f99596da4b23e8dc4a73c3d46f157dcc7e292 Mon Sep 17 00:00:00 2001 From: sillyguodong Date: Tue, 27 Jun 2023 13:45:27 +0800 Subject: [PATCH 23/62] unuse form in duplicate modal --- templates/repo/issue/view_content.tmpl | 26 ++++++++++---------------- web_src/js/features/repo-issue.js | 4 ++++ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 5a8658680af08..de358bb450fcf 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -130,6 +130,7 @@ {{if not .Issue.IsPull}} + {{template "base/footer" .}} diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 2e8732b40b806..8f8e9fa0e81b8 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -26,7 +26,8 @@ {{.locale.Tr "repo.diff.browse_source"}} {{if and ($.Permission.CanWrite $.UnitTypeCode) (not $.Repository.IsArchived) (not .IsDeleted)}}{{- /* */ -}} -