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
37 changes: 19 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand Down
92 changes: 90 additions & 2 deletions client.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
6 changes: 5 additions & 1 deletion client_mock.go → client_mock_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package main
package scanner

import "context"

Expand All @@ -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
}
35 changes: 35 additions & 0 deletions cmd/scanner/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 0 additions & 40 deletions main.go

This file was deleted.

30 changes: 16 additions & 14 deletions rules.go
Original file line number Diff line number Diff line change
@@ -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) != ""
}
32 changes: 10 additions & 22 deletions rules_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading