1bf215546Sopenharmony_ci#!/usr/bin/env python3
2bf215546Sopenharmony_ci# Copyright © 2020 - 2022 Collabora Ltd.
3bf215546Sopenharmony_ci# Authors:
4bf215546Sopenharmony_ci#   Tomeu Vizoso <tomeu.vizoso@collabora.com>
5bf215546Sopenharmony_ci#   David Heidelberg <david.heidelberg@collabora.com>
6bf215546Sopenharmony_ci#
7bf215546Sopenharmony_ci# TODO GraphQL for dependencies
8bf215546Sopenharmony_ci# SPDX-License-Identifier: MIT
9bf215546Sopenharmony_ci
10bf215546Sopenharmony_ci"""
11bf215546Sopenharmony_ciHelper script to restrict running only required CI jobs
12bf215546Sopenharmony_ciand show the job(s) logs.
13bf215546Sopenharmony_ci"""
14bf215546Sopenharmony_ci
15bf215546Sopenharmony_cifrom typing import Optional
16bf215546Sopenharmony_cifrom functools import partial
17bf215546Sopenharmony_cifrom concurrent.futures import ThreadPoolExecutor
18bf215546Sopenharmony_ci
19bf215546Sopenharmony_ciimport os
20bf215546Sopenharmony_ciimport re
21bf215546Sopenharmony_ciimport time
22bf215546Sopenharmony_ciimport argparse
23bf215546Sopenharmony_ciimport sys
24bf215546Sopenharmony_ciimport gitlab
25bf215546Sopenharmony_ci
26bf215546Sopenharmony_cifrom colorama import Fore, Style
27bf215546Sopenharmony_ci
28bf215546Sopenharmony_ciREFRESH_WAIT_LOG = 10
29bf215546Sopenharmony_ciREFRESH_WAIT_JOBS = 6
30bf215546Sopenharmony_ci
31bf215546Sopenharmony_ciURL_START = "\033]8;;"
32bf215546Sopenharmony_ciURL_END = "\033]8;;\a"
33bf215546Sopenharmony_ci
34bf215546Sopenharmony_ciSTATUS_COLORS = {
35bf215546Sopenharmony_ci    "created": "",
36bf215546Sopenharmony_ci    "running": Fore.BLUE,
37bf215546Sopenharmony_ci    "success": Fore.GREEN,
38bf215546Sopenharmony_ci    "failed": Fore.RED,
39bf215546Sopenharmony_ci    "canceled": Fore.MAGENTA,
40bf215546Sopenharmony_ci    "manual": "",
41bf215546Sopenharmony_ci    "pending": "",
42bf215546Sopenharmony_ci    "skipped": "",
43bf215546Sopenharmony_ci}
44bf215546Sopenharmony_ci
45bf215546Sopenharmony_ci# TODO: This hardcoded list should be replaced by querying the pipeline's
46bf215546Sopenharmony_ci# dependency graph to see which jobs the target jobs need
47bf215546Sopenharmony_ciDEPENDENCIES = [
48bf215546Sopenharmony_ci    "debian/x86_build-base",
49bf215546Sopenharmony_ci    "debian/x86_build",
50bf215546Sopenharmony_ci    "debian/x86_test-base",
51bf215546Sopenharmony_ci    "debian/x86_test-gl",
52bf215546Sopenharmony_ci    "debian/arm_build",
53bf215546Sopenharmony_ci    "debian/arm_test",
54bf215546Sopenharmony_ci    "kernel+rootfs_amd64",
55bf215546Sopenharmony_ci    "kernel+rootfs_arm64",
56bf215546Sopenharmony_ci    "kernel+rootfs_armhf",
57bf215546Sopenharmony_ci    "debian-testing",
58bf215546Sopenharmony_ci    "debian-arm64",
59bf215546Sopenharmony_ci]
60bf215546Sopenharmony_ci
61bf215546Sopenharmony_ciCOMPLETED_STATUSES = ["success", "failed"]
62bf215546Sopenharmony_ci
63bf215546Sopenharmony_ci
64bf215546Sopenharmony_cidef get_gitlab_project(glab, name: str):
65bf215546Sopenharmony_ci    """Finds a specified gitlab project for given user"""
66bf215546Sopenharmony_ci    glab.auth()
67bf215546Sopenharmony_ci    username = glab.user.username
68bf215546Sopenharmony_ci    return glab.projects.get(f"{username}/mesa")
69bf215546Sopenharmony_ci
70bf215546Sopenharmony_ci
71bf215546Sopenharmony_cidef wait_for_pipeline(project, sha: str):
72bf215546Sopenharmony_ci    """await until pipeline appears in Gitlab"""
73bf215546Sopenharmony_ci    print("⏲ for the pipeline to appear..", end="")
74bf215546Sopenharmony_ci    while True:
75bf215546Sopenharmony_ci        pipelines = project.pipelines.list(sha=sha)
76bf215546Sopenharmony_ci        if pipelines:
77bf215546Sopenharmony_ci            print("", flush=True)
78bf215546Sopenharmony_ci            return pipelines[0]
79bf215546Sopenharmony_ci        print("", end=".", flush=True)
80bf215546Sopenharmony_ci        time.sleep(1)
81bf215546Sopenharmony_ci
82bf215546Sopenharmony_ci
83bf215546Sopenharmony_cidef print_job_status(job) -> None:
84bf215546Sopenharmony_ci    """It prints a nice, colored job status with a link to the job."""
85bf215546Sopenharmony_ci    if job.status == "canceled":
86bf215546Sopenharmony_ci        return
87bf215546Sopenharmony_ci
88bf215546Sopenharmony_ci    print(
89bf215546Sopenharmony_ci        STATUS_COLORS[job.status]
90bf215546Sopenharmony_ci        + "� job "
91bf215546Sopenharmony_ci        + URL_START
92bf215546Sopenharmony_ci        + f"{job.web_url}\a{job.name}"
93bf215546Sopenharmony_ci        + URL_END
94bf215546Sopenharmony_ci        + f" :: {job.status}"
95bf215546Sopenharmony_ci        + Style.RESET_ALL
96bf215546Sopenharmony_ci    )
97bf215546Sopenharmony_ci
98bf215546Sopenharmony_ci
99bf215546Sopenharmony_cidef print_job_status_change(job) -> None:
100bf215546Sopenharmony_ci    """It reports job status changes."""
101bf215546Sopenharmony_ci    if job.status == "canceled":
102bf215546Sopenharmony_ci        return
103bf215546Sopenharmony_ci
104bf215546Sopenharmony_ci    print(
105bf215546Sopenharmony_ci        STATUS_COLORS[job.status]
106bf215546Sopenharmony_ci        + "� job "
107bf215546Sopenharmony_ci        + URL_START
108bf215546Sopenharmony_ci        + f"{job.web_url}\a{job.name}"
109bf215546Sopenharmony_ci        + URL_END
110bf215546Sopenharmony_ci        + f" has new status: {job.status}"
111bf215546Sopenharmony_ci        + Style.RESET_ALL
112bf215546Sopenharmony_ci    )
113bf215546Sopenharmony_ci
114bf215546Sopenharmony_ci
115bf215546Sopenharmony_cidef pretty_wait(sec: int) -> None:
116bf215546Sopenharmony_ci    """shows progressbar in dots"""
117bf215546Sopenharmony_ci    for val in range(sec, 0, -1):
118bf215546Sopenharmony_ci        print(f"⏲  {val} seconds", end="\r")
119bf215546Sopenharmony_ci        time.sleep(1)
120bf215546Sopenharmony_ci
121bf215546Sopenharmony_ci
122bf215546Sopenharmony_cidef monitor_pipeline(
123bf215546Sopenharmony_ci    project, pipeline, target_job: Optional[str], dependencies, force_manual: bool
124bf215546Sopenharmony_ci) -> tuple[Optional[int], Optional[int]]:
125bf215546Sopenharmony_ci    """Monitors pipeline and delegate canceling jobs"""
126bf215546Sopenharmony_ci    statuses = {}
127bf215546Sopenharmony_ci    target_statuses = {}
128bf215546Sopenharmony_ci
129bf215546Sopenharmony_ci    if not dependencies:
130bf215546Sopenharmony_ci        dependencies = []
131bf215546Sopenharmony_ci    dependencies.extend(DEPENDENCIES)
132bf215546Sopenharmony_ci
133bf215546Sopenharmony_ci    if target_job:
134bf215546Sopenharmony_ci        target_jobs_regex = re.compile(target_job.strip())
135bf215546Sopenharmony_ci
136bf215546Sopenharmony_ci    while True:
137bf215546Sopenharmony_ci        to_cancel = []
138bf215546Sopenharmony_ci        for job in pipeline.jobs.list(all=True, sort="desc"):
139bf215546Sopenharmony_ci            # target jobs
140bf215546Sopenharmony_ci            if target_job and target_jobs_regex.match(job.name):
141bf215546Sopenharmony_ci                if force_manual and job.status == "manual":
142bf215546Sopenharmony_ci                    enable_job(project, job, True)
143bf215546Sopenharmony_ci
144bf215546Sopenharmony_ci                if (job.id not in target_statuses) or (
145bf215546Sopenharmony_ci                    job.status not in target_statuses[job.id]
146bf215546Sopenharmony_ci                ):
147bf215546Sopenharmony_ci                    print_job_status_change(job)
148bf215546Sopenharmony_ci                    target_statuses[job.id] = job.status
149bf215546Sopenharmony_ci                else:
150bf215546Sopenharmony_ci                    print_job_status(job)
151bf215546Sopenharmony_ci
152bf215546Sopenharmony_ci                continue
153bf215546Sopenharmony_ci
154bf215546Sopenharmony_ci            # all jobs
155bf215546Sopenharmony_ci            if (job.id not in statuses) or (job.status not in statuses[job.id]):
156bf215546Sopenharmony_ci                print_job_status_change(job)
157bf215546Sopenharmony_ci                statuses[job.id] = job.status
158bf215546Sopenharmony_ci
159bf215546Sopenharmony_ci            # dependencies and cancelling the rest
160bf215546Sopenharmony_ci            if job.name in dependencies:
161bf215546Sopenharmony_ci                if job.status == "manual":
162bf215546Sopenharmony_ci                    enable_job(project, job, False)
163bf215546Sopenharmony_ci
164bf215546Sopenharmony_ci            elif target_job and job.status not in [
165bf215546Sopenharmony_ci                "canceled",
166bf215546Sopenharmony_ci                "success",
167bf215546Sopenharmony_ci                "failed",
168bf215546Sopenharmony_ci                "skipped",
169bf215546Sopenharmony_ci            ]:
170bf215546Sopenharmony_ci                to_cancel.append(job)
171bf215546Sopenharmony_ci
172bf215546Sopenharmony_ci        if target_job:
173bf215546Sopenharmony_ci            cancel_jobs(project, to_cancel)
174bf215546Sopenharmony_ci
175bf215546Sopenharmony_ci        print("---------------------------------", flush=False)
176bf215546Sopenharmony_ci
177bf215546Sopenharmony_ci        if len(target_statuses) == 1 and {"running"}.intersection(
178bf215546Sopenharmony_ci            target_statuses.values()
179bf215546Sopenharmony_ci        ):
180bf215546Sopenharmony_ci            return next(iter(target_statuses)), None
181bf215546Sopenharmony_ci
182bf215546Sopenharmony_ci        if {"failed", "canceled"}.intersection(target_statuses.values()):
183bf215546Sopenharmony_ci            return None, 1
184bf215546Sopenharmony_ci
185bf215546Sopenharmony_ci        if {"success", "manual"}.issuperset(target_statuses.values()):
186bf215546Sopenharmony_ci            return None, 0
187bf215546Sopenharmony_ci
188bf215546Sopenharmony_ci        pretty_wait(REFRESH_WAIT_JOBS)
189bf215546Sopenharmony_ci
190bf215546Sopenharmony_ci
191bf215546Sopenharmony_cidef enable_job(project, job, target: bool) -> None:
192bf215546Sopenharmony_ci    """enable manual job"""
193bf215546Sopenharmony_ci    pjob = project.jobs.get(job.id, lazy=True)
194bf215546Sopenharmony_ci    pjob.play()
195bf215546Sopenharmony_ci    if target:
196bf215546Sopenharmony_ci        jtype = "� "
197bf215546Sopenharmony_ci    else:
198bf215546Sopenharmony_ci        jtype = "(dependency)"
199bf215546Sopenharmony_ci    print(Fore.MAGENTA + f"{jtype} job {job.name} manually enabled" + Style.RESET_ALL)
200bf215546Sopenharmony_ci
201bf215546Sopenharmony_ci
202bf215546Sopenharmony_cidef cancel_job(project, job) -> None:
203bf215546Sopenharmony_ci    """Cancel GitLab job"""
204bf215546Sopenharmony_ci    pjob = project.jobs.get(job.id, lazy=True)
205bf215546Sopenharmony_ci    pjob.cancel()
206bf215546Sopenharmony_ci    print(f"♲ {job.name}")
207bf215546Sopenharmony_ci
208bf215546Sopenharmony_ci
209bf215546Sopenharmony_cidef cancel_jobs(project, to_cancel) -> None:
210bf215546Sopenharmony_ci    """Cancel unwanted GitLab jobs"""
211bf215546Sopenharmony_ci    if not to_cancel:
212bf215546Sopenharmony_ci        return
213bf215546Sopenharmony_ci
214bf215546Sopenharmony_ci    with ThreadPoolExecutor(max_workers=6) as exe:
215bf215546Sopenharmony_ci        part = partial(cancel_job, project)
216bf215546Sopenharmony_ci        exe.map(part, to_cancel)
217bf215546Sopenharmony_ci
218bf215546Sopenharmony_ci
219bf215546Sopenharmony_cidef print_log(project, job_id) -> None:
220bf215546Sopenharmony_ci    """Print job log into output"""
221bf215546Sopenharmony_ci    printed_lines = 0
222bf215546Sopenharmony_ci    while True:
223bf215546Sopenharmony_ci        job = project.jobs.get(job_id)
224bf215546Sopenharmony_ci
225bf215546Sopenharmony_ci        # GitLab's REST API doesn't offer pagination for logs, so we have to refetch it all
226bf215546Sopenharmony_ci        lines = job.trace().decode("unicode_escape").splitlines()
227bf215546Sopenharmony_ci        for line in lines[printed_lines:]:
228bf215546Sopenharmony_ci            print(line)
229bf215546Sopenharmony_ci        printed_lines = len(lines)
230bf215546Sopenharmony_ci
231bf215546Sopenharmony_ci        if job.status in COMPLETED_STATUSES:
232bf215546Sopenharmony_ci            print(Fore.GREEN + f"Job finished: {job.web_url}" + Style.RESET_ALL)
233bf215546Sopenharmony_ci            return
234bf215546Sopenharmony_ci        pretty_wait(REFRESH_WAIT_LOG)
235bf215546Sopenharmony_ci
236bf215546Sopenharmony_ci
237bf215546Sopenharmony_cidef parse_args() -> None:
238bf215546Sopenharmony_ci    """Parse args"""
239bf215546Sopenharmony_ci    parser = argparse.ArgumentParser(
240bf215546Sopenharmony_ci        description="Tool to trigger a subset of container jobs "
241bf215546Sopenharmony_ci        + "and monitor the progress of a test job",
242bf215546Sopenharmony_ci        epilog="Example: mesa-monitor.py --rev $(git rev-parse HEAD) "
243bf215546Sopenharmony_ci        + '--target ".*traces" ',
244bf215546Sopenharmony_ci    )
245bf215546Sopenharmony_ci    parser.add_argument("--target", metavar="target-job", help="Target job")
246bf215546Sopenharmony_ci    parser.add_argument("--deps", nargs="+", help="Job dependencies")
247bf215546Sopenharmony_ci    parser.add_argument(
248bf215546Sopenharmony_ci        "--rev", metavar="revision", help="repository git revision", required=True
249bf215546Sopenharmony_ci    )
250bf215546Sopenharmony_ci    parser.add_argument(
251bf215546Sopenharmony_ci        "--token",
252bf215546Sopenharmony_ci        metavar="token",
253bf215546Sopenharmony_ci        help="force GitLab token, otherwise it's read from ~/.config/gitlab-token",
254bf215546Sopenharmony_ci    )
255bf215546Sopenharmony_ci    parser.add_argument(
256bf215546Sopenharmony_ci        "--force-manual", action="store_true", help="Force jobs marked as manual"
257bf215546Sopenharmony_ci    )
258bf215546Sopenharmony_ci    return parser.parse_args()
259bf215546Sopenharmony_ci
260bf215546Sopenharmony_ci
261bf215546Sopenharmony_cidef read_token(token_arg: Optional[str]) -> str:
262bf215546Sopenharmony_ci    """pick token from args or file"""
263bf215546Sopenharmony_ci    if token_arg:
264bf215546Sopenharmony_ci        return token_arg
265bf215546Sopenharmony_ci    return (
266bf215546Sopenharmony_ci        open(os.path.expanduser("~/.config/gitlab-token"), encoding="utf-8")
267bf215546Sopenharmony_ci        .readline()
268bf215546Sopenharmony_ci        .rstrip()
269bf215546Sopenharmony_ci    )
270bf215546Sopenharmony_ci
271bf215546Sopenharmony_ci
272bf215546Sopenharmony_ciif __name__ == "__main__":
273bf215546Sopenharmony_ci    try:
274bf215546Sopenharmony_ci        t_start = time.perf_counter()
275bf215546Sopenharmony_ci
276bf215546Sopenharmony_ci        args = parse_args()
277bf215546Sopenharmony_ci
278bf215546Sopenharmony_ci        token = read_token(args.token)
279bf215546Sopenharmony_ci
280bf215546Sopenharmony_ci        gl = gitlab.Gitlab(url="https://gitlab.freedesktop.org", private_token=token)
281bf215546Sopenharmony_ci
282bf215546Sopenharmony_ci        cur_project = get_gitlab_project(gl, "mesa")
283bf215546Sopenharmony_ci
284bf215546Sopenharmony_ci        print(f"Revision: {args.rev}")
285bf215546Sopenharmony_ci        pipe = wait_for_pipeline(cur_project, args.rev)
286bf215546Sopenharmony_ci        print(f"Pipeline: {pipe.web_url}")
287bf215546Sopenharmony_ci        if args.target:
288bf215546Sopenharmony_ci            print("� job: " + Fore.BLUE + args.target + Style.RESET_ALL)
289bf215546Sopenharmony_ci        print(f"Extra dependencies: {args.deps}")
290bf215546Sopenharmony_ci        target_job_id, ret = monitor_pipeline(
291bf215546Sopenharmony_ci            cur_project, pipe, args.target, args.deps, args.force_manual
292bf215546Sopenharmony_ci        )
293bf215546Sopenharmony_ci
294bf215546Sopenharmony_ci        if target_job_id:
295bf215546Sopenharmony_ci            print_log(cur_project, target_job_id)
296bf215546Sopenharmony_ci
297bf215546Sopenharmony_ci        t_end = time.perf_counter()
298bf215546Sopenharmony_ci        spend_minutes = (t_end - t_start) / 60
299bf215546Sopenharmony_ci        print(f"⏲ Duration of script execution: {spend_minutes:0.1f} minutes")
300bf215546Sopenharmony_ci
301bf215546Sopenharmony_ci        sys.exit(ret)
302bf215546Sopenharmony_ci    except KeyboardInterrupt:
303bf215546Sopenharmony_ci        sys.exit(1)
304