eirmos 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eirmos/__init__.py +70 -0
- eirmos/__main__.py +7 -0
- eirmos/_yaml.py +25 -0
- eirmos/cli.py +181 -0
- eirmos/colors.py +32 -0
- eirmos/formatters/__init__.py +22 -0
- eirmos/formatters/base.py +19 -0
- eirmos/formatters/dot.py +59 -0
- eirmos/formatters/mermaid.py +55 -0
- eirmos/formatters/summary.py +79 -0
- eirmos/formatters/tree.py +153 -0
- eirmos/formatters/variables.py +65 -0
- eirmos/graph.py +90 -0
- eirmos/parsers/__init__.py +277 -0
- eirmos/parsers/appveyor.py +161 -0
- eirmos/parsers/azure.py +147 -0
- eirmos/parsers/base.py +85 -0
- eirmos/parsers/bitbucket.py +119 -0
- eirmos/parsers/buildkite.py +128 -0
- eirmos/parsers/circleci.py +111 -0
- eirmos/parsers/codefresh.py +164 -0
- eirmos/parsers/drone.py +120 -0
- eirmos/parsers/github.py +46 -0
- eirmos/parsers/gitlab.py +261 -0
- eirmos/parsers/jenkins.py +139 -0
- eirmos/parsers/registry.py +89 -0
- eirmos/parsers/semaphore.py +72 -0
- eirmos/parsers/travis.py +171 -0
- eirmos-0.4.0.dist-info/METADATA +10 -0
- eirmos-0.4.0.dist-info/RECORD +33 -0
- eirmos-0.4.0.dist-info/WHEEL +5 -0
- eirmos-0.4.0.dist-info/entry_points.txt +2 -0
- eirmos-0.4.0.dist-info/top_level.txt +1 -0
eirmos/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""CI/CD pipeline visualiser.
|
|
2
|
+
|
|
3
|
+
A small library + CLI for parsing CI/CD pipeline definitions and
|
|
4
|
+
rendering them in various formats (tree, mermaid, dot, summary).
|
|
5
|
+
|
|
6
|
+
Supported systems: GitLab CI, GitHub Actions, Jenkins, CircleCI,
|
|
7
|
+
Azure Pipelines, Bitbucket Pipelines, Drone CI, Woodpecker CI,
|
|
8
|
+
Travis CI, AppVeyor, Buildkite, Codefresh, Semaphore.
|
|
9
|
+
|
|
10
|
+
The package is organised in clear architectural layers:
|
|
11
|
+
|
|
12
|
+
parsers/ - read pipeline definition files and produce a domain model
|
|
13
|
+
graph.py - build a dependency graph from a parser
|
|
14
|
+
formatters/ - render graphs to text/diagram formats
|
|
15
|
+
cli.py - thin command-line entry point
|
|
16
|
+
colors.py - ANSI color helpers (presentation only)
|
|
17
|
+
|
|
18
|
+
New CI systems can be supported by implementing
|
|
19
|
+
:class:`eirmos.parsers.base.BasePipelineParser` and
|
|
20
|
+
registering a :class:`eirmos.parsers.registry.ParserAdapter`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .colors import Colors
|
|
24
|
+
from .parsers.gitlab import GitLabCIParser
|
|
25
|
+
from .parsers.github import GitHubActionsParser
|
|
26
|
+
from .parsers.jenkins import JenkinsParser
|
|
27
|
+
from .parsers.circleci import CircleCIParser
|
|
28
|
+
from .parsers.azure import AzurePipelinesParser
|
|
29
|
+
from .parsers.bitbucket import BitbucketPipelinesParser
|
|
30
|
+
from .parsers.drone import DroneParser, WoodpeckerParser
|
|
31
|
+
from .parsers.travis import TravisCIParser
|
|
32
|
+
from .parsers.appveyor import AppVeyorParser
|
|
33
|
+
from .parsers.buildkite import BuildkiteParser
|
|
34
|
+
from .parsers.codefresh import CodefreshParser
|
|
35
|
+
from .parsers.semaphore import SemaphoreParser
|
|
36
|
+
from .parsers.base import BasePipelineParser
|
|
37
|
+
from .parsers.registry import ParserAdapter, REGISTRY, register_adapter
|
|
38
|
+
from .graph import DependencyGraph
|
|
39
|
+
from .formatters.tree import TreeFormatter
|
|
40
|
+
from .formatters.mermaid import MermaidFormatter
|
|
41
|
+
from .formatters.dot import DotFormatter
|
|
42
|
+
from .formatters.summary import SummaryFormatter
|
|
43
|
+
from .formatters.variables import VariableFormatter
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Colors",
|
|
47
|
+
"BasePipelineParser",
|
|
48
|
+
"GitLabCIParser",
|
|
49
|
+
"GitHubActionsParser",
|
|
50
|
+
"JenkinsParser",
|
|
51
|
+
"CircleCIParser",
|
|
52
|
+
"AzurePipelinesParser",
|
|
53
|
+
"BitbucketPipelinesParser",
|
|
54
|
+
"DroneParser",
|
|
55
|
+
"WoodpeckerParser",
|
|
56
|
+
"TravisCIParser",
|
|
57
|
+
"AppVeyorParser",
|
|
58
|
+
"BuildkiteParser",
|
|
59
|
+
"CodefreshParser",
|
|
60
|
+
"SemaphoreParser",
|
|
61
|
+
"ParserAdapter",
|
|
62
|
+
"REGISTRY",
|
|
63
|
+
"register_adapter",
|
|
64
|
+
"DependencyGraph",
|
|
65
|
+
"TreeFormatter",
|
|
66
|
+
"MermaidFormatter",
|
|
67
|
+
"DotFormatter",
|
|
68
|
+
"SummaryFormatter",
|
|
69
|
+
"VariableFormatter",
|
|
70
|
+
]
|
eirmos/__main__.py
ADDED
eirmos/_yaml.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""YAML loader configured for GitLab CI specifics.
|
|
2
|
+
|
|
3
|
+
Importing this module has the side effect of registering the
|
|
4
|
+
``!reference`` constructor on ``yaml.SafeLoader``. Centralising
|
|
5
|
+
the import means PyYAML is loaded exactly once and there's a
|
|
6
|
+
single place to change the loader configuration.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import yaml
|
|
13
|
+
except ImportError: # pragma: no cover - import-time guard
|
|
14
|
+
print("ERROR: PyYAML is required. Install with: pip install pyyaml")
|
|
15
|
+
sys.exit(1)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _reference_constructor(loader, node):
|
|
19
|
+
"""Handle GitLab CI ``!reference`` tags by returning the sequence as-is."""
|
|
20
|
+
return loader.construct_sequence(node)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
yaml.add_constructor('!reference', _reference_constructor, Loader=yaml.SafeLoader)
|
|
24
|
+
|
|
25
|
+
__all__ = ["yaml"]
|
eirmos/cli.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Kept intentionally thin: argument parsing + parser/formatter
|
|
4
|
+
selection. All real logic lives in the dedicated modules.
|
|
5
|
+
|
|
6
|
+
Parser selection is driven by the
|
|
7
|
+
:mod:`eirmos.parsers.registry`, so adding a new CI/CD
|
|
8
|
+
system is a matter of registering a :class:`ParserAdapter`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .colors import Colors
|
|
16
|
+
from .graph import DependencyGraph
|
|
17
|
+
from .parsers import detect as detect_adapter, REGISTRY
|
|
18
|
+
from .parsers.gitlab import GitLabCIParser
|
|
19
|
+
from .formatters.tree import TreeFormatter
|
|
20
|
+
from .formatters.mermaid import MermaidFormatter
|
|
21
|
+
from .formatters.dot import DotFormatter
|
|
22
|
+
from .formatters.summary import SummaryFormatter
|
|
23
|
+
from .formatters.variables import VariableFormatter
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
FORMATTERS = {
|
|
27
|
+
'tree': TreeFormatter,
|
|
28
|
+
'mermaid': MermaidFormatter,
|
|
29
|
+
'dot': DotFormatter,
|
|
30
|
+
'summary': SummaryFormatter,
|
|
31
|
+
'variables': VariableFormatter,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_ci_files(base_path):
|
|
36
|
+
"""Find all GitLab CI files in the repository.
|
|
37
|
+
|
|
38
|
+
Kept for backwards compatibility with previous releases of the
|
|
39
|
+
package; the CLI now relies on the parser registry.
|
|
40
|
+
"""
|
|
41
|
+
patterns = ['**/*.gitlab-ci.yml', '**/*.gitlab-ci.yaml', '.gitlab-ci.yml']
|
|
42
|
+
files = set()
|
|
43
|
+
for pattern in patterns:
|
|
44
|
+
for f in Path(base_path).glob(pattern):
|
|
45
|
+
files.add(f)
|
|
46
|
+
return sorted(files)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_arg_parser():
|
|
50
|
+
supported = ', '.join(a.name for a in REGISTRY) or 'none registered'
|
|
51
|
+
arg_parser = argparse.ArgumentParser(
|
|
52
|
+
prog='eirmos',
|
|
53
|
+
description=(
|
|
54
|
+
'CI/CD Pipeline Visualiser - Parse and visualise job dependencies. '
|
|
55
|
+
f'Supported systems: {supported}.'
|
|
56
|
+
),
|
|
57
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
58
|
+
)
|
|
59
|
+
arg_parser.add_argument('path', nargs='?', default='.',
|
|
60
|
+
help='Path to repository root (default: current directory)')
|
|
61
|
+
arg_parser.add_argument('--format', '-f', choices=list(FORMATTERS.keys()),
|
|
62
|
+
default='tree', help='Output format (default: tree)')
|
|
63
|
+
arg_parser.add_argument('--stage', '-s', default=None,
|
|
64
|
+
help='Filter output to a specific stage')
|
|
65
|
+
arg_parser.add_argument('--job', '-j', default=None,
|
|
66
|
+
help='Show detailed dependencies for a specific job')
|
|
67
|
+
arg_parser.add_argument('--no-includes', action='store_true',
|
|
68
|
+
help='Do not follow local include directives (GitLab only)')
|
|
69
|
+
arg_parser.add_argument('--output', '-o', default=None,
|
|
70
|
+
help='Output file path (default: stdout)')
|
|
71
|
+
arg_parser.add_argument('--no-color', action='store_true',
|
|
72
|
+
help='Disable colored output')
|
|
73
|
+
arg_parser.add_argument('--list-stages', action='store_true',
|
|
74
|
+
help='List all stages and exit')
|
|
75
|
+
arg_parser.add_argument('--list-jobs', action='store_true',
|
|
76
|
+
help='List all jobs and exit')
|
|
77
|
+
arg_parser.add_argument('--ci', default=None,
|
|
78
|
+
choices=[a.name for a in REGISTRY],
|
|
79
|
+
help='Force a specific CI system instead of auto-detection')
|
|
80
|
+
return arg_parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _select_ci_file(base_path, forced_name=None):
|
|
84
|
+
"""Return ``(adapter, main_ci_file)`` for ``base_path``.
|
|
85
|
+
|
|
86
|
+
If ``forced_name`` is given, only that adapter is consulted.
|
|
87
|
+
Otherwise the registry is queried in registration order and the
|
|
88
|
+
first hit wins. As a last-resort fallback we still recognise
|
|
89
|
+
misplaced ``*.gitlab-ci.yml`` files so existing behaviour is
|
|
90
|
+
preserved.
|
|
91
|
+
"""
|
|
92
|
+
if forced_name:
|
|
93
|
+
for adapter in REGISTRY:
|
|
94
|
+
if adapter.name == forced_name:
|
|
95
|
+
main = adapter.detect(base_path)
|
|
96
|
+
if main is not None:
|
|
97
|
+
print(f"{Colors.DIM}Forced CI system: {adapter.name} "
|
|
98
|
+
f"({main.name}){Colors.RESET}", file=sys.stderr)
|
|
99
|
+
return adapter, main
|
|
100
|
+
return None, None
|
|
101
|
+
|
|
102
|
+
adapter, main_file = detect_adapter(base_path)
|
|
103
|
+
if adapter is not None:
|
|
104
|
+
print(f"{Colors.DIM}Detected {adapter.name}: {main_file.name}"
|
|
105
|
+
f"{Colors.RESET}", file=sys.stderr)
|
|
106
|
+
return adapter, main_file
|
|
107
|
+
|
|
108
|
+
# Legacy fallback: any stray *.gitlab-ci.yml
|
|
109
|
+
ci_files = find_ci_files(base_path)
|
|
110
|
+
if ci_files:
|
|
111
|
+
print(f"WARNING: No CI definition found at root. "
|
|
112
|
+
f"Found {len(ci_files)} GitLab CI files; using {ci_files[0].name}.",
|
|
113
|
+
file=sys.stderr)
|
|
114
|
+
# Build a synthetic adapter for legacy callers.
|
|
115
|
+
from .parsers.registry import ParserAdapter
|
|
116
|
+
legacy = ParserAdapter(
|
|
117
|
+
name="GitLab CI",
|
|
118
|
+
parser_class=GitLabCIParser,
|
|
119
|
+
detect=lambda _: ci_files[0],
|
|
120
|
+
parser_kwargs=lambda args: {
|
|
121
|
+
'follow_includes': not getattr(args, 'no_includes', False),
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
return legacy, ci_files[0]
|
|
125
|
+
|
|
126
|
+
return None, None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv=None):
|
|
130
|
+
args = build_arg_parser().parse_args(argv)
|
|
131
|
+
|
|
132
|
+
if args.no_color or args.output or not sys.stdout.isatty():
|
|
133
|
+
Colors.disable()
|
|
134
|
+
|
|
135
|
+
base_path = Path(args.path).resolve()
|
|
136
|
+
if not base_path.exists():
|
|
137
|
+
print(f"ERROR: Path '{args.path}' does not exist.", file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
|
|
140
|
+
adapter, main_ci_file = _select_ci_file(base_path, forced_name=args.ci)
|
|
141
|
+
if not main_ci_file or adapter is None:
|
|
142
|
+
supported = ', '.join(a.name for a in REGISTRY)
|
|
143
|
+
print(f"ERROR: No supported CI files found ({supported}).", file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
|
|
146
|
+
print(f"{Colors.DIM}Parsing CI files from: {base_path}{Colors.RESET}", file=sys.stderr)
|
|
147
|
+
|
|
148
|
+
parser = adapter.parser_class(base_path=base_path, **adapter.parser_kwargs(args))
|
|
149
|
+
parser.parse(main_ci_file)
|
|
150
|
+
|
|
151
|
+
print(f"{Colors.DIM}Parsed {len(parser.parsed_files)} files, "
|
|
152
|
+
f"found {len(parser.jobs)} jobs.{Colors.RESET}\n", file=sys.stderr)
|
|
153
|
+
|
|
154
|
+
if args.list_stages:
|
|
155
|
+
graph = DependencyGraph(parser)
|
|
156
|
+
for stage in graph.get_ordered_stages():
|
|
157
|
+
count = len(graph.stage_jobs.get(stage, []))
|
|
158
|
+
print(f"{stage} ({count} jobs)")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
if args.list_jobs:
|
|
162
|
+
for job_name in sorted(parser.jobs.keys()):
|
|
163
|
+
stage = parser.get_job_stage(job_name)
|
|
164
|
+
print(f"{job_name:<60} [{stage}]")
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
graph = DependencyGraph(parser)
|
|
168
|
+
formatter = FORMATTERS[args.format](parser, graph)
|
|
169
|
+
output = formatter.render(filter_stage=args.stage, filter_job=args.job)
|
|
170
|
+
|
|
171
|
+
if args.output:
|
|
172
|
+
with open(args.output, 'w') as f:
|
|
173
|
+
f.write(output)
|
|
174
|
+
print(f"Output written to: {args.output}", file=sys.stderr)
|
|
175
|
+
else:
|
|
176
|
+
print(output)
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == '__main__': # pragma: no cover
|
|
181
|
+
sys.exit(main())
|
eirmos/colors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""ANSI color codes used by the text formatters.
|
|
2
|
+
|
|
3
|
+
This module is purposely tiny — it is the only place that knows
|
|
4
|
+
about terminal escape sequences. Other layers should depend on
|
|
5
|
+
``Colors`` rather than hard-coding escape codes so they can be
|
|
6
|
+
disabled centrally (e.g. when writing to a file or a non-TTY).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Colors:
|
|
11
|
+
HEADER = '\033[95m'
|
|
12
|
+
BLUE = '\033[94m'
|
|
13
|
+
CYAN = '\033[96m'
|
|
14
|
+
GREEN = '\033[92m'
|
|
15
|
+
YELLOW = '\033[93m'
|
|
16
|
+
RED = '\033[91m'
|
|
17
|
+
BOLD = '\033[1m'
|
|
18
|
+
DIM = '\033[2m'
|
|
19
|
+
RESET = '\033[0m'
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def disable(cls):
|
|
23
|
+
"""Replace all color codes with empty strings (irreversible)."""
|
|
24
|
+
cls.HEADER = ''
|
|
25
|
+
cls.BLUE = ''
|
|
26
|
+
cls.CYAN = ''
|
|
27
|
+
cls.GREEN = ''
|
|
28
|
+
cls.YELLOW = ''
|
|
29
|
+
cls.RED = ''
|
|
30
|
+
cls.BOLD = ''
|
|
31
|
+
cls.DIM = ''
|
|
32
|
+
cls.RESET = ''
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Output formatters for ``DependencyGraph`` instances.
|
|
2
|
+
|
|
3
|
+
All formatters share the same constructor signature
|
|
4
|
+
``Formatter(parser, graph)`` and a ``render(filter_stage=None,
|
|
5
|
+
filter_job=None) -> str`` method, making them interchangeable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .base import BaseFormatter
|
|
9
|
+
from .tree import TreeFormatter
|
|
10
|
+
from .mermaid import MermaidFormatter
|
|
11
|
+
from .dot import DotFormatter
|
|
12
|
+
from .summary import SummaryFormatter
|
|
13
|
+
from .variables import VariableFormatter
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BaseFormatter",
|
|
17
|
+
"TreeFormatter",
|
|
18
|
+
"MermaidFormatter",
|
|
19
|
+
"DotFormatter",
|
|
20
|
+
"SummaryFormatter",
|
|
21
|
+
"VariableFormatter",
|
|
22
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Common base class for formatters."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseFormatter:
|
|
7
|
+
"""Tiny shared base — keeps formatter API uniform."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, parser, graph):
|
|
10
|
+
self.parser = parser
|
|
11
|
+
self.graph = graph
|
|
12
|
+
|
|
13
|
+
def render(self, filter_stage=None, filter_job=None): # pragma: no cover
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def sanitize_id(name):
|
|
18
|
+
"""Sanitize a name for use as a Mermaid/DOT identifier."""
|
|
19
|
+
return re.sub(r'[^a-zA-Z0-9_]', '_', name)
|
eirmos/formatters/dot.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Graphviz DOT formatter."""
|
|
2
|
+
|
|
3
|
+
from .base import BaseFormatter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DotFormatter(BaseFormatter):
|
|
7
|
+
"""Outputs a Graphviz DOT diagram."""
|
|
8
|
+
|
|
9
|
+
def render(self, filter_stage=None, filter_job=None):
|
|
10
|
+
lines = [
|
|
11
|
+
"digraph pipeline {",
|
|
12
|
+
" rankdir=TB;",
|
|
13
|
+
" node [shape=box, style=rounded, fontname=\"Helvetica\"];",
|
|
14
|
+
" edge [fontname=\"Helvetica\", fontsize=10];",
|
|
15
|
+
"",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
stages = self.graph.get_ordered_stages()
|
|
19
|
+
|
|
20
|
+
for stage in stages:
|
|
21
|
+
if filter_stage and stage != filter_stage:
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
jobs = self.graph.stage_jobs.get(stage, [])
|
|
25
|
+
if not jobs:
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
lines.append(f" subgraph cluster_{self.sanitize_id(stage)} {{")
|
|
29
|
+
lines.append(f" label=\"{stage}\";")
|
|
30
|
+
lines.append(f" style=dashed;")
|
|
31
|
+
lines.append(f" color=\"#003087\";")
|
|
32
|
+
|
|
33
|
+
for job_name in sorted(jobs):
|
|
34
|
+
sid = self.sanitize_id(job_name)
|
|
35
|
+
trigger = self.parser.get_job_triggers(job_name)
|
|
36
|
+
color = '#00AEEF' if trigger else '#003087'
|
|
37
|
+
shape = 'doubleoctagon' if trigger else 'box'
|
|
38
|
+
lines.append(
|
|
39
|
+
f" {sid} [label=\"{job_name}\", "
|
|
40
|
+
f"color=\"{color}\", shape={shape}];"
|
|
41
|
+
)
|
|
42
|
+
lines.append(" }")
|
|
43
|
+
lines.append("")
|
|
44
|
+
|
|
45
|
+
for src, dst, etype in self.graph.edges:
|
|
46
|
+
src_id = self.sanitize_id(src)
|
|
47
|
+
dst_id = self.sanitize_id(dst)
|
|
48
|
+
if etype == 'needs':
|
|
49
|
+
lines.append(f" {src_id} -> {dst_id};")
|
|
50
|
+
elif etype == 'needs-optional':
|
|
51
|
+
lines.append(f" {src_id} -> {dst_id} [style=dashed, label=\"optional\"];")
|
|
52
|
+
elif etype == 'trigger':
|
|
53
|
+
lines.append(
|
|
54
|
+
f" {src_id} -> {dst_id} [style=bold, color=\"#FF6600\", "
|
|
55
|
+
f"label=\"trigger\"];"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
lines.append("}")
|
|
59
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Mermaid flowchart formatter."""
|
|
2
|
+
|
|
3
|
+
from .base import BaseFormatter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MermaidFormatter(BaseFormatter):
|
|
7
|
+
"""Outputs a Mermaid diagram."""
|
|
8
|
+
|
|
9
|
+
def render(self, filter_stage=None, filter_job=None):
|
|
10
|
+
lines = ["```mermaid", "flowchart TD", ""]
|
|
11
|
+
|
|
12
|
+
stages = self.graph.get_ordered_stages()
|
|
13
|
+
rendered_jobs = set()
|
|
14
|
+
|
|
15
|
+
for stage in stages:
|
|
16
|
+
if filter_stage and stage != filter_stage:
|
|
17
|
+
continue
|
|
18
|
+
|
|
19
|
+
jobs = self.graph.stage_jobs.get(stage, [])
|
|
20
|
+
if not jobs:
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
lines.append(f" subgraph {self.sanitize_id(stage)}[\"{stage}\"]")
|
|
24
|
+
for job_name in sorted(jobs):
|
|
25
|
+
if filter_job and filter_job != job_name:
|
|
26
|
+
preds = [src for src, dst, _ in self.graph.edges if dst == job_name]
|
|
27
|
+
succs = [dst for _, dst, _ in self.graph.edges if dst == job_name]
|
|
28
|
+
if filter_job not in preds and filter_job not in succs:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
sid = self.sanitize_id(job_name)
|
|
32
|
+
trigger = self.parser.get_job_triggers(job_name)
|
|
33
|
+
if trigger:
|
|
34
|
+
lines.append(f" {sid}[[\"{job_name}\"]]\n")
|
|
35
|
+
else:
|
|
36
|
+
lines.append(f" {sid}[\"{job_name}\"]\n")
|
|
37
|
+
rendered_jobs.add(job_name)
|
|
38
|
+
lines.append(" end")
|
|
39
|
+
lines.append("")
|
|
40
|
+
|
|
41
|
+
lines.append(" %% Dependencies")
|
|
42
|
+
for src, dst, etype in self.graph.edges:
|
|
43
|
+
if src not in rendered_jobs and dst not in rendered_jobs:
|
|
44
|
+
continue
|
|
45
|
+
src_id = self.sanitize_id(src)
|
|
46
|
+
dst_id = self.sanitize_id(dst)
|
|
47
|
+
if etype == 'needs':
|
|
48
|
+
lines.append(f" {src_id} --> {dst_id}")
|
|
49
|
+
elif etype == 'needs-optional':
|
|
50
|
+
lines.append(f" {src_id} -.-> {dst_id}")
|
|
51
|
+
elif etype == 'trigger':
|
|
52
|
+
lines.append(f" {src_id} ==> {dst_id}")
|
|
53
|
+
|
|
54
|
+
lines.append("```")
|
|
55
|
+
return '\n'.join(lines)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Compact summary formatter."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from ..colors import Colors
|
|
7
|
+
from .base import BaseFormatter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SummaryFormatter(BaseFormatter):
|
|
11
|
+
"""Outputs a compact summary view."""
|
|
12
|
+
|
|
13
|
+
def render(self, filter_stage=None, filter_job=None):
|
|
14
|
+
lines = []
|
|
15
|
+
lines.append(f"\n{Colors.BOLD}Pipeline Summary{Colors.RESET}")
|
|
16
|
+
lines.append(f"{'─' * 60}")
|
|
17
|
+
|
|
18
|
+
templates = getattr(self.parser, 'templates', {}) or {}
|
|
19
|
+
|
|
20
|
+
lines.append(f"\n{Colors.BOLD}Statistics:{Colors.RESET}")
|
|
21
|
+
lines.append(f" Total jobs: {len(self.parser.jobs)}")
|
|
22
|
+
lines.append(f" Total templates: {len(templates)}")
|
|
23
|
+
lines.append(f" Total stages: {len(self.graph.get_ordered_stages())}")
|
|
24
|
+
lines.append(f" Total edges: {len(self.graph.edges)}")
|
|
25
|
+
lines.append(f" Files parsed: {len(self.parser.parsed_files)}")
|
|
26
|
+
|
|
27
|
+
lines.append(f"\n{Colors.BOLD}Stages:{Colors.RESET}")
|
|
28
|
+
for stage in self.graph.get_ordered_stages():
|
|
29
|
+
jobs = self.graph.stage_jobs.get(stage, [])
|
|
30
|
+
bar = '█' * min(len(jobs), 40)
|
|
31
|
+
lines.append(f" {stage:<20} {Colors.BLUE}{bar}{Colors.RESET} ({len(jobs)})")
|
|
32
|
+
|
|
33
|
+
lines.append(f"\n{Colors.BOLD}Most Connected Jobs (by needs):{Colors.RESET}")
|
|
34
|
+
job_deps = {}
|
|
35
|
+
for job_name in self.parser.jobs:
|
|
36
|
+
needs = self.parser.get_job_needs(job_name)
|
|
37
|
+
succs = self.graph.get_successors(job_name)
|
|
38
|
+
job_deps[job_name] = len(needs) + len(succs)
|
|
39
|
+
|
|
40
|
+
top_jobs = sorted(job_deps.items(), key=lambda x: x[1], reverse=True)[:15]
|
|
41
|
+
for job_name, count in top_jobs:
|
|
42
|
+
if count > 0:
|
|
43
|
+
lines.append(f" {job_name:<55} {Colors.GREEN}{count}{Colors.RESET}")
|
|
44
|
+
|
|
45
|
+
trigger_jobs = [j for j in self.parser.jobs if self.parser.get_job_triggers(j)]
|
|
46
|
+
if trigger_jobs:
|
|
47
|
+
lines.append(f"\n{Colors.BOLD}Child Pipeline Triggers:{Colors.RESET}")
|
|
48
|
+
for job_name in sorted(trigger_jobs):
|
|
49
|
+
trigger = self.parser.get_job_triggers(job_name)
|
|
50
|
+
inc = trigger.get('include', 'unknown')
|
|
51
|
+
lines.append(f" {Colors.YELLOW}{job_name}{Colors.RESET}")
|
|
52
|
+
lines.append(f" ⟶ {inc}")
|
|
53
|
+
|
|
54
|
+
roots = self.graph.get_roots()
|
|
55
|
+
if roots:
|
|
56
|
+
lines.append(f"\n{Colors.BOLD}Root Jobs (no 'needs' dependencies):{Colors.RESET}")
|
|
57
|
+
for job_name in sorted(roots)[:20]:
|
|
58
|
+
stage = self.parser.get_job_stage(job_name)
|
|
59
|
+
lines.append(f" {job_name:<50} {Colors.DIM}[{stage}]{Colors.RESET}")
|
|
60
|
+
if len(roots) > 20:
|
|
61
|
+
lines.append(f" ... and {len(roots) - 20} more")
|
|
62
|
+
|
|
63
|
+
# CUSTOM_BUILD options mapping (GitLab CI specific)
|
|
64
|
+
lines.append(f"\n{Colors.BOLD}CUSTOM_BUILD → Job Mapping:{Colors.RESET}")
|
|
65
|
+
custom_build_map = defaultdict(list)
|
|
66
|
+
for job_name, job in self.parser.jobs.items():
|
|
67
|
+
for rule in job.get('rules', []) or []:
|
|
68
|
+
if isinstance(rule, dict) and 'if' in rule:
|
|
69
|
+
matches = re.findall(r'CUSTOM_BUILD\s*==\s*"([^"]+)"', str(rule['if']))
|
|
70
|
+
for m in matches:
|
|
71
|
+
custom_build_map[m].append(job_name)
|
|
72
|
+
|
|
73
|
+
for cb_name in sorted(custom_build_map.keys()):
|
|
74
|
+
jobs = custom_build_map[cb_name]
|
|
75
|
+
lines.append(f"\n {Colors.CYAN}{cb_name}{Colors.RESET}:")
|
|
76
|
+
for j in sorted(set(jobs)):
|
|
77
|
+
lines.append(f" → {j}")
|
|
78
|
+
|
|
79
|
+
return '\n'.join(lines)
|