From 029ecf0b347ac2977302c9b17f2e8b5d48411281 Mon Sep 17 00:00:00 2001 From: packet-mover Date: Sun, 5 Apr 2026 22:50:39 +0200 Subject: [PATCH] feat: add Has .gitignore rule with real ListFiles implementation - Add Files field to Repo struct for root-level file/dir names - Implement ListFiles on real GitHub client (GET /repos contents) - Scan() now fetches files per repo before evaluating rules - Add HasGitignore rule with hasFile helper - Update mock to support configurable per-repo file lists Co-Authored-By: Claude Opus 4.6 (1M context) --- client.go | 17 +++++++++++++++-- client_mock_test.go | 8 ++++++++ rules.go | 18 ++++++++++++++++++ rules_test.go | 24 ++++++++++++++++++++++++ scanner.go | 6 ++++++ 5 files changed, 71 insertions(+), 2 deletions(-) diff --git a/client.go b/client.go index 76a6b6b..0fece2f 100644 --- a/client.go +++ b/client.go @@ -14,6 +14,7 @@ type Repo struct { Description string DefaultBranch string Archived bool + Files []string // root-level file and directory names } // GitHubClient is the interface for all GitHub API interactions. @@ -102,8 +103,20 @@ func (c *realGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, e } func (c *realGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) { - // TODO: implement when file-existence rules are added - return nil, nil + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents", owner, repo) + + var entries []struct { + Name string `json:"name"` + } + if err := c.doRequest(ctx, url, &entries); err != nil { + return nil, fmt.Errorf("list files for %s/%s: %w", owner, repo, err) + } + + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name + } + return names, nil } func (c *realGitHubClient) CreateIssue(ctx context.Context, owner, repo, title, body string) error { diff --git a/client_mock_test.go b/client_mock_test.go index f83092b..93a98ea 100644 --- a/client_mock_test.go +++ b/client_mock_test.go @@ -6,6 +6,8 @@ import "context" type MockGitHubClient struct { Repos []Repo Err error + Files map[string][]string // repo name -> file list + FilesErr error IssueErr error // CreatedIssue records the last CreateIssue call for assertions. CreatedIssue struct { @@ -18,6 +20,12 @@ func (m *MockGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, e } func (m *MockGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) { + if m.FilesErr != nil { + return nil, m.FilesErr + } + if m.Files != nil { + return m.Files[repo], nil + } return nil, nil } diff --git a/rules.go b/rules.go index 3eaf691..09fe4ba 100644 --- a/rules.go +++ b/rules.go @@ -18,6 +18,7 @@ type RuleResult struct { func AllRules() []Rule { return []Rule{ HasRepoDescription{}, + HasGitignore{}, } } @@ -28,3 +29,20 @@ func (r HasRepoDescription) Name() string { return "Has repo description" } func (r HasRepoDescription) Check(repo Repo) bool { return strings.TrimSpace(repo.Description) != "" } + +// HasGitignore checks that a .gitignore file exists in the repo root. +type HasGitignore struct{} + +func (r HasGitignore) Name() string { return "Has .gitignore" } +func (r HasGitignore) Check(repo Repo) bool { + return hasFile(repo.Files, ".gitignore") +} + +func hasFile(files []string, name string) bool { + for _, f := range files { + if f == name { + return true + } + } + return false +} diff --git a/rules_test.go b/rules_test.go index c87af9a..c256265 100644 --- a/rules_test.go +++ b/rules_test.go @@ -25,3 +25,27 @@ func TestHasRepoDescription_Fail_WhitespaceOnly(t *testing.T) { t.Errorf("expected fail for repo with whitespace-only description") } } + +func TestHasGitignore_Pass(t *testing.T) { + rule := HasGitignore{} + + if !rule.Check(Repo{Files: []string{"README.md", ".gitignore", "main.go"}}) { + t.Error("expected pass when .gitignore exists") + } +} + +func TestHasGitignore_Fail(t *testing.T) { + rule := HasGitignore{} + + if rule.Check(Repo{Files: []string{"README.md", "main.go"}}) { + t.Error("expected fail when .gitignore is missing") + } +} + +func TestHasGitignore_Fail_EmptyFiles(t *testing.T) { + rule := HasGitignore{} + + if rule.Check(Repo{Files: nil}) { + t.Error("expected fail when file list is empty") + } +} diff --git a/scanner.go b/scanner.go index 620c349..350dc06 100644 --- a/scanner.go +++ b/scanner.go @@ -59,6 +59,12 @@ func Scan(ctx context.Context, client GitHubClient, org string) ([]RepoResult, e continue } + files, err := client.ListFiles(ctx, org, repo.Name) + if err != nil { + return nil, fmt.Errorf("list files for repo %s: %w", repo.Name, err) + } + repo.Files = files + rr := RepoResult{RepoName: repo.Name} for _, rule := range rules { rr.Results = append(rr.Results, RuleResult{