311 lines
9.4 KiB
Python
Executable File
311 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Generate CI pipeline YAML from detected stack data.
|
|
|
|
Input sources:
|
|
- --input stack report JSON file
|
|
- stdin stack report JSON
|
|
- --repo path (auto-detect stack)
|
|
|
|
Output:
|
|
- text/json summary
|
|
- pipeline YAML written via --output or printed to stdout
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
class CLIError(Exception):
|
|
"""Raised for expected CLI failures."""
|
|
|
|
|
|
@dataclass
|
|
class PipelineSummary:
|
|
platform: str
|
|
output: str
|
|
stages: List[str]
|
|
uses_cache: bool
|
|
languages: List[str]
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Generate CI/CD pipeline YAML from detected stack.")
|
|
parser.add_argument("--input", help="Stack report JSON file. If omitted, can read stdin JSON.")
|
|
parser.add_argument("--repo", help="Repository path for auto-detection fallback.")
|
|
parser.add_argument("--platform", choices=["github", "gitlab"], required=True, help="Target CI platform.")
|
|
parser.add_argument("--output", help="Write YAML to this file; otherwise print to stdout.")
|
|
parser.add_argument("--format", choices=["text", "json"], default="text", help="Summary output format.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_json_input(input_path: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
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: {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 detect_stack(repo: Path) -> Dict[str, Any]:
|
|
scripts = {}
|
|
pkg_file = repo / "package.json"
|
|
if pkg_file.exists():
|
|
try:
|
|
pkg = json.loads(pkg_file.read_text(encoding="utf-8"))
|
|
raw_scripts = pkg.get("scripts", {})
|
|
if isinstance(raw_scripts, dict):
|
|
scripts = raw_scripts
|
|
except Exception:
|
|
scripts = {}
|
|
|
|
languages: List[str] = []
|
|
if pkg_file.exists():
|
|
languages.append("node")
|
|
if (repo / "pyproject.toml").exists() or (repo / "requirements.txt").exists():
|
|
languages.append("python")
|
|
if (repo / "go.mod").exists():
|
|
languages.append("go")
|
|
|
|
return {
|
|
"languages": sorted(set(languages)),
|
|
"signals": {
|
|
"pnpm_lock": (repo / "pnpm-lock.yaml").exists(),
|
|
"yarn_lock": (repo / "yarn.lock").exists(),
|
|
"npm_lock": (repo / "package-lock.json").exists(),
|
|
"dockerfile": (repo / "Dockerfile").exists(),
|
|
},
|
|
"lint_commands": ["npm run lint"] if "lint" in scripts else [],
|
|
"test_commands": ["npm test"] if "test" in scripts else [],
|
|
"build_commands": ["npm run build"] if "build" in scripts else [],
|
|
}
|
|
|
|
|
|
def select_node_install(signals: Dict[str, Any]) -> str:
|
|
if signals.get("pnpm_lock"):
|
|
return "pnpm install --frozen-lockfile"
|
|
if signals.get("yarn_lock"):
|
|
return "yarn install --frozen-lockfile"
|
|
return "npm ci"
|
|
|
|
|
|
def github_yaml(stack: Dict[str, Any]) -> str:
|
|
langs = stack.get("languages", [])
|
|
signals = stack.get("signals", {})
|
|
lint_cmds = stack.get("lint_commands", []) or ["echo 'No lint command configured'"]
|
|
test_cmds = stack.get("test_commands", []) or ["echo 'No test command configured'"]
|
|
build_cmds = stack.get("build_commands", []) or ["echo 'No build command configured'"]
|
|
|
|
lines: List[str] = [
|
|
"name: CI",
|
|
"on:",
|
|
" push:",
|
|
" branches: [main, develop]",
|
|
" pull_request:",
|
|
" branches: [main, develop]",
|
|
"",
|
|
"jobs:",
|
|
]
|
|
|
|
if "node" in langs:
|
|
lines.extend(
|
|
[
|
|
" node-ci:",
|
|
" runs-on: ubuntu-latest",
|
|
" steps:",
|
|
" - uses: actions/checkout@v4",
|
|
" - uses: actions/setup-node@v4",
|
|
" with:",
|
|
" node-version: '20'",
|
|
" cache: 'npm'",
|
|
f" - run: {select_node_install(signals)}",
|
|
]
|
|
)
|
|
for cmd in lint_cmds + test_cmds + build_cmds:
|
|
lines.append(f" - run: {cmd}")
|
|
|
|
if "python" in langs:
|
|
lines.extend(
|
|
[
|
|
" python-ci:",
|
|
" runs-on: ubuntu-latest",
|
|
" steps:",
|
|
" - uses: actions/checkout@v4",
|
|
" - uses: actions/setup-python@v5",
|
|
" with:",
|
|
" python-version: '3.12'",
|
|
" - run: python3 -m pip install -U pip",
|
|
" - run: python3 -m pip install -r requirements.txt || true",
|
|
" - run: python3 -m pytest || true",
|
|
]
|
|
)
|
|
|
|
if "go" in langs:
|
|
lines.extend(
|
|
[
|
|
" go-ci:",
|
|
" runs-on: ubuntu-latest",
|
|
" steps:",
|
|
" - uses: actions/checkout@v4",
|
|
" - uses: actions/setup-go@v5",
|
|
" with:",
|
|
" go-version: '1.22'",
|
|
" - run: go test ./...",
|
|
" - run: go build ./...",
|
|
]
|
|
)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def gitlab_yaml(stack: Dict[str, Any]) -> str:
|
|
langs = stack.get("languages", [])
|
|
signals = stack.get("signals", {})
|
|
lint_cmds = stack.get("lint_commands", []) or ["echo 'No lint command configured'"]
|
|
test_cmds = stack.get("test_commands", []) or ["echo 'No test command configured'"]
|
|
build_cmds = stack.get("build_commands", []) or ["echo 'No build command configured'"]
|
|
|
|
lines: List[str] = [
|
|
"stages:",
|
|
" - lint",
|
|
" - test",
|
|
" - build",
|
|
"",
|
|
]
|
|
|
|
if "node" in langs:
|
|
install_cmd = select_node_install(signals)
|
|
lines.extend(
|
|
[
|
|
"node_lint:",
|
|
" image: node:20",
|
|
" stage: lint",
|
|
" script:",
|
|
f" - {install_cmd}",
|
|
]
|
|
)
|
|
for cmd in lint_cmds:
|
|
lines.append(f" - {cmd}")
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"node_test:",
|
|
" image: node:20",
|
|
" stage: test",
|
|
" script:",
|
|
f" - {install_cmd}",
|
|
]
|
|
)
|
|
for cmd in test_cmds:
|
|
lines.append(f" - {cmd}")
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"node_build:",
|
|
" image: node:20",
|
|
" stage: build",
|
|
" script:",
|
|
f" - {install_cmd}",
|
|
]
|
|
)
|
|
for cmd in build_cmds:
|
|
lines.append(f" - {cmd}")
|
|
|
|
if "python" in langs:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"python_test:",
|
|
" image: python:3.12",
|
|
" stage: test",
|
|
" script:",
|
|
" - python3 -m pip install -U pip",
|
|
" - python3 -m pip install -r requirements.txt || true",
|
|
" - python3 -m pytest || true",
|
|
]
|
|
)
|
|
|
|
if "go" in langs:
|
|
lines.extend(
|
|
[
|
|
"",
|
|
"go_test:",
|
|
" image: golang:1.22",
|
|
" stage: test",
|
|
" script:",
|
|
" - go test ./...",
|
|
" - go build ./...",
|
|
]
|
|
)
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
stack = load_json_input(args.input)
|
|
|
|
if stack is None:
|
|
if not args.repo:
|
|
raise CLIError("Provide stack input via --input/stdin or set --repo for auto-detection.")
|
|
repo = Path(args.repo).resolve()
|
|
if not repo.exists() or not repo.is_dir():
|
|
raise CLIError(f"Invalid repo path: {repo}")
|
|
stack = detect_stack(repo)
|
|
|
|
if args.platform == "github":
|
|
yaml_content = github_yaml(stack)
|
|
else:
|
|
yaml_content = gitlab_yaml(stack)
|
|
|
|
output_path = args.output or "stdout"
|
|
if args.output:
|
|
out = Path(args.output)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(yaml_content, encoding="utf-8")
|
|
else:
|
|
print(yaml_content, end="")
|
|
|
|
summary = PipelineSummary(
|
|
platform=args.platform,
|
|
output=output_path,
|
|
stages=["lint", "test", "build"],
|
|
uses_cache=True,
|
|
languages=stack.get("languages", []),
|
|
)
|
|
|
|
if args.format == "json":
|
|
print(json.dumps(asdict(summary), indent=2), file=sys.stderr if not args.output else sys.stdout)
|
|
else:
|
|
text = (
|
|
"Pipeline generated\n"
|
|
f"- platform: {summary.platform}\n"
|
|
f"- output: {summary.output}\n"
|
|
f"- stages: {', '.join(summary.stages)}\n"
|
|
f"- languages: {', '.join(summary.languages) if summary.languages else 'none'}"
|
|
)
|
|
print(text, file=sys.stderr if not args.output else sys.stdout)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except CLIError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|