feat: multi-repo patterns + directory recursion
CI / test (pull_request) Successful in 14s
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m57s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 2m2s

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.
This commit is contained in:
Rodin
2026-05-01 12:14:19 -07:00
parent e234dca474
commit 56f5abda3c
3 changed files with 160 additions and 15 deletions
+45 -15
View File
@@ -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 {
+60
View File
@@ -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
}
+55
View File
@@ -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"])
}
}