Files
Charon/backend/cmd/localpatchreport/main.go
2026-03-04 18:34:49 +00:00

291 lines
11 KiB
Go

package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/Wikid82/charon/backend/internal/patchreport"
)
type thresholdJSON struct {
Overall float64 `json:"overall_patch_coverage_min"`
Backend float64 `json:"backend_patch_coverage_min"`
Frontend float64 `json:"frontend_patch_coverage_min"`
}
type thresholdSourcesJSON struct {
Overall string `json:"overall"`
Backend string `json:"backend"`
Frontend string `json:"frontend"`
}
type artifactsJSON struct {
Markdown string `json:"markdown"`
JSON string `json:"json"`
}
type reportJSON struct {
Baseline string `json:"baseline"`
GeneratedAt string `json:"generated_at"`
Mode string `json:"mode"`
Thresholds thresholdJSON `json:"thresholds"`
ThresholdSources thresholdSourcesJSON `json:"threshold_sources"`
Overall patchreport.ScopeCoverage `json:"overall"`
Backend patchreport.ScopeCoverage `json:"backend"`
Frontend patchreport.ScopeCoverage `json:"frontend"`
FilesNeedingCoverage []patchreport.FileCoverageDetail `json:"files_needing_coverage,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Artifacts artifactsJSON `json:"artifacts"`
}
func main() {
repoRootFlag := flag.String("repo-root", ".", "Repository root path")
baselineFlag := flag.String("baseline", "origin/development...HEAD", "Git diff baseline")
backendCoverageFlag := flag.String("backend-coverage", "backend/coverage.txt", "Backend Go coverage profile")
frontendCoverageFlag := flag.String("frontend-coverage", "frontend/coverage/lcov.info", "Frontend LCOV coverage report")
jsonOutFlag := flag.String("json-out", "test-results/local-patch-report.json", "Path to JSON output report")
mdOutFlag := flag.String("md-out", "test-results/local-patch-report.md", "Path to markdown output report")
flag.Parse()
repoRoot, err := filepath.Abs(*repoRootFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "error resolving repo root: %v\n", err)
os.Exit(1)
}
backendCoveragePath := resolvePath(repoRoot, *backendCoverageFlag)
frontendCoveragePath := resolvePath(repoRoot, *frontendCoverageFlag)
jsonOutPath := resolvePath(repoRoot, *jsonOutFlag)
mdOutPath := resolvePath(repoRoot, *mdOutFlag)
err = assertFileExists(backendCoveragePath, "backend coverage file")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
err = assertFileExists(frontendCoveragePath, "frontend coverage file")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
diffContent, err := gitDiff(repoRoot, *baselineFlag)
if err != nil {
fmt.Fprintf(os.Stderr, "error generating git diff: %v\n", err)
os.Exit(1)
}
backendChanged, frontendChanged, err := patchreport.ParseUnifiedDiffChangedLines(diffContent)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing changed lines from diff: %v\n", err)
os.Exit(1)
}
backendCoverage, err := patchreport.ParseGoCoverageProfile(backendCoveragePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing backend coverage: %v\n", err)
os.Exit(1)
}
frontendCoverage, err := patchreport.ParseLCOVProfile(frontendCoveragePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error parsing frontend coverage: %v\n", err)
os.Exit(1)
}
overallThreshold := patchreport.ResolveThreshold("CHARON_OVERALL_PATCH_COVERAGE_MIN", 90, nil)
backendThreshold := patchreport.ResolveThreshold("CHARON_BACKEND_PATCH_COVERAGE_MIN", 85, nil)
frontendThreshold := patchreport.ResolveThreshold("CHARON_FRONTEND_PATCH_COVERAGE_MIN", 85, nil)
backendScope := patchreport.ComputeScopeCoverage(backendChanged, backendCoverage)
frontendScope := patchreport.ComputeScopeCoverage(frontendChanged, frontendCoverage)
overallScope := patchreport.MergeScopeCoverage(backendScope, frontendScope)
backendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(backendChanged, backendCoverage, backendThreshold.Value)
frontendFilesNeedingCoverage := patchreport.ComputeFilesNeedingCoverage(frontendChanged, frontendCoverage, frontendThreshold.Value)
filesNeedingCoverage := patchreport.MergeFileCoverageDetails(backendFilesNeedingCoverage, frontendFilesNeedingCoverage)
backendScope = patchreport.ApplyStatus(backendScope, backendThreshold.Value)
frontendScope = patchreport.ApplyStatus(frontendScope, frontendThreshold.Value)
overallScope = patchreport.ApplyStatus(overallScope, overallThreshold.Value)
warnings := patchreport.SortedWarnings([]string{
overallThreshold.Warning,
backendThreshold.Warning,
frontendThreshold.Warning,
})
if overallScope.Status == "warn" {
warnings = append(warnings, fmt.Sprintf("Overall patch coverage %.1f%% is below threshold %.1f%%", overallScope.PatchCoveragePct, overallThreshold.Value))
}
if backendScope.Status == "warn" {
warnings = append(warnings, fmt.Sprintf("Backend patch coverage %.1f%% is below threshold %.1f%%", backendScope.PatchCoveragePct, backendThreshold.Value))
}
if frontendScope.Status == "warn" {
warnings = append(warnings, fmt.Sprintf("Frontend patch coverage %.1f%% is below threshold %.1f%%", frontendScope.PatchCoveragePct, frontendThreshold.Value))
}
report := reportJSON{
Baseline: *baselineFlag,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Mode: "warn",
Thresholds: thresholdJSON{
Overall: overallThreshold.Value,
Backend: backendThreshold.Value,
Frontend: frontendThreshold.Value,
},
ThresholdSources: thresholdSourcesJSON{
Overall: overallThreshold.Source,
Backend: backendThreshold.Source,
Frontend: frontendThreshold.Source,
},
Overall: overallScope,
Backend: backendScope,
Frontend: frontendScope,
FilesNeedingCoverage: filesNeedingCoverage,
Warnings: warnings,
Artifacts: artifactsJSON{
Markdown: relOrAbs(repoRoot, mdOutPath),
JSON: relOrAbs(repoRoot, jsonOutPath),
},
}
if err := os.MkdirAll(filepath.Dir(jsonOutPath), 0o750); err != nil {
fmt.Fprintf(os.Stderr, "error creating json output directory: %v\n", err)
os.Exit(1)
}
if err := os.MkdirAll(filepath.Dir(mdOutPath), 0o750); err != nil {
fmt.Fprintf(os.Stderr, "error creating markdown output directory: %v\n", err)
os.Exit(1)
}
if err := writeJSON(jsonOutPath, report); err != nil {
fmt.Fprintf(os.Stderr, "error writing json report: %v\n", err)
os.Exit(1)
}
if err := writeMarkdown(mdOutPath, report, relOrAbs(repoRoot, backendCoveragePath), relOrAbs(repoRoot, frontendCoveragePath)); err != nil {
fmt.Fprintf(os.Stderr, "error writing markdown report: %v\n", err)
os.Exit(1)
}
fmt.Printf("Local patch report generated (mode=%s)\n", report.Mode)
fmt.Printf("JSON: %s\n", relOrAbs(repoRoot, jsonOutPath))
fmt.Printf("Markdown: %s\n", relOrAbs(repoRoot, mdOutPath))
for _, warning := range warnings {
fmt.Printf("WARN: %s\n", warning)
}
}
func resolvePath(repoRoot, configured string) string {
if filepath.IsAbs(configured) {
return configured
}
return filepath.Join(repoRoot, configured)
}
func relOrAbs(repoRoot, path string) string {
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(rel)
}
func assertFileExists(path, label string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("missing %s at %s: %w", label, path, err)
}
if info.IsDir() {
return fmt.Errorf("expected %s to be a file but found directory: %s", label, path)
}
return nil
}
func gitDiff(repoRoot, baseline string) (string, error) {
cmd := exec.Command("git", "-C", repoRoot, "diff", "--unified=0", baseline)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git diff %s failed: %w (%s)", baseline, err, strings.TrimSpace(string(output)))
}
return string(output), nil
}
func writeJSON(path string, report reportJSON) error {
encoded, err := json.MarshalIndent(report, "", " ")
if err != nil {
return fmt.Errorf("marshal report json: %w", err)
}
encoded = append(encoded, '\n')
if err := os.WriteFile(path, encoded, 0o600); err != nil {
return fmt.Errorf("write report json file: %w", err)
}
return nil
}
func writeMarkdown(path string, report reportJSON, backendCoveragePath, frontendCoveragePath string) error {
var builder strings.Builder
builder.WriteString("# Local Patch Coverage Report\n\n")
builder.WriteString("## Metadata\n\n")
builder.WriteString(fmt.Sprintf("- Generated: %s\n", report.GeneratedAt))
builder.WriteString(fmt.Sprintf("- Baseline: `%s`\n", report.Baseline))
builder.WriteString(fmt.Sprintf("- Mode: `%s`\n\n", report.Mode))
builder.WriteString("## Inputs\n\n")
builder.WriteString(fmt.Sprintf("- Backend coverage: `%s`\n", backendCoveragePath))
builder.WriteString(fmt.Sprintf("- Frontend coverage: `%s`\n\n", frontendCoveragePath))
builder.WriteString("## Resolved Thresholds\n\n")
builder.WriteString("| Scope | Minimum (%) | Source |\n")
builder.WriteString("|---|---:|---|\n")
builder.WriteString(fmt.Sprintf("| Overall | %.1f | %s |\n", report.Thresholds.Overall, report.ThresholdSources.Overall))
builder.WriteString(fmt.Sprintf("| Backend | %.1f | %s |\n", report.Thresholds.Backend, report.ThresholdSources.Backend))
builder.WriteString(fmt.Sprintf("| Frontend | %.1f | %s |\n\n", report.Thresholds.Frontend, report.ThresholdSources.Frontend))
builder.WriteString("## Coverage Summary\n\n")
builder.WriteString("| Scope | Changed Lines | Covered Lines | Patch Coverage (%) | Status |\n")
builder.WriteString("|---|---:|---:|---:|---|\n")
builder.WriteString(scopeRow("Overall", report.Overall))
builder.WriteString(scopeRow("Backend", report.Backend))
builder.WriteString(scopeRow("Frontend", report.Frontend))
builder.WriteString("\n")
if len(report.FilesNeedingCoverage) > 0 {
builder.WriteString("## Files Needing Coverage\n\n")
builder.WriteString("| Path | Patch Coverage (%) | Uncovered Changed Lines | Uncovered Changed Line Ranges |\n")
builder.WriteString("|---|---:|---:|---|\n")
for _, fileCoverage := range report.FilesNeedingCoverage {
ranges := "-"
if len(fileCoverage.UncoveredChangedLineRange) > 0 {
ranges = strings.Join(fileCoverage.UncoveredChangedLineRange, ", ")
}
builder.WriteString(fmt.Sprintf("| `%s` | %.1f | %d | %s |\n", fileCoverage.Path, fileCoverage.PatchCoveragePct, fileCoverage.UncoveredChangedLines, ranges))
}
builder.WriteString("\n")
}
if len(report.Warnings) > 0 {
builder.WriteString("## Warnings\n\n")
for _, warning := range report.Warnings {
builder.WriteString(fmt.Sprintf("- %s\n", warning))
}
builder.WriteString("\n")
}
builder.WriteString("## Artifacts\n\n")
builder.WriteString(fmt.Sprintf("- Markdown: `%s`\n", report.Artifacts.Markdown))
builder.WriteString(fmt.Sprintf("- JSON: `%s`\n", report.Artifacts.JSON))
if err := os.WriteFile(path, []byte(builder.String()), 0o600); err != nil {
return fmt.Errorf("write markdown file: %w", err)
}
return nil
}
func scopeRow(name string, scope patchreport.ScopeCoverage) string {
return fmt.Sprintf("| %s | %d | %d | %.1f | %s |\n", name, scope.ChangedLines, scope.CoveredLines, scope.PatchCoveragePct, scope.Status)
}