|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +CI Failure Summary Script |
| 4 | +Extracts concise failure information from GitHub Actions logs. |
| 5 | +Usage: python ci-failure-summary.py <PR_NUMBER|RUN_ID> [--verbose] |
| 6 | +""" |
| 7 | + |
| 8 | +import json |
| 9 | +import re |
| 10 | +import subprocess |
| 11 | +import sys |
| 12 | +from dataclasses import dataclass |
| 13 | + |
| 14 | + |
| 15 | +# ANSI colors |
| 16 | +RED = "\033[0;31m" |
| 17 | +YELLOW = "\033[1;33m" |
| 18 | +GREEN = "\033[0;32m" |
| 19 | +CYAN = "\033[0;36m" |
| 20 | +BOLD = "\033[1m" |
| 21 | +NC = "\033[0m" |
| 22 | + |
| 23 | + |
| 24 | +@dataclass |
| 25 | +class FailureInfo: |
| 26 | + test_failures: list[str] |
| 27 | + assertion_details: list[str] |
| 28 | + build_errors: list[str] |
| 29 | + timeouts: list[str] |
| 30 | + exceptions: list[str] |
| 31 | + |
| 32 | + |
| 33 | +def run_gh(args: list[str]) -> str: |
| 34 | + """Run a gh CLI command and return output.""" |
| 35 | + try: |
| 36 | + result = subprocess.run( |
| 37 | + ["gh"] + args, |
| 38 | + capture_output=True, |
| 39 | + text=True, |
| 40 | + check=True |
| 41 | + ) |
| 42 | + return result.stdout |
| 43 | + except subprocess.CalledProcessError as e: |
| 44 | + return e.stdout or "" |
| 45 | + except FileNotFoundError: |
| 46 | + print("Error: gh CLI not found", file=sys.stderr) |
| 47 | + sys.exit(1) |
| 48 | + |
| 49 | + |
| 50 | +def parse_failures(log: str) -> FailureInfo: |
| 51 | + """Parse failure log and extract relevant information.""" |
| 52 | + lines = log.split("\n") |
| 53 | + |
| 54 | + # Test failures |
| 55 | + test_pattern = re.compile(r"Failed\s+\S+|✗.*\[FAIL\]|\[xUnit.*\].*Failed", re.IGNORECASE) |
| 56 | + test_failures = [ |
| 57 | + clean_line(line) for line in lines |
| 58 | + if test_pattern.search(line) |
| 59 | + ][:20] |
| 60 | + |
| 61 | + # Assertion details |
| 62 | + assertion_pattern = re.compile( |
| 63 | + r"Expected.*to be|Expected.*but found|expected.*got|should be.*but|difference of", |
| 64 | + re.IGNORECASE |
| 65 | + ) |
| 66 | + assertion_details = [ |
| 67 | + clean_line(line) for line in lines |
| 68 | + if assertion_pattern.search(line) and "Passed" not in line |
| 69 | + ][:15] |
| 70 | + |
| 71 | + # Build errors |
| 72 | + build_pattern = re.compile(r"error CS\d+:|error NU\d+:|error MSB\d+:") |
| 73 | + build_errors = [ |
| 74 | + clean_line(line) for line in lines |
| 75 | + if build_pattern.search(line) |
| 76 | + ][:10] |
| 77 | + |
| 78 | + # Timeouts |
| 79 | + timeout_pattern = re.compile(r"timeout|timed out|exceeded|too slow", re.IGNORECASE) |
| 80 | + fail_pattern = re.compile(r"fail|error|exceed", re.IGNORECASE) |
| 81 | + timeouts = [ |
| 82 | + clean_line(line) for line in lines |
| 83 | + if timeout_pattern.search(line) and fail_pattern.search(line) and "Passed" not in line |
| 84 | + ][:10] |
| 85 | + |
| 86 | + # Exceptions |
| 87 | + exception_pattern = re.compile(r"Exception:|Error:|System\.\w+Exception") |
| 88 | + exceptions = [ |
| 89 | + clean_line(line) for line in lines |
| 90 | + if exception_pattern.search(line) and not line.strip().startswith("at ") |
| 91 | + ][:10] |
| 92 | + |
| 93 | + return FailureInfo( |
| 94 | + test_failures=test_failures, |
| 95 | + assertion_details=assertion_details, |
| 96 | + build_errors=build_errors, |
| 97 | + timeouts=timeouts, |
| 98 | + exceptions=exceptions |
| 99 | + ) |
| 100 | + |
| 101 | + |
| 102 | +def clean_line(line: str) -> str: |
| 103 | + """Remove timestamps and job prefixes from log line.""" |
| 104 | + # Remove tab-prefixed content |
| 105 | + if "\t" in line: |
| 106 | + line = line.split("\t")[-1] |
| 107 | + # Remove ISO timestamps |
| 108 | + line = re.sub(r"^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*", "", line) |
| 109 | + return line.strip() |
| 110 | + |
| 111 | + |
| 112 | +def print_section(title: str, items: list[str], color: str = YELLOW, item_color: str = RED): |
| 113 | + """Print a section with items.""" |
| 114 | + print(f"\n{color}── {title} ──{NC}") |
| 115 | + if items: |
| 116 | + for item in items: |
| 117 | + print(f" {item_color}✗{NC} {item}") |
| 118 | + else: |
| 119 | + print(f" {GREEN}No {title.lower()} found{NC}") |
| 120 | + |
| 121 | + |
| 122 | +def main(): |
| 123 | + if len(sys.argv) < 2: |
| 124 | + print(f"{RED}Usage: {sys.argv[0]} <PR_NUMBER|RUN_ID> [--verbose]{NC}") |
| 125 | + print(" PR_NUMBER: e.g., 29") |
| 126 | + print(" RUN_ID: e.g., 12345678") |
| 127 | + sys.exit(1) |
| 128 | + |
| 129 | + input_arg = sys.argv[1] |
| 130 | + verbose = "--verbose" in sys.argv |
| 131 | + |
| 132 | + # Determine if input is PR number or run ID |
| 133 | + run_id = None |
| 134 | + if input_arg.isdigit() and len(input_arg) < 6: |
| 135 | + # Likely a PR number |
| 136 | + pr_num = input_arg |
| 137 | + print(f"{CYAN}=== CI Failure Summary for PR #{pr_num} ==={NC}\n") |
| 138 | + |
| 139 | + # Get PR info |
| 140 | + pr_json = run_gh(["pr", "view", pr_num, "--json", "title,headRefName,baseRefName,state"]) |
| 141 | + try: |
| 142 | + pr_info = json.loads(pr_json) |
| 143 | + print(f"{BOLD}PR:{NC} {pr_info.get('title', 'Unknown')}") |
| 144 | + print(f"{BOLD}Branch:{NC} {pr_info.get('headRefName', '?')} → {pr_info.get('baseRefName', '?')}\n") |
| 145 | + |
| 146 | + # Get check status |
| 147 | + print(f"{BOLD}Check Status:{NC}") |
| 148 | + checks_output = run_gh(["pr", "checks", pr_num]) |
| 149 | + for line in checks_output.strip().split("\n"): |
| 150 | + if "fail" in line.lower() or "X" in line: |
| 151 | + print(f" {RED}✗{NC} {line}") |
| 152 | + elif "pass" in line.lower() or "✓" in line: |
| 153 | + print(f" {GREEN}✓{NC} {line}") |
| 154 | + else: |
| 155 | + print(f" {line}") |
| 156 | + print() |
| 157 | + |
| 158 | + # Get the failed run ID |
| 159 | + head_branch = pr_info.get("headRefName", "") |
| 160 | + runs_json = run_gh(["run", "list", "--branch", head_branch, "--limit", "1", "--json", "databaseId,conclusion"]) |
| 161 | + runs = json.loads(runs_json) |
| 162 | + if runs: |
| 163 | + run_id = str(runs[0].get("databaseId")) |
| 164 | + except json.JSONDecodeError: |
| 165 | + print(f"{YELLOW}Could not parse PR info{NC}") |
| 166 | + else: |
| 167 | + run_id = input_arg |
| 168 | + print(f"{CYAN}=== CI Failure Summary for Run #{run_id} ==={NC}\n") |
| 169 | + |
| 170 | + if not run_id: |
| 171 | + print(f"{YELLOW}No recent runs found{NC}") |
| 172 | + return |
| 173 | + |
| 174 | + # Get run info |
| 175 | + run_json = run_gh(["run", "view", run_id, "--json", "conclusion,status,jobs"]) |
| 176 | + try: |
| 177 | + run_info = json.loads(run_json) |
| 178 | + conclusion = run_info.get("conclusion") or "in_progress" |
| 179 | + status = run_info.get("status", "unknown") |
| 180 | + print(f"{BOLD}Run #{run_id}:{NC} {status} ({conclusion})\n") |
| 181 | + |
| 182 | + # List failed jobs |
| 183 | + print(f"{BOLD}Failed Jobs:{NC}") |
| 184 | + jobs = run_info.get("jobs", []) |
| 185 | + failed_jobs = [j.get("name") for j in jobs if j.get("conclusion") == "failure"] |
| 186 | + if failed_jobs: |
| 187 | + for job in failed_jobs: |
| 188 | + print(f" {RED}✗{NC} {job}") |
| 189 | + else: |
| 190 | + print(f" {GREEN}No failed jobs{NC}") |
| 191 | + print() |
| 192 | + except json.JSONDecodeError: |
| 193 | + pass |
| 194 | + |
| 195 | + # Get failed logs |
| 196 | + print(f"{BOLD}Failure Details:{NC}") |
| 197 | + failed_log = run_gh(["run", "view", run_id, "--log-failed"]) |
| 198 | + |
| 199 | + if not failed_log.strip(): |
| 200 | + print(f" {GREEN}No failure logs available{NC}") |
| 201 | + return |
| 202 | + |
| 203 | + if verbose: |
| 204 | + log_path = "/tmp/ci-failure-raw.log" |
| 205 | + with open(log_path, "w") as f: |
| 206 | + f.write(failed_log) |
| 207 | + print(f" {CYAN}Raw log saved to {log_path}{NC}") |
| 208 | + |
| 209 | + # Parse and display failures |
| 210 | + failures = parse_failures(failed_log) |
| 211 | + |
| 212 | + print_section("Test Failures", failures.test_failures) |
| 213 | + print_section("Assertion Details", failures.assertion_details, item_color=NC) |
| 214 | + print_section("Build Errors", failures.build_errors) |
| 215 | + print_section("Timeouts/Performance", failures.timeouts, item_color=YELLOW) |
| 216 | + print_section("Exceptions", failures.exceptions) |
| 217 | + |
| 218 | + print(f"\n{CYAN}=== End Summary ==={NC}") |
| 219 | + |
| 220 | + |
| 221 | +if __name__ == "__main__": |
| 222 | + main() |
0 commit comments