|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate a coverage summary from Cobertura XML, with optional diff coverage.""" |
| 3 | + |
| 4 | +import os |
| 5 | +import re |
| 6 | +import subprocess |
| 7 | +import sys |
| 8 | +import xml.etree.ElementTree as ET |
| 9 | +from collections import defaultdict |
| 10 | + |
| 11 | + |
| 12 | +def make_bar(pct, width=20): |
| 13 | + pct = max(0.0, min(100.0, pct)) |
| 14 | + filled = round(pct / 100 * width) |
| 15 | + return "\u2593" * filled + "\u2591" * (width - filled) |
| 16 | + |
| 17 | + |
| 18 | +def status_icon(pct): |
| 19 | + if pct >= 80: |
| 20 | + return "\U0001f7e2" |
| 21 | + if pct >= 60: |
| 22 | + return "\U0001f7e1" |
| 23 | + return "\U0001f534" |
| 24 | + |
| 25 | + |
| 26 | +def _get_repo_root(): |
| 27 | + result = subprocess.run( |
| 28 | + ["git", "rev-parse", "--show-toplevel"], |
| 29 | + capture_output=True, |
| 30 | + text=True, |
| 31 | + check=True, |
| 32 | + ) |
| 33 | + return result.stdout.strip() |
| 34 | + |
| 35 | + |
| 36 | +_repo_root = None |
| 37 | + |
| 38 | + |
| 39 | +def _normalize_filename(filename, source_roots): |
| 40 | + """Normalize coverage XML filename to repo-relative path. |
| 41 | +
|
| 42 | + Coverage XML records paths relative to <source> roots, while git diff |
| 43 | + produces paths relative to the repo root. This joins the two and strips |
| 44 | + the repo root prefix so both sides use the same reference frame. |
| 45 | + """ |
| 46 | + global _repo_root |
| 47 | + if _repo_root is None: |
| 48 | + _repo_root = _get_repo_root() |
| 49 | + repo_prefix = _repo_root + "/" |
| 50 | + |
| 51 | + for root in source_roots: |
| 52 | + if os.path.isabs(filename): |
| 53 | + if filename.startswith(repo_prefix): |
| 54 | + return filename[len(repo_prefix) :] |
| 55 | + else: |
| 56 | + full = os.path.join(root, filename) |
| 57 | + if full.startswith(repo_prefix): |
| 58 | + return full[len(repo_prefix) :] |
| 59 | + return filename |
| 60 | + |
| 61 | + |
| 62 | +def parse_coverage_xml(xml_path, depth): |
| 63 | + """Parse coverage.xml, return (groups_dict, file_line_coverage_dict).""" |
| 64 | + tree = ET.parse(xml_path) |
| 65 | + root = tree.getroot() |
| 66 | + |
| 67 | + source_roots = [s.text.rstrip("/") for s in root.findall(".//source") if s.text] |
| 68 | + |
| 69 | + groups = defaultdict(lambda: {"hits": 0, "lines": 0}) |
| 70 | + file_coverage = defaultdict(dict) |
| 71 | + |
| 72 | + for pkg in root.findall(".//package"): |
| 73 | + name = pkg.get("name", "") |
| 74 | + parts = name.split(".") |
| 75 | + key = ".".join(parts[:depth]) if len(parts) >= depth else name |
| 76 | + |
| 77 | + for cls in pkg.findall(".//class"): |
| 78 | + filename = cls.get("filename", "") |
| 79 | + filename = _normalize_filename(filename, source_roots) |
| 80 | + for line in cls.findall(".//line"): |
| 81 | + line_num = int(line.get("number", "0")) |
| 82 | + hit = int(line.get("hits", "0")) > 0 |
| 83 | + file_coverage[filename][line_num] = ( |
| 84 | + file_coverage[filename].get(line_num, False) or hit |
| 85 | + ) |
| 86 | + groups[key]["lines"] += 1 |
| 87 | + if hit: |
| 88 | + groups[key]["hits"] += 1 |
| 89 | + |
| 90 | + return groups, file_coverage |
| 91 | + |
| 92 | + |
| 93 | +def get_changed_lines(diff_branch): |
| 94 | + """Run git diff and return {filepath: set_of_changed_line_numbers} for non-test .py files.""" |
| 95 | + result = subprocess.run( |
| 96 | + ["git", "diff", "--unified=0", diff_branch, "--", "*.py"], |
| 97 | + capture_output=True, |
| 98 | + text=True, |
| 99 | + check=True, |
| 100 | + ) |
| 101 | + |
| 102 | + changed = defaultdict(set) |
| 103 | + current_file = None |
| 104 | + hunk_re = re.compile(r"^@@ .+?\+(\d+)(?:,(\d+))? @@") |
| 105 | + |
| 106 | + for line in result.stdout.splitlines(): |
| 107 | + if line.startswith("+++ b/"): |
| 108 | + current_file = line[6:] |
| 109 | + elif line.startswith("@@") and current_file: |
| 110 | + m = hunk_re.match(line) |
| 111 | + if m: |
| 112 | + start = int(m.group(1)) |
| 113 | + count = int(m.group(2)) if m.group(2) else 1 |
| 114 | + if count > 0: |
| 115 | + for i in range(start, start + count): |
| 116 | + changed[current_file].add(i) |
| 117 | + |
| 118 | + return { |
| 119 | + f: lines |
| 120 | + for f, lines in changed.items() |
| 121 | + if f.endswith(".py") and not f.startswith("tests/") and f.startswith("flow360/") |
| 122 | + } |
| 123 | + |
| 124 | + |
| 125 | +def build_diff_coverage_md(changed_lines, file_coverage): |
| 126 | + """Build diff coverage markdown section.""" |
| 127 | + if not changed_lines: |
| 128 | + return "## Diff Coverage\n\nNo implementation files changed.\n" |
| 129 | + |
| 130 | + total_covered = 0 |
| 131 | + total_changed = 0 |
| 132 | + |
| 133 | + file_stats = [] |
| 134 | + for filepath, line_nums in sorted(changed_lines.items()): |
| 135 | + cov_map = file_coverage.get(filepath, {}) |
| 136 | + executable = {ln for ln in line_nums if ln in cov_map} |
| 137 | + covered = {ln for ln in executable if cov_map[ln]} |
| 138 | + missing = sorted(executable - covered) |
| 139 | + |
| 140 | + n_exec = len(executable) |
| 141 | + n_cov = len(covered) |
| 142 | + total_covered += n_cov |
| 143 | + total_changed += n_exec |
| 144 | + pct = (n_cov / n_exec * 100) if n_exec else -1 |
| 145 | + file_stats.append((filepath, pct, n_cov, n_exec, missing)) |
| 146 | + |
| 147 | + file_stats.sort(key=lambda x: x[1]) |
| 148 | + total_pct = (total_covered / total_changed * 100) if total_changed else 100 |
| 149 | + |
| 150 | + lines = [] |
| 151 | + lines.append(f"## {status_icon(total_pct)} Diff Coverage — {total_pct:.0f}%") |
| 152 | + lines.append("") |
| 153 | + lines.append( |
| 154 | + f"`{make_bar(total_pct, 30)}` **{total_pct:.1f}%** ({total_covered} / {total_changed} changed lines covered)" |
| 155 | + ) |
| 156 | + lines.append("") |
| 157 | + lines.append("| File | Coverage | Lines | Missing |") |
| 158 | + lines.append("|:-----|:--------:|:-----:|:--------|") |
| 159 | + |
| 160 | + for filepath, pct, n_cov, n_exec, missing in file_stats: |
| 161 | + icon = status_icon(pct) if pct >= 0 else "\u26aa" |
| 162 | + pct_str = f"{pct:.0f}%" if pct >= 0 else "N/A" |
| 163 | + missing_str = ", ".join(f"L{ln}" for ln in missing[:20]) |
| 164 | + if len(missing) > 20: |
| 165 | + missing_str += f" \u2026 +{len(missing) - 20} more" |
| 166 | + lines.append(f"| `{filepath}` | {icon} {pct_str} | {n_cov} / {n_exec} | {missing_str} |") |
| 167 | + |
| 168 | + lines.append(f"| **Total** | **{total_pct:.1f}%** | **{total_covered} / {total_changed}** | |") |
| 169 | + lines.append("") |
| 170 | + return "\n".join(lines) |
| 171 | + |
| 172 | + |
| 173 | +def build_full_coverage_md(groups): |
| 174 | + """Build full coverage markdown section (wrapped in <details>, collapsed by default).""" |
| 175 | + total_lines = sum(g["lines"] for g in groups.values()) |
| 176 | + total_hits = sum(g["hits"] for g in groups.values()) |
| 177 | + total_pct = (total_hits / total_lines * 100) if total_lines else 0 |
| 178 | + |
| 179 | + sorted_groups = sorted( |
| 180 | + groups.items(), |
| 181 | + key=lambda x: (x[1]["hits"] / x[1]["lines"] * 100) if x[1]["lines"] else 0, |
| 182 | + ) |
| 183 | + |
| 184 | + lines = [] |
| 185 | + lines.append("<details>") |
| 186 | + lines.append( |
| 187 | + f"<summary><h3>{status_icon(total_pct)} Full Coverage Report — {total_pct:.0f}% ({total_hits} / {total_lines} lines)</h3></summary>" |
| 188 | + ) |
| 189 | + lines.append("") |
| 190 | + lines.append( |
| 191 | + f"`{make_bar(total_pct, 30)}` **{total_pct:.1f}%** ({total_hits} / {total_lines} lines)" |
| 192 | + ) |
| 193 | + lines.append("") |
| 194 | + lines.append("| Package | Coverage | Progress | Lines |") |
| 195 | + lines.append("|:--------|:--------:|:---------|------:|") |
| 196 | + |
| 197 | + for key, g in sorted_groups: |
| 198 | + pct = (g["hits"] / g["lines"] * 100) if g["lines"] else 0 |
| 199 | + icon = status_icon(pct) |
| 200 | + lines.append( |
| 201 | + f"| `{key}` | {icon} {pct:.1f}% | `{make_bar(pct)}` | {g['hits']} / {g['lines']} |" |
| 202 | + ) |
| 203 | + |
| 204 | + lines.append(f"| **Total** | **{total_pct:.1f}%** | | **{total_hits} / {total_lines}** |") |
| 205 | + lines.append("") |
| 206 | + lines.append("</details>") |
| 207 | + lines.append("") |
| 208 | + return "\n".join(lines) |
| 209 | + |
| 210 | + |
| 211 | +def main(): |
| 212 | + xml_path = sys.argv[1] if len(sys.argv) > 1 else "coverage.xml" |
| 213 | + output_path = sys.argv[2] if len(sys.argv) > 2 else "coverage-summary.md" |
| 214 | + depth = int(sys.argv[3]) if len(sys.argv) > 3 else 2 |
| 215 | + diff_branch = sys.argv[4] if len(sys.argv) > 4 else None |
| 216 | + |
| 217 | + groups, file_coverage = parse_coverage_xml(xml_path, depth) |
| 218 | + |
| 219 | + parts = [] |
| 220 | + |
| 221 | + if diff_branch: |
| 222 | + changed_lines = get_changed_lines(diff_branch) |
| 223 | + parts.append(build_diff_coverage_md(changed_lines, file_coverage)) |
| 224 | + |
| 225 | + parts.append(build_full_coverage_md(groups)) |
| 226 | + |
| 227 | + with open(output_path, "w") as f: |
| 228 | + f.write("\n".join(parts)) |
| 229 | + |
| 230 | + print(f"Coverage summary written to {output_path}") |
| 231 | + |
| 232 | + |
| 233 | +if __name__ == "__main__": |
| 234 | + main() |
0 commit comments