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.
This commit is contained in:
+45
-15
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user