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.
@@ -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
+ )