diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index b3eb7b1f4a4fd..4caf8dd45414b 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1638,6 +1638,11 @@ issues.reopen_issue = Reopen issues.reopen_comment_issue = Reopen with Comment issues.create_comment = Comment issues.comment.blocked_user = Cannot create or edit comment because you are blocked by the poster or repository owner. +issues.not_closed = The issue is not closed. +issues.reopen_not_allowed = No permission to reopen this issue. +issues.reopen_not_allowed_merged = A pull request cannot be reopened after it has been merged. +issues.comment.empty_content = The comment content cannot be empty. +issues.already_closed = The issue is already closed. issues.closed_at = `closed this issue %[2]s` issues.reopened_at = `reopened this issue %[2]s` issues.commit_ref_at = `referenced this issue from a commit %[2]s` @@ -1958,7 +1963,8 @@ pulls.has_merged = Failed: The pull request has been merged. You cannot merge ag pulls.push_rejected = Push Failed: The push was rejected. Review the Git Hooks for this repository. pulls.push_rejected_summary = Full Rejection Message pulls.push_rejected_no_message = Push Failed: The push was rejected but there was no remote message. Review the Git Hooks for this repository. -pulls.open_unmerged_pull_exists = `You cannot perform a reopen operation because there is a pending pull request (#%d) with identical properties.` +pulls.open_unmerged_pull_exists = You cannot perform a reopen operation because there is a pending pull request (#%d) with identical properties. +pulls.head_branch_not_exist = The head branch does not exist, cannot reopen the pull request. pulls.status_checking = Some checks are pending pulls.status_checks_success = All checks were successful pulls.status_checks_warning = Some checks reported warnings diff --git a/routers/web/repo/issue_comment.go b/routers/web/repo/issue_comment.go index cb5b2d801952d..d0ea9fc061947 100644 --- a/routers/web/repo/issue_comment.go +++ b/routers/web/repo/issue_comment.go @@ -10,11 +10,11 @@ import ( "net/http" "strconv" + git_model "code.gitea.io/gitea/models/git" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/renderhelper" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/markdown" repo_module "code.gitea.io/gitea/modules/repository" @@ -74,132 +74,140 @@ func NewComment(ctx *context.Context) { return } - 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 + var createdComment *issues_model.Comment + var err error - if form.Status == "reopen" && issue.IsPull { - pull := issue.PullRequest - var err error - pr, err = issues_model.GetUnmergedPullRequest(ctx, pull.HeadRepoID, pull.BaseRepoID, pull.HeadBranch, pull.BaseBranch, pull.Flow) - if err != nil { - if !issues_model.IsErrPullRequestNotExist(err) { - ctx.JSONError(ctx.Tr("repo.issues.dependency.pr_close_blocked")) - return - } - } - - // Regenerate patch and test conflict. - if pr == nil { - issue.PullRequest.HeadCommitID = "" - pull_service.StartPullRequestCheckImmediately(ctx, issue.PullRequest) - } + switch form.Status { + case "reopen": + if !issue.IsClosed { + ctx.JSONError(ctx.Tr("repo.issues.not_closed")) + 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 - if pull.Flow == issues_model.PullRequestFlowGithub { - prHeadRef := pull.GetGitHeadRefName() - 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 !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && + !issue.IsPoster(ctx.Doer.ID) && + !ctx.Doer.IsAdmin { + ctx.JSONError(ctx.Tr("repo.issues.reopen_not_allowed")) + 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 := gitrepo.IsBranchExist(ctx, pull.HeadRepo, 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 - } + if issue.IsPull { + pull := issue.PullRequest + if pull.HasMerged { + ctx.JSONError(ctx.Tr("repo.issues.reopen_not_allowed_merged")) + return + } - err = pull.LoadIssue(ctx) - if err != nil { - ctx.ServerError("load the issue of pull request error", 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 + } + branchExist, err := git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch) + if err != nil { + ctx.ServerError("IsBranchExist", err) + return + } + if !branchExist { + ctx.JSONError(ctx.Tr("repo.pulls.head_branch_not_exist")) + 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 - } - } + // check if an opened pull request exists with the same head branch and base branch + 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(err.Error()) + return } } - if pr != nil { ctx.Flash.Info(ctx.Tr("repo.pulls.open_unmerged_pull_exists", pr.Index)) + return + } + } + + createdComment, err = issue_service.ReopenIssueWithComment(ctx, issue, ctx.Doer, "", form.Content, attachments) + if err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) } else { - if form.Status == "close" && !issue.IsClosed { - if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ""); err != nil { - log.Error("CloseIssue: %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, ctx.Doer, issue); err != nil { - ctx.ServerError("stopTimerIfAvailable", err) - return - } - log.Trace("Issue [%d] status changed to closed: %v", issue.ID, issue.IsClosed) - } - } else if form.Status == "reopen" && issue.IsClosed { - if err := issue_service.ReopenIssue(ctx, issue, ctx.Doer, ""); err != nil { - log.Error("ReopenIssue: %v", err) + ctx.ServerError("ReopenIssue", err) + } + return + } + + if issue.IsPull { + pull := issue.PullRequest + // check whether the ref of PR in base repo is consistent with the head commit of head branch in the head repo + // get head commit of PR + if pull.Flow == issues_model.PullRequestFlowGithub { + prHeadRef := pull.GetGitHeadRefName() + 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 + } + + 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 + } + + if err = pull.LoadIssue(ctx); err != nil { + ctx.ServerError("load the issue of pull request error", err) + return + } + + // if the head commit ID of the PR is different from the head branch + 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. + pull.HeadCommitID = "" + pull_service.StartPullRequestCheckImmediately(ctx, pull) + } + case "close": + if issue.IsClosed { + ctx.JSONError(ctx.Tr("repo.issues.already_closed")) + return } - // Redirect to comment hashtag if there is any actual content. - typeName := "issues" - if issue.IsPull { - typeName = "pulls" + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && + !issue.IsPoster(ctx.Doer.ID) && + !ctx.Doer.IsAdmin { + ctx.JSONError(ctx.Tr("repo.issues.close_not_allowed")) + return } - 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)) + + createdComment, err = issue_service.CloseIssueWithComment(ctx, issue, ctx.Doer, "", form.Content, attachments) + default: + if len(form.Content) == 0 && len(attachments) == 0 { + ctx.JSONError(ctx.Tr("repo.issues.comment.empty_content")) + return } - }() - // Fix #321: Allow empty comments, as long as we have attachments. - if len(form.Content) == 0 && len(attachments) == 0 { - return + createdComment, err = issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) } - comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Content, attachments) if err != nil { if errors.Is(err, user_model.ErrBlockedUser) { ctx.JSONError(ctx.Tr("repo.issues.comment.blocked_user")) @@ -209,7 +217,17 @@ func NewComment(ctx *context.Context) { return } - log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, comment.ID) + // Redirect to comment hashtag if there is any actual content. + typeName := "issues" + if issue.IsPull { + typeName = "pulls" + } + if createdComment != nil { + log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, createdComment.ID) + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, issue.Index, createdComment.HashTag())) + } else { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, issue.Index)) + } } // UpdateCommentContent change comment of issue's content diff --git a/services/issue/comments.go b/services/issue/comments.go index 9442701029b57..bbeb75f27244e 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -15,6 +15,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/timeutil" git_service "code.gitea.io/gitea/services/git" notify_service "code.gitea.io/gitea/services/notify" @@ -55,6 +56,22 @@ func CreateRefComment(ctx context.Context, doer *user_model.User, repo *repo_mod return err } +func notifyCommentCreated(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, comment *issues_model.Comment) error { + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content) + if err != nil { + return err + } + + // reload issue to ensure it has the latest data, especially the number of comments + issue, err = issues_model.GetIssueByID(ctx, issue.ID) + if err != nil { + return err + } + + notify_service.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) + return nil +} + // CreateIssueComment creates a plain issue comment. func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content string, attachments []string) (*issues_model.Comment, error) { if user_model.IsUserBlockedBy(ctx, doer, issue.PosterID, repo.OwnerID) { @@ -75,19 +92,11 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m return nil, err } - mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content) - if err != nil { - return nil, err + if err := notifyCommentCreated(ctx, doer, repo, issue, comment); err != nil { + // If notification fails, we still return the comment but log the error. + log.Error("Failed to notify comment creation: %v", err) } - // reload issue to ensure it has the latest data, especially the number of comments - issue, err = issues_model.GetIssueByID(ctx, issue.ID) - if err != nil { - return nil, err - } - - notify_service.CreateIssueComment(ctx, doer, repo, issue, comment, mentions) - return comment, nil } diff --git a/services/issue/status.go b/services/issue/status.go index fa59df93ba107..06d9ad5f6426b 100644 --- a/services/issue/status.go +++ b/services/issue/status.go @@ -13,7 +13,7 @@ import ( notify_service "code.gitea.io/gitea/services/notify" ) -// CloseIssue close an issue. +// CloseIssue closes an issue func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error { var comment *issues_model.Comment if err := db.WithTx(ctx, func(ctx context.Context) error { @@ -39,7 +39,53 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model return nil } -// ReopenIssue reopen an issue. +// CloseIssueWithComment close an issue with comment +func CloseIssueWithComment(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID, commentContent string, attachments []string) (*issues_model.Comment, error) { + var refComment, createdComment *issues_model.Comment + if err := db.WithTx(ctx, func(ctx context.Context) error { + var err error + if commentContent != "" || len(attachments) > 0 { + createdComment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeComment, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Content: commentContent, + Attachments: attachments, + }) + if err != nil { + return err + } + } + + refComment, err = issues_model.CloseIssue(ctx, issue, doer) + if err != nil { + if issues_model.IsErrDependenciesLeft(err) { + if _, err := issues_model.FinishIssueStopwatch(ctx, doer, issue); err != nil { + log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err) + } + } + return err + } + + _, err = issues_model.FinishIssueStopwatch(ctx, doer, issue) + return err + }); err != nil { + return nil, err + } + + if createdComment != nil { + if err := notifyCommentCreated(ctx, doer, issue.Repo, issue, createdComment); err != nil { + log.Error("Unable to notify comment created for issue[%d]#%d: %v", issue.ID, issue.Index, err) + } + } + + notify_service.IssueChangeStatus(ctx, doer, commitID, issue, refComment, true) + + return createdComment, nil +} + +// ReopenIssue reopen an issue // FIXME: If some issues dependent this one are closed, should we also reopen them? func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID string) error { comment, err := issues_model.ReopenIssue(ctx, issue, doer) @@ -51,3 +97,40 @@ func ReopenIssue(ctx context.Context, issue *issues_model.Issue, doer *user_mode return nil } + +// ReopenIssue reopen an issue with a comment. +// FIXME: If some issues dependent this one are closed, should we also reopen them? +func ReopenIssueWithComment(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, commitID, commentContent string, attachments []string) (*issues_model.Comment, error) { + var createdComment *issues_model.Comment + refComment, err := db.WithTx2(ctx, func(ctx context.Context) (*issues_model.Comment, error) { + var err error + if commentContent != "" || len(attachments) > 0 { + createdComment, err = issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{ + Type: issues_model.CommentTypeComment, + Doer: doer, + Repo: issue.Repo, + Issue: issue, + Content: commentContent, + Attachments: attachments, + }) + if err != nil { + return nil, err + } + } + + return issues_model.ReopenIssue(ctx, issue, doer) + }) + if err != nil { + return nil, err + } + + if createdComment != nil { + if err := notifyCommentCreated(ctx, doer, issue.Repo, issue, createdComment); err != nil { + log.Error("Unable to notify comment created for issue[%d]#%d: %v", issue.ID, issue.Index, err) + } + } + + notify_service.IssueChangeStatus(ctx, doer, commitID, issue, refComment, false) + + return createdComment, nil +}