gitacross 2.0.0__tar.gz → 2.1.0__tar.gz
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-2.0.0/src/gitacross.egg-info → gitacross-2.1.0}/PKG-INFO +11 -1
- {gitacross-2.0.0 → gitacross-2.1.0}/README.md +9 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/pyproject.toml +14 -1
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/__init__.py +2 -1
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/__main__.py +0 -1
- gitacross-2.1.0/src/gitacross/cli.py +105 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/config.py +8 -1
- gitacross-2.1.0/src/gitacross/linter/__init__.py +26 -0
- gitacross-2.1.0/src/gitacross/linter/fixer.py +282 -0
- gitacross-2.1.0/src/gitacross/linter/keys.py +39 -0
- gitacross-2.1.0/src/gitacross/linter/models.py +145 -0
- gitacross-2.0.0/src/gitacross/linter.py → gitacross-2.1.0/src/gitacross/linter/validator.py +24 -444
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/main.py +232 -284
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/providers/__init__.py +16 -3
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/providers/base.py +8 -1
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/providers/gitea.py +10 -5
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/providers/github.py +0 -1
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/renderer.py +8 -10
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/source.py +10 -9
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/target.py +6 -6
- {gitacross-2.0.0 → gitacross-2.1.0/src/gitacross.egg-info}/PKG-INFO +11 -1
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross.egg-info/SOURCES.txt +16 -2
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross.egg-info/requires.txt +1 -0
- gitacross-2.1.0/tests/test_config.py +488 -0
- gitacross-2.1.0/tests/test_git.py +341 -0
- gitacross-2.1.0/tests/test_integration.py +1205 -0
- gitacross-2.1.0/tests/test_linter.py +1030 -0
- gitacross-2.1.0/tests/test_providers.py +643 -0
- gitacross-2.1.0/tests/test_renderer.py +648 -0
- gitacross-2.1.0/tests/test_source.py +779 -0
- gitacross-2.1.0/tests/test_state.py +77 -0
- gitacross-2.1.0/tests/test_sync_api.py +1142 -0
- gitacross-2.1.0/tests/test_target.py +223 -0
- gitacross-2.0.0/tests/test_sync.py +0 -4560
- {gitacross-2.0.0 → gitacross-2.1.0}/LICENSE +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/setup.cfg +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/git.py +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/retry.py +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross/state.py +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.0.0 → gitacross-2.1.0}/src/gitacross.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
|
|
5
5
|
Author: Matthew Deik
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,7 @@ License-File: LICENSE
|
|
|
22
22
|
Requires-Dist: PyYAML<7.0,>=6.0
|
|
23
23
|
Provides-Extra: dev
|
|
24
24
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
25
26
|
Requires-Dist: build; extra == "dev"
|
|
26
27
|
Requires-Dist: twine; extra == "dev"
|
|
27
28
|
Dynamic: license-file
|
|
@@ -77,6 +78,15 @@ For development, install the local checkout:
|
|
|
77
78
|
pip install -e .
|
|
78
79
|
```
|
|
79
80
|
|
|
81
|
+
### Tests
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e .[dev] # pytest + pytest-cov
|
|
85
|
+
pytest # run the test suite
|
|
86
|
+
pytest --cov=gitacross # run with a coverage floor (90% line + branch)
|
|
87
|
+
ruff check src tests # lint
|
|
88
|
+
```
|
|
89
|
+
|
|
80
90
|
### Config
|
|
81
91
|
|
|
82
92
|
GitAcross reads a YAML file listing the mirrors you want. Each entry in the `projects` list is a **project**: it has a `source` (where releases come from) and a `target` (where they go).
|
|
@@ -49,6 +49,15 @@ For development, install the local checkout:
|
|
|
49
49
|
pip install -e .
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
### Tests
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install -e .[dev] # pytest + pytest-cov
|
|
56
|
+
pytest # run the test suite
|
|
57
|
+
pytest --cov=gitacross # run with a coverage floor (90% line + branch)
|
|
58
|
+
ruff check src tests # lint
|
|
59
|
+
```
|
|
60
|
+
|
|
52
61
|
### Config
|
|
53
62
|
|
|
54
63
|
GitAcross reads a YAML file listing the mirrors you want. Each entry in the `projects` list is a **project**: it has a `source` (where releases come from) and a `target` (where they go).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitacross"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -33,6 +33,7 @@ dependencies = [
|
|
|
33
33
|
[project.optional-dependencies]
|
|
34
34
|
dev = [
|
|
35
35
|
"pytest>=7.0",
|
|
36
|
+
"pytest-cov>=4.0",
|
|
36
37
|
"build",
|
|
37
38
|
"twine",
|
|
38
39
|
]
|
|
@@ -46,3 +47,15 @@ where = ["src"]
|
|
|
46
47
|
[tool.pytest.ini_options]
|
|
47
48
|
pythonpath = ["src"]
|
|
48
49
|
testpaths = ["tests"]
|
|
50
|
+
|
|
51
|
+
[tool.coverage.run]
|
|
52
|
+
source = ["gitacross"]
|
|
53
|
+
# Keep branch measurement on for the report, but don't gate on it —
|
|
54
|
+
# a 100% branch gate incentivizes line-touching tests over behavior tests.
|
|
55
|
+
branch = true
|
|
56
|
+
|
|
57
|
+
[tool.coverage.report]
|
|
58
|
+
# Enforcement floor with headroom: catches regressions without demanding
|
|
59
|
+
# 100% (which encourages tests-for-the-sake-of-it on defensive branches).
|
|
60
|
+
fail_under = 90
|
|
61
|
+
show_missing = true
|
|
@@ -28,6 +28,7 @@ from importlib.metadata import PackageNotFoundError
|
|
|
28
28
|
from importlib.metadata import version as _installed_version
|
|
29
29
|
from pathlib import Path
|
|
30
30
|
|
|
31
|
+
from .cli import main # the CLI lives in cli.py; main.py re-exports it too
|
|
31
32
|
from .config import Config, ProjectConfig
|
|
32
33
|
from .linter import (
|
|
33
34
|
ConfigFixer,
|
|
@@ -40,7 +41,7 @@ from .linter import (
|
|
|
40
41
|
fix_config,
|
|
41
42
|
lint_config,
|
|
42
43
|
)
|
|
43
|
-
from .main import
|
|
44
|
+
from .main import run, sync_project
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def _package_version() -> str:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Command-line interface for GitAcross.
|
|
2
|
+
|
|
3
|
+
The sync logic lives in :mod:`gitacross.main`; this module only parses
|
|
4
|
+
``argv`` and maps results to process exit codes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _setup_logging(verbose):
|
|
16
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=level,
|
|
19
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
20
|
+
datefmt="%H:%M:%S",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
from .main import DEFAULT_WORK_DIR, run
|
|
26
|
+
|
|
27
|
+
parser = argparse.ArgumentParser(description="Sync releases from source to target")
|
|
28
|
+
_ = parser.add_argument("--config", required=True, help="Path to config.yml")
|
|
29
|
+
_ = parser.add_argument("--project", help="Sync only this project (by name)")
|
|
30
|
+
_ = parser.add_argument(
|
|
31
|
+
"--dry-run", action="store_true", help="Print changes without pushing"
|
|
32
|
+
)
|
|
33
|
+
_ = parser.add_argument(
|
|
34
|
+
"--reset",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Clear state and cache before running (fresh start)",
|
|
37
|
+
)
|
|
38
|
+
_ = parser.add_argument(
|
|
39
|
+
"--lint",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="Lint config file for YAML errors, invalid settings, and redundant options",
|
|
42
|
+
)
|
|
43
|
+
_ = parser.add_argument(
|
|
44
|
+
"--fix",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Automatically fix misplaced keys and remove redundant options in config file",
|
|
47
|
+
)
|
|
48
|
+
_ = parser.add_argument(
|
|
49
|
+
"--workdir",
|
|
50
|
+
default=DEFAULT_WORK_DIR,
|
|
51
|
+
help="Directory for state.yml and cache/ (default: .gitsync)",
|
|
52
|
+
)
|
|
53
|
+
_ = parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
|
|
54
|
+
args = parser.parse_args()
|
|
55
|
+
|
|
56
|
+
# Convert argparse's Any values into concrete types (cast is the escape hatch).
|
|
57
|
+
verbose = cast(bool, args.verbose)
|
|
58
|
+
config_path = Path(cast(str, args.config))
|
|
59
|
+
fix_flag = cast(bool, args.fix)
|
|
60
|
+
lint_flag = cast(bool, args.lint)
|
|
61
|
+
project_filter = None if args.project is None else cast(str, args.project)
|
|
62
|
+
dry_run_flag = cast(bool, args.dry_run)
|
|
63
|
+
reset_flag = cast(bool, args.reset)
|
|
64
|
+
workdir = cast(str, args.workdir)
|
|
65
|
+
|
|
66
|
+
_setup_logging(verbose)
|
|
67
|
+
|
|
68
|
+
if fix_flag:
|
|
69
|
+
if not config_path.exists():
|
|
70
|
+
sys.exit(f"Config file not found: {config_path}")
|
|
71
|
+
from .linter import fix_config, lint_config
|
|
72
|
+
|
|
73
|
+
fix_report = fix_config(
|
|
74
|
+
config_path, write_back=not dry_run_flag, dry_run=dry_run_flag
|
|
75
|
+
)
|
|
76
|
+
if not fix_report.is_valid:
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
if lint_flag:
|
|
79
|
+
print()
|
|
80
|
+
lint_report = lint_config(config_path)
|
|
81
|
+
sys.exit(0 if lint_report.is_valid else 1)
|
|
82
|
+
sys.exit(0)
|
|
83
|
+
|
|
84
|
+
if lint_flag:
|
|
85
|
+
if not config_path.exists():
|
|
86
|
+
sys.exit(f"Config file not found: {config_path}")
|
|
87
|
+
from .linter import lint_config
|
|
88
|
+
|
|
89
|
+
report = lint_config(config_path)
|
|
90
|
+
sys.exit(0 if report.is_valid else 1)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
results = run(
|
|
94
|
+
config_path,
|
|
95
|
+
project=project_filter,
|
|
96
|
+
dry_run=dry_run_flag,
|
|
97
|
+
reset=reset_flag,
|
|
98
|
+
work_dir=workdir,
|
|
99
|
+
)
|
|
100
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
101
|
+
sys.exit(str(exc))
|
|
102
|
+
|
|
103
|
+
if any(not r["synced"] for r in results):
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
@@ -7,6 +7,8 @@ from typing import final
|
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
10
|
+
from .providers import REMOTE_TYPES
|
|
11
|
+
|
|
10
12
|
logger = logging.getLogger(__name__)
|
|
11
13
|
|
|
12
14
|
VALID_PROJECT_KEYS = {
|
|
@@ -164,7 +166,12 @@ class _EndpointConfig:
|
|
|
164
166
|
|
|
165
167
|
@property
|
|
166
168
|
def is_remote(self):
|
|
167
|
-
return self.type in
|
|
169
|
+
return self.type in REMOTE_TYPES
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def repo_slug(self):
|
|
173
|
+
"""Filesystem-safe repo identifier (``owner/name`` → ``owner_name``)."""
|
|
174
|
+
return self.repo.replace("/", "_")
|
|
168
175
|
|
|
169
176
|
@property
|
|
170
177
|
def owner(self):
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Config linter and fixer for GitAcross.
|
|
2
|
+
|
|
3
|
+
``lint_config`` validates a config; ``fix_config`` rewrites it in place to
|
|
4
|
+
remove redundant and misplaced options.
|
|
5
|
+
|
|
6
|
+
Implementation lives in three modules:
|
|
7
|
+
|
|
8
|
+
* :mod:`gitacross.linter.models` — report/issue data types and YAML dump helper
|
|
9
|
+
* :mod:`gitacross.linter.validator` — :class:`ConfigLinter` (the checks)
|
|
10
|
+
* :mod:`gitacross.linter.fixer` — :class:`ConfigFixer` (the rewrites)
|
|
11
|
+
"""
|
|
12
|
+
from .fixer import ConfigFixer, fix_config
|
|
13
|
+
from .models import FixIssue, FixReport, LintIssue, LintReport, LintSeverity
|
|
14
|
+
from .validator import ConfigLinter, lint_config
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ConfigFixer",
|
|
18
|
+
"ConfigLinter",
|
|
19
|
+
"FixIssue",
|
|
20
|
+
"FixReport",
|
|
21
|
+
"LintIssue",
|
|
22
|
+
"LintReport",
|
|
23
|
+
"LintSeverity",
|
|
24
|
+
"fix_config",
|
|
25
|
+
"lint_config",
|
|
26
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Config fixer — rewrites configs to remove redundant/misplaced options.
|
|
2
|
+
"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from ..config import KNOWN_SOURCE_KEYS, VALID_PROJECT_KEYS
|
|
11
|
+
from .models import FixIssue, FixReport, _CleanDumper
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigFixer:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.fixes: list[FixIssue] = []
|
|
17
|
+
|
|
18
|
+
def __repr__(self):
|
|
19
|
+
return f"ConfigFixer(fixes={len(self.fixes)})"
|
|
20
|
+
|
|
21
|
+
def _add(self, message: str, project: str | None = None):
|
|
22
|
+
self.fixes.append(FixIssue(message=message, project=project))
|
|
23
|
+
|
|
24
|
+
def fix_yaml_string(self, content: str) -> FixReport:
|
|
25
|
+
# Fresh accumulator — reusing an instance must not leak prior fixes
|
|
26
|
+
self.fixes = []
|
|
27
|
+
try:
|
|
28
|
+
data = yaml.safe_load(content)
|
|
29
|
+
except yaml.YAMLError as e:
|
|
30
|
+
return FixReport(fixes=[], content=content, is_valid=False, error=str(e))
|
|
31
|
+
|
|
32
|
+
if data is None:
|
|
33
|
+
return FixReport(fixes=[], content=content, is_valid=True)
|
|
34
|
+
|
|
35
|
+
is_dict_wrapper = isinstance(data, dict) and "projects" in data
|
|
36
|
+
projects_list = data["projects"] if is_dict_wrapper else data
|
|
37
|
+
|
|
38
|
+
if not isinstance(projects_list, list):
|
|
39
|
+
return FixReport(
|
|
40
|
+
fixes=[],
|
|
41
|
+
content=content,
|
|
42
|
+
is_valid=False,
|
|
43
|
+
error="Top-level YAML structure must be a list of projects or a map with a 'projects' list.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
for _idx, p in enumerate(projects_list):
|
|
47
|
+
if isinstance(p, dict):
|
|
48
|
+
self._fix_project(p)
|
|
49
|
+
|
|
50
|
+
fixed_yaml = yaml.dump(
|
|
51
|
+
data,
|
|
52
|
+
Dumper=_CleanDumper,
|
|
53
|
+
sort_keys=False,
|
|
54
|
+
default_flow_style=False,
|
|
55
|
+
)
|
|
56
|
+
return FixReport(fixes=self.fixes, content=fixed_yaml, is_valid=True)
|
|
57
|
+
|
|
58
|
+
def _fix_project(self, p: dict[str, Any]):
|
|
59
|
+
p_name = p.get("name") or "<unnamed>"
|
|
60
|
+
|
|
61
|
+
# 1. Misplaced endpoint keys at project level -> move to source
|
|
62
|
+
for k in list(p.keys()):
|
|
63
|
+
if k in KNOWN_SOURCE_KEYS:
|
|
64
|
+
val = p.pop(k)
|
|
65
|
+
if "source" not in p or not isinstance(p["source"], dict):
|
|
66
|
+
p["source"] = {}
|
|
67
|
+
if k not in p["source"]:
|
|
68
|
+
p["source"][k] = val
|
|
69
|
+
self._add(
|
|
70
|
+
f"Moved '{k}: {val}' from project level into 'source:'",
|
|
71
|
+
project=p_name,
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
self._add(
|
|
75
|
+
f"Removed duplicate project-level '{k}' (already present under source)",
|
|
76
|
+
project=p_name,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# 2. Misplaced project-level keys inside source -> move to project level
|
|
80
|
+
if isinstance(p.get("source"), dict):
|
|
81
|
+
for k in list(p["source"].keys()):
|
|
82
|
+
if k in VALID_PROJECT_KEYS and k != "type":
|
|
83
|
+
val = p["source"].pop(k)
|
|
84
|
+
if k not in p:
|
|
85
|
+
p[k] = val
|
|
86
|
+
self._add(
|
|
87
|
+
f"Moved '{k}' from 'source:' to project level",
|
|
88
|
+
project=p_name,
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
self._add(
|
|
92
|
+
f"Removed duplicate '{k}' from 'source:' (already present at project level)",
|
|
93
|
+
project=p_name,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# 3. Misplaced project keys in target -> move to project level
|
|
97
|
+
# and misplaced source keys in target -> move to source
|
|
98
|
+
if isinstance(p.get("target"), dict):
|
|
99
|
+
for k in list(p["target"].keys()):
|
|
100
|
+
if k in VALID_PROJECT_KEYS and k != "type":
|
|
101
|
+
val = p["target"].pop(k)
|
|
102
|
+
if k not in p:
|
|
103
|
+
p[k] = val
|
|
104
|
+
self._add(
|
|
105
|
+
f"Moved '{k}' from 'target:' to project level",
|
|
106
|
+
project=p_name,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
self._add(
|
|
110
|
+
f"Removed duplicate '{k}' from 'target:' (already present at project level)",
|
|
111
|
+
project=p_name,
|
|
112
|
+
)
|
|
113
|
+
elif k in KNOWN_SOURCE_KEYS:
|
|
114
|
+
val = p["target"].pop(k)
|
|
115
|
+
if "source" not in p or not isinstance(p["source"], dict):
|
|
116
|
+
p["source"] = {}
|
|
117
|
+
if k not in p["source"]:
|
|
118
|
+
p["source"][k] = val
|
|
119
|
+
self._add(
|
|
120
|
+
f"Moved '{k}: {val}' from 'target:' into 'source:'",
|
|
121
|
+
project=p_name,
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
self._add(
|
|
125
|
+
f"Removed duplicate '{k}' from 'target:' (already present under source)",
|
|
126
|
+
project=p_name,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# 4. Redundant project-level options
|
|
130
|
+
if p.get("enabled") is True:
|
|
131
|
+
del p["enabled"]
|
|
132
|
+
self._add("Removed redundant 'enabled: true'", project=p_name)
|
|
133
|
+
|
|
134
|
+
for key in ("preserve_description",):
|
|
135
|
+
if p.get(key) is True:
|
|
136
|
+
del p[key]
|
|
137
|
+
self._add(f"Removed redundant '{key}: true'", project=p_name)
|
|
138
|
+
|
|
139
|
+
for key in ("sync_assets",):
|
|
140
|
+
if p.get(key) is False:
|
|
141
|
+
del p[key]
|
|
142
|
+
self._add(f"Removed redundant '{key}: false'", project=p_name)
|
|
143
|
+
|
|
144
|
+
if p.get("stream_assets") is False:
|
|
145
|
+
del p["stream_assets"]
|
|
146
|
+
self._add("Removed redundant 'stream_assets: false'", project=p_name)
|
|
147
|
+
|
|
148
|
+
# 3. Source endpoint fixes
|
|
149
|
+
if isinstance(p.get("source"), dict):
|
|
150
|
+
src = p["source"]
|
|
151
|
+
if src.get("mode") == "release":
|
|
152
|
+
del src["mode"]
|
|
153
|
+
self._add(
|
|
154
|
+
"Removed redundant 'source.mode: release'", project=p_name
|
|
155
|
+
)
|
|
156
|
+
if src.get("include_prereleases") is False:
|
|
157
|
+
del src["include_prereleases"]
|
|
158
|
+
self._add(
|
|
159
|
+
"Removed redundant 'source.include_prereleases: false'",
|
|
160
|
+
project=p_name,
|
|
161
|
+
)
|
|
162
|
+
if src.get("include_drafts") is False:
|
|
163
|
+
del src["include_drafts"]
|
|
164
|
+
self._add(
|
|
165
|
+
"Removed redundant 'source.include_drafts: false'",
|
|
166
|
+
project=p_name,
|
|
167
|
+
)
|
|
168
|
+
if src.get("sync_from") == "":
|
|
169
|
+
del src["sync_from"]
|
|
170
|
+
self._add(
|
|
171
|
+
"Removed redundant 'source.sync_from: \"\"'",
|
|
172
|
+
project=p_name,
|
|
173
|
+
)
|
|
174
|
+
if src.get("tag_pattern") == "*":
|
|
175
|
+
del src["tag_pattern"]
|
|
176
|
+
self._add(
|
|
177
|
+
"Removed redundant 'source.tag_pattern: \"*\"'",
|
|
178
|
+
project=p_name,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# 4. Target endpoint fixes
|
|
182
|
+
if isinstance(p.get("target"), dict):
|
|
183
|
+
tgt = p["target"]
|
|
184
|
+
if tgt.get("branch") == "main":
|
|
185
|
+
del tgt["branch"]
|
|
186
|
+
self._add(
|
|
187
|
+
"Removed redundant 'target.branch: main'", project=p_name
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# 5. Retry fixes
|
|
191
|
+
if isinstance(p.get("retry"), dict):
|
|
192
|
+
ret = p["retry"]
|
|
193
|
+
if ret.get("max_attempts") == 3:
|
|
194
|
+
del ret["max_attempts"]
|
|
195
|
+
if ret.get("backoff_seconds") == 2:
|
|
196
|
+
del ret["backoff_seconds"]
|
|
197
|
+
if len(ret) == 0:
|
|
198
|
+
del p["retry"]
|
|
199
|
+
self._add(
|
|
200
|
+
"Removed default 'retry' configuration block",
|
|
201
|
+
project=p_name,
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
self._add(
|
|
205
|
+
"Removed redundant retry defaults", project=p_name
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# 6. Renderer fixes
|
|
209
|
+
if isinstance(p.get("renderer"), dict):
|
|
210
|
+
ren = p["renderer"]
|
|
211
|
+
if isinstance(ren.get("operations"), list):
|
|
212
|
+
for op in ren["operations"]:
|
|
213
|
+
if not isinstance(op, dict):
|
|
214
|
+
continue
|
|
215
|
+
for op_type, items in op.items():
|
|
216
|
+
if isinstance(items, list):
|
|
217
|
+
for item in items:
|
|
218
|
+
if not isinstance(item, dict):
|
|
219
|
+
continue
|
|
220
|
+
if item.get("pattern") == "literal":
|
|
221
|
+
del item["pattern"]
|
|
222
|
+
self._add(
|
|
223
|
+
f"Removed redundant 'pattern: literal' in {op_type} operation",
|
|
224
|
+
project=p_name,
|
|
225
|
+
)
|
|
226
|
+
if op_type == "replace":
|
|
227
|
+
if item.get("case_sensitive") is True:
|
|
228
|
+
del item["case_sensitive"]
|
|
229
|
+
self._add(
|
|
230
|
+
"Removed redundant 'case_sensitive: true' in replace operation",
|
|
231
|
+
project=p_name,
|
|
232
|
+
)
|
|
233
|
+
if item.get("match_case") is False:
|
|
234
|
+
del item["match_case"]
|
|
235
|
+
self._add(
|
|
236
|
+
"Removed redundant 'match_case: false' in replace operation",
|
|
237
|
+
project=p_name,
|
|
238
|
+
)
|
|
239
|
+
if op_type == "validate" and item.get(
|
|
240
|
+
"case_sensitive"
|
|
241
|
+
) is True:
|
|
242
|
+
del item["case_sensitive"]
|
|
243
|
+
self._add(
|
|
244
|
+
"Removed redundant 'case_sensitive: true' in validate operation",
|
|
245
|
+
project=p_name,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fix_config(
|
|
251
|
+
config: str | Path,
|
|
252
|
+
write_back: bool = True,
|
|
253
|
+
print_output: bool = True,
|
|
254
|
+
dry_run: bool = False,
|
|
255
|
+
) -> FixReport:
|
|
256
|
+
"""Fix misplaced and redundant options in config file.
|
|
257
|
+
|
|
258
|
+
Path-only: unlike :func:`lint_config`, this writes the fixed YAML back to
|
|
259
|
+
the file (when *write_back* is true), so it requires a real file path.
|
|
260
|
+
Pass ``dry_run=True`` to preview the fixes without writing anything.
|
|
261
|
+
"""
|
|
262
|
+
path = Path(config)
|
|
263
|
+
if not path.exists():
|
|
264
|
+
report = FixReport(
|
|
265
|
+
fixes=[], content="", is_valid=False, error=f"File not found: {path}"
|
|
266
|
+
)
|
|
267
|
+
if print_output:
|
|
268
|
+
print(report.format_text())
|
|
269
|
+
return report
|
|
270
|
+
|
|
271
|
+
content = path.read_text(encoding="utf-8")
|
|
272
|
+
fixer = ConfigFixer()
|
|
273
|
+
report = fixer.fix_yaml_string(content)
|
|
274
|
+
if report.is_valid and write_back and not dry_run and report.fixes:
|
|
275
|
+
_ = path.write_text(report.content, encoding="utf-8")
|
|
276
|
+
if print_output:
|
|
277
|
+
if dry_run and report.fixes:
|
|
278
|
+
print(f"[DRY-RUN] Would apply {len(report.fixes)} fix(es):")
|
|
279
|
+
print(report.format_text())
|
|
280
|
+
return report
|
|
281
|
+
|
|
282
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Valid key sets for source/target endpoints.
|
|
2
|
+
|
|
3
|
+
Separate from the validator so the fixer never pulls in validation code.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
_VALID_SOURCE_KEYS_REMOTE = {
|
|
8
|
+
"type",
|
|
9
|
+
"repo",
|
|
10
|
+
"api",
|
|
11
|
+
"token",
|
|
12
|
+
"mode",
|
|
13
|
+
"branch",
|
|
14
|
+
"include_prereleases",
|
|
15
|
+
"include_drafts",
|
|
16
|
+
"sync_from",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_VALID_SOURCE_KEYS_LOCAL = {
|
|
20
|
+
"type",
|
|
21
|
+
"path",
|
|
22
|
+
"tag_pattern",
|
|
23
|
+
"sync_from",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_VALID_TARGET_KEYS_REMOTE = {
|
|
27
|
+
"type",
|
|
28
|
+
"repo",
|
|
29
|
+
"api",
|
|
30
|
+
"token",
|
|
31
|
+
"branch",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_VALID_TARGET_KEYS_LOCAL = {
|
|
35
|
+
"type",
|
|
36
|
+
"path",
|
|
37
|
+
"branch",
|
|
38
|
+
}
|
|
39
|
+
|