1
1
mirror of https://github.com/go-gitea/gitea synced 2025-10-30 19:08:37 +00:00

feat(diff): Enable commenting on expanded lines in PR diffs (#35662)

Fixes #32257 
/claim #32257

Implemented commenting on unchanged lines in Pull Request diffs, lines
are accessed by expanding the diff preview. Comments also appear in the
"Files Changed" tab on the unchanged lines where they were placed.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Bryan Mutai
2025-10-19 13:19:12 +03:00
committed by GitHub
parent 2d36a0c9ff
commit c30d74d0f9
13 changed files with 753 additions and 138 deletions

View File

@@ -22,19 +22,21 @@ import (
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
pull_model "code.gitea.io/gitea/models/pull"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/analyze"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/git/attribute"
"code.gitea.io/gitea/modules/git/gitcmd"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/lfs"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
@@ -67,18 +69,6 @@ const (
DiffFileCopy
)
// DiffLineExpandDirection represents the DiffLineSection expand direction
type DiffLineExpandDirection uint8
// DiffLineExpandDirection possible values.
const (
DiffLineExpandNone DiffLineExpandDirection = iota + 1
DiffLineExpandSingle
DiffLineExpandUpDown
DiffLineExpandUp
DiffLineExpandDown
)
// DiffLine represents a line difference in a DiffSection.
type DiffLine struct {
LeftIdx int // line number, 1-based
@@ -99,6 +89,8 @@ type DiffLineSectionInfo struct {
RightIdx int
LeftHunkSize int
RightHunkSize int
HiddenCommentIDs []int64 // IDs of hidden comments in this section
}
// DiffHTMLOperation is the HTML version of diffmatchpatch.Diff
@@ -153,8 +145,7 @@ func (d *DiffLine) GetLineTypeMarker() string {
return ""
}
// GetBlobExcerptQuery builds query string to get blob excerpt
func (d *DiffLine) GetBlobExcerptQuery() string {
func (d *DiffLine) getBlobExcerptQuery() string {
query := fmt.Sprintf(
"last_left=%d&last_right=%d&"+
"left=%d&right=%d&"+
@@ -167,19 +158,88 @@ func (d *DiffLine) GetBlobExcerptQuery() string {
return query
}
// GetExpandDirection gets DiffLineExpandDirection
func (d *DiffLine) GetExpandDirection() DiffLineExpandDirection {
func (d *DiffLine) getExpandDirection() string {
if d.Type != DiffLineSection || d.SectionInfo == nil || d.SectionInfo.LeftIdx-d.SectionInfo.LastLeftIdx <= 1 || d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx <= 1 {
return DiffLineExpandNone
return ""
}
if d.SectionInfo.LastLeftIdx <= 0 && d.SectionInfo.LastRightIdx <= 0 {
return DiffLineExpandUp
return "up"
} else if d.SectionInfo.RightIdx-d.SectionInfo.LastRightIdx > BlobExcerptChunkSize && d.SectionInfo.RightHunkSize > 0 {
return DiffLineExpandUpDown
return "updown"
} else if d.SectionInfo.LeftHunkSize <= 0 && d.SectionInfo.RightHunkSize <= 0 {
return DiffLineExpandDown
return "down"
}
return DiffLineExpandSingle
return "single"
}
type DiffBlobExcerptData struct {
BaseLink string
IsWikiRepo bool
PullIssueIndex int64
DiffStyle string
AfterCommitID string
}
func (d *DiffLine) RenderBlobExcerptButtons(fileNameHash string, data *DiffBlobExcerptData) template.HTML {
dataHiddenCommentIDs := strings.Join(base.Int64sToStrings(d.SectionInfo.HiddenCommentIDs), ",")
anchor := fmt.Sprintf("diff-%sK%d", fileNameHash, d.SectionInfo.RightIdx)
makeButton := func(direction, svgName string) template.HTML {
style := util.IfZero(data.DiffStyle, "unified")
link := data.BaseLink + "/" + data.AfterCommitID + fmt.Sprintf("?style=%s&direction=%s&anchor=%s", url.QueryEscape(style), direction, url.QueryEscape(anchor)) + "&" + d.getBlobExcerptQuery()
if data.PullIssueIndex > 0 {
link += fmt.Sprintf("&pull_issue_index=%d", data.PullIssueIndex)
}
return htmlutil.HTMLFormat(
`<button class="code-expander-button" hx-target="closest tr" hx-get="%s" data-hidden-comment-ids=",%s,">%s</button>`,
link, dataHiddenCommentIDs, svg.RenderHTML(svgName),
)
}
var content template.HTML
if len(d.SectionInfo.HiddenCommentIDs) > 0 {
tooltip := fmt.Sprintf("%d hidden comment(s)", len(d.SectionInfo.HiddenCommentIDs))
content += htmlutil.HTMLFormat(`<span class="code-comment-more" data-tooltip-content="%s">%d</span>`, tooltip, len(d.SectionInfo.HiddenCommentIDs))
}
expandDirection := d.getExpandDirection()
if expandDirection == "up" || expandDirection == "updown" {
content += makeButton("up", "octicon-fold-up")
}
if expandDirection == "updown" || expandDirection == "down" {
content += makeButton("down", "octicon-fold-down")
}
if expandDirection == "single" {
content += makeButton("single", "octicon-fold")
}
return htmlutil.HTMLFormat(`<div class="code-expander-buttons" data-expand-direction="%s">%s</div>`, expandDirection, content)
}
// FillHiddenCommentIDsForDiffLine finds comment IDs that are in the hidden range of an expand button
func FillHiddenCommentIDsForDiffLine(line *DiffLine, lineComments map[int64][]*issues_model.Comment) {
if line.Type != DiffLineSection {
return
}
var hiddenCommentIDs []int64
for commentLineNum, comments := range lineComments {
if commentLineNum < 0 {
// ATTENTION: BLOB-EXCERPT-COMMENT-RIGHT: skip left-side, unchanged lines always use "right (proposed)" side for comments
continue
}
lineNum := int(commentLineNum)
isEndOfFileExpansion := line.SectionInfo.RightHunkSize == 0
inRange := lineNum > line.SectionInfo.LastRightIdx &&
(isEndOfFileExpansion && lineNum <= line.SectionInfo.RightIdx ||
!isEndOfFileExpansion && lineNum < line.SectionInfo.RightIdx)
if inRange {
for _, comment := range comments {
hiddenCommentIDs = append(hiddenCommentIDs, comment.ID)
}
}
}
line.SectionInfo.HiddenCommentIDs = hiddenCommentIDs
}
func getDiffLineSectionInfo(treePath, line string, lastLeftIdx, lastRightIdx int) *DiffLineSectionInfo {
@@ -485,6 +545,8 @@ func (diff *Diff) LoadComments(ctx context.Context, issue *issues_model.Issue, c
sort.SliceStable(line.Comments, func(i, j int) bool {
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
})
// Mark expand buttons that have comments in hidden lines
FillHiddenCommentIDsForDiffLine(line, lineCommits)
}
}
}
@@ -1281,7 +1343,7 @@ type DiffShortStat struct {
NumFiles, TotalAddition, TotalDeletion int
}
func GetDiffShortStat(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, beforeCommitID, afterCommitID string) (*DiffShortStat, error) {
func GetDiffShortStat(ctx context.Context, repoStorage gitrepo.Repository, gitRepo *git.Repository, beforeCommitID, afterCommitID string) (*DiffShortStat, error) {
afterCommit, err := gitRepo.GetCommit(afterCommitID)
if err != nil {
return nil, err
@@ -1293,7 +1355,7 @@ func GetDiffShortStat(ctx context.Context, repo *repo_model.Repository, gitRepo
}
diff := &DiffShortStat{}
diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = gitrepo.GetDiffShortStatByCmdArgs(ctx, repo, nil, actualBeforeCommitID.String(), afterCommitID)
diff.NumFiles, diff.TotalAddition, diff.TotalDeletion, err = gitrepo.GetDiffShortStatByCmdArgs(ctx, repoStorage, nil, actualBeforeCommitID.String(), afterCommitID)
if err != nil {
return nil, err
}
@@ -1386,6 +1448,75 @@ func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error)
return diff, nil
}
// GeneratePatchForUnchangedLine creates a patch showing code context for an unchanged line
func GeneratePatchForUnchangedLine(gitRepo *git.Repository, commitID, treePath string, line int64, contextLines int) (string, error) {
commit, err := gitRepo.GetCommit(commitID)
if err != nil {
return "", fmt.Errorf("GetCommit: %w", err)
}
entry, err := commit.GetTreeEntryByPath(treePath)
if err != nil {
return "", fmt.Errorf("GetTreeEntryByPath: %w", err)
}
blob := entry.Blob()
dataRc, err := blob.DataAsync()
if err != nil {
return "", fmt.Errorf("DataAsync: %w", err)
}
defer dataRc.Close()
return generatePatchForUnchangedLineFromReader(dataRc, treePath, line, contextLines)
}
// generatePatchForUnchangedLineFromReader is the testable core logic that generates a patch from a reader
func generatePatchForUnchangedLineFromReader(reader io.Reader, treePath string, line int64, contextLines int) (string, error) {
// Calculate line range (commented line + lines above it)
commentLine := int(line)
if line < 0 {
commentLine = int(-line)
}
startLine := max(commentLine-contextLines, 1)
endLine := commentLine
// Read only the needed lines efficiently
scanner := bufio.NewScanner(reader)
currentLine := 0
var lines []string
for scanner.Scan() {
currentLine++
if currentLine >= startLine && currentLine <= endLine {
lines = append(lines, scanner.Text())
}
if currentLine > endLine {
break
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("scanner error: %w", err)
}
if len(lines) == 0 {
return "", fmt.Errorf("no lines found in range %d-%d", startLine, endLine)
}
// Generate synthetic patch
var patchBuilder strings.Builder
patchBuilder.WriteString(fmt.Sprintf("diff --git a/%s b/%s\n", treePath, treePath))
patchBuilder.WriteString(fmt.Sprintf("--- a/%s\n", treePath))
patchBuilder.WriteString(fmt.Sprintf("+++ b/%s\n", treePath))
patchBuilder.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", startLine, len(lines), startLine, len(lines)))
for _, lineContent := range lines {
patchBuilder.WriteString(" ")
patchBuilder.WriteString(lineContent)
patchBuilder.WriteString("\n")
}
return patchBuilder.String(), nil
}
// CommentMustAsDiff executes AsDiff and logs the error instead of returning
func CommentMustAsDiff(ctx context.Context, c *issues_model.Comment) *Diff {
if c == nil {

View File

@@ -640,3 +640,346 @@ func TestNoCrashes(t *testing.T) {
ParsePatch(t.Context(), setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "")
}
}
func TestGeneratePatchForUnchangedLineFromReader(t *testing.T) {
tests := []struct {
name string
content string
treePath string
line int64
contextLines int
want string
wantErr bool
}{
{
name: "single line with context",
content: "line1\nline2\nline3\nline4\nline5\n",
treePath: "test.txt",
line: 3,
contextLines: 1,
want: `diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -2,2 +2,2 @@
line2
line3
`,
},
{
name: "negative line number (left side)",
content: "line1\nline2\nline3\nline4\nline5\n",
treePath: "test.txt",
line: -3,
contextLines: 1,
want: `diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -2,2 +2,2 @@
line2
line3
`,
},
{
name: "line near start of file",
content: "line1\nline2\nline3\n",
treePath: "test.txt",
line: 2,
contextLines: 5,
want: `diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -1,2 +1,2 @@
line1
line2
`,
},
{
name: "first line with context",
content: "line1\nline2\nline3\n",
treePath: "test.txt",
line: 1,
contextLines: 3,
want: `diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -1,1 +1,1 @@
line1
`,
},
{
name: "zero context lines",
content: "line1\nline2\nline3\n",
treePath: "test.txt",
line: 2,
contextLines: 0,
want: `diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -2,1 +2,1 @@
line2
`,
},
{
name: "multi-line context",
content: "package main\n\nfunc main() {\n fmt.Println(\"Hello\")\n}\n",
treePath: "main.go",
line: 4,
contextLines: 2,
want: `diff --git a/main.go b/main.go
--- a/main.go
+++ b/main.go
@@ -2,3 +2,3 @@
<SP>
func main() {
fmt.Println("Hello")
`,
},
{
name: "empty file",
content: "",
treePath: "empty.txt",
line: 1,
contextLines: 1,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.content)
got, err := generatePatchForUnchangedLineFromReader(reader, tt.treePath, tt.line, tt.contextLines)
if tt.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, strings.ReplaceAll(tt.want, "<SP>", " "), got)
}
})
}
}
func TestCalculateHiddenCommentIDsForLine(t *testing.T) {
tests := []struct {
name string
line *DiffLine
lineComments map[int64][]*issues_model.Comment
expected []int64
}{
{
name: "comments in hidden range",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 10,
RightIdx: 20,
},
},
lineComments: map[int64][]*issues_model.Comment{
15: {{ID: 100}, {ID: 101}},
12: {{ID: 102}},
},
expected: []int64{100, 101, 102},
},
{
name: "comments outside hidden range",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 10,
RightIdx: 20,
},
},
lineComments: map[int64][]*issues_model.Comment{
5: {{ID: 100}},
25: {{ID: 101}},
},
expected: nil,
},
{
name: "negative line numbers (left side)",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 10,
RightIdx: 20,
},
},
lineComments: map[int64][]*issues_model.Comment{
-15: {{ID: 100}}, // Left-side comment, should NOT be counted
15: {{ID: 101}}, // Right-side comment, should be counted
},
expected: []int64{101}, // Only right-side comment
},
{
name: "boundary conditions - normal expansion (both boundaries exclusive)",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 10,
RightIdx: 20,
RightHunkSize: 5, // Normal case: next section has content
},
},
lineComments: map[int64][]*issues_model.Comment{
10: {{ID: 100}}, // at LastRightIdx (visible line), should NOT be included
20: {{ID: 101}}, // at RightIdx (visible line), should NOT be included
11: {{ID: 102}}, // just inside range, should be included
19: {{ID: 103}}, // just inside range, should be included
},
expected: []int64{102, 103},
},
{
name: "boundary conditions - end of file expansion (RightIdx inclusive)",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 54,
RightIdx: 70,
RightHunkSize: 0, // End of file: no more content after
},
},
lineComments: map[int64][]*issues_model.Comment{
54: {{ID: 54}}, // at LastRightIdx (visible line), should NOT be included
70: {{ID: 70}}, // at RightIdx (last hidden line), SHOULD be included
60: {{ID: 60}}, // inside range, should be included
},
expected: []int64{60, 70}, // Lines 60 and 70 are hidden
},
{
name: "real-world scenario - start of file with hunk",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 0, // No previous visible section
RightIdx: 26, // Line 26 is first visible line of hunk
RightHunkSize: 9, // Normal hunk with content
},
},
lineComments: map[int64][]*issues_model.Comment{
1: {{ID: 1}}, // Line 1 is hidden
26: {{ID: 26}}, // Line 26 is visible (hunk start) - should NOT be hidden
10: {{ID: 10}}, // Line 10 is hidden
15: {{ID: 15}}, // Line 15 is hidden
},
expected: []int64{1, 10, 15}, // Lines 1, 10, 15 are hidden; line 26 is visible
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
FillHiddenCommentIDsForDiffLine(tt.line, tt.lineComments)
assert.ElementsMatch(t, tt.expected, tt.line.SectionInfo.HiddenCommentIDs)
})
}
}
func TestDiffLine_RenderBlobExcerptButtons(t *testing.T) {
tests := []struct {
name string
line *DiffLine
fileNameHash string
data *DiffBlobExcerptData
expectContains []string
expectNotContain []string
}{
{
name: "expand up button with hidden comments",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 0,
RightIdx: 26,
LeftIdx: 26,
LastLeftIdx: 0,
LeftHunkSize: 0,
RightHunkSize: 0,
HiddenCommentIDs: []int64{100},
},
},
fileNameHash: "abc123",
data: &DiffBlobExcerptData{
BaseLink: "/repo/blob_excerpt",
AfterCommitID: "commit123",
DiffStyle: "unified",
},
expectContains: []string{
"octicon-fold-up",
"direction=up",
"code-comment-more",
"1 hidden comment(s)",
},
},
{
name: "expand up and down buttons with pull request",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 10,
RightIdx: 50,
LeftIdx: 10,
LastLeftIdx: 5,
LeftHunkSize: 5,
RightHunkSize: 5,
HiddenCommentIDs: []int64{200, 201},
},
},
fileNameHash: "def456",
data: &DiffBlobExcerptData{
BaseLink: "/repo/blob_excerpt",
AfterCommitID: "commit456",
DiffStyle: "split",
PullIssueIndex: 42,
},
expectContains: []string{
"octicon-fold-down",
"octicon-fold-up",
"direction=down",
"direction=up",
`data-hidden-comment-ids=",200,201,"`, // use leading and trailing commas to ensure exact match by CSS selector `attr*=",id,"`
"pull_issue_index=42",
"2 hidden comment(s)",
},
},
{
name: "no hidden comments",
line: &DiffLine{
Type: DiffLineSection,
SectionInfo: &DiffLineSectionInfo{
LastRightIdx: 10,
RightIdx: 20,
LeftIdx: 10,
LastLeftIdx: 5,
LeftHunkSize: 5,
RightHunkSize: 5,
HiddenCommentIDs: nil,
},
},
fileNameHash: "ghi789",
data: &DiffBlobExcerptData{
BaseLink: "/repo/blob_excerpt",
AfterCommitID: "commit789",
},
expectContains: []string{
"code-expander-button",
},
expectNotContain: []string{
"code-comment-more",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.line.RenderBlobExcerptButtons(tt.fileNameHash, tt.data)
resultStr := string(result)
for _, expected := range tt.expectContains {
assert.Contains(t, resultStr, expected, "Expected to contain: %s", expected)
}
for _, notExpected := range tt.expectNotContain {
assert.NotContains(t, resultStr, notExpected, "Expected NOT to contain: %s", notExpected)
}
})
}
}