Compare commits

..

1 Commits

Author SHA1 Message Date
claw 7c83365fc4 feat(vcs): complete Phase 1 — util.go, type cleanup, interface additions (fixes #84, #85, #86)
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
2026-05-12 12:38:21 -07:00
8 changed files with 337 additions and 550 deletions
+9 -5
View File
@@ -15,6 +15,7 @@ import (
"gitea.weiker.me/rodin/review-bot/gitea" "gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/llm" "gitea.weiker.me/rodin/review-bot/llm"
"gitea.weiker.me/rodin/review-bot/review" "gitea.weiker.me/rodin/review-bot/review"
"gitea.weiker.me/rodin/review-bot/vcs"
) )
var version = "dev" var version = "dev"
@@ -812,7 +813,7 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
return evaluatedSHA != currentSHA return evaluatedSHA != currentSHA
} }
// giteaClientAdapter adapts gitea.Client to review.GiteaClient interface. // giteaClientAdapter adapts gitea.Client to vcs.FileReader interface.
type giteaClientAdapter struct { type giteaClientAdapter struct {
client *gitea.Client client *gitea.Client
} }
@@ -821,14 +822,14 @@ func newGiteaClientAdapter(c *gitea.Client) *giteaClientAdapter {
return &giteaClientAdapter{client: c} return &giteaClientAdapter{client: c}
} }
func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) { func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
entries, err := a.client.ListContents(ctx, owner, repo, path) entries, err := a.client.ListContents(ctx, owner, repo, path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make([]review.ContentEntry, len(entries)) result := make([]vcs.ContentEntry, len(entries))
for i, e := range entries { for i, e := range entries {
result[i] = review.ContentEntry{ result[i] = vcs.ContentEntry{
Name: e.Name, Name: e.Name,
Path: e.Path, Path: e.Path,
Type: e.Type, Type: e.Type,
@@ -837,6 +838,9 @@ func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path
return result, nil return result, nil
} }
func (a *giteaClientAdapter) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) { 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)
}
return a.client.GetFileContent(ctx, owner, repo, filepath) return a.client.GetFileContent(ctx, owner, repo, filepath)
} }
+12
View File
@@ -831,3 +831,15 @@ func (c *Client) ResolveComment(ctx context.Context, owner, repo string, comment
} }
return nil 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)
}
+4 -17
View File
@@ -4,32 +4,19 @@ import (
"context" "context"
"log/slog" "log/slog"
"strings" "strings"
"gitea.weiker.me/rodin/review-bot/vcs"
) )
// RepoPersonaPath is the directory path where repo-specific personas are stored. // RepoPersonaPath is the directory path where repo-specific personas are stored.
const RepoPersonaPath = ".review-bot/personas" 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. // 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. // 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. // Individual parse failures are logged and skipped; the remaining personas are still returned.
// Auth errors and other non-404 errors are propagated. // Auth errors and other non-404 errors are propagated.
// Files exceeding MaxPersonaFileSize are rejected to prevent resource exhaustion. // Files exceeding MaxPersonaFileSize are rejected to prevent resource exhaustion.
func LoadRepoPersonas(ctx context.Context, client GiteaClient, owner, repo string) (map[string]*Persona, error) { func LoadRepoPersonas(ctx context.Context, client vcs.FileReader, owner, repo string) (map[string]*Persona, error) {
result := make(map[string]*Persona) result := make(map[string]*Persona)
entries, err := client.ListContents(ctx, owner, repo, RepoPersonaPath) entries, err := client.ListContents(ctx, owner, repo, RepoPersonaPath)
@@ -57,7 +44,7 @@ func LoadRepoPersonas(ctx context.Context, client GiteaClient, owner, repo strin
continue continue
} }
content, err := client.GetFileContent(ctx, owner, repo, entry.Path) content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "")
if err != nil { if err != nil {
slog.Warn("could not fetch repo persona file", slog.Warn("could not fetch repo persona file",
"file", entry.Path, "file", entry.Path,
+21 -52
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"strings" "strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/vcs"
) )
func TestParsePersonaBytes(t *testing.T) { func TestParsePersonaBytes(t *testing.T) {
@@ -17,11 +19,7 @@ func TestParsePersonaBytes(t *testing.T) {
}{ }{
{ {
name: "valid yaml", name: "valid yaml",
data: `name: test data: "name: test\nidentity: test identity\nfocus:\n - testing\n",
identity: test identity
focus:
- testing
`,
source: "test.yaml", source: "test.yaml",
wantName: "test", wantName: "test",
}, },
@@ -67,15 +65,15 @@ focus:
} }
} }
// mockGiteaClient implements GiteaClient for testing. // mockGiteaClient implements vcs.FileReader for testing.
type mockGiteaClient struct { type mockGiteaClient struct {
contents map[string][]ContentEntry // path -> entries contents map[string][]vcs.ContentEntry // path -> entries
files map[string]string // path -> content files map[string]string // path -> content
listErr error listErr error
fileErr map[string]error // path -> error fileErr map[string]error // path -> error
} }
func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) { func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
if m.listErr != nil { if m.listErr != nil {
return nil, m.listErr return nil, m.listErr
} }
@@ -86,7 +84,7 @@ func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path st
return entries, nil return entries, nil
} }
func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) { func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
if m.fileErr != nil { if m.fileErr != nil {
if err, ok := m.fileErr[filepath]; ok { if err, ok := m.fileErr[filepath]; ok {
return "", err return "", err
@@ -118,7 +116,7 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("empty directory returns empty map", func(t *testing.T) { t.Run("empty directory returns empty map", func(t *testing.T) {
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: {}, RepoPersonaPath: {},
}, },
} }
@@ -133,27 +131,15 @@ func TestLoadRepoPersonas(t *testing.T) {
t.Run("loads valid personas", func(t *testing.T) { t.Run("loads valid personas", func(t *testing.T) {
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: { RepoPersonaPath: {
{Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"}, {Name: "trading.yaml", Path: ".review-bot/personas/trading.yaml", Type: "file"},
{Name: "crypto.yaml", Path: ".review-bot/personas/crypto.yaml", Type: "file"}, {Name: "crypto.yaml", Path: ".review-bot/personas/crypto.yaml", Type: "file"},
}, },
}, },
files: map[string]string{ files: map[string]string{
".review-bot/personas/trading.yaml": `name: trading ".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",
display_name: Trading Expert ".review-bot/personas/crypto.yaml": "name: crypto\ndisplay_name: Crypto Expert\nidentity: You are a cryptography expert.\nfocus:\n - key management\n - encryption\n",
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") personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
@@ -176,16 +162,14 @@ focus:
t.Run("skips invalid persona files", func(t *testing.T) { t.Run("skips invalid persona files", func(t *testing.T) {
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: { RepoPersonaPath: {
{Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"}, {Name: "valid.yaml", Path: ".review-bot/personas/valid.yaml", Type: "file"},
{Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"}, {Name: "invalid.yaml", Path: ".review-bot/personas/invalid.yaml", Type: "file"},
}, },
}, },
files: map[string]string{ files: map[string]string{
".review-bot/personas/valid.yaml": `name: valid ".review-bot/personas/valid.yaml": "name: valid\nidentity: Valid persona\n",
identity: Valid persona
`,
".review-bot/personas/invalid.yaml": "not valid yaml: [broken", ".review-bot/personas/invalid.yaml": "not valid yaml: [broken",
}, },
} }
@@ -193,7 +177,6 @@ identity: Valid persona
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
// Should have the valid one, skip the invalid
if len(personas) != 1 { if len(personas) != 1 {
t.Fatalf("expected 1 persona (skipped invalid), got %d", len(personas)) t.Fatalf("expected 1 persona (skipped invalid), got %d", len(personas))
} }
@@ -204,7 +187,7 @@ identity: Valid persona
t.Run("skips non-yaml files", func(t *testing.T) { t.Run("skips non-yaml files", func(t *testing.T) {
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: { RepoPersonaPath: {
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"}, {Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
{Name: "README.md", Path: ".review-bot/personas/README.md", Type: "file"}, {Name: "README.md", Path: ".review-bot/personas/README.md", Type: "file"},
@@ -212,9 +195,7 @@ identity: Valid persona
}, },
}, },
files: map[string]string{ files: map[string]string{
".review-bot/personas/persona.yaml": `name: test ".review-bot/personas/persona.yaml": "name: test\nidentity: Test persona\n",
identity: Test persona
`,
".review-bot/personas/README.md": "# Personas\n\nPut your personas here.", ".review-bot/personas/README.md": "# Personas\n\nPut your personas here.",
}, },
} }
@@ -229,16 +210,14 @@ identity: Test persona
t.Run("skips subdirectories", func(t *testing.T) { t.Run("skips subdirectories", func(t *testing.T) {
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: { RepoPersonaPath: {
{Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"}, {Name: "persona.yaml", Path: ".review-bot/personas/persona.yaml", Type: "file"},
{Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"}, {Name: "subdir", Path: ".review-bot/personas/subdir", Type: "dir"},
}, },
}, },
files: map[string]string{ files: map[string]string{
".review-bot/personas/persona.yaml": `name: test ".review-bot/personas/persona.yaml": "name: test\nidentity: Test persona\n",
identity: Test persona
`,
}, },
} }
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo") personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
@@ -265,16 +244,14 @@ identity: Test persona
t.Run("skips files that fail to fetch", func(t *testing.T) { t.Run("skips files that fail to fetch", func(t *testing.T) {
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: { RepoPersonaPath: {
{Name: "good.yaml", Path: ".review-bot/personas/good.yaml", Type: "file"}, {Name: "good.yaml", Path: ".review-bot/personas/good.yaml", Type: "file"},
{Name: "bad.yaml", Path: ".review-bot/personas/bad.yaml", Type: "file"}, {Name: "bad.yaml", Path: ".review-bot/personas/bad.yaml", Type: "file"},
}, },
}, },
files: map[string]string{ files: map[string]string{
".review-bot/personas/good.yaml": `name: good ".review-bot/personas/good.yaml": "name: good\nidentity: Good persona\n",
identity: Good persona
`,
}, },
fileErr: map[string]error{ fileErr: map[string]error{
".review-bot/personas/bad.yaml": errors.New("HTTP 500: internal server error"), ".review-bot/personas/bad.yaml": errors.New("HTTP 500: internal server error"),
@@ -290,19 +267,16 @@ identity: Good persona
}) })
t.Run("skips oversized files", func(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) oversizedContent := strings.Repeat("a", MaxPersonaFileSize+1)
client := &mockGiteaClient{ client := &mockGiteaClient{
contents: map[string][]ContentEntry{ contents: map[string][]vcs.ContentEntry{
RepoPersonaPath: { RepoPersonaPath: {
{Name: "normal.yaml", Path: ".review-bot/personas/normal.yaml", Type: "file"}, {Name: "normal.yaml", Path: ".review-bot/personas/normal.yaml", Type: "file"},
{Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"}, {Name: "huge.yaml", Path: ".review-bot/personas/huge.yaml", Type: "file"},
}, },
}, },
files: map[string]string{ files: map[string]string{
".review-bot/personas/normal.yaml": `name: normal ".review-bot/personas/normal.yaml": "name: normal\nidentity: Normal sized persona\n",
identity: Normal sized persona
`,
".review-bot/personas/huge.yaml": oversizedContent, ".review-bot/personas/huge.yaml": oversizedContent,
}, },
} }
@@ -310,7 +284,6 @@ identity: Normal sized persona
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
// Should have the normal one, skip the oversized
if len(personas) != 1 { if len(personas) != 1 {
t.Fatalf("expected 1 persona (skipped oversized), got %d", len(personas)) t.Fatalf("expected 1 persona (skipped oversized), got %d", len(personas))
} }
@@ -370,7 +343,6 @@ func TestGetBuiltinPersonasMap(t *testing.T) {
t.Fatal("expected at least one built-in persona") t.Fatal("expected at least one built-in persona")
} }
// Verify expected personas exist
expected := []string{"security", "architect", "docs"} expected := []string{"security", "architect", "docs"}
for _, name := range expected { for _, name := range expected {
if personas[name] == nil { if personas[name] == nil {
@@ -378,7 +350,6 @@ func TestGetBuiltinPersonasMap(t *testing.T) {
} }
} }
// Verify personas are valid
for name, p := range personas { for name, p := range personas {
if p.Name != name { if p.Name != name {
t.Errorf("persona %q has mismatched name %q", name, p.Name) t.Errorf("persona %q has mismatched name %q", name, p.Name)
@@ -422,8 +393,6 @@ func TestIsNotFoundError(t *testing.T) {
{nil, false}, {nil, false},
{errors.New("HTTP 404: not found"), true}, {errors.New("HTTP 404: not found"), true},
{errors.New("HTTP 404"), 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("something not found"), false},
{errors.New("HTTP 401: unauthorized"), false}, {errors.New("HTTP 401: unauthorized"), false},
{errors.New("connection refused"), false}, {errors.New("connection refused"), false},
+3
View File
@@ -10,6 +10,8 @@ type PRReader interface {
GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error)
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, 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. // FileReader can fetch file contents and list directory entries.
@@ -23,6 +25,7 @@ type Reviewer interface {
PostReview(ctx context.Context, owner, repo string, number int, req ReviewRequest) (*Review, error) PostReview(ctx context.Context, owner, repo string, number int, req ReviewRequest) (*Review, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]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 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. // Identity can report who the authenticated user is.
+15
View File
@@ -15,6 +15,11 @@ const (
ReviewEventComment ReviewEvent = "COMMENT" 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. // HeadRef identifies the source branch and latest commit of a pull request.
type HeadRef struct { type HeadRef struct {
SHA string `json:"sha"` SHA string `json:"sha"`
@@ -28,9 +33,11 @@ type UserInfo struct {
// PullRequest holds relevant PR metadata. // PullRequest holds relevant PR metadata.
type PullRequest struct { type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
Head HeadRef `json:"head"` Head HeadRef `json:"head"`
Base BaseRef `json:"base"`
} }
// ChangedFile represents a file modified in a PR. // ChangedFile represents a file modified in a PR.
@@ -46,6 +53,14 @@ type ContentEntry struct {
Type string `json:"type"` // "file" or "dir" 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. // Review represents a pull request review.
type Review struct { type Review struct {
ID int64 `json:"id"` ID int64 `json:"id"`
+43 -40
View File
@@ -3,7 +3,6 @@ package vcs
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"strings" "strings"
) )
@@ -40,18 +39,11 @@ func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path
return results, nil return results, nil
} }
// BuildLineToPositionMap parses a unified diff and returns a per-file map of // BuildLineToPositionMap parses a unified diff and returns a map of
// new-file line number diff-position. // filename -> (new line number -> diff position). The diff position is a
// // 1-indexed offset from the @@ hunk header line for each file.
// Position is a 1-indexed offset from the @@ hunk line (GitHub convention): // Only lines that appear in the new file (context lines and additions) are mapped.
// - The @@ hunk header itself counts as position 1 (for the first hunk) // Deletion-only lines are not included.
// - 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 { func BuildLineToPositionMap(diff string) map[string]map[int]int {
result := make(map[string]map[int]int) result := make(map[string]map[int]int)
@@ -61,7 +53,7 @@ func BuildLineToPositionMap(diff string) map[string]map[int]int {
var newLine int var newLine int
for _, line := range lines { for _, line := range lines {
// Detect new file in diff — resets position counter per file. // Detect new file in diff
if strings.HasPrefix(line, "+++ b/") { if strings.HasPrefix(line, "+++ b/") {
currentFile = strings.TrimPrefix(line, "+++ b/") currentFile = strings.TrimPrefix(line, "+++ b/")
position = 0 position = 0
@@ -72,71 +64,82 @@ func BuildLineToPositionMap(diff string) map[string]map[int]int {
continue continue
} }
// Skip deleted-file target (no new lines to map). // Skip --- lines (old file header)
if strings.HasPrefix(line, "+++ /dev/null") { if strings.HasPrefix(line, "--- ") {
currentFile = ""
continue continue
} }
// Skip metadata lines that are not part of position counting. // Skip diff --git lines
if strings.HasPrefix(line, "diff --git") || if strings.HasPrefix(line, "diff --git") {
strings.HasPrefix(line, "--- ") ||
strings.HasPrefix(line, "index ") ||
strings.HasPrefix(line, "\\") {
continue continue
} }
// Parse hunk headers — the @@ line itself occupies a position. // Skip index lines
if strings.HasPrefix(line, "@@") && currentFile != "" { if strings.HasPrefix(line, "index ") {
continue
}
// Parse hunk headers
if strings.HasPrefix(line, "@@") {
position++ position++
// Extract new file start line from @@ -a,b +c,d @@
newLine = parseHunkNewStart(line) newLine = parseHunkNewStart(line)
continue continue
} }
// We need a current file to map lines
if currentFile == "" { if currentFile == "" {
continue continue
} }
// Content lines within a hunk. // Process diff content lines
switch { if strings.HasPrefix(line, "+") {
case strings.HasPrefix(line, "+"):
// Addition: maps to both a position and a new-file line.
position++ position++
result[currentFile][newLine] = position result[currentFile][newLine] = position
newLine++ newLine++
case strings.HasPrefix(line, "-"): } else if strings.HasPrefix(line, "-") {
// Deletion: occupies a position but has no new-file line number.
position++ position++
case strings.HasPrefix(line, " "): // Deletion lines don't map to new line numbers
// Context line: maps to both a position and a new-file line. } else if strings.HasPrefix(line, " ") {
// Context line (space-prefixed)
if position > 0 {
position++ position++
result[currentFile][newLine] = position result[currentFile][newLine] = position
newLine++ newLine++
} }
// Any other line (empty trailing lines from Split, etc.) is ignored. }
} }
return result return result
} }
// parseHunkNewStart extracts the new-file starting line number from a hunk header. // parseHunkNewStart extracts the new-file starting line number from a hunk header.
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@[ optional section heading] // Format: @@ -old_start[,old_count] +new_start[,new_count] @@
func parseHunkNewStart(hunkLine string) int { func parseHunkNewStart(hunkLine string) int {
// Find the +N part after the first @@ // Find the +N part
plusIdx := strings.Index(hunkLine, "+") plusIdx := strings.Index(hunkLine, "+")
if plusIdx < 0 { if plusIdx < 0 {
return 1 return 1
} }
rest := hunkLine[plusIdx+1:] rest := hunkLine[plusIdx+1:]
// Read digits until comma, space, or end. // Read digits until comma or space
if idx := strings.IndexAny(rest, ", @"); idx > 0 { var numStr string
rest = rest[:idx] for _, ch := range rest {
if ch >= '0' && ch <= '9' {
numStr += string(ch)
} else {
break
}
} }
n, err := strconv.Atoi(rest) if numStr == "" {
if err != nil {
return 1 return 1
} }
n := 0
for _, ch := range numStr {
n = n*10 + int(ch-'0')
}
return n return n
} }
+142 -348
View File
@@ -10,59 +10,58 @@ import (
// mockFileReader implements vcs.FileReader for testing. // mockFileReader implements vcs.FileReader for testing.
type mockFileReader struct { type mockFileReader struct {
contents map[string]string // path -> content contents map[string][]vcs.ContentEntry // path -> entries
dirs map[string][]vcs.ContentEntry // path -> entries files map[string]string // path -> content
} }
func (m *mockFileReader) GetFileContent(_ context.Context, _, _, path, _ string) (string, error) { func (m *mockFileReader) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
content, ok := m.contents[path] content, ok := m.files[path]
if !ok { if !ok {
return "", fmt.Errorf("file not found: %s", path) return "", fmt.Errorf("HTTP 404: file not found: %s", path)
} }
return content, nil return content, nil
} }
func (m *mockFileReader) ListContents(_ context.Context, _, _, path string) ([]vcs.ContentEntry, error) { func (m *mockFileReader) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
entries, ok := m.dirs[path] entries, ok := m.contents[path]
if !ok { if !ok {
return nil, fmt.Errorf("directory not found: %s", path) return nil, fmt.Errorf("HTTP 404: path not found: %s", path)
} }
return entries, nil return entries, nil
} }
// --- GetAllFilesInPath tests --- func TestGetAllFilesInPath(t *testing.T) {
ctx := context.Background()
func TestGetAllFilesInPath_EmptyDir(t *testing.T) { t.Run("empty directory", func(t *testing.T) {
mock := &mockFileReader{ client := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{ contents: map[string][]vcs.ContentEntry{
"src": {}, "src": {},
}, },
} }
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if len(result) != 0 { if len(result) != 0 {
t.Errorf("expected empty map, got %d entries", len(result)) t.Errorf("expected empty map, got %d entries", len(result))
} }
} })
func TestGetAllFilesInPath_FlatDir(t *testing.T) { t.Run("flat directory", func(t *testing.T) {
mock := &mockFileReader{ client := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{ contents: map[string][]vcs.ContentEntry{
"src": { "src": {
{Name: "main.go", Path: "src/main.go", Type: "file"}, {Name: "main.go", Path: "src/main.go", Type: "file"},
{Name: "util.go", Path: "src/util.go", Type: "file"}, {Name: "util.go", Path: "src/util.go", Type: "file"},
}, },
}, },
contents: map[string]string{ files: map[string]string{
"src/main.go": "package main", "src/main.go": "package main",
"src/util.go": "package main\n\nfunc helper() {}", "src/util.go": "package main\n// util",
}, },
} }
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -70,36 +69,35 @@ func TestGetAllFilesInPath_FlatDir(t *testing.T) {
t.Fatalf("expected 2 files, got %d", len(result)) t.Fatalf("expected 2 files, got %d", len(result))
} }
if result["src/main.go"] != "package main" { if result["src/main.go"] != "package main" {
t.Errorf("unexpected content for main.go: %q", result["src/main.go"]) t.Errorf("main.go content = %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"])
} }
})
func TestGetAllFilesInPath_NestedDirs(t *testing.T) { t.Run("nested directories", func(t *testing.T) {
mock := &mockFileReader{ client := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{ contents: map[string][]vcs.ContentEntry{
"src": { "src": {
{Name: "main.go", Path: "src/main.go", Type: "file"}, {Name: "main.go", Path: "src/main.go", Type: "file"},
{Name: "pkg", Path: "src/pkg", Type: "dir"}, {Name: "pkg", Path: "src/pkg", Type: "dir"},
}, },
"src/pkg": { "src/pkg": {
{Name: "lib.go", Path: "src/pkg/lib.go", Type: "file"}, {Name: "lib.go", Path: "src/pkg/lib.go", Type: "file"},
{Name: "internal", Path: "src/pkg/internal", Type: "dir"}, {Name: "sub", Path: "src/pkg/sub", Type: "dir"},
}, },
"src/pkg/internal": { "src/pkg/sub": {
{Name: "helper.go", Path: "src/pkg/internal/helper.go", Type: "file"}, {Name: "deep.go", Path: "src/pkg/sub/deep.go", Type: "file"},
}, },
}, },
contents: map[string]string{ files: map[string]string{
"src/main.go": "package main", "src/main.go": "package main",
"src/pkg/lib.go": "package pkg", "src/pkg/lib.go": "package pkg",
"src/pkg/internal/helper.go": "package internal", "src/pkg/sub/deep.go": "package sub",
}, },
} }
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -107,350 +105,146 @@ func TestGetAllFilesInPath_NestedDirs(t *testing.T) {
t.Fatalf("expected 3 files, got %d", len(result)) t.Fatalf("expected 3 files, got %d", len(result))
} }
if result["src/main.go"] != "package main" { if result["src/main.go"] != "package main" {
t.Errorf("unexpected content for main.go") t.Errorf("main.go content = %q", result["src/main.go"])
} }
if result["src/pkg/lib.go"] != "package pkg" { if result["src/pkg/lib.go"] != "package pkg" {
t.Errorf("unexpected content for lib.go") t.Errorf("lib.go content = %q", result["src/pkg/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"])
} }
})
func TestGetAllFilesInPath_Mixed(t *testing.T) { t.Run("mixed files and dirs", func(t *testing.T) {
mock := &mockFileReader{ client := &mockFileReader{
dirs: map[string][]vcs.ContentEntry{ contents: map[string][]vcs.ContentEntry{
"root": { "root": {
{Name: "README.md", Path: "root/README.md", Type: "file"}, {Name: "README.md", Path: "root/README.md", Type: "file"},
{Name: "empty", Path: "root/empty", Type: "dir"},
{Name: "docs", Path: "root/docs", Type: "dir"}, {Name: "docs", Path: "root/docs", Type: "dir"},
{Name: "config.yaml", Path: "root/config.yaml", Type: "file"},
}, },
"root/empty": {},
"root/docs": { "root/docs": {
{Name: "guide.md", Path: "root/docs/guide.md", Type: "file"}, {Name: "guide.md", Path: "root/docs/guide.md", Type: "file"},
}, },
}, },
contents: map[string]string{ files: map[string]string{
"root/README.md": "# Hello", "root/README.md": "# Hello",
"root/config.yaml": "key: value",
"root/docs/guide.md": "## Guide", "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 { if err != nil {
t.Fatalf("unexpected error: %v", err) 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 { if len(result) != 2 {
t.Fatalf("expected 2 files, got %d", len(result)) 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"] aMap, ok := result["a.go"]
if !ok { if !ok {
t.Fatal("expected a.go in result") 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"] bMap, ok := result["b.go"]
if !ok { if !ok {
t.Fatal("expected b.go in result") t.Fatal("expected b.go in result")
} }
// @@ line -> pos 1 // a.go line 3: "+// file a" -> position 4
// "+package b" -> pos 2, newLine 1 if aMap[3] != 4 {
// "+" -> pos 3, newLine 2 t.Errorf("a.go line 3 position = %d, want 4", aMap[3])
// "+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 { if bMap[3] != 4 {
t.Errorf("b.go line 3: expected pos 4, got %d", bMap[3]) t.Errorf("b.go line 3 position = %d, want 4", 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")
} }
})
} }