185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
DOC_EXTS = {".md", ".rst", ".txt", ".adoc"}
|
|
TEST_MARKERS = ("/tests/", "/test/", "__tests__", ".spec.", ".test.")
|
|
CLIENT_PREFIXES = ("client/", "frontend/", "web/", "ui/")
|
|
SERVER_PREFIXES = ("server/", "backend/", "api/", "services/", "worker/")
|
|
INFRA_PREFIXES = (
|
|
"deploy/",
|
|
".gitea/workflows/",
|
|
".github/workflows/",
|
|
"infra/",
|
|
"k8s/",
|
|
"helm/",
|
|
"nginx/",
|
|
)
|
|
SHARED_RUNTIME_FILES = {
|
|
"package.json",
|
|
"package-lock.json",
|
|
"pnpm-lock.yaml",
|
|
"yarn.lock",
|
|
"requirements.txt",
|
|
"pyproject.toml",
|
|
"poetry.lock",
|
|
"Dockerfile",
|
|
"docker-compose.yml",
|
|
"docker-compose.yaml",
|
|
}
|
|
|
|
|
|
def run_git(cwd: Path, *args: str) -> str:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=str(cwd),
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def normalize_path(raw: str) -> str:
|
|
return raw.replace("\\", "/").lstrip("./")
|
|
|
|
|
|
def classify_file(path: str) -> set[str]:
|
|
categories: set[str] = set()
|
|
normalized = normalize_path(path)
|
|
lower = normalized.lower()
|
|
suffix = Path(lower).suffix
|
|
|
|
if lower.startswith("docs/") or suffix in DOC_EXTS:
|
|
categories.add("docs")
|
|
if any(marker in lower for marker in TEST_MARKERS):
|
|
categories.add("tests")
|
|
if any(lower.startswith(prefix) for prefix in CLIENT_PREFIXES):
|
|
categories.add("client")
|
|
if any(lower.startswith(prefix) for prefix in SERVER_PREFIXES):
|
|
categories.add("server")
|
|
if any(lower.startswith(prefix) for prefix in INFRA_PREFIXES):
|
|
categories.add("infra")
|
|
if Path(lower).name in SHARED_RUNTIME_FILES:
|
|
categories.add("shared_runtime")
|
|
|
|
if not categories:
|
|
categories.add("unknown")
|
|
return categories
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Detect branch change scope and recommend minimum deployment strategy."
|
|
)
|
|
parser.add_argument("--repo-path", default=".", help="Local git repository path")
|
|
parser.add_argument("--base-ref", required=True, help="Base ref, e.g. origin/main")
|
|
parser.add_argument("--head-ref", default="HEAD", help="Head ref, e.g. feature branch or SHA")
|
|
parser.add_argument("--no-files", action="store_true", help="Do not include changed file list in output")
|
|
args = parser.parse_args()
|
|
|
|
repo_path = Path(args.repo_path).resolve()
|
|
|
|
try:
|
|
merge_base = run_git(repo_path, "merge-base", args.base_ref, args.head_ref)
|
|
diff_output = run_git(repo_path, "diff", "--name-only", f"{merge_base}..{args.head_ref}")
|
|
except Exception as error: # noqa: BLE001
|
|
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2))
|
|
sys.exit(2)
|
|
|
|
changed_files = [normalize_path(line) for line in diff_output.splitlines() if line.strip()]
|
|
file_classes: list[dict[str, Any]] = []
|
|
category_union: set[str] = set()
|
|
for path in changed_files:
|
|
categories = classify_file(path)
|
|
category_union.update(categories)
|
|
file_classes.append({"path": path, "categories": sorted(categories)})
|
|
|
|
has_docs = "docs" in category_union
|
|
has_tests = "tests" in category_union
|
|
has_client = "client" in category_union
|
|
has_server = "server" in category_union
|
|
has_infra = "infra" in category_union
|
|
has_shared_runtime = "shared_runtime" in category_union
|
|
has_unknown = "unknown" in category_union
|
|
|
|
only_docs_tests = bool(changed_files) and category_union.issubset({"docs", "tests"})
|
|
scope = "skip"
|
|
reasons: list[str] = []
|
|
|
|
if not changed_files:
|
|
scope = "skip"
|
|
reasons.append("no changed files")
|
|
elif only_docs_tests:
|
|
scope = "skip"
|
|
reasons.append("docs/tests-only changes")
|
|
else:
|
|
require_server = has_server or has_shared_runtime
|
|
require_client = has_client or has_shared_runtime
|
|
|
|
if has_unknown:
|
|
scope = "full_stack"
|
|
reasons.append("unknown file changes detected -> conservative full-stack deploy")
|
|
require_server = True
|
|
require_client = True
|
|
elif has_infra and not require_server and not require_client:
|
|
scope = "infra_only"
|
|
reasons.append("infra/workflow-only changes")
|
|
elif require_client and not require_server:
|
|
scope = "client_only"
|
|
reasons.append("client-only changes")
|
|
elif require_server and not require_client:
|
|
scope = "server_only"
|
|
reasons.append("server-only changes")
|
|
else:
|
|
scope = "full_stack"
|
|
reasons.append("client/server/shared runtime changes")
|
|
|
|
should_restart_server = scope in {"server_only", "full_stack"}
|
|
should_restart_client = scope in {"client_only", "full_stack"}
|
|
|
|
payload: dict[str, Any] = {
|
|
"repo_path": str(repo_path.as_posix()),
|
|
"base_ref": args.base_ref,
|
|
"head_ref": args.head_ref,
|
|
"merge_base": merge_base,
|
|
"changed_files_count": len(changed_files),
|
|
"scope": scope,
|
|
"categories": {
|
|
"docs": has_docs,
|
|
"tests": has_tests,
|
|
"client": has_client,
|
|
"server": has_server,
|
|
"infra": has_infra,
|
|
"shared_runtime": has_shared_runtime,
|
|
"unknown": has_unknown,
|
|
},
|
|
"only_docs_tests": only_docs_tests,
|
|
"actions": {
|
|
"skip_deploy": scope == "skip",
|
|
"should_restart_client": should_restart_client,
|
|
"should_restart_server": should_restart_server,
|
|
"reuse_shared_server": not should_restart_server,
|
|
"requires_dedicated_server": should_restart_server,
|
|
},
|
|
"reasons": reasons,
|
|
}
|
|
if not args.no_files:
|
|
payload["changed_files"] = changed_files
|
|
payload["file_classification"] = file_classes
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|