feat(github): implement FileReader interface #103

Merged
aweiker merged 2 commits from issue-80-c-file-reader into feature/github-support 2026-05-13 06:05:58 +00:00
3 changed files with 377 additions and 5 deletions
+6 -3
View File
@@ -5,6 +5,9 @@ import (
"gitea.weiker.me/rodin/review-bot/vcs"
)
rodin marked this conversation as resolved
Review

[NIT] Compile-time interface conformance checks live in a test file. Consider also placing them in non-test source to catch interface drift during regular builds (without requiring tests to run).

**[NIT]** Compile-time interface conformance checks live in a test file. Consider also placing them in non-test source to catch interface drift during regular builds (without requiring tests to run).
// Compile-time interface conformance assertion.
// Verifies github.Client satisfies vcs.PRReader.
var _ vcs.PRReader = (*github.Client)(nil)
// Compile-time interface conformance assertions.
// These verify github.Client satisfies vcs.PRReader and vcs.FileReader.
var (
rodin marked this conversation as resolved Outdated
Outdated
Review

[NIT] Compile-time interface conformance checks are placed in a test file (external test package). While effective, consider moving them to a non-test source file in the package so interface drift is caught during normal builds, consistent with the standard pattern (var _ Interface = (*Type)(nil)).

**[NIT]** Compile-time interface conformance checks are placed in a test file (external test package). While effective, consider moving them to a non-test source file in the package so interface drift is caught during normal builds, consistent with the standard pattern (var _ Interface = (*Type)(nil)).
_ vcs.PRReader = (*github.Client)(nil)
_ vcs.FileReader = (*github.Client)(nil)
)
+60
View File
@@ -8,8 +8,16 @@ import (
"net/url"
"path"
"strings"
"gitea.weiker.me/rodin/review-bot/vcs"
)
// GetFileContent fetches a file from a repo at the given ref.
// Delegates to GetFileContentAtRef with the provided ref.
rodin marked this conversation as resolved Outdated
Outdated
Review

[MINOR] GetFileContent is a thin wrapper that just calls GetFileContentAtRef. The doc comment 'Delegates to GetFileContentAtRef with the provided ref' is accurate, but this method exists only to satisfy the vcs.FileReader interface (if that's what it is). If the interface has the exact same signature as GetFileContentAtRef, consider whether GetFileContent should be the primary implementation and GetFileContentAtRef the derivative (or vice versa). As written, the public API surface is slightly confusing — callers see two methods with nearly identical signatures.

**[MINOR]** GetFileContent is a thin wrapper that just calls GetFileContentAtRef. The doc comment 'Delegates to GetFileContentAtRef with the provided ref' is accurate, but this method exists only to satisfy the vcs.FileReader interface (if that's what it is). If the interface has the exact same signature as GetFileContentAtRef, consider whether GetFileContent should be the primary implementation and GetFileContentAtRef the derivative (or vice versa). As written, the public API surface is slightly confusing — callers see two methods with nearly identical signatures.
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filePath, ref string) (string, error) {
rodin marked this conversation as resolved Outdated
Outdated
Review

[MINOR] GetFileContent is a pure one-line delegation to GetFileContentAtRef. If GetFileContent is required to satisfy the vcs.FileReader interface exactly, this is fine. If it is purely an alias, consider whether the exported surface is necessary or whether callers should call GetFileContentAtRef directly. The doc comment correctly explains the delegation, so this is a minor design observation rather than a bug.

**[MINOR]** `GetFileContent` is a pure one-line delegation to `GetFileContentAtRef`. If `GetFileContent` is required to satisfy the `vcs.FileReader` interface exactly, this is fine. If it is purely an alias, consider whether the exported surface is necessary or whether callers should call `GetFileContentAtRef` directly. The doc comment correctly explains the delegation, so this is a minor design observation rather than a bug.
Review

[MINOR] GetFileContent is a pure delegation wrapper that adds no behavior beyond calling GetFileContentAtRef. Per the documentation pattern, the comment 'Delegates to GetFileContentAtRef with the provided ref' is accurate but the function itself is only needed to satisfy the vcs.FileReader interface. This is fine, but the doc comment could note that it exists to satisfy the FileReader interface contract, which would explain why this thin wrapper exists rather than just exposing GetFileContentAtRef directly.

**[MINOR]** GetFileContent is a pure delegation wrapper that adds no behavior beyond calling GetFileContentAtRef. Per the documentation pattern, the comment 'Delegates to GetFileContentAtRef with the provided ref' is accurate but the function itself is only needed to satisfy the vcs.FileReader interface. This is fine, but the doc comment could note that it exists to satisfy the FileReader interface contract, which would explain why this thin wrapper exists rather than just exposing GetFileContentAtRef directly.
return c.GetFileContentAtRef(ctx, owner, repo, filePath, ref)
}
// GetFileContentAtRef fetches a file at a specific ref from a repo.
// If ref is empty, the query parameter is omitted (uses default branch).
//
@@ -46,6 +54,58 @@ func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, filePath,
return decoded, nil
rodin marked this conversation as resolved
Review

[MINOR] ListContents rejects an empty filePath via escapePath() (cleaned == "."), which prevents listing the repository root. If the FileReader contract expects root listings, consider special-casing empty paths to call /contents without a trailing path segment.

**[MINOR]** ListContents rejects an empty filePath via escapePath() (cleaned == "."), which prevents listing the repository root. If the FileReader contract expects root listings, consider special-casing empty paths to call /contents without a trailing path segment.
}
rodin marked this conversation as resolved
Review

