forgejo/models/issues/comment_code.go
steven.guiheux 6a27eb051d 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>
2026-06-03 16:06:29 +02:00

234 lines
8.1 KiB
Go

// Copyright 2022 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"forgejo.org/models/db"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/git"
"forgejo.org/modules/log"
"forgejo.org/modules/markup"
"forgejo.org/modules/markup/markdown"
"xorm.io/builder"
)
// CodeConversation contains the comment of a given review
type CodeConversation []*Comment
// CodeConversationsAtLine contains the conversations for a given line
type CodeConversationsAtLine map[int64][]CodeConversation
// CodeConversationsAtLineAndTreePath contains the conversations for a given TreePath and line
type CodeConversationsAtLineAndTreePath map[string]CodeConversationsAtLine
func newCodeConversationsAtLineAndTreePath(ctx context.Context, comments []*Comment, repo *repo_model.Repository, headCommitID string) (CodeConversationsAtLineAndTreePath, error) {
tree := make(CodeConversationsAtLineAndTreePath)
for _, comment := range comments {
blame, err := comment.ResolveCurrentLine(ctx, repo, headCommitID)
if err != nil {
// ResolveCurrentLine can fail in at least one known situation -- where a comment is left on a line in a
// file that is being deleted. The blame would be for the commit that deleted the file, and a reverse git
// blame won't work because the file is missing in the target sha.
log.Warn("ResolveCurrentLine failed: %s", err.Error())
// handle gracefully -- insertComment will use the original values which may be usable
blame = nil
} else if blame.CommitID != headCommitID {
// Commit was made on a line that can't be reverse-blamed to the currently viewing head. This can happen
// because:
// - line of code was removed between the commit it was tagged on, and the head commit
// - force push on the repo caused there to be no git relationship between blame.CommitID->headCommitID
// We won't insert this comment into the comment tree because we don't know where to place it; it may appear
// 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
}
func (tree CodeConversationsAtLineAndTreePath) insertComment(comment *Comment, blame *git.ReverseLineBlame) {
treePath := comment.TreePath
line := comment.DisplayLine()
if blame != nil {
treePath = blame.FilePath
if comment.Line < 0 {
// 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
}
}
// attempt to append comment to existing conversations (i.e. list of comments belonging to the same review)
for i, conversation := range tree[treePath][line] {
if conversation[0].ReviewID == comment.ReviewID {
tree[treePath][line][i] = append(conversation, comment)
return
}
}
// no previous conversation was found at this line, create it
if tree[treePath] == nil {
tree[treePath] = make(map[int64][]CodeConversation)
}
tree[treePath][line] = append(tree[treePath][line], CodeConversation{comment})
}
// FetchCodeConversations will return a 2d-map: ["Path"]["Line"] = List of CodeConversation (one per review) for this
// line. headCommitID will be used to reverse-blame the comment into the correct path & line for the current context
// that is being viewed.
func FetchCodeConversations(ctx context.Context, issue *Issue, doer *user_model.User, showOutdatedComments bool, headCommitID string) (CodeConversationsAtLineAndTreePath, error) {
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
}
comments, err := findCodeComments(ctx, opts, issue, doer, nil, showOutdatedComments)
if err != nil {
return nil, err
}
return newCodeConversationsAtLineAndTreePath(ctx, comments, issue.Repo, headCommitID)
}
// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS
type CodeComments map[string]map[int64][]*Comment
func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, doer *user_model.User, review *Review, showOutdatedComments bool) (CodeComments, error) {
pathToLineToComment := make(CodeComments)
if review == nil {
review = &Review{ID: 0}
}
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: issue.ID,
ReviewID: review.ID,
}
comments, err := findCodeComments(ctx, opts, issue, doer, review, showOutdatedComments)
if err != nil {
return nil, err
}
for _, comment := range comments {
if pathToLineToComment[comment.TreePath] == nil {
pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment)
}
displayLine := comment.DisplayLine()
pathToLineToComment[comment.TreePath][displayLine] = append(pathToLineToComment[comment.TreePath][displayLine], comment)
}
return pathToLineToComment, nil
}
func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, doer *user_model.User, review *Review, showOutdatedComments bool) (CommentList, error) {
var comments CommentList
if review == nil {
review = &Review{ID: 0}
}
conds := opts.ToConds()
if !showOutdatedComments && review.ID == 0 {
conds = conds.And(builder.Eq{"invalidated": false})
}
e := db.GetEngine(ctx)
if err := e.Where(conds).
Asc("comment.created_unix").
Asc("comment.id").
Find(&comments); err != nil {
return nil, err
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
if err := comments.LoadPosters(ctx); err != nil {
return nil, err
}
if err := comments.LoadAttachments(ctx); err != nil {
return nil, err
}
// Find all reviews by ReviewID
reviews := make(map[int64]*Review)
ids := make([]int64, 0, len(comments))
for _, comment := range comments {
if comment.ReviewID != 0 {
ids = append(ids, comment.ReviewID)
}
}
if err := e.In("id", ids).Find(&reviews); err != nil {
return nil, err
}
readyComments := make(CommentList, 0, len(comments))
for _, comment := range comments {
if re, ok := reviews[comment.ReviewID]; ok && re != nil {
// If the review is pending only the author can see the comments (except if the review is set)
if review.ID == 0 && re.Type == ReviewTypePending &&
(doer == nil || doer.ID != re.ReviewerID) {
continue
}
comment.Review = re
}
readyComments = append(readyComments, comment)
}
if err := readyComments.LoadResolveDoers(ctx); err != nil {
return nil, err
}
if err := readyComments.LoadReactions(ctx, issue.Repo); err != nil {
return nil, err
}
for _, comment := range readyComments {
var err error
if comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{
Ctx: ctx,
Links: markup.Links{
Base: issue.Repo.Link(),
},
Metas: issue.Repo.ComposeMetas(ctx),
}, comment.Content); err != nil {
return nil, err
}
}
return readyComments, nil
}
// FetchCodeConversation fetches the code conversation of a given comment (same review, treePath and line number)
func FetchCodeConversation(ctx context.Context, comment *Comment, doer *user_model.User) (CommentList, error) {
opts := FindCommentsOptions{
Type: CommentTypeCode,
IssueID: comment.IssueID,
ReviewID: comment.ReviewID,
TreePath: comment.TreePath,
Line: comment.Line,
}
return findCodeComments(ctx, opts, comment.Issue, doer, nil, true)
}