blob: 44f39865f6844f8212fed44d9020b1f1cb560a58 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright © 2023 Collabora Ltd.
# Authors:
# Helen Koike <helen.koike@collabora.com>
#
# 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(<span style='color: {status_color}'>{job.status}</span>,<a href='{job.web_url}'>{job.id}</a>)"
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 <a href='{pipeline.web_url}'>{pipeline.web_url}</a>."
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)