[MINOR] ListContents rejects an empty path because escapePath("") returns an error (cleaned == "."). This prevents listing the repository root with an empty path, which the GitHub API supports. Consider treating an empty path as root (omit the trailing segment or pass "/") before calling escapePath.

**[MINOR]** ListContents rejects an empty path because escapePath("") returns an error (cleaned == "."). This prevents listing the repository root with an empty path, which the GitHub API supports. Consider treating an empty path as root (omit the trailing segment or pass "/") before calling escapePath.
// ListContents lists files and directories at a given path in a repo.
// Returns the directory listing from the GitHub contents API.
// If the path points to a single file (not a directory), the API returns
// a JSON object instead of an array; this is handled by returning a
// single-element slice.
func (c *Client) ListContents(ctx context.Context, owner, repo, filePath string) ([]vcs.ContentEntry, error) {
escaped, err := escapePath(filePath)
if err != nil {
return nil, fmt.Errorf("invalid file path: %w", err)
}
reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escaped)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list contents %s: %w", filePath, err)
}
type entry struct {
Name string `json:"name"`
Path string `json:"path"`
rodin marked this conversation as resolved
Review

[NIT] When falling back from array to object JSON, the error uses %v for the first unmarshal and %w for the second. Consider using errors.Join(err, err2) or dual %w (Go 1.20+) to preserve both errors in the chain rather than formatting one as text.

**[NIT]** When falling back from array to object JSON, the error uses %v for the first unmarshal and %w for the second. Consider using errors.Join(err, err2) or dual %w (Go 1.20+) to preserve both errors in the chain rather than formatting one as text.
Type string `json:"type"`
}
rodin marked this conversation as resolved
Review

[NIT] When both array and object JSON unmarshals fail, the error uses %v for the first and %w for the second. Consider using errors.Join to preserve both underlying errors in a single error value for easier inspection.

**[NIT]** When both array and object JSON unmarshals fail, the error uses %v for the first and %w for the second. Consider using errors.Join to preserve both underlying errors in a single error value for easier inspection.
// The GitHub contents API returns an array for directories and an object
// for single files. Try array first (common case), then fall back to object.
// An empty array ([]) is valid — it represents an empty directory — and
// results in a zero-length slice returned without error.
rodin marked this conversation as resolved Outdated
Outdated
Review

[NIT] Comment uses the term "package-private" which is not idiomatic Go terminology; prefer "unexported" to describe non-exported identifiers.

