From 56f5abda3cad82f98269f266bd1e45249f918af9 Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 12:14:19 -0700 Subject: [PATCH] feat: multi-repo patterns + directory recursion patterns-repo now accepts a comma-separated list of repos: PATTERNS_REPO="rodin/elixir-patterns,rodin/phoenix-conventions" patterns-files accepts files AND directories: PATTERNS_FILES="README.md,docs/" When a path is a directory, all files within it are fetched recursively via the Gitea contents API. Only .md, .txt, .yml, and .yaml files are included as pattern content. New API methods: - ListContents: list files/dirs at a path via contents API - GetAllFilesInPath: recursively fetch all file contents This allows a single review action to pull idioms from multiple pattern repos (e.g. elixir-patterns + phoenix-conventions) and include entire directories of documentation as review criteria. --- cmd/review-bot/main.go | 60 +++++++++++++++++++++++++++++++----------- gitea/client.go | 60 ++++++++++++++++++++++++++++++++++++++++++ gitea/client_test.go | 55 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 15 deletions(-) diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index 470cc0f..8715b6e 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -184,31 +184,61 @@ func fetchFileContext(client *gitea.Client, owner, repo, ref string, files []git return sb.String() } -// fetchPatterns fetches pattern files from an external repo. +// fetchPatterns fetches pattern files from one or more external repos. +// patternsRepo is comma-separated list of owner/name repos. +// patternsFiles is comma-separated list of file paths or directories. +// If a path ends with / or is a directory, all files within it are fetched recursively. func fetchPatterns(client *gitea.Client, patternsRepo, patternsFiles string) string { - parts := strings.SplitN(patternsRepo, "/", 2) - if len(parts) != 2 { - log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", patternsRepo) - return "" - } - owner, repo := parts[0], parts[1] - var sb strings.Builder - for _, filepath := range strings.Split(patternsFiles, ",") { - filepath = strings.TrimSpace(filepath) - if filepath == "" { + + repos := strings.Split(patternsRepo, ",") + paths := strings.Split(patternsFiles, ",") + + for _, repoRef := range repos { + repoRef = strings.TrimSpace(repoRef) + if repoRef == "" { continue } - content, err := client.GetFileContent(owner, repo, filepath) - if err != nil { - log.Printf("Warning: could not fetch pattern file %s from %s: %v", filepath, patternsRepo, err) + parts := strings.SplitN(repoRef, "/", 2) + if len(parts) != 2 { + log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", repoRef) continue } - sb.WriteString(fmt.Sprintf("### %s/%s\n\n%s\n\n", patternsRepo, filepath, content)) + owner, repo := parts[0], parts[1] + + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + + files, err := client.GetAllFilesInPath(owner, repo, path) + if err != nil { + log.Printf("Warning: could not fetch %s from %s: %v", path, repoRef, err) + continue + } + + for filepath, content := range files { + // Only include markdown and text files as patterns + if !isPatternFile(filepath) { + continue + } + sb.WriteString(fmt.Sprintf("### %s/%s\n\n%s\n\n", repoRef, filepath, content)) + } + } } return sb.String() } +// isPatternFile returns true if the file should be included as pattern content. +func isPatternFile(path string) bool { + lower := strings.ToLower(path) + return strings.HasSuffix(lower, ".md") || + strings.HasSuffix(lower, ".txt") || + strings.HasSuffix(lower, ".yml") || + strings.HasSuffix(lower, ".yaml") +} + // evaluateCIStatus checks if all CI statuses indicate success. func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) { if len(statuses) == 0 { diff --git a/gitea/client.go b/gitea/client.go index 84caf8d..71607f4 100644 --- a/gitea/client.go +++ b/gitea/client.go @@ -177,3 +177,63 @@ func (c *Client) doGet(url string) ([]byte, error) { } return io.ReadAll(resp.Body) } + +// ContentEntry represents a file or directory entry from the contents API. +type ContentEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "dir" +} + +// ListContents lists files and directories at a given path in a repo. +func (c *Client) ListContents(owner, repo, path string) ([]ContentEntry, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.BaseURL, owner, repo, path) + body, err := c.doGet(url) + if err != nil { + return nil, fmt.Errorf("list contents %s: %w", path, err) + } + var entries []ContentEntry + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("parse contents JSON: %w", err) + } + return entries, nil +} + +// GetAllFilesInPath recursively fetches all file contents under a path. +// If the path is a file, returns just that file's content. +// If the path is a directory, recursively fetches all files within it. +func (c *Client) GetAllFilesInPath(owner, repo, path string) (map[string]string, error) { + results := make(map[string]string) + + // Try listing as directory first + entries, err := c.ListContents(owner, repo, path) + if err != nil { + // Might be a file, try fetching directly + content, fileErr := c.GetFileContent(owner, repo, path) + if fileErr != nil { + return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err) + } + results[path] = content + return results, nil + } + + for _, entry := range entries { + switch entry.Type { + case "file": + content, err := c.GetFileContent(owner, repo, entry.Path) + if err != nil { + continue // Skip files we can't read + } + results[entry.Path] = content + case "dir": + subResults, err := c.GetAllFilesInPath(owner, repo, entry.Path) + if err != nil { + continue + } + for k, v := range subResults { + results[k] = v + } + } + } + return results, nil +} diff --git a/gitea/client_test.go b/gitea/client_test.go index 600ff5c..94ff233 100644 --- a/gitea/client_test.go +++ b/gitea/client_test.go @@ -2,6 +2,7 @@ package gitea import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -238,3 +239,57 @@ func TestGetFileContentRef(t *testing.T) { t.Errorf("unexpected content: %q", content) } } + +func TestListContents(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/docs" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `[{"name":"guide.md","path":"docs/guide.md","type":"file"},{"name":"sub","path":"docs/sub","type":"dir"}]`) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + entries, err := client.ListContents("owner", "repo", "docs") + 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].Type != "file" || entries[0].Path != "docs/guide.md" { + t.Errorf("unexpected first entry: %+v", entries[0]) + } + if entries[1].Type != "dir" { + t.Errorf("expected dir type, got %s", entries[1].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) + return + } + if r.URL.Path == "/api/v1/repos/owner/repo/raw/README.md" { + fmt.Fprintf(w, "# Hello") + return + } + http.NotFound(w, r) + })) + defer server.Close() + + client := NewClient(server.URL, "test-token") + files, err := client.GetAllFilesInPath("owner", "repo", "README.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + if files["README.md"] != "# Hello" { + t.Errorf("unexpected content: %q", files["README.md"]) + } +}