diff --git a/report.go b/report.go
index 3251b3c..ddcf563 100644
--- a/report.go
+++ b/report.go
@@ -8,26 +8,72 @@ import (
)
// GenerateReport produces a Markdown compliance report from scan results.
-// The format matches the specification in README.md.
func GenerateReport(org string, results []RepoResult) string {
+ return generateReport(org, results, time.Now())
+}
+
+func generateReport(org string, results []RepoResult, now time.Time) string {
var b strings.Builder
+ compliant, nonCompliant := splitByCompliance(results)
+
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, "**Scanned:** %s\n", now.UTC().Format("2006-01-02 15:04 UTC"))
fmt.Fprintf(&b, "**Repos scanned:** %d\n", len(results))
+ if len(results) > 0 {
+ fmt.Fprintf(&b, "**Compliant:** %d/%d (%d%%)\n", len(compliant), len(results), len(compliant)*100/len(results))
+ }
+
+ if len(results) == 0 {
+ b.WriteString("\nNo repos found.\n")
+ return b.String()
+ }
b.WriteString("\n## Summary\n\n")
writeSummaryTable(&b, results)
- b.WriteString("\n## Results by repository\n")
- for _, rr := range results {
- writeRepoTable(&b, rr)
+ if len(compliant) > 0 {
+ writeCompliantSection(&b, org, compliant)
+ }
+
+ if len(nonCompliant) > 0 {
+ writeNonCompliantSection(&b, org, nonCompliant)
}
return b.String()
}
+func splitByCompliance(results []RepoResult) (compliant, nonCompliant []RepoResult) {
+ for _, rr := range results {
+ if isFullyCompliant(rr) {
+ compliant = append(compliant, rr)
+ } else {
+ nonCompliant = append(nonCompliant, rr)
+ }
+ }
+ return
+}
+
+func isFullyCompliant(rr RepoResult) bool {
+ for _, r := range rr.Results {
+ if !r.Passed {
+ return false
+ }
+ }
+ return true
+}
+
+func failingRules(rr RepoResult) []string {
+ var names []string
+ for _, r := range rr.Results {
+ if !r.Passed {
+ names = append(names, r.RuleName)
+ }
+ }
+ return names
+}
+
type ruleSummary struct {
name string
passing int
@@ -40,7 +86,6 @@ func writeSummaryTable(b *strings.Builder, results []RepoResult) {
return
}
- // Aggregate pass/fail counts per rule name.
ruleOrder := make([]string, 0)
counts := make(map[string]*ruleSummary)
@@ -60,7 +105,6 @@ func writeSummaryTable(b *strings.Builder, results []RepoResult) {
}
}
- // Calculate pass rates.
summaries := make([]ruleSummary, 0, len(ruleOrder))
total := len(results)
for _, name := range ruleOrder {
@@ -69,7 +113,6 @@ func writeSummaryTable(b *strings.Builder, results []RepoResult) {
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
})
@@ -81,15 +124,31 @@ func writeSummaryTable(b *strings.Builder, results []RepoResult) {
}
}
-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 = "✅"
+func pluralRepos(n int) string {
+ if n == 1 {
+ return "1 repo"
+ }
+ return fmt.Sprintf("%d repos", n)
+}
+
+func writeCompliantSection(b *strings.Builder, org string, compliant []RepoResult) {
+ fmt.Fprintf(b, "\n## ✅ Fully compliant (%s)\n\n", pluralRepos(len(compliant)))
+ b.WriteString("\nAll rules passing
\n\n")
+ for _, rr := range compliant {
+ fmt.Fprintf(b, "[%s](https://github.com/%s/%s)\n", rr.RepoName, org, rr.RepoName)
+ }
+ b.WriteString("\n \n")
+}
+
+func writeNonCompliantSection(b *strings.Builder, org string, nonCompliant []RepoResult) {
+ fmt.Fprintf(b, "\n## ❌ Non-compliant (%s)\n\n", pluralRepos(len(nonCompliant)))
+ for _, rr := range nonCompliant {
+ failing := failingRules(rr)
+ fmt.Fprintf(b, "\n%s - %d failing
\n\n",
+ org, rr.RepoName, rr.RepoName, len(failing))
+ for _, name := range failing {
+ fmt.Fprintf(b, "- %s\n", name)
}
- fmt.Fprintf(b, "| %s | %s |\n", result.RuleName, icon)
+ b.WriteString("\n \n\n")
}
}
diff --git a/report_test.go b/report_test.go
index 0014862..fb0ccc5 100644
--- a/report_test.go
+++ b/report_test.go
@@ -1,120 +1,215 @@
package scanner
import (
- "strings"
"testing"
+ "time"
)
-func TestGenerateReport_Structure(t *testing.T) {
+var testTime = time.Date(2026, 4, 5, 12, 0, 0, 0, time.UTC)
+
+func TestGenerateReport_MixedCompliance(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},
- },
- },
+ {RepoName: "alpha", Results: []RuleResult{
+ {RuleName: "Has repo description", Passed: true},
+ {RuleName: "Has .gitignore", Passed: true},
+ }},
+ {RepoName: "beta", Results: []RuleResult{
+ {RuleName: "Has repo description", Passed: false},
+ {RuleName: "Has .gitignore", Passed: true},
+ }},
}
- report := GenerateReport("test-org", results)
+ got := generateReport("test-org", results, testTime)
- // 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")
- }
+ want := `# Codatus - Org Compliance Report
- // 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")
+**Org:** test-org
+**Scanned:** 2026-04-05 12:00 UTC
+**Repos scanned:** 2
+**Compliant:** 1/2 (50%)
+
+## Summary
+
+| Rule | Passing | Failing | Pass rate |
+|------|---------|---------|----------|
+| Has repo description | 1 | 1 | 50% |
+| Has .gitignore | 2 | 0 | 100% |
+
+## ✅ Fully compliant (1 repo)
+
+
+All rules passing
+
+[alpha](https://github.com/test-org/alpha)
+
+
+
+## ❌ Non-compliant (1 repo)
+
+
+beta - 1 failing
+
+- Has repo description
+
+
+
+`
+ if got != want {
+ t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
}
+}
- // Per-repo tables
- if !strings.Contains(report, "### alpha") {
- t.Error("missing alpha repo section")
+func TestGenerateReport_AllCompliant(t *testing.T) {
+ results := []RepoResult{
+ {RepoName: "alpha", Results: []RuleResult{{RuleName: "Rule A", Passed: true}}},
+ {RepoName: "beta", Results: []RuleResult{{RuleName: "Rule A", Passed: true}}},
}
- if !strings.Contains(report, "### beta") {
- t.Error("missing beta repo section")
+
+ got := generateReport("my-org", results, testTime)
+
+ want := `# Codatus - Org Compliance Report
+
+**Org:** my-org
+**Scanned:** 2026-04-05 12:00 UTC
+**Repos scanned:** 2
+**Compliant:** 2/2 (100%)
+
+## Summary
+
+| Rule | Passing | Failing | Pass rate |
+|------|---------|---------|----------|
+| Rule A | 2 | 0 | 100% |
+
+## ✅ Fully compliant (2 repos)
+
+
+All rules passing
+
+[alpha](https://github.com/my-org/alpha)
+[beta](https://github.com/my-org/beta)
+
+
+`
+ if got != want {
+ t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
}
}
-func TestGenerateReport_PassFailIcons(t *testing.T) {
+func TestGenerateReport_AllNonCompliant(t *testing.T) {
results := []RepoResult{
- {
- RepoName: "my-repo",
- Results: []RuleResult{
- {RuleName: "Has repo description", Passed: true},
- },
- },
+ {RepoName: "repo-1", Results: []RuleResult{
+ {RuleName: "Rule A", Passed: false},
+ {RuleName: "Rule B", Passed: false},
+ }},
+ {RepoName: "repo-2", Results: []RuleResult{
+ {RuleName: "Rule A", Passed: true},
+ {RuleName: "Rule B", Passed: false},
+ }},
}
- report := GenerateReport("test-org", results)
+ got := generateReport("test-org", results, testTime)
- if !strings.Contains(report, "| Has repo description | ✅ |") {
- t.Error("expected ✅ for passing rule")
- }
+ want := `# Codatus - Org Compliance Report
+
+**Org:** test-org
+**Scanned:** 2026-04-05 12:00 UTC
+**Repos scanned:** 2
+**Compliant:** 0/2 (0%)
+
+## Summary
+
+| Rule | Passing | Failing | Pass rate |
+|------|---------|---------|----------|
+| Rule B | 0 | 2 | 0% |
+| Rule A | 1 | 1 | 50% |
+
+## ❌ Non-compliant (2 repos)
+
+
+repo-1 - 2 failing
- results[0].Results[0].Passed = false
- report = GenerateReport("test-org", results)
+- Rule A
+- Rule B
- if !strings.Contains(report, "| Has repo description | ❌ |") {
- t.Error("expected ❌ for failing rule")
+
+
+
+repo-2 - 1 failing
+
+- Rule B
+
+
+
+`
+ if got != want {
+ t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
}
}
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},
- },
- },
+ {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)
+ got := generateReport("test-org", results, testTime)
- // 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 |")
+ want := `# Codatus - Org Compliance Report
- 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")
+**Org:** test-org
+**Scanned:** 2026-04-05 12:00 UTC
+**Repos scanned:** 2
+**Compliant:** 1/2 (50%)
+
+## Summary
+
+| Rule | Passing | Failing | Pass rate |
+|------|---------|---------|----------|
+| Rule B | 1 | 1 | 50% |
+| Rule A | 2 | 0 | 100% |
+
+## ✅ Fully compliant (1 repo)
+
+
+All rules passing
+
+[repo-2](https://github.com/test-org/repo-2)
+
+
+
+## ❌ Non-compliant (1 repo)
+
+
+repo-1 - 1 failing
+
+- Rule B
+
+
+
+`
+ if got != want {
+ t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
}
}
func TestGenerateReport_EmptyResults(t *testing.T) {
- report := GenerateReport("empty-org", nil)
+ got := generateReport("empty-org", nil, testTime)
- if !strings.Contains(report, "**Repos scanned:** 0") {
- t.Error("expected 0 repos scanned")
- }
- if !strings.Contains(report, "## Summary") {
- t.Error("missing summary section")
+ want := `# Codatus - Org Compliance Report
+
+**Org:** empty-org
+**Scanned:** 2026-04-05 12:00 UTC
+**Repos scanned:** 0
+
+No repos found.
+`
+ if got != want {
+ t.Errorf("report mismatch.\n\nGOT:\n%s\n\nWANT:\n%s", got, want)
}
}