diff --git a/AGENTS.md b/AGENTS.md index 3de60ae..d55c5b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,16 +67,17 @@ After design approval: 2. Implement the full approved design. 3. Include tests (see "Testing approach" below). Tests are mandatory. 4. Run `go test ./...` and ensure all tests pass. If any test fails, fix the code or the test before proceeding. -5. Commit all changes to the feature branch with a message following the PR title convention. +5. Do not commit yet — leave changes uncommitted so the user can review the diff in their IDE. -### Phase 3 - Review and open PR +### Phase 3 - Review, commit, and open PR -After implementation is complete, tests pass, and changes are committed: +After implementation is complete and tests pass: 1. Present a summary of all changes (files added/modified, what each does). -2. Wait for the user to review the changes in their editor. If the user requests changes, apply them, re-run tests, and commit before proceeding. -3. Explicitly ask: "Ready to push and create the PR?" -4. Do not push or open a PR until the user confirms. If the user declines, ask what needs to change. -5. Push the branch and open a pull request targeting `main`. +2. Wait for the user to review the uncommitted changes in their IDE. If the user requests changes, apply them and re-run tests before proceeding. +3. Once the user approves the changes, commit to the feature branch with a message following the PR title convention. +4. Explicitly ask: "Ready to push and create the PR?" +5. Do not push or open a PR until the user confirms. If the user declines, ask what needs to change. +6. Push the branch and open a pull request targeting `main`. **PR requirements:** - **Title** must follow conventional commits: `type: description` (e.g., `feat: add CI workflow rule`, `fix: correct pass rate calculation`, `refactor: extract report formatting`). Allowed types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`. @@ -102,28 +103,28 @@ When the user requests changes on the PR (either in chat or via GitHub review co ## Repository layout -Start flat — all `.go` files in the root, all in `package main`: +The scanner is an importable Go package (`package scanner`) at the module root. The CLI is a thin wrapper in `cmd/scanner/`. ``` . -├── .claude/ # Claude Code settings +├── .claude/ # Claude Code settings +├── cmd/ +│ └── scanner/ +│ └── main.go # thin CLI wrapper (reads env, calls scanner.Run) ├── AGENTS.md ├── README.md ├── go.mod ├── go.sum -├── main.go # entry point -├── client.go # GitHubClient interface + real implementation -├── scanner.go # scan logic (takes a client, returns results) -├── rules.go # rule definitions + evaluation -├── report.go # Markdown report generation -├── client_mock.go # mock GitHubClient for tests -├── scanner_test.go # tests +├── client.go # GitHubClient interface + real implementation +├── client_mock_test.go # mock GitHubClient (test-only) +├── scanner.go # Config, Run(), Scan() +├── rules.go # rule definitions + evaluation +├── report.go # Markdown report generation (future) +├── scanner_test.go # tests ├── rules_test.go └── report_test.go ``` -**Restructure trigger:** when the project grows past 3 distinct concerns that don't belong together in `package main`, refactor into `cmd/codatus/` + `internal/` packages. Do not preemptively create this structure. - --- ## Go style preferences diff --git a/client.go b/client.go index 6b5820c..620e0de 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,11 @@ -package main +package scanner -import "context" +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) // Repo represents a GitHub repository with the fields the scanner needs. type Repo struct { @@ -14,4 +19,87 @@ type Repo struct { // 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) +} + +type realGitHubClient struct { + token string + httpClient *http.Client +} + +// NewGitHubClient creates a GitHubClient that calls the GitHub REST API. +func NewGitHubClient(token string) GitHubClient { + return &realGitHubClient{ + token: token, + httpClient: &http.Client{}, + } +} + +func (c *realGitHubClient) doRequest(ctx context.Context, url string, target interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request for %s: %w", url, err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("request %s: status %d", url, resp.StatusCode) + } + + if err := json.NewDecoder(resp.Body).Decode(target); err != nil { + return fmt.Errorf("decode response from %s: %w", url, err) + } + return nil +} + +type ghRepo struct { + Name string `json:"name"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Archived bool `json:"archived"` +} + +func (c *realGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, error) { + var allRepos []Repo + page := 1 + + for { + url := fmt.Sprintf("https://api.github.com/orgs/%s/repos?per_page=100&page=%d", org, page) + var ghRepos []ghRepo + if err := c.doRequest(ctx, url, &ghRepos); err != nil { + return nil, fmt.Errorf("list repos for org %s: %w", org, err) + } + + if len(ghRepos) == 0 { + break + } + + for _, r := range ghRepos { + allRepos = append(allRepos, Repo{ + Name: r.Name, + Description: r.Description, + DefaultBranch: r.DefaultBranch, + Archived: r.Archived, + }) + } + + if len(ghRepos) < 100 { + break + } + page++ + } + + return allRepos, nil +} + +func (c *realGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) { + // TODO: implement when file-existence rules are added + return nil, nil } diff --git a/client_mock.go b/client_mock_test.go similarity index 66% rename from client_mock.go rename to client_mock_test.go index 11d9d6b..2a264ee 100644 --- a/client_mock.go +++ b/client_mock_test.go @@ -1,4 +1,4 @@ -package main +package scanner import "context" @@ -11,3 +11,7 @@ type MockGitHubClient struct { func (m *MockGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, error) { return m.Repos, m.Err } + +func (m *MockGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) { + return nil, nil +} diff --git a/cmd/scanner/main.go b/cmd/scanner/main.go new file mode 100644 index 0000000..98d8a3c --- /dev/null +++ b/cmd/scanner/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/CodatusHQ/scanner" +) + +func main() { + org := os.Getenv("CODATUS_ORG") + token := os.Getenv("CODATUS_TOKEN") + reportRepo := os.Getenv("CODATUS_REPORT_REPO") + + if org == "" { + log.Fatal("CODATUS_ORG is required") + } + if token == "" { + log.Fatal("CODATUS_TOKEN is required") + } + if reportRepo == "" { + log.Fatal("CODATUS_REPORT_REPO is required") + } + + cfg := scanner.Config{ + Org: org, + Token: token, + ReportRepo: reportRepo, + } + + if err := scanner.Run(context.Background(), cfg); err != nil { + log.Fatal(err) + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 5097205..0000000 --- a/main.go +++ /dev/null @@ -1,40 +0,0 @@ -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 index a317687..3eaf691 100644 --- a/rules.go +++ b/rules.go @@ -1,28 +1,30 @@ -package main +package scanner import "strings" +// Rule defines a named check that produces a pass/fail result for a repo. +type Rule interface { + Name() string + Check(repo Repo) bool +} + // 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} - }, - }, + HasRepoDescription{}, } } + +// HasRepoDescription checks that the repo description field is not blank. +type HasRepoDescription struct{} + +func (r HasRepoDescription) Name() string { return "Has repo description" } +func (r HasRepoDescription) Check(repo Repo) bool { + return strings.TrimSpace(repo.Description) != "" +} diff --git a/rules_test.go b/rules_test.go index 4297864..c87af9a 100644 --- a/rules_test.go +++ b/rules_test.go @@ -1,39 +1,27 @@ -package main +package scanner import "testing" func TestHasRepoDescription_Pass(t *testing.T) { - rule := AllRules()[0] - repo := Repo{Name: "my-repo", Description: "A useful service"} + rule := HasRepoDescription{} - 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) + if !rule.Check(Repo{Name: "my-repo", Description: "A useful service"}) { + t.Errorf("expected pass for repo with description") } } func TestHasRepoDescription_Fail_Empty(t *testing.T) { - rule := AllRules()[0] - repo := Repo{Name: "my-repo", Description: ""} + rule := HasRepoDescription{} - result := rule.Check(repo) - - if result.Passed { - t.Errorf("expected fail for repo with empty description, got pass") + if rule.Check(Repo{Name: "my-repo", Description: ""}) { + t.Errorf("expected fail for repo with empty description") } } func TestHasRepoDescription_Fail_WhitespaceOnly(t *testing.T) { - rule := AllRules()[0] - repo := Repo{Name: "my-repo", Description: " \t\n"} - - result := rule.Check(repo) + rule := HasRepoDescription{} - if result.Passed { - t.Errorf("expected fail for repo with whitespace-only description, got pass") + if rule.Check(Repo{Name: "my-repo", Description: " \t\n"}) { + t.Errorf("expected fail for repo with whitespace-only description") } } diff --git a/scanner.go b/scanner.go index cc45757..31c570e 100644 --- a/scanner.go +++ b/scanner.go @@ -1,17 +1,43 @@ -package main +package scanner import ( "context" "fmt" + "log" "sort" ) +// Config holds the configuration needed to run a scan. +type Config struct { + Org string + Token string + ReportRepo string +} + // RepoResult holds all rule results for a single repository. type RepoResult struct { RepoName string Results []RuleResult } +// Run is the high-level entry point. It constructs a client, scans, and +// (in the future) posts the report. +func Run(ctx context.Context, cfg Config) error { + client := NewGitHubClient(cfg.Token) + + results, err := Scan(ctx, client, cfg.Org) + if err != nil { + return fmt.Errorf("scan org %s: %w", cfg.Org, err) + } + + log.Printf("scanned %d repos in org %s", len(results), cfg.Org) + + // TODO: generate report and post as GitHub issue to cfg.ReportRepo + _ = results + + return nil +} + // 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) @@ -29,7 +55,10 @@ func Scan(ctx context.Context, client GitHubClient, org string) ([]RepoResult, e rr := RepoResult{RepoName: repo.Name} for _, rule := range rules { - rr.Results = append(rr.Results, rule.Check(repo)) + rr.Results = append(rr.Results, RuleResult{ + RuleName: rule.Name(), + Passed: rule.Check(repo), + }) } results = append(results, rr) } diff --git a/scanner_test.go b/scanner_test.go index eb41ba5..41c65fb 100644 --- a/scanner_test.go +++ b/scanner_test.go @@ -1,4 +1,4 @@ -package main +package scanner import ( "context"