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
32 changes: 22 additions & 10 deletions client_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import "context"

// MockGitHubClient implements GitHubClient with canned responses for testing.
type MockGitHubClient struct {
Repos []Repo
Err error
Tree map[string][]FileEntry // repo name -> file entries
TreeErr error // global tree error (used if TreeErrs is nil)
TreeErrs map[string]error // repo name -> per-repo tree error
Protection map[string]*BranchProtection // repo name -> classic branch protection
ProtectionErr error
Rulesets map[string]*BranchProtection // repo name -> rulesets protection
RulesetsErr error
IssueErr error
Repos []Repo
Err error
Tree map[string][]FileEntry // repo name -> file entries
TreeErr error // global tree error (used if TreeErrs is nil)
TreeErrs map[string]error // repo name -> per-repo tree error
Protection map[string]*BranchProtection // repo name -> classic branch protection
ProtectionErr error // global protection error (used if ProtectionErrs is nil)
ProtectionErrs map[string]error // repo name -> per-repo protection error
Rulesets map[string]*BranchProtection // repo name -> rulesets protection
RulesetsErr error // global rulesets error (used if RulesetsErrs is nil)
RulesetsErrs map[string]error // repo name -> per-repo rulesets error
IssueErr error
// CreatedIssue records the last CreateIssue call for assertions.
CreatedIssue struct {
Owner, Repo, Title, Body string
Expand All @@ -40,6 +42,11 @@ func (m *MockGitHubClient) GetTree(ctx context.Context, owner, repo, branch stri
}

func (m *MockGitHubClient) GetBranchProtection(ctx context.Context, owner, repo, branch string) (*BranchProtection, error) {
if m.ProtectionErrs != nil {
if err, ok := m.ProtectionErrs[repo]; ok {
return nil, err
}
}
if m.ProtectionErr != nil {
return nil, m.ProtectionErr
}
Expand All @@ -50,6 +57,11 @@ func (m *MockGitHubClient) GetBranchProtection(ctx context.Context, owner, repo,
}

func (m *MockGitHubClient) GetRulesets(ctx context.Context, owner, repo, branch string) (*BranchProtection, error) {
if m.RulesetsErrs != nil {
if err, ok := m.RulesetsErrs[repo]; ok {
return nil, err
}
}
if m.RulesetsErr != nil {
return nil, m.RulesetsErr
}
Expand Down
11 changes: 6 additions & 5 deletions report.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func generateReport(org string, results []RepoResult, now time.Time) string {

func splitScanned(results []RepoResult) (scanned, skipped []RepoResult) {
for _, rr := range results {
if rr.Skipped {
if rr.Skipped() {
skipped = append(skipped, rr)
} else {
scanned = append(scanned, rr)
Expand Down Expand Up @@ -177,9 +177,10 @@ func writeNonCompliantSection(b *strings.Builder, org string, nonCompliant []Rep
func writeSkippedSection(b *strings.Builder, org string, skipped []RepoResult) {
fmt.Fprintf(b, "\n## ⚠️ Skipped (%s)\n\n", pluralRepos(len(skipped)))
for _, rr := range skipped {
fmt.Fprintf(b, "<details>\n<summary><a href=\"https://github.com/%s/%s\">%s</a> - %s</summary>\n\n",
org, rr.RepoName, rr.RepoName, rr.SkipReason)
b.WriteString("This repository was excluded from compliance results.\n")
b.WriteString("\n</details>\n\n")
if rr.KnownSkipReason != "" {
fmt.Fprintf(b, "- [%s](https://github.com/%s/%s) - %s\n", rr.RepoName, org, rr.RepoName, rr.KnownSkipReason)
} else {
fmt.Fprintf(b, "- [%s](https://github.com/%s/%s) - unexpected error: %s\n", rr.RepoName, org, rr.RepoName, rr.UnknownSkipError)
}
}
}
49 changes: 28 additions & 21 deletions report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ func TestGenerateReport_WithSkippedRepos(t *testing.T) {
{RepoName: "good-repo", Results: []RuleResult{
{RuleName: "Has repo description", Passed: true},
}},
{RepoName: "empty-repo", Skipped: true, SkipReason: "repository is empty"},
{RepoName: "huge-repo", Skipped: true, SkipReason: "file tree too large (truncated by GitHub API)"},
{RepoName: "empty-repo", KnownSkipReason: "repository is empty"},
{RepoName: "huge-repo", KnownSkipReason: "file tree too large (truncated by GitHub API)"},
}

got := generateReport("test-org", results, testTime)
Expand Down Expand Up @@ -250,20 +250,8 @@ func TestGenerateReport_WithSkippedRepos(t *testing.T) {

## ⚠️ Skipped (2 repos)

<details>
<summary><a href="https://github.com/test-org/empty-repo">empty-repo</a> - repository is empty</summary>

This repository was excluded from compliance results.

</details>

<details>
<summary><a href="https://github.com/test-org/huge-repo">huge-repo</a> - file tree too large (truncated by GitHub API)</summary>

This repository was excluded from compliance results.

</details>

- [empty-repo](https://github.com/test-org/empty-repo) - repository is empty
- [huge-repo](https://github.com/test-org/huge-repo) - file tree too large (truncated by GitHub API)
`
if got != want {
t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
Expand All @@ -272,7 +260,7 @@ This repository was excluded from compliance results.

func TestGenerateReport_OnlySkippedRepos(t *testing.T) {
results := []RepoResult{
{RepoName: "empty-repo", Skipped: true, SkipReason: "repository is empty"},
{RepoName: "empty-repo", KnownSkipReason: "repository is empty"},
}

got := generateReport("test-org", results, testTime)
Expand All @@ -286,13 +274,32 @@ func TestGenerateReport_OnlySkippedRepos(t *testing.T) {

## ⚠️ Skipped (1 repo)

<details>
<summary><a href="https://github.com/test-org/empty-repo">empty-repo</a> - repository is empty</summary>
- [empty-repo](https://github.com/test-org/empty-repo) - repository is empty
`
if got != want {
t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
}
}

This repository was excluded from compliance results.
func TestGenerateReport_WithUnexpectedSkipError(t *testing.T) {
results := []RepoResult{
{RepoName: "broken-repo", UnknownSkipError: "get tree for broken-repo: status 500"},
{RepoName: "empty-repo", KnownSkipReason: "repository is empty"},
}

</details>
got := generateReport("test-org", results, testTime)

want := `# Codatus - Org Compliance Report

**Org:** test-org
**Scanned:** 2026-04-05 12:00 UTC
**Repos scanned:** 0
**Skipped:** 2

## ⚠️ Skipped (2 repos)

- [broken-repo](https://github.com/test-org/broken-repo) - unexpected error: get tree for broken-repo: status 500
- [empty-repo](https://github.com/test-org/empty-repo) - repository is empty
`
if got != want {
t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
Expand Down
59 changes: 36 additions & 23 deletions scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ type Config struct {
}

// RepoResult holds all rule results for a single repository.
// KnownSkipReason and UnknownSkipError are mutually exclusive.
type RepoResult struct {
RepoName string
Results []RuleResult
Skipped bool
SkipReason string
RepoName string
Results []RuleResult
KnownSkipReason string
UnknownSkipError string
}

func (rr RepoResult) Skipped() bool {
return rr.KnownSkipReason != "" || rr.UnknownSkipError != ""
}

// Run is the high-level entry point. It constructs a client, scans the org,
Expand All @@ -36,7 +41,7 @@ func Run(ctx context.Context, cfg Config) error {
scanned := 0
skipped := 0
for _, r := range results {
if r.Skipped {
if r.Skipped() {
skipped++
} else {
scanned++
Expand All @@ -55,6 +60,18 @@ func Run(ctx context.Context, cfg Config) error {
return nil
}

// skipReasonForError returns a human-readable skip reason and an optional raw
// error string for unexpected failures. Known errors get a clean reason with no
// error detail. Unknown errors get a generic reason with the error message.
func skipRepo(name string, err error) RepoResult {
if errors.Is(err, ErrEmptyRepo) {
return RepoResult{RepoName: name, KnownSkipReason: "repository is empty"}
}
if errors.Is(err, ErrTruncatedTree) {
return RepoResult{RepoName: name, KnownSkipReason: "file tree too large (truncated by GitHub API)"}
}
return RepoResult{RepoName: name, UnknownSkipError: err.Error()}
}

// Scan lists all non-archived repos in the org and evaluates every rule against each.
func Scan(ctx context.Context, client GitHubClient, org string) ([]RepoResult, error) {
Expand All @@ -73,34 +90,30 @@ func Scan(ctx context.Context, client GitHubClient, org string) ([]RepoResult, e

files, err := client.GetTree(ctx, org, repo.Name, repo.DefaultBranch)
if err != nil {
if errors.Is(err, ErrEmptyRepo) {
results = append(results, RepoResult{
RepoName: repo.Name,
Skipped: true,
SkipReason: "repository is empty",
})
continue
if isRateLimitError(err) {
return nil, fmt.Errorf("get tree for repo %s: %w", repo.Name, err)
}
if errors.Is(err, ErrTruncatedTree) {
results = append(results, RepoResult{
RepoName: repo.Name,
Skipped: true,
SkipReason: "file tree too large (truncated by GitHub API)",
})
continue
}
return nil, fmt.Errorf("get tree for repo %s: %w", repo.Name, err)
results = append(results, skipRepo(repo.Name, err))
continue
}
repo.Files = files

protection, err := client.GetRulesets(ctx, org, repo.Name, repo.DefaultBranch)
if err != nil {
return nil, fmt.Errorf("get rulesets for repo %s: %w", repo.Name, err)
if isRateLimitError(err) {
return nil, fmt.Errorf("get rulesets for repo %s: %w", repo.Name, err)
}
results = append(results, skipRepo(repo.Name, err))
continue
}
if protection == nil {
protection, err = client.GetBranchProtection(ctx, org, repo.Name, repo.DefaultBranch)
if err != nil {
return nil, fmt.Errorf("get branch protection for repo %s: %w", repo.Name, err)
if isRateLimitError(err) {
return nil, fmt.Errorf("get branch protection for repo %s: %w", repo.Name, err)
}
results = append(results, skipRepo(repo.Name, err))
continue
}
}
repo.BranchProtection = protection
Expand Down
Loading
Loading