Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 3 | import argparse |
| 4 | import asyncio |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 5 | import re |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 6 | import sys |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 7 | from dataclasses import dataclass |
| 8 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 9 | import aiohttp |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 10 | |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 11 | # Constants to produce the report with |
Boyan Karatotev | 12cd590 | 2025-06-25 09:22:46 +0100 | [diff] [blame] | 12 | openci_url = "https://ci.trustedfirmware.org/" |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 13 | |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 14 | # Jenkins API helpers |
| 15 | def get_job_url(job_name: str) -> str: |
| 16 | return openci_url + f"job/{job_name}/api/json" |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 17 | |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 18 | def get_build_url(job_name: str, build_number: str) -> str: |
| 19 | return openci_url + f"job/{job_name}/{build_number}" |
| 20 | |
| 21 | def get_build_api(build_url: str) -> str: |
| 22 | return build_url + "/api/json" |
| 23 | |
| 24 | def get_build_console(build_url: str) -> str: |
| 25 | return build_url + "/consoleText" |
| 26 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 27 | async def get_json(session, url): |
| 28 | async with session.get(url) as response: |
| 29 | return await response.json() |
| 30 | |
| 31 | async def get_text(session, url): |
| 32 | async with session.get(url) as response: |
| 33 | return await response.text() |
| 34 | |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 35 | """Finds the latest run of a given job by name""" |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 36 | async def process_job(session, job_name: str) -> str: |
| 37 | req = await get_json(session, get_job_url(job_name)) |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 38 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 39 | name = req["displayName"] |
| 40 | number = req["lastCompletedBuild"]["number"] |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 41 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 42 | build = Build(session, name, name, number, level=0) |
| 43 | await build.process() |
| 44 | |
| 45 | return (build.passed, build.print_build_status()) |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 46 | |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 47 | """Represents an individual build. Will recursively fetch sub builds""" |
| 48 | class Build: |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 49 | def __init__(self, session, job_name, pretty_job_name: str, build_number: str, level: int) -> None: |
| 50 | self.session = session |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 51 | self.url = get_build_url(job_name, build_number) |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 52 | 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 Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 59 | self.passed = req["result"].lower() == "success" |
| 60 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 61 | self.name = self.pretty_job_name |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 62 | # 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 Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 69 | 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 Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 76 | Build(self.session, build["jobName"], build["jobAlias"], build["buildNumber"], self.level + 1) |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 77 | for build in req.get("subBuilds", []) |
| 78 | ] |
| 79 | # gateways don't, since they determine them dynamically |
| 80 | if self.sub_builds == []: |
| 81 | self.sub_builds = [ |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 82 | Build(self.session, name, name, num, self.level + 1) |
| 83 | for name, num in await self.get_builds_from_console_log() |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 84 | ] |
| 85 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 86 | # process sub-jobs concurrently |
| 87 | await asyncio.gather(*[ |
| 88 | build.process() |
| 89 | for build in self.sub_builds |
| 90 | ]) |
| 91 | |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 92 | # extracts (child_name, child_number) from the console output of a build |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 93 | async def get_builds_from_console_log(self) -> str: |
| 94 | log = await get_text(self.session, get_build_console(self.url)) |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 95 | |
| 96 | return re.findall(r"(tf-a[-\w+]+) #(\d+) started", log) |
| 97 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 98 | def print_build_status(self) -> str: |
| 99 | message = "" + str(self) |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 100 | |
| 101 | for build in self.sub_builds: |
| 102 | if not build.passed: |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 103 | message += build.print_build_status() |
| 104 | return message |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 105 | |
| 106 | def __str__(self) -> str: |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 107 | return (f"{' ' * self.level * 4}* {'✅' if self.passed else '❌'} " |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 108 | f"**{self.name}** [#{self.build_number}]({self.url})\n" |
Boyan Karatotev | 1c5e5c9 | 2025-06-27 11:46:41 +0100 | [diff] [blame] | 109 | ) |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 110 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 111 | async def main(session, job_names: list[str]) -> str: |
| 112 | # process jobs concurrently |
| 113 | results = await asyncio.gather( |
| 114 | *[process_job(session, name) for name in job_names] |
| 115 | ) |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 116 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 117 | final_msg = "🟢" if all(j[0] for j in results) else "🔴" |
| 118 | final_msg += " Daily Status\n" |
| 119 | for passed, message in results: |
| 120 | final_msg += message |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 121 | |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 122 | return final_msg |
| 123 | |
| 124 | async def run_local(jobs: list[str]) -> str: |
| 125 | async with aiohttp.ClientSession() as session: |
| 126 | msg = await main(session, jobs) |
| 127 | print(msg) |
| 128 | |
| 129 | def add_jobs_arg(parser): |
| 130 | parser.add_argument( |
| 131 | "-j", "--jobs", |
| 132 | metavar="JOB_NAME", default=["tf-a-daily"], nargs="+", |
| 133 | help="CI jobs to monitor" |
| 134 | ) |
Harrison Mutai | 0e9964e | 2024-06-14 09:51:42 +0100 | [diff] [blame] | 135 | |
| 136 | if __name__ == "__main__": |
Boyan Karatotev | 97db34a | 2025-07-02 10:25:46 +0100 | [diff] [blame] | 137 | parser = argparse.ArgumentParser( |
| 138 | description="Latest CI run status", |
| 139 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 140 | ) |
| 141 | add_jobs_arg(parser) |
| 142 | |
| 143 | args = parser.parse_args(sys.argv[1:]) |
| 144 | |
| 145 | asyncio.run(run_local(args.jobs)) |