Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90959da830 |
@@ -15,7 +15,6 @@ 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"
|
||||||
@@ -813,7 +812,7 @@ func shouldSkipStaleReview(evaluatedSHA, currentSHA string) bool {
|
|||||||
return evaluatedSHA != currentSHA
|
return evaluatedSHA != currentSHA
|
||||||
}
|
}
|
||||||
|
|
||||||
// giteaClientAdapter adapts gitea.Client to vcs.FileReader interface.
|
// giteaClientAdapter adapts gitea.Client to review.GiteaClient interface.
|
||||||
type giteaClientAdapter struct {
|
type giteaClientAdapter struct {
|
||||||
client *gitea.Client
|
client *gitea.Client
|
||||||
}
|
}
|
||||||
@@ -822,14 +821,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) ([]vcs.ContentEntry, error) {
|
func (a *giteaClientAdapter) ListContents(ctx context.Context, owner, repo, path string) ([]review.ContentEntry, error) {
|
||||||
entries, err := a.client.ListContents(ctx, owner, repo, path)
|
entries, err := a.client.ListContents(ctx, owner, repo, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result := make([]vcs.ContentEntry, len(entries))
|
result := make([]review.ContentEntry, len(entries))
|
||||||
for i, e := range entries {
|
for i, e := range entries {
|
||||||
result[i] = vcs.ContentEntry{
|
result[i] = review.ContentEntry{
|
||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
Path: e.Path,
|
Path: e.Path,
|
||||||
Type: e.Type,
|
Type: e.Type,
|
||||||
@@ -838,9 +837,6 @@ 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, ref string) (string, error) {
|
func (a *giteaClientAdapter) GetFileContent(ctx context.Context, owner, repo, filepath 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -831,15 +831,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-4
@@ -4,19 +4,32 @@ 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 vcs.FileReader, owner, repo string) (map[string]*Persona, error) {
|
func LoadRepoPersonas(ctx context.Context, client GiteaClient, owner, repo string) (map[string]*Persona, error) {
|
||||||
result := make(map[string]*Persona)
|
result := make(map[string]*Persona)
|
||||||
|
|
||||||
entries, err := client.ListContents(ctx, owner, repo, RepoPersonaPath)
|
entries, err := client.ListContents(ctx, owner, repo, RepoPersonaPath)
|
||||||
@@ -44,7 +57,7 @@ func LoadRepoPersonas(ctx context.Context, client vcs.FileReader, owner, repo st
|
|||||||
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,
|
||||||
|
|||||||
+52
-21
@@ -5,8 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/vcs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParsePersonaBytes(t *testing.T) {
|
func TestParsePersonaBytes(t *testing.T) {
|
||||||
@@ -19,7 +17,11 @@ func TestParsePersonaBytes(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid yaml",
|
name: "valid yaml",
|
||||||
data: "name: test\nidentity: test identity\nfocus:\n - testing\n",
|
data: `name: test
|
||||||
|
identity: test identity
|
||||||
|
focus:
|
||||||
|
- testing
|
||||||
|
`,
|
||||||
source: "test.yaml",
|
source: "test.yaml",
|
||||||
wantName: "test",
|
wantName: "test",
|
||||||
},
|
},
|
||||||
@@ -65,15 +67,15 @@ func TestParsePersonaBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mockGiteaClient implements vcs.FileReader for testing.
|
// mockGiteaClient implements GiteaClient for testing.
|
||||||
type mockGiteaClient struct {
|
type mockGiteaClient struct {
|
||||||
contents map[string][]vcs.ContentEntry // path -> entries
|
contents map[string][]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) ([]vcs.ContentEntry, error) {
|
func (m *mockGiteaClient) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
||||||
if m.listErr != nil {
|
if m.listErr != nil {
|
||||||
return nil, m.listErr
|
return nil, m.listErr
|
||||||
}
|
}
|
||||||
@@ -84,7 +86,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, ref string) (string, error) {
|
func (m *mockGiteaClient) GetFileContent(ctx context.Context, owner, repo, filepath 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
|
||||||
@@ -116,7 +118,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][]vcs.ContentEntry{
|
contents: map[string][]ContentEntry{
|
||||||
RepoPersonaPath: {},
|
RepoPersonaPath: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -131,15 +133,27 @@ 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][]vcs.ContentEntry{
|
contents: map[string][]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\ndisplay_name: Trading Expert\nidentity: You are a trading expert.\nfocus:\n - order handling\n - risk management\n",
|
".review-bot/personas/trading.yaml": `name: trading
|
||||||
".review-bot/personas/crypto.yaml": "name: crypto\ndisplay_name: Crypto Expert\nidentity: You are a cryptography expert.\nfocus:\n - key management\n - encryption\n",
|
display_name: Trading Expert
|
||||||
|
identity: You are a trading expert.
|
||||||
|
focus:
|
||||||
|
- order handling
|
||||||
|
- risk management
|
||||||
|
`,
|
||||||
|
".review-bot/personas/crypto.yaml": `name: crypto
|
||||||
|
display_name: Crypto Expert
|
||||||
|
identity: You are a cryptography expert.
|
||||||
|
focus:
|
||||||
|
- key management
|
||||||
|
- encryption
|
||||||
|
`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
||||||
@@ -162,14 +176,16 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("skips invalid persona files", func(t *testing.T) {
|
t.Run("skips invalid persona files", func(t *testing.T) {
|
||||||
client := &mockGiteaClient{
|
client := &mockGiteaClient{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
contents: map[string][]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\nidentity: Valid persona\n",
|
".review-bot/personas/valid.yaml": `name: valid
|
||||||
|
identity: Valid persona
|
||||||
|
`,
|
||||||
".review-bot/personas/invalid.yaml": "not valid yaml: [broken",
|
".review-bot/personas/invalid.yaml": "not valid yaml: [broken",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -177,6 +193,7 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
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))
|
||||||
}
|
}
|
||||||
@@ -187,7 +204,7 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
|
|
||||||
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][]vcs.ContentEntry{
|
contents: map[string][]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"},
|
||||||
@@ -195,7 +212,9 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: map[string]string{
|
files: map[string]string{
|
||||||
".review-bot/personas/persona.yaml": "name: test\nidentity: Test persona\n",
|
".review-bot/personas/persona.yaml": `name: test
|
||||||
|
identity: Test persona
|
||||||
|
`,
|
||||||
".review-bot/personas/README.md": "# Personas\n\nPut your personas here.",
|
".review-bot/personas/README.md": "# Personas\n\nPut your personas here.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -210,14 +229,16 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("skips subdirectories", func(t *testing.T) {
|
t.Run("skips subdirectories", func(t *testing.T) {
|
||||||
client := &mockGiteaClient{
|
client := &mockGiteaClient{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
contents: map[string][]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\nidentity: Test persona\n",
|
".review-bot/personas/persona.yaml": `name: test
|
||||||
|
identity: Test persona
|
||||||
|
`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
personas, err := LoadRepoPersonas(ctx, client, "owner", "repo")
|
||||||
@@ -244,14 +265,16 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("skips files that fail to fetch", func(t *testing.T) {
|
t.Run("skips files that fail to fetch", func(t *testing.T) {
|
||||||
client := &mockGiteaClient{
|
client := &mockGiteaClient{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
contents: map[string][]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\nidentity: Good persona\n",
|
".review-bot/personas/good.yaml": `name: good
|
||||||
|
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"),
|
||||||
@@ -267,16 +290,19 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
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][]vcs.ContentEntry{
|
contents: map[string][]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\nidentity: Normal sized persona\n",
|
".review-bot/personas/normal.yaml": `name: normal
|
||||||
|
identity: Normal sized persona
|
||||||
|
`,
|
||||||
".review-bot/personas/huge.yaml": oversizedContent,
|
".review-bot/personas/huge.yaml": oversizedContent,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -284,6 +310,7 @@ func TestLoadRepoPersonas(t *testing.T) {
|
|||||||
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))
|
||||||
}
|
}
|
||||||
@@ -343,6 +370,7 @@ 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 {
|
||||||
@@ -350,6 +378,7 @@ 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)
|
||||||
@@ -393,6 +422,8 @@ 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},
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ 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.
|
||||||
@@ -25,7 +23,6 @@ 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,11 +15,6 @@ 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"`
|
||||||
@@ -33,11 +28,9 @@ 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.
|
||||||
@@ -53,14 +46,6 @@ 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"`
|
||||||
|
|||||||
+40
-43
@@ -3,6 +3,7 @@ package vcs
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,11 +40,18 @@ func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildLineToPositionMap parses a unified diff and returns a map of
|
// BuildLineToPositionMap parses a unified diff and returns a per-file map of
|
||||||
// filename -> (new line number -> diff position). The diff position is a
|
// new-file line number → diff-position.
|
||||||
// 1-indexed offset from the @@ hunk header line for each file.
|
//
|
||||||
// Only lines that appear in the new file (context lines and additions) are mapped.
|
// Position is a 1-indexed offset from the @@ hunk line (GitHub convention):
|
||||||
// Deletion-only lines are not included.
|
// - The @@ hunk header itself counts as position 1 (for the first hunk)
|
||||||
|
// - Every subsequent content line (context, addition, deletion) increments position
|
||||||
|
// - A second @@ hunk in the same file continues the count; it does not reset
|
||||||
|
// - Addition and context lines have new-file line numbers and are mapped
|
||||||
|
// - Deletion lines have a position but no new-file line; they are not in the map
|
||||||
|
// - "\ No newline at end of file" markers do not count as a position
|
||||||
|
//
|
||||||
|
// Returns: map[filename]map[newFileLine]diffPosition
|
||||||
func BuildLineToPositionMap(diff string) map[string]map[int]int {
|
func BuildLineToPositionMap(diff string) map[string]map[int]int {
|
||||||
result := make(map[string]map[int]int)
|
result := make(map[string]map[int]int)
|
||||||
|
|
||||||
@@ -53,7 +61,7 @@ func BuildLineToPositionMap(diff string) map[string]map[int]int {
|
|||||||
var newLine int
|
var newLine int
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
// Detect new file in diff
|
// Detect new file in diff — resets position counter per file.
|
||||||
if strings.HasPrefix(line, "+++ b/") {
|
if strings.HasPrefix(line, "+++ b/") {
|
||||||
currentFile = strings.TrimPrefix(line, "+++ b/")
|
currentFile = strings.TrimPrefix(line, "+++ b/")
|
||||||
position = 0
|
position = 0
|
||||||
@@ -64,82 +72,71 @@ func BuildLineToPositionMap(diff string) map[string]map[int]int {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip --- lines (old file header)
|
// Skip deleted-file target (no new lines to map).
|
||||||
if strings.HasPrefix(line, "--- ") {
|
if strings.HasPrefix(line, "+++ /dev/null") {
|
||||||
|
currentFile = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip diff --git lines
|
// Skip metadata lines that are not part of position counting.
|
||||||
if strings.HasPrefix(line, "diff --git") {
|
if strings.HasPrefix(line, "diff --git") ||
|
||||||
|
strings.HasPrefix(line, "--- ") ||
|
||||||
|
strings.HasPrefix(line, "index ") ||
|
||||||
|
strings.HasPrefix(line, "\\") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip index lines
|
// Parse hunk headers — the @@ line itself occupies a position.
|
||||||
if strings.HasPrefix(line, "index ") {
|
if strings.HasPrefix(line, "@@") && currentFile != "" {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process diff content lines
|
// Content lines within a hunk.
|
||||||
if strings.HasPrefix(line, "+") {
|
switch {
|
||||||
|
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++
|
||||||
} else if strings.HasPrefix(line, "-") {
|
case strings.HasPrefix(line, "-"):
|
||||||
|
// Deletion: occupies a position but has no new-file line number.
|
||||||
position++
|
position++
|
||||||
// Deletion lines don't map to new line numbers
|
case strings.HasPrefix(line, " "):
|
||||||
} else if strings.HasPrefix(line, " ") {
|
// Context line: maps to both a position and a new-file 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] @@
|
// Format: @@ -old_start[,old_count] +new_start[,new_count] @@[ optional section heading]
|
||||||
func parseHunkNewStart(hunkLine string) int {
|
func parseHunkNewStart(hunkLine string) int {
|
||||||
// Find the +N part
|
// Find the +N part after the first @@
|
||||||
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 or space
|
// Read digits until comma, space, or end.
|
||||||
var numStr string
|
if idx := strings.IndexAny(rest, ", @"); idx > 0 {
|
||||||
for _, ch := range rest {
|
rest = rest[:idx]
|
||||||
if ch >= '0' && ch <= '9' {
|
|
||||||
numStr += string(ch)
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if numStr == "" {
|
n, err := strconv.Atoi(rest)
|
||||||
|
if err != nil {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
n := 0
|
|
||||||
for _, ch := range numStr {
|
|
||||||
n = n*10 + int(ch-'0')
|
|
||||||
}
|
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|||||||
+348
-142
@@ -10,58 +10,59 @@ import (
|
|||||||
|
|
||||||
// mockFileReader implements vcs.FileReader for testing.
|
// mockFileReader implements vcs.FileReader for testing.
|
||||||
type mockFileReader struct {
|
type mockFileReader struct {
|
||||||
contents map[string][]vcs.ContentEntry // path -> entries
|
contents map[string]string // path -> content
|
||||||
files map[string]string // path -> content
|
dirs map[string][]vcs.ContentEntry // path -> entries
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockFileReader) GetFileContent(ctx context.Context, owner, repo, path, ref string) (string, error) {
|
func (m *mockFileReader) GetFileContent(_ context.Context, _, _, path, _ string) (string, error) {
|
||||||
content, ok := m.files[path]
|
content, ok := m.contents[path]
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("HTTP 404: file not found: %s", path)
|
return "", fmt.Errorf("file not found: %s", path)
|
||||||
}
|
}
|
||||||
return content, nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockFileReader) ListContents(ctx context.Context, owner, repo, path string) ([]vcs.ContentEntry, error) {
|
func (m *mockFileReader) ListContents(_ context.Context, _, _, path string) ([]vcs.ContentEntry, error) {
|
||||||
entries, ok := m.contents[path]
|
entries, ok := m.dirs[path]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("HTTP 404: path not found: %s", path)
|
return nil, fmt.Errorf("directory not found: %s", path)
|
||||||
}
|
}
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAllFilesInPath(t *testing.T) {
|
// --- GetAllFilesInPath tests ---
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
t.Run("empty directory", func(t *testing.T) {
|
func TestGetAllFilesInPath_EmptyDir(t *testing.T) {
|
||||||
client := &mockFileReader{
|
mock := &mockFileReader{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
dirs: 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))
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
t.Run("flat directory", func(t *testing.T) {
|
func TestGetAllFilesInPath_FlatDir(t *testing.T) {
|
||||||
client := &mockFileReader{
|
mock := &mockFileReader{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
dirs: 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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: map[string]string{
|
contents: map[string]string{
|
||||||
"src/main.go": "package main",
|
"src/main.go": "package main",
|
||||||
"src/util.go": "package main\n// util",
|
"src/util.go": "package main\n\nfunc helper() {}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
||||||
|
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -69,35 +70,36 @@ func TestGetAllFilesInPath(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("main.go content = %q", result["src/main.go"])
|
t.Errorf("unexpected content for main.go: %q", result["src/main.go"])
|
||||||
|
}
|
||||||
|
if result["src/util.go"] != "package main\n\nfunc helper() {}" {
|
||||||
|
t.Errorf("unexpected content for util.go: %q", result["src/util.go"])
|
||||||
}
|
}
|
||||||
if result["src/util.go"] != "package main\n// util" {
|
|
||||||
t.Errorf("util.go content = %q", result["src/util.go"])
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nested directories", func(t *testing.T) {
|
func TestGetAllFilesInPath_NestedDirs(t *testing.T) {
|
||||||
client := &mockFileReader{
|
mock := &mockFileReader{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
dirs: 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: "sub", Path: "src/pkg/sub", Type: "dir"},
|
{Name: "internal", Path: "src/pkg/internal", Type: "dir"},
|
||||||
},
|
},
|
||||||
"src/pkg/sub": {
|
"src/pkg/internal": {
|
||||||
{Name: "deep.go", Path: "src/pkg/sub/deep.go", Type: "file"},
|
{Name: "helper.go", Path: "src/pkg/internal/helper.go", Type: "file"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: map[string]string{
|
contents: map[string]string{
|
||||||
"src/main.go": "package main",
|
"src/main.go": "package main",
|
||||||
"src/pkg/lib.go": "package pkg",
|
"src/pkg/lib.go": "package pkg",
|
||||||
"src/pkg/sub/deep.go": "package sub",
|
"src/pkg/internal/helper.go": "package internal",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result, err := vcs.GetAllFilesInPath(ctx, client, "owner", "repo", "src")
|
|
||||||
|
result, err := vcs.GetAllFilesInPath(context.Background(), mock, "owner", "repo", "src")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -105,146 +107,350 @@ func TestGetAllFilesInPath(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("main.go content = %q", result["src/main.go"])
|
t.Errorf("unexpected content for main.go")
|
||||||
}
|
}
|
||||||
if result["src/pkg/lib.go"] != "package pkg" {
|
if result["src/pkg/lib.go"] != "package pkg" {
|
||||||
t.Errorf("lib.go content = %q", result["src/pkg/lib.go"])
|
t.Errorf("unexpected content for lib.go")
|
||||||
|
}
|
||||||
|
if result["src/pkg/internal/helper.go"] != "package internal" {
|
||||||
|
t.Errorf("unexpected content for helper.go")
|
||||||
}
|
}
|
||||||
if result["src/pkg/sub/deep.go"] != "package sub" {
|
|
||||||
t.Errorf("deep.go content = %q", result["src/pkg/sub/deep.go"])
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("mixed files and dirs", func(t *testing.T) {
|
func TestGetAllFilesInPath_Mixed(t *testing.T) {
|
||||||
client := &mockFileReader{
|
mock := &mockFileReader{
|
||||||
contents: map[string][]vcs.ContentEntry{
|
dirs: 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"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: map[string]string{
|
contents: 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")
|
||||||
}
|
}
|
||||||
// a.go line 3: "+// file a" -> position 4
|
// @@ line -> pos 1
|
||||||
if aMap[3] != 4 {
|
// "+package b" -> pos 2, newLine 1
|
||||||
t.Errorf("a.go line 3 position = %d, want 4", aMap[3])
|
// "+" -> pos 3, newLine 2
|
||||||
|
// "+func B() {}" -> pos 4, newLine 3
|
||||||
|
if bMap[1] != 2 {
|
||||||
|
t.Errorf("b.go line 1: expected pos 2, got %d", bMap[1])
|
||||||
|
}
|
||||||
|
if bMap[2] != 3 {
|
||||||
|
t.Errorf("b.go line 2: expected pos 3, got %d", bMap[2])
|
||||||
}
|
}
|
||||||
// b.go line 3: "+// file b" -> position 4
|
|
||||||
if bMap[3] != 4 {
|
if bMap[3] != 4 {
|
||||||
t.Errorf("b.go line 3 position = %d, want 4", bMap[3])
|
t.Errorf("b.go line 3: expected pos 4, got %d", bMap[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLineToPositionMap_EmptyDiff(t *testing.T) {
|
||||||
|
result := vcs.BuildLineToPositionMap("")
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Errorf("expected empty map for empty diff, got %d entries", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLineToPositionMap_NoNewlineMarker(t *testing.T) {
|
||||||
|
diff := `diff --git a/a.go b/a.go
|
||||||
|
--- a/a.go
|
||||||
|
+++ b/a.go
|
||||||
|
@@ -1,2 +1,2 @@
|
||||||
|
-old
|
||||||
|
+new
|
||||||
|
\ No newline at end of file
|
||||||
|
`
|
||||||
|
|
||||||
|
result := vcs.BuildLineToPositionMap(diff)
|
||||||
|
|
||||||
|
fileMap := result["a.go"]
|
||||||
|
// @@ line -> pos 1
|
||||||
|
// "-old" -> pos 2
|
||||||
|
// "+new" -> pos 3, newLine 1
|
||||||
|
// "\ No.." -> not counted
|
||||||
|
if fileMap[1] != 3 {
|
||||||
|
t.Errorf("line 1: expected position 3, got %d", fileMap[1])
|
||||||
|
}
|
||||||
|
if len(fileMap) != 1 {
|
||||||
|
t.Errorf("expected 1 entry, got %d", len(fileMap))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLineToPositionMap_SingleLineHunk(t *testing.T) {
|
||||||
|
diff := `diff --git a/single.go b/single.go
|
||||||
|
--- a/single.go
|
||||||
|
+++ b/single.go
|
||||||
|
@@ -1 +1 @@
|
||||||
|
-old
|
||||||
|
+new
|
||||||
|
`
|
||||||
|
|
||||||
|
result := vcs.BuildLineToPositionMap(diff)
|
||||||
|
|
||||||
|
fileMap := result["single.go"]
|
||||||
|
// @@ line -> pos 1
|
||||||
|
// "-old" -> pos 2
|
||||||
|
// "+new" -> pos 3, newLine 1
|
||||||
|
if fileMap[1] != 3 {
|
||||||
|
t.Errorf("line 1: expected position 3, got %d", fileMap[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildLineToPositionMap_DeletedFile(t *testing.T) {
|
||||||
|
diff := `diff --git a/deleted.go b/deleted.go
|
||||||
|
deleted file mode 100644
|
||||||
|
--- a/deleted.go
|
||||||
|
+++ /dev/null
|
||||||
|
@@ -1,3 +0,0 @@
|
||||||
|
-package deleted
|
||||||
|
-
|
||||||
|
-func Gone() {}
|
||||||
|
`
|
||||||
|
|
||||||
|
result := vcs.BuildLineToPositionMap(diff)
|
||||||
|
|
||||||
|
if _, ok := result["deleted.go"]; ok {
|
||||||
|
t.Error("deleted file should not appear in result")
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user