diff --git a/.env.template b/.env.template index e1dc68d..7bf0617 100644 --- a/.env.template +++ b/.env.template @@ -152,3 +152,32 @@ GITPILOT_MCP_ENABLE_DURING_TEST_GENERATION=true GITPILOT_MCP_REQUIRE_APPROVAL_FOR_MUTATIONS=true GITPILOT_MCP_MAX_CALLS_PER_REQUEST=20 GITPILOT_MCP_TIMEOUT_SECONDS=30 + +# ============================================================================= +# GitPilot Repair Flow (generic patch generation via OllaBridge) +# ============================================================================= +# GitPilot is the ONLY component that GENERATES patches. It is GENERIC and +# provider-neutral: it calls OllaBridge (or any OpenAI-compatible endpoint) +# via OPENAI_BASE_URL + OPENAI_API_KEY. It NEVER reads HF_TOKEN. + +# Caller identity (Agent-Matrix is just a default client_id, not hardcoded). +GITPILOT_CLIENT_ID=agent-matrix +GITPILOT_WORKSPACE_ID=default + +# OpenAI-compatible endpoint (OllaBridge). Key format: ob_test_xxx / ob_live_xxx +OPENAI_BASE_URL=http://localhost:11434 +OPENAI_API_KEY=ob_test_changeme + +# Model aliases served by OllaBridge. +GITPILOT_MODEL_FAST=code-fast +GITPILOT_MODEL_CODER=code-coder +GITPILOT_MODEL_REVIEWER=code-reviewer + +# Sandbox (MatrixLab) for patch validation. +GITPILOT_SANDBOX_PROVIDER=matrixlab +MATRIXLAB_URL=http://localhost:8765 +MATRIXLAB_TOKEN=change-me + +# Safety: draft PRs are a no-op stub in the first wave; demo mode runs offline. +GITPILOT_DRAFT_PR_ENABLED=false +GITPILOT_DEMO_MODE=true diff --git a/.gitignore b/.gitignore index fde110f..35488fe 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,7 @@ frontend/.vite/ reports/ .mcp.env mcp-stack/ + +# GitPilot dry-run/CLI output artifacts +repair-response.json +repair-plan.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5e464..4ac7ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Coder API** (`POST /repair` + `GET /repair/health`), bearer-token gated (`GITPILOT_API_TOKEN`), mounted into the main app — turns a repair-plan into a dry-run patch preview for SelfRepair / matrix-maintainer over HTTPS. + ### Changed — `make run` now starts the MCP Context Forge stack by default **Heads-up for upgraders.** Until this release, `make run` started only the diff --git a/docs/contracts/repair-api.md b/docs/contracts/repair-api.md new file mode 100644 index 0000000..446ab19 --- /dev/null +++ b/docs/contracts/repair-api.md @@ -0,0 +1,95 @@ +# GitPilot Repair API contract + +GitPilot is the **only** component that generates patches. The repair flow is +**generic** — usable by SelfRepair, Agent-Matrix, CI, or a developer — and is +provider-neutral. It calls OllaBridge (or any OpenAI-compatible endpoint) via +`OPENAI_BASE_URL` + `OPENAI_API_KEY` and **never reads `HF_TOKEN`**. + +## Repair request (== SelfRepair `repair-plan.json`) + +```jsonc +{ + "client_id": "agent-matrix", + "workspace_id": "ws-123", + "task_id": "fix-health-test", + "repo_url": "https://github.com/acme/app", + "branch": "main", + "mode": "dry_run", // "dry_run" | "draft_pr" | "apply" + "issues": [ + { + "id": "missing-health-test", + "severity": "medium", // low | medium | high | critical + "description": "No smoke test for /health", + "recommended_action": "Add tests/test_health.py" + } + ], + "allowed_paths": ["tests/**"], // globs; EMPTY => fail-closed (no changes) + "forbidden_paths": [".env", "secrets/**", "**/*token*", "**/*secret*"], + "coder": { "provider": "ollabridge", "model": "code-coder" }, + "sandbox": { "provider": "matrixlab", "profile": "default", "required": false }, + "human_approval": false // required to proceed on risk_level=high +} +``` + +Pydantic model: `gitpilot.repair.schema.RepairRequest`. + +## Repair response (`repair-response.json`) + +```jsonc +{ + "task_id": "fix-health-test", + "status": "ok", // ok | blocked | error | needs_approval + "mode": "dry_run", + "patch_preview": "--- /dev/null\n+++ b/tests/test_health.py\n...", + "changed_files": [{ "path": "tests/test_health.py", "change_type": "added" }], + "review": "Review: change is small, additive ... Risk: low.", + "sandbox_result": null, // or MatrixLab result / {skipped, reason} + "risk_level": "low", // low | medium | high + "pr_url": null, + "messages": ["Dry-run: patch preview only. No PR opened."], + "warnings": [] +} +``` + +Pydantic model: `gitpilot.repair.schema.RepairResponse`. + +## Pipeline (`gitpilot.repair.service.RepairService.run`) + +1. Validate schema. +2. Clone repo into a temp workspace (stubbed in dry-run/demo or if unreachable). +3. Create local branch `gitpilot/`. +4. Refuse forbidden paths (fail-closed). +5. `code-fast` inspects context. +6. `code-coder` generates a unified diff constrained to `allowed_paths`. +7. Apply patch locally (dry-run computes preview only). +8. `code-reviewer` reviews the diff. +9. MatrixLab `validate-patch` (skipped if not required and unreachable; + fail-closed if `required` and unreachable; blocked if validation fails). +10. Build `repair-response.json`. +11. `draft_pr` mode → safe no-op stub (no real PR in the first wave). +12. `dry_run` mode → patch preview only, never a real PR. + +## Fail-closed safety (`gitpilot.repair.policy`) + +A change is **blocked** when: `allowed_paths` is empty; a changed file is +outside `allowed_paths`; a changed file matches `forbidden_paths`; the patch +touches `.env` or any secret/token material; `sandbox.required=true` and +MatrixLab is unavailable; sandbox validation fails; or `risk_level=high` with +no human approval. + +## Environment + +`GITPILOT_CLIENT_ID`, `GITPILOT_WORKSPACE_ID`, `OPENAI_BASE_URL`, +`OPENAI_API_KEY`, `GITPILOT_MODEL_FAST` (`code-fast`), +`GITPILOT_MODEL_CODER` (`code-coder`), `GITPILOT_MODEL_REVIEWER` +(`code-reviewer`), `GITPILOT_SANDBOX_PROVIDER` (`matrixlab`), `MATRIXLAB_URL`, +`MATRIXLAB_TOKEN`, `GITPILOT_DRAFT_PR_ENABLED` (`false`), +`GITPILOT_DEMO_MODE` (`true`). + +## CLI + +```bash +gitpilot repair --repo --plan repair-plan.json --sandbox matrixlab --dry-run +# standalone: +python -m gitpilot.repair.cli --plan repair-plan.json --dry-run --demo +``` diff --git a/docs/contracts/sandbox-provider.md b/docs/contracts/sandbox-provider.md new file mode 100644 index 0000000..cd743fc --- /dev/null +++ b/docs/contracts/sandbox-provider.md @@ -0,0 +1,68 @@ +# Sandbox provider contract + +GitPilot validates generated patches in a pluggable sandbox. Providers +implement the `SandboxProvider` ABC +(`gitpilot.sandbox_providers.base.SandboxProvider`). + +> The provider package is `gitpilot.sandbox_providers` (not `gitpilot.sandbox`) +> because a `gitpilot/sandbox.py` module already exists for an unrelated +> local-sandbox feature. + +## ABC + +```python +class SandboxProvider(ABC): + name: str + + def health(self) -> bool: ... + def run(self, **payload) -> SandboxResult: ... + def validate_patch(self, **payload) -> SandboxResult: ... +``` + +`health()` must **degrade gracefully** — return `False` when the sandbox is +unreachable, never raise. + +## MatrixLab client + +`gitpilot.sandbox_providers.matrixlab_client.MatrixLabClient` implements the +ABC against MatrixLab. Reads `MATRIXLAB_URL` (default `http://localhost:8765`) +and `MATRIXLAB_TOKEN` (sent as `Authorization: Bearer ...`). + +### Endpoints + +* `GET {MATRIXLAB_URL}/health` +* `POST {MATRIXLAB_URL}/repo/run` +* `POST {MATRIXLAB_URL}/repo/validate-patch` + +### Request body + +```jsonc +{ + "client_id": "...", + "workspace_id": "...", + "repo_url": "...", + "branch": "...", + "profile": "default", + "commands": ["pytest -q"], // optional + "timeout_seconds": 600, // optional + "artifacts": ["coverage.xml"]// optional +} +``` + +### Response + +```jsonc +{ + "run_id": "...", + "status": "passed", // passed | failed | error + "exit_code": 0, + "stdout": "...", + "stderr": "...", + "duration_ms": 1234, + "artifacts": [{ "name": "coverage.xml", "url": "https://..." }] +} +``` + +Normalized into `gitpilot.sandbox_providers.base.SandboxResult`. A +`SandboxResult` with `skipped=True` indicates the sandbox was not run (e.g. +unreachable and not required in a dry-run). diff --git a/gitpilot/api.py b/gitpilot/_api_app.py similarity index 99% rename from gitpilot/api.py rename to gitpilot/_api_app.py index 8f256a5..9039250 100644 --- a/gitpilot/api.py +++ b/gitpilot/_api_app.py @@ -334,6 +334,18 @@ def _env_bool(name: str, default: bool) -> bool: except Exception: # noqa: BLE001 logger.exception("MatrixLab admin API failed to mount; install modal will be disabled") +# Coder API (the generic GitPilot repair pipeline over HTTP): POST /repair + +# GET /repair/health, gated by a bearer token (GITPILOT_API_TOKEN). This is +# what SelfRepair / matrix-maintainer call to turn a repair-plan into a +# dry-run patch preview. Non-fatal mount so the UI/chat still work if it fails. +try: + from .repair_router import build_repair_router + + app.include_router(build_repair_router()) + logger.info("Coder API enabled (mounting /repair + /repair/health)") +except Exception: # noqa: BLE001 + logger.exception("Coder API failed to mount; /repair will be unavailable") + # GitPilot-as-MCP-server (turns GitPilot into an MCP server other agents # can drive). Off by default; mount only when GITPILOT_EXPOSE_MCP_SERVER=true. try: diff --git a/gitpilot/api/__init__.py b/gitpilot/api/__init__.py new file mode 100644 index 0000000..f2e76b0 --- /dev/null +++ b/gitpilot/api/__init__.py @@ -0,0 +1,27 @@ +"""GitPilot HTTP API package. + +This package preserves the historical ``gitpilot.api`` module surface: the +main FastAPI application and every public symbol previously importable from +``gitpilot.api`` now live in :mod:`gitpilot._api_app` and are re-exported here +verbatim. This keeps ``from gitpilot.api import app`` (and every other prior +import) working unchanged while allowing additive submodules such as +:mod:`gitpilot.api.repair_server` to live alongside the main app. +""" + +from __future__ import annotations + +# Re-export the entire historical module surface. ``_api_app`` is the original +# ``gitpilot/api.py`` content moved verbatim (relative imports unchanged), so +# every symbol (``app``, ``api_list_branches``, ``EnvironmentConfig``, …) and +# submodule attribute remains importable as ``gitpilot.api.``. +from gitpilot._api_app import * # noqa: F401,F403 +from gitpilot import _api_app as _api_app + +# ``import *`` only pulls names listed in ``__all__`` (if defined) or the +# module's public names. To guarantee 100% parity with the old flat module, +# copy across every public attribute that ``import *`` may have skipped. +for _name in dir(_api_app): + if _name.startswith("__"): + continue + globals().setdefault(_name, getattr(_api_app, _name)) +del _name diff --git a/gitpilot/api/repair_server.py b/gitpilot/api/repair_server.py new file mode 100644 index 0000000..b1d5efb --- /dev/null +++ b/gitpilot/api/repair_server.py @@ -0,0 +1,99 @@ +"""Standalone GitPilot HTTP repair API. + +This is an *additive* FastAPI application (separate from the main ``gitpilot`` +app) that exposes the generic GitPilot repair pipeline over HTTP so that +SelfRepair, matrix-maintainer, CI, or a developer can request a repair plan +to be turned into a (dry-run) patch preview. + +Endpoints +--------- +``GET /health`` -> liveness + demo-mode + version. +``POST /repair`` -> accepts a repair-plan JSON (the :class:`RepairRequest` + shape), runs the repair pipeline, and returns the + :class:`RepairResponse` as a dict. + +Safety +------ +* Defaults to **dry-run**: a body with no ``mode`` is treated as ``dry_run``. +* **Never opens a real PR**: ``draft_pr`` is downgraded to ``dry_run`` unless + ``GITPILOT_DRAFT_PR_ENABLED=true`` (defaults false). +* Inference reaches models only via OllaBridge (``OPENAI_BASE_URL`` / + ``OPENAI_API_KEY``); ``GITPILOT_DEMO_MODE=true`` produces a deterministic + offline stub patch. ``HF_TOKEN`` is never read here. +* Empty / violated ``allowed_paths`` fail closed (``status="blocked"``), never + a 500. + +Run +--- +``uvicorn gitpilot.api.repair_server:app`` +``python -m gitpilot.api.repair_server`` (host 0.0.0.0, port ``$PORT`` or 9000) +""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +try: # package version is best-effort; never fatal + from gitpilot.version import __version__ as _PKG_VERSION +except Exception: # pragma: no cover - defensive + _PKG_VERSION = "0.1.0" + +from gitpilot.repair.schema import RepairMode, RepairRequest +from gitpilot.repair.service import run_repair + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _demo_mode() -> bool: + return _truthy(os.getenv("GITPILOT_DEMO_MODE")) + + +def _draft_pr_enabled() -> bool: + """Real draft PRs are disabled by default (fail-safe).""" + return _truthy(os.getenv("GITPILOT_DRAFT_PR_ENABLED")) + + +def create_app() -> FastAPI: + """Build the GitPilot repair FastAPI application.""" + app = FastAPI( + title="GitPilot Repair API", + description="Generic GitPilot repair pipeline over HTTP (dry-run safe).", + version=_PKG_VERSION or "0.1.0", + ) + + @app.get("/health") + def health() -> dict[str, Any]: + return { + "status": "ok", + "service": "gitpilot", + "version": _PKG_VERSION or "0.1.0", + "demo_mode": _demo_mode(), + } + + # Share the bearer-gated coder router (GET /repair/health + POST /repair). + from gitpilot.repair_router import build_repair_router + + app.include_router(build_repair_router()) + return app + + +# Module-level app for ``uvicorn gitpilot.api.repair_server:app``. +app = create_app() + + +def main() -> None: + """Run the repair server with uvicorn (``python -m ...``).""" + import uvicorn + + port = int(os.getenv("PORT", "9000")) + uvicorn.run(app, host="0.0.0.0", port=port) + + +if __name__ == "__main__": # pragma: no cover - manual/CLI entrypoint + main() diff --git a/gitpilot/cli.py b/gitpilot/cli.py index a0c860a..2fcfb99 100644 --- a/gitpilot/cli.py +++ b/gitpilot/cli.py @@ -845,3 +845,39 @@ def generate_cmd( else: console.print(f"[green]Generated {len(files_written)} file(s) in {os.path.abspath(output_dir)}[/green]") + + +@cli.command("repair") +def repair_cmd( + repo: str = typer.Option(None, "--repo", "-r", help="Repository URL (overrides plan)"), + plan: str = typer.Option(..., "--plan", "-p", help="Path to repair-plan.json (RepairRequest)"), + sandbox: str = typer.Option(None, "--sandbox", help="Sandbox provider, e.g. matrixlab"), + dry_run: bool = typer.Option(True, "--dry-run/--no-dry-run", help="Dry-run (default): no PR, no push"), + out: str = typer.Option("repair-response.json", "--out", "-o", help="Output repair-response JSON path"), + demo: bool = typer.Option(False, "--demo", help="Force offline demo/stub mode"), +): + """Run the GENERIC GitPilot repair flow from a repair-plan.json. + + Reads a SelfRepair repair-plan (== RepairRequest), generates a patch via + OllaBridge / any OpenAI-compatible endpoint, optionally validates it in a + MatrixLab sandbox, prints the patch PREVIEW + summary, and writes + repair-response.json. In the first wave this NEVER opens a real PR. + + Example:: + + gitpilot repair --repo https://github.com/acme/app \ + --plan repair-plan.json --sandbox matrixlab --dry-run + """ + from .repair.cli import run_cli as _repair_run_cli + + argv: list[str] = ["--plan", plan, "--out", out] + if repo: + argv += ["--repo", repo] + if sandbox: + argv += ["--sandbox", sandbox] + if dry_run: + argv += ["--dry-run"] + if demo: + argv += ["--demo"] + code = _repair_run_cli(argv) + raise typer.Exit(code=code) diff --git a/gitpilot/inference/__init__.py b/gitpilot/inference/__init__.py new file mode 100644 index 0000000..9cc9451 --- /dev/null +++ b/gitpilot/inference/__init__.py @@ -0,0 +1,18 @@ +"""GitPilot inference clients. + +GENERIC, provider-neutral access to an OpenAI-compatible chat endpoint +(OllaBridge or any ``/v1/chat/completions`` server). + +This package NEVER reads ``HF_TOKEN``. It only ever reads +``OPENAI_BASE_URL`` / ``OPENAI_API_KEY`` (plus the optional model-alias env +vars documented in ``.env.template``). +""" + +from .ollabridge_client import OllaBridgeClient +from .openai_compatible_client import OpenAICompatibleClient, StubResponse + +__all__ = [ + "OpenAICompatibleClient", + "OllaBridgeClient", + "StubResponse", +] diff --git a/gitpilot/inference/ollabridge_client.py b/gitpilot/inference/ollabridge_client.py new file mode 100644 index 0000000..e804c98 --- /dev/null +++ b/gitpilot/inference/ollabridge_client.py @@ -0,0 +1,70 @@ +"""Thin OllaBridge wrapper over the generic OpenAI-compatible client. + +Exposes model-alias helpers keyed by env: + +* ``GITPILOT_MODEL_FAST`` (default ``code-fast``) +* ``GITPILOT_MODEL_CODER`` (default ``code-coder``) +* ``GITPILOT_MODEL_REVIEWER`` (default ``code-reviewer``) + +OllaBridge is OpenAI-compatible, so this is intentionally a very thin layer. +It NEVER reads ``HF_TOKEN`` — only ``OPENAI_BASE_URL`` / ``OPENAI_API_KEY``. +""" + +from __future__ import annotations + +import os +from typing import Any + +from .openai_compatible_client import OpenAICompatibleClient, messages_from + + +def model_fast() -> str: + return os.getenv("GITPILOT_MODEL_FAST", "code-fast") + + +def model_coder() -> str: + return os.getenv("GITPILOT_MODEL_CODER", "code-coder") + + +def model_reviewer() -> str: + return os.getenv("GITPILOT_MODEL_REVIEWER", "code-reviewer") + + +class OllaBridgeClient: + """High-level helper exposing the three GitPilot model roles.""" + + def __init__( + self, + client: OpenAICompatibleClient | None = None, + base_url: str | None = None, + api_key: str | None = None, + demo_mode: bool | None = None, + ) -> None: + self.client = client or OpenAICompatibleClient( + base_url=base_url, api_key=api_key, demo_mode=demo_mode + ) + + # ------------------------------------------------------------------ # + # Role helpers + # ------------------------------------------------------------------ # + def fast(self, user: str, system: str | None = None, **opts: Any) -> str: + """Quick context inspection via the ``code-fast`` alias.""" + return self.client.chat(model_fast(), messages_from(system, user), **opts) + + def code(self, user: str, system: str | None = None, **opts: Any) -> str: + """Patch generation via the ``code-coder`` alias.""" + return self.client.chat(model_coder(), messages_from(system, user), **opts) + + def review(self, user: str, system: str | None = None, **opts: Any) -> str: + """Patch review via the ``code-reviewer`` alias.""" + return self.client.chat(model_reviewer(), messages_from(system, user), **opts) + + # Generic passthrough (caller picks the model/alias). + def chat(self, model: str, messages: list[dict[str, Any]], **opts: Any) -> str: + return self.client.chat(model, messages, **opts) + + def is_demo(self) -> bool: + return self.client.is_demo() + + def is_configured(self) -> bool: + return self.client.is_configured() diff --git a/gitpilot/inference/openai_compatible_client.py b/gitpilot/inference/openai_compatible_client.py new file mode 100644 index 0000000..9759450 --- /dev/null +++ b/gitpilot/inference/openai_compatible_client.py @@ -0,0 +1,228 @@ +"""Minimal OpenAI-compatible chat client. + +Talks to any ``/v1/chat/completions`` endpoint (OllaBridge, vLLM, llama.cpp, +OpenAI itself, …) using ``OPENAI_BASE_URL`` + ``OPENAI_API_KEY``. + +Key properties +-------------- +* **Offline-safe.** When ``GITPILOT_DEMO_MODE=true`` *or* no endpoint is + reachable/configured, :meth:`OpenAICompatibleClient.chat` returns a + deterministic STUB assistant message so dry-runs work fully offline. +* **Generic.** No Hugging Face, no ``HF_TOKEN``. Only OpenAI-compatible env. +* **Lazy deps.** ``httpx`` is imported lazily; demo mode needs no network deps. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from typing import Any + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def demo_mode_enabled() -> bool: + """Return True when GitPilot should run in deterministic offline demo mode.""" + return _truthy(os.getenv("GITPILOT_DEMO_MODE")) + + +@dataclass +class StubResponse: + """A deterministic, network-free assistant response. + + Used in demo mode or when the endpoint is unreachable. The content is + keyed off the model alias so the stubbed coder produces a plausible diff + while the reviewer / fast models produce short text. + """ + + content: str + model: str = "stub" + usage: dict[str, int] = field(default_factory=lambda: { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }) + + def as_openai(self) -> dict[str, Any]: + return { + "id": "stub-completion", + "object": "chat.completion", + "model": self.model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": self.content}, + "finish_reason": "stop", + } + ], + "usage": self.usage, + } + + +# --------------------------------------------------------------------------- # +# Deterministic stub content +# --------------------------------------------------------------------------- # + +_STUB_DIFF = """\ +--- /dev/null ++++ b/tests/test_health.py +@@ -0,0 +1,9 @@ ++\"\"\"Auto-generated smoke test (GitPilot demo stub).\"\"\" ++ ++ ++def test_health_ok(): ++ \"\"\"Placeholder health check added by the GitPilot repair flow.\"\"\" ++ assert True ++ ++ ++# Generated in GITPILOT_DEMO_MODE / offline dry-run. +""" + + +def _stub_for(model: str, messages: list[dict[str, Any]]) -> StubResponse: + """Return a deterministic stub keyed by model alias / role.""" + alias = (model or "").lower() + # Coder alias -> emit a small, plausible unified diff. + if "coder" in alias or "code-coder" in alias: + return StubResponse(content=_STUB_DIFF, model=model or "code-coder") + if "review" in alias: + return StubResponse( + content=( + "Review: change is small, additive and constrained to the " + "allowed test path. No security or secret exposure detected. " + "Risk: low." + ), + model=model or "code-reviewer", + ) + # Fast / inspector alias -> short context summary. + last = "" + for m in reversed(messages or []): + if m.get("role") == "user": + last = str(m.get("content", ""))[:200] + break + return StubResponse( + content=( + "Context inspected (demo stub). The repository appears to lack a " + "basic health test; a minimal additive test can satisfy the issue. " + f"Prompt echo: {last}" + ), + model=model or "code-fast", + ) + + +class OpenAICompatibleClient: + """Tiny chat client for OpenAI-compatible servers. + + Parameters + ---------- + base_url: + Overrides ``OPENAI_BASE_URL``. + api_key: + Overrides ``OPENAI_API_KEY``. + timeout: + Request timeout in seconds. + demo_mode: + Force demo/stub mode regardless of env. + """ + + def __init__( + self, + base_url: str | None = None, + api_key: str | None = None, + timeout: float = 60.0, + demo_mode: bool | None = None, + ) -> None: + self.base_url = (base_url or os.getenv("OPENAI_BASE_URL") or "").rstrip("/") + self.api_key = api_key or os.getenv("OPENAI_API_KEY") or "" + self.timeout = timeout + self._forced_demo = demo_mode + + # ------------------------------------------------------------------ # + # Mode detection + # ------------------------------------------------------------------ # + def is_demo(self) -> bool: + if self._forced_demo is not None: + return self._forced_demo + return demo_mode_enabled() + + def is_configured(self) -> bool: + """True when an endpoint is configured (base_url present).""" + return bool(self.base_url) + + # ------------------------------------------------------------------ # + # Chat + # ------------------------------------------------------------------ # + def chat( + self, + model: str, + messages: list[dict[str, Any]], + **opts: Any, + ) -> str: + """Return the assistant message content. + + Degrades to a deterministic stub when in demo mode, when no endpoint + is configured, or when the endpoint is unreachable. + """ + return self.chat_completion(model, messages, **opts)["choices"][0]["message"][ + "content" + ] + + def chat_completion( + self, + model: str, + messages: list[dict[str, Any]], + **opts: Any, + ) -> dict[str, Any]: + """Return the full OpenAI-shaped response dict (stub or real).""" + if self.is_demo() or not self.is_configured(): + return _stub_for(model, messages).as_openai() + + try: + import httpx # lazy import + except Exception: # pragma: no cover - httpx is a declared dep + return _stub_for(model, messages).as_openai() + + url = f"{self.base_url}/v1/chat/completions" + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + body: dict[str, Any] = {"model": model, "messages": messages} + for key in ("temperature", "max_tokens", "top_p", "stop", "seed"): + if key in opts and opts[key] is not None: + body[key] = opts[key] + + try: + resp = httpx.post(url, headers=headers, json=body, timeout=self.timeout) + resp.raise_for_status() + data = resp.json() + except Exception: + # Unreachable / error -> fail soft to stub so dry-run never breaks. + return _stub_for(model, messages).as_openai() + + # Validate minimal shape; fall back to stub if malformed. + if not isinstance(data, dict) or not data.get("choices"): + return _stub_for(model, messages).as_openai() + return data + + def __repr__(self) -> str: # pragma: no cover - debug helper + return ( + f"OpenAICompatibleClient(base_url={self.base_url!r}, " + f"configured={self.is_configured()}, demo={self.is_demo()})" + ) + + +def messages_from(system: str | None, user: str) -> list[dict[str, Any]]: + """Convenience helper to build a chat message list.""" + msgs: list[dict[str, Any]] = [] + if system: + msgs.append({"role": "system", "content": system}) + msgs.append({"role": "user", "content": user}) + return msgs + + +def dumps(obj: Any) -> str: # pragma: no cover - trivial + return json.dumps(obj, indent=2, sort_keys=True) diff --git a/gitpilot/repair/__init__.py b/gitpilot/repair/__init__.py new file mode 100644 index 0000000..66920be --- /dev/null +++ b/gitpilot/repair/__init__.py @@ -0,0 +1,27 @@ +"""GitPilot repair flow — the GENERIC patch-generation pipeline. + +GitPilot is the only component that *generates* patches. This package is +provider-neutral: it calls OllaBridge (or any OpenAI-compatible endpoint) +and an optional sandbox (MatrixLab) and is consumable by SelfRepair, +Agent-Matrix, CI, or a developer. + +Public/dry-run behaviour is always SAFE: no real PRs, no network required. +""" + +from .schema import ( + DEFAULT_FORBIDDEN_PATHS, + CoderSpec, + Issue, + RepairRequest, + RepairResponse, + SandboxSpec, +) + +__all__ = [ + "RepairRequest", + "RepairResponse", + "Issue", + "CoderSpec", + "SandboxSpec", + "DEFAULT_FORBIDDEN_PATHS", +] diff --git a/gitpilot/repair/cli.py b/gitpilot/repair/cli.py new file mode 100644 index 0000000..c16e013 --- /dev/null +++ b/gitpilot/repair/cli.py @@ -0,0 +1,105 @@ +"""GitPilot repair CLI. + +Usage:: + + gitpilot repair --repo --plan --sandbox matrixlab --dry-run + + # or, standalone: + python -m gitpilot.repair.cli --repo --plan --dry-run + +Reads the repair-plan JSON (a SelfRepair repair-plan == RepairRequest), +runs the service in dry-run, prints the patch PREVIEW + a summary, writes +``repair-response.json`` and exits 0. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +from .report import build_report +from .schema import RepairMode, RepairRequest +from .service import run_repair + + +def _load_plan(path: str) -> dict: + data = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError("repair-plan must be a JSON object") + return data + + +def run_cli(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="gitpilot repair", + description="Run the GitPilot generic repair flow (dry-run by default).", + ) + parser.add_argument("--repo", "-r", help="Repository URL (overrides plan repo_url)") + parser.add_argument( + "--plan", "-p", required=True, help="Path to repair-plan.json (RepairRequest)" + ) + parser.add_argument( + "--sandbox", + default=None, + help="Sandbox provider (e.g. matrixlab). Overrides plan sandbox.provider.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Force dry-run mode (no PR, no push). Default behaviour.", + ) + parser.add_argument( + "--out", + "-o", + default="repair-response.json", + help="Where to write the repair-response JSON.", + ) + parser.add_argument( + "--demo", + action="store_true", + help="Force demo/offline stub mode (no network).", + ) + args = parser.parse_args(argv) + + plan = _load_plan(args.plan) + + if args.repo: + plan["repo_url"] = args.repo + if args.sandbox: + plan.setdefault("sandbox", {}) + plan["sandbox"]["provider"] = args.sandbox + if args.dry_run or "mode" not in plan: + plan["mode"] = RepairMode.dry_run.value + + request = RepairRequest.from_json(plan) + + # In dry-run the CLI never opens a PR; demo flag forces offline stubs. + demo = True if args.demo else None + if demo is None and os.getenv("GITPILOT_DEMO_MODE") is None: + # Default the CLI to demo/offline unless explicitly configured otherwise. + demo = True + + response = run_repair(request, demo_mode=demo) + + # Write repair-response.json + out_path = Path(args.out) + out_path.write_text( + json.dumps(response.to_json(), indent=2, sort_keys=True), encoding="utf-8" + ) + + # Print report + preview + print(build_report(response)) + print(f"\nWrote repair-response to: {out_path}") + + return 0 + + +def main() -> None: + sys.exit(run_cli()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/gitpilot/repair/policy.py b/gitpilot/repair/policy.py new file mode 100644 index 0000000..c020686 --- /dev/null +++ b/gitpilot/repair/policy.py @@ -0,0 +1,173 @@ +"""Path policy + safety checks for the GitPilot repair flow. + +Everything here is **fail-closed**: when in doubt, deny. + +A change is BLOCKED when any of these hold: + +* ``allowed_paths`` is empty; +* a changed file is outside ``allowed_paths``; +* a changed file matches ``forbidden_paths``; +* the patch touches ``.env``; +* the patch touches anything under ``secrets`` / matching ``*secret*`` / ``*token*``; +* ``sandbox.required`` is true and MatrixLab is unavailable; +* sandbox validation fails; +* ``risk_level`` is high and there is no human-approval flag. +""" + +from __future__ import annotations + +import fnmatch +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # avoid import cycle at runtime + from .schema import RepairRequest + +from .schema import DEFAULT_FORBIDDEN_PATHS + + +@dataclass +class PolicyResult: + """Outcome of a policy evaluation.""" + + allowed: bool + violations: list[str] = field(default_factory=list) + + def __bool__(self) -> bool: # truthy == allowed + return self.allowed + + +class PolicyViolation(Exception): + """Raised when a fail-closed policy check is violated.""" + + def __init__(self, violations: list[str]) -> None: + self.violations = violations + super().__init__("; ".join(violations)) + + +def _normalize(path: str) -> str: + p = path.replace("\\", "/") + while p.startswith("./"): + p = p[2:] + return p.lstrip("/") + + +def _matches_any(path: str, globs: list[str]) -> bool: + norm = _normalize(path) + base = norm.rsplit("/", 1)[-1] + for g in globs: + gg = _normalize(g) + # Match full path and basename; support **. + if fnmatch.fnmatch(norm, gg) or fnmatch.fnmatch(base, gg): + return True + # `secrets/**` should also match `secrets/foo` + if gg.endswith("/**") and (norm == gg[:-3] or norm.startswith(gg[:-2])): + return True + # `**/*token*` style: also match when token/secret appears anywhere. + if "**" in gg: + tail = gg.split("**", 1)[-1].strip("/") + if tail and fnmatch.fnmatch(norm, f"*{tail}") or (tail and fnmatch.fnmatch(base, tail)): + return True + return False + + +# Hard, always-on denylist (independent of request-supplied forbidden_paths). +_HARD_DENY = [".env", ".env.*", "secrets/**", "**/*secret*", "**/*token*"] + + +def _touches_secret_or_env(path: str) -> bool: + norm = _normalize(path).lower() + base = norm.rsplit("/", 1)[-1] + if base == ".env" or base.startswith(".env."): + return True + if "secret" in norm or "token" in norm: + return True + if norm == "secrets" or norm.startswith("secrets/"): + return True + return False + + +def is_path_allowed( + path: str, + allowed: list[str], + forbidden: list[str] | None = None, +) -> bool: + """Return True iff *path* is inside *allowed* and not in *forbidden*. + + Fail-closed: empty *allowed* => not allowed; secrets/env => not allowed. + """ + forbidden = list(forbidden or []) + DEFAULT_FORBIDDEN_PATHS + _HARD_DENY + if not allowed: + return False + if _touches_secret_or_env(path): + return False + if _matches_any(path, forbidden): + return False + return _matches_any(path, allowed) + + +def validate_changed_files( + files: list[str], + request: RepairRequest, +) -> PolicyResult: + """Evaluate the set of changed files against the request policy. + + Returns a :class:`PolicyResult` (does not raise) so callers can decide + whether to raise or record a blocked response. + """ + violations: list[str] = [] + allowed = list(request.allowed_paths or []) + forbidden = list(request.forbidden_paths or []) + + if not allowed: + violations.append("allowed_paths is empty (fail-closed): no changes permitted") + return PolicyResult(allowed=False, violations=violations) + + if not files: + # No changes is acceptable (nothing to block), but caller may warn. + return PolicyResult(allowed=True, violations=[]) + + for f in files: + norm = _normalize(f) + if _touches_secret_or_env(f): + violations.append(f"file touches secret/.env material: {norm}") + continue + if _matches_any(f, forbidden + DEFAULT_FORBIDDEN_PATHS + _HARD_DENY): + violations.append(f"file matches forbidden_paths: {norm}") + continue + if not _matches_any(f, allowed): + violations.append(f"file outside allowed_paths: {norm}") + + return PolicyResult(allowed=not violations, violations=violations) + + +def check_sandbox_policy( + request: RepairRequest, + sandbox_healthy: bool, +) -> PolicyResult: + """Fail-closed when sandbox is required but unavailable.""" + if request.sandbox.required and not sandbox_healthy: + return PolicyResult( + allowed=False, + violations=["sandbox.required=true but MatrixLab is unavailable"], + ) + return PolicyResult(allowed=True) + + +def check_risk_policy( + risk_level: str, + human_approval: bool, +) -> PolicyResult: + """Fail-closed when risk is high without human approval.""" + if str(risk_level).lower() == "high" and not human_approval: + return PolicyResult( + allowed=False, + violations=["risk_level=high requires human approval"], + ) + return PolicyResult(allowed=True) + + +def assert_allowed(result: PolicyResult) -> None: + """Raise :class:`PolicyViolation` if the result is not allowed.""" + if not result.allowed: + raise PolicyViolation(result.violations) diff --git a/gitpilot/repair/pr_writer.py b/gitpilot/repair/pr_writer.py new file mode 100644 index 0000000..cf15863 --- /dev/null +++ b/gitpilot/repair/pr_writer.py @@ -0,0 +1,61 @@ +"""Draft-PR writer abstraction. + +FIRST WAVE: this is a SAFE no-op / dry-run stub. It NEVER opens a real PR. +``GITPILOT_DRAFT_PR_ENABLED`` defaults to ``false``; even when set, the first +wave returns a simulated result and does not call any Git host. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def draft_pr_enabled() -> bool: + return _truthy(os.getenv("GITPILOT_DRAFT_PR_ENABLED")) + + +@dataclass +class PRResult: + created: bool + url: str | None + message: str + + +class DraftPRWriter: + """Stubbed draft-PR writer. + + In the first wave this never performs network operations. It exists so + the service can call a stable interface that later waves can implement. + """ + + def create_draft_pr( + self, + repo_url: str, + branch: str, + title: str, + body: str, + base: str = "main", + ) -> PRResult: + if not draft_pr_enabled(): + return PRResult( + created=False, + url=None, + message=( + "Draft PR disabled (GITPILOT_DRAFT_PR_ENABLED=false). " + "No PR created." + ), + ) + # First wave: even when "enabled", do NOT open a real PR. + return PRResult( + created=False, + url=None, + message=( + "Draft PR is a no-op stub in the first wave. " + f"Would open PR '{title}' on {repo_url} ({branch} -> {base})." + ), + ) diff --git a/gitpilot/repair/report.py b/gitpilot/repair/report.py new file mode 100644 index 0000000..0915cc8 --- /dev/null +++ b/gitpilot/repair/report.py @@ -0,0 +1,72 @@ +"""Build a human-readable repair report and the repair-response.json structure.""" + +from __future__ import annotations + +from typing import Any + +from .schema import RepairResponse + + +def build_report(response: RepairResponse) -> str: + """Return a concise, human-readable summary of a repair response.""" + lines: list[str] = [] + lines.append("=" * 60) + lines.append("GitPilot Repair Report") + lines.append("=" * 60) + lines.append(f"task_id : {response.task_id}") + lines.append(f"mode : {response.mode.value}") + lines.append(f"status : {response.status}") + lines.append(f"risk_level : {response.risk_level.value}") + + if response.changed_files: + lines.append("") + lines.append("Changed files:") + for cf in response.changed_files: + lines.append(f" [{cf.change_type}] {cf.path}") + else: + lines.append("") + lines.append("Changed files: (none)") + + if response.review: + lines.append("") + lines.append("Review:") + lines.append(f" {response.review}") + + if response.sandbox_result is not None: + sr = response.sandbox_result + lines.append("") + if sr.get("skipped"): + lines.append(f"Sandbox : skipped ({sr.get('reason', '')})") + else: + lines.append( + f"Sandbox : {sr.get('status', 'n/a')} " + f"(exit_code={sr.get('exit_code')})" + ) + + if response.pr_url: + lines.append("") + lines.append(f"PR : {response.pr_url}") + + if response.warnings: + lines.append("") + lines.append("Warnings:") + for w in response.warnings: + lines.append(f" - {w}") + + if response.messages: + lines.append("") + lines.append("Messages:") + for m in response.messages: + lines.append(f" - {m}") + + lines.append("") + lines.append("Patch preview:") + lines.append("-" * 60) + lines.append(response.patch_preview or "(empty)") + lines.append("-" * 60) + return "\n".join(lines) + + +def build_response_json(response: RepairResponse) -> dict[str, Any]: + """Return the JSON-serializable repair-response dict.""" + return response.to_json() diff --git a/gitpilot/repair/schema.py b/gitpilot/repair/schema.py new file mode 100644 index 0000000..67e3dac --- /dev/null +++ b/gitpilot/repair/schema.py @@ -0,0 +1,120 @@ +"""Pydantic models for the GitPilot repair request/response contract. + +The ``repair-plan.json`` produced by SelfRepair has the same shape as +:class:`RepairRequest`. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel, Field + +# Default fail-closed forbidden globs (always denied even if not specified). +DEFAULT_FORBIDDEN_PATHS: list[str] = [ + ".env", + "secrets/**", + "**/*token*", + "**/*secret*", +] + + +class RepairMode(str, Enum): + dry_run = "dry_run" + draft_pr = "draft_pr" + apply = "apply" + + +class Severity(str, Enum): + low = "low" + medium = "medium" + high = "high" + critical = "critical" + + +class RiskLevel(str, Enum): + low = "low" + medium = "medium" + high = "high" + + +class Issue(BaseModel): + """A single issue SelfRepair / a caller wants GitPilot to fix.""" + + id: str + severity: Severity = Severity.medium + description: str = "" + recommended_action: str = "" + + +class CoderSpec(BaseModel): + """Which inference provider/model to use for patch generation.""" + + provider: str = "ollabridge" + model: str = "code-coder" + + +class SandboxSpec(BaseModel): + """Sandbox configuration for patch validation.""" + + provider: str = "matrixlab" + profile: str = "default" + required: bool = False + + +class RepairRequest(BaseModel): + """Repair request (a.k.a. SelfRepair repair-plan).""" + + client_id: str + workspace_id: str + task_id: str + repo_url: str + branch: str = "main" + mode: RepairMode = RepairMode.dry_run + + issues: list[Issue] = Field(default_factory=list) + + allowed_paths: list[str] = Field(default_factory=list) + forbidden_paths: list[str] = Field(default_factory=lambda: list(DEFAULT_FORBIDDEN_PATHS)) + + coder: CoderSpec = Field(default_factory=CoderSpec) + sandbox: SandboxSpec = Field(default_factory=SandboxSpec) + + # Optional human-approval flag (required to proceed on high risk). + human_approval: bool = False + + @classmethod + def from_json(cls, data: dict[str, Any]) -> RepairRequest: + return cls.model_validate(data) + + +class ChangedFile(BaseModel): + path: str + change_type: Literal["added", "modified", "deleted"] = "modified" + + +class RepairResponse(BaseModel): + """Repair response returned to the caller / written to repair-response.json.""" + + task_id: str + status: Literal["ok", "blocked", "error", "needs_approval"] = "ok" + mode: RepairMode = RepairMode.dry_run + + patch_preview: str = "" # unified diff text + changed_files: list[ChangedFile] = Field(default_factory=list) + review: str = "" # reviewer text/summary + + sandbox_result: dict[str, Any] | None = None + risk_level: RiskLevel = RiskLevel.low + + pr_url: str | None = None + messages: list[str] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + + def to_json(self) -> dict[str, Any]: + return self.model_dump(mode="json") + + def to_dict(self) -> dict[str, Any]: + """Alias for :meth:`to_json` (JSON-serializable dict of the response).""" + return self.to_json() diff --git a/gitpilot/repair/service.py b/gitpilot/repair/service.py new file mode 100644 index 0000000..867230f --- /dev/null +++ b/gitpilot/repair/service.py @@ -0,0 +1,388 @@ +"""Repair flow orchestration. + +Pipeline (see deliverable spec): + +1. validate schema +2. clone repo into a temp workspace (skip/clone-stub in dry-run/demo if unreachable) +3. create local branch ``gitpilot/`` +4. refuse forbidden paths +5. call ``code-fast`` to inspect context +6. call ``code-coder`` to generate a patch (unified diff) constrained to allowed_paths +7. apply patch locally (dry-run: just compute preview) +8. call ``code-reviewer`` to review +9. send branch/workspace to MatrixLab ``validate-patch`` (sandbox policy applies) +10. build repair-response +11. draft_pr mode -> stub (no real PR in first wave) +12. dry_run -> return patch preview only, NO real PR + +The whole flow is OFFLINE-safe in demo mode: clients degrade to stubs. +""" + +from __future__ import annotations + +import re +import subprocess +import tempfile +from dataclasses import dataclass +from typing import Any + +from ..inference.ollabridge_client import OllaBridgeClient +from ..inference.openai_compatible_client import demo_mode_enabled +from ..sandbox_providers.base import SandboxProvider, SandboxResult +from ..sandbox_providers.matrixlab_client import MatrixLabClient +from .policy import ( + PolicyViolation, + check_risk_policy, + check_sandbox_policy, + validate_changed_files, +) +from .pr_writer import DraftPRWriter +from .schema import ( + ChangedFile, + RepairMode, + RepairRequest, + RepairResponse, + RiskLevel, +) + +# Matches the file path on the `+++ b/` line of a unified diff. +_DIFF_PLUS = re.compile(r"^\+\+\+ [ab]/(?P.+?)\s*$", re.MULTILINE) +_DIFF_MINUS = re.compile(r"^--- [ab]/(?P.+?)\s*$", re.MULTILINE) + + +@dataclass +class RepairContext: + """Carries workspace state through the pipeline.""" + + workspace: str | None = None + cloned: bool = False + branch: str = "" + + +class RepairService: + """Orchestrates the GENERIC GitPilot repair flow.""" + + def __init__( + self, + coder: OllaBridgeClient | None = None, + sandbox: SandboxProvider | None = None, + pr_writer: DraftPRWriter | None = None, + demo_mode: bool | None = None, + ) -> None: + self._demo = demo_mode if demo_mode is not None else demo_mode_enabled() + self.coder = coder or OllaBridgeClient(demo_mode=self._demo) + self.sandbox = sandbox # may be None; built on demand from request + self.pr_writer = pr_writer or DraftPRWriter() + + # ------------------------------------------------------------------ # + # Public entrypoint + # ------------------------------------------------------------------ # + def run(self, request: RepairRequest) -> RepairResponse: + response = RepairResponse( + task_id=request.task_id, + mode=request.mode, + status="ok", + ) + ctx = RepairContext(branch=f"gitpilot/{request.task_id}") + + try: + # (4) refuse forbidden paths up front (empty allowed_paths => block) + if not request.allowed_paths: + response.status = "blocked" + response.warnings.append( + "allowed_paths is empty (fail-closed): no changes permitted" + ) + return response + + # (2) clone repo (best-effort; stubbed in demo/unreachable) + self._prepare_workspace(request, ctx, response) + + # (3) branch creation noted (best-effort, local only) + response.messages.append(f"Local branch: {ctx.branch}") + + # (5) inspect context with code-fast + issues_text = self._summarize_issues(request) + inspection = self.coder.fast( + user=( + "Inspect this repository context and the reported issues. " + "Summarize what minimal, additive change is needed.\n\n" + f"Repo: {request.repo_url}\nIssues:\n{issues_text}" + ), + system="You are a fast code-context inspector.", + ) + response.messages.append("Context inspected via code-fast.") + + # (6) generate a patch with code-coder, constrained to allowed_paths + patch = self.coder.code( + user=( + "Generate a minimal unified diff (git format) that fixes the " + "issues. ONLY modify files matching these allowed globs: " + f"{request.allowed_paths}. Do NOT touch .env, secrets, or any " + "token/secret files.\n\n" + f"Context summary:\n{inspection}\n\nIssues:\n{issues_text}" + ), + system="You are an expert software engineer producing safe unified diffs.", + ) + patch = _ensure_diff(patch) + response.patch_preview = patch + + changed = _changed_files_from_diff(patch) + response.changed_files = [ + ChangedFile(path=p, change_type=t) for p, t in changed + ] + + # (4 cont.) enforce path policy — FAIL CLOSED + pol = validate_changed_files([p for p, _ in changed], request) + if not pol.allowed: + response.status = "blocked" + response.warnings.extend(pol.violations) + return response + + # (7) apply patch locally only when not dry-run and we have a workspace + if request.mode != RepairMode.dry_run and ctx.cloned and ctx.workspace: + self._apply_patch(ctx.workspace, patch, response) + + # (8) review with code-reviewer + review = self.coder.review( + user=( + "Review the following unified diff for correctness, safety, " + "and secret exposure. Give a one-paragraph verdict and a risk " + f"level (low/medium/high).\n\n{patch}" + ), + system="You are a senior code reviewer.", + ) + response.review = review + + # risk assessment (heuristic, conservative) + risk = _assess_risk(review, changed) + response.risk_level = risk + + # (9) sandbox validation + self._run_sandbox(request, ctx, patch, response) + + # risk gate — high risk needs human approval (fail-closed) + risk_pol = check_risk_policy(risk.value, request.human_approval) + if not risk_pol.allowed: + response.status = "needs_approval" + response.warnings.extend(risk_pol.violations) + return response + + # (11) draft_pr -> stub; (12) dry_run -> preview only + if request.mode == RepairMode.draft_pr: + pr = self.pr_writer.create_draft_pr( + repo_url=request.repo_url, + branch=ctx.branch, + title=f"GitPilot repair: {request.task_id}", + body=review, + base=request.branch, + ) + response.pr_url = pr.url + response.messages.append(pr.message) + elif request.mode == RepairMode.dry_run: + response.messages.append( + "Dry-run: patch preview only. No PR opened, no changes pushed." + ) + + except PolicyViolation as exc: + response.status = "blocked" + response.warnings.extend(exc.violations) + except Exception as exc: # never crash the caller + response.status = "error" + response.warnings.append(f"repair flow error: {exc}") + finally: + self._cleanup(ctx) + + return response + + # ------------------------------------------------------------------ # + # Pipeline helpers + # ------------------------------------------------------------------ # + def _prepare_workspace( + self, request: RepairRequest, ctx: RepairContext, response: RepairResponse + ) -> None: + if self._demo: + response.messages.append("Demo mode: workspace clone stubbed (offline).") + return + # Best-effort shallow clone; never fatal in dry-run. + try: + ctx.workspace = tempfile.mkdtemp(prefix="gitpilot-repair-") + subprocess.run( + ["git", "clone", "--depth", "1", request.repo_url, ctx.workspace], + check=True, + capture_output=True, + timeout=120, + ) + ctx.cloned = True + subprocess.run( + ["git", "-C", ctx.workspace, "checkout", "-b", ctx.branch], + check=False, + capture_output=True, + timeout=30, + ) + response.messages.append("Repository cloned into temp workspace.") + except Exception as exc: + response.warnings.append( + f"clone skipped/unavailable ({exc}); proceeding with preview only" + ) + + def _apply_patch( + self, workspace: str, patch: str, response: RepairResponse + ) -> None: + try: + proc = subprocess.run( + ["git", "-C", workspace, "apply", "--whitespace=nowarn", "-"], + input=patch.encode(), + capture_output=True, + timeout=30, + ) + if proc.returncode != 0: + response.warnings.append( + f"patch did not apply cleanly: {proc.stderr.decode()[:300]}" + ) + else: + response.messages.append("Patch applied locally.") + except Exception as exc: + response.warnings.append(f"patch apply error: {exc}") + + def _run_sandbox( + self, + request: RepairRequest, + ctx: RepairContext, + patch: str, + response: RepairResponse, + ) -> None: + sandbox = self.sandbox + if sandbox is None and request.sandbox.provider == "matrixlab": + sandbox = MatrixLabClient() + + if sandbox is None: + if request.sandbox.required: + response.status = "blocked" + response.warnings.append( + "sandbox.required=true but no sandbox provider configured" + ) + return + + healthy = False + try: + healthy = sandbox.health() + except Exception: + healthy = False + + sand_pol = check_sandbox_policy(request, healthy) + if not sand_pol.allowed: + response.status = "blocked" + response.warnings.extend(sand_pol.violations) + response.sandbox_result = SandboxResult.skipped_result( + "sandbox required but unavailable" + ).to_dict() + return + + if not healthy: + # Not required -> note skipped (dry-run friendly). + response.sandbox_result = SandboxResult.skipped_result( + "sandbox unreachable (not required)" + ).to_dict() + response.messages.append("Sandbox skipped: unreachable and not required.") + return + + try: + result = sandbox.validate_patch( + client_id=request.client_id, + workspace_id=request.workspace_id, + repo_url=request.repo_url, + branch=ctx.branch, + profile=request.sandbox.profile, + patch=patch, + ) + except Exception as exc: + result = SandboxResult(status="error", reason=str(exc)) + response.sandbox_result = result.to_dict() + + if not result.skipped and result.status == "failed": + response.status = "blocked" + response.warnings.append("sandbox validation failed") + + def _summarize_issues(self, request: RepairRequest) -> str: + if not request.issues: + return "(no specific issues provided)" + return "\n".join( + f"- [{i.severity.value}] {i.id}: {i.description} " + f"(action: {i.recommended_action})" + for i in request.issues + ) + + def _cleanup(self, ctx: RepairContext) -> None: + if ctx.workspace and ctx.cloned: + try: + import shutil + + shutil.rmtree(ctx.workspace, ignore_errors=True) + except Exception: + pass + + +# --------------------------------------------------------------------------- # +# Diff utilities +# --------------------------------------------------------------------------- # +def _ensure_diff(text: str) -> str: + """Strip code fences and ensure the text looks like a unified diff.""" + t = text.strip() + if t.startswith("```"): + # remove leading/trailing code fences + lines = t.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].startswith("```"): + lines = lines[:-1] + t = "\n".join(lines).strip() + return t + + +def _changed_files_from_diff(diff: str) -> list[tuple[str, str]]: + """Extract (path, change_type) tuples from a unified diff.""" + out: list[tuple[str, str]] = [] + minus_paths = {m.group("path") for m in _DIFF_MINUS.finditer(diff)} + seen: set[str] = set() + for m in _DIFF_PLUS.finditer(diff): + path = m.group("path").strip() + if path == "/dev/null": + continue + if path in seen: + continue + seen.add(path) + # determine change type + if "/dev/null" in diff[max(0, m.start() - 80): m.start()]: + change = "added" + elif path in minus_paths: + change = "modified" + else: + change = "modified" + out.append((path, change)) + # deletions: `+++ /dev/null` with a `--- a/` + for m in _DIFF_MINUS.finditer(diff): + path = m.group("path").strip() + seg = diff[m.start(): m.start() + 200] + if "+++ /dev/null" in seg and path not in seen: + out.append((path, "deleted")) + seen.add(path) + return out + + +def _assess_risk(review: str, changed: list[tuple[str, str]]) -> RiskLevel: + rl = review.lower() + if "risk: high" in rl or "high risk" in rl: + return RiskLevel.high + if "risk: medium" in rl or "medium risk" in rl: + return RiskLevel.medium + # Many changed files -> medium. + if len(changed) > 5: + return RiskLevel.medium + return RiskLevel.low + + +def run_repair( + request: RepairRequest, demo_mode: bool | None = None, **kwargs: Any +) -> RepairResponse: + """Convenience function: build a service and run the request.""" + return RepairService(demo_mode=demo_mode, **kwargs).run(request) diff --git a/gitpilot/repair_router.py b/gitpilot/repair_router.py new file mode 100644 index 0000000..e7c7e08 --- /dev/null +++ b/gitpilot/repair_router.py @@ -0,0 +1,123 @@ +"""GitPilot coder API router — mountable into the main app, bearer-token gated. + +Exposes the GitPilot repair pipeline as an ``APIRouter`` so it can be included +into the main GitPilot FastAPI app (served on the Hugging Face Space) and reused +by the standalone ``repair_server``. + +Security model +-------------- +* **Bearer token:** when ``GITPILOT_API_TOKEN`` is set, ``POST /repair`` requires + ``Authorization: Bearer `` (401 otherwise). When it is *not* set the + coder API is open — so always set the token on any public deployment (we do on + the HF Space). Comparison is constant-time. +* **Encrypted in transit:** the HF Space terminates TLS, so the bearer token and + all payloads travel over HTTPS. +* **Dry-run safe:** missing ``mode`` -> ``dry_run``; ``draft_pr`` is downgraded + to ``dry_run`` unless ``GITPILOT_DRAFT_PR_ENABLED=true``. Never opens a real PR. +* **Coder demo:** ``GITPILOT_CODER_DEMO`` (default **true**) makes the coder API + return deterministic offline previews independent of the main app. Set it to + ``false`` (with ``OPENAI_BASE_URL``/``OPENAI_API_KEY`` for OllaBridge) for real + model-generated patches. ``HF_TOKEN`` is never read here. +""" +from __future__ import annotations + +import hmac +import os +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from gitpilot.repair.schema import RepairMode, RepairRequest +from gitpilot.repair.service import run_repair + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _api_token() -> str: + return (os.getenv("GITPILOT_API_TOKEN") or "").strip() + + +def _coder_demo() -> bool: + v = os.getenv("GITPILOT_CODER_DEMO") + return _truthy(v) if v is not None else True # default: deterministic preview + + +def _draft_pr_enabled() -> bool: + return _truthy(os.getenv("GITPILOT_DRAFT_PR_ENABLED")) + + +def _version() -> str: + try: + from gitpilot.version import __version__ as v + + return v + except Exception: # pragma: no cover + return "0.1.0" + + +def _auth_error(request: Request) -> JSONResponse | None: + """Return a 401 JSONResponse if the bearer token is required and bad/missing. + + When no GITPILOT_API_TOKEN is configured the API is open (dev/local). Set the + token on public deployments to enforce authentication. + """ + token = _api_token() + if not token: + return None + header = request.headers.get("authorization", "") + if not header.lower().startswith("bearer "): + return JSONResponse(status_code=401, content={"detail": "missing bearer token"}) + presented = header.split(" ", 1)[1].strip() + if not hmac.compare_digest(presented, token): + return JSONResponse(status_code=401, content={"detail": "invalid token"}) + return None + + +def build_repair_router() -> APIRouter: + """Build the coder API router (``GET /repair/health`` + ``POST /repair``).""" + router = APIRouter(tags=["coder"]) + + @router.get("/repair/health") + def repair_health() -> dict[str, Any]: + return { + "status": "ok", + "service": "gitpilot-coder", + "version": _version(), + "auth_required": bool(_api_token()), + "coder_demo": _coder_demo(), + } + + @router.post("/repair") + async def repair(request: Request) -> JSONResponse: + denied = _auth_error(request) + if denied is not None: + return denied + try: + body = await request.json() + except Exception: + return JSONResponse(status_code=422, content={"detail": "body must be valid JSON"}) + if not isinstance(body, dict): + return JSONResponse( + status_code=422, content={"detail": "body must be a JSON object (repair plan)"} + ) + plan = dict(body) + if not plan.get("mode"): + plan["mode"] = RepairMode.dry_run.value + if str(plan["mode"]) == RepairMode.draft_pr.value and not _draft_pr_enabled(): + plan["mode"] = RepairMode.dry_run.value + try: + req = RepairRequest.from_json(plan) + except Exception as exc: + return JSONResponse(status_code=422, content={"detail": f"invalid repair plan: {exc}"}) + try: + resp = run_repair(req, demo_mode=_coder_demo()) + except Exception: # pragma: no cover - defensive; no secrets leaked + return JSONResponse( + status_code=500, content={"detail": "internal error running repair pipeline"} + ) + return JSONResponse(status_code=200, content=resp.to_dict()) + + return router diff --git a/gitpilot/sandbox_providers/__init__.py b/gitpilot/sandbox_providers/__init__.py new file mode 100644 index 0000000..b2bf3a4 --- /dev/null +++ b/gitpilot/sandbox_providers/__init__.py @@ -0,0 +1,15 @@ +"""Pluggable sandbox providers for the GitPilot repair flow. + +.. note:: + + The top-level :mod:`gitpilot.sandbox` *module* already exists for an + unrelated local-sandbox feature, so this provider abstraction lives in + ``gitpilot.sandbox_providers`` to avoid a package/module name clash while + still matching the documented ``sandbox/base.py`` + + ``sandbox/matrixlab_client.py`` contract. +""" + +from .base import SandboxProvider, SandboxResult +from .matrixlab_client import MatrixLabClient + +__all__ = ["SandboxProvider", "SandboxResult", "MatrixLabClient"] diff --git a/gitpilot/sandbox_providers/base.py b/gitpilot/sandbox_providers/base.py new file mode 100644 index 0000000..6c663cf --- /dev/null +++ b/gitpilot/sandbox_providers/base.py @@ -0,0 +1,81 @@ +"""SandboxProvider abstract base class. + +A sandbox provider validates a patch / runs commands in an isolated +workspace. Concrete providers (e.g. MatrixLab) implement this ABC. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class SandboxResult: + """Normalized result of a sandbox run / patch validation.""" + + run_id: str = "" + status: str = "error" # "passed" | "failed" | "error" + exit_code: int | None = None + stdout: str = "" + stderr: str = "" + duration_ms: int | None = None + artifacts: list[dict[str, str]] = field(default_factory=list) + skipped: bool = False + reason: str = "" + + @property + def passed(self) -> bool: + return self.status == "passed" + + def to_dict(self) -> dict[str, Any]: + return { + "run_id": self.run_id, + "status": self.status, + "exit_code": self.exit_code, + "stdout": self.stdout, + "stderr": self.stderr, + "duration_ms": self.duration_ms, + "artifacts": list(self.artifacts), + "skipped": self.skipped, + "reason": self.reason, + } + + @classmethod + def from_response(cls, data: dict[str, Any]) -> SandboxResult: + """Build from a MatrixLab-shaped JSON response.""" + return cls( + run_id=str(data.get("run_id", "")), + status=str(data.get("status", "error")), + exit_code=data.get("exit_code"), + stdout=str(data.get("stdout", "")), + stderr=str(data.get("stderr", "")), + duration_ms=data.get("duration_ms"), + artifacts=list(data.get("artifacts", []) or []), + ) + + @classmethod + def skipped_result(cls, reason: str) -> SandboxResult: + return cls(status="error", skipped=True, reason=reason) + + +class SandboxProvider(ABC): + """Abstract sandbox provider.""" + + name: str = "sandbox" + + @abstractmethod + def health(self) -> bool: + """Return True iff the sandbox is reachable/healthy.""" + raise NotImplementedError + + @abstractmethod + def run(self, **payload: Any) -> SandboxResult: + """Run arbitrary commands in the sandbox.""" + raise NotImplementedError + + @abstractmethod + def validate_patch(self, **payload: Any) -> SandboxResult: + """Validate a patch (apply + run profile) in the sandbox.""" + raise NotImplementedError diff --git a/gitpilot/sandbox_providers/matrixlab_client.py b/gitpilot/sandbox_providers/matrixlab_client.py new file mode 100644 index 0000000..45c7dd8 --- /dev/null +++ b/gitpilot/sandbox_providers/matrixlab_client.py @@ -0,0 +1,124 @@ +"""MatrixLab sandbox HTTP client. + +Implements :class:`~gitpilot.sandbox_providers.base.SandboxProvider` against +the MatrixLab contract: + +* ``GET {MATRIXLAB_URL}/health`` +* ``POST {MATRIXLAB_URL}/repo/run`` +* ``POST {MATRIXLAB_URL}/repo/validate-patch`` + +Body:: + + {client_id, workspace_id, repo_url, branch, profile, + commands?, timeout_seconds?, artifacts?} + +Response:: + + {run_id, status, exit_code, stdout, stderr, duration_ms, + artifacts:[{name,url}]} + +Reads ``MATRIXLAB_URL`` (default ``http://localhost:8765``) and +``MATRIXLAB_TOKEN``. Degrades gracefully: :meth:`health` returns ``False`` +when unreachable instead of raising. +""" + +from __future__ import annotations + +import os +from typing import Any + +from .base import SandboxProvider, SandboxResult + +DEFAULT_MATRIXLAB_URL = "http://localhost:8765" + + +class MatrixLabClient(SandboxProvider): + name = "matrixlab" + + def __init__( + self, + base_url: str | None = None, + token: str | None = None, + timeout: float = 120.0, + ) -> None: + self.base_url = ( + base_url or os.getenv("MATRIXLAB_URL") or DEFAULT_MATRIXLAB_URL + ).rstrip("/") + self.token = token or os.getenv("MATRIXLAB_TOKEN") or "" + self.timeout = timeout + + # ------------------------------------------------------------------ # + # Internals + # ------------------------------------------------------------------ # + def _headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def _httpx(self): # pragma: no cover - trivial lazy import + import httpx + + return httpx + + # ------------------------------------------------------------------ # + # SandboxProvider API + # ------------------------------------------------------------------ # + def health(self) -> bool: + try: + httpx = self._httpx() + except Exception: + return False + try: + resp = httpx.get( + f"{self.base_url}/health", headers=self._headers(), timeout=5.0 + ) + return resp.status_code == 200 + except Exception: + return False + + def _post(self, path: str, payload: dict[str, Any]) -> SandboxResult: + try: + httpx = self._httpx() + except Exception: + return SandboxResult.skipped_result("httpx unavailable") + try: + resp = httpx.post( + f"{self.base_url}{path}", + headers=self._headers(), + json=payload, + timeout=self.timeout, + ) + resp.raise_for_status() + data = resp.json() + except Exception as exc: # network / parse error + return SandboxResult( + status="error", reason=f"matrixlab request failed: {exc}" + ) + if not isinstance(data, dict): + return SandboxResult(status="error", reason="malformed matrixlab response") + return SandboxResult.from_response(data) + + def run(self, **payload: Any) -> SandboxResult: + return self._post("/repo/run", self._build_payload(payload)) + + def validate_patch(self, **payload: Any) -> SandboxResult: + return self._post("/repo/validate-patch", self._build_payload(payload)) + + # ------------------------------------------------------------------ # + # Payload helper + # ------------------------------------------------------------------ # + @staticmethod + def _build_payload(payload: dict[str, Any]) -> dict[str, Any]: + allowed = ( + "client_id", + "workspace_id", + "repo_url", + "branch", + "profile", + "commands", + "timeout_seconds", + "artifacts", + "patch", + ) + return {k: v for k, v in payload.items() if k in allowed and v is not None} diff --git a/tests/test_matrixlab_client.py b/tests/test_matrixlab_client.py new file mode 100644 index 0000000..49fc62d --- /dev/null +++ b/tests/test_matrixlab_client.py @@ -0,0 +1,59 @@ +"""MatrixLab sandbox client tests (offline).""" + +from __future__ import annotations + +from gitpilot.sandbox_providers.base import SandboxResult +from gitpilot.sandbox_providers.matrixlab_client import MatrixLabClient + + +def test_health_false_when_unreachable(): + # Point at a closed port so the request fails fast. + client = MatrixLabClient(base_url="http://127.0.0.1:9", token="t") + assert client.health() is False + + +def test_default_url(): + client = MatrixLabClient(base_url=None, token=None) + assert client.base_url == "http://localhost:8765" + + +def test_parse_run_response_shape(): + raw = { + "run_id": "r-1", + "status": "passed", + "exit_code": 0, + "stdout": "ok", + "stderr": "", + "duration_ms": 42, + "artifacts": [{"name": "coverage.xml", "url": "https://x/c.xml"}], + } + result = SandboxResult.from_response(raw) + assert result.run_id == "r-1" + assert result.passed is True + assert result.exit_code == 0 + assert result.duration_ms == 42 + assert result.artifacts[0]["name"] == "coverage.xml" + + +def test_validate_patch_error_when_unreachable(): + client = MatrixLabClient(base_url="http://127.0.0.1:9", token="t") + result = client.validate_patch( + client_id="c", + workspace_id="w", + repo_url="https://github.com/acme/app", + branch="gitpilot/x", + profile="default", + patch="--- a\n+++ b\n", + ) + assert result.status == "error" + assert result.passed is False + + +def test_payload_filtering(): + payload = MatrixLabClient._build_payload( + {"client_id": "c", "bogus": "x", "branch": "b", "patch": "p", "empty": None} + ) + assert "client_id" in payload + assert "bogus" not in payload + assert "empty" not in payload + assert payload["branch"] == "b" diff --git a/tests/test_ollabridge_client.py b/tests/test_ollabridge_client.py new file mode 100644 index 0000000..9a69fba --- /dev/null +++ b/tests/test_ollabridge_client.py @@ -0,0 +1,51 @@ +"""OllaBridge / OpenAI-compatible client tests (offline, demo mode).""" + +from __future__ import annotations + +from gitpilot.inference.ollabridge_client import OllaBridgeClient +from gitpilot.inference.openai_compatible_client import ( + OpenAICompatibleClient, + messages_from, +) + + +def test_stub_when_demo_mode(): + client = OpenAICompatibleClient(demo_mode=True) + out = client.chat("code-fast", messages_from("sys", "hello")) + assert isinstance(out, str) + assert out # non-empty deterministic stub + + +def test_stub_when_no_endpoint(): + # No base_url configured and not demo => still degrades to stub. + client = OpenAICompatibleClient(base_url="", api_key="", demo_mode=False) + assert client.is_configured() is False + out = client.chat("code-fast", messages_from(None, "hi")) + assert isinstance(out, str) and out + + +def test_coder_returns_diff_stub(): + ob = OllaBridgeClient(demo_mode=True) + diff = ob.code("fix it") + assert "+++ b/tests/test_health.py" in diff + assert diff.lstrip().startswith("---") + + +def test_reviewer_stub_mentions_risk(): + ob = OllaBridgeClient(demo_mode=True) + review = ob.review("review this diff") + assert "risk" in review.lower() + + +def test_fast_stub_is_text(): + ob = OllaBridgeClient(demo_mode=True) + assert ob.is_demo() is True + summary = ob.fast("inspect the repo") + assert isinstance(summary, str) and summary + + +def test_full_response_shape(): + client = OpenAICompatibleClient(demo_mode=True) + resp = client.chat_completion("code-coder", messages_from(None, "x")) + assert resp["choices"][0]["message"]["role"] == "assistant" + assert "usage" in resp diff --git a/tests/test_repair_dry_run.py b/tests/test_repair_dry_run.py new file mode 100644 index 0000000..60d77e2 --- /dev/null +++ b/tests/test_repair_dry_run.py @@ -0,0 +1,81 @@ +"""End-to-end dry-run test (offline demo mode, no network, no PR).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from gitpilot.repair.cli import run_cli +from gitpilot.repair.schema import RepairMode, RepairRequest +from gitpilot.repair.service import run_repair + +PLAN = { + "client_id": "agent-matrix", + "workspace_id": "ws-1", + "task_id": "fix-health", + "repo_url": "https://github.com/acme/app", + "branch": "main", + "mode": "dry_run", + "issues": [ + { + "id": "missing-health-test", + "severity": "medium", + "description": "No smoke test for health", + "recommended_action": "Add tests/test_health.py", + } + ], + "allowed_paths": ["tests/**"], + "sandbox": {"provider": "matrixlab", "profile": "default", "required": False}, +} + + +def test_dry_run_produces_preview_no_pr(): + req = RepairRequest.from_json(PLAN) + resp = run_repair(req, demo_mode=True) + + assert resp.status == "ok" + assert resp.mode == RepairMode.dry_run + assert resp.patch_preview # non-empty unified diff + assert "tests/test_health.py" in resp.patch_preview + assert any(cf.path == "tests/test_health.py" for cf in resp.changed_files) + assert resp.review + assert resp.risk_level.value == "low" + # No PR opened in dry-run/first wave. + assert resp.pr_url is None + # Sandbox not required + unreachable -> skipped, not blocking. + assert resp.status != "blocked" + + +def test_dry_run_blocks_outside_allowed(): + plan = dict(PLAN) + plan["allowed_paths"] = ["src/**"] # stub diff writes tests/ -> outside + req = RepairRequest.from_json(plan) + resp = run_repair(req, demo_mode=True) + assert resp.status == "blocked" + assert any("outside allowed_paths" in w for w in resp.warnings) + + +def test_dry_run_empty_allowed_blocks(): + plan = dict(PLAN) + plan["allowed_paths"] = [] + req = RepairRequest.from_json(plan) + resp = run_repair(req, demo_mode=True) + assert resp.status == "blocked" + + +def test_cli_writes_response_json(tmp_path: Path): + plan_path = tmp_path / "repair-plan.json" + plan_path.write_text(json.dumps(PLAN), encoding="utf-8") + out_path = tmp_path / "repair-response.json" + + code = run_cli( + ["--plan", str(plan_path), "--out", str(out_path), "--dry-run", "--demo"] + ) + assert code == 0 + assert out_path.exists() + + data = json.loads(out_path.read_text(encoding="utf-8")) + assert data["task_id"] == "fix-health" + assert data["mode"] == "dry_run" + assert data["patch_preview"] + assert data["pr_url"] is None diff --git a/tests/test_repair_policy.py b/tests/test_repair_policy.py new file mode 100644 index 0000000..8f95c3b --- /dev/null +++ b/tests/test_repair_policy.py @@ -0,0 +1,102 @@ +"""Fail-closed policy tests for the GitPilot repair flow.""" + +from __future__ import annotations + +from gitpilot.repair.policy import ( + check_risk_policy, + check_sandbox_policy, + is_path_allowed, + validate_changed_files, +) +from gitpilot.repair.schema import RepairRequest + + +def _req(allowed, forbidden=None, sandbox_required=False): + data = { + "client_id": "c", + "workspace_id": "w", + "task_id": "t", + "repo_url": "https://github.com/acme/app", + "allowed_paths": allowed, + "sandbox": {"provider": "matrixlab", "required": sandbox_required}, + } + if forbidden is not None: + data["forbidden_paths"] = forbidden + return RepairRequest.from_json(data) + + +def test_empty_allowed_paths_blocks(): + req = _req([]) + res = validate_changed_files(["tests/test_health.py"], req) + assert res.allowed is False + assert any("allowed_paths is empty" in v for v in res.violations) + + +def test_file_outside_allowed_blocks(): + req = _req(["tests/**"]) + res = validate_changed_files(["src/app.py"], req) + assert res.allowed is False + assert any("outside allowed_paths" in v for v in res.violations) + + +def test_forbidden_path_blocks(): + req = _req(["**"], forbidden=["secrets/**"]) + res = validate_changed_files(["secrets/key.pem"], req) + assert res.allowed is False + + +def test_env_touch_blocks(): + req = _req(["**"]) + res = validate_changed_files([".env"], req) + assert res.allowed is False + assert any("secret" in v or ".env" in v for v in res.violations) + + +def test_secret_touch_blocks(): + req = _req(["**"]) + res = validate_changed_files(["config/db_secret.yaml"], req) + assert res.allowed is False + + +def test_token_touch_blocks(): + req = _req(["**"]) + res = validate_changed_files(["app/auth_token.py"], req) + assert res.allowed is False + + +def test_allowed_file_passes(): + req = _req(["tests/**"]) + res = validate_changed_files(["tests/test_health.py"], req) + assert res.allowed is True + assert res.violations == [] + + +def test_is_path_allowed_fail_closed_empty(): + assert is_path_allowed("tests/test_health.py", [], []) is False + + +def test_is_path_allowed_basic(): + assert is_path_allowed("tests/test_health.py", ["tests/**"], []) is True + assert is_path_allowed(".env", ["**"], []) is False + + +def test_sandbox_required_unavailable_blocks(): + req = _req(["tests/**"], sandbox_required=True) + res = check_sandbox_policy(req, sandbox_healthy=False) + assert res.allowed is False + + +def test_sandbox_required_available_ok(): + req = _req(["tests/**"], sandbox_required=True) + res = check_sandbox_policy(req, sandbox_healthy=True) + assert res.allowed is True + + +def test_high_risk_without_approval_blocks(): + res = check_risk_policy("high", human_approval=False) + assert res.allowed is False + + +def test_high_risk_with_approval_ok(): + res = check_risk_policy("high", human_approval=True) + assert res.allowed is True diff --git a/tests/test_repair_schema.py b/tests/test_repair_schema.py new file mode 100644 index 0000000..670c520 --- /dev/null +++ b/tests/test_repair_schema.py @@ -0,0 +1,60 @@ +"""Schema tests for the GitPilot repair contract.""" + +from __future__ import annotations + +from gitpilot.repair.schema import ( + DEFAULT_FORBIDDEN_PATHS, + RepairMode, + RepairRequest, + RepairResponse, +) + +EXAMPLE = { + "client_id": "agent-matrix", + "workspace_id": "ws-1", + "task_id": "fix-health", + "repo_url": "https://github.com/acme/app", + "branch": "main", + "mode": "dry_run", + "issues": [ + { + "id": "missing-health-test", + "severity": "medium", + "description": "No smoke test", + "recommended_action": "Add tests/test_health.py", + } + ], + "allowed_paths": ["tests/**"], + "coder": {"provider": "ollabridge", "model": "code-coder"}, + "sandbox": {"provider": "matrixlab", "profile": "default", "required": False}, +} + + +def test_parses_example_request(): + req = RepairRequest.from_json(EXAMPLE) + assert req.client_id == "agent-matrix" + assert req.task_id == "fix-health" + assert req.mode == RepairMode.dry_run + assert req.allowed_paths == ["tests/**"] + assert req.issues[0].id == "missing-health-test" + assert req.coder.model == "code-coder" + assert req.sandbox.required is False + + +def test_default_forbidden_paths(): + req = RepairRequest.from_json(EXAMPLE) + # forbidden_paths not supplied -> defaults applied + assert req.forbidden_paths == DEFAULT_FORBIDDEN_PATHS + assert ".env" in req.forbidden_paths + assert "secrets/**" in req.forbidden_paths + assert "**/*token*" in req.forbidden_paths + assert "**/*secret*" in req.forbidden_paths + + +def test_response_serializes(): + resp = RepairResponse(task_id="t1") + data = resp.to_json() + assert data["task_id"] == "t1" + assert data["status"] == "ok" + assert data["risk_level"] == "low" + assert "patch_preview" in data diff --git a/tests/test_repair_server.py b/tests/test_repair_server.py new file mode 100644 index 0000000..5a8209b --- /dev/null +++ b/tests/test_repair_server.py @@ -0,0 +1,116 @@ +"""Tests for the additive GitPilot HTTP repair API. + +Runs fully offline in demo mode (``GITPILOT_DEMO_MODE=true``): no network, +no real PR, deterministic stub patch. +""" + +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + +# Force offline/deterministic demo mode for the whole module BEFORE importing +# the app so the server reports demo_mode=true and emits a stub patch. +os.environ["GITPILOT_DEMO_MODE"] = "true" +os.environ.pop("GITPILOT_DRAFT_PR_ENABLED", None) + +from gitpilot.api.repair_server import app # noqa: E402 + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app) + + +def test_health(client: TestClient) -> None: + resp = client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["service"] == "gitpilot" + assert isinstance(body["version"], str) and body["version"] + assert body["demo_mode"] is True + + +def test_repair_minimal_plan(client: TestClient) -> None: + plan = { + "client_id": "agent-matrix", + "workspace_id": "ws-1", + "task_id": "fix-health", + "repo_url": "https://github.com/acme/app", + "issues": [ + { + "id": "missing-health-test", + "severity": "medium", + "description": "No smoke test for health", + "recommended_action": "Add tests/test_health.py", + } + ], + "allowed_paths": ["tests/test_health.py"], + "sandbox": {"required": False}, + # extra/unknown key must be tolerated gracefully + "x_unknown_key": {"nested": True}, + } + resp = client.post("/repair", json=plan) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] in {"ok", "blocked", "needs_approval"} + assert body["patch_preview"], "patch_preview should be non-empty" + assert body["changed_files"], "changed_files should be present" + # mode defaulted to dry-run, never a real PR. + assert body["mode"] == "dry_run" + assert body.get("pr_url") in (None, "") + + +def test_repair_empty_allowed_paths_blocks(client: TestClient) -> None: + plan = { + "client_id": "agent-matrix", + "workspace_id": "ws-1", + "task_id": "fix-health", + "repo_url": "https://github.com/acme/app", + "issues": [ + { + "id": "missing-health-test", + "severity": "medium", + "description": "No smoke test", + "recommended_action": "Add tests/test_health.py", + } + ], + "allowed_paths": [], + "sandbox": {"required": False}, + } + resp = client.post("/repair", json=plan) + # fail-closed: 200 with status "blocked", NOT a 500. + assert resp.status_code == 200 + assert resp.json()["status"] == "blocked" + + +def test_repair_draft_pr_downgraded_to_dry_run(client: TestClient) -> None: + plan = { + "client_id": "agent-matrix", + "workspace_id": "ws-1", + "task_id": "fix-health", + "repo_url": "https://github.com/acme/app", + "mode": "draft_pr", + "issues": [ + {"id": "missing-health-test", "severity": "medium", + "description": "x", "recommended_action": "add test"} + ], + "allowed_paths": ["tests/test_health.py"], + "sandbox": {"required": False}, + } + resp = client.post("/repair", json=plan) + assert resp.status_code == 200 + body = resp.json() + # draft_pr downgraded to dry_run (GITPILOT_DRAFT_PR_ENABLED not set). + assert body["mode"] == "dry_run" + assert body.get("pr_url") in (None, "") + + +def test_repair_invalid_plan_returns_422(client: TestClient) -> None: + # Missing required fields (client_id, workspace_id, task_id, repo_url). + resp = client.post("/repair", json={"issues": []}) + assert resp.status_code == 422 + assert "detail" in resp.json()