
When the gantt chart service was first implemented, the read_token() function would attempt to read from the default token file if the token argument was missing. Now it's necessary to call a separate function to look in the default token file, so if the token argument is not provided, expressly call get_token_from_default_dir(). Part-of: <https://gitlab.freedesktop.org/mesa/mesa/-/merge_requests/32637>
203 lines
6.1 KiB
Python
Executable File
203 lines
6.1 KiB
Python
Executable File
#!/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)
|