gitacross 1.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.
- gitacross/__init__.py +34 -0
- gitacross/__main__.py +6 -0
- gitacross/config.py +218 -0
- gitacross/git.py +342 -0
- gitacross/gitea.py +9 -0
- gitacross/github.py +9 -0
- gitacross/linter.py +1105 -0
- gitacross/main.py +360 -0
- gitacross/providers/__init__.py +44 -0
- gitacross/providers/base.py +230 -0
- gitacross/providers/gitea.py +170 -0
- gitacross/providers/github.py +120 -0
- gitacross/renderer.py +195 -0
- gitacross/retry.py +23 -0
- gitacross/source.py +297 -0
- gitacross/state.py +67 -0
- gitacross/target.py +159 -0
- gitacross-1.0.0.dist-info/METADATA +318 -0
- gitacross-1.0.0.dist-info/RECORD +23 -0
- gitacross-1.0.0.dist-info/WHEEL +5 -0
- gitacross-1.0.0.dist-info/entry_points.txt +2 -0
- gitacross-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitacross-1.0.0.dist-info/top_level.txt +1 -0
gitacross/linter.py
ADDED
|
@@ -0,0 +1,1105 @@
|
|
|
1
|
+
"""Config linter for GitAcross.
|
|
2
|
+
|
|
3
|
+
Validates YAML syntax, detects invalid or misplaced configurations,
|
|
4
|
+
and flags redundant options that match default values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
import io
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LintSeverity(Enum):
|
|
16
|
+
ERROR = "ERROR"
|
|
17
|
+
WARNING = "WARNING"
|
|
18
|
+
REDUNDANT = "REDUNDANT"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class LintIssue:
|
|
23
|
+
severity: LintSeverity
|
|
24
|
+
message: str
|
|
25
|
+
project: Optional[str] = None
|
|
26
|
+
key: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
def __str__(self) -> str:
|
|
29
|
+
prefix = f"[{self.severity.value}]"
|
|
30
|
+
if self.project:
|
|
31
|
+
return f" {prefix} (project '{self.project}'): {self.message}"
|
|
32
|
+
return f" {prefix} {self.message}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LintReport:
|
|
37
|
+
issues: List[LintIssue]
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def errors(self) -> List[LintIssue]:
|
|
41
|
+
return [i for i in self.issues if i.severity == LintSeverity.ERROR]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def warnings(self) -> List[LintIssue]:
|
|
45
|
+
return [i for i in self.issues if i.severity == LintSeverity.WARNING]
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def redundant(self) -> List[LintIssue]:
|
|
49
|
+
return [i for i in self.issues if i.severity == LintSeverity.REDUNDANT]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def is_valid(self) -> bool:
|
|
53
|
+
return len(self.errors) == 0
|
|
54
|
+
|
|
55
|
+
def format_text(self) -> str:
|
|
56
|
+
out = []
|
|
57
|
+
# Group issues by project (None first)
|
|
58
|
+
by_proj: Dict[Optional[str], List[LintIssue]] = {}
|
|
59
|
+
for issue in self.issues:
|
|
60
|
+
by_proj.setdefault(issue.project, []).append(issue)
|
|
61
|
+
|
|
62
|
+
if None in by_proj:
|
|
63
|
+
for issue in by_proj[None]:
|
|
64
|
+
out.append(f"[{issue.severity.value}] {issue.message}")
|
|
65
|
+
|
|
66
|
+
for proj, issues in by_proj.items():
|
|
67
|
+
if proj is None:
|
|
68
|
+
continue
|
|
69
|
+
out.append(f"\nProject '{proj}':")
|
|
70
|
+
for issue in issues:
|
|
71
|
+
out.append(f" [{issue.severity.value}] {issue.message}")
|
|
72
|
+
|
|
73
|
+
summary = f"\nSummary: {len(self.errors)} error(s), {len(self.warnings)} warning(s), {len(self.redundant)} redundant option(s)."
|
|
74
|
+
out.append(summary)
|
|
75
|
+
return "\n".join(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_VALID_PROJECT_KEYS = {
|
|
79
|
+
"name",
|
|
80
|
+
"enabled",
|
|
81
|
+
"source",
|
|
82
|
+
"target",
|
|
83
|
+
"renderer",
|
|
84
|
+
"retry",
|
|
85
|
+
"preserve_description",
|
|
86
|
+
"preserve_release_description",
|
|
87
|
+
"sync_assets",
|
|
88
|
+
"preserve_assets",
|
|
89
|
+
"include_assets",
|
|
90
|
+
"stream_assets",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_KNOWN_SOURCE_KEYS = {
|
|
94
|
+
"mode": "source",
|
|
95
|
+
"sync_from": "source",
|
|
96
|
+
"include_prereleases": "source",
|
|
97
|
+
"include_drafts": "source",
|
|
98
|
+
"tag_pattern": "source",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_KNOWN_ENDPOINT_KEYS = {
|
|
102
|
+
"repo": "source or target",
|
|
103
|
+
"api": "source or target",
|
|
104
|
+
"token": "source or target",
|
|
105
|
+
"branch": "source or target",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_VALID_SOURCE_KEYS_REMOTE = {
|
|
109
|
+
"type",
|
|
110
|
+
"repo",
|
|
111
|
+
"api",
|
|
112
|
+
"token",
|
|
113
|
+
"mode",
|
|
114
|
+
"branch",
|
|
115
|
+
"include_prereleases",
|
|
116
|
+
"include_drafts",
|
|
117
|
+
"sync_from",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_VALID_SOURCE_KEYS_LOCAL = {
|
|
121
|
+
"type",
|
|
122
|
+
"path",
|
|
123
|
+
"tag_pattern",
|
|
124
|
+
"sync_from",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_VALID_TARGET_KEYS_REMOTE = {
|
|
128
|
+
"type",
|
|
129
|
+
"repo",
|
|
130
|
+
"api",
|
|
131
|
+
"token",
|
|
132
|
+
"branch",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
_VALID_TARGET_KEYS_LOCAL = {
|
|
136
|
+
"type",
|
|
137
|
+
"path",
|
|
138
|
+
"branch",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ConfigLinter:
|
|
143
|
+
def __init__(self):
|
|
144
|
+
self.issues: List[LintIssue] = []
|
|
145
|
+
|
|
146
|
+
def _add(
|
|
147
|
+
self,
|
|
148
|
+
severity: LintSeverity,
|
|
149
|
+
message: str,
|
|
150
|
+
project: Optional[str] = None,
|
|
151
|
+
key: Optional[str] = None,
|
|
152
|
+
):
|
|
153
|
+
self.issues.append(
|
|
154
|
+
LintIssue(severity=severity, message=message, project=project, key=key)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def lint_file(self, file_path: Union[str, Path]) -> LintReport:
|
|
158
|
+
path = Path(file_path)
|
|
159
|
+
if not path.exists():
|
|
160
|
+
self._add(LintSeverity.ERROR, f"File not found: {path}")
|
|
161
|
+
return LintReport(self.issues)
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
content = path.read_text(encoding="utf-8")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
self._add(LintSeverity.ERROR, f"Cannot read file '{path}': {e}")
|
|
167
|
+
return LintReport(self.issues)
|
|
168
|
+
|
|
169
|
+
return self.lint_yaml_string(content, filename=str(path))
|
|
170
|
+
|
|
171
|
+
def lint_yaml_string(
|
|
172
|
+
self, content: str, filename: Optional[str] = None
|
|
173
|
+
) -> LintReport:
|
|
174
|
+
try:
|
|
175
|
+
data = yaml.safe_load(content)
|
|
176
|
+
except yaml.YAMLError as e:
|
|
177
|
+
mark = getattr(e, "problem_mark", None)
|
|
178
|
+
loc = (
|
|
179
|
+
f" (line {mark.line + 1}, column {mark.column + 1})" if mark else ""
|
|
180
|
+
)
|
|
181
|
+
self._add(
|
|
182
|
+
LintSeverity.ERROR,
|
|
183
|
+
f"YAML syntax error{loc}: {e.problem if hasattr(e, 'problem') and e.problem else str(e)}",
|
|
184
|
+
)
|
|
185
|
+
return LintReport(self.issues)
|
|
186
|
+
|
|
187
|
+
if data is None:
|
|
188
|
+
self._add(LintSeverity.WARNING, "Configuration file is empty")
|
|
189
|
+
return LintReport(self.issues)
|
|
190
|
+
|
|
191
|
+
projects_raw = []
|
|
192
|
+
if isinstance(data, list):
|
|
193
|
+
projects_raw = data
|
|
194
|
+
elif isinstance(data, dict):
|
|
195
|
+
if "projects" in data:
|
|
196
|
+
if not isinstance(data["projects"], list):
|
|
197
|
+
self._add(
|
|
198
|
+
LintSeverity.ERROR,
|
|
199
|
+
"'projects' key must contain a list of project configurations.",
|
|
200
|
+
)
|
|
201
|
+
return LintReport(self.issues)
|
|
202
|
+
projects_raw = data["projects"]
|
|
203
|
+
else:
|
|
204
|
+
self._add(
|
|
205
|
+
LintSeverity.ERROR,
|
|
206
|
+
"Top-level YAML object must be a list of projects or a map with a 'projects' list.",
|
|
207
|
+
)
|
|
208
|
+
return LintReport(self.issues)
|
|
209
|
+
else:
|
|
210
|
+
self._add(
|
|
211
|
+
LintSeverity.ERROR,
|
|
212
|
+
f"Invalid top-level YAML type: expected list or dict, got {type(data).__name__}.",
|
|
213
|
+
)
|
|
214
|
+
return LintReport(self.issues)
|
|
215
|
+
|
|
216
|
+
seen_names = set()
|
|
217
|
+
for idx, p in enumerate(projects_raw):
|
|
218
|
+
if not isinstance(p, dict):
|
|
219
|
+
self._add(
|
|
220
|
+
LintSeverity.ERROR,
|
|
221
|
+
f"Project at index {idx} is not a valid map/dict (got {type(p).__name__}).",
|
|
222
|
+
)
|
|
223
|
+
continue
|
|
224
|
+
self._lint_project(p, idx, seen_names)
|
|
225
|
+
|
|
226
|
+
return LintReport(self.issues)
|
|
227
|
+
|
|
228
|
+
def _lint_project(self, p: Dict[str, Any], idx: int, seen_names: set):
|
|
229
|
+
name = p.get("name")
|
|
230
|
+
if not name or not isinstance(name, str):
|
|
231
|
+
self._add(
|
|
232
|
+
LintSeverity.ERROR,
|
|
233
|
+
f"Project at index {idx} is missing required 'name' string field.",
|
|
234
|
+
)
|
|
235
|
+
p_name = f"<unnamed project #{idx}>"
|
|
236
|
+
else:
|
|
237
|
+
p_name = name
|
|
238
|
+
if name in seen_names:
|
|
239
|
+
self._add(
|
|
240
|
+
LintSeverity.ERROR,
|
|
241
|
+
f"Duplicate project name '{name}' found.",
|
|
242
|
+
project=p_name,
|
|
243
|
+
)
|
|
244
|
+
seen_names.add(name)
|
|
245
|
+
|
|
246
|
+
# 1. Project-level keys validation
|
|
247
|
+
for k in p:
|
|
248
|
+
if k in _VALID_PROJECT_KEYS:
|
|
249
|
+
continue
|
|
250
|
+
if k in _KNOWN_SOURCE_KEYS:
|
|
251
|
+
self._add(
|
|
252
|
+
LintSeverity.ERROR,
|
|
253
|
+
f"'{k}: {p[k]}' was specified at project level, but belongs under 'source:' (e.g. source.{k}: {p[k]}).",
|
|
254
|
+
project=p_name,
|
|
255
|
+
key=k,
|
|
256
|
+
)
|
|
257
|
+
elif k in _KNOWN_ENDPOINT_KEYS:
|
|
258
|
+
self._add(
|
|
259
|
+
LintSeverity.ERROR,
|
|
260
|
+
f"'{k}: {p[k]}' was specified at project level, but belongs under '{_KNOWN_ENDPOINT_KEYS[k]}:'.",
|
|
261
|
+
project=p_name,
|
|
262
|
+
key=k,
|
|
263
|
+
)
|
|
264
|
+
else:
|
|
265
|
+
self._add(
|
|
266
|
+
LintSeverity.WARNING,
|
|
267
|
+
f"Unrecognized project-level option '{k}'.",
|
|
268
|
+
project=p_name,
|
|
269
|
+
key=k,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# 2. Redundant project-level options
|
|
273
|
+
if "enabled" in p:
|
|
274
|
+
val = p["enabled"]
|
|
275
|
+
if not isinstance(val, bool):
|
|
276
|
+
self._add(
|
|
277
|
+
LintSeverity.ERROR,
|
|
278
|
+
f"'enabled' must be a boolean (got {type(val).__name__}).",
|
|
279
|
+
project=p_name,
|
|
280
|
+
key="enabled",
|
|
281
|
+
)
|
|
282
|
+
elif val is True:
|
|
283
|
+
self._add(
|
|
284
|
+
LintSeverity.REDUNDANT,
|
|
285
|
+
"'enabled: true' is redundant (default is true).",
|
|
286
|
+
project=p_name,
|
|
287
|
+
key="enabled",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if "preserve_description" in p:
|
|
291
|
+
val = p["preserve_description"]
|
|
292
|
+
if not isinstance(val, bool):
|
|
293
|
+
self._add(
|
|
294
|
+
LintSeverity.ERROR,
|
|
295
|
+
f"'preserve_description' must be a boolean (got {type(val).__name__}).",
|
|
296
|
+
project=p_name,
|
|
297
|
+
)
|
|
298
|
+
elif val is True:
|
|
299
|
+
self._add(
|
|
300
|
+
LintSeverity.REDUNDANT,
|
|
301
|
+
"'preserve_description: true' is redundant (default is true).",
|
|
302
|
+
project=p_name,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if "sync_assets" in p:
|
|
306
|
+
val = p["sync_assets"]
|
|
307
|
+
if isinstance(val, bool):
|
|
308
|
+
if val is False:
|
|
309
|
+
self._add(
|
|
310
|
+
LintSeverity.REDUNDANT,
|
|
311
|
+
"'sync_assets: false' is redundant (default is false).",
|
|
312
|
+
project=p_name,
|
|
313
|
+
)
|
|
314
|
+
elif isinstance(val, str):
|
|
315
|
+
pass
|
|
316
|
+
elif isinstance(val, (list, tuple)):
|
|
317
|
+
if not all(isinstance(x, str) for x in val):
|
|
318
|
+
self._add(
|
|
319
|
+
LintSeverity.ERROR,
|
|
320
|
+
"'sync_assets' list must only contain string glob patterns.",
|
|
321
|
+
project=p_name,
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
self._add(
|
|
325
|
+
LintSeverity.ERROR,
|
|
326
|
+
f"'sync_assets' must be a boolean, string, or list of strings (got {type(val).__name__}).",
|
|
327
|
+
project=p_name,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
if "stream_assets" in p:
|
|
331
|
+
val = p["stream_assets"]
|
|
332
|
+
if not isinstance(val, bool):
|
|
333
|
+
self._add(
|
|
334
|
+
LintSeverity.ERROR,
|
|
335
|
+
f"'stream_assets' must be a boolean (got {type(val).__name__}).",
|
|
336
|
+
project=p_name,
|
|
337
|
+
)
|
|
338
|
+
elif val is False:
|
|
339
|
+
self._add(
|
|
340
|
+
LintSeverity.REDUNDANT,
|
|
341
|
+
"'stream_assets: false' is redundant (default is false).",
|
|
342
|
+
project=p_name,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# 3. Source endpoint checks
|
|
346
|
+
source = p.get("source")
|
|
347
|
+
if source is None:
|
|
348
|
+
self._add(
|
|
349
|
+
LintSeverity.ERROR,
|
|
350
|
+
"Missing required 'source' endpoint configuration.",
|
|
351
|
+
project=p_name,
|
|
352
|
+
)
|
|
353
|
+
elif not isinstance(source, dict):
|
|
354
|
+
self._add(
|
|
355
|
+
LintSeverity.ERROR,
|
|
356
|
+
f"'source' must be a dictionary (got {type(source).__name__}).",
|
|
357
|
+
project=p_name,
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
self._lint_source(source, p_name)
|
|
361
|
+
|
|
362
|
+
# 4. Target endpoint checks
|
|
363
|
+
target = p.get("target")
|
|
364
|
+
if target is None:
|
|
365
|
+
self._add(
|
|
366
|
+
LintSeverity.ERROR,
|
|
367
|
+
"Missing required 'target' endpoint configuration.",
|
|
368
|
+
project=p_name,
|
|
369
|
+
)
|
|
370
|
+
elif not isinstance(target, dict):
|
|
371
|
+
self._add(
|
|
372
|
+
LintSeverity.ERROR,
|
|
373
|
+
f"'target' must be a dictionary (got {type(target).__name__}).",
|
|
374
|
+
project=p_name,
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
self._lint_target(target, p_name)
|
|
378
|
+
|
|
379
|
+
# 5. Retry checks
|
|
380
|
+
if "retry" in p:
|
|
381
|
+
retry = p["retry"]
|
|
382
|
+
if not isinstance(retry, dict):
|
|
383
|
+
self._add(
|
|
384
|
+
LintSeverity.ERROR,
|
|
385
|
+
f"'retry' must be a dictionary (got {type(retry).__name__}).",
|
|
386
|
+
project=p_name,
|
|
387
|
+
)
|
|
388
|
+
else:
|
|
389
|
+
self._lint_retry(retry, p_name)
|
|
390
|
+
|
|
391
|
+
# 6. Renderer checks
|
|
392
|
+
if "renderer" in p:
|
|
393
|
+
renderer = p["renderer"]
|
|
394
|
+
if not isinstance(renderer, dict):
|
|
395
|
+
self._add(
|
|
396
|
+
LintSeverity.ERROR,
|
|
397
|
+
f"'renderer' must be a dictionary (got {type(renderer).__name__}).",
|
|
398
|
+
project=p_name,
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
self._lint_renderer(renderer, p_name)
|
|
402
|
+
|
|
403
|
+
def _lint_source(self, s: Dict[str, Any], p_name: str):
|
|
404
|
+
src_type = s.get("type", "gitea")
|
|
405
|
+
if src_type not in ("gitea", "github", "local"):
|
|
406
|
+
self._add(
|
|
407
|
+
LintSeverity.ERROR,
|
|
408
|
+
f"Invalid source type '{src_type}'. Expected 'gitea', 'github', or 'local'.",
|
|
409
|
+
project=p_name,
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
is_remote = src_type in ("gitea", "github")
|
|
414
|
+
valid_keys = _VALID_SOURCE_KEYS_REMOTE if is_remote else _VALID_SOURCE_KEYS_LOCAL
|
|
415
|
+
for k in s:
|
|
416
|
+
if k in _VALID_PROJECT_KEYS and k != "type":
|
|
417
|
+
self._add(
|
|
418
|
+
LintSeverity.WARNING,
|
|
419
|
+
f"'{k}' was specified inside 'source:', but belongs at the project level.",
|
|
420
|
+
project=p_name,
|
|
421
|
+
key=k,
|
|
422
|
+
)
|
|
423
|
+
elif k not in valid_keys:
|
|
424
|
+
self._add(
|
|
425
|
+
LintSeverity.WARNING,
|
|
426
|
+
f"Unrecognized option '{k}' under source ({src_type}).",
|
|
427
|
+
project=p_name,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if is_remote:
|
|
431
|
+
if not s.get("repo"):
|
|
432
|
+
self._add(
|
|
433
|
+
LintSeverity.ERROR,
|
|
434
|
+
"Source is missing required 'repo' string (e.g. 'owner/repo').",
|
|
435
|
+
project=p_name,
|
|
436
|
+
)
|
|
437
|
+
if not s.get("api"):
|
|
438
|
+
self._add(
|
|
439
|
+
LintSeverity.ERROR,
|
|
440
|
+
"Source is missing required 'api' URL.",
|
|
441
|
+
project=p_name,
|
|
442
|
+
)
|
|
443
|
+
if not s.get("token"):
|
|
444
|
+
self._add(
|
|
445
|
+
LintSeverity.ERROR,
|
|
446
|
+
"Source is missing required 'token'.",
|
|
447
|
+
project=p_name,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
mode = s.get("mode", "release")
|
|
451
|
+
if mode not in ("release", "tag", "commit"):
|
|
452
|
+
self._add(
|
|
453
|
+
LintSeverity.ERROR,
|
|
454
|
+
f"Invalid source mode '{mode}'. Expected 'release', 'tag', or 'commit'.",
|
|
455
|
+
project=p_name,
|
|
456
|
+
)
|
|
457
|
+
elif "mode" in s and s["mode"] == "release":
|
|
458
|
+
self._add(
|
|
459
|
+
LintSeverity.REDUNDANT,
|
|
460
|
+
"'source.mode: release' is redundant (default is release).",
|
|
461
|
+
project=p_name,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
if "include_prereleases" in s:
|
|
465
|
+
val = s["include_prereleases"]
|
|
466
|
+
if not isinstance(val, bool):
|
|
467
|
+
self._add(
|
|
468
|
+
LintSeverity.ERROR,
|
|
469
|
+
f"'source.include_prereleases' must be a boolean (got {type(val).__name__}).",
|
|
470
|
+
project=p_name,
|
|
471
|
+
)
|
|
472
|
+
elif val is False:
|
|
473
|
+
self._add(
|
|
474
|
+
LintSeverity.REDUNDANT,
|
|
475
|
+
"'source.include_prereleases: false' is redundant (default is false).",
|
|
476
|
+
project=p_name,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if "include_drafts" in s:
|
|
480
|
+
val = s["include_drafts"]
|
|
481
|
+
if not isinstance(val, bool):
|
|
482
|
+
self._add(
|
|
483
|
+
LintSeverity.ERROR,
|
|
484
|
+
f"'source.include_drafts' must be a boolean (got {type(val).__name__}).",
|
|
485
|
+
project=p_name,
|
|
486
|
+
)
|
|
487
|
+
elif val is False:
|
|
488
|
+
self._add(
|
|
489
|
+
LintSeverity.REDUNDANT,
|
|
490
|
+
"'source.include_drafts: false' is redundant (default is false).",
|
|
491
|
+
project=p_name,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
if "sync_from" in s and s["sync_from"] == "":
|
|
495
|
+
self._add(
|
|
496
|
+
LintSeverity.REDUNDANT,
|
|
497
|
+
"'source.sync_from: \"\"' is redundant (default is empty).",
|
|
498
|
+
project=p_name,
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
# Local source
|
|
502
|
+
if not s.get("path"):
|
|
503
|
+
self._add(
|
|
504
|
+
LintSeverity.ERROR,
|
|
505
|
+
"Local source is missing required 'path'.",
|
|
506
|
+
project=p_name,
|
|
507
|
+
)
|
|
508
|
+
if "tag_pattern" in s:
|
|
509
|
+
if s["tag_pattern"] == "*":
|
|
510
|
+
self._add(
|
|
511
|
+
LintSeverity.REDUNDANT,
|
|
512
|
+
"'source.tag_pattern: \"*\"' is redundant (default is \"*\").",
|
|
513
|
+
project=p_name,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def _lint_target(self, t: Dict[str, Any], p_name: str):
|
|
517
|
+
tgt_type = t.get("type", "github")
|
|
518
|
+
if tgt_type not in ("github", "gitea", "local"):
|
|
519
|
+
self._add(
|
|
520
|
+
LintSeverity.ERROR,
|
|
521
|
+
f"Invalid target type '{tgt_type}'. Expected 'github', 'gitea', or 'local'.",
|
|
522
|
+
project=p_name,
|
|
523
|
+
)
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
is_remote = tgt_type in ("github", "gitea")
|
|
527
|
+
valid_keys = _VALID_TARGET_KEYS_REMOTE if is_remote else _VALID_TARGET_KEYS_LOCAL
|
|
528
|
+
for k in t:
|
|
529
|
+
if k in _VALID_PROJECT_KEYS and k != "type":
|
|
530
|
+
self._add(
|
|
531
|
+
LintSeverity.WARNING,
|
|
532
|
+
f"'{k}' was specified inside 'target:', but belongs at the project level.",
|
|
533
|
+
project=p_name,
|
|
534
|
+
key=k,
|
|
535
|
+
)
|
|
536
|
+
elif k in _KNOWN_SOURCE_KEYS:
|
|
537
|
+
self._add(
|
|
538
|
+
LintSeverity.ERROR,
|
|
539
|
+
f"'{k}' was specified inside 'target:', but belongs under 'source:'.",
|
|
540
|
+
project=p_name,
|
|
541
|
+
key=k,
|
|
542
|
+
)
|
|
543
|
+
elif k not in valid_keys:
|
|
544
|
+
self._add(
|
|
545
|
+
LintSeverity.WARNING,
|
|
546
|
+
f"Unrecognized option '{k}' under target ({tgt_type}).",
|
|
547
|
+
project=p_name,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if is_remote:
|
|
551
|
+
if not t.get("repo"):
|
|
552
|
+
self._add(
|
|
553
|
+
LintSeverity.ERROR,
|
|
554
|
+
"Target is missing required 'repo' string (e.g. 'owner/repo').",
|
|
555
|
+
project=p_name,
|
|
556
|
+
)
|
|
557
|
+
if not t.get("api"):
|
|
558
|
+
self._add(
|
|
559
|
+
LintSeverity.ERROR,
|
|
560
|
+
"Target is missing required 'api' URL.",
|
|
561
|
+
project=p_name,
|
|
562
|
+
)
|
|
563
|
+
if not t.get("token"):
|
|
564
|
+
self._add(
|
|
565
|
+
LintSeverity.ERROR,
|
|
566
|
+
"Target is missing required 'token'.",
|
|
567
|
+
project=p_name,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if "branch" in t and t["branch"] == "main":
|
|
571
|
+
self._add(
|
|
572
|
+
LintSeverity.REDUNDANT,
|
|
573
|
+
"'target.branch: main' is redundant (default is main).",
|
|
574
|
+
project=p_name,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
def _lint_retry(self, r: Dict[str, Any], p_name: str):
|
|
578
|
+
valid_retry_keys = {"max_attempts", "backoff_seconds"}
|
|
579
|
+
for k in r:
|
|
580
|
+
if k not in valid_retry_keys:
|
|
581
|
+
self._add(
|
|
582
|
+
LintSeverity.WARNING,
|
|
583
|
+
f"Unrecognized option '{k}' under retry.",
|
|
584
|
+
project=p_name,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
max_att = r.get("max_attempts")
|
|
588
|
+
backoff = r.get("backoff_seconds")
|
|
589
|
+
|
|
590
|
+
if max_att is not None:
|
|
591
|
+
if not isinstance(max_att, int) or max_att < 1:
|
|
592
|
+
self._add(
|
|
593
|
+
LintSeverity.ERROR,
|
|
594
|
+
f"'retry.max_attempts' must be an integer >= 1 (got {max_att}).",
|
|
595
|
+
project=p_name,
|
|
596
|
+
)
|
|
597
|
+
elif max_att == 3:
|
|
598
|
+
self._add(
|
|
599
|
+
LintSeverity.REDUNDANT,
|
|
600
|
+
"'retry.max_attempts: 3' is redundant (default is 3).",
|
|
601
|
+
project=p_name,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if backoff is not None:
|
|
605
|
+
if not isinstance(backoff, (int, float)) or backoff < 0:
|
|
606
|
+
self._add(
|
|
607
|
+
LintSeverity.ERROR,
|
|
608
|
+
f"'retry.backoff_seconds' must be a number >= 0 (got {backoff}).",
|
|
609
|
+
project=p_name,
|
|
610
|
+
)
|
|
611
|
+
elif backoff == 2:
|
|
612
|
+
self._add(
|
|
613
|
+
LintSeverity.REDUNDANT,
|
|
614
|
+
"'retry.backoff_seconds: 2' is redundant (default is 2).",
|
|
615
|
+
project=p_name,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def _lint_renderer(self, ren: Dict[str, Any], p_name: str):
|
|
619
|
+
valid_renderer_keys = {"ignore", "operations", "author"}
|
|
620
|
+
for k in ren:
|
|
621
|
+
if k not in valid_renderer_keys:
|
|
622
|
+
self._add(
|
|
623
|
+
LintSeverity.WARNING,
|
|
624
|
+
f"Unrecognized option '{k}' under renderer.",
|
|
625
|
+
project=p_name,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if "ignore" in ren:
|
|
629
|
+
ig = ren["ignore"]
|
|
630
|
+
if not isinstance(ig, (list, tuple)):
|
|
631
|
+
self._add(
|
|
632
|
+
LintSeverity.ERROR,
|
|
633
|
+
f"'renderer.ignore' must be a list of patterns (got {type(ig).__name__}).",
|
|
634
|
+
project=p_name,
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if "author" in ren:
|
|
638
|
+
auth = ren["author"]
|
|
639
|
+
if not isinstance(auth, dict):
|
|
640
|
+
self._add(
|
|
641
|
+
LintSeverity.ERROR,
|
|
642
|
+
f"'renderer.author' must be a dictionary (got {type(auth).__name__}).",
|
|
643
|
+
project=p_name,
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
if "operations" in ren:
|
|
647
|
+
ops = ren["operations"]
|
|
648
|
+
if not isinstance(ops, list):
|
|
649
|
+
self._add(
|
|
650
|
+
LintSeverity.ERROR,
|
|
651
|
+
f"'renderer.operations' must be a list of operation maps (got {type(ops).__name__}).",
|
|
652
|
+
project=p_name,
|
|
653
|
+
)
|
|
654
|
+
else:
|
|
655
|
+
self._lint_operations(ops, p_name)
|
|
656
|
+
|
|
657
|
+
def _lint_operations(self, ops: List[Any], p_name: str):
|
|
658
|
+
valid_op_types = {"remove", "rename", "replace", "add", "validate"}
|
|
659
|
+
for idx, op in enumerate(ops):
|
|
660
|
+
if not isinstance(op, dict):
|
|
661
|
+
self._add(
|
|
662
|
+
LintSeverity.ERROR,
|
|
663
|
+
f"Operation at index {idx} must be a dictionary (got {type(op).__name__}).",
|
|
664
|
+
project=p_name,
|
|
665
|
+
)
|
|
666
|
+
continue
|
|
667
|
+
for op_type, items in op.items():
|
|
668
|
+
if op_type not in valid_op_types:
|
|
669
|
+
self._add(
|
|
670
|
+
LintSeverity.ERROR,
|
|
671
|
+
f"Unknown renderer operation '{op_type}' at index {idx}. Valid operations: {', '.join(sorted(valid_op_types))}.",
|
|
672
|
+
project=p_name,
|
|
673
|
+
)
|
|
674
|
+
continue
|
|
675
|
+
if not isinstance(items, list):
|
|
676
|
+
self._add(
|
|
677
|
+
LintSeverity.ERROR,
|
|
678
|
+
f"Renderer operation '{op_type}' must contain a list of actions.",
|
|
679
|
+
project=p_name,
|
|
680
|
+
)
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
if op_type == "remove":
|
|
684
|
+
for item in items:
|
|
685
|
+
if not isinstance(item, dict) or not item.get("path"):
|
|
686
|
+
self._add(
|
|
687
|
+
LintSeverity.ERROR,
|
|
688
|
+
"Remove operation item requires 'path'.",
|
|
689
|
+
project=p_name,
|
|
690
|
+
)
|
|
691
|
+
pat = item.get("pattern")
|
|
692
|
+
if pat == "literal":
|
|
693
|
+
self._add(
|
|
694
|
+
LintSeverity.REDUNDANT,
|
|
695
|
+
"'pattern: literal' in remove operation is redundant (default is literal).",
|
|
696
|
+
project=p_name,
|
|
697
|
+
)
|
|
698
|
+
elif pat and pat not in ("literal", "glob", "regex"):
|
|
699
|
+
self._add(
|
|
700
|
+
LintSeverity.ERROR,
|
|
701
|
+
f"Invalid remove pattern mode '{pat}'. Expected 'literal', 'glob', or 'regex'.",
|
|
702
|
+
project=p_name,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
elif op_type == "rename":
|
|
706
|
+
for item in items:
|
|
707
|
+
if (
|
|
708
|
+
not isinstance(item, dict)
|
|
709
|
+
or not item.get("from")
|
|
710
|
+
or not item.get("to")
|
|
711
|
+
):
|
|
712
|
+
self._add(
|
|
713
|
+
LintSeverity.ERROR,
|
|
714
|
+
"Rename operation item requires 'from' and 'to'.",
|
|
715
|
+
project=p_name,
|
|
716
|
+
)
|
|
717
|
+
pat = item.get("pattern")
|
|
718
|
+
if pat == "literal":
|
|
719
|
+
self._add(
|
|
720
|
+
LintSeverity.REDUNDANT,
|
|
721
|
+
"'pattern: literal' in rename operation is redundant (default is literal).",
|
|
722
|
+
project=p_name,
|
|
723
|
+
)
|
|
724
|
+
elif pat and pat != "literal":
|
|
725
|
+
self._add(
|
|
726
|
+
LintSeverity.ERROR,
|
|
727
|
+
f"Rename pattern mode '{pat}' is not supported — only 'literal' is supported.",
|
|
728
|
+
project=p_name,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
elif op_type == "replace":
|
|
732
|
+
for item in items:
|
|
733
|
+
if (
|
|
734
|
+
not isinstance(item, dict)
|
|
735
|
+
or "search" not in item
|
|
736
|
+
or "replace" not in item
|
|
737
|
+
):
|
|
738
|
+
self._add(
|
|
739
|
+
LintSeverity.ERROR,
|
|
740
|
+
"Replace operation item requires 'search' and 'replace'.",
|
|
741
|
+
project=p_name,
|
|
742
|
+
)
|
|
743
|
+
pat = item.get("pattern")
|
|
744
|
+
if pat == "literal":
|
|
745
|
+
self._add(
|
|
746
|
+
LintSeverity.REDUNDANT,
|
|
747
|
+
"'pattern: literal' in replace operation is redundant (default is literal).",
|
|
748
|
+
project=p_name,
|
|
749
|
+
)
|
|
750
|
+
elif pat and pat not in ("literal", "regex"):
|
|
751
|
+
self._add(
|
|
752
|
+
LintSeverity.ERROR,
|
|
753
|
+
f"Invalid replace pattern mode '{pat}'. Expected 'literal' or 'regex'.",
|
|
754
|
+
project=p_name,
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
elif op_type == "add":
|
|
758
|
+
for item in items:
|
|
759
|
+
if (
|
|
760
|
+
not isinstance(item, dict)
|
|
761
|
+
or not item.get("path")
|
|
762
|
+
or "content" not in item
|
|
763
|
+
):
|
|
764
|
+
self._add(
|
|
765
|
+
LintSeverity.ERROR,
|
|
766
|
+
"Add operation item requires 'path' and 'content'.",
|
|
767
|
+
project=p_name,
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
elif op_type == "validate":
|
|
771
|
+
valid_asserts = {
|
|
772
|
+
"file_exists",
|
|
773
|
+
"file_absent",
|
|
774
|
+
"string_exists",
|
|
775
|
+
"string_absent",
|
|
776
|
+
}
|
|
777
|
+
for item in items:
|
|
778
|
+
if not isinstance(item, dict) or not item.get("path"):
|
|
779
|
+
self._add(
|
|
780
|
+
LintSeverity.ERROR,
|
|
781
|
+
"Validate operation item requires 'path'.",
|
|
782
|
+
project=p_name,
|
|
783
|
+
)
|
|
784
|
+
asrt = item.get("assert")
|
|
785
|
+
if asrt not in valid_asserts:
|
|
786
|
+
self._add(
|
|
787
|
+
LintSeverity.ERROR,
|
|
788
|
+
f"Invalid validate assert '{asrt}'. Expected one of {', '.join(sorted(valid_asserts))}.",
|
|
789
|
+
project=p_name,
|
|
790
|
+
)
|
|
791
|
+
if asrt in ("string_exists", "string_absent") and not item.get(
|
|
792
|
+
"pattern"
|
|
793
|
+
):
|
|
794
|
+
self._add(
|
|
795
|
+
LintSeverity.ERROR,
|
|
796
|
+
f"Validate assert '{asrt}' requires 'pattern'.",
|
|
797
|
+
project=p_name,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def lint_config(
|
|
802
|
+
file_path: Union[str, Path], print_output: bool = True
|
|
803
|
+
) -> LintReport:
|
|
804
|
+
"""Lint a config file and optionally print the results."""
|
|
805
|
+
linter = ConfigLinter()
|
|
806
|
+
report = linter.lint_file(file_path)
|
|
807
|
+
if print_output:
|
|
808
|
+
print(report.format_text())
|
|
809
|
+
return report
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
# ---------------------------------------------------------------------------
|
|
813
|
+
# Config Fixer
|
|
814
|
+
# ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
class _CleanDumper(yaml.SafeDumper):
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _str_presenter(dumper, data):
|
|
822
|
+
if "\n" in data:
|
|
823
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
824
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
_CleanDumper.add_representer(str, _str_presenter)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
@dataclass
|
|
831
|
+
class FixIssue:
|
|
832
|
+
message: str
|
|
833
|
+
project: Optional[str] = None
|
|
834
|
+
|
|
835
|
+
def __str__(self) -> str:
|
|
836
|
+
if self.project:
|
|
837
|
+
return f" ✓ (project '{self.project}'): {self.message}"
|
|
838
|
+
return f" ✓ {self.message}"
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@dataclass
|
|
842
|
+
class FixReport:
|
|
843
|
+
fixes: List[FixIssue]
|
|
844
|
+
content: str
|
|
845
|
+
is_valid: bool
|
|
846
|
+
error: Optional[str] = None
|
|
847
|
+
|
|
848
|
+
def format_text(self) -> str:
|
|
849
|
+
if not self.is_valid:
|
|
850
|
+
return f"[ERROR] Could not fix configuration: {self.error}"
|
|
851
|
+
if not self.fixes:
|
|
852
|
+
return "No fixes needed. Config is already clean and optimal."
|
|
853
|
+
out = [f"Applied {len(self.fixes)} fix(es):"]
|
|
854
|
+
by_proj: Dict[Optional[str], List[FixIssue]] = {}
|
|
855
|
+
for fix in self.fixes:
|
|
856
|
+
by_proj.setdefault(fix.project, []).append(fix)
|
|
857
|
+
|
|
858
|
+
if None in by_proj:
|
|
859
|
+
for fix in by_proj[None]:
|
|
860
|
+
out.append(f" ✓ {fix.message}")
|
|
861
|
+
|
|
862
|
+
for proj, fixes in by_proj.items():
|
|
863
|
+
if proj is None:
|
|
864
|
+
continue
|
|
865
|
+
out.append(f"\nProject '{proj}':")
|
|
866
|
+
for fix in fixes:
|
|
867
|
+
out.append(f" ✓ {fix.message}")
|
|
868
|
+
|
|
869
|
+
return "\n".join(out)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
class ConfigFixer:
|
|
873
|
+
def __init__(self):
|
|
874
|
+
self.fixes: List[FixIssue] = []
|
|
875
|
+
|
|
876
|
+
def _add(self, message: str, project: Optional[str] = None):
|
|
877
|
+
self.fixes.append(FixIssue(message=message, project=project))
|
|
878
|
+
|
|
879
|
+
def fix_yaml_string(self, content: str) -> FixReport:
|
|
880
|
+
try:
|
|
881
|
+
data = yaml.safe_load(content)
|
|
882
|
+
except yaml.YAMLError as e:
|
|
883
|
+
return FixReport(fixes=[], content=content, is_valid=False, error=str(e))
|
|
884
|
+
|
|
885
|
+
if data is None:
|
|
886
|
+
return FixReport(fixes=[], content=content, is_valid=True)
|
|
887
|
+
|
|
888
|
+
is_dict_wrapper = isinstance(data, dict) and "projects" in data
|
|
889
|
+
projects_list = data["projects"] if is_dict_wrapper else data
|
|
890
|
+
|
|
891
|
+
if not isinstance(projects_list, list):
|
|
892
|
+
return FixReport(
|
|
893
|
+
fixes=[],
|
|
894
|
+
content=content,
|
|
895
|
+
is_valid=False,
|
|
896
|
+
error="Top-level YAML structure must be a list of projects or a map with a 'projects' list.",
|
|
897
|
+
)
|
|
898
|
+
|
|
899
|
+
for idx, p in enumerate(projects_list):
|
|
900
|
+
if isinstance(p, dict):
|
|
901
|
+
self._fix_project(p)
|
|
902
|
+
|
|
903
|
+
fixed_yaml = yaml.dump(
|
|
904
|
+
data,
|
|
905
|
+
Dumper=_CleanDumper,
|
|
906
|
+
sort_keys=False,
|
|
907
|
+
default_flow_style=False,
|
|
908
|
+
)
|
|
909
|
+
return FixReport(fixes=self.fixes, content=fixed_yaml, is_valid=True)
|
|
910
|
+
|
|
911
|
+
def _fix_project(self, p: Dict[str, Any]):
|
|
912
|
+
p_name = p.get("name") or "<unnamed>"
|
|
913
|
+
|
|
914
|
+
# 1. Misplaced endpoint keys at project level -> move to source
|
|
915
|
+
for k in list(p.keys()):
|
|
916
|
+
if k in _KNOWN_SOURCE_KEYS:
|
|
917
|
+
val = p.pop(k)
|
|
918
|
+
if "source" not in p or not isinstance(p["source"], dict):
|
|
919
|
+
p["source"] = {}
|
|
920
|
+
if k not in p["source"]:
|
|
921
|
+
p["source"][k] = val
|
|
922
|
+
self._add(
|
|
923
|
+
f"Moved '{k}: {val}' from project level into 'source:'",
|
|
924
|
+
project=p_name,
|
|
925
|
+
)
|
|
926
|
+
else:
|
|
927
|
+
self._add(
|
|
928
|
+
f"Removed duplicate project-level '{k}' (already present under source)",
|
|
929
|
+
project=p_name,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# 2. Misplaced project-level keys inside source -> move to project level
|
|
933
|
+
if isinstance(p.get("source"), dict):
|
|
934
|
+
for k in list(p["source"].keys()):
|
|
935
|
+
if k in _VALID_PROJECT_KEYS and k != "type":
|
|
936
|
+
val = p["source"].pop(k)
|
|
937
|
+
if k not in p:
|
|
938
|
+
p[k] = val
|
|
939
|
+
self._add(
|
|
940
|
+
f"Moved '{k}' from 'source:' to project level",
|
|
941
|
+
project=p_name,
|
|
942
|
+
)
|
|
943
|
+
else:
|
|
944
|
+
self._add(
|
|
945
|
+
f"Removed duplicate '{k}' from 'source:' (already present at project level)",
|
|
946
|
+
project=p_name,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
# 3. Misplaced project keys in target -> move to project level
|
|
950
|
+
# and misplaced source keys in target -> move to source
|
|
951
|
+
if isinstance(p.get("target"), dict):
|
|
952
|
+
for k in list(p["target"].keys()):
|
|
953
|
+
if k in _VALID_PROJECT_KEYS and k != "type":
|
|
954
|
+
val = p["target"].pop(k)
|
|
955
|
+
if k not in p:
|
|
956
|
+
p[k] = val
|
|
957
|
+
self._add(
|
|
958
|
+
f"Moved '{k}' from 'target:' to project level",
|
|
959
|
+
project=p_name,
|
|
960
|
+
)
|
|
961
|
+
else:
|
|
962
|
+
self._add(
|
|
963
|
+
f"Removed duplicate '{k}' from 'target:' (already present at project level)",
|
|
964
|
+
project=p_name,
|
|
965
|
+
)
|
|
966
|
+
elif k in _KNOWN_SOURCE_KEYS:
|
|
967
|
+
val = p["target"].pop(k)
|
|
968
|
+
if "source" not in p or not isinstance(p["source"], dict):
|
|
969
|
+
p["source"] = {}
|
|
970
|
+
if k not in p["source"]:
|
|
971
|
+
p["source"][k] = val
|
|
972
|
+
self._add(
|
|
973
|
+
f"Moved '{k}: {val}' from 'target:' into 'source:'",
|
|
974
|
+
project=p_name,
|
|
975
|
+
)
|
|
976
|
+
else:
|
|
977
|
+
self._add(
|
|
978
|
+
f"Removed duplicate '{k}' from 'target:' (already present under source)",
|
|
979
|
+
project=p_name,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# 4. Redundant project-level options
|
|
983
|
+
if p.get("enabled") is True:
|
|
984
|
+
del p["enabled"]
|
|
985
|
+
self._add("Removed redundant 'enabled: true'", project=p_name)
|
|
986
|
+
|
|
987
|
+
for key in ("preserve_description", "preserve_release_description"):
|
|
988
|
+
if p.get(key) is True:
|
|
989
|
+
del p[key]
|
|
990
|
+
self._add(f"Removed redundant '{key}: true'", project=p_name)
|
|
991
|
+
|
|
992
|
+
for key in ("sync_assets", "preserve_assets", "include_assets"):
|
|
993
|
+
if p.get(key) is False:
|
|
994
|
+
del p[key]
|
|
995
|
+
self._add(f"Removed redundant '{key}: false'", project=p_name)
|
|
996
|
+
|
|
997
|
+
if p.get("stream_assets") is False:
|
|
998
|
+
del p["stream_assets"]
|
|
999
|
+
self._add("Removed redundant 'stream_assets: false'", project=p_name)
|
|
1000
|
+
|
|
1001
|
+
# 3. Source endpoint fixes
|
|
1002
|
+
if isinstance(p.get("source"), dict):
|
|
1003
|
+
src = p["source"]
|
|
1004
|
+
if src.get("mode") == "release":
|
|
1005
|
+
del src["mode"]
|
|
1006
|
+
self._add(
|
|
1007
|
+
"Removed redundant 'source.mode: release'", project=p_name
|
|
1008
|
+
)
|
|
1009
|
+
if src.get("include_prereleases") is False:
|
|
1010
|
+
del src["include_prereleases"]
|
|
1011
|
+
self._add(
|
|
1012
|
+
"Removed redundant 'source.include_prereleases: false'",
|
|
1013
|
+
project=p_name,
|
|
1014
|
+
)
|
|
1015
|
+
if src.get("include_drafts") is False:
|
|
1016
|
+
del src["include_drafts"]
|
|
1017
|
+
self._add(
|
|
1018
|
+
"Removed redundant 'source.include_drafts: false'",
|
|
1019
|
+
project=p_name,
|
|
1020
|
+
)
|
|
1021
|
+
if src.get("sync_from") == "":
|
|
1022
|
+
del src["sync_from"]
|
|
1023
|
+
self._add(
|
|
1024
|
+
"Removed redundant 'source.sync_from: \"\"'",
|
|
1025
|
+
project=p_name,
|
|
1026
|
+
)
|
|
1027
|
+
if src.get("tag_pattern") == "*":
|
|
1028
|
+
del src["tag_pattern"]
|
|
1029
|
+
self._add(
|
|
1030
|
+
"Removed redundant 'source.tag_pattern: \"*\"'",
|
|
1031
|
+
project=p_name,
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
# 4. Target endpoint fixes
|
|
1035
|
+
if isinstance(p.get("target"), dict):
|
|
1036
|
+
tgt = p["target"]
|
|
1037
|
+
if tgt.get("branch") == "main":
|
|
1038
|
+
del tgt["branch"]
|
|
1039
|
+
self._add(
|
|
1040
|
+
"Removed redundant 'target.branch: main'", project=p_name
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
# 5. Retry fixes
|
|
1044
|
+
if isinstance(p.get("retry"), dict):
|
|
1045
|
+
ret = p["retry"]
|
|
1046
|
+
if ret.get("max_attempts") == 3:
|
|
1047
|
+
del ret["max_attempts"]
|
|
1048
|
+
if ret.get("backoff_seconds") == 2:
|
|
1049
|
+
del ret["backoff_seconds"]
|
|
1050
|
+
if len(ret) == 0:
|
|
1051
|
+
del p["retry"]
|
|
1052
|
+
self._add(
|
|
1053
|
+
"Removed default 'retry' configuration block",
|
|
1054
|
+
project=p_name,
|
|
1055
|
+
)
|
|
1056
|
+
else:
|
|
1057
|
+
self._add(
|
|
1058
|
+
"Removed redundant retry defaults", project=p_name
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
# 6. Renderer fixes
|
|
1062
|
+
if isinstance(p.get("renderer"), dict):
|
|
1063
|
+
ren = p["renderer"]
|
|
1064
|
+
if isinstance(ren.get("operations"), list):
|
|
1065
|
+
for op in ren["operations"]:
|
|
1066
|
+
if not isinstance(op, dict):
|
|
1067
|
+
continue
|
|
1068
|
+
for op_type, items in op.items():
|
|
1069
|
+
if isinstance(items, list):
|
|
1070
|
+
for item in items:
|
|
1071
|
+
if (
|
|
1072
|
+
isinstance(item, dict)
|
|
1073
|
+
and item.get("pattern") == "literal"
|
|
1074
|
+
):
|
|
1075
|
+
del item["pattern"]
|
|
1076
|
+
self._add(
|
|
1077
|
+
f"Removed redundant 'pattern: literal' in {op_type} operation",
|
|
1078
|
+
project=p_name,
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def fix_config(
|
|
1083
|
+
file_path: Union[str, Path],
|
|
1084
|
+
write_back: bool = True,
|
|
1085
|
+
print_output: bool = True,
|
|
1086
|
+
) -> FixReport:
|
|
1087
|
+
"""Fix misplaced and redundant options in config file."""
|
|
1088
|
+
path = Path(file_path)
|
|
1089
|
+
if not path.exists():
|
|
1090
|
+
report = FixReport(
|
|
1091
|
+
fixes=[], content="", is_valid=False, error=f"File not found: {path}"
|
|
1092
|
+
)
|
|
1093
|
+
if print_output:
|
|
1094
|
+
print(report.format_text())
|
|
1095
|
+
return report
|
|
1096
|
+
|
|
1097
|
+
content = path.read_text(encoding="utf-8")
|
|
1098
|
+
fixer = ConfigFixer()
|
|
1099
|
+
report = fixer.fix_yaml_string(content)
|
|
1100
|
+
if report.is_valid and write_back and report.fixes:
|
|
1101
|
+
path.write_text(report.content, encoding="utf-8")
|
|
1102
|
+
if print_output:
|
|
1103
|
+
print(report.format_text())
|
|
1104
|
+
return report
|
|
1105
|
+
|