23dc781908
CI / test (pull_request) Successful in 28s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 27s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 44s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m49s
BuildPositionToLineMap incremented position and updated maxPositions for @@ hunk-header lines but did not store a map entry, causing Translate() to return a hard error for any comment positioned at a hunk header. Store sentinel value 0 for hunk-header positions (analogous to -1 for deletions) and extend Translate() to fall through to the nearest context/addition line below, matching the existing deletion-line behavior. Fixes #97
198 lines
5.7 KiB
Go
198 lines
5.7 KiB
Go
package gitea
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// PositionMap holds a per-file mapping of GitHub diff-position to new-file line number.
|
|
// Position is a 1-indexed offset from the @@ hunk header line in the unified diff.
|
|
type PositionMap struct {
|
|
// files maps filename → (position → new-file line number).
|
|
// Deletion lines are mapped to -1 (no new-file line).
|
|
// Hunk-header lines are mapped to 0 (no new-file line).
|
|
files map[string]map[int]int
|
|
// maxPositions caches the highest position number per file,
|
|
// tracked during construction to avoid O(n) scans at translate time.
|
|
maxPositions map[string]int
|
|
}
|
|
|
|
// Translate converts a GitHub diff-position to a new-file line number for a given file.
|
|
// Returns an error if the file is not in the diff or the position is out of range.
|
|
// If the position targets a deletion or hunk-header line, it maps to the nearest
|
|
// context/addition line below; if no such line exists, returns an error.
|
|
func (pm *PositionMap) Translate(file string, position int) (int, error) {
|
|
if pm == nil || pm.files == nil {
|
|
return 0, fmt.Errorf("empty position map")
|
|
}
|
|
|
|
fileMap, ok := pm.files[file]
|
|
if !ok {
|
|
return 0, fmt.Errorf("file %q not found in diff", file)
|
|
}
|
|
|
|
if position < 1 {
|
|
return 0, fmt.Errorf("position %d out of range (must be >= 1)", position)
|
|
}
|
|
|
|
lineNum, ok := fileMap[position]
|
|
if !ok {
|
|
return 0, fmt.Errorf("position %d out of range for file %q", position, file)
|
|
}
|
|
|
|
// lineNum == -1 means this position is a deletion line.
|
|
// lineNum == 0 means this position is a hunk-header line.
|
|
// Both map to the nearest context/addition line below.
|
|
if lineNum <= 0 {
|
|
maxPos := pm.maxPosition(file)
|
|
for p := position + 1; p <= maxPos; p++ {
|
|
if ln, exists := fileMap[p]; exists && ln > 0 {
|
|
return ln, nil
|
|
}
|
|
}
|
|
if lineNum == 0 {
|
|
return 0, fmt.Errorf("position %d targets a hunk-header line with no subsequent new-file line in %q", position, file)
|
|
}
|
|
return 0, fmt.Errorf("position %d targets a deletion line with no subsequent new-file line in %q", position, file)
|
|
}
|
|
|
|
return lineNum, nil
|
|
}
|
|
|
|
// maxPosition returns the highest position number for a file.
|
|
// O(1) — the maximum is tracked during map construction.
|
|
func (pm *PositionMap) maxPosition(file string) int {
|
|
return pm.maxPositions[file]
|
|
}
|
|
|
|
// BuildPositionToLineMap parses a unified diff and builds a PositionMap
|
|
// mapping diff-position → new-file line number per file.
|
|
//
|
|
// Diff-position counting rules (GitHub spec):
|
|
// - The @@ hunk header line is position 1 for the file's first hunk
|
|
// - Every subsequent line increments position by 1 — context, additions, AND deletions
|
|
// - A new @@ hunk within the same file continues incrementing (does not reset)
|
|
// - Position maps to the new file line number for additions and context lines
|
|
// - Deletion lines have a position but no new-file line number (stored as -1)
|
|
// - Hunk-header lines have a position but no new-file line number (stored as 0)
|
|
func BuildPositionToLineMap(diff string) *PositionMap {
|
|
pm := &PositionMap{
|
|
files: make(map[string]map[int]int),
|
|
maxPositions: make(map[string]int),
|
|
}
|
|
|
|
lines := strings.Split(diff, "\n")
|
|
var currentFile string
|
|
var position int
|
|
var newLine int
|
|
|
|
for _, line := range lines {
|
|
// Detect new file in diff.
|
|
// "+++ b/" is checked before "+++ /dev/null" — the two prefixes are
|
|
// non-overlapping ("+++ /dev/null" does not start with "+++ b/"), so
|
|
// ordering is independent. Checking the common case first for clarity.
|
|
if strings.HasPrefix(line, "+++ b/") {
|
|
currentFile = strings.TrimPrefix(line, "+++ b/")
|
|
position = 0
|
|
newLine = 0
|
|
if pm.files[currentFile] == nil {
|
|
pm.files[currentFile] = make(map[int]int)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Deleted file: +++ /dev/null means the file is being deleted
|
|
if strings.HasPrefix(line, "+++ /dev/null") {
|
|
currentFile = ""
|
|
continue
|
|
}
|
|
|
|
// Skip --- lines (old file header)
|
|
if strings.HasPrefix(line, "--- ") {
|
|
continue
|
|
}
|
|
|
|
// Skip diff --git lines
|
|
if strings.HasPrefix(line, "diff --git") {
|
|
continue
|
|
}
|
|
|
|
// Skip index lines
|
|
if strings.HasPrefix(line, "index ") {
|
|
continue
|
|
}
|
|
|
|
// Binary file detection
|
|
if strings.HasPrefix(line, "Binary files") {
|
|
currentFile = ""
|
|
continue
|
|
}
|
|
|
|
// Parse hunk headers
|
|
if strings.HasPrefix(line, "@@") && currentFile != "" {
|
|
position++
|
|
pm.files[currentFile][position] = 0 // sentinel: hunk-header has no new-file line
|
|
pm.maxPositions[currentFile] = position
|
|
newLine = parseHunkStart(line)
|
|
continue
|
|
}
|
|
|
|
if currentFile == "" {
|
|
continue
|
|
}
|
|
|
|
// Skip "\ No newline at end of file" markers
|
|
if strings.HasPrefix(line, `\`) {
|
|
continue
|
|
}
|
|
|
|
// Process diff content lines
|
|
if strings.HasPrefix(line, "+") {
|
|
// Addition: has a new-file line number
|
|
position++
|
|
pm.files[currentFile][position] = newLine
|
|
pm.maxPositions[currentFile] = position
|
|
newLine++
|
|
} else if strings.HasPrefix(line, "-") {
|
|
// Deletion: has a position but no new-file line number
|
|
position++
|
|
pm.files[currentFile][position] = -1
|
|
pm.maxPositions[currentFile] = position
|
|
} else if strings.HasPrefix(line, " ") {
|
|
// Context line
|
|
position++
|
|
pm.files[currentFile][position] = newLine
|
|
pm.maxPositions[currentFile] = position
|
|
newLine++
|
|
}
|
|
}
|
|
|
|
return pm
|
|
}
|
|
|
|
// parseHunkStart extracts the new-file starting line number from a hunk header.
|
|
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@
|
|
func parseHunkStart(hunkLine string) int {
|
|
plusIdx := strings.Index(hunkLine, "+")
|
|
if plusIdx < 0 {
|
|
return 1
|
|
}
|
|
rest := hunkLine[plusIdx+1:]
|
|
|
|
endIdx := 0
|
|
for endIdx < len(rest) && rest[endIdx] >= '0' && rest[endIdx] <= '9' {
|
|
endIdx++
|
|
}
|
|
|
|
if endIdx == 0 {
|
|
return 1
|
|
}
|
|
|
|
n, err := strconv.Atoi(rest[:endIdx])
|
|
if err != nil {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|