1+ import argparse
12import os
3+ import subprocess
24
35try :
46 import yaml
57except 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
1614def parse_gha_yml (file_path ):
@@ -20,27 +18,96 @@ def parse_gha_yml(file_path):
2018
2119
2220def 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
3788def 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
41109if __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