1*5a449678SAlex Bennée#!/usr/bin/env python3 2*5a449678SAlex Bennée# 3*5a449678SAlex Bennée# A script to analyse failures in the gitlab pipelines. It requires an 4*5a449678SAlex Bennée# API key from gitlab with the following permissions: 5*5a449678SAlex Bennée# - api 6*5a449678SAlex Bennée# - read_repository 7*5a449678SAlex Bennée# - read_user 8*5a449678SAlex Bennée# 9*5a449678SAlex Bennée 10*5a449678SAlex Bennéeimport argparse 11*5a449678SAlex Bennéeimport gitlab 12*5a449678SAlex Bennéeimport os 13*5a449678SAlex Bennée 14*5a449678SAlex Bennée# 15*5a449678SAlex Bennée# Arguments 16*5a449678SAlex Bennée# 17*5a449678SAlex Bennéeclass NoneForEmptyStringAction(argparse.Action): 18*5a449678SAlex Bennée def __call__(self, parser, namespace, value, option_string=None): 19*5a449678SAlex Bennée if value == '': 20*5a449678SAlex Bennée setattr(namespace, self.dest, None) 21*5a449678SAlex Bennée else: 22*5a449678SAlex Bennée setattr(namespace, self.dest, value) 23*5a449678SAlex Bennée 24*5a449678SAlex Bennée 25*5a449678SAlex Bennéeparser = argparse.ArgumentParser(description="Analyse failed GitLab CI runs.") 26*5a449678SAlex Bennée 27*5a449678SAlex Bennéeparser.add_argument("--gitlab", 28*5a449678SAlex Bennée default="https://gitlab.com", 29*5a449678SAlex Bennée help="GitLab instance URL (default: https://gitlab.com).") 30*5a449678SAlex Bennéeparser.add_argument("--id", default=11167699, 31*5a449678SAlex Bennée type=int, 32*5a449678SAlex Bennée help="GitLab project id (default: 11167699 for qemu-project/qemu)") 33*5a449678SAlex Bennéeparser.add_argument("--token", 34*5a449678SAlex Bennée default=os.getenv("GITLAB_TOKEN"), 35*5a449678SAlex Bennée help="Your personal access token with 'api' scope.") 36*5a449678SAlex Bennéeparser.add_argument("--branch", 37*5a449678SAlex Bennée type=str, 38*5a449678SAlex Bennée default="staging", 39*5a449678SAlex Bennée action=NoneForEmptyStringAction, 40*5a449678SAlex Bennée help="The name of the branch (default: 'staging')") 41*5a449678SAlex Bennéeparser.add_argument("--status", 42*5a449678SAlex Bennée type=str, 43*5a449678SAlex Bennée action=NoneForEmptyStringAction, 44*5a449678SAlex Bennée default="failed", 45*5a449678SAlex Bennée help="Filter by branch status (default: 'failed')") 46*5a449678SAlex Bennéeparser.add_argument("--count", type=int, 47*5a449678SAlex Bennée default=3, 48*5a449678SAlex Bennée help="The number of failed runs to fetch.") 49*5a449678SAlex Bennéeparser.add_argument("--skip-jobs", 50*5a449678SAlex Bennée default=False, 51*5a449678SAlex Bennée action='store_true', 52*5a449678SAlex Bennée help="Skip dumping the job info") 53*5a449678SAlex Bennéeparser.add_argument("--pipeline", type=int, 54*5a449678SAlex Bennée nargs="+", 55*5a449678SAlex Bennée default=None, 56*5a449678SAlex Bennée help="Explicit pipeline ID(s) to fetch.") 57*5a449678SAlex Bennée 58*5a449678SAlex Bennée 59*5a449678SAlex Bennéeif __name__ == "__main__": 60*5a449678SAlex Bennée args = parser.parse_args() 61*5a449678SAlex Bennée 62*5a449678SAlex Bennée gl = gitlab.Gitlab(url=args.gitlab, private_token=args.token) 63*5a449678SAlex Bennée project = gl.projects.get(args.id) 64*5a449678SAlex Bennée 65*5a449678SAlex Bennée 66*5a449678SAlex Bennée pipelines_to_process = [] 67*5a449678SAlex Bennée 68*5a449678SAlex Bennée # Use explicit pipeline IDs if provided, otherwise fetch a list 69*5a449678SAlex Bennée if args.pipeline: 70*5a449678SAlex Bennée args.count = len(args.pipeline) 71*5a449678SAlex Bennée for p_id in args.pipeline: 72*5a449678SAlex Bennée pipelines_to_process.append(project.pipelines.get(p_id)) 73*5a449678SAlex Bennée else: 74*5a449678SAlex Bennée # Use an iterator to fetch the pipelines 75*5a449678SAlex Bennée pipe_iter = project.pipelines.list(iterator=True, 76*5a449678SAlex Bennée status=args.status, 77*5a449678SAlex Bennée ref=args.branch) 78*5a449678SAlex Bennée # Check each failed pipeline 79*5a449678SAlex Bennée pipelines_to_process = [next(pipe_iter) for _ in range(args.count)] 80*5a449678SAlex Bennée 81*5a449678SAlex Bennée # Check each pipeline 82*5a449678SAlex Bennée for p in pipelines_to_process: 83*5a449678SAlex Bennée 84*5a449678SAlex Bennée jobs = p.jobs.list(get_all=True) 85*5a449678SAlex Bennée failed_jobs = [j for j in jobs if j.status == "failed"] 86*5a449678SAlex Bennée skipped_jobs = [j for j in jobs if j.status == "skipped"] 87*5a449678SAlex Bennée manual_jobs = [j for j in jobs if j.status == "manual"] 88*5a449678SAlex Bennée 89*5a449678SAlex Bennée trs = p.test_report_summary.get() 90*5a449678SAlex Bennée total = trs.total["count"] 91*5a449678SAlex Bennée skipped = trs.total["skipped"] 92*5a449678SAlex Bennée failed = trs.total["failed"] 93*5a449678SAlex Bennée 94*5a449678SAlex Bennée print(f"{p.status} pipeline {p.id}, total jobs {len(jobs)}, " 95*5a449678SAlex Bennée f"skipped {len(skipped_jobs)}, " 96*5a449678SAlex Bennée f"failed {len(failed_jobs)}, ", 97*5a449678SAlex Bennée f"{total} tests, " 98*5a449678SAlex Bennée f"{skipped} skipped tests, " 99*5a449678SAlex Bennée f"{failed} failed tests") 100*5a449678SAlex Bennée 101*5a449678SAlex Bennée if not args.skip_jobs: 102*5a449678SAlex Bennée for j in failed_jobs: 103*5a449678SAlex Bennée print(f" Failed job {j.id}, {j.name}, {j.web_url}") 104*5a449678SAlex Bennée 105*5a449678SAlex Bennée # It seems we can only extract failing tests from the full 106*5a449678SAlex Bennée # test report, maybe there is some way to filter it. 107*5a449678SAlex Bennée 108*5a449678SAlex Bennée if failed > 0: 109*5a449678SAlex Bennée ftr = p.test_report.get() 110*5a449678SAlex Bennée failed_suites = [s for s in ftr.test_suites if 111*5a449678SAlex Bennée s["failed_count"] > 0] 112*5a449678SAlex Bennée for fs in failed_suites: 113*5a449678SAlex Bennée name = fs["name"] 114*5a449678SAlex Bennée tests = fs["test_cases"] 115*5a449678SAlex Bennée failed_tests = [t for t in tests if t["status"] == 'failed'] 116*5a449678SAlex Bennée for t in failed_tests: 117*5a449678SAlex Bennée print(f" Failed test {t["classname"]}, {name}, {t["name"]}") 118