Skip to content

Commit 1b8da69

Browse files
authored
[doc] Improve CI jobs graph generation (#804)
Align `jobs_graph.py` to generate the actual svg with the same layout
1 parent 90337bc commit 1b8da69

2 files changed

Lines changed: 119 additions & 54 deletions

File tree

docs/_static/ci_graph.svg

Lines changed: 37 additions & 39 deletions
Loading

scripts/jobs_graph.py

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
1+
import argparse
12
import os
3+
import subprocess
24

35
try:
46
import yaml
57
except ImportError:
68
print("Please install pyyaml: pip install pyyaml")
79
exit(1)
810

9-
try:
10-
import graphviz
11-
except ImportError:
12-
print("Please install graphviz: pip install graphviz")
13-
exit(1)
11+
EXCLUDED_JOBS = {"ci-scope"}
1412

1513

1614
def parse_gha_yml(file_path):
@@ -20,27 +18,96 @@ def parse_gha_yml(file_path):
2018

2119

2220
def build_jobs_graph(gha_data):
23-
jobs = gha_data.get("jobs", {})
24-
dot = graphviz.Digraph()
21+
jobs = filter_jobs(gha_data.get("jobs", {}))
22+
dot = [
23+
"digraph CI_Overview {",
24+
' graph [rankdir="LR", ranksep="1.0", nodesep="0.5", splines="ortho"];',
25+
' node [shape="box", style="rounded", fontname="Helvetica"];',
26+
' edge [color="#333333"];',
27+
]
2528

2629
for job_name, job_data in jobs.items():
27-
dot.node(job_name)
30+
dot.append(f" {quote_dot_id(job_name)};")
2831
needs = job_data.get("needs", [])
2932
if isinstance(needs, str):
3033
needs = [needs]
3134
for dependency in needs:
32-
dot.edge(dependency, job_name)
35+
if dependency not in jobs:
36+
continue
37+
dot.append(f" {quote_dot_id(dependency)} -> {quote_dot_id(job_name)};")
38+
39+
for rank_jobs in get_ranked_jobs(jobs).values():
40+
same_rank_jobs = " ".join(
41+
f"{quote_dot_id(job_name)};" for job_name in rank_jobs
42+
)
43+
dot.append(f" {{ rank=same; {same_rank_jobs} }}")
44+
45+
dot.append("}")
46+
return "\n".join(dot)
47+
48+
49+
def filter_jobs(jobs):
50+
return {
51+
job_name: job_data
52+
for job_name, job_data in jobs.items()
53+
if job_name not in EXCLUDED_JOBS
54+
}
55+
56+
57+
def quote_dot_id(value):
58+
return f'"{value.replace("\\", "\\\\").replace('"', '\\"')}"'
59+
3360

34-
return dot
61+
def get_ranked_jobs(jobs):
62+
ranks = {}
63+
64+
def get_rank(job_name):
65+
if job_name in ranks:
66+
return ranks[job_name]
67+
68+
needs = jobs[job_name].get("needs", [])
69+
if isinstance(needs, str):
70+
needs = [needs]
71+
needs = [dependency for dependency in needs if dependency in jobs]
72+
73+
if not needs:
74+
ranks[job_name] = 0
75+
else:
76+
ranks[job_name] = max(get_rank(dependency) for dependency in needs) + 1
77+
return ranks[job_name]
78+
79+
for job_name in jobs:
80+
get_rank(job_name)
81+
82+
ranked_jobs = {}
83+
for job_name, rank in ranks.items():
84+
ranked_jobs.setdefault(rank, []).append(job_name)
85+
return ranked_jobs
3586

3687

3788
def save_graph(dot, filename, file_format):
38-
dot.render(filename, format=file_format, cleanup=True)
89+
subprocess.run(
90+
["dot", f"-T{file_format}", "-o", f"{filename}.{file_format}"],
91+
input=dot,
92+
text=True,
93+
check=True,
94+
)
95+
96+
97+
def parse_args():
98+
parser = argparse.ArgumentParser(
99+
description="Generate a Graphviz CI jobs graph from a GitHub Actions workflow."
100+
)
101+
parser.add_argument(
102+
"--workflow", default=os.path.join(".github", "workflows", "main.yml")
103+
)
104+
parser.add_argument("--out", default=os.path.join("docs", "_static", "ci_graph"))
105+
parser.add_argument("--format", default="svg")
106+
return parser.parse_args()
39107

40108

41109
if __name__ == "__main__":
42-
gha_file_path = os.path.join(".github", "workflows", "main.yml")
43-
svg_path = os.path.join("docs", "_static", "ci_graph")
44-
gha_data = parse_gha_yml(gha_file_path)
110+
args = parse_args()
111+
gha_data = parse_gha_yml(args.workflow)
45112
jobs_graph = build_jobs_graph(gha_data)
46-
save_graph(jobs_graph, svg_path, "svg")
113+
save_graph(jobs_graph, args.out, args.format)

0 commit comments

Comments
 (0)