mcp-souschef 2.1.2__py3-none-any.whl → 2.5.3__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,11 @@
1
+ """CI/CD pipeline generation from Chef patterns."""
2
+
3
+ from souschef.ci.github_actions import generate_github_workflow_from_chef_ci
4
+ from souschef.ci.gitlab_ci import generate_gitlab_ci_from_chef_ci
5
+ from souschef.ci.jenkins_pipeline import generate_jenkinsfile_from_chef_ci
6
+
7
+ __all__ = [
8
+ "generate_jenkinsfile_from_chef_ci",
9
+ "generate_gitlab_ci_from_chef_ci",
10
+ "generate_github_workflow_from_chef_ci",
11
+ ]
@@ -0,0 +1,379 @@
1
+ """
2
+ GitHub Actions workflow generation from Chef CI/CD patterns.
3
+
4
+ Analyzes Chef testing tools (Test Kitchen, ChefSpec, Cookstyle) and
5
+ generates equivalent GitHub Actions workflows with proper job
6
+ configuration and caching.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import yaml
13
+
14
+ # GitHub Actions constants
15
+ ACTION_CHECKOUT = "actions/checkout@v4"
16
+ ACTION_SETUP_RUBY = "ruby/setup-ruby@v1"
17
+ ACTION_CACHE = "actions/cache@v4"
18
+ ACTION_UPLOAD_ARTIFACT = "actions/upload-artifact@v4"
19
+
20
+ STEP_NAME_CHECKOUT = "Checkout code"
21
+ STEP_NAME_SETUP_RUBY = "Setup Ruby"
22
+ STEP_NAME_CACHE_GEMS = "Cache gems"
23
+ STEP_NAME_INSTALL_DEPS = "Install dependencies"
24
+
25
+ GEM_BUNDLE_PATH = "vendor/bundle"
26
+ GEM_CACHE_KEY = "gems-${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}"
27
+ GEM_CACHE_RESTORE_KEY = "gems-${{ runner.os }}-"
28
+
29
+ BUNDLE_INSTALL_CMD = "bundle install --jobs 4 --retry 3"
30
+
31
+
32
+ def generate_github_workflow_from_chef_ci(
33
+ cookbook_path: str,
34
+ workflow_name: str = "Chef Cookbook CI",
35
+ enable_cache: bool = True,
36
+ enable_artifacts: bool = True,
37
+ ) -> str:
38
+ """
39
+ Generate GitHub Actions workflow from Chef cookbook CI/CD patterns.
40
+
41
+ Args:
42
+ cookbook_path: Path to Chef cookbook directory.
43
+ workflow_name: Name for the GitHub Actions workflow.
44
+ enable_cache: Enable caching for Chef dependencies.
45
+ enable_artifacts: Enable artifacts for test results.
46
+
47
+ Returns:
48
+ GitHub Actions workflow YAML content.
49
+
50
+ """
51
+ cookbook_dir = Path(cookbook_path)
52
+ if not cookbook_dir.exists():
53
+ raise FileNotFoundError(f"Cookbook directory not found: {cookbook_path}")
54
+
55
+ # Analyze Chef CI patterns
56
+ patterns = _analyze_chef_ci_patterns(cookbook_dir)
57
+
58
+ # Build workflow structure
59
+ workflow = _build_workflow_structure(
60
+ workflow_name, patterns, enable_cache, enable_artifacts
61
+ )
62
+
63
+ return yaml.dump(workflow, default_flow_style=False, sort_keys=False)
64
+
65
+
66
+ def _analyze_chef_ci_patterns(cookbook_dir: Path) -> dict[str, Any]:
67
+ """
68
+ Analyze Chef cookbook for CI/CD patterns and testing configurations.
69
+
70
+ This function examines a Chef cookbook directory to detect various
71
+ testing and linting tools, as well as Test Kitchen configurations
72
+ including suites and platforms.
73
+
74
+ Args:
75
+ cookbook_dir: Path to the Chef cookbook directory to analyze.
76
+
77
+ Returns:
78
+ Dictionary containing detected patterns with the following keys:
79
+ - has_kitchen (bool): Whether Test Kitchen is configured
80
+ (.kitchen.yml exists)
81
+ - has_chefspec (bool): Whether ChefSpec tests are present
82
+ (spec/**/*_spec.rb files)
83
+ - has_cookstyle (bool): Whether Cookstyle is configured
84
+ (.cookstyle.yml exists)
85
+ - has_foodcritic (bool): Whether Foodcritic (legacy) is
86
+ configured (.foodcritic exists)
87
+ - kitchen_suites (list[str]): Names of Test Kitchen suites
88
+ found in .kitchen.yml
89
+ - kitchen_platforms (list[str]): Names of Test Kitchen
90
+ platforms found in .kitchen.yml
91
+
92
+ Note:
93
+ If .kitchen.yml is malformed or cannot be parsed, the function
94
+ continues with empty suite and platform lists rather than
95
+ raising an exception.
96
+
97
+ Example:
98
+ >>> patterns = _analyze_chef_ci_patterns(Path("/path/to/cookbook"))
99
+ >>> patterns["has_kitchen"]
100
+ True
101
+ >>> patterns["kitchen_suites"]
102
+ ['default', 'integration']
103
+
104
+ """
105
+ patterns: dict[str, Any] = {
106
+ "has_kitchen": False,
107
+ "has_chefspec": False,
108
+ "has_cookstyle": False,
109
+ "has_foodcritic": False,
110
+ "kitchen_suites": [],
111
+ "kitchen_platforms": [],
112
+ }
113
+
114
+ # Check for Test Kitchen
115
+ kitchen_yml = cookbook_dir / ".kitchen.yml"
116
+ if kitchen_yml.exists():
117
+ patterns["has_kitchen"] = True
118
+ try:
119
+ with kitchen_yml.open() as f:
120
+ kitchen_config = yaml.safe_load(f)
121
+ if kitchen_config:
122
+ # Extract suites
123
+ suites = kitchen_config.get("suites", [])
124
+ if suites:
125
+ patterns["kitchen_suites"] = [
126
+ s.get("name", "default") for s in suites
127
+ ]
128
+ # Extract platforms
129
+ platforms = kitchen_config.get("platforms", [])
130
+ if platforms:
131
+ patterns["kitchen_platforms"] = [
132
+ p.get("name", "unknown") for p in platforms
133
+ ]
134
+ except (yaml.YAMLError, OSError, KeyError, TypeError, AttributeError):
135
+ # Gracefully handle malformed .kitchen.yml - continue with empty config
136
+ # Catches: YAML syntax errors, file I/O errors, missing config keys,
137
+ # type mismatches in config structure, and missing dict attributes
138
+ pass
139
+
140
+ # Check for ChefSpec
141
+ spec_dir = cookbook_dir / "spec"
142
+ if spec_dir.exists() and any(spec_dir.glob("**/*_spec.rb")):
143
+ patterns["has_chefspec"] = True
144
+
145
+ # Check for Cookstyle
146
+ cookstyle_yml = cookbook_dir / ".cookstyle.yml"
147
+ if cookstyle_yml.exists():
148
+ patterns["has_cookstyle"] = True
149
+
150
+ # Check for Foodcritic (legacy)
151
+ if (cookbook_dir / ".foodcritic").exists():
152
+ patterns["has_foodcritic"] = True
153
+
154
+ return patterns
155
+
156
+
157
+ def _build_workflow_structure(
158
+ workflow_name: str,
159
+ patterns: dict[str, Any],
160
+ enable_cache: bool,
161
+ enable_artifacts: bool,
162
+ ) -> dict[str, Any]:
163
+ """
164
+ Build GitHub Actions workflow structure.
165
+
166
+ Args:
167
+ workflow_name: Workflow name.
168
+ patterns: Detected Chef CI patterns.
169
+ enable_cache: Enable caching.
170
+ enable_artifacts: Enable artifacts.
171
+
172
+ Returns:
173
+ Workflow dictionary structure.
174
+
175
+ """
176
+ workflow: dict[str, Any] = {
177
+ "name": workflow_name,
178
+ "on": {
179
+ "push": {"branches": ["main", "develop"]},
180
+ "pull_request": {"branches": ["main", "develop"]},
181
+ },
182
+ "jobs": {},
183
+ }
184
+
185
+ # Add lint job
186
+ if patterns["has_cookstyle"] or patterns["has_foodcritic"]:
187
+ workflow["jobs"]["lint"] = _build_lint_job(patterns, enable_cache)
188
+
189
+ # Add unit test job
190
+ if patterns["has_chefspec"]:
191
+ workflow["jobs"]["unit-test"] = _build_unit_test_job(enable_cache)
192
+
193
+ # Add integration test jobs
194
+ if patterns["has_kitchen"]:
195
+ workflow["jobs"]["integration-test"] = _build_integration_test_job(
196
+ patterns, enable_cache, enable_artifacts
197
+ )
198
+
199
+ return workflow
200
+
201
+
202
+ def _build_lint_job(patterns: dict[str, Any], enable_cache: bool) -> dict[str, Any]:
203
+ """
204
+ Build lint job configuration.
205
+
206
+ Args:
207
+ patterns: Detected CI patterns.
208
+ enable_cache: Enable caching.
209
+
210
+ Returns:
211
+ Lint job configuration.
212
+
213
+ """
214
+ job: dict[str, Any] = {
215
+ "name": "Lint Cookbook",
216
+ "runs-on": "ubuntu-latest",
217
+ "steps": [
218
+ {"name": STEP_NAME_CHECKOUT, "uses": ACTION_CHECKOUT},
219
+ {
220
+ "name": STEP_NAME_SETUP_RUBY,
221
+ "uses": ACTION_SETUP_RUBY,
222
+ "with": {"ruby-version": "3.2"},
223
+ },
224
+ ],
225
+ }
226
+
227
+ if enable_cache:
228
+ job["steps"].append(
229
+ {
230
+ "name": STEP_NAME_CACHE_GEMS,
231
+ "uses": ACTION_CACHE,
232
+ "with": {
233
+ "path": GEM_BUNDLE_PATH,
234
+ "key": GEM_CACHE_KEY,
235
+ "restore-keys": GEM_CACHE_RESTORE_KEY,
236
+ },
237
+ }
238
+ )
239
+
240
+ job["steps"].extend(
241
+ [
242
+ {
243
+ "name": STEP_NAME_INSTALL_DEPS,
244
+ "run": BUNDLE_INSTALL_CMD,
245
+ },
246
+ ]
247
+ )
248
+
249
+ # Add appropriate lint commands
250
+ if patterns["has_cookstyle"]:
251
+ job["steps"].append({"name": "Run Cookstyle", "run": "bundle exec cookstyle"})
252
+
253
+ if patterns["has_foodcritic"]:
254
+ job["steps"].append(
255
+ {"name": "Run Foodcritic", "run": "bundle exec foodcritic ."}
256
+ )
257
+
258
+ return job
259
+
260
+
261
+ def _build_unit_test_job(enable_cache: bool) -> dict[str, Any]:
262
+ """
263
+ Build unit test job configuration.
264
+
265
+ Args:
266
+ enable_cache: Enable caching.
267
+
268
+ Returns:
269
+ Unit test job configuration.
270
+
271
+ """
272
+ job: dict[str, Any] = {
273
+ "name": "Unit Tests (ChefSpec)",
274
+ "runs-on": "ubuntu-latest",
275
+ "steps": [
276
+ {"name": STEP_NAME_CHECKOUT, "uses": ACTION_CHECKOUT},
277
+ {
278
+ "name": STEP_NAME_SETUP_RUBY,
279
+ "uses": ACTION_SETUP_RUBY,
280
+ "with": {"ruby-version": "3.2"},
281
+ },
282
+ ],
283
+ }
284
+
285
+ if enable_cache:
286
+ job["steps"].append(
287
+ {
288
+ "name": STEP_NAME_CACHE_GEMS,
289
+ "uses": ACTION_CACHE,
290
+ "with": {
291
+ "path": GEM_BUNDLE_PATH,
292
+ "key": GEM_CACHE_KEY,
293
+ "restore-keys": GEM_CACHE_RESTORE_KEY,
294
+ },
295
+ }
296
+ )
297
+
298
+ job["steps"].extend(
299
+ [
300
+ {
301
+ "name": STEP_NAME_INSTALL_DEPS,
302
+ "run": BUNDLE_INSTALL_CMD,
303
+ },
304
+ {"name": "Run ChefSpec tests", "run": "bundle exec rspec"},
305
+ ]
306
+ )
307
+
308
+ return job
309
+
310
+
311
+ def _build_integration_test_job(
312
+ patterns: dict[str, Any], enable_cache: bool, enable_artifacts: bool
313
+ ) -> dict[str, Any]:
314
+ """
315
+ Build integration test job configuration.
316
+
317
+ Args:
318
+ patterns: Detected CI patterns.
319
+ enable_cache: Enable caching.
320
+ enable_artifacts: Enable artifacts.
321
+
322
+ Returns:
323
+ Integration test job configuration.
324
+
325
+ """
326
+ job: dict[str, Any] = {
327
+ "name": "Integration Tests (Test Kitchen)",
328
+ "runs-on": "ubuntu-latest",
329
+ "strategy": {"matrix": {"suite": patterns["kitchen_suites"] or ["default"]}},
330
+ "steps": [
331
+ {"name": STEP_NAME_CHECKOUT, "uses": ACTION_CHECKOUT},
332
+ {
333
+ "name": STEP_NAME_SETUP_RUBY,
334
+ "uses": ACTION_SETUP_RUBY,
335
+ "with": {"ruby-version": "3.2"},
336
+ },
337
+ ],
338
+ }
339
+
340
+ if enable_cache:
341
+ job["steps"].append(
342
+ {
343
+ "name": STEP_NAME_CACHE_GEMS,
344
+ "uses": ACTION_CACHE,
345
+ "with": {
346
+ "path": GEM_BUNDLE_PATH,
347
+ "key": GEM_CACHE_KEY,
348
+ "restore-keys": GEM_CACHE_RESTORE_KEY,
349
+ },
350
+ }
351
+ )
352
+
353
+ job["steps"].extend(
354
+ [
355
+ {
356
+ "name": STEP_NAME_INSTALL_DEPS,
357
+ "run": BUNDLE_INSTALL_CMD,
358
+ },
359
+ {
360
+ "name": "Run Test Kitchen",
361
+ "run": "bundle exec kitchen test ${{ matrix.suite }}",
362
+ },
363
+ ]
364
+ )
365
+
366
+ if enable_artifacts:
367
+ job["steps"].append(
368
+ {
369
+ "name": "Upload test results",
370
+ "uses": ACTION_UPLOAD_ARTIFACT,
371
+ "if": "always()",
372
+ "with": {
373
+ "name": "kitchen-logs-${{ matrix.suite }}",
374
+ "path": ".kitchen/logs/",
375
+ },
376
+ }
377
+ )
378
+
379
+ return job
@@ -0,0 +1,299 @@
1
+ """GitLab CI generation from Chef CI/CD patterns."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ def generate_gitlab_ci_from_chef_ci(
10
+ cookbook_path: str,
11
+ project_name: str,
12
+ enable_cache: bool = True,
13
+ enable_artifacts: bool = True,
14
+ ) -> str:
15
+ """
16
+ Generate .gitlab-ci.yml from Chef cookbook CI/CD patterns.
17
+
18
+ Analyzes Chef testing tools and generates equivalent GitLab CI stages.
19
+
20
+ Args:
21
+ cookbook_path: Path to Chef cookbook.
22
+ project_name: GitLab project name.
23
+ enable_cache: Enable caching for dependencies.
24
+ enable_artifacts: Enable artifacts for test results.
25
+
26
+ Returns:
27
+ GitLab CI YAML content.
28
+
29
+ """
30
+ # Analyze Chef CI patterns
31
+ ci_patterns = _analyze_chef_ci_patterns(cookbook_path)
32
+
33
+ # Generate CI configuration
34
+ return _generate_gitlab_ci_yaml(
35
+ project_name, ci_patterns, enable_cache, enable_artifacts
36
+ )
37
+
38
+
39
+ def _analyze_chef_ci_patterns(cookbook_path: str) -> dict[str, Any]:
40
+ """
41
+ Analyze Chef cookbook for CI/CD patterns.
42
+
43
+ Args:
44
+ cookbook_path: Path to Chef cookbook.
45
+
46
+ Returns:
47
+ Dictionary of detected CI patterns.
48
+
49
+ """
50
+ base_path = Path(cookbook_path)
51
+
52
+ patterns: dict[str, Any] = {
53
+ "has_kitchen": (base_path / ".kitchen.yml").exists(),
54
+ "has_chefspec": (base_path / "spec").exists(),
55
+ "has_inspec": (base_path / "test" / "integration").exists(),
56
+ "has_berksfile": (base_path / "Berksfile").exists(),
57
+ "lint_tools": [],
58
+ "test_suites": [],
59
+ }
60
+
61
+ # Detect linting tools
62
+ lint_tools: list[str] = patterns["lint_tools"]
63
+ if (base_path / ".foodcritic").exists():
64
+ lint_tools.append("foodcritic")
65
+ if (base_path / ".cookstyle.yml").exists():
66
+ lint_tools.append("cookstyle")
67
+
68
+ # Parse kitchen.yml for test suites
69
+ kitchen_file = base_path / ".kitchen.yml"
70
+ if kitchen_file.exists():
71
+ try:
72
+ test_suites: list[str] = patterns["test_suites"]
73
+ with kitchen_file.open() as f:
74
+ kitchen_config = yaml.safe_load(f)
75
+ if kitchen_config and "suites" in kitchen_config:
76
+ test_suites.extend(
77
+ suite["name"] for suite in kitchen_config["suites"]
78
+ )
79
+ except (yaml.YAMLError, OSError, KeyError, TypeError, AttributeError):
80
+ # Gracefully handle malformed .kitchen.yml - continue with empty
81
+ # test suites. Catches: YAML syntax errors, file I/O errors,
82
+ # missing config keys, type mismatches
83
+ pass
84
+
85
+ return patterns
86
+
87
+
88
+ def _build_lint_jobs(ci_patterns: dict[str, Any], enable_artifacts: bool) -> list[str]:
89
+ """Build lint job configurations."""
90
+ jobs = []
91
+ if ci_patterns.get("lint_tools"):
92
+ lint_commands = []
93
+ for tool in ci_patterns["lint_tools"]:
94
+ if tool == "cookstyle":
95
+ lint_commands.append(" - ansible-lint playbooks/")
96
+ elif tool == "foodcritic":
97
+ lint_commands.append(" - yamllint -c .yamllint .")
98
+
99
+ if lint_commands:
100
+ jobs.append(
101
+ _create_gitlab_job(
102
+ "lint:ansible",
103
+ "lint",
104
+ lint_commands,
105
+ allow_failure=False,
106
+ artifacts=enable_artifacts,
107
+ )
108
+ )
109
+ return jobs
110
+
111
+
112
+ def _build_test_jobs(ci_patterns: dict[str, Any], enable_artifacts: bool) -> list[str]:
113
+ """Build test job configurations."""
114
+ jobs = []
115
+
116
+ # Unit test job (ChefSpec → Molecule)
117
+ if ci_patterns.get("has_chefspec"):
118
+ jobs.append(
119
+ _create_gitlab_job(
120
+ "test:unit",
121
+ "test",
122
+ [" - molecule test --scenario-name default"],
123
+ artifacts=enable_artifacts,
124
+ )
125
+ )
126
+
127
+ # Integration test jobs (Kitchen → Molecule)
128
+ if ci_patterns.get("has_kitchen") or ci_patterns.get("has_inspec"):
129
+ if ci_patterns.get("test_suites"):
130
+ for suite in ci_patterns["test_suites"]:
131
+ jobs.append(
132
+ _create_gitlab_job(
133
+ f"test:integration:{suite}",
134
+ "test",
135
+ [f" - molecule test --scenario-name {suite}"],
136
+ artifacts=enable_artifacts,
137
+ )
138
+ )
139
+ else:
140
+ jobs.append(
141
+ _create_gitlab_job(
142
+ "test:integration",
143
+ "test",
144
+ [" - molecule test"],
145
+ artifacts=enable_artifacts,
146
+ )
147
+ )
148
+
149
+ return jobs
150
+
151
+
152
+ def _generate_gitlab_ci_yaml(
153
+ project_name: str,
154
+ ci_patterns: dict[str, Any],
155
+ enable_cache: bool,
156
+ enable_artifacts: bool,
157
+ ) -> str:
158
+ """
159
+ Generate GitLab CI YAML configuration.
160
+
161
+ Args:
162
+ project_name: Project name.
163
+ ci_patterns: Detected CI patterns.
164
+ enable_cache: Enable caching.
165
+ enable_artifacts: Enable artifacts.
166
+
167
+ Returns:
168
+ GitLab CI YAML content.
169
+
170
+ """
171
+ stages = ["lint", "test", "deploy"]
172
+ jobs = []
173
+
174
+ # Global configuration
175
+ config_lines = [
176
+ f"# .gitlab-ci.yml: {project_name}",
177
+ "# Generated from Chef cookbook CI/CD patterns",
178
+ "",
179
+ "image: python:3.11",
180
+ "",
181
+ "stages:",
182
+ ]
183
+ for stage in stages:
184
+ config_lines.append(f" - {stage}")
185
+ config_lines.append("")
186
+
187
+ # Cache configuration
188
+ if enable_cache:
189
+ config_lines.extend(
190
+ [
191
+ "cache:",
192
+ " paths:",
193
+ " - .cache/pip",
194
+ " - venv/",
195
+ "",
196
+ ]
197
+ )
198
+
199
+ # Variables
200
+ config_lines.extend(
201
+ [
202
+ "variables:",
203
+ " PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip",
204
+ " ANSIBLE_FORCE_COLOR: 'true'",
205
+ "",
206
+ ]
207
+ )
208
+
209
+ # Before script
210
+ config_lines.extend(
211
+ [
212
+ "before_script:",
213
+ " - python -m venv venv",
214
+ " - source venv/bin/activate",
215
+ " - pip install --upgrade pip",
216
+ " - pip install ansible ansible-lint molecule molecule-docker",
217
+ "",
218
+ ]
219
+ )
220
+
221
+ # Build jobs
222
+ jobs.extend(_build_lint_jobs(ci_patterns, enable_artifacts))
223
+ jobs.extend(_build_test_jobs(ci_patterns, enable_artifacts))
224
+
225
+ # Deploy job
226
+ jobs.append(
227
+ _create_gitlab_job(
228
+ "deploy:production",
229
+ "deploy",
230
+ [
231
+ (
232
+ " - ansible-playbook -i inventory/production "
233
+ "playbooks/site.yml --check"
234
+ ),
235
+ " - ansible-playbook -i inventory/production playbooks/site.yml",
236
+ ],
237
+ when="manual",
238
+ only_branches=["main", "master"],
239
+ )
240
+ )
241
+
242
+ # Combine configuration and jobs
243
+ config = "\n".join(config_lines) + "\n" + "\n".join(jobs)
244
+ return config
245
+
246
+
247
+ def _create_gitlab_job(
248
+ name: str,
249
+ stage: str,
250
+ script: list[str],
251
+ allow_failure: bool = False,
252
+ artifacts: bool = False,
253
+ when: str | None = None,
254
+ only_branches: list[str] | None = None,
255
+ ) -> str:
256
+ """
257
+ Create a GitLab CI job.
258
+
259
+ Args:
260
+ name: Job name.
261
+ stage: Stage name.
262
+ script: List of script commands.
263
+ allow_failure: Allow job failure.
264
+ artifacts: Enable artifacts.
265
+ when: When to run (manual, on_success, etc).
266
+ only_branches: Only run on specific branches.
267
+
268
+ Returns:
269
+ GitLab CI job YAML block.
270
+
271
+ """
272
+ job_lines = [f"{name}:", f" stage: {stage}", " script:"]
273
+ job_lines.extend(script)
274
+
275
+ if allow_failure:
276
+ job_lines.append(" allow_failure: true")
277
+
278
+ if artifacts:
279
+ job_lines.extend(
280
+ [
281
+ " artifacts:",
282
+ " reports:",
283
+ " junit: test-results/*.xml",
284
+ " paths:",
285
+ " - test-results/",
286
+ " expire_in: 1 week",
287
+ ]
288
+ )
289
+
290
+ if when:
291
+ job_lines.append(f" when: {when}")
292
+
293
+ if only_branches:
294
+ job_lines.append(" only:")
295
+ for branch in only_branches:
296
+ job_lines.append(f" - {branch}")
297
+
298
+ job_lines.append("")
299
+ return "\n".join(job_lines)