feat(api,ui): add multiline comment on pullrequest (#12582)

Closes https://codeberg.org/forgejo/forgejo/issues/6093

This PR adds support for **multi-line review comments** on pull requests, allowing reviewers to select a range of lines in diffs instead of only a single line — similar to GitHub's implementation.

### Tests for Go changes

- I added test coverage for Go changes...
  - [X] in their respective `*_test.go` for unit tests.
  - [X] `make pr-go` before pushing

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12582
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
This commit is contained in:
steven.guiheux
2026-06-03 16:06:29 +02:00
committed by Mathieu Fenniak
parent f576a1a21e
commit 6a27eb051d
26 changed files with 1185 additions and 103 deletions
+14 -13
View File
@@ -81,19 +81,20 @@ func ToPullReviewList(ctx context.Context, rl []*issues_model.Review, doer *user
// ToPullReviewCommentList convert the CodeComments of an review to it's api format
func ToPullReviewComment(ctx context.Context, review *issues_model.Review, comment *issues_model.Comment, doer *user_model.User) (*api.PullReviewComment, error) {
apiComment := &api.PullReviewComment{
ID: comment.ID,
Body: comment.Content,
Poster: ToUser(ctx, comment.Poster, doer),
Resolver: ToUser(ctx, comment.ResolveDoer, doer),
ReviewID: review.ID,
Created: comment.CreatedUnix.AsTime(),
Updated: comment.UpdatedUnix.AsTime(),
Path: comment.TreePath,
CommitID: comment.CommitSHA,
OrigCommitID: comment.OldRef,
DiffHunk: patch2diff(comment.Patch),
HTMLURL: comment.HTMLURL(ctx),
HTMLPullURL: review.Issue.HTMLURL(),
ID: comment.ID,
Body: comment.Content,
Poster: ToUser(ctx, comment.Poster, doer),
Resolver: ToUser(ctx, comment.ResolveDoer, doer),
ReviewID: review.ID,
Created: comment.CreatedUnix.AsTime(),
Updated: comment.UpdatedUnix.AsTime(),
Path: comment.TreePath,
CommitID: comment.CommitSHA,
OrigCommitID: comment.OldRef,
DiffHunk: patch2diff(comment.Patch),
ExtraLinesCount: comment.ExtraLinesCount,
HTMLURL: comment.HTMLURL(ctx),
HTMLPullURL: review.Issue.HTMLURL(),
}
if comment.Line < 0 {
+11 -10
View File
@@ -465,16 +465,17 @@ func (f *MergePullRequestForm) Validate(req *http.Request, errs binding.Errors)
// CodeCommentForm form for adding code comments for PRs
type CodeCommentForm struct {
Origin string `binding:"Required;In(timeline,diff)"`
Content string `binding:"Required"`
Side string `binding:"Required;In(previous,proposed)"`
Line int64
TreePath string `form:"path" binding:"Required"`
SingleReview bool `form:"single_review"`
Reply int64 `form:"reply"`
BeforeCommitID string
LatestCommitID string
Files []string
Origin string `binding:"Required;In(timeline,diff)"`
Content string `binding:"Required"`
Side string `binding:"Required;In(previous,proposed)"`
Line int64
ExtraLinesCount int64 `form:"extra_lines_count"`
TreePath string `form:"path" binding:"Required"`
SingleReview bool `form:"single_review"`
Reply int64 `form:"reply"`
BeforeCommitID string
LatestCommitID string
Files []string
}
// Validate validates the fields
@@ -148,6 +148,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u
nil,
issue,
comment.Line,
0, // extraLinesCount: a reply to a comment inherit single line
content.Content,
comment.TreePath,
false, // not pending review but a single review
+83 -23
View File
@@ -46,13 +46,38 @@ func checkInvalidation(ctx context.Context, c *issues_model.Comment, repo *repo_
reverseBlame, err := c.ResolveCurrentLine(ctx, repo, newCommitID)
if err != nil {
log.Warn("ResolveCurrentLine failed: %s", err.Error())
} else if reverseBlame.CommitID != newCommitID {
return nil
}
if reverseBlame.CommitID != newCommitID {
c.Invalidated = true
return issues_model.UpdateCommentInvalidate(ctx, c)
}
// For multi-line comments, check additional lines in the range
if c.ExtraLinesCount > 0 {
invalidated, err := checkMultiLineInvalidation(ctx, c, repo, newCommitID)
if err != nil {
log.Warn("checkMultiLineInvalidation failed: %s", err.Error())
} else if invalidated {
c.Invalidated = true
return issues_model.UpdateCommentInvalidate(ctx, c)
}
}
return nil
}
// checkMultiLineInvalidation checks if any additional line in a multi-line comment range
// has been changed. Returns true if the comment should be invalidated.
// Uses cached results via Comment.CheckLineRangeValid.
func checkMultiLineInvalidation(ctx context.Context, c *issues_model.Comment, repo *repo_model.Repository, newCommitID string) (bool, error) {
valid, err := c.CheckLineRangeValid(ctx, repo, newCommitID)
if err != nil {
return false, err
}
return !valid, nil
}
// InvalidateCodeComments will lookup the prs for code comments which got invalidated by change
func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestList, doer *user_model.User, repo *repo_model.Repository, newCommitID string) error {
if len(prs) == 0 {
@@ -77,9 +102,22 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis
return nil
}
// ValidateCodeCommentLineRange validates the extra_lines_count of a (multi-line) code comment: it must
// not be negative and the resulting range must not span more than setting.UI.MaxCodeCommentLines lines
// (0 = no limit). It is the single source of truth shared by the web and API creation handlers.
func ValidateCodeCommentLineRange(extraLinesCount int64) error {
if extraLinesCount < 0 {
return fmt.Errorf("extra_lines_count must be >= 0")
}
if setting.UI.MaxCodeCommentLines > 0 && extraLinesCount+1 > int64(setting.UI.MaxCodeCommentLines) {
return fmt.Errorf("a code comment may span at most %d lines", setting.UI.MaxCodeCommentLines)
}
return nil
}
// CreateCodeComment creates a comment on the code line
func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository,
issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool,
issue *issues_model.Issue, line, extraLinesCount int64, content, treePath string, pendingReview bool,
replyReviewID int64, beforeCommitID, latestCommitID string, attachments []string,
) (*issues_model.Comment, error) {
var (
@@ -115,6 +153,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
beforeCommitID,
latestCommitID,
line,
extraLinesCount,
replyReviewID,
attachments,
)
@@ -158,6 +197,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
beforeCommitID,
latestCommitID,
line,
extraLinesCount,
review.ID,
attachments,
)
@@ -180,7 +220,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
// CreateCodeCommentKnownReviewID creates a plain code comment at the specified line / path
func CreateCodeCommentKnownReviewID(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
issue *issues_model.Issue, content, treePath, beforeCommitID, afterCommitID string,
line, reviewID int64, attachments []string,
line, extraLinesCount, reviewID int64, attachments []string,
) (*issues_model.Comment, error) {
var commitID, blamedCommitID, patch string
blamedLine := line
@@ -248,12 +288,23 @@ func CreateCodeCommentKnownReviewID(ctx context.Context, doer *user_model.User,
blame, err := gitRepo.ReverseLineBlame(beforeCommitID, treePath, uint64(-1*line), afterCommitID)
if err != nil {
return nil, fmt.Errorf("ReverseLineBlame[%s, %s, %d, %s]: %w", beforeCommitID, treePath, -1*line, afterCommitID, err)
} else if blame.CommitID == afterCommitID {
// Although this is a comment on the "previous" side of the diff, the reverse blame indicates that the line
// of code still exists in the commit being viewed (eg. it was a comment on a white line in the left-side of
// the diff, not a red removed line). In order to record the right information for where to place this
// commit, we'll convert this into a right-hand comment -- using the present line number that the reverse
// blame gave us:
}
// Convert to a right-hand (proposed) comment only when EVERY line of the range still exists at head
// (whole selection unchanged); if any line was removed or modified, keep it on the previous side.
rangeStillExists := blame.CommitID == afterCommitID
for i := int64(1); rangeStillExists && i <= extraLinesCount; i++ {
lineBlame, err := gitRepo.ReverseLineBlame(beforeCommitID, treePath, uint64(-1*line)+uint64(i), afterCommitID)
if err != nil {
return nil, fmt.Errorf("ReverseLineBlame[%s, %s, %d, %s]: %w", beforeCommitID, treePath, -1*line+i, afterCommitID, err)
}
if lineBlame.CommitID != afterCommitID {
rangeStillExists = false
}
}
switch {
case rangeStillExists:
commit, lineres, err := gitRepo.LineBlame(afterCommitID, treePath, blame.LineNumber)
if err == nil {
blamedCommitID = commit.ID.String()
@@ -261,7 +312,12 @@ func CreateCodeCommentKnownReviewID(ctx context.Context, doer *user_model.User,
} else if !errors.Is(err, git.ErrBlameFileDoesNotExist) && !errors.Is(err, git.ErrBlameFileNotEnoughLines) {
return nil, fmt.Errorf("LineBlame[%s, %s, %s, %d]: %w", pr.GetGitRefName(), gitRepo.Path, treePath, line, err)
}
} else {
case blame.CommitID == afterCommitID:
// First line still exists but a later one changed
blamedCommitID = beforeCommitID
// retain negative line numbering to identify we're commenting on the "previous" side of the diff
blamedLine = line
default:
blamedCommitID = blame.CommitID
// retain negative line numbering to identify we're commenting on the "previous" side of the diff
blamedLine = -1 * int64(blame.LineNumber)
@@ -292,25 +348,29 @@ func CreateCodeCommentKnownReviewID(ctx context.Context, doer *user_model.User,
_ = writer.Close()
}()
patch, err = git.CutDiffAroundLine(reader, int64((&issues_model.Comment{Line: line}).UnsignedLine()), line < 0, setting.UI.CodeCommentLines)
// For multi-line comments, center the patch on the last line and expand context to include the full range
displayLine := int64((&issues_model.Comment{Line: line, ExtraLinesCount: extraLinesCount}).UnsignedDisplayLine())
contextLines := setting.UI.CodeCommentLines + int(extraLinesCount)
patch, err = git.CutDiffAroundLine(reader, displayLine, line < 0, contextLines)
if err != nil {
log.Error("Error whilst generating patch: %v", err)
return nil, err
}
}
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeCode,
Doer: doer,
Repo: repo,
Issue: issue,
Content: content,
LineNum: blamedLine,
TreePath: treePath,
CommitSHA: blamedCommitID,
ReviewID: reviewID,
Patch: patch,
Invalidated: invalidated,
Attachments: attachments,
Type: issues_model.CommentTypeCode,
Doer: doer,
Repo: repo,
Issue: issue,
Content: content,
LineNum: blamedLine,
ExtraLinesCount: extraLinesCount,
TreePath: treePath,
CommitSHA: blamedCommitID,
ReviewID: reviewID,
Patch: patch,
Invalidated: invalidated,
Attachments: attachments,
})
}