Compare commits

..

3 Commits

Author SHA1 Message Date
claw ec03dc2373 fix: address remaining review findings (interface assertions, DismissReview ctx, import order, filepath param, spelling)
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
2026-05-12 13:07:41 -07:00
claw 1749d95727 fix(vcs): address review findings on PR #88
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 1m8s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m53s
Findings addressed:
- F1/G1: Add doc comment to GetAllFilesInPath documenting fail-fast contract
- F2/G2: Add explicit backslash-prefix guard to skip '\ No newline' markers
- F3: Add comment explaining position > 0 guard (skip lines before first hunk)
- F4: Refactor parseHunkNewStart to use strconv.Atoi instead of per-char concat
- F5: Add error propagation tests (ListContents, GetFileContent, nested, ctx cancel)
- F6: Wrap errors.ErrUnsupported in DismissReview for programmatic checking
- S1: Add ctx.Err() checks + max file count/byte constants with clear errors
- S2: Addressed by S1 — input bounds are now enforced via the same constants
2026-05-12 12:56:13 -07:00
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
9 changed files with 507 additions and 566 deletions
+10 -6
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) {
return a.client.GetFileContent(ctx, owner, repo, filepath) if ref != "" {
return a.client.GetFileContentRef(ctx, owner, repo, filePath, ref)
}
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("dismiss review %d on %s/%s#%d: %w", reviewID, owner, repo, number, errors.ErrUnsupported)
}
// 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)
}
+25
View File
@@ -0,0 +1,25 @@
//go:build phase2
package gitea_test
import (
"gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// Compile-time interface conformance assertions.
// These will verify gitea.Client satisfies vcs interfaces once the Phase 2
// adapter bridges the method signature gaps:
//
// - PRReader: GetPullRequest returns *gitea.PullRequest (needs *vcs.PullRequest)
// - PRReader: GetPullRequestFiles returns []gitea.ChangedFile (needs []vcs.ChangedFile)
// - FileReader: GetFileContent lacks ref parameter
// - Reviewer: PostReview uses (event, body, comments) instead of vcs.ReviewRequest
//
// Remove the phase2 build tag once the adapter is complete.
var (
_ vcs.PRReader = (*gitea.Client)(nil)
_ vcs.FileReader = (*gitea.Client)(nil)
_ vcs.Reviewer = (*gitea.Client)(nil)
_ vcs.Identity = (*gitea.Client)(nil)
)
+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,
+31 -62
View File
@@ -5,23 +5,21 @@ import (
"errors" "errors"
"strings" "strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/vcs"
) )
func TestParsePersonaBytes(t *testing.T) { func TestParsePersonaBytes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
data string data string
source string source string
wantName string wantName string
wantErr string wantErr string
}{ }{
{ {
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",
}, },
@@ -38,8 +36,8 @@ focus:
wantErr: "parse", wantErr: "parse",
}, },
{ {
name: "json format by extension", name: "json format by extension",
data: `{"name": "jsontest", "identity": "json identity"}`, data: `{"name": "jsontest", "identity": "json identity"}`,
source: "test.json", source: "test.json",
wantName: "jsontest", wantName: "jsontest",
}, },
@@ -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,10 +195,8 @@ 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.",
}, },
} }
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo") personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
@@ -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,27 +267,23 @@ 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,
}, },
} }
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo") personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
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.
+18 -3
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 {
Title string `json:"title"` Number int `json:"number"`
Body string `json:"body"` Title string `json:"title"`
Head HeadRef `json:"head"` Body string `json:"body"`
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"`
+114 -63
View File
@@ -7,51 +7,86 @@ import (
"strings" "strings"
) )
const (
// maxFilesInPath is the maximum number of files GetAllFilesInPath will fetch.
// Prevents unbounded resource consumption on very large directory trees.
maxFilesInPath = 10000
// maxTotalBytesInPath is the maximum total bytes GetAllFilesInPath will accumulate.
// Prevents memory exhaustion when fetching large repositories.
maxTotalBytesInPath = 100 * 1024 * 1024 // 100 MB
)
// GetAllFilesInPath recursively fetches all file contents under a path using the // GetAllFilesInPath recursively fetches all file contents under a path using the
// provided FileReader. Returns a map of filepath -> content for all files found. // provided FileReader. Returns a map of filepath -> content for all files found.
// If the path points to an empty directory, returns an empty map. // If the path points to an empty directory, returns an empty map.
//
// This function uses fail-fast error handling: any error from ListContents or
// GetFileContent aborts the entire traversal and returns the error immediately.
// This differs from gitea.Client.GetAllFilesInPath, which logs errors and continues.
// The fail-fast contract ensures callers can trust that a nil error means all files
// were successfully fetched.
//
// Resource limits: the traversal is bounded by maxFilesInPath (file count) and
// maxTotalBytesInPath (total accumulated bytes). The context is checked before each
// recursive call and file fetch to respect cancellation.
func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path string) (map[string]string, error) { func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path string) (map[string]string, error) {
results := make(map[string]string) results := make(map[string]string)
totalBytes := 0
entries, err := client.ListContents(ctx, owner, repo, path) var walk func(string) error
if err != nil { walk = func(dir string) error {
return nil, fmt.Errorf("list contents %q: %w", path, err) if err := ctx.Err(); err != nil {
} return fmt.Errorf("context canceled during traversal: %w", err)
}
for _, entry := range entries { entries, err := client.ListContents(ctx, owner, repo, dir)
switch entry.Type { if err != nil {
case "file": return fmt.Errorf("list contents %q: %w", dir, err)
content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "") }
if err != nil {
return nil, fmt.Errorf("get file %q: %w", entry.Path, err) for _, entry := range entries {
if err := ctx.Err(); err != nil {
return fmt.Errorf("context canceled during traversal: %w", err)
} }
results[entry.Path] = content
case "dir": switch entry.Type {
subResults, err := GetAllFilesInPath(ctx, client, owner, repo, entry.Path) case "file":
if err != nil { if len(results) >= maxFilesInPath {
return nil, fmt.Errorf("recurse into %q: %w", entry.Path, err) return fmt.Errorf("exceeded max file count (%d) in path %q", maxFilesInPath, path)
} }
for k, v := range subResults {
results[k] = v content, err := client.GetFileContent(ctx, owner, repo, entry.Path, "")
if err != nil {
return fmt.Errorf("get file %q: %w", entry.Path, err)
}
totalBytes += len(content)
if totalBytes > maxTotalBytesInPath {
return fmt.Errorf("exceeded max total bytes (%d) in path %q", maxTotalBytesInPath, path)
}
results[entry.Path] = content
case "dir":
if err := walk(entry.Path); err != nil {
return err
}
} }
} }
return nil
} }
if err := walk(path); err != nil {
return nil, err
}
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 +96,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,69 +107,85 @@ 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. // Skip "\ No newline at end of file" markers — these are git diff
switch { // metadata and not part of the file content.
case strings.HasPrefix(line, "+"): if strings.HasPrefix(line, `\`) {
// Addition: maps to both a position and a new-file line. continue
position++ }
result[currentFile][newLine] = position
newLine++ // Process diff content lines
case strings.HasPrefix(line, "-"): if strings.HasPrefix(line, "+") {
// Deletion: occupies a position but has no new-file line number. position++
position++ result[currentFile][newLine] = position
case strings.HasPrefix(line, " "): newLine++
// Context line: maps to both a position and a new-file line. } else if strings.HasPrefix(line, "-") {
position++ position++
result[currentFile][newLine] = position // Deletion lines don't map to new line numbers
newLine++ } else if strings.HasPrefix(line, " ") {
// Context line (space-prefixed).
// Only map if position > 0, which means we've seen a hunk header.
// Lines before the first hunk header (position == 0) are not part
// of any diff hunk and should be skipped.
if position > 0 {
position++
result[currentFile][newLine] = position
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. // Find the end of the number (first non-digit after +)
if idx := strings.IndexAny(rest, ", @"); idx > 0 { endIdx := 0
rest = rest[:idx] for endIdx < len(rest) && rest[endIdx] >= '0' && rest[endIdx] <= '9' {
endIdx++
} }
n, err := strconv.Atoi(rest) if endIdx == 0 {
return 1
}
n, err := strconv.Atoi(rest[:endIdx])
if err != nil { if err != nil {
return 1 return 1
} }
+289 -414
View File
@@ -3,6 +3,7 @@ package vcs_test
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"testing" "testing"
"gitea.weiker.me/rodin/review-bot/vcs" "gitea.weiker.me/rodin/review-bot/vcs"
@@ -10,447 +11,321 @@ 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(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))
}
}
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"},
}, },
},
contents: map[string]string{
"src/main.go": "package main",
"src/util.go": "package main\n\nfunc helper() {}",
},
}
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "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("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"])
}
}
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: "internal", Path: "src/pkg/internal", Type: "dir"},
},
"src/pkg/internal": {
{Name: "helper.go", Path: "src/pkg/internal/helper.go", Type: "file"},
},
},
contents: map[string]string{
"src/main.go": "package main",
"src/pkg/lib.go": "package pkg",
"src/pkg/internal/helper.go": "package internal",
},
}
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "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("unexpected content for main.go")
}
if result["src/pkg/lib.go"] != "package pkg" {
t.Errorf("unexpected content for lib.go")
}
if result["src/pkg/internal/helper.go"] != "package internal" {
t.Errorf("unexpected content for helper.go")
}
}
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"},
},
"root/empty": {},
"root/docs": {
{Name: "guide.md", Path: "root/docs/guide.md", Type: "file"},
},
},
contents: map[string]string{
"root/README.md": "# Hello",
"root/docs/guide.md": "## Guide",
},
}
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "root")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
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 { result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
t.Errorf("line %d: expected position %d, got %d", line, wantPos, gotPos) if err != nil {
t.Fatalf("unexpected error: %v", err)
} }
} if len(result) != 0 {
t.Errorf("expected empty map, got %d entries", len(result))
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)
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_DeletionLinesNotInMap(t *testing.T) { func TestBuildLineToPositionMap(t *testing.T) {
diff := `diff --git a/old.go b/old.go t.Run("single hunk", func(t *testing.T) {
--- a/old.go 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"
+++ b/old.go result := vcs.BuildLineToPositionMap(diff)
@@ -1,3 +1,1 @@ fileMap, ok := result["file.go"]
-line one if !ok {
-line two t.Fatal("expected file.go in result")
remaining }
` // 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])
}
})
result := vcs.BuildLineToPositionMap(diff) 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])
}
})
fileMap, ok := result["old.go"] t.Run("deletion lines not in map", func(t *testing.T) {
if !ok { 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"
t.Fatal("expected old.go in result") 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)
}
})
// @@ line -> pos 1 t.Run("multiple files", func(t *testing.T) {
// "-line one" -> pos 2, no new line 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"
// "-line two" -> pos 3, no new line result := vcs.BuildLineToPositionMap(diff)
// " remaining" -> pos 4, newLine 1 if len(result) != 2 {
t.Fatalf("expected 2 files, got %d", len(result))
if pos, ok := fileMap[1]; !ok || pos != 4 { }
t.Errorf("line 1: expected position 4, got %d (exists=%v)", pos, ok) aMap, ok := result["a.go"]
} if !ok {
if len(fileMap) != 1 { t.Fatal("expected a.go in result")
t.Errorf("expected 1 entry (only 'remaining'), got %d", len(fileMap)) }
} 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 TestBuildLineToPositionMap_MultipleFiles(t *testing.T) { func TestGetAllFilesInPath_ErrorPropagation(t *testing.T) {
diff := `diff --git a/a.go b/a.go ctx := context.Background()
--- a/a.go
+++ b/a.go
@@ -1,2 +1,3 @@
package a
+// added
diff --git a/b.go b/b.go t.Run("ListContents error propagates", func(t *testing.T) {
new file mode 100644 client := &mockFileReader{
--- /dev/null contents: map[string][]vcs.ContentEntry{
+++ b/b.go // "src" not in map, so ListContents will fail
@@ -0,0 +1,3 @@ },
+package b }
+ _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
+func B() {} 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)
}
})
result := vcs.BuildLineToPositionMap(diff) 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)
}
})
// File a.go t.Run("nested ListContents error propagates", func(t *testing.T) {
aMap, ok := result["a.go"] client := &mockFileReader{
if !ok { contents: map[string][]vcs.ContentEntry{
t.Fatal("expected a.go in result") "src": {
} {Name: "pkg", Path: "src/pkg", Type: "dir"},
// @@ line -> pos 1 },
// " package a" -> pos 2, newLine 1 // "src/pkg" not in map, so recursive ListContents will fail
// "+// added" -> pos 3, newLine 2 },
// " " -> pos 4, newLine 3 }
if aMap[1] != 2 { _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
t.Errorf("a.go line 1: expected pos 2, got %d", aMap[1]) if err == nil {
} t.Fatal("expected error, got nil")
if aMap[2] != 3 { }
t.Errorf("a.go line 2: expected pos 3, got %d", aMap[2]) if !strings.Contains(err.Error(), "list contents") {
} t.Errorf("expected error about list contents, got: %v", err)
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 t.Run("canceled context propagates", func(t *testing.T) {
bMap, ok := result["b.go"] ctx, cancel := context.WithCancel(context.Background())
if !ok { cancel() // Cancel immediately
t.Fatal("expected b.go in result")
} client := &mockFileReader{
// @@ line -> pos 1 contents: map[string][]vcs.ContentEntry{
// "+package b" -> pos 2, newLine 1 "src": {
// "+" -> pos 3, newLine 2 {Name: "main.go", Path: "src/main.go", Type: "file"},
// "+func B() {}" -> pos 4, newLine 3 },
if bMap[1] != 2 { },
t.Errorf("b.go line 1: expected pos 2, got %d", bMap[1]) files: map[string]string{
} "src/main.go": "package main",
if bMap[2] != 3 { },
t.Errorf("b.go line 2: expected pos 3, got %d", bMap[2]) }
} _, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
if bMap[3] != 4 { if err == nil {
t.Errorf("b.go line 3: expected pos 4, got %d", bMap[3]) 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)
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")
}
} }