**[NIT]** Comment uses the term "package-private" which is not idiomatic Go terminology; prefer "unexported" to describe non-exported identifiers.
var entries []entry
if err := json.Unmarshal(body, &entries); err != nil {
var single entry
rodin marked this conversation as resolved Outdated
Outdated
Review

[MINOR] The empty-object guard (single.Name == "" && single.Path == "" && single.Type == "") will also trigger on a real but empty file whose name/path/type fields happened to be zero strings — unlikely in practice but semantically imprecise. The real distinguishing factor between a valid file entry and an unexpected JSON shape is whether the unmarshal into entry succeeded on something that looks like neither an array nor a recognisable object; since json.Unmarshal of {} into a struct succeeds silently, the guard is reasonable, but the comment could note this is a heuristic, not a definitive check. Alternatively, using json.RawMessage and inspecting the first byte ([ vs {) before branching would be more robust and self-documenting.

**[MINOR]** The empty-object guard (`single.Name == "" && single.Path == "" && single.Type == ""`) will also trigger on a real but empty file whose name/path/type fields happened to be zero strings — unlikely in practice but semantically imprecise. The real distinguishing factor between a valid file entry and an unexpected JSON shape is whether the unmarshal into `entry` succeeded on something that looks like neither an array nor a recognisable object; since `json.Unmarshal` of `{}` into a struct succeeds silently, the guard is reasonable, but the comment could note this is a heuristic, not a definitive check. Alternatively, using `json.RawMessage` and inspecting the first byte (`[` vs `{`) before branching would be more robust and self-documenting.
if err2 := json.Unmarshal(body, &single); err2 != nil {
rodin marked this conversation as resolved Outdated
Outdated
Review

[MINOR] escapePath removes '.' and '..' segments by skipping them rather than collapsing them (e.g., 'foo/../bar' becomes 'foo/bar' instead of 'bar'). While documented, this deviates from canonical path semantics and may surprise callers by silently changing the requested path. Consider using path.Clean-like behavior (collapsing) or renaming to clarify the intentional non-canonical sanitization.

**[MINOR]** escapePath removes '.' and '..' segments by skipping them rather than collapsing them (e.g., 'foo/../bar' becomes 'foo/bar' instead of 'bar'). While documented, this deviates from canonical path semantics and may surprise callers by silently changing the requested path. Consider using path.Clean-like behavior (collapsing) or renaming to clarify the intentional non-canonical sanitization.
return nil, fmt.Errorf("parse contents JSON: as array: %v; as object: %w", err, err2)
}
// Guard against empty objects ({}) or unexpected shapes that
// unmarshal successfully but carry no useful data.
if single.Name == "" && single.Path == "" && single.Type == "" {
return nil, fmt.Errorf("parse contents JSON: unexpected response format")
rodin marked this conversation as resolved
Review

[MINOR] The empty-object guard (single.Name == "" && single.Path == "" && single.Type == "") catches unexpected shapes, but an empty JSON object {} would unmarshal successfully into entry{} and be rejected here — that's correct. However, the error message 'unexpected response format' is not wrapped (no %w), so there's no underlying error for callers to inspect. This is acceptable since there's no underlying error to wrap, but worth noting for consistency with the rest of the error handling in this file.

**[MINOR]** The empty-object guard (`single.Name == "" && single.Path == "" && single.Type == ""`) catches unexpected shapes, but an empty JSON object `{}` would unmarshal successfully into `entry{}` and be rejected here — that's correct. However, the error message 'unexpected response format' is not wrapped (no %w), so there's no underlying error for callers to inspect. This is acceptable since there's no underlying error to wrap, but worth noting for consistency with the rest of the error handling in this file.
Review

[NIT] The entry struct type is defined inside ListContents but could be a package-level unexported type. This is minor since it's only used here, but it makes it harder to reuse in tests or if additional methods need similar parsing. Not a real problem given the current scope.

**[NIT]** The `entry` struct type is defined inside `ListContents` but could be a package-level unexported type. This is minor since it's only used here, but it makes it harder to reuse in tests or if additional methods need similar parsing. Not a real problem given the current scope.
}
entries = []entry{single}
rodin marked this conversation as resolved Outdated
Outdated
Review

[MINOR] The 'try array first, fall back to object' JSON parsing strategy is a pragmatic workaround for the GitHub API's inconsistent response shape. The approach is well-commented, but an empty JSON array [] will unmarshal successfully into a nil []entry (not an empty struct), so the guard at line 100 (checking Name/Path/Type all empty) is actually only reached when the fallback object parse succeeds — it cannot be triggered by the successful array parse. The logic is correct, but the comment 'An empty array ([]) is valid' could be more precise: the entries variable will be nil (not zero-len) when the array is empty, and len(nil) == 0, so make([]vcs.ContentEntry, 0) would be returned instead of nil. This is a behavior difference compared to the populated-array path which returns a non-nil slice.

**[MINOR]** The 'try array first, fall back to object' JSON parsing strategy is a pragmatic workaround for the GitHub API's inconsistent response shape. The approach is well-commented, but an empty JSON array `[]` will unmarshal successfully into a nil `[]entry` (not an empty struct), so the guard at line 100 (checking Name/Path/Type all empty) is actually only reached when the fallback object parse succeeds — it cannot be triggered by the successful array parse. The logic is correct, but the comment 'An empty array ([]) is valid' could be more precise: the `entries` variable will be nil (not zero-len) when the array is empty, and `len(nil) == 0`, so `make([]vcs.ContentEntry, 0)` would be returned instead of nil. This is a behavior difference compared to the populated-array path which returns a non-nil slice.
}
result := make([]vcs.ContentEntry, len(entries))
for i, e := range entries {
result[i] = vcs.ContentEntry{
Name: e.Name,
Path: e.Path,
Type: e.Type,
}
rodin marked this conversation as resolved
Review

[NIT] The dual-unmarshal fallback (try array, then object) silently discards the first error err by capturing it in the error message string. This is intentional and documented, but the variable shadowing of err from the outer doGet call is slightly confusing. The code is correct but worth a quick read to confirm err at line 83 (doGet error) vs err at the inner json.Unmarshal are distinct. They are, because the doGet error is already returned early.

**[NIT]** The dual-unmarshal fallback (try array, then object) silently discards the first error `err` by capturing it in the error message string. This is intentional and documented, but the variable shadowing of `err` from the outer `doGet` call is slightly confusing. The code is correct but worth a quick read to confirm `err` at line 83 (doGet error) vs `err` at the inner `json.Unmarshal` are distinct. They are, because the doGet error is already returned early.
}
return result, nil
}
// escapePath validates and encodes a slash-separated file path for use in
rodin marked this conversation as resolved Outdated
Outdated
Review

[NIT] result is allocated with make([]vcs.ContentEntry, len(entries)) which is fine, but it means when entries has len 0 (the empty array case), a zero-length non-nil slice is returned. The doc comment for ListContents doesn't document nil-vs-empty semantics the way GetPullRequestFiles does ('Returns nil (not an empty slice) when...'). Minor inconsistency in documented behavior across sibling methods.

**[NIT]** result is allocated with `make([]vcs.ContentEntry, len(entries))` which is fine, but it means when `entries` has len 0 (the empty array case), a zero-length non-nil slice is returned. The doc comment for ListContents doesn't document nil-vs-empty semantics the way GetPullRequestFiles does ('Returns nil (not an empty slice) when...'). Minor inconsistency in documented behavior across sibling methods.
// GitHub API URLs. Returns an error if the path contains dot-segments ("."
// or "..") or resolves to a path outside the repository root.
1
+311 -2
View File
@@ -2,12 +2,291 @@ package github
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestGetFileContent_DelegatesToGetFileContentAtRef(t *testing.T) {
var gotRef string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRef = r.URL.Query().Get("ref")
json.NewEncoder(w).Encode(map[string]string{
"content": "dGVzdA==", // "test" in base64
"encoding": "base64",
})
}))
rodin marked this conversation as resolved
Review

