mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
feat(ci): add test impact analysis for selective test execution (#9844)
This commit is contained in:
Executable
+257
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Impact Analysis Script
|
||||
|
||||
Analyzes changed files and determines which tests need to run.
|
||||
Outputs GitHub Actions compatible outputs.
|
||||
|
||||
Usage:
|
||||
python test-impact.py <changed_files...>
|
||||
python test-impact.py --from-stdin # Read files from stdin (one per line)
|
||||
|
||||
Outputs (for GitHub Actions):
|
||||
- run-all: "true" if critical paths changed
|
||||
- sdk-tests: Space-separated list of SDK test paths
|
||||
- api-tests: Space-separated list of API test paths
|
||||
- ui-e2e: Space-separated list of UI E2E test paths
|
||||
- modules: Comma-separated list of affected module names
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load test-impact.yml configuration."""
|
||||
config_path = Path(__file__).parent.parent / "test-impact.yml"
|
||||
with open(config_path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def matches_pattern(file_path: str, pattern: str) -> bool:
|
||||
"""Check if file path matches a glob pattern."""
|
||||
# Normalize paths
|
||||
file_path = file_path.strip("/")
|
||||
pattern = pattern.strip("/")
|
||||
|
||||
# Handle ** patterns
|
||||
if "**" in pattern:
|
||||
# Convert glob pattern to work with fnmatch
|
||||
# e.g., "prowler/lib/**" matches "prowler/lib/check/foo.py"
|
||||
base = pattern.replace("/**", "")
|
||||
if file_path.startswith(base):
|
||||
return True
|
||||
# Also try standard fnmatch
|
||||
return fnmatch.fnmatch(file_path, pattern)
|
||||
|
||||
return fnmatch.fnmatch(file_path, pattern)
|
||||
|
||||
|
||||
def filter_ignored_files(
|
||||
changed_files: list[str], ignored_paths: list[str]
|
||||
) -> list[str]:
|
||||
"""Filter out files that match ignored patterns."""
|
||||
filtered = []
|
||||
for file_path in changed_files:
|
||||
is_ignored = False
|
||||
for pattern in ignored_paths:
|
||||
if matches_pattern(file_path, pattern):
|
||||
print(f" [IGNORED] {file_path} matches {pattern}", file=sys.stderr)
|
||||
is_ignored = True
|
||||
break
|
||||
if not is_ignored:
|
||||
filtered.append(file_path)
|
||||
return filtered
|
||||
|
||||
|
||||
def check_critical_paths(changed_files: list[str], critical_paths: list[str]) -> bool:
|
||||
"""Check if any changed file matches critical paths."""
|
||||
for file_path in changed_files:
|
||||
for pattern in critical_paths:
|
||||
if matches_pattern(file_path, pattern):
|
||||
print(f" [CRITICAL] {file_path} matches {pattern}", file=sys.stderr)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_affected_modules(
|
||||
changed_files: list[str], modules: list[dict]
|
||||
) -> dict[str, dict]:
|
||||
"""Find which modules are affected by changed files."""
|
||||
affected = {}
|
||||
|
||||
for file_path in changed_files:
|
||||
for module in modules:
|
||||
module_name = module["name"]
|
||||
match_patterns = module.get("match", [])
|
||||
|
||||
for pattern in match_patterns:
|
||||
if matches_pattern(file_path, pattern):
|
||||
if module_name not in affected:
|
||||
affected[module_name] = {
|
||||
"tests": set(),
|
||||
"e2e": set(),
|
||||
"matched_files": [],
|
||||
}
|
||||
affected[module_name]["matched_files"].append(file_path)
|
||||
|
||||
# Add test patterns
|
||||
for test_pattern in module.get("tests", []):
|
||||
affected[module_name]["tests"].add(test_pattern)
|
||||
|
||||
# Add E2E patterns
|
||||
for e2e_pattern in module.get("e2e", []):
|
||||
affected[module_name]["e2e"].add(e2e_pattern)
|
||||
|
||||
break # File matched this module, move to next file
|
||||
|
||||
return affected
|
||||
|
||||
|
||||
def categorize_tests(
|
||||
affected_modules: dict[str, dict],
|
||||
) -> tuple[set[str], set[str], set[str]]:
|
||||
"""Categorize tests into SDK, API, and UI E2E."""
|
||||
sdk_tests = set()
|
||||
api_tests = set()
|
||||
ui_e2e = set()
|
||||
|
||||
for module_name, data in affected_modules.items():
|
||||
for test_path in data["tests"]:
|
||||
if test_path.startswith("tests/"):
|
||||
sdk_tests.add(test_path)
|
||||
elif test_path.startswith("api/"):
|
||||
api_tests.add(test_path)
|
||||
|
||||
for e2e_path in data["e2e"]:
|
||||
ui_e2e.add(e2e_path)
|
||||
|
||||
return sdk_tests, api_tests, ui_e2e
|
||||
|
||||
|
||||
def set_github_output(name: str, value: str):
|
||||
"""Set GitHub Actions output."""
|
||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if github_output:
|
||||
with open(github_output, "a") as f:
|
||||
# Handle multiline values
|
||||
if "\n" in value:
|
||||
import uuid
|
||||
|
||||
delimiter = uuid.uuid4().hex
|
||||
f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
f.write(f"{name}={value}\n")
|
||||
# Print for debugging (without deprecated format)
|
||||
print(f" {name}={value}", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
# Parse arguments
|
||||
if "--from-stdin" in sys.argv:
|
||||
changed_files = [line.strip() for line in sys.stdin if line.strip()]
|
||||
else:
|
||||
changed_files = [f for f in sys.argv[1:] if f and not f.startswith("-")]
|
||||
|
||||
if not changed_files:
|
||||
print("No changed files provided", file=sys.stderr)
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", "")
|
||||
set_github_output("api-tests", "")
|
||||
set_github_output("ui-e2e", "")
|
||||
set_github_output("modules", "")
|
||||
set_github_output("has-tests", "false")
|
||||
return
|
||||
|
||||
print(f"Analyzing {len(changed_files)} changed files...", file=sys.stderr)
|
||||
for f in changed_files[:10]: # Show first 10
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
if len(changed_files) > 10:
|
||||
print(f" ... and {len(changed_files) - 10} more", file=sys.stderr)
|
||||
|
||||
# Load configuration
|
||||
config = load_config()
|
||||
|
||||
# Filter out ignored files (docs, configs, etc.)
|
||||
ignored_paths = config.get("ignored", {}).get("paths", [])
|
||||
changed_files = filter_ignored_files(changed_files, ignored_paths)
|
||||
|
||||
if not changed_files:
|
||||
print("\nAll changed files are ignored (docs, configs, etc.)", file=sys.stderr)
|
||||
print("No tests needed.", file=sys.stderr)
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", "")
|
||||
set_github_output("api-tests", "")
|
||||
set_github_output("ui-e2e", "")
|
||||
set_github_output("modules", "none-ignored")
|
||||
set_github_output("has-tests", "false")
|
||||
return
|
||||
|
||||
print(
|
||||
f"\n{len(changed_files)} files remain after filtering ignored paths",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Check critical paths
|
||||
critical_paths = config.get("critical", {}).get("paths", [])
|
||||
if check_critical_paths(changed_files, critical_paths):
|
||||
print("\nCritical path changed - running ALL tests", file=sys.stderr)
|
||||
set_github_output("run-all", "true")
|
||||
set_github_output("sdk-tests", "tests/")
|
||||
set_github_output("api-tests", "api/src/backend/")
|
||||
set_github_output("ui-e2e", "ui/tests/")
|
||||
set_github_output("modules", "all")
|
||||
set_github_output("has-tests", "true")
|
||||
return
|
||||
|
||||
# Find affected modules
|
||||
modules = config.get("modules", [])
|
||||
affected = find_affected_modules(changed_files, modules)
|
||||
|
||||
if not affected:
|
||||
print("\nNo test-mapped modules affected", file=sys.stderr)
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", "")
|
||||
set_github_output("api-tests", "")
|
||||
set_github_output("ui-e2e", "")
|
||||
set_github_output("modules", "")
|
||||
set_github_output("has-tests", "false")
|
||||
return
|
||||
|
||||
# Report affected modules
|
||||
print(f"\nAffected modules: {len(affected)}", file=sys.stderr)
|
||||
for module_name, data in affected.items():
|
||||
print(f" [{module_name}]", file=sys.stderr)
|
||||
for f in data["matched_files"][:3]:
|
||||
print(f" - {f}", file=sys.stderr)
|
||||
if len(data["matched_files"]) > 3:
|
||||
print(
|
||||
f" ... and {len(data['matched_files']) - 3} more files",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Categorize tests
|
||||
sdk_tests, api_tests, ui_e2e = categorize_tests(affected)
|
||||
|
||||
# Output results
|
||||
print("\nTest paths to run:", file=sys.stderr)
|
||||
print(f" SDK: {sdk_tests or 'none'}", file=sys.stderr)
|
||||
print(f" API: {api_tests or 'none'}", file=sys.stderr)
|
||||
print(f" E2E: {ui_e2e or 'none'}", file=sys.stderr)
|
||||
|
||||
set_github_output("run-all", "false")
|
||||
set_github_output("sdk-tests", " ".join(sorted(sdk_tests)))
|
||||
set_github_output("api-tests", " ".join(sorted(api_tests)))
|
||||
set_github_output("ui-e2e", " ".join(sorted(ui_e2e)))
|
||||
set_github_output("modules", ",".join(sorted(affected.keys())))
|
||||
set_github_output(
|
||||
"has-tests", "true" if (sdk_tests or api_tests or ui_e2e) else "false"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user