|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Calculate Go test coverage and generate reports. |
| 4 | +
|
| 5 | +This script parses the coverage.out file generated by `go test -coverprofile`, |
| 6 | +extracts coverage statistics, and generates formatted reports. |
| 7 | +""" |
| 8 | + |
| 9 | +import sys |
| 10 | +import re |
| 11 | +import os |
| 12 | +from typing import Dict, List, Tuple |
| 13 | + |
| 14 | + |
| 15 | +def parse_coverage_file(coverage_file: str) -> Tuple[float, Dict[str, float]]: |
| 16 | + """ |
| 17 | + Parse coverage output file and extract coverage data. |
| 18 | +
|
| 19 | + Args: |
| 20 | + coverage_file: Path to coverage.out file |
| 21 | +
|
| 22 | + Returns: |
| 23 | + Tuple of (total_coverage, package_coverage_dict) |
| 24 | + """ |
| 25 | + if not os.path.exists(coverage_file): |
| 26 | + print(f"Error: Coverage file {coverage_file} not found", file=sys.stderr) |
| 27 | + sys.exit(1) |
| 28 | + |
| 29 | + # Run go tool cover to get coverage data |
| 30 | + import subprocess |
| 31 | + |
| 32 | + try: |
| 33 | + result = subprocess.run( |
| 34 | + ['go', 'tool', 'cover', '-func', coverage_file], |
| 35 | + capture_output=True, |
| 36 | + text=True, |
| 37 | + check=True |
| 38 | + ) |
| 39 | + except subprocess.CalledProcessError as e: |
| 40 | + print(f"Error running go tool cover: {e}", file=sys.stderr) |
| 41 | + sys.exit(1) |
| 42 | + |
| 43 | + lines = result.stdout.strip().split('\n') |
| 44 | + package_coverage = {} |
| 45 | + total_coverage = 0.0 |
| 46 | + |
| 47 | + for line in lines: |
| 48 | + # Skip empty lines |
| 49 | + if not line.strip(): |
| 50 | + continue |
| 51 | + |
| 52 | + # Check for total coverage line |
| 53 | + if line.startswith('total:'): |
| 54 | + # Extract percentage from "total: (statements) XX.X%" |
| 55 | + match = re.search(r'(\d+\.\d+)%', line) |
| 56 | + if match: |
| 57 | + total_coverage = float(match.group(1)) |
| 58 | + continue |
| 59 | + |
| 60 | + # Parse package/file coverage |
| 61 | + # Format: "package/file.go:function statements coverage%" |
| 62 | + parts = line.split() |
| 63 | + if len(parts) >= 3: |
| 64 | + file_path = parts[0] |
| 65 | + coverage_str = parts[-1] |
| 66 | + |
| 67 | + # Extract package name from file path |
| 68 | + package = file_path.split(':')[0] |
| 69 | + package_name = '/'.join(package.split('/')[:-1]) if '/' in package else package |
| 70 | + |
| 71 | + # Extract coverage percentage |
| 72 | + match = re.search(r'(\d+\.\d+)%', coverage_str) |
| 73 | + if match: |
| 74 | + coverage_pct = float(match.group(1)) |
| 75 | + |
| 76 | + # Aggregate by package |
| 77 | + if package_name not in package_coverage: |
| 78 | + package_coverage[package_name] = [] |
| 79 | + package_coverage[package_name].append(coverage_pct) |
| 80 | + |
| 81 | + # Calculate average coverage per package |
| 82 | + package_avg = { |
| 83 | + pkg: sum(coverages) / len(coverages) |
| 84 | + for pkg, coverages in package_coverage.items() |
| 85 | + } |
| 86 | + |
| 87 | + return total_coverage, package_avg |
| 88 | + |
| 89 | + |
| 90 | +def get_coverage_status(coverage: float) -> Tuple[str, str, str]: |
| 91 | + """ |
| 92 | + Get coverage status based on percentage. |
| 93 | +
|
| 94 | + Args: |
| 95 | + coverage: Coverage percentage |
| 96 | +
|
| 97 | + Returns: |
| 98 | + Tuple of (emoji, status_text, badge_color) |
| 99 | + """ |
| 100 | + if coverage >= 80: |
| 101 | + return '🟢', 'excellent', 'brightgreen' |
| 102 | + elif coverage >= 60: |
| 103 | + return '🟡', 'good', 'yellow' |
| 104 | + elif coverage >= 40: |
| 105 | + return '🟠', 'fair', 'orange' |
| 106 | + else: |
| 107 | + return '🔴', 'needs improvement', 'red' |
| 108 | + |
| 109 | + |
| 110 | +def generate_coverage_report(coverage_file: str, output_file: str) -> None: |
| 111 | + """ |
| 112 | + Generate a detailed coverage report in markdown format. |
| 113 | +
|
| 114 | + Args: |
| 115 | + coverage_file: Path to coverage.out file |
| 116 | + output_file: Path to output markdown file |
| 117 | + """ |
| 118 | + import subprocess |
| 119 | + |
| 120 | + try: |
| 121 | + result = subprocess.run( |
| 122 | + ['go', 'tool', 'cover', '-func', coverage_file], |
| 123 | + capture_output=True, |
| 124 | + text=True, |
| 125 | + check=True |
| 126 | + ) |
| 127 | + except subprocess.CalledProcessError as e: |
| 128 | + print(f"Error generating coverage report: {e}", file=sys.stderr) |
| 129 | + sys.exit(1) |
| 130 | + |
| 131 | + with open(output_file, 'w') as f: |
| 132 | + f.write("## Coverage by Package\n\n") |
| 133 | + f.write("```\n") |
| 134 | + f.write(result.stdout) |
| 135 | + f.write("```\n") |
| 136 | + |
| 137 | + |
| 138 | +def set_github_output(name: str, value: str) -> None: |
| 139 | + """ |
| 140 | + Set GitHub Actions output variable. |
| 141 | +
|
| 142 | + Args: |
| 143 | + name: Output variable name |
| 144 | + value: Output variable value |
| 145 | + """ |
| 146 | + github_output = os.environ.get('GITHUB_OUTPUT') |
| 147 | + if github_output: |
| 148 | + with open(github_output, 'a') as f: |
| 149 | + f.write(f"{name}={value}\n") |
| 150 | + else: |
| 151 | + print(f"::set-output name={name}::{value}") |
| 152 | + |
| 153 | + |
| 154 | +def main(): |
| 155 | + """Main entry point.""" |
| 156 | + if len(sys.argv) < 2: |
| 157 | + print("Usage: calculate_coverage.py <coverage_file> [output_file]", file=sys.stderr) |
| 158 | + sys.exit(1) |
| 159 | + |
| 160 | + coverage_file = sys.argv[1] |
| 161 | + output_file = sys.argv[2] if len(sys.argv) > 2 else 'coverage_report.md' |
| 162 | + |
| 163 | + # Parse coverage data |
| 164 | + total_coverage, package_coverage = parse_coverage_file(coverage_file) |
| 165 | + |
| 166 | + # Get coverage status |
| 167 | + emoji, status, badge_color = get_coverage_status(total_coverage) |
| 168 | + |
| 169 | + # Generate detailed report |
| 170 | + generate_coverage_report(coverage_file, output_file) |
| 171 | + |
| 172 | + # Output results |
| 173 | + print(f"Total Coverage: {total_coverage}%") |
| 174 | + print(f"Status: {status}") |
| 175 | + print(f"Badge Color: {badge_color}") |
| 176 | + |
| 177 | + # Set GitHub Actions outputs |
| 178 | + set_github_output('coverage', f'{total_coverage}%') |
| 179 | + set_github_output('coverage_num', str(total_coverage)) |
| 180 | + set_github_output('status', status) |
| 181 | + set_github_output('emoji', emoji) |
| 182 | + set_github_output('badge_color', badge_color) |
| 183 | + |
| 184 | + # Print package breakdown |
| 185 | + if package_coverage: |
| 186 | + print("\nCoverage by Package:") |
| 187 | + for package, coverage in sorted(package_coverage.items()): |
| 188 | + print(f" {package}: {coverage:.1f}%") |
| 189 | + |
| 190 | + |
| 191 | +if __name__ == '__main__': |
| 192 | + main() |
0 commit comments