185 lines
5.6 KiB
Python
Executable File
185 lines
5.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Detect project stack/tooling signals for CI/CD pipeline generation.
|
|
|
|
Input sources:
|
|
- repository scan via --repo
|
|
- JSON via --input file
|
|
- JSON via stdin
|
|
|
|
Output:
|
|
- text summary or JSON payload
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
class CLIError(Exception):
|
|
"""Raised for expected CLI failures."""
|
|
|
|
|
|
@dataclass
|
|
class StackReport:
|
|
repo: str
|
|
languages: List[str]
|
|
package_managers: List[str]
|
|
ci_targets: List[str]
|
|
test_commands: List[str]
|
|
build_commands: List[str]
|
|
lint_commands: List[str]
|
|
signals: Dict[str, bool]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Detect stack/tooling from a repository.")
|
|
parser.add_argument("--input", help="JSON input file (precomputed signal payload).")
|
|
parser.add_argument("--repo", default=".", help="Repository path to scan.")
|
|
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_payload(input_path: Optional[str]) -> Optional[dict]:
|
|
if input_path:
|
|
try:
|
|
return json.loads(Path(input_path).read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise CLIError(f"Failed reading --input file: {exc}") from exc
|
|
|
|
if not sys.stdin.isatty():
|
|
raw = sys.stdin.read().strip()
|
|
if raw:
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
|
|
|
|
return None
|
|
|
|
|
|
def read_package_scripts(repo: Path) -> Dict[str, str]:
|
|
pkg = repo / "package.json"
|
|
if not pkg.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
scripts = data.get("scripts", {})
|
|
return scripts if isinstance(scripts, dict) else {}
|
|
|
|
|
|
def detect(repo: Path) -> StackReport:
|
|
signals = {
|
|
"package_json": (repo / "package.json").exists(),
|
|
"pnpm_lock": (repo / "pnpm-lock.yaml").exists(),
|
|
"yarn_lock": (repo / "yarn.lock").exists(),
|
|
"npm_lock": (repo / "package-lock.json").exists(),
|
|
"pyproject": (repo / "pyproject.toml").exists(),
|
|
"requirements": (repo / "requirements.txt").exists(),
|
|
"go_mod": (repo / "go.mod").exists(),
|
|
"dockerfile": (repo / "Dockerfile").exists(),
|
|
"vercel": (repo / "vercel.json").exists(),
|
|
"helm": (repo / "helm").exists() or (repo / "charts").exists(),
|
|
"k8s": (repo / "k8s").exists() or (repo / "kubernetes").exists(),
|
|
}
|
|
|
|
languages: List[str] = []
|
|
package_managers: List[str] = []
|
|
ci_targets: List[str] = ["github", "gitlab"]
|
|
|
|
if signals["package_json"]:
|
|
languages.append("node")
|
|
if signals["pnpm_lock"]:
|
|
package_managers.append("pnpm")
|
|
elif signals["yarn_lock"]:
|
|
package_managers.append("yarn")
|
|
else:
|
|
package_managers.append("npm")
|
|
|
|
if signals["pyproject"] or signals["requirements"]:
|
|
languages.append("python")
|
|
package_managers.append("pip")
|
|
|
|
if signals["go_mod"]:
|
|
languages.append("go")
|
|
|
|
scripts = read_package_scripts(repo)
|
|
lint_commands: List[str] = []
|
|
test_commands: List[str] = []
|
|
build_commands: List[str] = []
|
|
|
|
if "lint" in scripts:
|
|
lint_commands.append("npm run lint")
|
|
if "test" in scripts:
|
|
test_commands.append("npm test")
|
|
if "build" in scripts:
|
|
build_commands.append("npm run build")
|
|
|
|
if "python" in languages:
|
|
lint_commands.append("python3 -m ruff check .")
|
|
test_commands.append("python3 -m pytest")
|
|
|
|
if "go" in languages:
|
|
lint_commands.append("go vet ./...")
|
|
test_commands.append("go test ./...")
|
|
build_commands.append("go build ./...")
|
|
|
|
return StackReport(
|
|
repo=str(repo.resolve()),
|
|
languages=sorted(set(languages)),
|
|
package_managers=sorted(set(package_managers)),
|
|
ci_targets=ci_targets,
|
|
test_commands=sorted(set(test_commands)),
|
|
build_commands=sorted(set(build_commands)),
|
|
lint_commands=sorted(set(lint_commands)),
|
|
signals=signals,
|
|
)
|
|
|
|
|
|
def format_text(report: StackReport) -> str:
|
|
lines = [
|
|
"Detected stack",
|
|
f"- repo: {report.repo}",
|
|
f"- languages: {', '.join(report.languages) if report.languages else 'none'}",
|
|
f"- package managers: {', '.join(report.package_managers) if report.package_managers else 'none'}",
|
|
f"- lint commands: {', '.join(report.lint_commands) if report.lint_commands else 'none'}",
|
|
f"- test commands: {', '.join(report.test_commands) if report.test_commands else 'none'}",
|
|
f"- build commands: {', '.join(report.build_commands) if report.build_commands else 'none'}",
|
|
]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
payload = load_payload(args.input)
|
|
|
|
if payload:
|
|
try:
|
|
report = StackReport(**payload)
|
|
except TypeError as exc:
|
|
raise CLIError(f"Invalid input payload for StackReport: {exc}") from exc
|
|
else:
|
|
repo = Path(args.repo).resolve()
|
|
if not repo.exists() or not repo.is_dir():
|
|
raise CLIError(f"Invalid repo path: {repo}")
|
|
report = detect(repo)
|
|
|
|
if args.format == "json":
|
|
print(json.dumps(asdict(report), indent=2))
|
|
else:
|
|
print(format_text(report))
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except CLIError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|