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()
|
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 {
|
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
|
var sb strings.Builder
|
||||||
for _, filepath := range strings.Split(patternsFiles, ",") {
|
|
||||||
filepath = strings.TrimSpace(filepath)
|
repos := strings.Split(patternsRepo, ",")
|
||||||
if filepath == "" {
|
paths := strings.Split(patternsFiles, ",")
|
||||||
|
|
||||||
|
for _, repoRef := range repos {
|
||||||
|
repoRef = strings.TrimSpace(repoRef)
|
||||||
|
if repoRef == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
content, err := client.GetFileContent(owner, repo, filepath)
|
parts := strings.SplitN(repoRef, "/", 2)
|
||||||
if err != nil {
|
if len(parts) != 2 {
|
||||||
log.Printf("Warning: could not fetch pattern file %s from %s: %v", filepath, patternsRepo, err)
|
log.Printf("Warning: invalid patterns-repo format %q, expected owner/name", repoRef)
|
||||||
continue
|
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()
|
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.
|
// evaluateCIStatus checks if all CI statuses indicate success.
|
||||||
func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) {
|
func evaluateCIStatus(statuses []gitea.CommitStatus) (passed bool, details string) {
|
||||||
if len(statuses) == 0 {
|
if len(statuses) == 0 {
|
||||||
|
|||||||
@@ -177,3 +177,63 @@ func (c *Client) doGet(url string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return io.ReadAll(resp.Body)
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -238,3 +239,57 @@ func TestGetFileContentRef(t *testing.T) {
|
|||||||
t.Errorf("unexpected content: %q", content)
|
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