From 1b6c37605f82caebc83a7c3fa850fe8f61a37c99 Mon Sep 17 00:00:00 2001 From: Rodin Date: Mon, 11 May 2026 07:21:15 -0700 Subject: [PATCH 1/2] fix(gitea): handle single-object response in ListContents When ListContents is called with a path that points to a file (not a directory), Gitea returns a single JSON object instead of an array. Previously this caused json.Unmarshal to fail with: json: cannot unmarshal object into Go value of type []gitea.ContentEntry Now ListContents tries array unmarshal first, and falls back to single object unmarshal, wrapping it in a slice. This allows patterns-files config to specify individual files like 'README.md' without triggering a parse error. Also updates TestGetAllFilesInPath_File to reflect actual Gitea behavior (single object response, not 404). Fixes #73 --- gitea/client.go | 9 ++++++++- gitea/client_test.go | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/gitea/client.go b/gitea/client.go index 9cef17d..45389a6 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -434,6 +434,8 @@ type ContentEntry struct { // ListContents lists files and directories at a given path in a repo. // Pass an empty path to list the repository root. +// If the path points to a file (not a directory), Gitea returns a single +// object instead of an array; this method normalizes both cases to a slice. func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) { // Normalize "." to empty string — Gitea API rejects "." with 500 if path == "." { @@ -451,7 +453,12 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([] } var entries []ContentEntry if err := json.Unmarshal(body, &entries); err != nil { - return nil, fmt.Errorf("parse contents JSON: %w", err) + // Gitea returns a single object (not an array) when path is a file + var single ContentEntry + if err2 := json.Unmarshal(body, &single); err2 != nil { + return nil, fmt.Errorf("parse contents JSON: %w", err) + } + entries = []ContentEntry{single} } return entries, nil } diff --git a/gitea/client_test.go b/gitea/client_test.go index ba49fd5..2637c2e 100644 --- a/gitea/client_test.go +++ b/gitea/client_test.go @@ -304,11 +304,40 @@ func TestListContents_DotPath(t *testing.T) { } } +func TestListContents_FilePath(t *testing.T) { + // Gitea returns a single object (not an array) when path is a file + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/owner/repo/contents/README.md" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + // Single object, not an array + fmt.Fprintf(w, `{"name":"README.md","path":"README.md","type":"file"}`) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + entries, err := client.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 README.md, got %s", entries[0].Name) + } + if entries[0].Type != "file" { + t.Errorf("expected type file, got %s", entries[0].Type) + } +} + func TestGetAllFilesInPath_File(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/v1/repos/owner/repo/contents/README.md" { - // Gitea returns 404 for contents API on files (it's not a dir) - http.NotFound(w, r) + // Gitea returns a single object (not array) when path is a file + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"name":"README.md","path":"README.md","type":"file"}`) return } if r.URL.Path == "/api/v1/repos/owner/repo/raw/README.md" { -- 2.47.3 From c27dfd0f08ef4b5799e8ef45030ab53036c17128 Mon Sep 17 00:00:00 2001 From: Rodin Date: Mon, 11 May 2026 07:47:03 -0700 Subject: [PATCH 2/2] fix(gitea): guard against empty response in ListContents fallback Add defensive check for empty Name and Path fields when unmarshaling a single ContentEntry in the fallback path. While Gitea API won't return empty objects for valid file paths, this guard: - Explicitly documents the invariant we expect - Catches potential API behavior changes early - Costs nothing at runtime Addresses [MINOR] from sonnet-review-bot on PR #74. --- gitea/client.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gitea/client.go b/gitea/client.go index 45389a6..55835ac 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -458,6 +458,10 @@ func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([] if err2 := json.Unmarshal(body, &single); err2 != nil { return nil, fmt.Errorf("parse contents JSON: %w", err) } + // Guard against empty/malformed responses + if single.Name == "" && single.Path == "" { + return nil, fmt.Errorf("parse contents JSON: empty response for path %q", path) + } entries = []ContentEntry{single} } return entries, nil -- 2.47.3