"""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/-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)