Files

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()