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:
committed by
Mathieu Fenniak
parent
f576a1a21e
commit
6a27eb051d
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user