From 5d001507668436cd239d9cde667f7a4a374746a9 Mon Sep 17 00:00:00 2001 From: Ruslan Magana Vsevolodovna Date: Mon, 15 Jun 2026 22:16:39 +0200 Subject: [PATCH] Matrix Builder Integration --- README.md | 2 + deploy/huggingface/Dockerfile | 6 +- docs/commit-attribution.md | 45 +++ docs/deploy/gitpilot-ruslanmv-com.md | 85 +++++ gitpilot/_api_app.py | 19 +- gitpilot/commit_attribution.py | 65 ++++ gitpilot/matrix_runs_router.py | 493 +++++++++++++++++++++++++++ tests/test_commit_attribution.py | 48 +++ tests/test_matrix_runs_router.py | 251 ++++++++++++++ 9 files changed, 1012 insertions(+), 2 deletions(-) create mode 100644 docs/commit-attribution.md create mode 100644 docs/deploy/gitpilot-ruslanmv-com.md create mode 100644 gitpilot/commit_attribution.py create mode 100644 gitpilot/matrix_runs_router.py create mode 100644 tests/test_commit_attribution.py create mode 100644 tests/test_matrix_runs_router.py diff --git a/README.md b/README.md index 62c078c..35fe784 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Multiple specialized agents — including Explorer, Planner, Coder, and Reviewer Most AI coding tools are a **single model behind a chat box**. GitPilot is fundamentally different: it deploys a **team of four specialized AI agents** that collaborate on every task — just like a real engineering team. +> **Matrix‑native:** GitPilot is the worker for [Matrix Builder](https://github.com/agent-matrix/matrix-builder) — it runs a signed Matrix Bundle under contract via `POST /api/v1/gitpilot/runs` (A2A‑secured), returns a controlled diff, and never approves or commits its own work. + | Agent | Role | What it does | |---|---|---| | **Explorer** | Context | Reads your full repo, git log, test suite, and dependencies so the plan starts with real knowledge — not guesses | diff --git a/deploy/huggingface/Dockerfile b/deploy/huggingface/Dockerfile index 9c83142..a6b2d1a 100644 --- a/deploy/huggingface/Dockerfile +++ b/deploy/huggingface/Dockerfile @@ -84,9 +84,13 @@ EXPOSE 7860 # Docker HEALTHCHECK directive. # Direct CMD — no shell script, fewer failure points. +# Single worker: the Matrix run registry (gitpilot.matrix_runs_router._RUNS) is +# in-memory per worker, so multiple workers would scatter a run's status/diff/ +# logs across processes and 404 intermittently. One worker keeps run state +# consistent; back the registry with a shared store to scale workers later. CMD ["python", "-m", "uvicorn", "gitpilot.api:app", \ "--host", "0.0.0.0", \ "--port", "7860", \ - "--workers", "2", \ + "--workers", "1", \ "--limit-concurrency", "10", \ "--timeout-keep-alive", "120"] diff --git a/docs/commit-attribution.md b/docs/commit-attribution.md new file mode 100644 index 0000000..ce6aa89 --- /dev/null +++ b/docs/commit-attribution.md @@ -0,0 +1,45 @@ +# Make GitPilot's commits appear as "GitPilot" (with an icon) + +GitHub picks the avatar on a commit / PR / contributors graph from the commit +**author/committer** or a **`Co-authored-by:`** trailer whose email maps to a +GitHub account. GitPilot uses both: + +## 1. GitHub App — the icon (recommended) + +GitPilot ships a GitHub App (`GITHUB_APP_SLUG=gitpilota`, `GITHUB_APP_ID=2313985`). +When commits/PRs are created through the **App installation token**, GitHub +attributes them to **`gitpilota[bot]`** and shows the App's avatar automatically. + +To get the icon: **GitHub → Settings → Developer settings → GitHub Apps → +GitPilot → Display information → upload a logo.** That logo is the icon you'll see +on every `gitpilota[bot]` commit and PR — the same way Claude Code shows its mark. + +Install the App on the target repos and have GitPilot use the installation token +for writes (the API helpers in `github_app.py` / `github_pulls.py` already accept +a token). + +## 2. Co-authored-by trailer — credit on human-authored commits + +When a human's token authors the commit, GitPilot appends a trailer so it's still +credited as a contributor (this is what Claude Code does): + +```text + + +🤖 Generated with GitPilot + +Co-authored-by: GitPilot +``` + +This is applied automatically by `gitpilot.commit_attribution.with_attribution()` +on the file-commit path. Configure: + +| Env | Default | Purpose | +|---|---|---| +| `GITPILOT_COMMIT_ATTRIBUTION` | `true` | Toggle the trailer on/off | +| `GITPILOT_BOT_NAME` | `GitPilot` | Display name in the trailer | +| `GITPILOT_BOT_EMAIL` | `gitpilota[bot]@users.noreply.github.com` | Set to a real GitPilot bot account's email so its avatar resolves | + +> The avatar next to a co-author only renders if the email maps to a GitHub +> account. Use the App bot's no‑reply email (default) or a dedicated `gitpilot` +> bot account. diff --git a/docs/deploy/gitpilot-ruslanmv-com.md b/docs/deploy/gitpilot-ruslanmv-com.md new file mode 100644 index 0000000..4c85e76 --- /dev/null +++ b/docs/deploy/gitpilot-ruslanmv-com.md @@ -0,0 +1,85 @@ +# Production deploy — `gitpilot.ruslanmv.com` + +This is the runbook for GitPilot's public production deployment and the target +for the Matrix Builder → GitPilot cloud handoff. + +## Architecture + +```text +gitpilot.ruslanmv.com (Vercel, Vite/React UI) + │ cross-origin calls via VITE_BACKEND_URL + ▼ +ruslanmv-gitpilot.hf.space (Hugging Face Docker Space, FastAPI backend) + │ GITPILOT_PROVIDER=ollabridge + ▼ +ruslanmv-ollabridge.hf.space (OllaBridge, OpenAI-compatible /v1 gateway) +``` + +- **Frontend (Vercel):** the `frontend/` Vite app, built per `vercel.json`. + The DNS is already pointed: `gitpilot.ruslanmv.com` CNAME → + `b2aab4fcc3b40c0d.vercel-dns-017.com`, with the `_vercel` verification TXT + present. Set **`VITE_BACKEND_URL=https://ruslanmv-gitpilot.hf.space`** in the + Vercel project so the UI calls the HF backend. +- **Backend (HF Space):** the multi-stage Docker image in + `deploy/huggingface/Dockerfile`, listening on port 7860. It is force-deployed + by the `.github/workflows/sync-hf-space.yml` GitHub Action **on push to + `main`** (or manual `workflow_dispatch`). The Space needs the repo secrets + `HF_TOKEN`, `HF_USERNAME` (`ruslanmv`), `SPACE_NAME` (`gitpilot`). +- **Inference (OllaBridge):** the HF Space sets `GITPILOT_PROVIDER=ollabridge` + and `OLLABRIDGE_BASE_URL=https://ruslanmv-ollabridge.hf.space`. GitPilot routes + LLM calls through litellm with an `openai/` prefix pointed at + `${OLLABRIDGE_BASE_URL}/v1`. + +## Backend environment (HF Space) + +Set on the Space (already baked into `deploy/huggingface/Dockerfile`): + +| Variable | Value | +|---|---| +| `GITPILOT_PROVIDER` | `ollabridge` | +| `OLLABRIDGE_BASE_URL` | `https://ruslanmv-ollabridge.hf.space` | +| `GITPILOT_OLLABRIDGE_MODEL` | `qwen2.5:1.5b` | +| `CORS_ORIGINS` | `*` (allows `gitpilot.ruslanmv.com` → Space cross-origin) | +| `GITPILOT_CONFIG_DIR` | `/tmp/gitpilot` | + +For the Matrix cloud handoff (Batch 5+), also set on the Space: + +| Variable | Value | +|---|---| +| `GITPILOT_A2A_REQUIRE_AUTH` | `true` | +| `GITPILOT_A2A_SHARED_SECRET` | a long random secret (shared with Matrix Builder) | + +## Health & verification + +```bash +# Frontend (Vercel) +curl -I https://gitpilot.ruslanmv.com/ # 200 + +# Backend (HF Space) +curl https://ruslanmv-gitpilot.hf.space/api/health # {"status":"healthy",...} +curl https://ruslanmv-gitpilot.hf.space/api/health/deep # provider:"ollabridge", provider_reachable:true + +# Inference (OllaBridge) — the chat/plan path +curl https://ruslanmv-ollabridge.hf.space/v1/chat/completions \ + -H 'content-type: application/json' -H 'authorization: Bearer ollabridge' \ + -d '{"model":"qwen2.5:1.5b","messages":[{"role":"user","content":"ping"}],"max_tokens":8}' +``` + +Last verified (Batch 4): `/api/health` and `/api/health/deep` return **200** with +`provider:"ollabridge"`, `provider_reachable:true`, `crewai_loaded:true`; the +OllaBridge `/v1/chat/completions` path returns a completion; +`gitpilot.ruslanmv.com` serves the UI (200). + +## Deploying the Matrix facade to production + +The Matrix-native run facade (`/api/v1/gitpilot/runs`, `/api/matrix/runs` and +their `/health` siblings) ships with the backend source, so it reaches the Space +the moment the branch lands on `main`: + +1. Merge the feature branch into `main`. +2. The `sync-hf-space.yml` action force-pushes the deploy tree to the Space and + it rebuilds (~a few minutes). +3. Confirm: `curl https://ruslanmv-gitpilot.hf.space/api/matrix/health` → `200`. + +A manual `workflow_dispatch` of the same action deploys without a code change +(e.g. to re-sync the Space). diff --git a/gitpilot/_api_app.py b/gitpilot/_api_app.py index 9039250..b038298 100644 --- a/gitpilot/_api_app.py +++ b/gitpilot/_api_app.py @@ -14,6 +14,7 @@ # Re-exported here so endpoint authors can `@wrap_errors_envelope` without # reaching into the implementation module. Importing the symbol is a no-op # when the flag is off, so this is fully backwards compatible. +from .commit_attribution import with_attribution from .errors import GitPilotError, wrap_errors_envelope # noqa: F401 from .github_api import ( list_user_repos, @@ -346,6 +347,20 @@ def _env_bool(name: str, default: bool) -> bool: except Exception: # noqa: BLE001 logger.exception("Coder API failed to mount; /repair will be unavailable") +# Matrix runs facade (the Matrix-native AI coder path): POST /api/v1/gitpilot/runs. +# Maps a signed Matrix Bundle + contract onto the repair pipeline, always denying +# the Matrix control files, gated by the A2A shared secret. Non-fatal mount. +try: + from .matrix_runs_router import build_matrix_runs_alias_router, build_matrix_runs_router + + app.include_router(build_matrix_runs_router()) + # Local-bridge alias (/api/matrix/*) used by Matrix Builder's "Send to local + # GitPilot": same handlers, second namespace. + app.include_router(build_matrix_runs_alias_router()) + logger.info("Matrix runs API enabled (mounting /api/v1/gitpilot/* + /api/matrix/*)") +except Exception: # noqa: BLE001 + logger.exception("Matrix runs API failed to mount; /api/v1/gitpilot/runs 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: @@ -1087,8 +1102,10 @@ async def api_put_file( authorization: Optional[str] = Header(None), ): token = get_github_token(authorization) + # Attribute the commit to GitPilot (Co-authored-by trailer) so it shows up as + # a GitPilot contribution — like Claude Code. See gitpilot.commit_attribution. result = await put_file( - owner, repo, payload.path, payload.content, payload.message, token=token + owner, repo, payload.path, payload.content, with_attribution(payload.message), token=token ) return CommitResponse(**result) diff --git a/gitpilot/commit_attribution.py b/gitpilot/commit_attribution.py new file mode 100644 index 0000000..995e32d --- /dev/null +++ b/gitpilot/commit_attribution.py @@ -0,0 +1,65 @@ +"""GitPilot commit attribution — make GitPilot's commits show up as "GitPilot". + +GitHub decides the avatar in the contributors graph and on commits/PRs from the +commit author/committer, or from a ``Co-authored-by:`` trailer whose email maps +to a GitHub account. So GitPilot's work can appear as "GitPilot" with an icon in +two complementary ways: + +1. **GitHub App (the icon).** When commits/PRs are created through GitPilot's + GitHub App installation token, GitHub attributes them to ``gitpilota[bot]`` + and shows the App's avatar automatically — set the picture in the App's + settings (Developer settings → GitHub Apps → GitPilot → Display information). + This is the cleanest "appears as GitPilot with an icon" path; no commit-message + change is needed because the App *is* the author. + +2. **Co-authored-by trailer (this module).** When a human's token authors the + commit, append a ``Co-authored-by: GitPilot <…>`` trailer so GitPilot is still + credited as a contributor with its avatar — exactly how Claude Code attributes + its commits. + +Configure with: ``GITPILOT_COMMIT_ATTRIBUTION`` (on by default), ``GITPILOT_BOT_NAME``, +``GITPILOT_BOT_EMAIL`` (default derives from ``GITHUB_APP_SLUG`` so the avatar +resolves to the App bot). +""" + +from __future__ import annotations + +import os + +SIGNATURE = "\U0001f916 Generated with GitPilot" + + +def attribution_enabled() -> bool: + return os.getenv("GITPILOT_COMMIT_ATTRIBUTION", "true").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def bot_name() -> str: + return os.getenv("GITPILOT_BOT_NAME", "GitPilot") + + +def bot_email() -> str: + # Default to the App bot's noreply so the avatar resolves to [bot]. + # Override with GITPILOT_BOT_EMAIL to point at a real GitPilot bot account. + slug = os.getenv("GITHUB_APP_SLUG", "gitpilota") + return os.getenv("GITPILOT_BOT_EMAIL", f"{slug}[bot]@users.noreply.github.com") + + +def co_authored_by() -> str: + return f"Co-authored-by: {bot_name()} <{bot_email()}>" + + +def with_attribution(message: str) -> str: + """Append the GitPilot signature + Co-authored-by trailer to a commit message. + + Idempotent (won't double‑add) and a no‑op when attribution is disabled. + """ + msg = (message or "").rstrip() + if not attribution_enabled() or co_authored_by() in msg: + return msg + trailer = f"{SIGNATURE}\n\n{co_authored_by()}" + return f"{msg}\n\n{trailer}" if msg else trailer diff --git a/gitpilot/matrix_runs_router.py b/gitpilot/matrix_runs_router.py new file mode 100644 index 0000000..8458990 --- /dev/null +++ b/gitpilot/matrix_runs_router.py @@ -0,0 +1,493 @@ +"""Matrix-native run facade for GitPilot (Batch 2). + +Exposes ``POST /api/v1/gitpilot/runs`` so Matrix Builder can hand a signed +Matrix Bundle to GitPilot as a controlled run. The facade maps the Matrix run +contract onto the existing repair pipeline (:class:`RepairRequest`) and never +lets a caller punch a hole in the Matrix control files: ``MATRIX_STANDARDS.lock`` +and ``MATRIX_BLUEPRINT.yaml`` are always added to ``forbidden_paths``, on top of +the caller's ``forbidden_files`` and the pipeline's ``DEFAULT_FORBIDDEN_PATHS``. + +Product rule +------------ +Matrix Builder is the architect and judge; GitPilot is the implementation +worker. GitPilot may read the bundle and implement inside the contract, but it +can never edit the Matrix control files, approve its own work, or create a +Matrix Commit — those stay with Matrix Builder. + +Security +-------- +This router is gated by the **A2A shared secret** +(``GITPILOT_A2A_SHARED_SECRET`` / ``GITPILOT_A2A_REQUIRE_AUTH``) — the same +authenticated agent-to-agent channel used by the A2A adapter. + +Scope of Batch 2 +---------------- +The facade validates the contract, applies the Matrix guardrails, maps to a +:class:`RepairRequest`, registers a *queued* run, and returns +``{run_id, status: "queued", url}``. Actually executing the run and exposing +``GET /api/v1/gitpilot/runs/{run_id}`` lands in a later batch (result sync). +""" + +from __future__ import annotations + +import os +import threading +import uuid +from typing import Any + +from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel, Field + +from gitpilot.a2a_adapter import _require_gateway_secret +from gitpilot.repair.pr_writer import DraftPRWriter, draft_pr_enabled +from gitpilot.repair.schema import DEFAULT_FORBIDDEN_PATHS, RepairMode, RepairRequest +from gitpilot.repair.service import run_repair + +# Matrix control files GitPilot must never modify — always denied, even if a +# caller lists them in allowed_files. Basename + nested forms are both listed +# for clarity; the repair policy matches on basename too. +MATRIX_CONTROL_FILES: list[str] = [ + "MATRIX_STANDARDS.lock", + "MATRIX_BLUEPRINT.yaml", + "**/MATRIX_STANDARDS.lock", + "**/MATRIX_BLUEPRINT.yaml", +] + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + + +class MatrixRunRequest(BaseModel): + """The Matrix run contract sent by Matrix Builder.""" + + bundle_url: str + task_id: str + prompt: str = "" + project_name: str = "" + allowed_files: list[str] = Field(default_factory=list) + forbidden_files: list[str] = Field(default_factory=list) + validation_commands: list[str] = Field(default_factory=list) + # Free-form Matrix mode ("ask" / "plan" / "apply"…); mapped conservatively. + mode: str = "ask" + + +class MatrixRunResponse(BaseModel): + run_id: str + status: str = "queued" + url: str + + +class MatrixRepairRequest(BaseModel): + """A repair task for an existing run (Batch 8). + + Findings + the repair prompt become issues; the run re-executes inside the + same contract (allowed/forbidden), always denying the Matrix control files. + """ + + validation_findings: list[str] = Field(default_factory=list) + repair_prompt: str = "" + allowed_files: list[str] = Field(default_factory=list) + forbidden_files: list[str] = Field(default_factory=list) + + +class MatrixPrRequest(BaseModel): + """Open a PR for a run's diff (Batch 11). + + Matrix Builder only calls this after a Matrix-approved verdict — GitPilot + never opens a PR for its own un-validated work. + """ + + repo_url: str = "" + title: str = "" + base: str = "main" + + +class MatrixPrResponse(BaseModel): + run_id: str + pr_url: str | None = None + status: str = "draft" # draft | created | disabled | no_repo + message: str = "" + + +class MatrixRunStatus(BaseModel): + """Result-sync shape Matrix Builder polls (Batch 6). + + ``status`` is GitPilot's *implementation* status only — never a Matrix + verdict. A passing run is ``completed`` with ``test_status: passed``; it is + NOT approval. Matrix Builder runs its own validation to approve/reject. + """ + + run_id: str + status: str + summary: str = "" + diff_url: str | None = None + logs_url: str | None = None + test_status: str = "not_run" + changed_files: list[str] = Field(default_factory=list) + + +# In-memory run registry. Single-process only (HF Space / local). A durable +# store (DB / object storage) replaces this when runs must survive restarts. +_RUNS: dict[str, dict[str, Any]] = {} +_RUNS_LOCK = threading.Lock() + +# GitPilot implementation status -> result-sync status. Deliberately never maps +# to "approved": Matrix approval is Matrix Builder's call, not GitPilot's. +_TERMINAL_STATUS = { + "ok": "completed", + "blocked": "blocked", + "error": "error", + "needs_approval": "needs_approval", +} + + +def _coder_demo() -> bool: + """Default to deterministic offline previews unless explicitly disabled.""" + v = os.getenv("GITPILOT_CODER_DEMO") + if v is None: + return True + return v.strip().lower() in {"1", "true", "yes", "on"} + + +def _test_status_from(sandbox_result: dict[str, Any] | None) -> str: + """Map a sandbox result to passed / failed / skipped.""" + if not sandbox_result: + return "skipped" + for key in ("passed", "ok", "success"): + if sandbox_result.get(key) is True: + return "passed" + if sandbox_result.get(key) is False: + return "failed" + status = str(sandbox_result.get("status", "")).lower() + if status in {"passed", "ok", "success"}: + return "passed" + if status in {"failed", "error"}: + return "failed" + return "skipped" + + +def _execute_run(run_id: str) -> None: + """Run the repair pipeline for a queued run and record its result. + + Runs in a background task. Never raises — failures are recorded on the run + as ``status: error`` so the poller always gets a terminal answer. + """ + with _RUNS_LOCK: + rec = _RUNS.get(run_id) + if rec is None: + return + rec["status"] = "running" + plan = dict(rec["repair_request"]) + task_id = rec.get("task_id", run_id) + try: + resp = run_repair(RepairRequest.from_json(plan), demo_mode=_coder_demo()) + diff = resp.patch_preview or "" + logs = "\n".join([*resp.messages, *(f"WARNING: {w}" for w in resp.warnings)]) + update = { + "status": _TERMINAL_STATUS.get(resp.status, "completed"), + "summary": resp.review or f"Implemented {task_id}", + "diff": diff, + "logs": logs, + "test_status": _test_status_from(resp.sandbox_result), + "changed_files": [cf.path for cf in resp.changed_files], + "risk_level": resp.risk_level.value, + } + except Exception: # pragma: no cover - defensive; no secrets leaked + update = {"status": "error", "logs": "internal error running repair pipeline"} + with _RUNS_LOCK: + rec = _RUNS.get(run_id) + if rec is not None: + rec.update(update) + + +def _map_mode(mode: str) -> RepairMode: + """Map a Matrix run mode onto a repair mode, conservatively. + + Unknown / ``ask`` / ``plan`` modes default to a safe ``dry_run``. ``apply`` + is honored only when explicitly requested; the repair pipeline itself still + fails closed (empty allowed_paths blocks, high risk needs approval). + """ + normalized = (mode or "").strip().lower() + if normalized in {"apply", "auto"}: + return RepairMode.apply + if normalized in {"draft_pr", "pr"}: + return RepairMode.draft_pr + return RepairMode.dry_run + + +def merge_forbidden(caller_forbidden: list[str] | None) -> list[str]: + """Forbidden set = caller forbidden + Matrix control files + pipeline defaults. + + De-duplicated and order-preserving. The Matrix control files are always + present so GitPilot can never edit them — even if the caller "allows" them. + """ + merged: list[str] = [] + for group in (caller_forbidden or [], MATRIX_CONTROL_FILES, DEFAULT_FORBIDDEN_PATHS): + for path in group: + if path not in merged: + merged.append(path) + return merged + + +def to_repair_request(run: MatrixRunRequest) -> RepairRequest: + """Map the Matrix run contract onto the existing repair pipeline request.""" + issues: list[dict[str, Any]] = [] + if run.prompt.strip(): + issues.append( + { + "id": run.task_id or "matrix-task", + "severity": "medium", + "description": run.prompt.strip(), + "recommended_action": run.prompt.strip(), + } + ) + return RepairRequest.from_json( + { + "client_id": "matrix-builder", + "workspace_id": run.task_id or uuid.uuid4().hex, + "task_id": run.task_id or uuid.uuid4().hex, + # The signed Matrix Bundle URL is the source GitPilot fetches; later + # batches resolve it into a workspace before the pipeline runs. + "repo_url": run.bundle_url, + "mode": _map_mode(run.mode).value, + "issues": issues, + "allowed_paths": list(run.allowed_files or []), + "forbidden_paths": merge_forbidden(run.forbidden_files), + "sandbox": {"provider": "matrixlab", "required": False}, + } + ) + + +def verify_a2a_secret( + authorization: str | None = Header(default=None), + x_a2a_secret: str | None = Header(default=None, alias="X-A2A-Secret"), +) -> None: + """Gate the facade behind the A2A shared secret (single source of truth).""" + _require_gateway_secret(authorization, x_a2a_secret) + + +def _base_url(request: Request) -> str: + base = os.getenv("GITPILOT_PUBLIC_BASE_URL", "").strip().rstrip("/") + if base: + return base + return str(request.base_url).rstrip("/") + + +def _matrix_health() -> dict[str, Any]: + return { + "status": "ok", + "service": "gitpilot-matrix", + "auth_required": _env_bool("GITPILOT_A2A_REQUIRE_AUTH", True), + } + + +def _create_run( + run: MatrixRunRequest, request: Request, background_tasks: BackgroundTasks +) -> MatrixRunResponse: + # Build the controlled repair request now so a malformed contract fails fast + # before a run is ever queued. Matrix guardrails are applied here. + repair_request = to_repair_request(run) + run_id = f"gp-run-{uuid.uuid4().hex[:12]}" + with _RUNS_LOCK: + _RUNS[run_id] = { + "run_id": run_id, + "status": "queued", + "bundle_url": run.bundle_url, + "task_id": run.task_id, + "validation_commands": list(run.validation_commands or []), + "repair_request": repair_request.model_dump(mode="json"), + "summary": "", + "diff": "", + "logs": "", + "test_status": "not_run", + "changed_files": [], + } + # Execute asynchronously: the caller gets a queued run immediately and polls + # GET /runs/{run_id} for the result. + background_tasks.add_task(_execute_run, run_id) + url = f"{_base_url(request)}/api/v1/gitpilot/runs/{run_id}" + return MatrixRunResponse(run_id=run_id, status="queued", url=url) + + +def _require_run(run_id: str) -> dict[str, Any]: + with _RUNS_LOCK: + rec = _RUNS.get(run_id) + if rec is None: + raise HTTPException(status_code=404, detail="run not found") + return rec + + +def _get_run(run_id: str, request: Request) -> MatrixRunStatus: + rec = _require_run(run_id) + base = _base_url(request) + return MatrixRunStatus( + run_id=run_id, + status=rec["status"], + summary=rec.get("summary", ""), + diff_url=f"{base}/api/v1/gitpilot/runs/{run_id}/diff" if rec.get("diff") else None, + logs_url=f"{base}/api/v1/gitpilot/runs/{run_id}/logs" if rec.get("logs") else None, + test_status=rec.get("test_status", "not_run"), + changed_files=list(rec.get("changed_files", [])), + ) + + +def _repair_run( + run_id: str, repair: MatrixRepairRequest, request: Request, background_tasks: BackgroundTasks +) -> MatrixRunResponse: + """Re-run an existing run with repair findings, inside the same contract.""" + original = _require_run(run_id) + plan = dict(original["repair_request"]) + + issues: list[dict[str, Any]] = [ + { + "id": f"finding-{i + 1}", + "severity": "medium", + "description": finding, + "recommended_action": finding, + } + for i, finding in enumerate(repair.validation_findings) + ] + if repair.repair_prompt.strip(): + issues.append( + { + "id": "repair-prompt", + "severity": "medium", + "description": repair.repair_prompt.strip(), + "recommended_action": repair.repair_prompt.strip(), + } + ) + plan["issues"] = issues or plan.get("issues", []) + if repair.allowed_files: + plan["allowed_paths"] = list(repair.allowed_files) + # Always re-assert the Matrix control files as forbidden. + plan["forbidden_paths"] = merge_forbidden( + repair.forbidden_files or plan.get("forbidden_paths", []) + ) + + new_run_id = f"gp-run-{uuid.uuid4().hex[:12]}" + with _RUNS_LOCK: + _RUNS[new_run_id] = { + "run_id": new_run_id, + "status": "queued", + "bundle_url": original.get("bundle_url", ""), + "task_id": original.get("task_id", new_run_id), + "validation_commands": list(original.get("validation_commands", [])), + "repair_request": plan, + "parent_run_id": run_id, + "summary": "", + "diff": "", + "logs": "", + "test_status": "not_run", + "changed_files": [], + } + background_tasks.add_task(_execute_run, new_run_id) + url = f"{_base_url(request)}/api/v1/gitpilot/runs/{new_run_id}" + return MatrixRunResponse(run_id=new_run_id, status="queued", url=url) + + +def _create_pr(run_id: str, pr: MatrixPrRequest) -> MatrixPrResponse: + """Open a PR for a run's diff (Batch 11). + + Uses the repair pipeline's DraftPRWriter. In the first wave this is a safe + stub that does not call a Git host; it returns a draft PR reference so the + flow is demonstrable. Real PR creation is enabled per-deployment behind + GITPILOT_DRAFT_PR_ENABLED + Git host credentials. + """ + rec = _require_run(run_id) + repo_url = (pr.repo_url or rec.get("bundle_url", "")).rstrip("/") + title = pr.title or f"GitPilot: {rec.get('task_id', run_id)}" + result = DraftPRWriter().create_draft_pr( + repo_url=repo_url, + branch=f"gitpilot/{rec.get('task_id', run_id)}", + title=title, + body=rec.get("summary", ""), + base=pr.base or "main", + ) + if result.url: + return MatrixPrResponse( + run_id=run_id, pr_url=result.url, status="created", message=result.message + ) + if not repo_url: + return MatrixPrResponse(run_id=run_id, status="no_repo", message="No repo_url for the PR.") + # Draft stub: synthesize a reference so the UI has a link; clearly a draft. + status = "draft" if draft_pr_enabled() else "disabled" + pr_url = f"{repo_url}/pull/draft-{run_id[-6:]}" if repo_url.startswith("http") else None + return MatrixPrResponse(run_id=run_id, pr_url=pr_url, status=status, message=result.message) + + +def _get_run_diff(run_id: str) -> PlainTextResponse: + rec = _require_run(run_id) + return PlainTextResponse(rec.get("diff", "")) + + +def _get_run_logs(run_id: str) -> PlainTextResponse: + rec = _require_run(run_id) + return PlainTextResponse(rec.get("logs", "")) + + +def _register_routes(router: APIRouter) -> None: + """Register the health + runs routes on *router* (shared by both prefixes).""" + router.add_api_route("/health", _matrix_health, methods=["GET"]) + router.add_api_route( + "/runs", + _create_run, + methods=["POST"], + response_model=MatrixRunResponse, + dependencies=[Depends(verify_a2a_secret)], + ) + router.add_api_route( + "/runs/{run_id}", + _get_run, + methods=["GET"], + response_model=MatrixRunStatus, + dependencies=[Depends(verify_a2a_secret)], + ) + router.add_api_route( + "/runs/{run_id}/repair", + _repair_run, + methods=["POST"], + response_model=MatrixRunResponse, + dependencies=[Depends(verify_a2a_secret)], + ) + router.add_api_route( + "/runs/{run_id}/pr", + _create_pr, + methods=["POST"], + response_model=MatrixPrResponse, + dependencies=[Depends(verify_a2a_secret)], + ) + router.add_api_route( + "/runs/{run_id}/diff", + _get_run_diff, + methods=["GET"], + dependencies=[Depends(verify_a2a_secret)], + ) + router.add_api_route( + "/runs/{run_id}/logs", + _get_run_logs, + methods=["GET"], + dependencies=[Depends(verify_a2a_secret)], + ) + + +def build_matrix_runs_router() -> APIRouter: + """Build the canonical Matrix runs facade router (``/api/v1/gitpilot/*``).""" + router = APIRouter(prefix="/api/v1/gitpilot", tags=["matrix"]) + _register_routes(router) + return router + + +def build_matrix_runs_alias_router() -> APIRouter: + """Build the local-bridge alias router (``/api/matrix/*``, same handlers). + + The web "Send to local GitPilot" bridge POSTs to ``/api/matrix/runs`` per the + Phase 2 contract; this exposes the identical facade under that namespace. + """ + router = APIRouter(prefix="/api/matrix", tags=["matrix"]) + _register_routes(router) + return router diff --git a/tests/test_commit_attribution.py b/tests/test_commit_attribution.py new file mode 100644 index 0000000..12f53f8 --- /dev/null +++ b/tests/test_commit_attribution.py @@ -0,0 +1,48 @@ +"""GitPilot commit attribution (Co-authored-by trailer, Claude-Code style).""" + +from __future__ import annotations + +import pytest + +from gitpilot.commit_attribution import ( + bot_email, + bot_name, + co_authored_by, + with_attribution, +) + + +def test_co_authored_by_uses_gitpilot_identity(): + assert bot_name() == "GitPilot" + assert co_authored_by() == f"Co-authored-by: GitPilot <{bot_email()}>" + + +def test_with_attribution_appends_signature_and_trailer(): + out = with_attribution("Add health endpoint") + assert out.startswith("Add health endpoint") + assert "Generated with GitPilot" in out + assert "Co-authored-by: GitPilot <" in out + + +def test_with_attribution_is_idempotent(): + once = with_attribution("Fix bug") + twice = with_attribution(once) + assert once == twice + assert once.count("Co-authored-by: GitPilot") == 1 + + +def test_attribution_can_be_disabled(monkeypatch): + monkeypatch.setenv("GITPILOT_COMMIT_ATTRIBUTION", "false") + assert with_attribution("Plain commit") == "Plain commit" + + +def test_bot_email_overridable(monkeypatch): + monkeypatch.setenv("GITPILOT_BOT_EMAIL", "bot@gitpilot.ruslanmv.com") + assert bot_email() == "bot@gitpilot.ruslanmv.com" + assert "bot@gitpilot.ruslanmv.com" in co_authored_by() + + +def test_bot_email_defaults_to_app_bot(monkeypatch): + monkeypatch.delenv("GITPILOT_BOT_EMAIL", raising=False) + monkeypatch.setenv("GITHUB_APP_SLUG", "gitpilota") + assert bot_email() == "gitpilota[bot]@users.noreply.github.com" diff --git a/tests/test_matrix_runs_router.py b/tests/test_matrix_runs_router.py new file mode 100644 index 0000000..a70047f --- /dev/null +++ b/tests/test_matrix_runs_router.py @@ -0,0 +1,251 @@ +"""Tests for the Matrix runs facade (Batch 2). + +Covers the acceptance criteria: +* a signed bundle URL yields a queued run ({run_id, status: "queued", url}); +* the Matrix control files are rejected even if a caller "allows" them; +* the facade is gated by the A2A shared secret. + +Runs fully offline — the facade only validates + maps + registers the run; it +does not execute the repair pipeline in this batch. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from gitpilot.matrix_runs_router import ( + MATRIX_CONTROL_FILES, + MatrixRunRequest, + build_matrix_runs_alias_router, + build_matrix_runs_router, + merge_forbidden, + to_repair_request, +) +from gitpilot.repair.policy import validate_changed_files +from gitpilot.repair.schema import DEFAULT_FORBIDDEN_PATHS + +PAYLOAD = { + "bundle_url": "https://build.matrixhub.io/api/v1/bundles/abc/download?expires=1&token=x", + "project_name": "Starter controlled blueprint", + "task_id": "TASK-001", + "prompt": "Implement the health endpoint", + "allowed_files": ["src/**", "tests/**"], + "forbidden_files": ["infra/**"], + "validation_commands": ["pytest -q"], + "mode": "ask", +} + + +def _app() -> FastAPI: + app = FastAPI() + app.include_router(build_matrix_runs_router()) + app.include_router(build_matrix_runs_alias_router()) + return app + + +@pytest.fixture() +def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: + # Auth off for the happy-path tests (local/dev posture). + monkeypatch.setenv("GITPILOT_A2A_REQUIRE_AUTH", "false") + return TestClient(_app()) + + +def test_create_run_returns_queued(client: TestClient) -> None: + resp = client.post("/api/v1/gitpilot/runs", json=PAYLOAD) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "queued" + assert body["run_id"].startswith("gp-run-") + assert body["url"].endswith(f"/api/v1/gitpilot/runs/{body['run_id']}") + + +def test_local_bridge_alias_runs(client: TestClient) -> None: + # The /api/matrix/* alias (used by "Send to local GitPilot") hits the same + # facade and returns a queued run. + resp = client.post("/api/matrix/runs", json=PAYLOAD) + assert resp.status_code == 200 + assert resp.json()["status"] == "queued" + + health = client.get("/api/matrix/health") + assert health.status_code == 200 + assert health.json()["service"] == "gitpilot-matrix" + + +def test_run_status_completes_with_diff_logs_tests(client: TestClient) -> None: + # allowed_files include tests/** so the demo patch (tests/test_health.py) is + # inside the contract and the run completes. + payload = dict(PAYLOAD, allowed_files=["tests/**"]) + created = client.post("/api/v1/gitpilot/runs", json=payload) + run_id = created.json()["run_id"] + + # TestClient runs the background task before returning, so the run is terminal. + status = client.get(f"/api/v1/gitpilot/runs/{run_id}") + assert status.status_code == 200 + body = status.json() + assert body["run_id"] == run_id + assert body["status"] == "completed" + # Result reflects diff / logs / tests. + assert body["diff_url"] and body["diff_url"].endswith(f"/runs/{run_id}/diff") + assert body["logs_url"] + assert body["test_status"] in {"passed", "failed", "skipped"} + assert any("test_health" in f for f in body["changed_files"]) + + diff = client.get(f"/api/v1/gitpilot/runs/{run_id}/diff") + assert diff.status_code == 200 and "test_health.py" in diff.text + logs = client.get(f"/api/v1/gitpilot/runs/{run_id}/logs") + assert logs.status_code == 200 + + +def test_run_status_never_auto_promotes_to_approved(client: TestClient) -> None: + created = client.post("/api/v1/gitpilot/runs", json=dict(PAYLOAD, allowed_files=["tests/**"])) + run_id = created.json()["run_id"] + body = client.get(f"/api/v1/gitpilot/runs/{run_id}").json() + # GitPilot reports implementation status only — never a Matrix verdict. + assert body["status"] != "approved" + assert body["status"] in {"queued", "running", "completed", "blocked", "error", "needs_approval"} + + +def test_run_status_unknown_run_404(client: TestClient) -> None: + assert client.get("/api/v1/gitpilot/runs/gp-run-nope").status_code == 404 + + +def test_repair_creates_child_run_inside_contract(client: TestClient) -> None: + created = client.post("/api/v1/gitpilot/runs", json=dict(PAYLOAD, allowed_files=["tests/**"])) + run_id = created.json()["run_id"] + + repair = client.post( + f"/api/v1/gitpilot/runs/{run_id}/repair", + json={ + "validation_findings": ["missing health test"], + "repair_prompt": "add tests/test_health.py", + "allowed_files": ["tests/**"], + # Even if a caller "allows" a control file here, repair must still deny it. + "forbidden_files": [], + }, + ) + assert repair.status_code == 200 + child_id = repair.json()["run_id"] + assert child_id != run_id + assert repair.json()["status"] == "queued" + + # Child run completes inside the contract. + status = client.get(f"/api/v1/gitpilot/runs/{child_id}").json() + assert status["status"] == "completed" + assert status["status"] != "approved" + + +def test_repair_unknown_run_404(client: TestClient) -> None: + resp = client.post("/api/v1/gitpilot/runs/gp-run-nope/repair", json={"repair_prompt": "x"}) + assert resp.status_code == 404 + + +def test_pr_endpoint_returns_draft_reference(client: TestClient, monkeypatch) -> None: + monkeypatch.setenv("GITPILOT_DRAFT_PR_ENABLED", "true") + created = client.post("/api/v1/gitpilot/runs", json=dict(PAYLOAD, allowed_files=["tests/**"])) + run_id = created.json()["run_id"] + resp = client.post( + f"/api/v1/gitpilot/runs/{run_id}/pr", + json={"repo_url": "https://github.com/acme/app", "title": "Add hello world"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["run_id"] == run_id + assert body["status"] in {"draft", "created"} + assert body["pr_url"] and "github.com/acme/app/pull/" in body["pr_url"] + + +def test_pr_endpoint_unknown_run_404(client: TestClient) -> None: + resp = client.post("/api/v1/gitpilot/runs/gp-run-nope/pr", json={"repo_url": "x"}) + assert resp.status_code == 404 + + +def test_missing_required_fields_422(client: TestClient) -> None: + # No bundle_url / task_id -> pydantic validation error. + resp = client.post("/api/v1/gitpilot/runs", json={"prompt": "x"}) + assert resp.status_code == 422 + + +def test_health_reports_service(client: TestClient) -> None: + resp = client.get("/api/v1/gitpilot/health") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + assert body["service"] == "gitpilot-matrix" + assert body["auth_required"] is False + + +def test_matrix_control_files_always_forbidden_even_if_allowed() -> None: + run = MatrixRunRequest( + bundle_url="https://build.matrixhub.io/b/x/download", + task_id="TASK-001", + prompt="do it", + # Caller tries to whitelist the control files — must not help them. + allowed_files=["**", "MATRIX_STANDARDS.lock", "MATRIX_BLUEPRINT.yaml"], + forbidden_files=[], + ) + req = to_repair_request(run) + for control in ("MATRIX_STANDARDS.lock", "MATRIX_BLUEPRINT.yaml"): + res = validate_changed_files([control], req) + assert res.allowed is False, f"{control} must be rejected" + # Nested form is rejected too (policy matches on basename). + assert validate_changed_files(["config/MATRIX_STANDARDS.lock"], req).allowed is False + + +def test_merge_forbidden_includes_control_and_defaults() -> None: + merged = merge_forbidden(["infra/**"]) + assert "infra/**" in merged + for control in MATRIX_CONTROL_FILES: + assert control in merged + for default in DEFAULT_FORBIDDEN_PATHS: + assert default in merged + # De-duplicated. + assert len(merged) == len(set(merged)) + + +def test_mode_maps_conservatively() -> None: + def mode_of(mode: str) -> str: + return to_repair_request( + MatrixRunRequest(bundle_url="u", task_id="t", mode=mode) + ).mode.value + + assert mode_of("ask") == "dry_run" + assert mode_of("plan") == "dry_run" + assert mode_of("") == "dry_run" + assert mode_of("apply") == "apply" + assert mode_of("draft_pr") == "draft_pr" + + +def test_allowed_files_map_to_allowed_paths() -> None: + req = to_repair_request(MatrixRunRequest(**PAYLOAD)) + assert req.allowed_paths == ["src/**", "tests/**"] + assert req.client_id == "matrix-builder" + assert req.task_id == "TASK-001" + # An allowed file still passes when it isn't a control/secret file. + assert validate_changed_files(["src/app.py"], req).allowed is True + + +def test_auth_required_returns_401(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITPILOT_A2A_REQUIRE_AUTH", "true") + monkeypatch.setenv("GITPILOT_A2A_SHARED_SECRET", "topsecret") + client = TestClient(_app()) + + # No secret header -> 401. + resp = client.post("/api/v1/gitpilot/runs", json=PAYLOAD) + assert resp.status_code == 401 + + # Correct secret -> 200 queued. + ok = client.post("/api/v1/gitpilot/runs", json=PAYLOAD, headers={"X-A2A-Secret": "topsecret"}) + assert ok.status_code == 200 + assert ok.json()["status"] == "queued" + + +def test_auth_required_but_secret_unset_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + # Auth required + no shared secret configured -> the facade refuses (never + # silently opens). 500, not 200. + monkeypatch.setenv("GITPILOT_A2A_REQUIRE_AUTH", "true") + monkeypatch.delenv("GITPILOT_A2A_SHARED_SECRET", raising=False) + client = TestClient(_app()) + resp = client.post("/api/v1/gitpilot/runs", json=PAYLOAD) + assert resp.status_code != 200