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
17 changes: 17 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -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)
}
13 changes: 13 additions & 0 deletions client_mock.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/CodatusHQ/scanner

go 1.22.2
40 changes: 40 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -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")
}
28 changes: 28 additions & 0 deletions rules.go
Original file line number Diff line number Diff line change
@@ -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}
},
},
}
}
39 changes: 39 additions & 0 deletions rules_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
42 changes: 42 additions & 0 deletions scanner.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions scanner_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading