Files
MobileGL/scripts/ci/junit_annotate.py
T

91 lines
3.4 KiB
Python

#!/usr/bin/env python3
# MobileGL - scripts/ci/junit_annotate.py
# Copyright (c) 2025-2026 MobileGL-Dev
# Licensed under the GNU Lesser General Public License v3.0:
# https://www.gnu.org/licenses/gpl-3.0.txt
# https://www.gnu.org/licenses/lgpl-3.0.txt
# SPDX-License-Identifier: LGPL-3.0-only
# End of Source File Header
"""Turn the failed entries of ctest --output-junit files into GitHub error annotations.
A red lane's log needs a signed-in reader, but its annotations do not, so the names of the
failed entries - and the lines of their output that say why - are what a reader of the run
page (or of the check-runs API) can see without downloading anything. GitHub keeps at most
ten error annotations per step, so this prints one summary naming every failed entry and then
one excerpt for each of the first few.
Usage: junit_annotate.py [--excerpts N] FILE_OR_DIR... (a directory means every *.xml in it)
Exit status is always 0: this reports a failure, it is never one.
"""
import argparse
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
INTERESTING = re.compile(
r"Failure|FAILED|Failed|error|Error|Fatal|Refuse|Expected|Which is|Value of|Actual|"
r"Abort|abort|Segmentation|SIGSEGV|signal|timed out|Timeout|assert", re.ASCII)
def escape(text):
return text.replace("%", "%25").replace("\r", "").replace("\n", "%0A")
def failed_cases(path):
try:
root = ET.parse(path).getroot()
except (ET.ParseError, OSError) as error:
print(f"junit_annotate: {path}: {error}", file=sys.stderr)
return []
out = []
for case in root.iter("testcase"):
status = case.get("status", "")
failure = case.find("failure")
error = case.find("error")
if status not in ("fail", "failed") and failure is None and error is None:
continue
text = case.findtext("system-out") or ""
if failure is not None:
text = (failure.get("message") or "") + "\n" + (failure.text or "") + "\n" + text
out.append((case.get("name", "?"), text))
return out
def excerpt(text, limit=40):
lines = [line.rstrip() for line in text.splitlines() if line.strip()]
keep = set()
for index, line in enumerate(lines):
if INTERESTING.search(line):
keep.update(range(index, min(index + 4, len(lines))))
picked = [lines[index] for index in sorted(keep)] or lines[-limit:]
return "\n".join(line[:400] for line in picked[:limit])
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--excerpts", type=int, default=8)
parser.add_argument("paths", nargs="+")
args = parser.parse_args()
files = []
for name in args.paths:
path = Path(name)
files.extend(sorted(path.glob("*.xml")) if path.is_dir() else [path])
failed = []
for path in files:
if path.is_file():
failed.extend((path.name, name, text) for name, text in failed_cases(path))
if not failed:
print("junit_annotate: no failed entries in " + ", ".join(str(f) for f in files))
return 0
names = "\n".join(f"{name} ({source})" for source, name, _ in failed)
print(f"::error title={len(failed)} failed ctest entr(y/ies)::{escape(names[:60000])}")
for source, name, text in failed[:max(args.excerpts, 0)]:
print(f"::error title={name[:200]}::{escape(excerpt(text))}")
return 0
if __name__ == "__main__":
sys.exit(main())