7c83365fc4
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m48s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m0s
- Create vcs/util.go with GetAllFilesInPath and BuildLineToPositionMap - Create vcs/util_test.go with comprehensive tests for both functions - Remove review.ContentEntry type, replace with vcs.ContentEntry - Remove review.GiteaClient interface, replace with vcs.FileReader - Update review/repo_persona.go to use vcs.FileReader - Update review/repo_persona_test.go to use vcs.ContentEntry - Update cmd/review-bot/main.go adapter to implement vcs.FileReader - Add Number and Base fields to vcs.PullRequest - Add CommitStatus type to vcs/types.go - Add GetFileContentAtRef to vcs.PRReader interface - Add GetCommitStatuses to vcs.PRReader interface - Add DismissReview to vcs.Reviewer interface - Add stub implementations on gitea.Client for new interface methods Closes #84, Closes #85, Closes #86
146 lines
3.4 KiB
Go
146 lines
3.4 KiB
Go
package vcs
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// GetAllFilesInPath recursively fetches all file contents under a path using the
|
|
// provided FileReader. Returns a map of filepath -> content for all files found.
|
|
// If the path points to an empty directory, returns an empty map.
|
|
func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path string) (map[string]string, error) {
|
|
results := make(map[string]string)
|
|
|
|
entries, err := client.ListContents(ctx, owner, repo, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list contents %q: %w", path, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
switch entry.Type {
|
|
case "file":
|
|
content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get file %q: %w", entry.Path, err)
|
|
}
|
|
results[entry.Path] = content
|
|
case "dir":
|
|
subResults, err := GetAllFilesInPath(ctx, client, owner, repo, entry.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recurse into %q: %w", entry.Path, err)
|
|
}
|
|
for k, v := range subResults {
|
|
results[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// BuildLineToPositionMap parses a unified diff and returns a map of
|
|
// filename -> (new line number -> diff position). The diff position is a
|
|
// 1-indexed offset from the @@ hunk header line for each file.
|
|
// Only lines that appear in the new file (context lines and additions) are mapped.
|
|
// Deletion-only lines are not included.
|
|
func BuildLineToPositionMap(diff string) map[string]map[int]int {
|
|
result := make(map[string]map[int]int)
|
|
|
|
lines := strings.Split(diff, "\n")
|
|
var currentFile string
|
|
var position int
|
|
var newLine int
|
|
|
|
for _, line := range lines {
|
|
// Detect new file in diff
|
|
if strings.HasPrefix(line, "+++ b/") {
|
|
currentFile = strings.TrimPrefix(line, "+++ b/")
|
|
position = 0
|
|
newLine = 0
|
|
if result[currentFile] == nil {
|
|
result[currentFile] = make(map[int]int)
|
|
}
|
|
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
|
|
}
|
|
|
|
// Parse hunk headers
|
|
if strings.HasPrefix(line, "@@") {
|
|
position++
|
|
// Extract new file start line from @@ -a,b +c,d @@
|
|
newLine = parseHunkNewStart(line)
|
|
continue
|
|
}
|
|
|
|
// We need a current file to map lines
|
|
if currentFile == "" {
|
|
continue
|
|
}
|
|
|
|
// Process diff content lines
|
|
if strings.HasPrefix(line, "+") {
|
|
position++
|
|
result[currentFile][newLine] = position
|
|
newLine++
|
|
} else if strings.HasPrefix(line, "-") {
|
|
position++
|
|
// Deletion lines don't map to new line numbers
|
|
} else if strings.HasPrefix(line, " ") {
|
|
// Context line (space-prefixed)
|
|
if position > 0 {
|
|
position++
|
|
result[currentFile][newLine] = position
|
|
newLine++
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// parseHunkNewStart extracts the new-file starting line number from a hunk header.
|
|
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@
|
|
func parseHunkNewStart(hunkLine string) int {
|
|
// Find the +N part
|
|
plusIdx := strings.Index(hunkLine, "+")
|
|
if plusIdx < 0 {
|
|
return 1
|
|
}
|
|
rest := hunkLine[plusIdx+1:]
|
|
|
|
// Read digits until comma or space
|
|
var numStr string
|
|
for _, ch := range rest {
|
|
if ch >= '0' && ch <= '9' {
|
|
numStr += string(ch)
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
if numStr == "" {
|
|
return 1
|
|
}
|
|
|
|
n := 0
|
|
for _, ch := range numStr {
|
|
n = n*10 + int(ch-'0')
|
|
}
|
|
return n
|
|
}
|