Fix CODEOWNERS review request attribution using comment metadata (#36348)

Fixes #36333

## Problem

When CODEOWNERS automatically assigns reviewers to a pull request, the
timeline incorrectly shows the PR author as the one who requested the
review (e.g., "PR_AUTHOR requested review from CODE_OWNER"). This is
misleading since the action was triggered automatically by CODEOWNERS
rules, not by the PR author.

## Solution

Store CODEOWNERS attribution in comment metadata instead of changing the
doer user:
- Add `SpecialDoerName` field to `CommentMetaData` struct (value:
`"CODEOWNERS"` for CODEOWNERS-triggered requests)
- Pass `isCodeOwners=true` to `AddReviewRequest` and
`AddTeamReviewRequest` functions
- Template can check this metadata to show appropriate attribution
message

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Gregorius Bima Kharisma Wicaksana
2026-01-16 21:52:57 +07:00
committed by GitHub
parent 49edbbbc2e
commit 65422fde4d
14 changed files with 89 additions and 65 deletions
+44
View File
@@ -20,6 +20,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/references"
@@ -233,11 +234,17 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
return lang.TrString("repo.issues.role." + string(r) + "_helper")
}
type SpecialDoerNameType string
const SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS"
// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database
type CommentMetaData struct {
ProjectColumnID int64 `json:"project_column_id,omitempty"`
ProjectColumnTitle string `json:"project_column_title,omitempty"`
ProjectTitle string `json:"project_title,omitempty"`
SpecialDoerName SpecialDoerNameType `json:"special_doer_name,omitempty"` // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests
}
// Comment represents a comment in commit and issue page.
@@ -764,6 +771,37 @@ func (c *Comment) CodeCommentLink(ctx context.Context) string {
return fmt.Sprintf("%s/files#%s", c.Issue.Link(), c.HashTag())
}
func (c *Comment) MetaSpecialDoerTr(locale translation.Locale) template.HTML {
if c.CommentMetaData == nil {
return ""
}
if c.CommentMetaData.SpecialDoerName == SpecialDoerNameCodeOwners {
return locale.Tr("repo.issues.review.codeowners_rules")
}
return htmlutil.HTMLFormat("%s", c.CommentMetaData.SpecialDoerName)
}
func (c *Comment) TimelineRequestedReviewTr(locale translation.Locale, createdStr template.HTML) template.HTML {
if c.AssigneeID > 0 {
// it guarantees LoadAssigneeUserAndTeam has been called, and c.Assignee is Ghost user but not nil if the user doesn't exist
if c.RemovedAssignee {
if c.PosterID == c.AssigneeID {
return locale.Tr("repo.issues.review.remove_review_request_self", createdStr)
}
return locale.Tr("repo.issues.review.remove_review_request", c.Assignee.GetDisplayName(), createdStr)
}
return locale.Tr("repo.issues.review.add_review_request", c.Assignee.GetDisplayName(), createdStr)
}
teamName := "Ghost Team"
if c.AssigneeTeam != nil {
teamName = c.AssigneeTeam.Name
}
if c.RemovedAssignee {
return locale.Tr("repo.issues.review.remove_review_request", teamName, createdStr)
}
return locale.Tr("repo.issues.review.add_review_request", teamName, createdStr)
}
// CreateComment creates comment with context
func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
@@ -780,6 +818,11 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
ProjectTitle: opts.ProjectTitle,
}
}
if opts.SpecialDoerName != "" {
commentMetaData = &CommentMetaData{
SpecialDoerName: opts.SpecialDoerName,
}
}
comment := &Comment{
Type: opts.Type,
@@ -976,6 +1019,7 @@ type CreateCommentOptions struct {
RefIsPull bool
IsForcePush bool
Invalidated bool
SpecialDoerName SpecialDoerNameType // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests
}
// GetCommentByID returns the comment by given ID.
+1 -1
View File
@@ -130,7 +130,7 @@ func TestLoadRequestedReviewers(t *testing.T) {
user1, err := user_model.GetUserByID(t.Context(), 1)
assert.NoError(t, err)
comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{})
comment, err := issues_model.AddReviewRequest(t.Context(), issue, user1, &user_model.User{}, false)
assert.NoError(t, err)
assert.NotNil(t, comment)
+4 -2
View File
@@ -643,7 +643,7 @@ func InsertReviews(ctx context.Context, reviews []*Review) error {
}
// AddReviewRequest add a review request from one reviewer
func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User) (*Comment, error) {
func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_model.User, isCodeOwners bool) (*Comment, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
sess := db.GetEngine(ctx)
@@ -702,6 +702,7 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
RemovedAssignee: false, // Use RemovedAssignee as !isRequest
AssigneeID: reviewer.ID, // Use AssigneeID as reviewer ID
ReviewID: review.ID,
SpecialDoerName: util.Iif(isCodeOwners, SpecialDoerNameCodeOwners, ""),
})
if err != nil {
return nil, err
@@ -767,7 +768,7 @@ func restoreLatestOfficialReview(ctx context.Context, issueID, reviewerID int64)
}
// AddTeamReviewRequest add a review request from one team
func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User) (*Comment, error) {
func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organization.Team, doer *user_model.User, isCodeOwners bool) (*Comment, error) {
return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
review, err := GetTeamReviewerByIssueIDAndTeamID(ctx, issue.ID, reviewer.ID)
if err != nil && !IsErrReviewNotExist(err) {
@@ -812,6 +813,7 @@ func AddTeamReviewRequest(ctx context.Context, issue *Issue, reviewer *organizat
RemovedAssignee: false, // Use RemovedAssignee as !isRequest
AssigneeTeamID: reviewer.ID, // Use AssigneeTeamID as reviewer team ID
ReviewID: review.ID,
SpecialDoerName: util.Iif(isCodeOwners, SpecialDoerNameCodeOwners, ""),
})
if err != nil {
return nil, fmt.Errorf("CreateComment(): %w", err)
+16 -2
View File
@@ -321,14 +321,28 @@ func TestAddReviewRequest(t *testing.T) {
pull.HasMerged = false
assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
issue.IsClosed = true
_, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{})
_, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err))
pull.HasMerged = true
assert.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
issue.IsClosed = false
_, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{})
_, err = issues_model.AddReviewRequest(t.Context(), issue, reviewer, &user_model.User{}, false)
assert.Error(t, err)
assert.True(t, issues_model.IsErrReviewRequestOnClosedPR(err))
// Test CODEOWNERS review request stores metadata correctly
pull2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
assert.NoError(t, pull2.LoadIssue(t.Context()))
issue2 := pull2.Issue
assert.NoError(t, issue2.LoadRepo(t.Context()))
reviewer2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 7})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
comment, err := issues_model.AddReviewRequest(t.Context(), issue2, reviewer2, doer, true)
assert.NoError(t, err)
assert.NotNil(t, comment)
assert.NotNil(t, comment.CommentMetaData)
assert.Equal(t, issues_model.SpecialDoerNameCodeOwners, comment.CommentMetaData.SpecialDoerName)
}