diff --git a/models/issues/comment.go b/models/issues/comment.go index 0f7adbd87a..f15618bf50 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -116,9 +116,6 @@ const ( CommentTypeUnpin // 37 unpin Issue/PullRequest CommentTypeChangeTimeEstimate // 38 Change time estimate - - CommentTypeMarkedAsWorkInProgress // 39 Mark PR as work in progress - CommentTypeMarkedAsReadyForReview // 40 Mark PR as ready for review ) var commentStrings = []string{ @@ -161,8 +158,6 @@ var commentStrings = []string{ "pin", "unpin", "change_time_estimate", - "marked_as_work_in_progress", - "marked_as_ready_for_review", } func (t CommentType) String() string { diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 19f3d9fd8b..d4ec226b34 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -208,24 +208,8 @@ func ChangeIssueTitle(ctx context.Context, issue *Issue, doer *user_model.User, if err = issue.LoadRepo(ctx); err != nil { return fmt.Errorf("loadRepo: %w", err) } - - // Determine the comment type based on WIP prefix changes for pull requests - commentType := CommentTypeChangeTitle - if issue.IsPull { - hadWIP := HasWorkInProgressPrefix(oldTitle) - hasWIP := HasWorkInProgressPrefix(issue.Title) - - if !hadWIP && hasWIP { - // WIP prefix was added - commentType = CommentTypeMarkedAsWorkInProgress - } else if hadWIP && !hasWIP { - // WIP prefix was removed - commentType = CommentTypeMarkedAsReadyForReview - } - } - opts := &CreateCommentOptions{ - Type: commentType, + Type: CommentTypeChangeTitle, Doer: doer, Repo: issue.Repo, Issue: issue, diff --git a/models/issues/pull.go b/models/issues/pull.go index 18977ed212..9f180f9ac9 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -658,12 +658,18 @@ func (pr *PullRequest) IsWorkInProgress(ctx context.Context) bool { // HasWorkInProgressPrefix determines if the given PR title has a Work In Progress prefix func HasWorkInProgressPrefix(title string) bool { + _, ok := CutWorkInProgressPrefix(title) + return ok +} + +func CutWorkInProgressPrefix(title string) (origTitle string, ok bool) { for _, prefix := range setting.Repository.PullRequest.WorkInProgressPrefixes { - if strings.HasPrefix(strings.ToUpper(title), strings.ToUpper(prefix)) { - return true + prefixLen := len(prefix) + if prefixLen <= len(title) && util.AsciiEqualFold(title[:prefixLen], prefix) { + return title[len(prefix):], true } } - return false + return title, false } // IsFilesConflicted determines if the Pull Request has changes conflicting with the target branch. diff --git a/modules/templates/util_render_comment.go b/modules/templates/util_render_comment.go new file mode 100644 index 0000000000..7ba371b517 --- /dev/null +++ b/modules/templates/util_render_comment.go @@ -0,0 +1,48 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package templates + +import ( + "html/template" + "strings" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/htmlutil" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/svg" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" +) + +func commentTimelineEventIsWipToggle(c *issues_model.Comment) (isToggle, isWip bool) { + title1, ok1 := issues_model.CutWorkInProgressPrefix(c.OldTitle) + title2, ok2 := issues_model.CutWorkInProgressPrefix(c.NewTitle) + return ok1 != ok2 && strings.TrimSpace(title1) == strings.TrimSpace(title2), ok2 +} + +func (ut *RenderUtils) RenderTimelineEventBadge(c *issues_model.Comment) template.HTML { + if c.Type == issues_model.CommentTypeChangeTitle { + isToggle, isWip := commentTimelineEventIsWipToggle(c) + if !isToggle { + return svg.RenderHTML("octicon-pencil") + } + return util.Iif(isWip, svg.RenderHTML("octicon-git-pull-request-draft"), svg.RenderHTML("octicon-eye")) + } + setting.PanicInDevOrTesting("unimplemented comment type %v: %v", c.Type, c) + return htmlutil.HTMLFormat("(CommentType:%v)", c.Type) +} + +func (ut *RenderUtils) RenderTimelineEventComment(c *issues_model.Comment, createdStr template.HTML) template.HTML { + if c.Type == issues_model.CommentTypeChangeTitle { + locale := ut.ctx.Value(translation.ContextKey).(translation.Locale) + isToggle, isWip := commentTimelineEventIsWipToggle(c) + if !isToggle { + return locale.Tr("repo.issues.change_title_at", ut.RenderEmoji(c.OldTitle), ut.RenderEmoji(c.NewTitle), createdStr) + } + trKey := util.Iif(isWip, "repo.pulls.marked_as_work_in_progress", "repo.pulls.marked_as_ready_for_review") + return locale.Tr(trKey, createdStr) + } + setting.PanicInDevOrTesting("unimplemented comment type %v: %v", c.Type, c) + return htmlutil.HTMLFormat("(Comment:%v,%v)", c.Type, c.Content) +} diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index cff501ad71..670aed6702 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -344,6 +344,35 @@ func (d *pullCommitStatusCheckData) CommitStatusCheckPrompt(locale translation.L return locale.TrString("repo.pulls.status_checking") } +func getViewPullHeadRepoInfo(ctx *context.Context, pull *issues_model.PullRequest, baseGitRepo *git.Repository) (headCommitID string, headCommitExists bool, err error) { + if pull.HeadRepo == nil { + return "", false, nil + } + headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pull.HeadRepo) + if err != nil { + return "", false, util.Iif(errors.Is(err, util.ErrNotExist), nil, err) + } + defer closer.Close() + + if pull.Flow == issues_model.PullRequestFlowGithub { + headCommitExists, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch) + } else { + headCommitExists = gitrepo.IsReferenceExist(ctx, pull.BaseRepo, pull.GetGitHeadRefName()) + } + + if headCommitExists { + if pull.Flow != issues_model.PullRequestFlowGithub { + headCommitID, err = baseGitRepo.GetRefCommitID(pull.GetGitHeadRefName()) + } else { + headCommitID, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) + } + if err != nil { + return "", false, util.Iif(errors.Is(err, util.ErrNotExist), nil, err) + } + } + return headCommitID, headCommitExists, nil +} + // prepareViewPullInfo show meta information for a pull request preview page func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git_service.CompareInfo { ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes @@ -430,34 +459,10 @@ func prepareViewPullInfo(ctx *context.Context, issue *issues_model.Issue) *git_s return compareInfo } - var headBranchExist bool - var headBranchSha string - // HeadRepo may be missing - if pull.HeadRepo != nil { - headGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pull.HeadRepo) - if err != nil { - ctx.ServerError("RepositoryFromContextOrOpen", err) - return nil - } - defer closer.Close() - - if pull.Flow == issues_model.PullRequestFlowGithub { - headBranchExist, _ = git_model.IsBranchExist(ctx, pull.HeadRepo.ID, pull.HeadBranch) - } else { - headBranchExist = gitrepo.IsReferenceExist(ctx, pull.BaseRepo, pull.GetGitHeadRefName()) - } - - if headBranchExist { - if pull.Flow != issues_model.PullRequestFlowGithub { - headBranchSha, err = baseGitRepo.GetRefCommitID(pull.GetGitHeadRefName()) - } else { - headBranchSha, err = headGitRepo.GetBranchCommitID(pull.HeadBranch) - } - if err != nil { - ctx.ServerError("GetBranchCommitID", err) - return nil - } - } + headBranchSha, headBranchExist, err := getViewPullHeadRepoInfo(ctx, pull, baseGitRepo) + if err != nil { + ctx.ServerError("getViewPullHeadRepoInfo", err) + return nil } if headBranchExist { diff --git a/services/issue/title_test.go b/services/issue/title_test.go deleted file mode 100644 index bf6da5de0b..0000000000 --- a/services/issue/title_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2026 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package issue - -import ( - "testing" - - issues_model "code.gitea.io/gitea/models/issues" - "code.gitea.io/gitea/models/unittest" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/setting" - - "github.com/stretchr/testify/assert" -) - -func TestChangeTitleWIPPrefix(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - // Load a pull request - pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) - assert.NoError(t, pr.LoadIssue(t.Context())) - issue := pr.Issue - - // Load repo and doer - assert.NoError(t, issue.LoadRepo(t.Context())) - doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) - - // Get the WIP prefix from settings - wipPrefix := setting.Repository.PullRequest.WorkInProgressPrefixes[0] - - // Store original title - originalTitle := issue.Title - wipTitle := wipPrefix + " " + originalTitle - - // Test 1: Add WIP prefix - err := ChangeTitle(t.Context(), issue, doer, wipTitle) - assert.NoError(t, err) - - // Check that a comment was created with the correct type - comments, err := issues_model.FindComments(t.Context(), &issues_model.FindCommentsOptions{ - IssueID: issue.ID, - Type: issues_model.CommentTypeMarkedAsWorkInProgress, - }) - assert.NoError(t, err) - assert.Len(t, comments, 1, "Should have created a CommentTypeMarkedAsWorkInProgress comment") - - // Test 2: Remove WIP prefix - err = ChangeTitle(t.Context(), issue, doer, originalTitle) - assert.NoError(t, err) - - // Check that a comment was created with the correct type - comments, err = issues_model.FindComments(t.Context(), &issues_model.FindCommentsOptions{ - IssueID: issue.ID, - Type: issues_model.CommentTypeMarkedAsReadyForReview, - }) - assert.NoError(t, err) - assert.Len(t, comments, 1, "Should have created a CommentTypeMarkedAsReadyForReview comment") -} - -func TestChangeTitleNormalChange(t *testing.T) { - assert.NoError(t, unittest.PrepareTestDatabase()) - - // Load a pull request - pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 1}) - assert.NoError(t, pr.LoadIssue(t.Context())) - issue := pr.Issue - - // Load repo and doer - assert.NoError(t, issue.LoadRepo(t.Context())) - doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID}) - - // Store original title - originalTitle := issue.Title - newTitle := "New title without WIP" - - // Ensure neither title has WIP prefix - assert.False(t, issues_model.HasWorkInProgressPrefix(originalTitle)) - assert.False(t, issues_model.HasWorkInProgressPrefix(newTitle)) - - // Change title - err := ChangeTitle(t.Context(), issue, doer, newTitle) - assert.NoError(t, err) - - // Check that a normal change title comment was created - comments, err := issues_model.FindComments(t.Context(), &issues_model.FindCommentsOptions{ - IssueID: issue.ID, - Type: issues_model.CommentTypeChangeTitle, - }) - assert.NoError(t, err) - assert.NotEmpty(t, comments, "Should have created a CommentTypeChangeTitle comment") - - // Verify the last comment has the correct old and new titles - lastComment := comments[len(comments)-1] - assert.Equal(t, originalTitle, lastComment.OldTitle) - assert.Equal(t, newTitle, lastComment.NewTitle) -} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 405c7f7548..e7b4c8758d 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -1,5 +1,5 @@ {{template "base/alert"}} -{{range .Issue.Comments}} +{{range $comment := .Issue.Comments}} {{if call $.ShouldShowCommentType .Type}} {{$createdStr:= DateUtils.TimeSince .CreatedUnix}} @@ -13,8 +13,7 @@ 29 = PULL_PUSH_EVENT, 30 = PROJECT_CHANGED, 31 = PROJECT_BOARD_CHANGED 32 = DISMISSED_REVIEW, 33 = COMMENT_TYPE_CHANGE_ISSUE_REF, 34 = PR_SCHEDULE_TO_AUTO_MERGE, 35 = CANCEL_SCHEDULED_AUTO_MERGE_PR, 36 = PIN_ISSUE, 37 = UNPIN_ISSUE, - 38 = COMMENT_TYPE_CHANGE_TIME_ESTIMATE, 39 = MARKED_AS_WORK_IN_PROGRESS, - 40 = MARKED_AS_READY_FOR_REVIEW --> + 38 = COMMENT_TYPE_CHANGE_TIME_ESTIMATE --> {{if eq .Type 0}}
{{if .OriginalAuthor}} @@ -221,11 +220,11 @@
{{else if eq .Type 10}}
- {{svg "octicon-pencil"}} + {{ctx.RenderUtils.RenderTimelineEventBadge $comment}} {{template "shared/user/avatarlink" dict "user" .Poster}} {{template "shared/user/authorlink" .Poster}} - {{ctx.Locale.Tr "repo.issues.change_title_at" (.OldTitle|ctx.RenderUtils.RenderEmoji) (.NewTitle|ctx.RenderUtils.RenderEmoji) $createdStr}} + {{ctx.RenderUtils.RenderTimelineEventComment $comment $createdStr}}
{{else if eq .Type 11}} @@ -693,24 +692,6 @@ {{end}} - {{else if eq .Type 39}} -
- {{svg "octicon-git-pull-request-draft"}} - {{template "shared/user/avatarlink" dict "user" .Poster}} - - {{template "shared/user/authorlink" .Poster}} - {{ctx.Locale.Tr "repo.pulls.marked_as_work_in_progress" $createdStr}} - -
- {{else if eq .Type 40}} -
- {{svg "octicon-eye"}} - {{template "shared/user/avatarlink" dict "user" .Poster}} - - {{template "shared/user/authorlink" .Poster}} - {{ctx.Locale.Tr "repo.pulls.marked_as_ready_for_review" $createdStr}} - -
{{end}} {{end}} {{end}}