mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-06-22 10:02:15 +00:00
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>
234 lines
8.1 KiB
Go
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)
|
|
}
|