Merge pull request '[BUG] split code conversations in diff tab' (#2362) from oliverpool/forgejo:bg2306 into v1.21/forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2362
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-02-17 10:50:48 +00:00
commit 8283305d53
11 changed files with 348 additions and 144 deletions

View file

@ -14,15 +14,58 @@ import (
"xorm.io/builder" "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(comments []*Comment) CodeConversationsAtLineAndTreePath {
tree := make(CodeConversationsAtLineAndTreePath)
for _, comment := range comments {
tree.insertComment(comment)
}
return tree
}
func (tree CodeConversationsAtLineAndTreePath) insertComment(comment *Comment) {
// attempt to append comment to existing conversations (i.e. list of comments belonging to the same review)
for i, conversation := range tree[comment.TreePath][comment.Line] {
if conversation[0].ReviewID == comment.ReviewID {
tree[comment.TreePath][comment.Line][i] = append(conversation, comment)
return
}
}
// no previous conversation was found at this line, create it
if tree[comment.TreePath] == nil {
tree[comment.TreePath] = make(map[int64][]CodeConversation)
}
tree[comment.TreePath][comment.Line] = append(tree[comment.TreePath][comment.Line], CodeConversation{comment})
}
// FetchCodeConversations will return a 2d-map: ["Path"]["Line"] = List of CodeConversation (one per review) for this line
func FetchCodeConversations(ctx context.Context, issue *Issue, doer *user_model.User, showOutdatedComments bool) (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(comments), nil
}
// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS // CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS
type CodeComments map[string]map[int64][]*Comment type CodeComments map[string]map[int64][]*Comment
// FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, doer *user_model.User, review *Review, showOutdatedComments bool) (CodeComments, error) {
func FetchCodeComments(ctx context.Context, issue *Issue, currentUser *user_model.User, showOutdatedComments bool) (CodeComments, error) {
return fetchCodeCommentsByReview(ctx, issue, currentUser, nil, showOutdatedComments)
}
func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *user_model.User, review *Review, showOutdatedComments bool) (CodeComments, error) {
pathToLineToComment := make(CodeComments) pathToLineToComment := make(CodeComments)
if review == nil { if review == nil {
review = &Review{ID: 0} review = &Review{ID: 0}
@ -33,7 +76,7 @@ func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *u
ReviewID: review.ID, ReviewID: review.ID,
} }
comments, err := findCodeComments(ctx, opts, issue, currentUser, review, showOutdatedComments) comments, err := findCodeComments(ctx, opts, issue, doer, review, showOutdatedComments)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -47,7 +90,7 @@ func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *u
return pathToLineToComment, nil return pathToLineToComment, nil
} }
func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, currentUser *user_model.User, review *Review, showOutdatedComments bool) ([]*Comment, error) { func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, doer *user_model.User, review *Review, showOutdatedComments bool) ([]*Comment, error) {
var comments CommentList var comments CommentList
if review == nil { if review == nil {
review = &Review{ID: 0} review = &Review{ID: 0}
@ -91,7 +134,7 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
if re, ok := reviews[comment.ReviewID]; ok && re != nil { 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 the review is pending only the author can see the comments (except if the review is set)
if review.ID == 0 && re.Type == ReviewTypePending && if review.ID == 0 && re.Type == ReviewTypePending &&
(currentUser == nil || currentUser.ID != re.ReviewerID) { (doer == nil || doer.ID != re.ReviewerID) {
continue continue
} }
comment.Review = re comment.Review = re
@ -121,13 +164,14 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
return comments[:n], nil return comments[:n], nil
} }
// FetchCodeCommentsByLine fetches the code comments for a given treePath and line number // FetchCodeConversation fetches the code conversation of a given comment (same review, treePath and line number)
func FetchCodeCommentsByLine(ctx context.Context, issue *Issue, currentUser *user_model.User, treePath string, line int64, showOutdatedComments bool) ([]*Comment, error) { func FetchCodeConversation(ctx context.Context, comment *Comment, doer *user_model.User) ([]*Comment, error) {
opts := FindCommentsOptions{ opts := FindCommentsOptions{
Type: CommentTypeCode, Type: CommentTypeCode,
IssueID: issue.ID, IssueID: comment.IssueID,
TreePath: treePath, ReviewID: comment.ReviewID,
Line: line, TreePath: comment.TreePath,
Line: comment.Line,
} }
return findCodeComments(ctx, opts, issue, currentUser, nil, showOutdatedComments) return findCodeComments(ctx, opts, comment.Issue, doer, nil, true)
} }

View file

@ -45,20 +45,20 @@ func TestCreateComment(t *testing.T) {
unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix)) unittest.AssertInt64InRange(t, now, then, int64(updatedIssue.UpdatedUnix))
} }
func TestFetchCodeComments(t *testing.T) { func TestFetchCodeConversations(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
res, err := issues_model.FetchCodeComments(db.DefaultContext, issue, user, false) res, err := issues_model.FetchCodeConversations(db.DefaultContext, issue, user, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, res, "README.md") assert.Contains(t, res, "README.md")
assert.Contains(t, res["README.md"], int64(4)) assert.Contains(t, res["README.md"], int64(4))
assert.Len(t, res["README.md"][4], 1) assert.Len(t, res["README.md"][4], 1)
assert.Equal(t, int64(4), res["README.md"][4][0].ID) assert.Equal(t, int64(4), res["README.md"][4][0][0].ID)
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
res, err = issues_model.FetchCodeComments(db.DefaultContext, issue, user2, false) res, err = issues_model.FetchCodeConversations(db.DefaultContext, issue, user2, false)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, res, 1) assert.Len(t, res, 1)
} }

