Files
a0_software_orchestrator/tools/quality_gate.py
T

470 lines
18 KiB
Python
Raw Normal View History

"""Tool: phase gate validation (real implementation, DB-backed).
Replaces the stub. Persists every run to orch_quality_runs in patterns.db.
Args (kwargs):
action (str): 'run' | 'show_last' | 'list_runs' | 'show_config'
Default: 'run'
project_name (str): project name (preferred, e.g. 'rentman-clone')
project_root (str): alternative — path to project root, name derived from basename
gate_level (str): 'implementation' | 'quality-review' | 'deployment' | 'release'
Default: 'quality-review'
triggered_by (str): 'manual' | 'pre-commit' | 'pre-deploy' | etc.
Default: 'manual'
dry_run (bool): if True, list checks that would run without executing
Default: False
Returns:
JSON string with decision, checks, blockers, next_action.
Persists to orch_quality_runs (one row per run).
For action=show_last: returns last run's full record.
For action=list_runs: returns summary of recent runs.
For action=show_config: returns orch_quality_config row.
"""
from __future__ import annotations
import json
import os
import subprocess
import shlex
import time
from typing import Any, Dict, List, Optional
from helpers.tool import Tool, Response
from usr.plugins.a0_software_orchestrator.helpers.db_state_store import (
DB_PATH,
get_project_id,
get_kv,
set_kv,
)
from usr.plugins.a0_software_orchestrator.helpers.exceptions import PluginDBError
from usr.plugins.a0_software_orchestrator.helpers.safety_rules import redact_secrets
def _resolve_name(project_name: str = "", project_root: str = "") -> str:
if project_name:
return project_name.strip()
if project_root:
return os.path.basename(project_root.rstrip("/"))
return os.path.basename(os.getcwd())
# Registry of L1-L3 checks. Each entry: (name, level, command_template, expect_exit, parser)
# command_template uses {project_root} placeholder. timeout in seconds.
PYTHON_FASTAPI_CHECKS = {
"L1": [
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
],
"L2": [
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
],
"L3": [
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
],
}
PYTHON_FASTAPI_VUE_CHECKS = {
"L1": [
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
],
"L2": [
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
],
"L3": [
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
],
}
PYTHON_FASTAPI_REACT_CHECKS = {
"L1": [
{"name": "L1_ruff_check", "cmd": "ruff check {project_root}/backend", "expect": 0},
{"name": "L1_ruff_format", "cmd": "ruff format --check {project_root}/backend", "expect": 0},
{"name": "L1_mypy_strict", "cmd": "mypy --strict --no-error-summary {project_root}/backend", "expect": 0},
{"name": "L1_tsc_noemit", "cmd": "cd {project_root}/frontend && ./node_modules/.bin/tsc --noEmit 2>&1", "expect": 0},
],
"L2": [
{"name": "L2_python_import_test", "cmd": "cd {project_root}/backend && python -c 'import app.main' 2>&1", "expect": 0},
{"name": "L2_vite_build", "cmd": "cd {project_root}/frontend && npm run build 2>&1 | tail -10", "expect": 0},
],
"L3": [
{"name": "L3_pytest", "cmd": "cd {project_root}/backend && pytest tests/ -q --tb=no 2>&1", "expect": 0},
],
}
def _detect_tech_stack(project_root: str) -> str:
"""Auto-detect tech stack from project structure."""
has_pyproject = os.path.isfile(os.path.join(project_root, "backend", "pyproject.toml")) or os.path.isfile(os.path.join(project_root, "pyproject.toml"))
has_reqs = os.path.isfile(os.path.join(project_root, "backend", "requirements.txt")) or os.path.isfile(os.path.join(project_root, "requirements.txt"))
has_python_app = os.path.isdir(os.path.join(project_root, "backend", "app")) or os.path.isdir(os.path.join(project_root, "app"))
has_react = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and os.path.isfile(os.path.join(project_root, "frontend", "tsconfig.json"))
has_vue = os.path.isfile(os.path.join(project_root, "frontend", "package.json")) and (
os.path.isfile(os.path.join(project_root, "frontend", "vite.config.js")) or os.path.isfile(os.path.join(project_root, "frontend", "vite.config.ts"))
)
if has_reqs and has_python_app and has_react:
return "python_fastapi+node_react"
if has_reqs and has_python_app and has_vue:
return "python_fastapi+vue"
if (has_reqs or has_pyproject) and has_python_app:
return "python_fastapi"
return "unknown"
def _get_checks_for_stack(tech_stack: str, gate_level: str) -> List[Dict[str, Any]]:
"""Return list of check definitions for the given stack + gate level."""
if tech_stack == "python_fastapi+node_react":
registry = PYTHON_FASTAPI_REACT_CHECKS
elif tech_stack == "python_fastapi+vue":
registry = PYTHON_FASTAPI_VUE_CHECKS
elif tech_stack == "python_fastapi":
registry = PYTHON_FASTAPI_CHECKS
else:
return []
# Map gate_level → which L1-L3 levels are required
if gate_level in ("implementation", "quality-review"):
levels = ["L1", "L2"]
elif gate_level == "deployment":
levels = ["L1", "L2", "L3"]
elif gate_level == "release":
levels = ["L1", "L2", "L3"]
else:
levels = ["L1"]
checks: List[Dict[str, Any]] = []
for lvl in levels:
for c in registry.get(lvl, []):
checks.append({"level": lvl, **c})
return checks
def _run_check(check: Dict[str, Any], project_root: str, timeout: int = 300) -> Dict[str, Any]:
"""Run a single check, return result dict."""
name = check["name"]
safe_project_root = shlex.quote(os.path.abspath(project_root))
cmd = check["cmd"].format(project_root=safe_project_root)
expect = check.get("expect", 0)
start = time.time()
try:
proc = subprocess.run(
["bash", "-lc", cmd],
shell=False,
capture_output=True,
text=True,
timeout=timeout,
)
duration_ms = int((time.time() - start) * 1000)
stdout = redact_secrets(proc.stdout[-500:] if proc.stdout else "")
stderr = redact_secrets(proc.stderr[-500:] if proc.stderr else "")
detail = redact_secrets((stdout + stderr).strip()[:300]) or f"exit={proc.returncode}"
if proc.returncode == expect:
result = "pass"
elif proc.returncode == 5: # ruff format: "would reformat" = 0 fixed
result = "fail"
else:
result = "fail"
return {
"name": name,
"level": check["level"],
"result": result,
"duration_ms": duration_ms,
"detail": detail,
"exit_code": proc.returncode,
}
except subprocess.TimeoutExpired:
return {
"name": name,
"level": check["level"],
"result": "fail",
"duration_ms": timeout * 1000,
"detail": f"timeout after {timeout}s",
"exit_code": -1,
}
except FileNotFoundError as e:
return {
"name": name,
"level": check["level"],
"result": "skip",
"duration_ms": 0,
"detail": f"command not found: {e}",
"exit_code": -1,
}
except Exception as e: # pragma: no cover
return {
"name": name,
"level": check["level"],
"result": "fail",
"duration_ms": 0,
"detail": f"exception: {e}",
"exit_code": -1,
}
def _persist_run(
project_id: int,
gate_level: str,
decision: str,
checks: List[Dict[str, Any]],
blockers: List[str],
next_action: str,
duration_ms: int,
triggered_by: str,
) -> int:
"""Insert a run into orch_quality_runs, return the new id."""
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
cur = conn.execute(
"""
INSERT INTO orch_quality_runs (project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
project_id,
gate_level,
decision,
json.dumps(checks),
json.dumps(blockers),
next_action,
duration_ms,
triggered_by,
),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def _suggest_next_action(failed_checks: List[Dict[str, Any]]) -> str:
"""Generate a human-readable next-action suggestion from failed checks."""
actions: List[str] = []
for c in failed_checks:
name = c["name"]
if name == "L1_ruff_check":
actions.append("Run 'ruff check --fix' to auto-fix")
elif name == "L1_ruff_format":
actions.append("Run 'ruff format' to fix formatting")
elif name == "L1_mypy_strict":
actions.append("Add type annotations manually")
elif name == "L1_tsc_noemit":
actions.append("Fix TypeScript errors (unused imports, type annotations)")
elif name == "L2_vite_build":
actions.append("Fix build errors (usually TS or import issues)")
elif name == "L3_pytest":
actions.append("Fix failing tests")
else:
actions.append(f"Investigate {name}")
return "; ".join(actions) if actions else "No actions needed"
def _fetch_last_run(project_id: int) -> Optional[Dict[str, Any]]:
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
row = conn.execute(
"SELECT id, project_id, gate_level, decision, checks_json, blockers_json, next_action, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT 1",
(project_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"project_id": row[1],
"gate_level": row[2],
"decision": row[3],
"checks": json.loads(row[4]) if row[4] else [],
"blockers": json.loads(row[5]) if row[5] else [],
"next_action": row[6],
"duration_ms": row[7],
"triggered_by": row[8],
"created_at": row[9],
}
finally:
conn.close()
def _list_runs(project_id: int, limit: int = 10) -> List[Dict[str, Any]]:
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
rows = conn.execute(
"SELECT id, gate_level, decision, duration_ms, triggered_by, created_at FROM orch_quality_runs WHERE project_id = ? ORDER BY id DESC LIMIT ?",
(project_id, limit),
).fetchall()
return [
{
"id": r[0],
"gate_level": r[1],
"decision": r[2],
"duration_ms": r[3],
"triggered_by": r[4],
"created_at": r[5],
}
for r in rows
]
finally:
conn.close()
def _show_config(project_id: int) -> Optional[Dict[str, Any]]:
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
row = conn.execute(
"SELECT project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, custom_checks_json, updated_at FROM orch_quality_config WHERE project_id = ?",
(project_id,),
).fetchone()
if not row:
return None
return {
"project_id": row[0],
"tech_stack": row[1],
"coverage_min_pct": row[2],
"strict_types": bool(row[3]),
"require_security_scan": bool(row[4]),
"require_openapi_check": bool(row[5]),
"custom_checks_json": row[6],
"updated_at": row[7],
}
finally:
conn.close()
def _ensure_config(project_id: int, tech_stack: str) -> None:
"""Create or update orch_quality_config for a project."""
import sqlite3
conn = sqlite3.connect(DB_PATH)
try:
conn.execute(
"""
INSERT INTO orch_quality_config (project_id, tech_stack, coverage_min_pct, strict_types, require_security_scan, require_openapi_check, updated_at)
VALUES (?, ?, 70, 1, 1, 0, datetime('now'))
ON CONFLICT(project_id) DO UPDATE SET
tech_stack = excluded.tech_stack,
updated_at = datetime('now')
""",
(project_id, tech_stack),
)
conn.commit()
finally:
conn.close()
class QualityGate(Tool):
"""Phase gate validation (real implementation, DB-backed).
Actions:
run: execute checks, persist run, return decision
show_last: return most recent run for project
list_runs: return summary of recent runs (default 10)
show_config: return orch_quality_config for project
Auto-detects tech_stack from project_root if no config exists yet.
"""
async def execute(
self,
project_name: str = "",
project_root: str = "",
action: str = "run",
gate_level: str = "quality-review",
triggered_by: str = "manual",
dry_run: bool = False,
**kwargs,
) -> Response:
name = _resolve_name(project_name, project_root)
if not name:
return Response(message=json.dumps({"error": "no project_name or project_root given"}), break_loop=False)
# resolve project_id (no auto-create; LookupError on unknown name)
try:
project_id = get_project_id(name)
if project_id is None:
raise LookupError(f"project '{name}' is not registered")
except (ValueError, LookupError) as e:
return Response(message=json.dumps({"error": str(e)}), break_loop=False)
except PluginDBError as e:
return Response(message=json.dumps({"error": f"Database error: {e}"}), break_loop=False)
# Determine project_root: prefer explicit, else default to /a0/usr/workdir/<name>-check
if not project_root:
project_root = f"/a0/usr/workdir/{name}-check"
if not os.path.isdir(project_root):
return Response(message=json.dumps({"error": f"project_root {project_root!r} does not exist; clone the repo first"}), break_loop=False)
if action == "show_last":
run = _fetch_last_run(project_id)
return Response(message=json.dumps(run or {"info": "no runs yet"}, indent=2), break_loop=False)
if action == "list_runs":
runs = _list_runs(project_id)
return Response(message=json.dumps(runs, indent=2), break_loop=False)
if action == "show_config":
cfg = _show_config(project_id)
return Response(message=json.dumps(cfg or {"info": "no config yet"}, indent=2), break_loop=False)
# action == 'run' (default)
tech_stack = _detect_tech_stack(project_root)
if tech_stack == "unknown":
return Response(message=json.dumps({"error": f"could not detect tech_stack in {project_root}", "project_root": project_root}), break_loop=False)
_ensure_config(project_id, tech_stack)
checks_def = _get_checks_for_stack(tech_stack, gate_level)
if not checks_def:
return Response(message=json.dumps({"error": f"no checks defined for tech_stack={tech_stack} gate_level={gate_level}"}), break_loop=False)
if dry_run:
return Response(message=json.dumps({
"project": name,
"project_id": project_id,
"tech_stack": tech_stack,
"gate_level": gate_level,
"would_run": [c["name"] for c in checks_def],
}, indent=2), break_loop=False)
start = time.time()
results: List[Dict[str, Any]] = []
for c in checks_def:
results.append(_run_check(c, project_root))
duration_ms = int((time.time() - start) * 1000)
failed = [r for r in results if r["result"] == "fail"]
skipped = [r for r in results if r["result"] == "skip"]
decision = "fail" if failed else ("warn" if skipped else "pass")
blockers = [f"{r['name']}: {r['detail']}" for r in failed]
next_action = _suggest_next_action(failed)
run_id = _persist_run(
project_id=project_id,
gate_level=gate_level,
decision=decision,
checks=results,
blockers=blockers,
next_action=next_action,
duration_ms=duration_ms,
triggered_by=triggered_by,
)
summary = {
"run_id": run_id,
"project": name,
"project_id": project_id,
"tech_stack": tech_stack,
"gate_level": gate_level,
"decision": decision,
"checks": results,
"blockers": blockers,
"next_action": next_action,
"duration_ms": duration_ms,
"triggered_by": triggered_by,
}
return Response(message=json.dumps(summary, indent=2), break_loop=False)