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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user