View file

@ -153,7 +153,7 @@ func UpdateResolveConversation(ctx *context.Context) {
} }
func renderConversation(ctx *context.Context, comment *issues_model.Comment, origin string) { func renderConversation(ctx *context.Context, comment *issues_model.Comment, origin string) {
comments, err := issues_model.FetchCodeCommentsByLine(ctx, comment.Issue, ctx.Doer, comment.TreePath, comment.Line, true) comments, err := issues_model.FetchCodeConversation(ctx, comment, ctx.Doer)
if err != nil { if err != nil {
ctx.ServerError("FetchCodeCommentsByLine", err) ctx.ServerError("FetchCodeCommentsByLine", err)
return return

View file

@ -13,7 +13,6 @@ import (
"html/template" "html/template"
"io" "io"
"net/url" "net/url"
"sort"
"strings" "strings"
"time" "time"
@ -75,13 +74,13 @@ const (
// DiffLine represents a line difference in a DiffSection. // DiffLine represents a line difference in a DiffSection.
type DiffLine struct { type DiffLine struct {
LeftIdx int LeftIdx int
RightIdx int RightIdx int
Match int Match int
Type DiffLineType Type DiffLineType
Content string Content string
Comments []*issues_model.Comment Conversations []issues_model.CodeConversation
SectionInfo *DiffLineSectionInfo SectionInfo *DiffLineSectionInfo
} }
// DiffLineSectionInfo represents diff line section meta data // DiffLineSectionInfo represents diff line section meta data
@ -118,15 +117,15 @@ func (d *DiffLine) GetHTMLDiffLineType() string {
// CanComment returns whether a line can get commented // CanComment returns whether a line can get commented
func (d *DiffLine) CanComment() bool { func (d *DiffLine) CanComment() bool {
return len(d.Comments) == 0 && d.Type != DiffLineSection return len(d.Conversations) == 0 && d.Type != DiffLineSection
} }
// GetCommentSide returns the comment side of the first comment, if not set returns empty string // GetCommentSide returns the comment side of the first comment, if not set returns empty string
func (d *DiffLine) GetCommentSide() string { func (d *DiffLine) GetCommentSide() string {
if len(d.Comments) == 0 { if len(d.Conversations) == 0 || len(d.Conversations[0]) == 0 {
return "" return ""
} }
return d.Comments[0].DiffSide() return d.Conversations[0][0].DiffSide()
} }
// GetLineTypeMarker returns the line type marker // GetLineTypeMarker returns the line type marker
@ -467,23 +466,20 @@ type Diff struct {
// LoadComments loads comments into each line // LoadComments loads comments into each line
func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, currentUser *user_model.User, showOutdatedComments bool) error { func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, currentUser *user_model.User, showOutdatedComments bool) error {
allComments, err := issues_model.FetchCodeComments(ctx, issue, currentUser, showOutdatedComments) allConversations, err := issues_model.FetchCodeConversations(ctx, issue, currentUser, showOutdatedComments)
if err != nil { if err != nil {
return err return err
} }
for _, file := range diff.Files { for _, file := range diff.Files {
if lineCommits, ok := allComments[file.Name]; ok { if lineCommits, ok := allConversations[file.Name]; ok {
for _, section := range file.Sections { for _, section := range file.Sections {
for _, line := range section.Lines { for _, line := range section.Lines {
if comments, ok := lineCommits[int64(line.LeftIdx*-1)]; ok { if conversations, ok := lineCommits[int64(line.LeftIdx*-1)]; ok {
line.Comments = append(line.Comments, comments...) line.Conversations = append(line.Conversations, conversations...)
} }
if comments, ok := lineCommits[int64(line.RightIdx)]; ok { if comments, ok := lineCommits[int64(line.RightIdx)]; ok {
line.Comments = append(line.Comments, comments...) line.Conversations = append(line.Conversations, comments...)
} }
sort.SliceStable(line.Comments, func(i, j int) bool {
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
})
} }
} }
} }

View file

@ -601,7 +601,7 @@ func TestDiff_LoadCommentsNoOutdated(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
diff := setupDefaultDiff() diff := setupDefaultDiff()
assert.NoError(t, diff.LoadComments(db.DefaultContext, issue, user, false)) assert.NoError(t, diff.LoadComments(db.DefaultContext, issue, user, false))
assert.Len(t, diff.Files[0].Sections[0].Lines[0].Comments, 2) assert.Len(t, diff.Files[0].Sections[0].Lines[0].Conversations, 2)
} }
func TestDiff_LoadCommentsWithOutdated(t *testing.T) { func TestDiff_LoadCommentsWithOutdated(t *testing.T) {
@ -611,20 +611,22 @@ func TestDiff_LoadCommentsWithOutdated(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
diff := setupDefaultDiff() diff := setupDefaultDiff()
assert.NoError(t, diff.LoadComments(db.DefaultContext, issue, user, true)) assert.NoError(t, diff.LoadComments(db.DefaultContext, issue, user, true))
assert.Len(t, diff.Files[0].Sections[0].Lines[0].Comments, 3) assert.Len(t, diff.Files[0].Sections[0].Lines[0].Conversations, 2)
assert.Len(t, diff.Files[0].Sections[0].Lines[0].Conversations[0], 2)
assert.Len(t, diff.Files[0].Sections[0].Lines[0].Conversations[1], 1)
} }
func TestDiffLine_CanComment(t *testing.T) { func TestDiffLine_CanComment(t *testing.T) {
assert.False(t, (&DiffLine{Type: DiffLineSection}).CanComment()) assert.False(t, (&DiffLine{Type: DiffLineSection}).CanComment())
assert.False(t, (&DiffLine{Type: DiffLineAdd, Comments: []*issues_model.Comment{{Content: "bla"}}}).CanComment()) assert.False(t, (&DiffLine{Type: DiffLineAdd, Conversations: []issues_model.CodeConversation{{{Content: "bla"}}}}).CanComment())
assert.True(t, (&DiffLine{Type: DiffLineAdd}).CanComment()) assert.True(t, (&DiffLine{Type: DiffLineAdd}).CanComment())
assert.True(t, (&DiffLine{Type: DiffLineDel}).CanComment()) assert.True(t, (&DiffLine{Type: DiffLineDel}).CanComment())
assert.True(t, (&DiffLine{Type: DiffLinePlain}).CanComment()) assert.True(t, (&DiffLine{Type: DiffLinePlain}).CanComment())
} }
func TestDiffLine_GetCommentSide(t *testing.T) { func TestDiffLine_GetCommentSide(t *testing.T) {
assert.Equal(t, "previous", (&DiffLine{Comments: []*issues_model.Comment{{Line: -3}}}).GetCommentSide()) assert.Equal(t, "previous", (&DiffLine{Conversations: []issues_model.CodeConversation{{{Line: -3}}}}).GetCommentSide())
assert.Equal(t, "proposed", (&DiffLine{Comments: []*issues_model.Comment{{Line: 3}}}).GetCommentSide()) assert.Equal(t, "proposed", (&DiffLine{Conversations: []issues_model.CodeConversation{{{Line: 3}}}}).GetCommentSide())
} }
func TestGetDiffRangeWithWhitespaceBehavior(t *testing.T) { func TestGetDiffRangeWithWhitespaceBehavior(t *testing.T) {

View file

@ -53,11 +53,11 @@ func TestGetDiffPreview(t *testing.T) {
Name: "", Name: "",
Lines: []*gitdiff.DiffLine{ Lines: []*gitdiff.DiffLine{
{ {
LeftIdx: 0, LeftIdx: 0,
RightIdx: 0, RightIdx: 0,
Type: 4, Type: 4,
Content: "@@ -1,3 +1,4 @@", Content: "@@ -1,3 +1,4 @@",
Comments: nil, Conversations: nil,
SectionInfo: &gitdiff.DiffLineSectionInfo{ SectionInfo: &gitdiff.DiffLineSectionInfo{
Path: "README.md", Path: "README.md",
LastLeftIdx: 0, LastLeftIdx: 0,
@ -69,42 +69,42 @@ func TestGetDiffPreview(t *testing.T) {
}, },
}, },
{ {
LeftIdx: 1, LeftIdx: 1,
RightIdx: 1, RightIdx: 1,
Type: 1, Type: 1,
Content: " # repo1", Content: " # repo1",
Comments: nil, Conversations: nil,
}, },
{ {
LeftIdx: 2, LeftIdx: 2,
RightIdx: 2, RightIdx: 2,
Type: 1, Type: 1,
Content: " ", Content: " ",
Comments: nil, Conversations: nil,
}, },
{ {
LeftIdx: 3, LeftIdx: 3,
RightIdx: 0, RightIdx: 0,
Match: 4, Match: 4,
Type: 3, Type: 3,
Content: "-Description for repo1", Content: "-Description for repo1",
Comments: nil, Conversations: nil,
}, },
{ {
LeftIdx: 0, LeftIdx: 0,
RightIdx: 3, RightIdx: 3,
Match: 3, Match: 3,
Type: 2, Type: 2,
Content: "+Description for repo1", Content: "+Description for repo1",
Comments: nil, Conversations: nil,
}, },
{ {
LeftIdx: 0, LeftIdx: 0,
RightIdx: 4, RightIdx: 4,
Match: -1, Match: -1,
Type: 2, Type: 2,
Content: "+this is a new line", Content: "+this is a new line",
Comments: nil, Conversations: nil,
}, },
}, },
}, },

View file

@ -0,0 +1,3 @@
{{range .conversations}}
{{template "repo/diff/conversation" dict "." $ "comments" .}}
{{end}}

View file

@ -108,44 +108,44 @@
</tr> </tr>
{{if and (eq .GetType 3) $hasmatch}} {{if and (eq .GetType 3) $hasmatch}}
{{$match := index $section.Lines $line.Match}} {{$match := index $section.Lines $line.Match}}
{{if or $line.Comments $match.Comments}} {{if or $line.Conversations $match.Conversations}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}"> <tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left" colspan="4"> <td class="add-comment-left" colspan="4">
{{if $line.Comments}} {{if $line.Conversations}}
{{if eq $line.GetCommentSide "previous"}} {{if eq $line.GetCommentSide "previous"}}
{{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $line.Conversations}}
{{end}} {{end}}
{{end}} {{end}}
{{if $match.Comments}} {{if $match.Conversations}}
{{if eq $match.GetCommentSide "previous"}} {{if eq $match.GetCommentSide "previous"}}
{{template "repo/diff/conversation" dict "." $.root "comments" $match.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $match.Conversations}}
{{end}} {{end}}
{{end}} {{end}}
</td> </td>
<td class="add-comment-right" colspan="4"> <td class="add-comment-right" colspan="4">
{{if $line.Comments}} {{if $line.Conversations}}
{{if eq $line.GetCommentSide "proposed"}} {{if eq $line.GetCommentSide "proposed"}}
{{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $line.Conversations}}
{{end}} {{end}}
{{end}} {{end}}
{{if $match.Comments}} {{if $match.Conversations}}
{{if eq $match.GetCommentSide "proposed"}} {{if eq $match.GetCommentSide "proposed"}}
{{template "repo/diff/conversation" dict "." $.root "comments" $match.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $match.Conversations}}
{{end}} {{end}}
{{end}} {{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}
{{else if $line.Comments}} {{else if $line.Conversations}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}"> <tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left" colspan="4"> <td class="add-comment-left" colspan="4">
{{if eq $line.GetCommentSide "previous"}} {{if eq $line.GetCommentSide "previous"}}
{{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $line.Conversations}}
{{end}} {{end}}
</td> </td>
<td class="add-comment-right" colspan="4"> <td class="add-comment-right" colspan="4">
{{if eq $line.GetCommentSide "proposed"}} {{if eq $line.GetCommentSide "proposed"}}
{{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $line.Conversations}}
{{end}} {{end}}
</td> </td>
</tr> </tr>

View file

@ -60,10 +60,10 @@
*/}}</td> */}}</td>
{{end}} {{end}}
</tr> </tr>
{{if $line.Comments}} {{if $line.Conversations}}
<tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}"> <tr class="add-comment" data-line-type="{{.GetHTMLDiffLineType}}">
<td class="add-comment-left add-comment-right" colspan="5"> <td class="add-comment-left add-comment-right" colspan="5">
{{template "repo/diff/conversation" dict "." $.root "comments" $line.Comments}} {{template "repo/diff/conversations" dict "." $.root "conversations" $line.Conversations}}
</td> </td>
</tr> </tr>
{{end}} {{end}}

View file

@ -1,7 +1,6 @@
{{$invalid := (index .comments 0).Invalidated}} {{$invalid := (index .comments 0).Invalidated}}
{{$resolved := (index .comments 0).IsResolved}} {{$resolved := (index .comments 0).IsResolved}}
{{$resolveDoer := (index .comments 0).ResolveDoer}} {{$resolveDoer := (index .comments 0).ResolveDoer}}
{{$hideByDefault := or $resolved (and $invalid (not .ShowOutdatedComments))}}
{{$isNotPending := (not (eq (index .comments 0).Review.Type 0))}} {{$isNotPending := (not (eq (index .comments 0).Review.Type 0))}}
<div class="ui segments conversation-holder"> <div class="ui segments conversation-holder">
<div class="ui segment collapsible-comment-box gt-py-3 gt-df gt-ac gt-sb"> <div class="ui segment collapsible-comment-box gt-py-3 gt-df gt-ac gt-sb">
@ -15,7 +14,7 @@
</div> </div>
<div> <div>
{{if or $invalid $resolved}} {{if or $invalid $resolved}}
<button id="show-outdated-{{(index .comments 0).ID}}" data-comment="{{(index .comments 0).ID}}" class="{{if not $hideByDefault}}gt-hidden {{end}}ui compact labeled button show-outdated gt-df gt-ac"> <button id="show-outdated-{{(index .comments 0).ID}}" data-comment="{{(index .comments 0).ID}}" class="{{if not $resolved}}gt-hidden {{end}}ui compact labeled button show-outdated gt-df gt-ac">
{{svg "octicon-unfold" 16 "gt-mr-3"}} {{svg "octicon-unfold" 16 "gt-mr-3"}}
{{if $resolved}} {{if $resolved}}
{{ctx.Locale.Tr "repo.issues.review.show_resolved"}} {{ctx.Locale.Tr "repo.issues.review.show_resolved"}}
@ -23,7 +22,7 @@
{{ctx.Locale.Tr "repo.issues.review.show_outdated"}} {{ctx.Locale.Tr "repo.issues.review.show_outdated"}}
{{end}} {{end}}
</button> </button>
<button id="hide-outdated-{{(index .comments 0).ID}}" data-comment="{{(index .comments 0).ID}}" class="{{if $hideByDefault}}gt-hidden {{end}}ui compact labeled button hide-outdated gt-df gt-ac"> <button id="hide-outdated-{{(index .comments 0).ID}}" data-comment="{{(index .comments 0).ID}}" class="{{if $resolved}}gt-hidden {{end}}ui compact labeled button hide-outdated gt-df gt-ac">
{{svg "octicon-fold" 16 "gt-mr-3"}} {{svg "octicon-fold" 16 "gt-mr-3"}}
{{if $resolved}} {{if $resolved}}
{{ctx.Locale.Tr "repo.issues.review.hide_resolved"}} {{ctx.Locale.Tr "repo.issues.review.hide_resolved"}}
@ -37,7 +36,7 @@
{{$diff := (CommentMustAsDiff (index .comments 0))}} {{$diff := (CommentMustAsDiff (index .comments 0))}}
{{if $diff}} {{if $diff}}
{{$file := (index $diff.Files 0)}} {{$file := (index $diff.Files 0)}}
<div id="code-preview-{{(index .comments 0).ID}}" class="ui table segment{{if $hideByDefault}} gt-hidden{{end}}"> <div id="code-preview-{{(index .comments 0).ID}}" class="ui table segment{{if $resolved}} gt-hidden{{end}}">
<div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}}"> <div class="diff-file-box diff-box file-content {{TabSizeClass $.Editorconfig $file.Name}}">
<div class="file-body file-code code-view code-diff code-diff-unified unicode-escaped"> <div class="file-body file-code code-view code-diff code-diff-unified unicode-escaped">
<table> <table>
@ -49,7 +48,7 @@
</div> </div>
</div> </div>
{{end}} {{end}}
<div id="code-comments-{{(index .comments 0).ID}}" class="comment-code-cloud ui segment{{if $hideByDefault}} gt-hidden{{end}}"> <div id="code-comments-{{(index .comments 0).ID}}" class="comment-code-cloud ui segment{{if $resolved}} gt-hidden{{end}}">
<div class="ui comments gt-mb-0"> <div class="ui comments gt-mb-0">
{{range .comments}} {{range .comments}}
{{$createdSubStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} {{$createdSubStr:= TimeSinceUnix .CreatedUnix ctx.Locale}}

View file

@ -10,8 +10,10 @@ import (
"testing" "testing"
"code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -26,6 +28,13 @@ func TestPullView_ReviewerMissed(t *testing.T) {
session.MakeRequest(t, req, http.StatusOK) session.MakeRequest(t, req, http.StatusOK)
} }
func loadComment(t *testing.T, commentID string) *issues.Comment {
t.Helper()
id, err := strconv.ParseInt(commentID, 10, 64)
assert.NoError(t, err)
return unittest.AssertExistsAndLoadBean(t, &issues.Comment{ID: id})
}
func TestPullView_ResolveInvalidatedReviewComment(t *testing.T) { func TestPullView_ResolveInvalidatedReviewComment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
session := loginUser(t, "user1") session := loginUser(t, "user1")
@ -33,60 +42,211 @@ func TestPullView_ResolveInvalidatedReviewComment(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files") req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files")
session.MakeRequest(t, req, http.StatusOK) session.MakeRequest(t, req, http.StatusOK)
req = NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment") t.Run("single outdated review (line 1)", func(t *testing.T) {
resp := session.MakeRequest(t, req, http.StatusOK) defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"origin": doc.GetInputValueByName("origin"),
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"side": "proposed",
"line": "1",
"path": "iso-8859-1.txt",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body) req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{ "_csrf": doc.GetInputValueByName("_csrf"),
"_csrf": doc.GetInputValueByName("_csrf"), "commit_id": doc.GetInputValueByName("latest_commit_id"),
"origin": doc.GetInputValueByName("origin"), "content": "looks good",
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"), "type": "comment",
"side": "proposed", })
"line": "1", session.MakeRequest(t, req, http.StatusOK)
"path": "iso-8859-1.txt",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"), // retrieve comment_id by reloading the comment page
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"), req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"), resp = session.MakeRequest(t, req, http.StatusOK)
"content": "nitpicking comment", doc = NewHTMLParser(t, resp.Body)
"pending_review": "", commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id")
assert.True(t, ok)
// adjust the database to mark the comment as invalidated
// (to invalidate it properly, one should push a commit which should trigger this logic,
// in the meantime, use this quick-and-dirty trick)
comment := loadComment(t, commentID)
assert.NoError(t, issues.UpdateCommentInvalidate(context.Background(), &issues.Comment{
ID: comment.ID,
Invalidated: true,
}))
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"origin": "timeline",
"action": "Resolve",
"comment_id": commentID,
})
resp = session.MakeRequest(t, req, http.StatusOK)
// even on template error, the page returns HTTP 200
// count the comments to ensure success.
doc = NewHTMLParser(t, resp.Body)
assert.Len(t, doc.Find(`.comments > .comment`).Nodes, 1)
}) })
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{ t.Run("outdated and newer review (line 2)", func(t *testing.T) {
"_csrf": doc.GetInputValueByName("_csrf"), defer tests.PrintCurrentTest(t)()
"commit_id": doc.GetInputValueByName("latest_commit_id"), req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
"content": "looks good", resp := session.MakeRequest(t, req, http.StatusOK)
"type": "comment", doc := NewHTMLParser(t, resp.Body)
var firstReviewID int64
{
// first (outdated) review
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"origin": doc.GetInputValueByName("origin"),
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"side": "proposed",
"line": "2",
"path": "iso-8859-1.txt",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "looks good",
"type": "comment",
})
session.MakeRequest(t, req, http.StatusOK)
// retrieve comment_id by reloading the comment page
req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp = session.MakeRequest(t, req, http.StatusOK)
doc = NewHTMLParser(t, resp.Body)
commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id")
assert.True(t, ok)
// adjust the database to mark the comment as invalidated
// (to invalidate it properly, one should push a commit which should trigger this logic,
// in the meantime, use this quick-and-dirty trick)
comment := loadComment(t, commentID)
assert.NoError(t, issues.UpdateCommentInvalidate(context.Background(), &issues.Comment{
ID: comment.ID,
Invalidated: true,
}))
firstReviewID = comment.ReviewID
assert.NotZero(t, firstReviewID)
}
// ID of the first comment for the second (up-to-date) review
var commentID string
{
// second (up-to-date) review on the same line
// make a second review
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"origin": doc.GetInputValueByName("origin"),
"latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
"side": "proposed",
"line": "2",
"path": "iso-8859-1.txt",
"diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
"diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
"diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
"content": "nitpicking comment",
"pending_review": "",
})
session.MakeRequest(t, req, http.StatusOK)
req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"commit_id": doc.GetInputValueByName("latest_commit_id"),
"content": "looks better",
"type": "comment",
})
session.MakeRequest(t, req, http.StatusOK)
// retrieve comment_id by reloading the comment page
req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
resp = session.MakeRequest(t, req, http.StatusOK)
doc = NewHTMLParser(t, resp.Body)
commentIDs := doc.Find(`[data-action="Resolve"]`).Map(func(i int, elt *goquery.Selection) string {
v, _ := elt.Attr("data-comment-id")
return v
})
assert.Len(t, commentIDs, 2) // 1 for the outdated review, 1 for the current review
// check that the first comment is for the previous review
comment := loadComment(t, commentIDs[0])
assert.Equal(t, comment.ReviewID, firstReviewID)
// check that the second comment is for a different review
comment = loadComment(t, commentIDs[1])
assert.NotZero(t, comment.ReviewID)
assert.NotEqual(t, comment.ReviewID, firstReviewID)
commentID = commentIDs[1] // save commentID for later
}
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"origin": "timeline",
"action": "Resolve",
"comment_id": commentID,
})
resp = session.MakeRequest(t, req, http.StatusOK)
// even on template error, the page returns HTTP 200
// count the comments to ensure success.
doc = NewHTMLParser(t, resp.Body)
comments := doc.Find(`.comments > .comment`)
assert.Len(t, comments.Nodes, 1) // the outdated comment belongs to another review and should not be shown
}) })
session.MakeRequest(t, req, http.StatusOK)
// retrieve comment_id by reloading the comment page t.Run("Files Changed tab", func(t *testing.T) {
req = NewRequest(t, "GET", "/user2/repo1/pulls/3") defer tests.PrintCurrentTest(t)()
resp = session.MakeRequest(t, req, http.StatusOK) for _, c := range []struct {
doc = NewHTMLParser(t, resp.Body) style, outdated string
commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id") expectedCount int
assert.True(t, ok) }{
{"unified", "true", 3}, // 1 comment on line 1 + 2 comments on line 3
{"unified", "false", 1}, // 1 comment on line 3 is not outdated
{"split", "true", 3}, // 1 comment on line 1 + 2 comments on line 3
{"split", "false", 1}, // 1 comment on line 3 is not outdated
} {
t.Run(c.style+"+"+c.outdated, func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files?style="+c.style+"&show-outdated="+c.outdated)
resp := session.MakeRequest(t, req, http.StatusOK)
// adjust the database to mark the comment as invalidated doc := NewHTMLParser(t, resp.Body)
// (to invalidate it properly, one should push a commit which should trigger this logic, comments := doc.Find(`.comments > .comment`)
// in the meantime, use this quick-and-dirty trick) assert.Len(t, comments.Nodes, c.expectedCount)
id, err := strconv.ParseInt(commentID, 10, 64) })
assert.NoError(t, err) }
assert.NoError(t, issues.UpdateCommentInvalidate(context.Background(), &issues.Comment{
ID: id,
Invalidated: true,
}))
req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
"_csrf": doc.GetInputValueByName("_csrf"),
"origin": "timeline",
"action": "Resolve",
"comment_id": commentID,
}) })
resp = session.MakeRequest(t, req, http.StatusOK)
// even on template error, the page returns HTTP 200 t.Run("Conversation tab", func(t *testing.T) {
// search the button to mark the comment as unresolved to ensure success. defer tests.PrintCurrentTest(t)()
doc = NewHTMLParser(t, resp.Body) req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
assert.Len(t, doc.Find(`[data-action="UnResolve"][data-comment-id="`+commentID+`"]`).Nodes, 1) resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
comments := doc.Find(`.comments > .comment`)
assert.Len(t, comments.Nodes, 3) // 1 comment on line 1 + 2 comments on line 3
})
} }