gitlab-compliance 2.0.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.
- gitlab_compliance-2.0.0.dist-info/METADATA +92 -0
- gitlab_compliance-2.0.0.dist-info/RECORD +50 -0
- gitlab_compliance-2.0.0.dist-info/WHEEL +4 -0
- gitlab_compliance-2.0.0.dist-info/entry_points.txt +4 -0
- gitlab_compliance-2.0.0.dist-info/licenses/LICENSE +21 -0
- src/__init__.py +1 -0
- src/compliance/__init__.py +1 -0
- src/compliance/api_config.py +94 -0
- src/compliance/api_enrichment.py +52 -0
- src/compliance/behave_support/environment.py +43 -0
- src/compliance/behave_support/steps/__init__.py +0 -0
- src/compliance/behave_support/steps/given_steps.py +154 -0
- src/compliance/behave_support/steps/then_steps.py +234 -0
- src/compliance/behave_support/steps/when_steps.py +168 -0
- src/compliance/console.py +95 -0
- src/compliance/container_fix.py +135 -0
- src/compliance/gitlab_api.py +94 -0
- src/compliance/image_versions.py +316 -0
- src/compliance/include_fix.py +112 -0
- src/compliance/include_versions.py +250 -0
- src/compliance/metadata.py +245 -0
- src/compliance/model.py +277 -0
- src/compliance/models.py +29 -0
- src/compliance/oci_registry.py +147 -0
- src/compliance/policy_doc.py +177 -0
- src/compliance/release_cache.py +51 -0
- src/compliance/render.py +360 -0
- src/compliance/runner.py +310 -0
- src/compliance/stash.py +203 -0
- src/compliance/supply_chain_fix.py +69 -0
- src/gitlab_compliance.py +636 -0
- src/gitlab_docs.py +40 -0
- src/modules/__init__.py +1 -0
- src/modules/command_reference.py +220 -0
- src/modules/common.py +144 -0
- src/modules/constants.py +22 -0
- src/modules/doc_controller.py +178 -0
- src/modules/logging.py +36 -0
- src/modules/pipeline_data.py +364 -0
- src/modules/release.py +343 -0
- src/modules/swagger_html.py +408 -0
- src/modules/yaml_lines.py +70 -0
- src/modules/yaml_md_table.py +29 -0
- src/properties/__init__.py +0 -0
- src/properties/extract_job_attribute.py +76 -0
- src/properties/includes.py +178 -0
- src/properties/inputs.py +86 -0
- src/properties/jobs.py +167 -0
- src/properties/variables.py +91 -0
- src/properties/workflows.py +54 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Load GitLab project/group settings via API for compliance checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
PROJECT_SETTING_KEYS = [
|
|
9
|
+
"public_jobs",
|
|
10
|
+
"auto_devops_enabled",
|
|
11
|
+
"builds_access_level",
|
|
12
|
+
"forking_access_level",
|
|
13
|
+
"merge_requests_enabled",
|
|
14
|
+
"jobs_enabled",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _setting_entity(setting_type: str, name: str, value: Any) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"address": f"{setting_type}.{name}",
|
|
21
|
+
"type": setting_type,
|
|
22
|
+
"name": name,
|
|
23
|
+
"value": value,
|
|
24
|
+
"values": {"value": value},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ci_variable_entity(variable: Any) -> dict:
|
|
29
|
+
return {
|
|
30
|
+
"address": f"project_ci_variable.{variable.key}",
|
|
31
|
+
"type": "project_ci_variable",
|
|
32
|
+
"name": variable.key,
|
|
33
|
+
"key": variable.key,
|
|
34
|
+
"value": "***",
|
|
35
|
+
"protected": getattr(variable, "protected", False),
|
|
36
|
+
"masked": getattr(variable, "masked", False),
|
|
37
|
+
"values": {
|
|
38
|
+
"key": variable.key,
|
|
39
|
+
"protected": getattr(variable, "protected", False),
|
|
40
|
+
"masked": getattr(variable, "masked", False),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_api_entities(
|
|
46
|
+
gitlab_url: str | None = None,
|
|
47
|
+
token: str | None = None,
|
|
48
|
+
project: str | None = None,
|
|
49
|
+
group: str | None = None,
|
|
50
|
+
gl: Any | None = None,
|
|
51
|
+
) -> dict[str, list[dict]]:
|
|
52
|
+
gitlab_url = (
|
|
53
|
+
gitlab_url
|
|
54
|
+
or os.getenv("CI_SERVER_URL")
|
|
55
|
+
or os.getenv("GITLAB_URL")
|
|
56
|
+
or "https://gitlab.com"
|
|
57
|
+
)
|
|
58
|
+
token = token or os.getenv("GITLAB_TOKEN") or os.getenv("CI_JOB_TOKEN")
|
|
59
|
+
|
|
60
|
+
if not token:
|
|
61
|
+
raise ValueError("GitLab API token required. Set --token or GITLAB_TOKEN.")
|
|
62
|
+
|
|
63
|
+
if gl is None:
|
|
64
|
+
import gitlab
|
|
65
|
+
|
|
66
|
+
gl = gitlab.Gitlab(gitlab_url, private_token=token)
|
|
67
|
+
gl.auth()
|
|
68
|
+
|
|
69
|
+
entities: dict[str, list[dict]] = {
|
|
70
|
+
"project_settings": [],
|
|
71
|
+
"project_ci_variables": [],
|
|
72
|
+
"group_settings": [],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if project:
|
|
76
|
+
project_obj = gl.projects.get(project)
|
|
77
|
+
for key in PROJECT_SETTING_KEYS:
|
|
78
|
+
if hasattr(project_obj, key):
|
|
79
|
+
entities["project_settings"].append(
|
|
80
|
+
_setting_entity("project_setting", key, getattr(project_obj, key))
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
for variable in project_obj.variables.list(all=True):
|
|
84
|
+
entities["project_ci_variables"].append(_ci_variable_entity(variable))
|
|
85
|
+
|
|
86
|
+
if group:
|
|
87
|
+
group_obj = gl.groups.get(group)
|
|
88
|
+
for attr in ("shared_runners_enabled", "request_access_enabled"):
|
|
89
|
+
if hasattr(group_obj, attr):
|
|
90
|
+
entities["group_settings"].append(
|
|
91
|
+
_setting_entity("group_setting", attr, getattr(group_obj, attr))
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return entities
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Container image version validation and registry metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import semver
|
|
10
|
+
|
|
11
|
+
from src.compliance.include_versions import (
|
|
12
|
+
_semver_tags_descending,
|
|
13
|
+
compute_version_tag_rank,
|
|
14
|
+
is_valid_semver_version,
|
|
15
|
+
)
|
|
16
|
+
from src.compliance.release_cache import ReleaseMetadataCache
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_container_image_ref(image: str) -> dict[str, str]:
|
|
20
|
+
image = str(image or "").strip()
|
|
21
|
+
if not image:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
digest = ""
|
|
25
|
+
repository_part = image
|
|
26
|
+
if "@sha256:" in image:
|
|
27
|
+
repository_part, digest_value = image.split("@sha256:", 1)
|
|
28
|
+
digest = digest_value.strip()
|
|
29
|
+
|
|
30
|
+
registry = "registry-1.docker.io"
|
|
31
|
+
repository = repository_part
|
|
32
|
+
tag = "latest"
|
|
33
|
+
|
|
34
|
+
if "/" in repository_part and "." in repository_part.split("/", 1)[0]:
|
|
35
|
+
registry, repository = repository_part.split("/", 1)
|
|
36
|
+
|
|
37
|
+
if ":" in repository:
|
|
38
|
+
repository, tag = repository.rsplit(":", 1)
|
|
39
|
+
|
|
40
|
+
if "/" not in repository and registry in {
|
|
41
|
+
"registry-1.docker.io",
|
|
42
|
+
"docker.io",
|
|
43
|
+
"index.docker.io",
|
|
44
|
+
}:
|
|
45
|
+
repository = f"library/{repository}"
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"registry": registry,
|
|
49
|
+
"repository": repository,
|
|
50
|
+
"tag": tag,
|
|
51
|
+
"digest": digest,
|
|
52
|
+
"image": image,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def image_uses_sha256_digest(image: str) -> bool:
|
|
57
|
+
return "@sha256:" in str(image or "")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _docker_auth_token(
|
|
61
|
+
repository: str, cache: ReleaseMetadataCache | None = None
|
|
62
|
+
) -> str:
|
|
63
|
+
if cache is not None:
|
|
64
|
+
cached = cache.get_docker_token(repository)
|
|
65
|
+
if cached:
|
|
66
|
+
return cached
|
|
67
|
+
|
|
68
|
+
response = requests.get(
|
|
69
|
+
"https://auth.docker.io/token",
|
|
70
|
+
params={
|
|
71
|
+
"service": "registry.docker.io",
|
|
72
|
+
"scope": f"repository:{repository}:pull",
|
|
73
|
+
},
|
|
74
|
+
timeout=30,
|
|
75
|
+
)
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
token = response.json()["token"]
|
|
78
|
+
if cache is not None:
|
|
79
|
+
cache.set_docker_token(repository, token)
|
|
80
|
+
return token
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _list_docker_hub_tags(
|
|
84
|
+
repository: str, cache: ReleaseMetadataCache | None = None
|
|
85
|
+
) -> list[str]:
|
|
86
|
+
registry = "registry-1.docker.io"
|
|
87
|
+
if cache is not None:
|
|
88
|
+
cached = cache.get_registry_tags(registry, repository)
|
|
89
|
+
if cached is not None:
|
|
90
|
+
return cached
|
|
91
|
+
|
|
92
|
+
token = _docker_auth_token(repository, cache=cache)
|
|
93
|
+
response = requests.get(
|
|
94
|
+
f"https://registry-1.docker.io/v2/{repository}/tags/list",
|
|
95
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
96
|
+
timeout=30,
|
|
97
|
+
)
|
|
98
|
+
response.raise_for_status()
|
|
99
|
+
tags = list(response.json().get("tags") or [])
|
|
100
|
+
if cache is not None:
|
|
101
|
+
cache.set_registry_tags(registry, repository, tags)
|
|
102
|
+
return tags
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _fetch_docker_hub_digest(
|
|
106
|
+
repository: str, tag: str, cache: ReleaseMetadataCache | None = None
|
|
107
|
+
) -> str:
|
|
108
|
+
registry = "registry-1.docker.io"
|
|
109
|
+
if cache is not None:
|
|
110
|
+
cached = cache.get_registry_digest(registry, repository, tag)
|
|
111
|
+
if cached is not None:
|
|
112
|
+
return cached
|
|
113
|
+
|
|
114
|
+
token = _docker_auth_token(repository, cache=cache)
|
|
115
|
+
response = requests.get(
|
|
116
|
+
f"https://registry-1.docker.io/v2/{repository}/manifests/{tag}",
|
|
117
|
+
headers={
|
|
118
|
+
"Authorization": f"Bearer {token}",
|
|
119
|
+
"Accept": "application/vnd.docker.distribution.manifest.v2+json",
|
|
120
|
+
},
|
|
121
|
+
timeout=30,
|
|
122
|
+
)
|
|
123
|
+
response.raise_for_status()
|
|
124
|
+
digest = response.headers.get("Docker-Content-Digest", "")
|
|
125
|
+
digest_value = digest.split(":", 1)[1] if digest.startswith("sha256:") else ""
|
|
126
|
+
if cache is not None:
|
|
127
|
+
cache.set_registry_digest(registry, repository, tag, digest_value)
|
|
128
|
+
return digest_value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gitlab_registry_host(gitlab_url: str | None) -> str:
|
|
132
|
+
gitlab_url = (
|
|
133
|
+
gitlab_url
|
|
134
|
+
or os.getenv("CI_SERVER_URL")
|
|
135
|
+
or os.getenv("GITLAB_URL")
|
|
136
|
+
or "https://gitlab.com"
|
|
137
|
+
)
|
|
138
|
+
host = urlparse(gitlab_url).netloc or "gitlab.com"
|
|
139
|
+
return f"registry.{host}"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _list_gitlab_registry_tags(
|
|
143
|
+
repository: str,
|
|
144
|
+
*,
|
|
145
|
+
gitlab_url: str | None,
|
|
146
|
+
token: str,
|
|
147
|
+
cache: ReleaseMetadataCache | None = None,
|
|
148
|
+
) -> list[str]:
|
|
149
|
+
registry_host = _gitlab_registry_host(gitlab_url)
|
|
150
|
+
if cache is not None:
|
|
151
|
+
cached = cache.get_registry_tags(registry_host, repository)
|
|
152
|
+
if cached is not None:
|
|
153
|
+
return cached
|
|
154
|
+
|
|
155
|
+
response = requests.get(
|
|
156
|
+
f"https://{registry_host}/v2/{repository}/tags/list",
|
|
157
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
158
|
+
timeout=30,
|
|
159
|
+
)
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
tags = list(response.json().get("tags") or [])
|
|
162
|
+
if cache is not None:
|
|
163
|
+
cache.set_registry_tags(registry_host, repository, tags)
|
|
164
|
+
return tags
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _fetch_gitlab_registry_digest(
|
|
168
|
+
repository: str,
|
|
169
|
+
tag: str,
|
|
170
|
+
*,
|
|
171
|
+
gitlab_url: str | None,
|
|
172
|
+
token: str,
|
|
173
|
+
cache: ReleaseMetadataCache | None = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
registry_host = _gitlab_registry_host(gitlab_url)
|
|
176
|
+
if cache is not None:
|
|
177
|
+
cached = cache.get_registry_digest(registry_host, repository, tag)
|
|
178
|
+
if cached is not None:
|
|
179
|
+
return cached
|
|
180
|
+
|
|
181
|
+
response = requests.get(
|
|
182
|
+
f"https://{registry_host}/v2/{repository}/manifests/{tag}",
|
|
183
|
+
headers={
|
|
184
|
+
"Authorization": f"Bearer {token}",
|
|
185
|
+
"Accept": "application/vnd.docker.distribution.manifest.v2+json",
|
|
186
|
+
},
|
|
187
|
+
timeout=30,
|
|
188
|
+
)
|
|
189
|
+
response.raise_for_status()
|
|
190
|
+
digest = response.headers.get("Docker-Content-Digest", "")
|
|
191
|
+
digest_value = digest.split(":", 1)[1] if digest.startswith("sha256:") else ""
|
|
192
|
+
if cache is not None:
|
|
193
|
+
cache.set_registry_digest(registry_host, repository, tag, digest_value)
|
|
194
|
+
return digest_value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _latest_semver_tag(tag_names: list[str]) -> str | None:
|
|
198
|
+
valid = [name for name in tag_names if is_valid_semver_version(name)]
|
|
199
|
+
if not valid:
|
|
200
|
+
return None
|
|
201
|
+
return max(valid, key=lambda value: semver.Version.parse(value))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def image_update_available(current_tag: str, latest_tag: str) -> bool:
|
|
205
|
+
if not latest_tag:
|
|
206
|
+
return False
|
|
207
|
+
current = str(current_tag or "").strip()
|
|
208
|
+
if current in {"", "latest"} or not is_valid_semver_version(current):
|
|
209
|
+
return is_valid_semver_version(latest_tag)
|
|
210
|
+
if not is_valid_semver_version(latest_tag):
|
|
211
|
+
return False
|
|
212
|
+
return semver.Version.parse(latest_tag) > semver.Version.parse(current)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def enrich_container_image_metadata(
|
|
216
|
+
image_entity: dict,
|
|
217
|
+
*,
|
|
218
|
+
gitlab_url: str | None,
|
|
219
|
+
token: str | None,
|
|
220
|
+
cache: ReleaseMetadataCache | None = None,
|
|
221
|
+
) -> dict:
|
|
222
|
+
enriched = dict(image_entity)
|
|
223
|
+
image_ref = str(enriched.get("image", enriched.get("project", ""))).strip()
|
|
224
|
+
parsed = parse_container_image_ref(image_ref)
|
|
225
|
+
enriched["image"] = image_ref
|
|
226
|
+
enriched["registry"] = parsed.get("registry", "")
|
|
227
|
+
enriched["repository"] = parsed.get("repository", "")
|
|
228
|
+
enriched["version"] = parsed.get("tag", "")
|
|
229
|
+
enriched["digest"] = parsed.get("digest", "")
|
|
230
|
+
enriched["valid_version"] = is_valid_semver_version(enriched["version"])
|
|
231
|
+
enriched["latest_version"] = ""
|
|
232
|
+
enriched["latest_digest"] = ""
|
|
233
|
+
enriched["update_available"] = False
|
|
234
|
+
enriched["release_metadata_resolved"] = False
|
|
235
|
+
enriched["latest_release_age_days"] = None
|
|
236
|
+
enriched["release_lag_days"] = None
|
|
237
|
+
enriched["version_tag_rank"] = None
|
|
238
|
+
enriched["semver_tag_count"] = 0
|
|
239
|
+
|
|
240
|
+
if image_uses_sha256_digest(image_ref):
|
|
241
|
+
enriched["release_metadata_resolved"] = bool(enriched["digest"])
|
|
242
|
+
return enriched
|
|
243
|
+
|
|
244
|
+
repository = enriched.get("repository", "")
|
|
245
|
+
tag = enriched.get("version", "latest")
|
|
246
|
+
registry = enriched.get("registry", "")
|
|
247
|
+
tags: list[str] = []
|
|
248
|
+
latest_tag = ""
|
|
249
|
+
latest_digest = ""
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
if registry in {"registry-1.docker.io", "docker.io", "index.docker.io"}:
|
|
253
|
+
tags = _list_docker_hub_tags(repository, cache=cache)
|
|
254
|
+
latest_tag = _latest_semver_tag(tags) or (
|
|
255
|
+
"latest" if "latest" in tags else (tags[0] if tags else "")
|
|
256
|
+
)
|
|
257
|
+
latest_digest = _fetch_docker_hub_digest(repository, tag, cache=cache)
|
|
258
|
+
if latest_tag and latest_tag != tag:
|
|
259
|
+
enriched["latest_digest"] = _fetch_docker_hub_digest(
|
|
260
|
+
repository, latest_tag, cache=cache
|
|
261
|
+
)
|
|
262
|
+
elif registry == _gitlab_registry_host(gitlab_url) and token:
|
|
263
|
+
tags = _list_gitlab_registry_tags(
|
|
264
|
+
repository, gitlab_url=gitlab_url, token=token, cache=cache
|
|
265
|
+
)
|
|
266
|
+
latest_tag = _latest_semver_tag(tags) or (
|
|
267
|
+
"latest" if "latest" in tags else (tags[0] if tags else "")
|
|
268
|
+
)
|
|
269
|
+
latest_digest = _fetch_gitlab_registry_digest(
|
|
270
|
+
repository, tag, gitlab_url=gitlab_url, token=token, cache=cache
|
|
271
|
+
)
|
|
272
|
+
if latest_tag and latest_tag != tag:
|
|
273
|
+
enriched["latest_digest"] = _fetch_gitlab_registry_digest(
|
|
274
|
+
repository,
|
|
275
|
+
latest_tag,
|
|
276
|
+
gitlab_url=gitlab_url,
|
|
277
|
+
token=token,
|
|
278
|
+
cache=cache,
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
return enriched
|
|
282
|
+
except Exception:
|
|
283
|
+
return enriched
|
|
284
|
+
|
|
285
|
+
if not latest_tag:
|
|
286
|
+
return enriched
|
|
287
|
+
|
|
288
|
+
tags_desc = _semver_tags_descending(
|
|
289
|
+
[name for name in tags if is_valid_semver_version(name)]
|
|
290
|
+
)
|
|
291
|
+
enriched["latest_version"] = latest_tag
|
|
292
|
+
enriched["latest_digest"] = latest_digest or enriched.get("latest_digest", "")
|
|
293
|
+
enriched["update_available"] = image_update_available(tag, latest_tag)
|
|
294
|
+
enriched["release_metadata_resolved"] = True
|
|
295
|
+
enriched["semver_tag_count"] = len(tags_desc)
|
|
296
|
+
if enriched["valid_version"]:
|
|
297
|
+
enriched["version_tag_rank"] = compute_version_tag_rank(tag, tags_desc)
|
|
298
|
+
return enriched
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def enrich_container_images_with_releases(
|
|
302
|
+
images: list[dict],
|
|
303
|
+
*,
|
|
304
|
+
gitlab_url: str | None,
|
|
305
|
+
token: str | None,
|
|
306
|
+
cache: ReleaseMetadataCache | None = None,
|
|
307
|
+
) -> list[dict]:
|
|
308
|
+
return [
|
|
309
|
+
enrich_container_image_metadata(
|
|
310
|
+
image,
|
|
311
|
+
gitlab_url=gitlab_url,
|
|
312
|
+
token=token or "",
|
|
313
|
+
cache=cache,
|
|
314
|
+
)
|
|
315
|
+
for image in images
|
|
316
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Auto-fix outdated include refs in GitLab CI YAML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class IncludeVersionFix:
|
|
11
|
+
source_file: str
|
|
12
|
+
line: int
|
|
13
|
+
include_type: str
|
|
14
|
+
project: str
|
|
15
|
+
current_version: str
|
|
16
|
+
latest_version: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collect_include_version_fixes(entities: dict) -> list[IncludeVersionFix]:
|
|
20
|
+
fixes: list[IncludeVersionFix] = []
|
|
21
|
+
for include in entities.get("includes", []):
|
|
22
|
+
include_type = include.get("include_type", "")
|
|
23
|
+
if include_type not in {"project", "component"}:
|
|
24
|
+
continue
|
|
25
|
+
latest = str(include.get("latest_version", "")).strip()
|
|
26
|
+
current = str(include.get("version", "")).strip()
|
|
27
|
+
if not latest or not include.get("update_available"):
|
|
28
|
+
continue
|
|
29
|
+
if current == latest:
|
|
30
|
+
continue
|
|
31
|
+
fixes.append(
|
|
32
|
+
IncludeVersionFix(
|
|
33
|
+
source_file=include.get("source_file", ""),
|
|
34
|
+
line=int(include.get("line", 0) or 0),
|
|
35
|
+
include_type=include_type,
|
|
36
|
+
project=str(include.get("project", "")),
|
|
37
|
+
current_version=current,
|
|
38
|
+
latest_version=latest,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
return fixes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _replace_ref_line(line: str, latest_version: str) -> str | None:
|
|
45
|
+
match = re.match(r"^(\s*ref:\s*)(['\"]?)([^'\"#\n]+)\2\s*(#.*)?$", line)
|
|
46
|
+
if not match:
|
|
47
|
+
return None
|
|
48
|
+
prefix, quote, _value, suffix = match.groups()
|
|
49
|
+
suffix = suffix or ""
|
|
50
|
+
if quote:
|
|
51
|
+
return f"{prefix}{quote}{latest_version}{quote}{suffix}\n"
|
|
52
|
+
return f"{prefix}{latest_version}{suffix}\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _replace_component_line(
|
|
56
|
+
line: str, current_version: str, latest_version: str
|
|
57
|
+
) -> str | None:
|
|
58
|
+
if "component:" not in line and "component :" not in line:
|
|
59
|
+
return None
|
|
60
|
+
pattern = re.compile(
|
|
61
|
+
rf"(@{re.escape(current_version)})(?=(['\"\s#]|$))",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
if not pattern.search(line):
|
|
65
|
+
return None
|
|
66
|
+
return pattern.sub(f"@{latest_version}", line, count=1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _apply_fix_to_file(fix: IncludeVersionFix, lines: list[str]) -> bool:
|
|
70
|
+
if fix.line <= 0 or fix.line > len(lines):
|
|
71
|
+
return False
|
|
72
|
+
start = fix.line - 1
|
|
73
|
+
end = min(len(lines), start + 12)
|
|
74
|
+
for index in range(start, end):
|
|
75
|
+
line = lines[index]
|
|
76
|
+
if index > start and re.match(r"^\s*-\s+", line):
|
|
77
|
+
break
|
|
78
|
+
updated = None
|
|
79
|
+
if fix.include_type == "project":
|
|
80
|
+
updated = _replace_ref_line(line, fix.latest_version)
|
|
81
|
+
elif fix.include_type == "component":
|
|
82
|
+
updated = _replace_component_line(
|
|
83
|
+
line, fix.current_version, fix.latest_version
|
|
84
|
+
)
|
|
85
|
+
if updated and updated != line:
|
|
86
|
+
lines[index] = updated if updated.endswith("\n") else updated + "\n"
|
|
87
|
+
return True
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def apply_include_version_fixes(
|
|
92
|
+
fixes: list[IncludeVersionFix],
|
|
93
|
+
) -> list[IncludeVersionFix]:
|
|
94
|
+
applied: list[IncludeVersionFix] = []
|
|
95
|
+
by_file: dict[str, list[IncludeVersionFix]] = {}
|
|
96
|
+
for fix in fixes:
|
|
97
|
+
if not fix.source_file:
|
|
98
|
+
continue
|
|
99
|
+
by_file.setdefault(fix.source_file, []).append(fix)
|
|
100
|
+
|
|
101
|
+
for source_file, file_fixes in by_file.items():
|
|
102
|
+
with open(source_file, encoding="utf-8") as handle:
|
|
103
|
+
lines = handle.readlines()
|
|
104
|
+
changed = False
|
|
105
|
+
for fix in file_fixes:
|
|
106
|
+
if _apply_fix_to_file(fix, lines):
|
|
107
|
+
applied.append(fix)
|
|
108
|
+
changed = True
|
|
109
|
+
if changed:
|
|
110
|
+
with open(source_file, "w", encoding="utf-8") as handle:
|
|
111
|
+
handle.writelines(lines)
|
|
112
|
+
return applied
|