Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ The real implementation calls GitHub's REST API. Tests use a mock implementation
At minimum, every rule must have:
- A passing case (repo satisfies the rule)
- A failing case (repo violates the rule)
- An edge case where applicable (e.g., README exists but is under 100 lines)
- An edge case where applicable (e.g., README exists but is under 2KB)

Report generation tests must verify the Markdown output matches expected structure.

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ Each rule produces a **pass** or **fail** result per repository. There are no sc
**Pass:** file found.
**Fail:** file not found.

#### 3. Has README over 100 lines
#### 3. Has README over 2KB

**Check:** a `README.md` file exists in the repo root and contains more than 100 lines.
**Check:** a `README.md` file exists in the repo root and is larger than 2048 bytes.

**Pass:** `README.md` exists and has >100 lines.
**Fail:** `README.md` is missing, or exists but has ≤100 lines.
**Pass:** `README.md` exists and is >2048 bytes.
**Fail:** `README.md` is missing, or exists but is ≤2048 bytes.

#### 4. Has LICENSE

Expand Down Expand Up @@ -134,7 +134,7 @@ The report is a single Markdown document posted as a GitHub Issue. Structure:
|------|--------|
| Has repo description | ✅ |
| Has .gitignore | ✅ |
| Has README over 100 lines | ❌ |
| Has README over 2KB | ❌ |
| ... | ... |

### repo-name-2
Expand Down
19 changes: 13 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,26 @@ import (
"net/http"
)

// FileEntry represents a file or directory in a repo's root.
type FileEntry struct {
Name string
Size int
}

// Repo represents a GitHub repository with the fields the scanner needs.
type Repo struct {
Name string
Description string
DefaultBranch string
Archived bool
Files []string // root-level file and directory names
Files []FileEntry // root-level file and directory entries
}

// GitHubClient is the interface for all GitHub API interactions.
// The scanner depends only on this interface, making it testable via mocks.
type GitHubClient interface {
ListRepos(ctx context.Context, org string) ([]Repo, error)
ListFiles(ctx context.Context, owner, repo string) ([]string, error)
ListFiles(ctx context.Context, owner, repo string) ([]FileEntry, error)
CreateIssue(ctx context.Context, owner, repo, title, body string) error
}

Expand Down Expand Up @@ -102,21 +108,22 @@ func (c *realGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, e
return allRepos, nil
}

func (c *realGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) {
func (c *realGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]FileEntry, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/contents", owner, repo)

var entries []struct {
Name string `json:"name"`
Size int `json:"size"`
}
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))
files := make([]FileEntry, len(entries))
for i, e := range entries {
names[i] = e.Name
files[i] = FileEntry{Name: e.Name, Size: e.Size}
}
return names, nil
return files, nil
}

func (c *realGitHubClient) CreateIssue(ctx context.Context, owner, repo, title, body string) error {
Expand Down
4 changes: 2 additions & 2 deletions client_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import "context"
type MockGitHubClient struct {
Repos []Repo
Err error
Files map[string][]string // repo name -> file list
Files map[string][]FileEntry // repo name -> file entries
FilesErr error
IssueErr error
// CreatedIssue records the last CreateIssue call for assertions.
Expand All @@ -19,7 +19,7 @@ func (m *MockGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, e
return m.Repos, m.Err
}

func (m *MockGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) {
func (m *MockGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]FileEntry, error) {
if m.FilesErr != nil {
return nil, m.FilesErr
}
Expand Down
23 changes: 19 additions & 4 deletions rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func AllRules() []Rule {
return []Rule{
HasRepoDescription{},
HasGitignore{},
HasSubstantialReadme{},
}
}

Expand All @@ -38,11 +39,25 @@ func (r HasGitignore) Check(repo Repo) bool {
return hasFile(repo.Files, ".gitignore")
}

func hasFile(files []string, name string) bool {
// HasSubstantialReadme checks that README.md exists and is larger than 2048 bytes.
type HasSubstantialReadme struct{}

func (r HasSubstantialReadme) Name() string { return "Has README over 2KB" }
func (r HasSubstantialReadme) Check(repo Repo) bool {
f, ok := findFile(repo.Files, "README.md")
return ok && f.Size > 2048
}

func findFile(files []FileEntry, name string) (FileEntry, bool) {
for _, f := range files {
if f == name {
return true
if f.Name == name {
return f, true
}
}
return false
return FileEntry{}, false
}

func hasFile(files []FileEntry, name string) bool {
_, ok := findFile(files, name)
return ok
}
28 changes: 26 additions & 2 deletions rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ func TestHasRepoDescription_Fail_WhitespaceOnly(t *testing.T) {
func TestHasGitignore_Pass(t *testing.T) {
rule := HasGitignore{}

if !rule.Check(Repo{Files: []string{"README.md", ".gitignore", "main.go"}}) {
if !rule.Check(Repo{Files: []FileEntry{{Name: "README.md"}, {Name: ".gitignore"}, {Name: "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"}}) {
if rule.Check(Repo{Files: []FileEntry{{Name: "README.md"}, {Name: "main.go"}}}) {
t.Error("expected fail when .gitignore is missing")
}
}
Expand All @@ -49,3 +49,27 @@ func TestHasGitignore_Fail_EmptyFiles(t *testing.T) {
t.Error("expected fail when file list is empty")
}
}

func TestHasSubstantialReadme_Pass(t *testing.T) {
rule := HasSubstantialReadme{}

if !rule.Check(Repo{Files: []FileEntry{{Name: "README.md", Size: 3000}}}) {
t.Error("expected pass for README.md over 2KB")
}
}

func TestHasSubstantialReadme_Fail_TooSmall(t *testing.T) {
rule := HasSubstantialReadme{}

if rule.Check(Repo{Files: []FileEntry{{Name: "README.md", Size: 2048}}}) {
t.Error("expected fail for README.md exactly 2048 bytes")
}
}

func TestHasSubstantialReadme_Fail_Missing(t *testing.T) {
rule := HasSubstantialReadme{}

if rule.Check(Repo{Files: []FileEntry{{Name: "main.go"}}}) {
t.Error("expected fail when README.md is missing")
}
}
Loading