Compare commits

..

1 Commits

Author SHA1 Message Date
claw 90959da830 feat(vcs): add util.go with GetAllFilesInPath and BuildLineToPositionMap (#84)
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 57s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 2m40s
Add vcs/util.go containing two utility functions that were specified
in issue #78 but omitted from PR #83:

- GetAllFilesInPath: recursively fetches all file contents under a path
  using the vcs.FileReader interface. Returns map[string]string of
  path -> content.

- BuildLineToPositionMap: parses a unified diff and returns per-file
  mapping of new-file line numbers to GitHub diff-position convention.
  Position is 1-indexed from @@ hunk headers. Deletion lines are not
  mapped (no new-file line number). Position counter continues across
  hunks within the same file but resets for each new file.

Unit tests cover:
- GetAllFilesInPath: empty dir, flat dir, nested dirs, mixed, errors
- BuildLineToPositionMap: single hunk, multi-hunk, deletions not mapped,
  multiple files, empty diff, no-newline marker, single-line hunk,
  deleted file
2026-05-12 12:31:10 -07:00
8 changed files with 556 additions and 343 deletions
+5 -9
View File
@@ -15,7 +15,6 @@ import (
"gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/llm"
"gitea.weiker.me/rodin/review-bot/review"
"gitea.weiker.me/rodin/review-bot/vcs"
)
var version = "dev"
@@ -813,7 +812,7 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
return evaluatedSHA != currentSHA
}
// giteaClientAdapter adapts gitea.Client to vcs.FileReader interface.
// giteaClientAdapter adapts gitea.Client to review.GiteaClient interface.
type giteaClientAdapter struct {
client *gitea.Client
}
@@ -822,14 +821,14 @@ func newGiteaClientAdapter(c *gitea.Client) *giteaClientAdapter {
return &giteaClientAdapter{client: c}
}
func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
entries, err := a.client.ListContents(ctx, owner, repo, path)
if err != nil {
return nil, err
}
result := make([]vcs.ContentEntry, len(entries))
result := make([]review.ContentEntry, len(entries))
for i, e := range entries {
result[i] = vcs.ContentEntry{
result[i] = review.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
@@ -838,9 +837,6 @@ func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path
return result, nil
}
func (a *giteaClientAdapter) GetFileContent(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
if ref != "" {
return a.client.GetFileContentRef(ctx, owner, repo, filepath, ref)
}
func (a *giteaClientAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
return a.client.GetFileContent(ctx, owner, repo, filepath)
}
-12
View File
@@ -831,15 +831,3 @@ func (c *Client) ResolveComment(ctx context.Context, owner, repo string, comment
}
return nil
}
// DismissReview dismisses a review on a pull request.
// This is a stub for the vcs.Reviewer interface; full implementation is Phase 2.
func (c *Client) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
return fmt.Errorf("DismissReview: not implemented")
}
// GetFileContentAtRef fetches a file at a specific ref from a repo.
// This delegates to GetFileContentRef for the Gitea implementation.
func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
return c.GetFileContentRef(ctx, owner, repo, path, ref)
}
+17 -4
View File
@@ -4,19 +4,32 @@ import (
"context"
"log/slog"
"strings"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// RepoPersonaPath is the directory path where repo-specific personas are stored.
const RepoPersonaPath = ".review-bot/personas"
// GiteaClient defines the subset of gitea.Client methods needed for loading repo personas.
// This interface allows for easier testing and decouples the review package from gitea.
type GiteaClient interface {
ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error)
GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error)
}
// ContentEntry represents a file or directory entry from the contents API.
// This mirrors gitea.ContentEntry to avoid import cycles.
type ContentEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // "file" or "dir"
}
// LoadRepoPersonas fetches personas from a repository's .review-bot/personas/ directory.
// Returns an empty map (not nil) if the directory doesn't exist or is empty.
// Individual parse failures are logged and skipped; the remaining personas are still returned.
// Auth errors and other non-404 errors are propagated.
// Files exceeding MaxPersonaFileSize are rejected to prevent resource exhaustion.
func LoadRepoPersonas(ctx context.Context, client vcs.FileReader, owner, repo string) (map[string]*Persona, error) {
func LoadRepoPersonas(ctx context.Context, client GiteaClient, owner, repo string) (map[string]*Persona, error) {
result := make(map[string]*Persona)
entries, err := client.ListContents(ctx, owner, repo, RepoPersonaPath)
@@ -44,7 +57,7 @@ func LoadRepoPersonas(ctx context.Context, client vcs.FileReader, owner, repo st
continue
}
content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "")
content, err := client.GetFileContent(ctx, owner, repo, entry.Path)
if err != nil {
slog.Warn("could not fetch repo persona file",
"file", entry.Path,
+52 -21
View File
@@ -5,8 +5,6 @@ import (
"errors"
"strings"
"testing"
"gitea.weiker.me/rodin/review-bot/vcs"
)
func TestParsePersonaBytes(t *testing.T) {
@@ -19,7 +17,11 @@ func TestParsePersonaBytes(t *testing.T) {
}{
{
name: "valid yaml",
data: "name: test\nidentity: test identity\nfocus:\n - testing\n",
data: `name: test
identity: test identity
focus:
- testing
`,
source: "test.yaml",
wantName: "test",
},
@@ -65,15 +67,15 @@ func TestParsePersonaBytes(t *testing.T) {
}
}
// mockGiteaClient implements vcs.FileReader for testing.
// mockGiteaClient implements GiteaClient for testing.
type mockGiteaClient struct {
contents map[string][]vcs.ContentEntry // path -> entries
contents map[string][]ContentEntry // path -> entries
files map[string]string // path -> content
listErr error
fileErr map[string]error // path -> error
}
func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
if m.listErr != nil {
return nil, m.listErr
}
@@ -84,7 +86,7 @@ func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path st
return entries, nil
}
func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
if m.fileErr != nil {
if err, ok := m.fileErr[filepath]; ok {
return "", err
@@ -116,7 +118,7 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("empty directory returns empty map", func(t *testing.T) {
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {},
},
}
@@ -131,15 +133,27 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("loads valid personas", func(t *testing.T) {
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {
{Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"},
{Name: "crypto.yaml", Path: ".review-bot/personas/crypto.yaml", Type: "file"},
},
},
files: map[string]string{
".review-bot/personas/trading.yaml": "name: trading\ndisplay_name: Trading Expert\nidentity: You are a trading expert.\nfocus:\n - order handling\n - risk management\n",
".review-bot/personas/crypto.yaml": "name: crypto\ndisplay_name: Crypto Expert\nidentity: You are a cryptography expert.\nfocus:\n - key management\n - encryption\n",
".review-bot/personas/trading.yaml": `name: trading
display_name: Trading Expert
identity: You are a trading expert.
focus:
- order handling
- risk management
`,
".review-bot/personas/crypto.yaml": `name: crypto
display_name: Crypto Expert
identity: You are a cryptography expert.
focus:
- key management
- encryption
`,
},
}
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
@@ -162,14 +176,16 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("skips invalid persona files", func(t *testing.T) {
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
{Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"},
},
},
files: map[string]string{
".review-bot/personas/valid.yaml": "name: valid\nidentity: Valid persona\n",
".review-bot/personas/valid.yaml": `name: valid
identity: Valid persona
`,
".review-bot/personas/invalid.yaml": "not valid yaml: [broken",
},
}
@@ -177,6 +193,7 @@ func TestLoadRepoPersonas(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have the valid one, skip the invalid
if len(personas) != 1 {
t.Fatalf("expected 1 persona (skipped invalid), got %d", len(personas))
}
@@ -187,7 +204,7 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("skips non-yaml files", func(t *testing.T) {
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
{Name: "README.md", Path: ".review-bot/personas/README.md", Type: "file"},
@@ -195,7 +212,9 @@ func TestLoadRepoPersonas(t *testing.T) {
},
},
files: map[string]string{
".review-bot/personas/persona.yaml": "name: test\nidentity: Test persona\n",
".review-bot/personas/persona.yaml": `name: test
identity: Test persona
`,
".review-bot/personas/README.md": "# Personas\n\nPut your personas here.",
},
}
@@ -210,14 +229,16 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("skips subdirectories", func(t *testing.T) {
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
{Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"},
},
},
files: map[string]string{
".review-bot/personas/persona.yaml": "name: test\nidentity: Test persona\n",
".review-bot/personas/persona.yaml": `name: test
identity: Test persona
`,
},
}
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
@@ -244,14 +265,16 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("skips files that fail to fetch", func(t *testing.T) {
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {
{Name: "good.yaml", Path: ".review-bot/personas/good.yaml", Type: "file"},
{Name: "bad.yaml", Path: ".review-bot/personas/bad.yaml", Type: "file"},
},
},
files: map[string]string{
".review-bot/personas/good.yaml": "name: good\nidentity: Good persona\n",
".review-bot/personas/good.yaml": `name: good
identity: Good persona
`,
},
fileErr: map[string]error{
".review-bot/personas/bad.yaml": errors.New("HTTP 500: internal server error"),
@@ -267,16 +290,19 @@ func TestLoadRepoPersonas(t *testing.T) {
})
t.Run("skips oversized files", func(t *testing.T) {
// Create a content string that exceeds MaxPersonaFileSize (64KB)
oversizedContent := strings.Repeat("a", MaxPersonaFileSize+1)
client := &mockGiteaClient{
contents: map[string][]vcs.ContentEntry{
contents: map[string][]ContentEntry{
RepoPersonaPath: {
{Name: "normal.yaml", Path: ".review-bot/personas/normal.yaml", Type: "file"},
{Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"},
},
},
files: map[string]string{
".review-bot/personas/normal.yaml": "name: normal\nidentity: Normal sized persona\n",
".review-bot/personas/normal.yaml": `name: normal
identity: Normal sized persona
`,
".review-bot/personas/huge.yaml": oversizedContent,
},
}
@@ -284,6 +310,7 @@ func TestLoadRepoPersonas(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have the normal one, skip the oversized
if len(personas) != 1 {
t.Fatalf("expected 1 persona (skipped oversized), got %d", len(personas))
}
@@ -343,6 +370,7 @@ func TestGetBuiltinPersonasMap(t *testing.T) {
t.Fatal("expected at least one built-in persona")
}
// Verify expected personas exist
expected := []string{"security", "architect", "docs"}
for _, name := range expected {
if personas[name] == nil {
@@ -350,6 +378,7 @@ func TestGetBuiltinPersonasMap(t *testing.T) {
}
}
// Verify personas are valid
for name, p := range personas {
if p.Name != name {
t.Errorf("persona %q has mismatched name %q", name, p.Name)
@@ -393,6 +422,8 @@ func TestIsNotFoundError(t *testing.T) {
{nil, false},
{errors.New("HTTP 404: not found"), true},
{errors.New("HTTP 404"), true},
// Intentionally false: generic "not found" could mask auth/transport errors.
// Only explicit HTTP 404 responses should be treated as "directory doesn't exist".
{errors.New("something not found"), false},
{errors.New("HTTP 401: unauthorized"), false},
{errors.New("connection refused"), false},
-3
View File
@@ -10,8 +10,6 @@ type PRReader interface {
GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error)
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error)
GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error)
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error)
}
// FileReader can fetch file contents and list directory entries.
@@ -25,7 +23,6 @@ type Reviewer interface {
PostReview(ctx context.Context, owner, repo string, number int, req ReviewRequest) (*Review, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error)
DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error
DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error
}
// Identity can report who the authenticated user is.
-15
View File
@@ -15,11 +15,6 @@ const (
ReviewEventComment ReviewEvent = "COMMENT"
)
// BaseRef identifies the target branch of a pull request.
type BaseRef struct {
Ref string `json:"ref"`
}
// HeadRef identifies the source branch and latest commit of a pull request.
type HeadRef struct {
SHA string `json:"sha"`
@@ -33,11 +28,9 @@ type UserInfo struct {
// PullRequest holds relevant PR metadata.
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
Head HeadRef `json:"head"`
Base BaseRef `json:"base"`
}
// ChangedFile represents a file modified in a PR.
@@ -53,14 +46,6 @@ type ContentEntry struct {
Type string `json:"type"` // "file" or "dir"
}
// CommitStatus represents a single CI status entry for a commit.
type CommitStatus struct {
Status string `json:"status"`
Context string `json:"context"`
Description string `json:"description"`
TargetURL string `json:"target_url"`
}
// Review represents a pull request review.
type Review struct {
ID int64 `json:"id"`
+40 -43
View File
@@ -3,6 +3,7 @@ package vcs
import (
"context"
"fmt"
"strconv"
"strings"
)
@@ -39,11 +40,18 @@ func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path
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.
// BuildLineToPositionMap parses a unified diff and returns a per-file map of
// new-file line number diff-position.
//
// Position is a 1-indexed offset from the @@ hunk line (GitHub convention):
// - The @@ hunk header itself counts as position 1 (for the first hunk)
// - Every subsequent content line (context, addition, deletion) increments position
// - A second @@ hunk in the same file continues the count; it does not reset
// - Addition and context lines have new-file line numbers and are mapped
// - Deletion lines have a position but no new-file line; they are not in the map
// - "\ No newline at end of file" markers do not count as a position
//
// Returns: map[filename]map[newFileLine]diffPosition
func BuildLineToPositionMap(diff string) map[string]map[int]int {
result := make(map[string]map[int]int)
@@ -53,7 +61,7 @@ func BuildLineToPositionMap(diff string) map[string]map[int]int {
var newLine int
for _, line := range lines {
// Detect new file in diff
// Detect new file in diff — resets position counter per file.
if strings.HasPrefix(line, "+++ b/") {
currentFile = strings.TrimPrefix(line, "+++ b/")
position = 0
@@ -64,82 +72,71 @@ func BuildLineToPositionMap(diff string) map[string]map[int]int {
continue
}
// Skip --- lines (old file header)
if strings.HasPrefix(line, "--- ") {
// Skip deleted-file target (no new lines to map).
if strings.HasPrefix(line, "+++ /dev/null") {
currentFile = ""
continue
}
// Skip diff --git lines
if strings.HasPrefix(line, "diff --git") {
// Skip metadata lines that are not part of position counting.
if strings.HasPrefix(line, "diff --git") ||
strings.HasPrefix(line, "--- ") ||
strings.HasPrefix(line, "index ") ||
strings.HasPrefix(line, "\\") {
continue
}
// Skip index lines
if strings.HasPrefix(line, "index ") {
continue
}
// Parse hunk headers
if strings.HasPrefix(line, "@@") {
// Parse hunk headers — the @@ line itself occupies a position.
if strings.HasPrefix(line, "@@") && currentFile != "" {
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, "+") {
// Content lines within a hunk.
switch {
case strings.HasPrefix(line, "+"):
// Addition: maps to both a position and a new-file line.
position++
result[currentFile][newLine] = position
newLine++
} else if strings.HasPrefix(line, "-") {
case strings.HasPrefix(line, "-"):
// Deletion: occupies a position but has no new-file line number.
position++
// Deletion lines don't map to new line numbers
} else if strings.HasPrefix(line, " ") {
// Context line (space-prefixed)
if position > 0 {
case strings.HasPrefix(line, " "):
// Context line: maps to both a position and a new-file line.
position++
result[currentFile][newLine] = position
newLine++
}
}
// Any other line (empty trailing lines from Split, etc.) is ignored.
}
return result
}
// parseHunkNewStart extracts the new-file starting line number from a hunk header.
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@[ optional section heading]
func parseHunkNewStart(hunkLine string) int {
// Find the +N part
// Find the +N part after the first @@
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
}
// Read digits until comma, space, or end.
if idx := strings.IndexAny(rest, ", @"); idx > 0 {
rest = rest[:idx]
}
if numStr == "" {
n, err := strconv.Atoi(rest)
if err != nil {
return 1
}
n := 0
for _, ch := range numStr {
n = n*10 + int(ch-'0')
}
return n
}
+348 -142
View File
@@ -10,58 +10,59 @@ import (
// mockFileReader implements vcs.FileReader for testing.
type mockFileReader struct {
contents map[string][]vcs.ContentEntry // path -> entries
files map[string]string // path -> content
contents map[string]string // path -> content
dirs map[string][]vcs.ContentEntry // path -> entries
}
func (m *mockFileReader) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
content, ok := m.files[path]
func (m *mockFileReader) GetFileContent(_ context.Context, _, _, path, _ string) (string, error) {
content, ok := m.contents[path]
if !ok {
return "", fmt.Errorf("HTTP 404: file not found: %s", path)
return "", fmt.Errorf("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]
func (m *mockFileReader) ListContents(_ context.Context, _, _, path string) ([]vcs.ContentEntry, error) {
entries, ok := m.dirs[path]
if !ok {
return nil, fmt.Errorf("HTTP 404: path not found: %s", path)
return nil, fmt.Errorf("directory not found: %s", path)
}
return entries, nil
}
func TestGetAllFilesInPath(t *testing.T) {
ctx := context.Background()
// --- GetAllFilesInPath tests ---
t.Run("empty directory", func(t *testing.T) {
client := &mockFileReader{
contents: map[string][]vcs.ContentEntry{
func TestGetAllFilesInPath_EmptyDir(t *testing.T) {
mock := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{
"src": {},
},
}
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "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{
func TestGetAllFilesInPath_FlatDir(t *testing.T) {
mock := &mockFileReader{
dirs: 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{
contents: map[string]string{
"src/main.go": "package main",
"src/util.go": "package main\n// util",
"src/util.go": "package main\n\nfunc helper() {}",
},
}
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -69,35 +70,36 @@ func TestGetAllFilesInPath(t *testing.T) {
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"])
t.Errorf("unexpected content for main.go: %q", result["src/main.go"])
}
if result["src/util.go"] != "package main\n\nfunc helper() {}" {
t.Errorf("unexpected content for util.go: %q", result["src/util.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{
func TestGetAllFilesInPath_NestedDirs(t *testing.T) {
mock := &mockFileReader{
dirs: 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"},
{Name: "internal", Path: "src/pkg/internal", Type: "dir"},
},
"src/pkg/sub": {
{Name: "deep.go", Path: "src/pkg/sub/deep.go", Type: "file"},
"src/pkg/internal": {
{Name: "helper.go", Path: "src/pkg/internal/helper.go", Type: "file"},
},
},
files: map[string]string{
contents: map[string]string{
"src/main.go": "package main",
"src/pkg/lib.go": "package pkg",
"src/pkg/sub/deep.go": "package sub",
"src/pkg/internal/helper.go": "package internal",
},
}
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -105,146 +107,350 @@ func TestGetAllFilesInPath(t *testing.T) {
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"])
t.Errorf("unexpected content for main.go")
}
if result["src/pkg/lib.go"] != "package pkg" {
t.Errorf("lib.go content = %q", result["src/pkg/lib.go"])
t.Errorf("unexpected content for lib.go")
}
if result["src/pkg/internal/helper.go"] != "package internal" {
t.Errorf("unexpected content for helper.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{
func TestGetAllFilesInPath_Mixed(t *testing.T) {
mock := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{
"root": {
{Name: "README.md", Path: "root/README.md", Type: "file"},
{Name: "empty", Path: "root/empty", Type: "dir"},
{Name: "docs", Path: "root/docs", Type: "dir"},
{Name: "config.yaml", Path: "root/config.yaml", Type: "file"},
},
"root/empty": {},
"root/docs": {
{Name: "guide.md", Path: "root/docs/guide.md", Type: "file"},
},
},
files: map[string]string{
contents: 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")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "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))
}
if result["root/README.md"] != "# Hello" {
t.Errorf("unexpected content for README.md")
}
if result["root/docs/guide.md"] != "## Guide" {
t.Errorf("unexpected content for guide.md")
}
}
func TestGetAllFilesInPath_ListContentsError(t *testing.T) {
mock := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{},
}
_, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "missing")
if err == nil {
t.Fatal("expected error for missing directory")
}
}
func TestGetAllFilesInPath_GetFileContentError(t *testing.T) {
mock := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{
"src": {
{Name: "bad.go", Path: "src/bad.go", Type: "file"},
},
},
contents: map[string]string{},
}
_, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
if err == nil {
t.Fatal("expected error when file content fetch fails")
}
}
// --- BuildLineToPositionMap tests ---
func TestBuildLineToPositionMap_SingleHunk(t *testing.T) {
diff := `diff --git a/main.go b/main.go
index abc..def 100644
--- a/main.go
+++ b/main.go
@@ -5,7 +5,8 @@ package main
import "fmt"
func main() {
- fmt.Println("old")
+ fmt.Println("new")
+ fmt.Println("added")
return
}
`
result := vcs.BuildLineToPositionMap(diff)
fileMap, ok := result["main.go"]
if !ok {
t.Fatal("expected main.go in result")
}
// @@ line is position 1
// " import \"fmt\"" -> pos 2, newLine 5
// " " -> pos 3, newLine 6
// " func main() {" -> pos 4, newLine 7
// "-\tfmt.Println..." -> pos 5, no new line
// "+\tfmt.Println..." -> pos 6, newLine 8
// "+\tfmt.Println..." -> pos 7, newLine 9
// " \treturn" -> pos 8, newLine 10
// " }" -> pos 9, newLine 11
expected := map[int]int{
5: 2,
6: 3,
7: 4,
8: 6,
9: 7,
10: 8,
11: 9,
}
for line, wantPos := range expected {
gotPos, exists := fileMap[line]
if !exists {
t.Errorf("line %d: expected position %d, but line not in map", line, wantPos)
continue
}
if gotPos != wantPos {
t.Errorf("line %d: expected position %d, got %d", line, wantPos, gotPos)
}
}
if len(fileMap) != len(expected) {
t.Errorf("expected %d entries, got %d", len(expected), len(fileMap))
}
}
func TestBuildLineToPositionMap_MultiHunk(t *testing.T) {
diff := `diff --git a/main.go b/main.go
--- a/main.go
+++ b/main.go
@@ -1,3 +1,4 @@
package main
+import "fmt"
func main() {
@@ -10,3 +11,4 @@ func main() {
return
+ // extra
}
`
result := vcs.BuildLineToPositionMap(diff)
fileMap, ok := result["main.go"]
if !ok {
t.Fatal("expected main.go in result")
}
// First hunk:
// @@ line -> pos 1
// " package main" -> pos 2, newLine 1
// " " -> pos 3, newLine 2
// "+import..." -> pos 4, newLine 3
// " func main.." -> pos 5, newLine 4
//
// Second hunk (position continues!):
// @@ line -> pos 6
// " \treturn" -> pos 7, newLine 11
// "+\t// extra" -> pos 8, newLine 12
// " }" -> pos 9, newLine 13
expected := map[int]int{
1: 2,
2: 3,
3: 4,
4: 5,
11: 7,
12: 8,
13: 9,
}
for line, wantPos := range expected {
gotPos, exists := fileMap[line]
if !exists {
t.Errorf("line %d: expected position %d, but line not in map", line, wantPos)
continue
}
if gotPos != wantPos {
t.Errorf("line %d: expected position %d, got %d", line, wantPos, gotPos)
}
}
}
func TestBuildLineToPositionMap_DeletionLinesNotInMap(t *testing.T) {
diff := `diff --git a/old.go b/old.go
--- a/old.go
+++ b/old.go
@@ -1,3 +1,1 @@
-line one
-line two
remaining
`
result := vcs.BuildLineToPositionMap(diff)
fileMap, ok := result["old.go"]
if !ok {
t.Fatal("expected old.go in result")
}
// @@ line -> pos 1
// "-line one" -> pos 2, no new line
// "-line two" -> pos 3, no new line
// " remaining" -> pos 4, newLine 1
if pos, ok := fileMap[1]; !ok || pos != 4 {
t.Errorf("line 1: expected position 4, got %d (exists=%v)", pos, ok)
}
if len(fileMap) != 1 {
t.Errorf("expected 1 entry (only 'remaining'), got %d", len(fileMap))
}
}
func TestBuildLineToPositionMap_MultipleFiles(t *testing.T) {
diff := `diff --git a/a.go b/a.go
--- a/a.go
+++ b/a.go
@@ -1,2 +1,3 @@
package a
+// added
diff --git a/b.go b/b.go
new file mode 100644
--- /dev/null
+++ b/b.go
@@ -0,0 +1,3 @@
+package b
+
+func B() {}
`
result := vcs.BuildLineToPositionMap(diff)
// File a.go
aMap, ok := result["a.go"]
if !ok {
t.Fatal("expected a.go in result")
}
// @@ line -> pos 1
// " package a" -> pos 2, newLine 1
// "+// added" -> pos 3, newLine 2
// " " -> pos 4, newLine 3
if aMap[1] != 2 {
t.Errorf("a.go line 1: expected pos 2, got %d", aMap[1])
}
if aMap[2] != 3 {
t.Errorf("a.go line 2: expected pos 3, got %d", aMap[2])
}
if aMap[3] != 4 {
t.Errorf("a.go line 3: expected pos 4, got %d", aMap[3])
}
// File b.go — position resets for new file
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])
// @@ line -> pos 1
// "+package b" -> pos 2, newLine 1
// "+" -> pos 3, newLine 2
// "+func B() {}" -> pos 4, newLine 3
if bMap[1] != 2 {
t.Errorf("b.go line 1: expected pos 2, got %d", bMap[1])
}
if bMap[2] != 3 {
t.Errorf("b.go line 2: expected pos 3, got %d", bMap[2])
}
// b.go line 3: "+// file b" -> position 4
if bMap[3] != 4 {
t.Errorf("b.go line 3 position = %d, want 4", bMap[3])
t.Errorf("b.go line 3: expected pos 4, got %d", bMap[3])
}
}
func TestBuildLineToPositionMap_EmptyDiff(t *testing.T) {
result := vcs.BuildLineToPositionMap("")
if len(result) != 0 {
t.Errorf("expected empty map for empty diff, got %d entries", len(result))
}
}
func TestBuildLineToPositionMap_NoNewlineMarker(t *testing.T) {
diff := `diff --git a/a.go b/a.go
--- a/a.go
+++ b/a.go
@@ -1,2 +1,2 @@
-old
+new
\ No newline at end of file
`
result := vcs.BuildLineToPositionMap(diff)
fileMap := result["a.go"]
// @@ line -> pos 1
// "-old" -> pos 2
// "+new" -> pos 3, newLine 1
// "\ No.." -> not counted
if fileMap[1] != 3 {
t.Errorf("line 1: expected position 3, got %d", fileMap[1])
}
if len(fileMap) != 1 {
t.Errorf("expected 1 entry, got %d", len(fileMap))
}
}
func TestBuildLineToPositionMap_SingleLineHunk(t *testing.T) {
diff := `diff --git a/single.go b/single.go
--- a/single.go
+++ b/single.go
@@ -1 +1 @@
-old
+new
`
result := vcs.BuildLineToPositionMap(diff)
fileMap := result["single.go"]
// @@ line -> pos 1
// "-old" -> pos 2
// "+new" -> pos 3, newLine 1
if fileMap[1] != 3 {
t.Errorf("line 1: expected position 3, got %d", fileMap[1])
}
}
func TestBuildLineToPositionMap_DeletedFile(t *testing.T) {
diff := `diff --git a/deleted.go b/deleted.go
deleted file mode 100644
--- a/deleted.go
+++ /dev/null
@@ -1,3 +0,0 @@
-package deleted
-
-func Gone() {}
`
result := vcs.BuildLineToPositionMap(diff)
if _, ok := result["deleted.go"]; ok {
t.Error("deleted file should not appear in result")
}
})
}