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
36 changes: 36 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package scanner

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand All @@ -20,6 +21,7 @@ type Repo struct {
type GitHubClient interface {
ListRepos(ctx context.Context, org string) ([]Repo, error)
ListFiles(ctx context.Context, owner, repo string) ([]string, error)
CreateIssue(ctx context.Context, owner, repo, title, body string) error
}

type realGitHubClient struct {
Expand Down Expand Up @@ -103,3 +105,37 @@ func (c *realGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([
// TODO: implement when file-existence rules are added
return nil, nil
}

func (c *realGitHubClient) CreateIssue(ctx context.Context, owner, repo, title, body string) error {
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/issues", owner, repo)

payload := struct {
Title string `json:"title"`
Body string `json:"body"`
}{Title: title, Body: body}

jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal issue payload: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
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")
req.Header.Set("Content-Type", "application/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.StatusCreated {
return fmt.Errorf("create issue in %s/%s: status %d", owner, repo, resp.StatusCode)
}

return nil
}
17 changes: 15 additions & 2 deletions client_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import "context"

// MockGitHubClient implements GitHubClient with canned responses for testing.
type MockGitHubClient struct {
Repos []Repo
Err error
Repos []Repo
Err error
IssueErr error
// CreatedIssue records the last CreateIssue call for assertions.
CreatedIssue struct {
Owner, Repo, Title, Body string
}
}

func (m *MockGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, error) {
Expand All @@ -15,3 +20,11 @@ func (m *MockGitHubClient) ListRepos(ctx context.Context, org string) ([]Repo, e
func (m *MockGitHubClient) ListFiles(ctx context.Context, owner, repo string) ([]string, error) {
return nil, nil
}

func (m *MockGitHubClient) CreateIssue(ctx context.Context, owner, repo, title, body string) error {
m.CreatedIssue.Owner = owner
m.CreatedIssue.Repo = repo
m.CreatedIssue.Title = title
m.CreatedIssue.Body = body
return m.IssueErr
}
95 changes: 95 additions & 0 deletions report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package scanner

import (
"fmt"
"sort"
"strings"
"time"
)

// GenerateReport produces a Markdown compliance report from scan results.
// The format matches the specification in README.md.
func GenerateReport(org string, results []RepoResult) string {
var b strings.Builder

b.WriteString("# Codatus — Org Compliance Report\n\n")
fmt.Fprintf(&b, "**Org:** %s\n", org)
fmt.Fprintf(&b, "**Scanned:** %s\n", time.Now().UTC().Format("2006-01-02 15:04 UTC"))
fmt.Fprintf(&b, "**Repos scanned:** %d\n", len(results))

b.WriteString("\n## Summary\n\n")
writeSummaryTable(&b, results)

b.WriteString("\n## Results by repository\n")
for _, rr := range results {
writeRepoTable(&b, rr)
}

return b.String()
}

type ruleSummary struct {
name string
passing int
failing int
passRate int
}

func writeSummaryTable(b *strings.Builder, results []RepoResult) {
if len(results) == 0 {
return
}

// Aggregate pass/fail counts per rule name.
ruleOrder := make([]string, 0)
counts := make(map[string]*ruleSummary)

for _, rr := range results {
for _, result := range rr.Results {
s, ok := counts[result.RuleName]
if !ok {
s = &ruleSummary{name: result.RuleName}
counts[result.RuleName] = s
ruleOrder = append(ruleOrder, result.RuleName)
}
if result.Passed {
s.passing++
} else {
s.failing++
}
}
}

// Calculate pass rates.
summaries := make([]ruleSummary, 0, len(ruleOrder))
total := len(results)
for _, name := range ruleOrder {
s := counts[name]
s.passRate = s.passing * 100 / total
summaries = append(summaries, *s)
}

// Sort by pass rate ascending (worst compliance first).
sort.SliceStable(summaries, func(i, j int) bool {
return summaries[i].passRate < summaries[j].passRate
})

b.WriteString("| Rule | Passing | Failing | Pass rate |\n")
b.WriteString("|------|---------|---------|----------|\n")
for _, s := range summaries {
fmt.Fprintf(b, "| %s | %d | %d | %d%% |\n", s.name, s.passing, s.failing, s.passRate)
}
}

func writeRepoTable(b *strings.Builder, rr RepoResult) {
fmt.Fprintf(b, "\n### %s\n\n", rr.RepoName)
b.WriteString("| Rule | Result |\n")
b.WriteString("|------|--------|\n")
for _, result := range rr.Results {
icon := "❌"
if result.Passed {
icon = "✅"
}
fmt.Fprintf(b, "| %s | %s |\n", result.RuleName, icon)
}
}
120 changes: 120 additions & 0 deletions report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package scanner

import (
"strings"
"testing"
)

func TestGenerateReport_Structure(t *testing.T) {
results := []RepoResult{
{
RepoName: "alpha",
Results: []RuleResult{
{RuleName: "Has repo description", Passed: true},
},
},
{
RepoName: "beta",
Results: []RuleResult{
{RuleName: "Has repo description", Passed: false},
},
},
}

report := GenerateReport("test-org", results)

// Header
if !strings.Contains(report, "# Codatus — Org Compliance Report") {
t.Error("missing report title")
}
if !strings.Contains(report, "**Org:** test-org") {
t.Error("missing org name")
}
if !strings.Contains(report, "**Repos scanned:** 2") {
t.Error("missing repo count")
}

// Summary table
if !strings.Contains(report, "## Summary") {
t.Error("missing summary section")
}
if !strings.Contains(report, "| Has repo description | 1 | 1 | 50% |") {
t.Error("missing or incorrect summary row")
}

// Per-repo tables
if !strings.Contains(report, "### alpha") {
t.Error("missing alpha repo section")
}
if !strings.Contains(report, "### beta") {
t.Error("missing beta repo section")
}
}

func TestGenerateReport_PassFailIcons(t *testing.T) {
results := []RepoResult{
{
RepoName: "my-repo",
Results: []RuleResult{
{RuleName: "Has repo description", Passed: true},
},
},
}

report := GenerateReport("test-org", results)

if !strings.Contains(report, "| Has repo description | ✅ |") {
t.Error("expected ✅ for passing rule")
}

results[0].Results[0].Passed = false
report = GenerateReport("test-org", results)

if !strings.Contains(report, "| Has repo description | ❌ |") {
t.Error("expected ❌ for failing rule")
}
}

func TestGenerateReport_SummarySortedByPassRateAscending(t *testing.T) {
results := []RepoResult{
{
RepoName: "repo-1",
Results: []RuleResult{
{RuleName: "Rule A", Passed: true},
{RuleName: "Rule B", Passed: false},
},
},
{
RepoName: "repo-2",
Results: []RuleResult{
{RuleName: "Rule A", Passed: true},
{RuleName: "Rule B", Passed: true},
},
},
}

report := GenerateReport("test-org", results)

// Rule B: 1 pass / 2 repos = 50%. Rule A: 2 pass / 2 repos = 100%.
// Sorted ascending: Rule B first, then Rule A.
posB := strings.Index(report, "| Rule B |")
posA := strings.Index(report, "| Rule A |")

if posB == -1 || posA == -1 {
t.Fatal("missing rule rows in summary")
}
if posB > posA {
t.Error("expected Rule B (50%) before Rule A (100%) in summary")
}
}

func TestGenerateReport_EmptyResults(t *testing.T) {
report := GenerateReport("empty-org", nil)

if !strings.Contains(report, "**Repos scanned:** 0") {
t.Error("expected 0 repos scanned")
}
if !strings.Contains(report, "## Summary") {
t.Error("missing summary section")
}
}
27 changes: 23 additions & 4 deletions scanner.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"sort"
"strings"
)

// Config holds the configuration needed to run a scan.
Expand All @@ -20,8 +21,8 @@ type RepoResult struct {
Results []RuleResult
}

// Run is the high-level entry point. It constructs a client, scans, and
// (in the future) posts the report.
// Run is the high-level entry point. It constructs a client, scans the org,
// generates a Markdown report, and posts it as a GitHub Issue.
func Run(ctx context.Context, cfg Config) error {
client := NewGitHubClient(cfg.Token)

Expand All @@ -32,12 +33,30 @@ func Run(ctx context.Context, cfg Config) error {

log.Printf("scanned %d repos in org %s", len(results), cfg.Org)

// TODO: generate report and post as GitHub issue to cfg.ReportRepo
_ = results
report := GenerateReport(cfg.Org, results)

owner, repo, err := parseReportRepo(cfg.ReportRepo)
if err != nil {
return err
}

title := fmt.Sprintf("Codatus — %s Compliance Report", cfg.Org)
if err := client.CreateIssue(ctx, owner, repo, title, report); err != nil {
return fmt.Errorf("post report to %s: %w", cfg.ReportRepo, err)
}

log.Printf("report posted to %s", cfg.ReportRepo)
return nil
}

func parseReportRepo(reportRepo string) (string, string, error) {
parts := strings.SplitN(reportRepo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid report repo format %q: expected owner/repo", reportRepo)
}
return parts[0], parts[1], 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)
Expand Down
Loading