From e1a738d9af4c57d43c93ff250790abdcaa90b0d1 Mon Sep 17 00:00:00 2001 From: packet-mover Date: Sun, 5 Apr 2026 21:05:37 +0200 Subject: [PATCH] feat: add report generation and GitHub Issue posting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GenerateReport() producing Markdown per README spec (header, summary table sorted by pass rate, per-repo ✅/❌ tables) - Add CreateIssue to GitHubClient interface + real implementation - Wire up Run() end-to-end: scan → generate report → post issue - Add parseReportRepo helper for owner/repo parsing - Tests for report structure, icons, summary sorting, empty results Co-Authored-By: Claude Opus 4.6 (1M context) --- client.go | 36 +++++++++++++ client_mock_test.go | 17 ++++++- report.go | 95 +++++++++++++++++++++++++++++++++++ report_test.go | 120 ++++++++++++++++++++++++++++++++++++++++++++ scanner.go | 27 ++++++++-- 5 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 report.go create mode 100644 report_test.go diff --git a/client.go b/client.go index 620e0de..76a6b6b 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package scanner import ( + "bytes" "context" "encoding/json" "fmt" @@ -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 { @@ -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 +} diff --git a/client_mock_test.go b/client_mock_test.go index 2a264ee..f83092b 100644 --- a/client_mock_test.go +++ b/client_mock_test.go @@ -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) { @@ -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 +} diff --git a/report.go b/report.go new file mode 100644 index 0000000..9d46f3d --- /dev/null +++ b/report.go @@ -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) + } +} diff --git a/report_test.go b/report_test.go new file mode 100644 index 0000000..9111358 --- /dev/null +++ b/report_test.go @@ -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") + } +} diff --git a/scanner.go b/scanner.go index 31c570e..6ebf8b8 100644 --- a/scanner.go +++ b/scanner.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "sort" + "strings" ) // Config holds the configuration needed to run a scan. @@ -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) @@ -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)