mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
chore: guard generated OpenAPI spec sync in pre-commit and pre-push
This commit is contained in:
@@ -29,6 +29,12 @@ repos:
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: guard-openapi-sync
|
||||
name: guard generated OpenAPI sync (staged)
|
||||
entry: python -B scripts/check_openapi_sync.py --staged
|
||||
language: python
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
# Secret detection
|
||||
- repo: https://github.com/Yelp/detect-secrets
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
OPENAPI_SYNC_TRIGGER_PATHS = {
|
||||
"docs/openapi.yaml",
|
||||
"docs/release/api_contract.md",
|
||||
"scripts/generate_openapi_spec.py",
|
||||
"services/openapi_generation.py",
|
||||
}
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
return path.replace("\\", "/").strip()
|
||||
|
||||
|
||||
def _get_staged_paths() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [
|
||||
_normalize_path(line)
|
||||
for line in result.stdout.splitlines()
|
||||
if _normalize_path(line)
|
||||
]
|
||||
|
||||
|
||||
def should_validate_openapi(paths: Iterable[str]) -> bool:
|
||||
normalized = {_normalize_path(path) for path in paths if _normalize_path(path)}
|
||||
if not normalized:
|
||||
return False
|
||||
return any(path in OPENAPI_SYNC_TRIGGER_PATHS for path in normalized)
|
||||
|
||||
|
||||
def _load_generate_openapi_yaml() -> Callable[[str | Path | None], str]:
|
||||
root = str(_repo_root())
|
||||
if root not in sys.path:
|
||||
sys.path.insert(0, root)
|
||||
from services.openapi_generation import generate_openapi_yaml
|
||||
|
||||
return generate_openapi_yaml
|
||||
|
||||
|
||||
def validate_openapi_sync(
|
||||
*,
|
||||
openapi_path: str | Path | None = None,
|
||||
contract_path: str | Path | None = None,
|
||||
generate_openapi_yaml: Callable[[str | Path | None], str] | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
root = _repo_root()
|
||||
openapi_file = Path(openapi_path) if openapi_path else root / "docs" / "openapi.yaml"
|
||||
contract_file = (
|
||||
Path(contract_path)
|
||||
if contract_path
|
||||
else root / "docs" / "release" / "api_contract.md"
|
||||
)
|
||||
if not openapi_file.exists():
|
||||
return False, f"[OpenClaw] OpenAPI sync check failed: missing {openapi_file}"
|
||||
|
||||
generator = generate_openapi_yaml or _load_generate_openapi_yaml()
|
||||
expected = generator(contract_file)
|
||||
actual = openapi_file.read_text(encoding="utf-8")
|
||||
if actual == expected:
|
||||
return True, ""
|
||||
|
||||
message = "\n".join(
|
||||
[
|
||||
"[OpenClaw] Commit/push blocked: docs/openapi.yaml is out of sync.",
|
||||
"",
|
||||
"Generated specs must not be edited by hand without regenerating them.",
|
||||
"",
|
||||
"Fix:",
|
||||
" 1. Update generator/contract inputs as needed.",
|
||||
" 2. Regenerate with: python scripts/generate_openapi_spec.py",
|
||||
" 3. Review and stage docs/openapi.yaml together with the source change.",
|
||||
]
|
||||
)
|
||||
return False, message
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Guard that docs/openapi.yaml stays in sync with the generator."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--staged",
|
||||
action="store_true",
|
||||
help="Only validate when staged changes touch OpenAPI generator/spec sources.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.staged and not should_validate_openapi(_get_staged_paths()):
|
||||
return 0
|
||||
|
||||
ok, message = validate_openapi_sync()
|
||||
if ok:
|
||||
return 0
|
||||
print(message, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -339,6 +339,10 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# IMPORTANT: generated spec drift must fail before backend tests so docs-only
|
||||
# edits cannot hide until deep in the pre-push unit suite.
|
||||
"$VENV_PY" scripts/check_openapi_sync.py
|
||||
|
||||
echo "[pre-push] 3/7 backend unit tests"
|
||||
MOLTBOT_STATE_DIR="$ROOT_DIR/moltbot_state/_pre_push_unit" \
|
||||
"$VENV_PY" scripts/run_unittests.py --start-dir tests --pattern "test_*.py" --enforce-skip-policy tests/skip_policy.json
|
||||
|
||||
@@ -322,7 +322,7 @@ def build_openapi_document(
|
||||
"in": "header",
|
||||
"name": "X-OpenClaw-Bridge-Token",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
module_path = root / "scripts" / "check_openapi_sync.py"
|
||||
spec = importlib.util.spec_from_file_location("openapi_sync_guard_mod", module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("Failed to load check_openapi_sync.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestOpenApiSyncGuard(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.mod = _load_module()
|
||||
|
||||
def test_should_validate_openapi_true_for_generated_spec(self):
|
||||
self.assertTrue(self.mod.should_validate_openapi(["docs/openapi.yaml"]))
|
||||
|
||||
def test_should_validate_openapi_true_for_generator_source(self):
|
||||
self.assertTrue(
|
||||
self.mod.should_validate_openapi(["services/openapi_generation.py"])
|
||||
)
|
||||
|
||||
def test_should_validate_openapi_false_for_unrelated_paths(self):
|
||||
self.assertFalse(self.mod.should_validate_openapi(["README.md"]))
|
||||
|
||||
def test_validate_openapi_sync_passes_when_content_matches(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
openapi_path = Path(td) / "openapi.yaml"
|
||||
contract_path = Path(td) / "api_contract.md"
|
||||
contract_path.write_text("dummy", encoding="utf-8")
|
||||
openapi_path.write_text("expected-yaml\n", encoding="utf-8")
|
||||
ok, message = self.mod.validate_openapi_sync(
|
||||
openapi_path=openapi_path,
|
||||
contract_path=contract_path,
|
||||
generate_openapi_yaml=lambda _: "expected-yaml\n",
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(message, "")
|
||||
|
||||
def test_validate_openapi_sync_fails_when_content_drifts(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
openapi_path = Path(td) / "openapi.yaml"
|
||||
contract_path = Path(td) / "api_contract.md"
|
||||
contract_path.write_text("dummy", encoding="utf-8")
|
||||
openapi_path.write_text("hand-edited\n", encoding="utf-8")
|
||||
ok, message = self.mod.validate_openapi_sync(
|
||||
openapi_path=openapi_path,
|
||||
contract_path=contract_path,
|
||||
generate_openapi_yaml=lambda _: "generated\n",
|
||||
)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("docs/openapi.yaml is out of sync", message)
|
||||
self.assertIn("python scripts/generate_openapi_spec.py", message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user