blob: cb1cec307da2e582bf3d02c28faf0698367ac756 [file] [log] [blame]
Harrison Mutai0e9964e2024-06-14 09:51:42 +01001#!/usr/bin/env python3
2
Boyan Karatotev97db34a2025-07-02 10:25:46 +01003import argparse
4import asyncio
Harrison Mutai0e9964e2024-06-14 09:51:42 +01005import re
Boyan Karatotev97db34a2025-07-02 10:25:46 +01006import sys
Harrison Mutai0e9964e2024-06-14 09:51:42 +01007from dataclasses import dataclass
8
Boyan Karatotev97db34a2025-07-02 10:25:46 +01009import aiohttp
Harrison Mutai0e9964e2024-06-14 09:51:42 +010010
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010011# Constants to produce the report with
Boyan Karatotev12cd5902025-06-25 09:22:46 +010012openci_url = "https://ci.trustedfirmware.org/"
Harrison Mutai0e9964e2024-06-14 09:51:42 +010013
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010014# Jenkins API helpers
15def get_job_url(job_name: str) -> str:
16 return openci_url + f"job/{job_name}/api/json"
Harrison Mutai0e9964e2024-06-14 09:51:42 +010017
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010018def get_build_url(job_name: str, build_number: str) -> str:
19 return openci_url + f"job/{job_name}/{build_number}"
20
21def get_build_api(build_url: str) -> str:
22 return build_url + "/api/json"
23
24def get_build_console(build_url: str) -> str:
25 return build_url + "/consoleText"
26
Boyan Karatotev97db34a2025-07-02 10:25:46 +010027async def get_json(session, url):
28 async with session.get(url) as response:
29 return await response.json()
30
31async def get_text(session, url):
32 async with session.get(url) as response:
33 return await response.text()
34
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010035"""Finds the latest run of a given job by name"""
Boyan Karatotev97db34a2025-07-02 10:25:46 +010036async def process_job(session, job_name: str) -> str:
37 req = await get_json(session, get_job_url(job_name))
Harrison Mutai0e9964e2024-06-14 09:51:42 +010038
Boyan Karatotev97db34a2025-07-02 10:25:46 +010039 name = req["displayName"]
40 number = req["lastCompletedBuild"]["number"]
Harrison Mutai0e9964e2024-06-14 09:51:42 +010041
Boyan Karatotev97db34a2025-07-02 10:25:46 +010042 build = Build(session, name, name, number, level=0)
43 await build.process()
44
45 return (build.passed, build.print_build_status())
Harrison Mutai0e9964e2024-06-14 09:51:42 +010046
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010047"""Represents an individual build. Will recursively fetch sub builds"""
48class Build:
Boyan Karatotev97db34a2025-07-02 10:25:46 +010049 def __init__(self, session, job_name, pretty_job_name: str, build_number: str, level: int) -> None:
50 self.session = session
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010051 self.url = get_build_url(job_name, build_number)
Boyan Karatotev97db34a2025-07-02 10:25:46 +010052 self.pretty_job_name = pretty_job_name
53 self.name = None
54 self.build_number = build_number
55 self.level = level
56
57 async def process(self):
58 req = await get_json(self.session, get_build_api(self.url))
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010059 self.passed = req["result"].lower() == "success"
60
Boyan Karatotev97db34a2025-07-02 10:25:46 +010061 self.name = self.pretty_job_name
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010062 # The full display name is "{job_name} {build_number}"
63 if self.name == "":
64 self.name = req["fullDisplayName"].split(" ")[0]
65 # and builds should show up with their configuration name
66 elif self.name == "tf-a-builder":
67 self.name = req["actions"][0]["parameters"][1]["value"]
68
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010069 self.sub_builds = []
70
71 # parent job passed => children passed. Skip
72 if not self.passed:
73 # the main jobs list sub builds nicely
74 self.sub_builds = [
75 # the gateways get an alias to differentiate them
Boyan Karatotev97db34a2025-07-02 10:25:46 +010076 Build(self.session, build["jobName"], build["jobAlias"], build["buildNumber"], self.level + 1)
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010077 for build in req.get("subBuilds", [])
78 ]
79 # gateways don't, since they determine them dynamically
Boyan Karatotevba707472025-08-15 08:02:49 +010080 # but the windows job doesn't parse. It's a leaf anyway so skip
81 if self.sub_builds == [] and self.name != "tf-a-windows-builder":
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010082 self.sub_builds = [
Boyan Karatotev97db34a2025-07-02 10:25:46 +010083 Build(self.session, name, name, num, self.level + 1)
84 for name, num in await self.get_builds_from_console_log()
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010085 ]
86
Boyan Karatotev97db34a2025-07-02 10:25:46 +010087 # process sub-jobs concurrently
88 await asyncio.gather(*[
89 build.process()
90 for build in self.sub_builds
91 ])
92
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010093 # extracts (child_name, child_number) from the console output of a build
Boyan Karatotev97db34a2025-07-02 10:25:46 +010094 async def get_builds_from_console_log(self) -> str:
95 log = await get_text(self.session, get_build_console(self.url))
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +010096
97 return re.findall(r"(tf-a[-\w+]+) #(\d+) started", log)
98
Boyan Karatotev97db34a2025-07-02 10:25:46 +010099 def print_build_status(self) -> str:
100 message = "" + str(self)
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +0100101
102 for build in self.sub_builds:
103 if not build.passed:
Boyan Karatotev97db34a2025-07-02 10:25:46 +0100104 message += build.print_build_status()
105 return message
Harrison Mutai0e9964e2024-06-14 09:51:42 +0100106
107 def __str__(self) -> str:
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +0100108 return (f"{' ' * self.level * 4}* {'✅' if self.passed else '❌'} "
Boyan Karatotev97db34a2025-07-02 10:25:46 +0100109 f"**{self.name}** [#{self.build_number}]({self.url})\n"
Boyan Karatotev1c5e5c92025-06-27 11:46:41 +0100110 )
Harrison Mutai0e9964e2024-06-14 09:51:42 +0100111
Boyan Karatotev97db34a2025-07-02 10:25:46 +0100112async def main(session, job_names: list[str]) -> str:
113 # process jobs concurrently
114 results = await asyncio.gather(
115 *[process_job(session, name) for name in job_names]
116 )
Harrison Mutai0e9964e2024-06-14 09:51:42 +0100117
Boyan Karatotev97db34a2025-07-02 10:25:46 +0100118 final_msg = "🟢" if all(j[0] for j in results) else "🔴"
119 final_msg += " Daily Status\n"
120 for passed, message in results:
121 final_msg += message
Harrison Mutai0e9964e2024-06-14 09:51:42 +0100122
Boyan Karatotev97db34a2025-07-02 10:25:46 +0100123 return final_msg
124
125async def run_local(jobs: list[str]) -> str:
126 async with aiohttp.ClientSession() as session:
127 msg = await main(session, jobs)
128 print(msg)
129
130def add_jobs_arg(parser):
131 parser.add_argument(
132 "-j", "--jobs",
133 metavar="JOB_NAME", default=["tf-a-daily"], nargs="+",
134 help="CI jobs to monitor"
135 )
Harrison Mutai0e9964e2024-06-14 09:51:42 +0100136
137if __name__ == "__main__":
Boyan Karatotev97db34a2025-07-02 10:25:46 +0100138 parser = argparse.ArgumentParser(
139 description="Latest CI run status",
140 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
141 )
142 add_jobs_arg(parser)
143
144 args = parser.parse_args(sys.argv[1:])
145
146 asyncio.run(run_local(args.jobs))