Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
17be181
feat(diff): Enable commenting on expanded lines in PR diffs
brymut Oct 14, 2025
1b9ac2c
feat(gitdiff): Use generatePatchForUnchangedLine instead to generate …
brymut Oct 16, 2025
8216008
fix(PR): Add permission check
brymut Oct 16, 2025
30e3fd1
fix(diff): update hidden comment count on code expand buttons
brymut Oct 16, 2025
b234f31
feat(gitdiff): fix code context in conversation tab
brymut Oct 16, 2025
4b85c50
fix(gitdiff): remove patching logic duplication
brymut Oct 17, 2025
2c33c7d
feat(diff): Enhance hidden comment expansion when sharing link
brymut Oct 18, 2025
19ed046
fix: highlighting to shared hidden comments
brymut Oct 18, 2025
3e0c892
test(diff): add tests for helper functions in ExcerptBlob
brymut Oct 18, 2025
aafa231
refactor(gitdiff): Extract hidden comment calculation to shared metho…
brymut Oct 18, 2025
fd2fc61
clean up code-expander-button
wxiaoguang Oct 18, 2025
a95ebbb
fix
wxiaoguang Oct 18, 2025
38d40a6
fix tmpl var
wxiaoguang Oct 18, 2025
6408c43
add missing comment
wxiaoguang Oct 18, 2025
bbd4799
fine tune tests
wxiaoguang Oct 18, 2025
c0b8201
remove unnecessary code
wxiaoguang Oct 18, 2025
590df91
fix: handle righthunksize == 0 scenario
brymut Oct 18, 2025
d1fc0d7
fine tune
wxiaoguang Oct 19, 2025
9356dbe
fix: remove reduntant test
brymut Oct 19, 2025
78cd2ce
fix(gitdiff): fix line number handling in `FillHiddenCommentIDsForDif…
brymut Oct 19, 2025
bb714b4
fine tune
wxiaoguang Oct 19, 2025
c5c1c2b
Merge branch 'main' into enable-commenting-unchanged-line
wxiaoguang Oct 19, 2025
af864c2
fix assumption
wxiaoguang Oct 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions routers/web/repo/compare.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"sort"
"strings"

"code.gitea.io/gitea/models/db"
Expand Down Expand Up @@ -43,6 +44,7 @@ import (
"code.gitea.io/gitea/services/context/upload"
"code.gitea.io/gitea/services/gitdiff"
pull_service "code.gitea.io/gitea/services/pull"
user_service "code.gitea.io/gitea/services/user"
)

const (
Expand Down Expand Up @@ -947,6 +949,67 @@ func ExcerptBlob(ctx *context.Context) {
section.Lines = append(section.Lines, lineSection)
}
}
if ctx.Doer != nil {
ctx.Data["SignedUserID"] = ctx.Doer.ID
}
isPull := ctx.FormBool("is_pull")
ctx.Data["PageIsPullFiles"] = isPull

if isPull {
if !ctx.Repo.CanRead(unit.TypePullRequests) {
ctx.NotFound(nil)
return
}
issueIndex := ctx.FormInt64("issue_index")
ctx.Data["IssueIndex"] = issueIndex
if issueIndex > 0 {
if issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex); err == nil && issue.IsPull {
ctx.Data["Issue"] = issue
ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool {
return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee)
}

if allComments, err := issues_model.FetchCodeComments(ctx, issue, ctx.Doer, ctx.FormBool("show_outdated")); err == nil {
if lineComments, ok := allComments[filePath]; ok {
for _, line := range section.Lines {
if comments, ok := lineComments[int64(line.LeftIdx*-1)]; ok {
line.Comments = append(line.Comments, comments...)
}
if comments, ok := lineComments[int64(line.RightIdx)]; ok {
line.Comments = append(line.Comments, comments...)
}
sort.SliceStable(line.Comments, func(i, j int) bool {
return line.Comments[i].CreatedUnix < line.Comments[j].CreatedUnix
})
}

// Recalculate hidden comment counts for expand buttons after loading comments
for _, line := range section.Lines {
if line.Type == gitdiff.DiffLineSection && line.SectionInfo != nil {
hiddenCommentCount := 0
// Check if there are comments in the hidden range
for commentLineNum := range lineComments {
absLineNum := commentLineNum
if absLineNum < 0 {
absLineNum = -absLineNum
}
// Count comments that are in the hidden range
if int(absLineNum) > line.SectionInfo.LastRightIdx && int(absLineNum) < line.SectionInfo.RightIdx {
hiddenCommentCount++
}
}
if hiddenCommentCount > 0 {
line.HasHiddenComments = true
line.HiddenCommentCount = hiddenCommentCount
}
}
}
}
}
}
}
}

ctx.Data["section"] = section
ctx.Data["FileNameHash"] = git.HashFilePathForWebUI(filePath)
ctx.Data["AfterCommitID"] = commitID
Expand Down
133 changes: 125 additions & 8 deletions services/gitdiff/gitdiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,15 @@ const (

// DiffLine represents a line difference in a DiffSection.
type DiffLine struct {
LeftIdx int // line number, 1-based
RightIdx int // line number, 1-based
Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side
Type DiffLineType
Content string
Comments issues_model.CommentList // related PR code comments
SectionInfo *DiffLineSectionInfo
LeftIdx int // line number, 1-based
RightIdx int // line number, 1-based
Match int // the diff matched index. -1: no match. 0: plain and no need to match. >0: for add/del, "Lines" slice index of the other side
Type DiffLineType
Content string
Comments issues_model.CommentList // related PR code comments
SectionInfo *DiffLineSectionInfo
HasHiddenComments bool // indicates if this expand button has comments in hidden lines
HiddenCommentCount int // number of hidden comments in this section
}

// DiffLineSectionInfo represents diff line section meta data
Expand Down Expand Up @@ -485,6 +487,25 @@ 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
if line.Type == DiffLineSection && line.SectionInfo != nil {
hiddenCommentCount := 0
// Check if there are comments in the hidden range
for commentLineNum := range lineCommits {
absLineNum := int(commentLineNum)
if commentLineNum < 0 {
absLineNum = int(-commentLineNum)
}
if absLineNum > line.SectionInfo.LastRightIdx && absLineNum < line.SectionInfo.RightIdx {
hiddenCommentCount++
}
}
if hiddenCommentCount > 0 {
line.HasHiddenComments = true
line.HiddenCommentCount = hiddenCommentCount
}
}
}
}
}
Expand Down Expand Up @@ -1370,10 +1391,15 @@ outer:

// CommentAsDiff returns c.Patch as *Diff
func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error) {
// If patch is empty, generate it
if c.Patch == "" && c.TreePath != "" && c.CommitSHA != "" && c.Line != 0 {
return generateCodeContextForComment(ctx, c)
}

diff, err := ParsePatch(ctx, setting.Git.MaxGitDiffLines,
setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(c.Patch), "")
if err != nil {
log.Error("Unable to parse patch: %v", err)
log.Error("Unable to parse patch for comment ID=%d: %v", c.ID, err)
return nil, err
}
if len(diff.Files) == 0 {
Expand All @@ -1386,6 +1412,97 @@ func CommentAsDiff(ctx context.Context, c *issues_model.Comment) (*Diff, error)
return diff, nil
}

// generateCodeContextForComment creates a synthetic diff showing code context around a comment
func generateCodeContextForComment(ctx context.Context, c *issues_model.Comment) (*Diff, error) {
if err := c.LoadIssue(ctx); err != nil {
return nil, fmt.Errorf("LoadIssue: %w", err)
}
if err := c.Issue.LoadRepo(ctx); err != nil {
return nil, fmt.Errorf("LoadRepo: %w", err)
}
if err := c.Issue.LoadPullRequest(ctx); err != nil {
return nil, fmt.Errorf("LoadPullRequest: %w", err)
}

pr := c.Issue.PullRequest
if err := pr.LoadBaseRepo(ctx); err != nil {
return nil, fmt.Errorf("LoadBaseRepo: %w", err)
}

gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
if err != nil {
return nil, fmt.Errorf("RepositoryFromContextOrOpen: %w", err)
}
defer closer.Close()

// Get the file content at the commit
commit, err := gitRepo.GetCommit(c.CommitSHA)
if err != nil {
return nil, fmt.Errorf("GetCommit: %w", err)
}

entry, err := commit.GetTreeEntryByPath(c.TreePath)
if err != nil {
return nil, fmt.Errorf("GetTreeEntryByPath: %w", err)
}

blob := entry.Blob()
dataRc, err := blob.DataAsync()
if err != nil {
return nil, fmt.Errorf("DataAsync: %w", err)
}
defer dataRc.Close()

// Calculate line range (commented line + lines above it)
commentLine := int(c.UnsignedLine())
contextLines := setting.UI.CodeCommentLines
startLine := max(commentLine-contextLines, 1)
endLine := commentLine

// Read only the needed lines efficiently
scanner := bufio.NewScanner(dataRc)
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 nil, fmt.Errorf("scanner error: %w", err)
}

if len(lines) == 0 {
return nil, 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", c.TreePath, c.TreePath))
patchBuilder.WriteString(fmt.Sprintf("--- a/%s\n", c.TreePath))
patchBuilder.WriteString(fmt.Sprintf("+++ b/%s\n", c.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")
}

// Parse the synthetic patch
diff, err := ParsePatch(ctx, setting.Git.MaxGitDiffLines,
setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(patchBuilder.String()), "")
if err != nil {
return nil, fmt.Errorf("ParsePatch: %w", err)
}

return diff, nil
}

// CommentMustAsDiff executes AsDiff and logs the error instead of returning
func CommentMustAsDiff(ctx context.Context, c *issues_model.Comment) *Diff {
if c == nil {
Expand Down
74 changes: 74 additions & 0 deletions services/pull/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package pull

import (
"bufio"
"context"
"errors"
"fmt"
Expand Down Expand Up @@ -197,6 +198,70 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.
return comment, 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()

// 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(dataRc)
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
}

// createCodeComment creates a plain code comment at the specified line / path
func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) {
var commitID, patch string
Expand Down Expand Up @@ -283,6 +348,15 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo
log.Error("Error whilst generating patch: %v", err)
return nil, err
}

// If patch is still empty (unchanged line), generate code context
if len(patch) == 0 && len(commitID) > 0 {
patch, err = generatePatchForUnchangedLine(gitRepo, commitID, treePath, line, setting.UI.CodeCommentLines)
if err != nil {
log.Warn("Error generating patch for unchanged line: %v", err)
// Don't fail comment creation, just leave patch empty
}
}
}
return issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
Type: issues_model.CommentTypeCode,
Expand Down
Loading