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
@@ -0,0 +1,25 @@
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
package forgejo_migrations
import "code.forgejo.org/xorm/xorm"
func init() {
registerMigration(&Migration{
Description: "add extra_lines_count to comment for multi-line review comments",
Upgrade: addCommentExtraLinesCount,
})
}
func addCommentExtraLinesCount(x *xorm.Engine) error {
type Comment struct {
ExtraLinesCount int64 `xorm:"NOT NULL DEFAULT 0"`
}
_, err := x.SyncWithOptions(
xorm.SyncOptions{IgnoreDropIndices: true},
new(Comment),
)
return err
}
+124 -37
View File
@@ -283,6 +283,7 @@ type Comment struct {
CommitID int64
Line int64 // - previous line / + proposed line
ExtraLinesCount int64 `xorm:"NOT NULL DEFAULT 0"` // number of additional lines after Line (0 = single line)
TreePath string
Content string `xorm:"LONGTEXT"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
@@ -751,6 +752,58 @@ func (c *Comment) UnsignedLine() uint64 {
return uint64(c.Line)
}
// DisplayLine returns the signed line number where the comment should be displayed
// in the diff view. For multi-line comments, this is the last line of the range.
func (c *Comment) DisplayLine() int64 {
if c.Line < 0 {
return c.Line - c.ExtraLinesCount
}
return c.Line + c.ExtraLinesCount
}
// UnsignedDisplayLine returns the unsigned (absolute) display line number.
// For multi-line comments, this is the last line of the range (UnsignedLine + ExtraLinesCount).
func (c *Comment) UnsignedDisplayLine() uint64 {
return c.UnsignedLine() + uint64(c.ExtraLinesCount)
}
// resolveLineAtHead checks whether a specific line is still present at the given head commit.
// For positive lines (proposed side), uses git blame --reverse.
// For negative lines (previous side), uses diff + FindAdjustedLineNumber.
func (c *Comment) resolveLineAtHead(gitRepo *git.Repository, lineNum uint64, currentHead string) (*git.ReverseLineBlame, error) {
if c.Line > 0 {
blame, err := gitRepo.ReverseLineBlame(c.CommitSHA, c.TreePath, lineNum, currentHead)
if err != nil {
return nil, fmt.Errorf("ReverseLineBlame for line %d: %w", lineNum, err)
}
return blame, nil
}
// For comments on removed lines, diff the commit the line was known to exist in against the head
// being viewed, then locate the line in that diff.
var buffer bytes.Buffer
if err := git.GetRepoRawDiffForFile(gitRepo, c.CommitSHA, currentHead, git.RawDiffNormal, c.TreePath, &buffer); err != nil {
return nil, fmt.Errorf("failed to get diff: %w", err)
}
diff := buffer.String()
adjustedLine, err := git.FindAdjustedLineNumber(c.Patch, int64(lineNum), strings.NewReader(diff))
if err != nil && errors.Is(err, git.ErrLineNotFound) {
return &git.ReverseLineBlame{
CommitID: "", // not currentHead — indicates the line is outdated
LineNumber: lineNum,
FilePath: c.TreePath,
}, nil
} else if err != nil {
return nil, fmt.Errorf("FindAdjustedLineNumber for line %d: %w", lineNum, err)
}
return &git.ReverseLineBlame{
CommitID: currentHead,
LineNumber: uint64(adjustedLine.Left),
FilePath: c.TreePath,
}, nil
}
func (c *Comment) ResolveCurrentLine(ctx context.Context, repo *repo_model.Repository, currentHead string) (*git.ReverseLineBlame, error) {
if c.reverseLineBlame != nil {
return c.reverseLineBlame, nil
@@ -774,43 +827,15 @@ func (c *Comment) ResolveCurrentLine(ctx context.Context, repo *repo_model.Repos
}
defer closer.Close()
var reverseBlame *git.ReverseLineBlame
if c.Line > 0 {
var err error
reverseBlame, err = gitRepo.ReverseLineBlame(c.CommitSHA, c.TreePath, c.UnsignedLine(), currentHead)
if err != nil {
return "", fmt.Errorf("failed to perform `git blame --reverse` to resolve current line for comment (id=%d): %w", c.ID, err)
}
} else {
// For comments on removed lines, perform a `git diff` between the last commit that the line of code was
// known to exist (which is recorded as CommitSHA) and the requested head. Then inspect the diff to verify
// that the removed line of code is present in the diff.
buffer := bytes.Buffer{}
err := git.GetRepoRawDiffForFile(gitRepo, c.CommitSHA, currentHead, git.RawDiffNormal, c.TreePath, &buffer)
if err != nil {
return "", fmt.Errorf("failed to get diff: %w", err)
}
diff := buffer.String()
adjustedLine, err := git.FindAdjustedLineNumber(c.Patch, int64(c.UnsignedLine()), strings.NewReader(diff))
if err != nil && errors.Is(err, git.ErrLineNotFound) {
// Line not found in the diff. Don't treat this as an error, because that would break the caching --
// instead, return a blame where CommitID != headCommitID, which will be an indicator to callers (for
// both resolution methods) that the line of code is outdated in the diff.
reverseBlame = &git.ReverseLineBlame{
CommitID: "", // not currentHead
LineNumber: c.UnsignedLine(),
FilePath: c.TreePath,
}
} else if err != nil {
return "", fmt.Errorf("failed in finding adjusted line number: %w", err)
} else {
reverseBlame = &git.ReverseLineBlame{
CommitID: currentHead,
LineNumber: uint64(adjustedLine.Left),
FilePath: c.TreePath,
}
}
// On the previous side the patch is cut around the last line of the range, so resolve that line
// (for single-line comments and the proposed side it equals UnsignedLine).
resolveLine := c.UnsignedLine()
if c.Line < 0 {
resolveLine = c.UnsignedDisplayLine()
}
reverseBlame, err := c.resolveLineAtHead(gitRepo, resolveLine, currentHead)
if err != nil {
return "", err
}
data, err := json.Marshal(reverseBlame)
@@ -834,6 +859,66 @@ func (c *Comment) ResolveCurrentLine(ctx context.Context, repo *repo_model.Repos
return c.reverseLineBlame, nil
}
// CheckLineRangeValid reports whether a multi-line comment range can still be placed at the given head.
// Previous (negative) side: only the last line can be located in the cut patch, so only it is checked.
// Proposed (positive) side: modified lines are tolerated; only a line landing at an unexpected offset
// (lines inserted/removed inside the range) invalidates it. Uses the same caching pattern as ResolveCurrentLine.
func (c *Comment) CheckLineRangeValid(ctx context.Context, repo *repo_model.Repository, currentHead string) (bool, error) {
if c.ExtraLinesCount <= 0 {
return true, nil
}
resultJSON, err := cache.GetString(fmt.Sprintf("comment.ResolveRange;ID=%d;HEAD=%s", c.ID, currentHead), func() (string, error) {
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return "", fmt.Errorf("failed to open repo: %w", err)
}
defer closer.Close()
// Previous side: only the last line of the range can be located in the cut patch, so validate it.
if c.Line < 0 {
blame, err := c.resolveLineAtHead(gitRepo, c.UnsignedDisplayLine(), currentHead)
if err != nil {
return "", err
}
if blame.CommitID != currentHead {
return "invalid", nil
}
return "valid", nil
}
// Proposed side: resolve the first line; the others are expected to follow it consecutively.
anchorBlame, err := c.resolveLineAtHead(gitRepo, c.UnsignedLine(), currentHead)
if err != nil {
return "", err
}
if anchorBlame.CommitID != currentHead {
return "invalid", nil
}
anchorResolvedLine := anchorBlame.LineNumber
// A line that no longer resolves is treated as "modified but present" and tolerated; only a
// resolved line landing at an unexpected offset (lines inserted/removed inside the range) is a break.
startLine := c.UnsignedLine()
for i := int64(1); i <= c.ExtraLinesCount; i++ {
blame, err := c.resolveLineAtHead(gitRepo, startLine+uint64(i), currentHead)
if err != nil {
return "", err
}
if blame.CommitID == currentHead && blame.LineNumber != anchorResolvedLine+uint64(i) {
return "invalid", nil
}
}
return "valid", nil
})
if err != nil {
return false, err
}
return resultJSON == "valid", nil
}
// CodeCommentLink returns the url to a comment in code
func (c *Comment) CodeCommentLink(ctx context.Context) string {
err := c.LoadIssue(ctx)
@@ -914,6 +999,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
CommitID: opts.CommitID,
CommitSHA: opts.CommitSHA,
Line: opts.LineNum,
ExtraLinesCount: opts.ExtraLinesCount,
Content: opts.Content,
OldTitle: opts.OldTitle,
NewTitle: opts.NewTitle,
@@ -1090,6 +1176,7 @@ type CreateCommentOptions struct {
CommitSHA string
Patch string
LineNum int64
ExtraLinesCount int64
TreePath string
ReviewID int64
Content string
+23 -4
View File
@@ -46,6 +46,19 @@ func newCodeConversationsAtLineAndTreePath(ctx context.Context, comments []*Comm
// when the user views a different commit in the PR, and it will always appear on the "Conversations" tab.
continue
}
// For multi-line comments, verify that the full line range is still valid and contiguous at head.
// If lines were inserted/removed/reordered within the range, the comment would be displayed
// at wrong lines — skip it in this view (it remains visible on the "Conversations" tab).
if comment.ExtraLinesCount > 0 && blame != nil {
valid, err := comment.CheckLineRangeValid(ctx, repo, headCommitID)
if err != nil {
log.Warn("CheckLineRangeValid failed for comment %d: %s", comment.ID, err.Error())
} else if !valid {
continue
}
}
tree.insertComment(comment, blame)
}
return tree, nil
@@ -53,12 +66,17 @@ func newCodeConversationsAtLineAndTreePath(ctx context.Context, comments []*Comm
func (tree CodeConversationsAtLineAndTreePath) insertComment(comment *Comment, blame *git.ReverseLineBlame) {
treePath := comment.TreePath
line := comment.Line
line := comment.DisplayLine()
if blame != nil {
treePath = blame.FilePath
line = int64(blame.LineNumber)
if comment.Line < 0 {
line *= -1
// On the previous side, ResolveCurrentLine resolves the last line of the range (the display
// line) directly, so blame.LineNumber already is the signed display line.
line = int64(blame.LineNumber) * -1
} else {
// On the proposed side, blame resolves the first line; the display line is that line shifted
// down by the number of extra lines.
line = int64(blame.LineNumber) + comment.ExtraLinesCount
}
}
@@ -117,7 +135,8 @@ func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, doer *user_mod
if pathToLineToComment[comment.TreePath] == nil {
pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment)
}
pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment)
displayLine := comment.DisplayLine()
pathToLineToComment[comment.TreePath][displayLine] = append(pathToLineToComment[comment.TreePath][displayLine], comment)
}
return pathToLineToComment, nil
}
+56
View File
@@ -151,3 +151,59 @@ func Test_UpdateIssueNumComments(t *testing.T) {
issue2 = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
assert.Equal(t, 1, issue2.NumComments)
}
func TestDisplayLine(t *testing.T) {
tests := []struct {
name string
line int64
count int64
expected int64
}{
{"positive single line", 10, 0, 10},
{"positive multi-line", 7, 2, 9},
{"positive large range", 1, 49, 50},
{"negative single line", -10, 0, -10},
{"negative multi-line", -7, 2, -9},
{"negative large range", -1, 49, -50},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := &issues_model.Comment{Line: tc.line, ExtraLinesCount: tc.count}
assert.Equal(t, tc.expected, c.DisplayLine())
})
}
}
func TestUnsignedDisplayLine(t *testing.T) {
tests := []struct {
name string
line int64
count int64
expected uint64
}{
{"positive single line", 10, 0, 10},
{"positive multi-line", 7, 2, 9},
{"negative single line", -10, 0, 10},
{"negative multi-line", -7, 2, 9},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := &issues_model.Comment{Line: tc.line, ExtraLinesCount: tc.count}
assert.Equal(t, tc.expected, c.UnsignedDisplayLine())
})
}
}
func TestCheckLineRangeValid_SingleLine(t *testing.T) {
// ExtraLinesCount=0 should return true immediately without any git operations
c := &issues_model.Comment{Line: 10, ExtraLinesCount: 0}
valid, err := c.CheckLineRangeValid(t.Context(), nil, "any-commit-id")
require.NoError(t, err)
assert.True(t, valid)
// Negative line, ExtraLinesCount=0
c2 := &issues_model.Comment{Line: -5, ExtraLinesCount: 0}
valid2, err := c2.CheckLineRangeValid(t.Context(), nil, "any-commit-id")
require.NoError(t, err)
assert.True(t, valid2)
}