#!/usr/bin/env python3 # Copyright © 2023 Collabora Ltd. # Authors: # Helen Koike # # For the dependencies, see the requirements.txt # SPDX-License-Identifier: MIT import argparse from datetime import datetime, timedelta, timezone from typing import Dict, List import plotly.express as px import plotly.graph_objs as go from gitlab import Gitlab, base from gitlab.v4.objects import ProjectPipeline from gitlab_common import (GITLAB_URL, get_gitlab_pipeline_from_url, get_token_from_default_dir, pretty_duration, read_token) def calculate_queued_at(job) -> datetime: started_at = job.started_at.replace("Z", "+00:00") return datetime.fromisoformat(started_at) - timedelta(seconds=job.queued_duration) def calculate_time_difference(time1, time2) -> str: if type(time1) is str: time1 = datetime.fromisoformat(time1.replace("Z", "+00:00")) if type(time2) is str: time2 = datetime.fromisoformat(time2.replace("Z", "+00:00")) diff = time2 - time1 return pretty_duration(diff.seconds) def create_task_name(job) -> str: status_color = {"success": "green", "failed": "red"}.get(job.status, "grey") return f"{job.name}\t({job.status},{job.id})" def add_gantt_bar( job: base.RESTObject, tasks: List[Dict[str, str | datetime | timedelta]] ) -> None: queued_at = calculate_queued_at(job) task_name = create_task_name(job) tasks.append( { "Job": task_name, "Start": job.created_at, "Finish": queued_at, "Duration": calculate_time_difference(job.created_at, queued_at), "Phase": "Waiting dependencies", } ) tasks.append( { "Job": task_name, "Start": queued_at, "Finish": job.started_at, "Duration": calculate_time_difference(queued_at, job.started_at), "Phase": "Queued", } ) if job.finished_at: tasks.append( { "Job": task_name, "Start": job.started_at, "Finish": job.finished_at, "Duration": calculate_time_difference(job.started_at, job.finished_at), "Phase": "Time spent running", } ) else: current_time = datetime.now(timezone.utc).isoformat() tasks.append( { "Job": task_name, "Start": job.started_at, "Finish": current_time, "Duration": calculate_time_difference(job.started_at, current_time), "Phase": "In-Progress", } ) def generate_gantt_chart( pipeline: ProjectPipeline, ci_timeout: float = 60 ) -> go.Figure: if pipeline.yaml_errors: raise ValueError("Pipeline YAML errors detected") # Convert the data into a list of dictionaries for plotly tasks: List[Dict[str, str | datetime | timedelta]] = [] for job in pipeline.jobs.list(all=True, include_retried=True): # we can have queued_duration without started_at when a job is canceled if not job.queued_duration or not job.started_at: continue add_gantt_bar(job, tasks) # Make it easier to see retried jobs tasks.sort(key=lambda x: x["Job"]) title = f"Gantt chart of jobs in pipeline {pipeline.web_url}." title += ( f" Total duration {str(timedelta(seconds=pipeline.duration))}" if pipeline.duration else "" ) # Create a Gantt chart default_colors = px.colors.qualitative.Plotly fig: go.Figure = px.timeline( tasks, x_start="Start", x_end="Finish", y="Job", color="Phase", title=title, hover_data=["Duration"], color_discrete_map={ "In-Progress": default_colors[3], # purple "Waiting dependencies": default_colors[0], # blue "Queued": default_colors[1], # red "Time spent running": default_colors[2], # green }, ) # Calculate the height dynamically fig.update_layout(height=len(tasks) * 10, yaxis_tickfont_size=14) # Add a deadline line to the chart created_at = datetime.fromisoformat(pipeline.created_at.replace("Z", "+00:00")) timeout_at = created_at + timedelta(minutes=ci_timeout) fig.add_vrect( x0=timeout_at, x1=timeout_at, annotation_text=f"{int(ci_timeout)} min Timeout", fillcolor="gray", line_width=2, line_color="gray", line_dash="dash", annotation_position="top left", annotation_textangle=90, ) return fig def main( token: str | None, pipeline_url: str, output: str | None, ci_timeout: float = 60, ): if token is None: token = get_token_from_default_dir() token = read_token(token) gl = Gitlab(url=GITLAB_URL, private_token=token, retry_transient_errors=True) pipeline, _ = get_gitlab_pipeline_from_url(gl, pipeline_url) fig: go.Figure = generate_gantt_chart(pipeline, ci_timeout) if output and "htm" in output: fig.write_html(output) elif output: fig.update_layout(width=1000) fig.write_image(output) else: fig.show() if __name__ == "__main__": parser = argparse.ArgumentParser( description="Generate the Gantt chart from a given pipeline." ) parser.add_argument("pipeline_url", type=str, help="URLs to the pipeline.") parser.add_argument( "-o", "--output", type=str, help="Output file name. Use html or image suffixes to choose the format.", ) parser.add_argument( "--token", metavar="token", help="force GitLab token, otherwise it's read from ~/.config/gitlab-token", ) parser.add_argument( "--ci-timeout", metavar="ci_timeout", type=float, default=60, help="Time that marge-bot will wait for ci to finish. Defaults to one hour.", ) args = parser.parse_args() main(args.token, args.pipeline_url, args.output, args.ci_timeout)