ci-time-tracker 0.2.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.
- ci_time_tracker/__init__.py +3 -0
- ci_time_tracker/__main__.py +12 -0
- ci_time_tracker/analyzer.py +444 -0
- ci_time_tracker/cache.py +113 -0
- ci_time_tracker/cli.py +664 -0
- ci_time_tracker/config_parser.py +484 -0
- ci_time_tracker/detection.py +234 -0
- ci_time_tracker/gates.py +108 -0
- ci_time_tracker/github_api.py +624 -0
- ci_time_tracker/impact.py +361 -0
- ci_time_tracker/log_parser.py +728 -0
- ci_time_tracker/models.py +277 -0
- ci_time_tracker/pricing.py +202 -0
- ci_time_tracker/reporter.py +577 -0
- ci_time_tracker-0.2.0.dist-info/METADATA +561 -0
- ci_time_tracker-0.2.0.dist-info/RECORD +20 -0
- ci_time_tracker-0.2.0.dist-info/WHEEL +5 -0
- ci_time_tracker-0.2.0.dist-info/entry_points.txt +2 -0
- ci_time_tracker-0.2.0.dist-info/licenses/LICENSE +21 -0
- ci_time_tracker-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Configuration parser for CI/CD pipeline files.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to parse CI configuration files from
|
|
4
|
+
various providers (GitHub Actions, GitLab CI, CircleCI) and extract
|
|
5
|
+
pipeline step information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from ci_time_tracker.models import PipelineConfig, PipelineStep
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigParseError(Exception):
|
|
16
|
+
"""Raised when configuration parsing fails."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, line: int | None = None, details: str | None = None):
|
|
19
|
+
self.message = message
|
|
20
|
+
self.line = line
|
|
21
|
+
self.details = details
|
|
22
|
+
super().__init__(self._format_message())
|
|
23
|
+
|
|
24
|
+
def _format_message(self) -> str:
|
|
25
|
+
parts = [f"Error: CONFIG_PARSE_ERROR - {self.message}"]
|
|
26
|
+
if self.line is not None:
|
|
27
|
+
parts.append(f" Line: {self.line}")
|
|
28
|
+
if self.details:
|
|
29
|
+
parts.append(f" Details: {self.details}")
|
|
30
|
+
return "\n".join(parts)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnsupportedProviderError(Exception):
|
|
34
|
+
"""Raised when CI provider cannot be detected or is unsupported."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str):
|
|
37
|
+
self.message = message
|
|
38
|
+
super().__init__(f"Error: UNSUPPORTED_PROVIDER - {message}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_provider(content: str) -> str:
|
|
42
|
+
"""Auto-detect CI provider from configuration content.
|
|
43
|
+
|
|
44
|
+
Analyzes the structure and keys in the YAML/JSON content to determine
|
|
45
|
+
which CI provider the configuration belongs to.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
content: Raw configuration file content (YAML or JSON string)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Provider identifier: 'github', 'gitlab', or 'circleci'
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
UnsupportedProviderError: If provider cannot be detected
|
|
55
|
+
ConfigParseError: If content cannot be parsed as YAML/JSON
|
|
56
|
+
"""
|
|
57
|
+
if not content or not content.strip():
|
|
58
|
+
raise ConfigParseError("Empty configuration content")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
data = yaml.safe_load(content)
|
|
62
|
+
except yaml.YAMLError as e:
|
|
63
|
+
line = None
|
|
64
|
+
if hasattr(e, 'problem_mark') and e.problem_mark:
|
|
65
|
+
line = e.problem_mark.line + 1
|
|
66
|
+
raise ConfigParseError(
|
|
67
|
+
"Invalid YAML syntax",
|
|
68
|
+
line=line,
|
|
69
|
+
details=str(e)
|
|
70
|
+
) from e
|
|
71
|
+
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
raise ConfigParseError(
|
|
74
|
+
"Configuration must be a YAML/JSON object",
|
|
75
|
+
details=f"Got {type(data).__name__} instead"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# CircleCI detection FIRST: has 'version' key with numeric value
|
|
79
|
+
# CircleCI jobs have 'docker', 'machine', or 'executor' keys
|
|
80
|
+
if 'version' in data:
|
|
81
|
+
if 'jobs' in data:
|
|
82
|
+
jobs = data['jobs']
|
|
83
|
+
if isinstance(jobs, dict):
|
|
84
|
+
for job in jobs.values():
|
|
85
|
+
if isinstance(job, dict):
|
|
86
|
+
# CircleCI-specific executor keys
|
|
87
|
+
if any(k in job for k in ['docker', 'machine', 'executor', 'macos']):
|
|
88
|
+
return 'circleci'
|
|
89
|
+
# CircleCI steps are a list with 'checkout' string or 'run' dicts
|
|
90
|
+
if 'steps' in job and isinstance(job['steps'], list):
|
|
91
|
+
for step in job['steps']:
|
|
92
|
+
if isinstance(step, str) and step == 'checkout':
|
|
93
|
+
return 'circleci'
|
|
94
|
+
if isinstance(step, dict) and any(k in step for k in ['run', 'checkout', 'save_cache', 'restore_cache', 'persist_to_workspace', 'attach_workspace']):
|
|
95
|
+
return 'circleci'
|
|
96
|
+
if 'workflows' in data and isinstance(data.get('workflows'), dict):
|
|
97
|
+
# CircleCI workflows have version and jobs list
|
|
98
|
+
workflows = data['workflows']
|
|
99
|
+
for wf in workflows.values():
|
|
100
|
+
if isinstance(wf, dict) and 'jobs' in wf:
|
|
101
|
+
return 'circleci'
|
|
102
|
+
# If version is present with orbs, it's CircleCI
|
|
103
|
+
if 'orbs' in data:
|
|
104
|
+
return 'circleci'
|
|
105
|
+
|
|
106
|
+
# GitHub Actions detection: has 'jobs' key and 'on' trigger or 'runs-on'
|
|
107
|
+
if 'jobs' in data and isinstance(data.get('jobs'), dict):
|
|
108
|
+
# 'on' trigger is GitHub-specific
|
|
109
|
+
if 'on' in data:
|
|
110
|
+
return 'github'
|
|
111
|
+
jobs = data['jobs']
|
|
112
|
+
# 'runs-on' is GitHub-specific
|
|
113
|
+
for job in jobs.values():
|
|
114
|
+
if isinstance(job, dict) and 'runs-on' in job:
|
|
115
|
+
return 'github'
|
|
116
|
+
# Check if jobs have 'steps' arrays (GitHub Actions pattern)
|
|
117
|
+
for job in jobs.values():
|
|
118
|
+
if isinstance(job, dict) and 'steps' in job:
|
|
119
|
+
return 'github'
|
|
120
|
+
|
|
121
|
+
# GitLab CI detection: has 'stages' key or jobs with 'script' key
|
|
122
|
+
if 'stages' in data and isinstance(data.get('stages'), list):
|
|
123
|
+
return 'gitlab'
|
|
124
|
+
|
|
125
|
+
# GitLab jobs have 'script' key directly
|
|
126
|
+
for key, value in data.items():
|
|
127
|
+
if key.startswith('.'): # GitLab hidden jobs/templates
|
|
128
|
+
continue
|
|
129
|
+
if isinstance(value, dict) and 'script' in value:
|
|
130
|
+
return 'gitlab'
|
|
131
|
+
|
|
132
|
+
# If we have 'image' at top level, likely GitLab
|
|
133
|
+
if 'image' in data and isinstance(data.get('image'), (str, dict)):
|
|
134
|
+
# Check for GitLab-specific job patterns
|
|
135
|
+
for key, value in data.items():
|
|
136
|
+
if key not in ('image', 'variables', 'before_script', 'after_script', 'cache', 'services'):
|
|
137
|
+
if isinstance(value, dict):
|
|
138
|
+
return 'gitlab'
|
|
139
|
+
|
|
140
|
+
raise UnsupportedProviderError(
|
|
141
|
+
"Unable to detect CI provider from configuration. "
|
|
142
|
+
"Supported providers: GitHub Actions, GitLab CI, CircleCI"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_config(content: str, provider: str | None = None) -> PipelineConfig:
|
|
147
|
+
"""Parse CI configuration content and return structured pipeline data.
|
|
148
|
+
|
|
149
|
+
Main entry point for configuration parsing. Auto-detects the CI provider
|
|
150
|
+
if not specified, then delegates to the appropriate provider-specific parser.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
content: Raw configuration file content (YAML or JSON string)
|
|
154
|
+
provider: Optional provider hint ('github', 'gitlab', 'circleci').
|
|
155
|
+
If None, provider will be auto-detected.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
PipelineConfig containing extracted steps, stages, and metadata
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ConfigParseError: If content cannot be parsed
|
|
162
|
+
UnsupportedProviderError: If provider is unsupported or cannot be detected
|
|
163
|
+
"""
|
|
164
|
+
if not content or not content.strip():
|
|
165
|
+
raise ConfigParseError("Empty configuration content")
|
|
166
|
+
|
|
167
|
+
# Parse YAML first
|
|
168
|
+
try:
|
|
169
|
+
data = yaml.safe_load(content)
|
|
170
|
+
except yaml.YAMLError as e:
|
|
171
|
+
line = None
|
|
172
|
+
if hasattr(e, 'problem_mark') and e.problem_mark:
|
|
173
|
+
line = e.problem_mark.line + 1
|
|
174
|
+
raise ConfigParseError(
|
|
175
|
+
"Invalid YAML syntax",
|
|
176
|
+
line=line,
|
|
177
|
+
details=str(e)
|
|
178
|
+
) from e
|
|
179
|
+
|
|
180
|
+
if not isinstance(data, dict):
|
|
181
|
+
raise ConfigParseError(
|
|
182
|
+
"Configuration must be a YAML/JSON object",
|
|
183
|
+
details=f"Got {type(data).__name__} instead"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Detect provider if not specified
|
|
187
|
+
if provider is None:
|
|
188
|
+
provider = detect_provider(content)
|
|
189
|
+
|
|
190
|
+
# Normalize provider name
|
|
191
|
+
provider = provider.lower().strip()
|
|
192
|
+
|
|
193
|
+
# Route to appropriate parser
|
|
194
|
+
if provider == 'github':
|
|
195
|
+
return parse_github_actions(data)
|
|
196
|
+
elif provider == 'gitlab':
|
|
197
|
+
return parse_gitlab_ci(data)
|
|
198
|
+
elif provider == 'circleci':
|
|
199
|
+
return parse_circleci(data)
|
|
200
|
+
else:
|
|
201
|
+
raise UnsupportedProviderError(
|
|
202
|
+
f"Unsupported CI provider: '{provider}'. "
|
|
203
|
+
"Supported providers: github, gitlab, circleci"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_github_actions(data: dict[str, Any]) -> PipelineConfig:
|
|
208
|
+
"""Parse GitHub Actions workflow YAML.
|
|
209
|
+
|
|
210
|
+
Extracts jobs, steps, and their order from a GitHub Actions workflow file.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
data: Parsed YAML data dictionary
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
PipelineConfig with extracted GitHub Actions steps
|
|
217
|
+
"""
|
|
218
|
+
steps: list[PipelineStep] = []
|
|
219
|
+
stages: list[str] = []
|
|
220
|
+
metadata: dict[str, Any] = {}
|
|
221
|
+
|
|
222
|
+
# Extract workflow name
|
|
223
|
+
if 'name' in data:
|
|
224
|
+
metadata['workflow_name'] = data['name']
|
|
225
|
+
|
|
226
|
+
# Extract trigger events
|
|
227
|
+
if 'on' in data:
|
|
228
|
+
metadata['triggers'] = data['on']
|
|
229
|
+
|
|
230
|
+
# Process jobs
|
|
231
|
+
jobs = data.get('jobs', {})
|
|
232
|
+
if not isinstance(jobs, dict):
|
|
233
|
+
return PipelineConfig(provider='github', steps=steps, stages=stages, metadata=metadata)
|
|
234
|
+
|
|
235
|
+
for job_name, job_config in jobs.items():
|
|
236
|
+
if not isinstance(job_config, dict):
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
# Job name becomes a stage
|
|
240
|
+
stages.append(job_name)
|
|
241
|
+
|
|
242
|
+
# Extract job-level metadata
|
|
243
|
+
if 'runs-on' in job_config:
|
|
244
|
+
metadata.setdefault('runners', {})[job_name] = job_config['runs-on']
|
|
245
|
+
|
|
246
|
+
# Process steps within the job
|
|
247
|
+
job_steps = job_config.get('steps', [])
|
|
248
|
+
if not isinstance(job_steps, list):
|
|
249
|
+
continue
|
|
250
|
+
|
|
251
|
+
for idx, step in enumerate(job_steps):
|
|
252
|
+
if not isinstance(step, dict):
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Determine step name
|
|
256
|
+
step_name = step.get('name')
|
|
257
|
+
if not step_name:
|
|
258
|
+
# Use 'uses' action name or 'run' command as fallback
|
|
259
|
+
if 'uses' in step:
|
|
260
|
+
step_name = step['uses']
|
|
261
|
+
elif 'run' in step:
|
|
262
|
+
# Use first line of run command, truncated
|
|
263
|
+
run_cmd = step['run']
|
|
264
|
+
if isinstance(run_cmd, str):
|
|
265
|
+
first_line = run_cmd.split('\n')[0][:50]
|
|
266
|
+
step_name = f"run: {first_line}"
|
|
267
|
+
else:
|
|
268
|
+
step_name = f"step_{idx + 1}"
|
|
269
|
+
|
|
270
|
+
# Extract command
|
|
271
|
+
command = None
|
|
272
|
+
if 'run' in step:
|
|
273
|
+
command = step['run']
|
|
274
|
+
elif 'uses' in step:
|
|
275
|
+
command = step['uses']
|
|
276
|
+
|
|
277
|
+
steps.append(PipelineStep(
|
|
278
|
+
name=step_name,
|
|
279
|
+
stage=job_name,
|
|
280
|
+
command=command,
|
|
281
|
+
estimated_duration=None
|
|
282
|
+
))
|
|
283
|
+
|
|
284
|
+
return PipelineConfig(
|
|
285
|
+
provider='github',
|
|
286
|
+
steps=steps,
|
|
287
|
+
stages=stages,
|
|
288
|
+
metadata=metadata
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def parse_gitlab_ci(data: dict[str, Any]) -> PipelineConfig:
|
|
293
|
+
"""Parse GitLab CI YAML.
|
|
294
|
+
|
|
295
|
+
Extracts stages, jobs, and script commands from a GitLab CI configuration.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
data: Parsed YAML data dictionary
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
PipelineConfig with extracted GitLab CI steps
|
|
302
|
+
"""
|
|
303
|
+
steps: list[PipelineStep] = []
|
|
304
|
+
metadata: dict[str, Any] = {}
|
|
305
|
+
|
|
306
|
+
# Extract stages (or use default)
|
|
307
|
+
stages = data.get('stages', [])
|
|
308
|
+
if not isinstance(stages, list):
|
|
309
|
+
stages = []
|
|
310
|
+
|
|
311
|
+
# Extract global image
|
|
312
|
+
if 'image' in data:
|
|
313
|
+
metadata['image'] = data['image']
|
|
314
|
+
|
|
315
|
+
# Extract global variables
|
|
316
|
+
if 'variables' in data:
|
|
317
|
+
metadata['variables'] = data['variables']
|
|
318
|
+
|
|
319
|
+
# Reserved GitLab CI keys that are not jobs
|
|
320
|
+
reserved_keys = {
|
|
321
|
+
'stages', 'image', 'variables', 'before_script', 'after_script',
|
|
322
|
+
'cache', 'services', 'include', 'default', 'workflow'
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Process jobs
|
|
326
|
+
for key, value in data.items():
|
|
327
|
+
# Skip reserved keys and hidden jobs (starting with .)
|
|
328
|
+
if key in reserved_keys or key.startswith('.'):
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
if not isinstance(value, dict):
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
job_name = key
|
|
335
|
+
job_config = value
|
|
336
|
+
|
|
337
|
+
# Determine stage for this job
|
|
338
|
+
job_stage = job_config.get('stage')
|
|
339
|
+
if job_stage and job_stage not in stages:
|
|
340
|
+
stages.append(job_stage)
|
|
341
|
+
|
|
342
|
+
# Extract script commands as steps
|
|
343
|
+
script = job_config.get('script', [])
|
|
344
|
+
if isinstance(script, str):
|
|
345
|
+
script = [script]
|
|
346
|
+
|
|
347
|
+
if isinstance(script, list):
|
|
348
|
+
for idx, cmd in enumerate(script):
|
|
349
|
+
if not isinstance(cmd, str):
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
# Create step name from job name and command index
|
|
353
|
+
if len(script) == 1:
|
|
354
|
+
step_name = job_name
|
|
355
|
+
else:
|
|
356
|
+
step_name = f"{job_name}[{idx}]"
|
|
357
|
+
|
|
358
|
+
steps.append(PipelineStep(
|
|
359
|
+
name=step_name,
|
|
360
|
+
stage=job_stage,
|
|
361
|
+
command=cmd,
|
|
362
|
+
estimated_duration=None
|
|
363
|
+
))
|
|
364
|
+
|
|
365
|
+
# If no script but job exists, add job as a step
|
|
366
|
+
if not script and 'extends' not in job_config:
|
|
367
|
+
steps.append(PipelineStep(
|
|
368
|
+
name=job_name,
|
|
369
|
+
stage=job_stage,
|
|
370
|
+
command=None,
|
|
371
|
+
estimated_duration=None
|
|
372
|
+
))
|
|
373
|
+
|
|
374
|
+
return PipelineConfig(
|
|
375
|
+
provider='gitlab',
|
|
376
|
+
steps=steps,
|
|
377
|
+
stages=stages,
|
|
378
|
+
metadata=metadata
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def parse_circleci(data: dict[str, Any]) -> PipelineConfig:
|
|
383
|
+
"""Parse CircleCI configuration.
|
|
384
|
+
|
|
385
|
+
Extracts jobs, steps, and executor information from a CircleCI config.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
data: Parsed YAML data dictionary
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
PipelineConfig with extracted CircleCI steps
|
|
392
|
+
"""
|
|
393
|
+
steps: list[PipelineStep] = []
|
|
394
|
+
stages: list[str] = []
|
|
395
|
+
metadata: dict[str, Any] = {}
|
|
396
|
+
|
|
397
|
+
# Extract version
|
|
398
|
+
if 'version' in data:
|
|
399
|
+
metadata['version'] = data['version']
|
|
400
|
+
|
|
401
|
+
# Extract orbs
|
|
402
|
+
if 'orbs' in data:
|
|
403
|
+
metadata['orbs'] = data['orbs']
|
|
404
|
+
|
|
405
|
+
# Extract executors
|
|
406
|
+
if 'executors' in data:
|
|
407
|
+
metadata['executors'] = data['executors']
|
|
408
|
+
|
|
409
|
+
# Process jobs
|
|
410
|
+
jobs = data.get('jobs', {})
|
|
411
|
+
if isinstance(jobs, dict):
|
|
412
|
+
for job_name, job_config in jobs.items():
|
|
413
|
+
if not isinstance(job_config, dict):
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
# Job name becomes a stage
|
|
417
|
+
stages.append(job_name)
|
|
418
|
+
|
|
419
|
+
# Extract executor info
|
|
420
|
+
for exec_key in ['docker', 'machine', 'macos', 'executor']:
|
|
421
|
+
if exec_key in job_config:
|
|
422
|
+
metadata.setdefault('job_executors', {})[job_name] = {
|
|
423
|
+
'type': exec_key,
|
|
424
|
+
'config': job_config[exec_key]
|
|
425
|
+
}
|
|
426
|
+
break
|
|
427
|
+
|
|
428
|
+
# Process steps within the job
|
|
429
|
+
job_steps = job_config.get('steps', [])
|
|
430
|
+
if not isinstance(job_steps, list):
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
for idx, step in enumerate(job_steps):
|
|
434
|
+
step_name = None
|
|
435
|
+
command = None
|
|
436
|
+
|
|
437
|
+
if isinstance(step, str):
|
|
438
|
+
# Simple step like 'checkout'
|
|
439
|
+
step_name = step
|
|
440
|
+
command = step
|
|
441
|
+
elif isinstance(step, dict):
|
|
442
|
+
# Complex step
|
|
443
|
+
if 'run' in step:
|
|
444
|
+
run_config = step['run']
|
|
445
|
+
if isinstance(run_config, str):
|
|
446
|
+
step_name = run_config[:50]
|
|
447
|
+
command = run_config
|
|
448
|
+
elif isinstance(run_config, dict):
|
|
449
|
+
step_name = run_config.get('name', f"run_{idx + 1}")
|
|
450
|
+
command = run_config.get('command')
|
|
451
|
+
else:
|
|
452
|
+
# Other step types (checkout, save_cache, etc.)
|
|
453
|
+
step_type = list(step.keys())[0] if step else f"step_{idx + 1}"
|
|
454
|
+
step_name = step_type
|
|
455
|
+
command = str(step.get(step_type, ''))
|
|
456
|
+
|
|
457
|
+
if step_name is None:
|
|
458
|
+
step_name = f"step_{idx + 1}"
|
|
459
|
+
|
|
460
|
+
steps.append(PipelineStep(
|
|
461
|
+
name=step_name,
|
|
462
|
+
stage=job_name,
|
|
463
|
+
command=command,
|
|
464
|
+
estimated_duration=None
|
|
465
|
+
))
|
|
466
|
+
|
|
467
|
+
# Process workflows to understand job ordering
|
|
468
|
+
workflows = data.get('workflows', {})
|
|
469
|
+
if isinstance(workflows, dict):
|
|
470
|
+
workflow_info = {}
|
|
471
|
+
for wf_name, wf_config in workflows.items():
|
|
472
|
+
if wf_name == 'version':
|
|
473
|
+
continue
|
|
474
|
+
if isinstance(wf_config, dict) and 'jobs' in wf_config:
|
|
475
|
+
workflow_info[wf_name] = wf_config['jobs']
|
|
476
|
+
if workflow_info:
|
|
477
|
+
metadata['workflows'] = workflow_info
|
|
478
|
+
|
|
479
|
+
return PipelineConfig(
|
|
480
|
+
provider='circleci',
|
|
481
|
+
steps=steps,
|
|
482
|
+
stages=stages,
|
|
483
|
+
metadata=metadata
|
|
484
|
+
)
|