ec03dc2373
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 43s
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 1m44s
332 lines
10 KiB
Go
332 lines
10 KiB
Go
package vcs_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
)
|
|
|
|
// mockFileReader implements vcs.FileReader for testing.
|
|
type mockFileReader struct {
|
|
contents map[string][]vcs.ContentEntry // path -> entries
|
|
files map[string]string // path -> content
|
|
}
|
|
|
|
func (m *mockFileReader) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
|
content, ok := m.files[path]
|
|
if !ok {
|
|
return "", fmt.Errorf("HTTP 404: file not found: %s", path)
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
func (m *mockFileReader) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
|
|
entries, ok := m.contents[path]
|
|
if !ok {
|
|
return nil, fmt.Errorf("HTTP 404: path not found: %s", path)
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func TestGetAllFilesInPath(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("empty directory", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"src": {},
|
|
},
|
|
}
|
|
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 0 {
|
|
t.Errorf("expected empty map, got %d entries", len(result))
|
|
}
|
|
})
|
|
|
|
t.Run("flat directory", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"src": {
|
|
{Name: "main.go", Path: "src/main.go", Type: "file"},
|
|
{Name: "util.go", Path: "src/util.go", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
"src/main.go": "package main",
|
|
"src/util.go": "package main\n// util",
|
|
},
|
|
}
|
|
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 files, got %d", len(result))
|
|
}
|
|
if result["src/main.go"] != "package main" {
|
|
t.Errorf("main.go content = %q", result["src/main.go"])
|
|
}
|
|
if result["src/util.go"] != "package main\n// util" {
|
|
t.Errorf("util.go content = %q", result["src/util.go"])
|
|
}
|
|
})
|
|
|
|
t.Run("nested directories", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"src": {
|
|
{Name: "main.go", Path: "src/main.go", Type: "file"},
|
|
{Name: "pkg", Path: "src/pkg", Type: "dir"},
|
|
},
|
|
"src/pkg": {
|
|
{Name: "lib.go", Path: "src/pkg/lib.go", Type: "file"},
|
|
{Name: "sub", Path: "src/pkg/sub", Type: "dir"},
|
|
},
|
|
"src/pkg/sub": {
|
|
{Name: "deep.go", Path: "src/pkg/sub/deep.go", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
"src/main.go": "package main",
|
|
"src/pkg/lib.go": "package pkg",
|
|
"src/pkg/sub/deep.go": "package sub",
|
|
},
|
|
}
|
|
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 3 {
|
|
t.Fatalf("expected 3 files, got %d", len(result))
|
|
}
|
|
if result["src/main.go"] != "package main" {
|
|
t.Errorf("main.go content = %q", result["src/main.go"])
|
|
}
|
|
if result["src/pkg/lib.go"] != "package pkg" {
|
|
t.Errorf("lib.go content = %q", result["src/pkg/lib.go"])
|
|
}
|
|
if result["src/pkg/sub/deep.go"] != "package sub" {
|
|
t.Errorf("deep.go content = %q", result["src/pkg/sub/deep.go"])
|
|
}
|
|
})
|
|
|
|
t.Run("mixed files and dirs", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"root": {
|
|
{Name: "README.md", Path: "root/README.md", Type: "file"},
|
|
{Name: "docs", Path: "root/docs", Type: "dir"},
|
|
{Name: "config.yaml", Path: "root/config.yaml", Type: "file"},
|
|
},
|
|
"root/docs": {
|
|
{Name: "guide.md", Path: "root/docs/guide.md", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
"root/README.md": "# Hello",
|
|
"root/config.yaml": "key: value",
|
|
"root/docs/guide.md": "## Guide",
|
|
},
|
|
}
|
|
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "root")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result) != 3 {
|
|
t.Fatalf("expected 3 files, got %d", len(result))
|
|
}
|
|
if result["root/README.md"] != "# Hello" {
|
|
t.Errorf("README content = %q", result["root/README.md"])
|
|
}
|
|
if result["root/docs/guide.md"] != "## Guide" {
|
|
t.Errorf("guide content = %q", result["root/docs/guide.md"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBuildLineToPositionMap(t *testing.T) {
|
|
t.Run("single hunk", func(t *testing.T) {
|
|
diff := "diff --git a/file.go b/file.go\nindex abc..def 100644\n--- a/file.go\n+++ b/file.go\n@@ -1,3 +1,4 @@\n package main\n \n+// new comment\n func main() {}\n"
|
|
result := vcs.BuildLineToPositionMap(diff)
|
|
fileMap, ok := result["file.go"]
|
|
if !ok {
|
|
t.Fatal("expected file.go in result")
|
|
}
|
|
// Hunk header @@ is position 1
|
|
// Line 1: " package main" -> position 2
|
|
if fileMap[1] != 2 {
|
|
t.Errorf("line 1 position = %d, want 2", fileMap[1])
|
|
}
|
|
// Line 2: " " (context) -> position 3
|
|
if fileMap[2] != 3 {
|
|
t.Errorf("line 2 position = %d, want 3", fileMap[2])
|
|
}
|
|
// Line 3: "+// new comment" -> position 4
|
|
if fileMap[3] != 4 {
|
|
t.Errorf("line 3 position = %d, want 4", fileMap[3])
|
|
}
|
|
// Line 4: " func main() {}" -> position 5
|
|
if fileMap[4] != 5 {
|
|
t.Errorf("line 4 position = %d, want 5", fileMap[4])
|
|
}
|
|
})
|
|
|
|
t.Run("multi hunk", func(t *testing.T) {
|
|
diff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1,3 +1,3 @@\n package main\n \n-// old\n+// new\n@@ -10,3 +10,4 @@\n func foo() {\n+\t// added\n \treturn\n }\n"
|
|
result := vcs.BuildLineToPositionMap(diff)
|
|
fileMap, ok := result["file.go"]
|
|
if !ok {
|
|
t.Fatal("expected file.go in result")
|
|
}
|
|
// First hunk: @@ is position 1
|
|
// Line 1: " package main" -> position 2
|
|
if fileMap[1] != 2 {
|
|
t.Errorf("line 1 position = %d, want 2", fileMap[1])
|
|
}
|
|
// Line 3: "+// new" -> position 5 (after " ", "-// old" at pos 3,4)
|
|
if fileMap[3] != 5 {
|
|
t.Errorf("line 3 position = %d, want 5", fileMap[3])
|
|
}
|
|
// Second hunk: @@ is position 6
|
|
// Line 10: " func foo() {" -> position 7
|
|
if fileMap[10] != 7 {
|
|
t.Errorf("line 10 position = %d, want 7", fileMap[10])
|
|
}
|
|
// Line 11: "+\t// added" -> position 8
|
|
if fileMap[11] != 8 {
|
|
t.Errorf("line 11 position = %d, want 8", fileMap[11])
|
|
}
|
|
})
|
|
|
|
t.Run("deletion lines not in map", func(t *testing.T) {
|
|
diff := "diff --git a/file.go b/file.go\n--- a/file.go\n+++ b/file.go\n@@ -1,4 +1,3 @@\n package main\n \n-// deleted line\n func main() {}\n"
|
|
result := vcs.BuildLineToPositionMap(diff)
|
|
fileMap, ok := result["file.go"]
|
|
if !ok {
|
|
t.Fatal("expected file.go in result")
|
|
}
|
|
// Line 1: " package main" -> position 2
|
|
if fileMap[1] != 2 {
|
|
t.Errorf("line 1 position = %d, want 2", fileMap[1])
|
|
}
|
|
// Line 3 in new file: " func main() {}" -> position 5 (after deletion at pos 4)
|
|
if fileMap[3] != 5 {
|
|
t.Errorf("line 3 position = %d, want 5", fileMap[3])
|
|
}
|
|
// Should only have 3 entries (lines 1, 2, 3 of new file)
|
|
if len(fileMap) != 3 {
|
|
t.Errorf("expected 3 mapped lines, got %d: %v", len(fileMap), fileMap)
|
|
}
|
|
})
|
|
|
|
t.Run("multiple files", func(t *testing.T) {
|
|
diff := "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1,2 +1,3 @@\n package a\n \n+// file a\ndiff --git a/b.go b/b.go\n--- a/b.go\n+++ b/b.go\n@@ -1,2 +1,3 @@\n package b\n \n+// file b\n"
|
|
result := vcs.BuildLineToPositionMap(diff)
|
|
if len(result) != 2 {
|
|
t.Fatalf("expected 2 files, got %d", len(result))
|
|
}
|
|
aMap, ok := result["a.go"]
|
|
if !ok {
|
|
t.Fatal("expected a.go in result")
|
|
}
|
|
bMap, ok := result["b.go"]
|
|
if !ok {
|
|
t.Fatal("expected b.go in result")
|
|
}
|
|
// a.go line 3: "+// file a" -> position 4
|
|
if aMap[3] != 4 {
|
|
t.Errorf("a.go line 3 position = %d, want 4", aMap[3])
|
|
}
|
|
// b.go line 3: "+// file b" -> position 4
|
|
if bMap[3] != 4 {
|
|
t.Errorf("b.go line 3 position = %d, want 4", bMap[3])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestGetAllFilesInPath_ErrorPropagation(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("ListContents error propagates", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
// "src" not in map, so ListContents will fail
|
|
},
|
|
}
|
|
_, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "list contents") {
|
|
t.Errorf("expected error about list contents, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("GetFileContent error propagates", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"src": {
|
|
{Name: "main.go", Path: "src/main.go", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
// "src/main.go" not in files map, so GetFileContent will fail
|
|
},
|
|
}
|
|
_, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "get file") {
|
|
t.Errorf("expected error about get file, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("nested ListContents error propagates", func(t *testing.T) {
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"src": {
|
|
{Name: "pkg", Path: "src/pkg", Type: "dir"},
|
|
},
|
|
// "src/pkg" not in map, so recursive ListContents will fail
|
|
},
|
|
}
|
|
_, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "list contents") {
|
|
t.Errorf("expected error about list contents, got: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("canceled context propagates", func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
client := &mockFileReader{
|
|
contents: map[string][]vcs.ContentEntry{
|
|
"src": {
|
|
{Name: "main.go", Path: "src/main.go", Type: "file"},
|
|
},
|
|
},
|
|
files: map[string]string{
|
|
"src/main.go": "package main",
|
|
},
|
|
}
|
|
_, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
if err == nil {
|
|
t.Fatal("expected error from canceled context, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "context canceled") {
|
|
t.Errorf("expected context cancellation error, got: %v", err)
|
|
}
|
|
})
|
|
}
|