From 84b2f330e3e2699306bb7a99fb0abe3c5e0f4ebb Mon Sep 17 00:00:00 2001 From: packet-mover Date: Sat, 4 Apr 2026 22:44:28 +0200 Subject: [PATCH] feat: add scanner skeleton and "Has repo description" rule Establishes the project foundation: - GitHubClient interface and mock implementation - Scanner with rule evaluation engine (skips archived repos, sorts alphabetically) - First rule: "Has repo description" (pass if non-empty, fail if blank/whitespace) - Tests covering pass, fail, edge cases, and scanner integration Co-Authored-By: Claude Opus 4.6 (1M context) --- client.go | 17 +++++++++ client_mock.go | 13 +++++++ go.mod | 3 ++ main.go | 40 ++++++++++++++++++++ rules.go | 28 ++++++++++++++ rules_test.go | 39 ++++++++++++++++++++ scanner.go | 42 +++++++++++++++++++++ scanner_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 279 insertions(+) create mode 100644 client.go create mode 100644 client_mock.go create mode 100644 go.mod create mode 100644 main.go create mode 100644 rules.go create mode 100644 rules_test.go create mode 100644 scanner.go create mode 100644 scanner_test.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..6b5820c --- /dev/null +++ b/client.go @@ -0,0 +1,17 @@ +package main + +import "context" + +// Repo represents a GitHub repository with the fields the scanner needs. +type Repo struct { + Name string + Description string + DefaultBranch string + Archived bool +} + +// 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) +} diff --git a/client_mock.go b/client_mock.go new file mode 100644 index 0000000..11d9d6b --- /dev/null +++ b/client_mock.go @@ -0,0 +1,13 @@ +package main + +import "context" + +// MockGitHubClient implements GitHubClient with canned responses for testing. +type MockGitHubClient struct { + Repos []Repo + Err error +} + +func (m *MockGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, error) { + return m.Repos, m.Err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8c120de --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/CodatusHQ/scanner + +go 1.22.2 diff --git a/main.go b/main.go new file mode 100644 index 0000000..5097205 --- /dev/null +++ b/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" +) + +// ScanConfig holds the configuration needed to run a scan. +type ScanConfig struct { + Org string + Token string + ReportRepo string +} + +func main() { + cfg := ScanConfig{ + Org: os.Getenv("CODATUS_ORG"), + Token: os.Getenv("CODATUS_TOKEN"), + ReportRepo: os.Getenv("CODATUS_REPORT_REPO"), + } + + if cfg.Org == "" { + log.Fatal("CODATUS_ORG is required") + } + if cfg.Token == "" { + log.Fatal("CODATUS_TOKEN is required") + } + if cfg.ReportRepo == "" { + log.Fatal("CODATUS_REPORT_REPO is required") + } + + ctx := context.Background() + + // TODO: replace with real GitHubClient implementation + _ = ctx + _ = cfg + fmt.Println("codatus scanner — not yet wired up") +} diff --git a/rules.go b/rules.go new file mode 100644 index 0000000..a317687 --- /dev/null +++ b/rules.go @@ -0,0 +1,28 @@ +package main + +import "strings" + +// RuleResult holds the outcome of a single rule check for a single repo. +type RuleResult struct { + RuleName string + Passed bool +} + +// Rule defines a named check that produces a pass/fail result for a repo. +type Rule struct { + Name string + Check func(repo Repo) RuleResult +} + +// AllRules returns the ordered list of rules the scanner evaluates. +func AllRules() []Rule { + return []Rule{ + { + Name: "Has repo description", + Check: func(repo Repo) RuleResult { + passed := strings.TrimSpace(repo.Description) != "" + return RuleResult{RuleName: "Has repo description", Passed: passed} + }, + }, + } +} diff --git a/rules_test.go b/rules_test.go new file mode 100644 index 0000000..4297864 --- /dev/null +++ b/rules_test.go @@ -0,0 +1,39 @@ +package main + +import "testing" + +func TestHasRepoDescription_Pass(t *testing.T) { + rule := AllRules()[0] + repo := Repo{Name: "my-repo", Description: "A useful service"} + + result := rule.Check(repo) + + if !result.Passed { + t.Errorf("expected pass for repo with description, got fail") + } + if result.RuleName != "Has repo description" { + t.Errorf("unexpected rule name: %s", result.RuleName) + } +} + +func TestHasRepoDescription_Fail_Empty(t *testing.T) { + rule := AllRules()[0] + repo := Repo{Name: "my-repo", Description: ""} + + result := rule.Check(repo) + + if result.Passed { + t.Errorf("expected fail for repo with empty description, got pass") + } +} + +func TestHasRepoDescription_Fail_WhitespaceOnly(t *testing.T) { + rule := AllRules()[0] + repo := Repo{Name: "my-repo", Description: " \t\n"} + + result := rule.Check(repo) + + if result.Passed { + t.Errorf("expected fail for repo with whitespace-only description, got pass") + } +} diff --git a/scanner.go b/scanner.go new file mode 100644 index 0000000..cc45757 --- /dev/null +++ b/scanner.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "sort" +) + +// RepoResult holds all rule results for a single repository. +type RepoResult struct { + RepoName string + Results []RuleResult +} + +// 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) { + repos, err := client.ListRepos(ctx, org) + if err != nil { + return nil, fmt.Errorf("list repos for org %s: %w", org, err) + } + + rules := AllRules() + var results []RepoResult + + for _, repo := range repos { + if repo.Archived { + continue + } + + rr := RepoResult{RepoName: repo.Name} + for _, rule := range rules { + rr.Results = append(rr.Results, rule.Check(repo)) + } + results = append(results, rr) + } + + sort.Slice(results, func(i, j int) bool { + return results[i].RepoName < results[j].RepoName + }) + + return results, nil +} diff --git a/scanner_test.go b/scanner_test.go new file mode 100644 index 0000000..eb41ba5 --- /dev/null +++ b/scanner_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "fmt" + "testing" +) + +func TestScan_SkipsArchivedRepos(t *testing.T) { + client := &MockGitHubClient{ + Repos: []Repo{ + {Name: "active-repo", Description: "Active", Archived: false}, + {Name: "old-repo", Description: "Old", Archived: true}, + }, + } + + results, err := Scan(context.Background(), client, "test-org") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].RepoName != "active-repo" { + t.Errorf("expected active-repo, got %s", results[0].RepoName) + } +} + +func TestScan_ResultsSortedAlphabetically(t *testing.T) { + client := &MockGitHubClient{ + Repos: []Repo{ + {Name: "zebra", Description: "Z"}, + {Name: "alpha", Description: "A"}, + {Name: "middle", Description: "M"}, + }, + } + + results, err := Scan(context.Background(), client, "test-org") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expected := []string{"alpha", "middle", "zebra"} + for i, name := range expected { + if results[i].RepoName != name { + t.Errorf("position %d: expected %s, got %s", i, name, results[i].RepoName) + } + } +} + +func TestScan_EvaluatesRulesPerRepo(t *testing.T) { + client := &MockGitHubClient{ + Repos: []Repo{ + {Name: "with-desc", Description: "Has a description"}, + {Name: "no-desc", Description: ""}, + }, + } + + results, err := Scan(context.Background(), client, "test-org") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + + // Results are sorted alphabetically: no-desc first, then with-desc + noDesc := results[0] + withDesc := results[1] + + if noDesc.RepoName != "no-desc" { + t.Fatalf("expected no-desc first, got %s", noDesc.RepoName) + } + if withDesc.RepoName != "with-desc" { + t.Fatalf("expected with-desc second, got %s", withDesc.RepoName) + } + + if noDesc.Results[0].Passed { + t.Errorf("expected no-desc to fail 'Has repo description'") + } + if !withDesc.Results[0].Passed { + t.Errorf("expected with-desc to pass 'Has repo description'") + } +} + +func TestScan_PropagatesClientError(t *testing.T) { + client := &MockGitHubClient{ + Err: fmt.Errorf("API rate limit exceeded"), + } + + _, err := Scan(context.Background(), client, "test-org") + if err == nil { + t.Fatal("expected error, got nil") + } +}