[NIT] Several test functions use json.NewEncoder(w).Encode(...) without checking the error return. The encoding/json patterns documented in stdlib tests typically ignore this in test helpers since encoding a map[string]string cannot fail, but json.NewEncoder(w).Encode does return an error that is silently dropped. This is a common and accepted pattern in test HTTP handlers but worth noting.

**[NIT]** Several test functions use `json.NewEncoder(w).Encode(...)` without checking the error return. The encoding/json patterns documented in stdlib tests typically ignore this in test helpers since encoding a map[string]string cannot fail, but `json.NewEncoder(w).Encode` does return an error that is silently dropped. This is a common and accepted pattern in test HTTP handlers but worth noting.
Review

[NIT] json.NewEncoder(w).Encode(...) return values are ignored in several test handlers. The testing patterns document recommends checking all error returns per the repo conventions. In httptest handlers this is typically acceptable since test failures would manifest as downstream assertion errors, but it deviates slightly from the "check all error returns" convention.

**[NIT]** `json.NewEncoder(w).Encode(...)` return values are ignored in several test handlers. The testing patterns document recommends checking all error returns per the repo conventions. In httptest handlers this is typically acceptable since test failures would manifest as downstream assertion errors, but it deviates slightly from the "check all error returns" convention.
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
// Call with empty ref — should not include ref param
content, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "test" {
t.Errorf("expected 'test', got %q", content)
}
if gotRef != "" {
t.Errorf("expected empty ref, got %q", gotRef)
}
}
func TestGetFileContent_WithRef(t *testing.T) {
var gotRef string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRef = r.URL.Query().Get("ref")
json.NewEncoder(w).Encode(map[string]string{
"content": "dGVzdA==",
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotRef != "abc123" {
t.Errorf("expected ref 'abc123', got %q", gotRef)
}
}
func TestGetFileContent_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "missing.go", "")
if err == nil {
t.Fatal("expected error for 404")
}
}
func TestGetFileContent_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestGetFileContent_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode(map[string]string{
"content": "b2s=",
"encoding": "base64",
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
t.Fatalf("SetRetryBackoff: %v", err)
}
content, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if content != "ok" {
t.Errorf("expected 'ok', got %q", content)
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestGetFileContent_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.GetFileContent(context.Background(), "owner", "repo", "file.go", "")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestListContents_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/owner/repo/contents/src" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
json.NewEncoder(w).Encode([]map[string]string{
{"name": "main.go", "path": "src/main.go", "type": "file"},
{"name": "lib", "path": "src/lib", "type": "dir"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Name != "main.go" {
t.Errorf("expected name 'main.go', got %q", entries[0].Name)
}
if entries[0].Path != "src/main.go" {
t.Errorf("expected path 'src/main.go', got %q", entries[0].Path)
}
if entries[0].Type != "file" {
t.Errorf("expected type 'file', got %q", entries[0].Type)
}
if entries[1].Name != "lib" {
t.Errorf("expected name 'lib', got %q", entries[1].Name)
}
rodin marked this conversation as resolved
Review

[NIT] TestListContents_HappyPath is not parallelized (no t.Parallel()) while some other tests in this file are. This is inconsistent but not harmful — HTTP server tests are acceptable either way and the inconsistency is minor.

**[NIT]** TestListContents_HappyPath is not parallelized (no t.Parallel()) while some other tests in this file are. This is inconsistent but not harmful — HTTP server tests are acceptable either way and the inconsistency is minor.
if entries[1].Type != "dir" {
t.Errorf("expected type 'dir', got %q", entries[1].Type)
}
}
func TestListContents_404(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
w.Write([]byte(`{"message":"Not Found"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "missing")
if err == nil {
t.Fatal("expected error for 404")
}
}
rodin marked this conversation as resolved Outdated
Outdated
Review

[NIT] The TestEscapePath_RejectsDotSegments test name implies rejection/error, but the actual behavior is silent removal (the dot segments are stripped, not rejected with an error). A name like TestEscapePath_RemovesDotSegments would more accurately describe the behavior documented in the function's comment.

**[NIT]** The `TestEscapePath_RejectsDotSegments` test name implies rejection/error, but the actual behavior is silent removal (the dot segments are stripped, not rejected with an error). A name like `TestEscapePath_RemovesDotSegments` would more accurately describe the behavior documented in the function's comment.
func TestListContents_401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
w.Write([]byte(`{"message":"Bad credentials"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err == nil {
t.Fatal("expected error for 401")
}
}
func TestListContents_429Retry(t *testing.T) {
attempts := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
w.WriteHeader(429)
w.Write([]byte(`{"message":"rate limit"}`))
return
}
json.NewEncoder(w).Encode([]map[string]string{
{"name": "file.go", "path": "file.go", "type": "file"},
})
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
if err := c.SetRetryBackoff([]time.Duration{1 * time.Millisecond, 1 * time.Millisecond}); err != nil {
t.Fatalf("SetRetryBackoff: %v", err)
}
entries, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if attempts != 2 {
t.Errorf("expected 2 attempts, got %d", attempts)
}
}
func TestListContents_MalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`not json`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
_, err := c.ListContents(context.Background(), "owner", "repo", "src")
if err == nil {
t.Fatal("expected error for malformed JSON")
}
}
func TestListContents_SingleFile(t *testing.T) {
// GitHub Contents API returns a JSON object (not array) for single-file paths
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write([]byte(`{"name":"README.md","path":"README.md","type":"file"}`))
}))
defer srv.Close()
c := NewClient("token", srv.URL, AllowInsecureHTTP())
c.SetHTTPClient(srv.Client())
entries, err := c.ListContents(context.Background(), "owner", "repo", "README.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].Name != "README.md" {
t.Errorf("expected name 'README.md', got %q", entries[0].Name)
}
if entries[0].Type != "file" {
t.Errorf("expected type 'file', got %q", entries[0].Type)
}
}
func TestEscapePath_ValidPaths(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -68,7 +347,7 @@ func TestGetFileContentAtRef_DotSegmentError(t *testing.T) {
}))
defer srv.Close()
c := NewClient("token", srv.URL)
c := NewClient("token", srv.URL, AllowInsecureHTTP())
_, err := c.GetFileContentAtRef(context.Background(), "owner", "repo", "foo/../bar.go", "main")
if err == nil {
t.Fatal("expected error for path with dot-segments")
@@ -78,6 +357,37 @@ func TestGetFileContentAtRef_DotSegmentError(t *testing.T) {
}
}
func TestDecodeBase64Content(t *testing.T) {
// Test with newlines (GitHub's format)
encoded := "cGFja2FnZSBt\nYWlu"
decoded, err := decodeBase64Content(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != "package main" {
t.Errorf("expected 'package main', got %q", decoded)
}
}
func TestDecodeBase64Content_Invalid(t *testing.T) {
_, err := decodeBase64Content("not!!!valid!!!base64")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecodeBase64Content_CRLF(t *testing.T) {
// Base64 of "hello world" with CRLF line breaks inserted
encoded := "aGVs\r\nbG8g\r\nd29y\r\nbGQ="
decoded, err := decodeBase64Content(encoded)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if decoded != "hello world" {
t.Errorf("expected 'hello world', got %q", decoded)
}
}
func TestDecodeBase64Content_SizeLimit(t *testing.T) {
t.Parallel()
rodin marked this conversation as resolved
Review

[NIT] TestDecodeBase64Content_SizeLimit builds a large base64 string with strings.Repeat("A", ...), which may not be a multiple of 4. The test currently relies on the early size estimate to fail before decoding; if that logic changes, it could fail due to invalid base64. Consider constructing a valid base64 string of the desired size to keep the test robust.

**[NIT]** TestDecodeBase64Content_SizeLimit builds a large base64 string with strings.Repeat("A", ...), which may not be a multiple of 4. The test currently relies on the early size estimate to fail before decoding; if that logic changes, it could fail due to invalid base64. Consider constructing a valid base64 string of the desired size to keep the test robust.
// Create base64 content that would decode to > maxFileContentSize.
@@ -93,4 +403,3 @@ func TestDecodeBase64Content_SizeLimit(t *testing.T) {
t.Errorf("expected 'too large' error, got: %v", err)
}
}
5