diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7c272fd0f5e160bed88d5761c47c0f3c341d9de0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,118 @@ +# ============================================================================= +# GitPilot - Hugging Face Spaces Dockerfile +# ============================================================================= +# Follows the official HF Docker Spaces pattern: +# https://huggingface.co/docs/hub/spaces-sdks-docker +# +# Architecture: +# React UI (Vite build) -> FastAPI backend -> OllaBridge Cloud / any LLM +# ============================================================================= + +# -- Stage 1: Build React frontend ------------------------------------------- +FROM node:20-slim AS frontend-builder + +WORKDIR /build + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --production=false + +COPY frontend/ ./ +RUN npm run build + +# -- Stage 2: Python runtime ------------------------------------------------- +FROM python:3.12-slim + +# System deps needed at runtime +RUN apt-get update && apt-get install -y --no-install-recommends \ + git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# HF Spaces runs containers as UID 1000 — create user early (official pattern) +RUN useradd -m -u 1000 user + +USER user + +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH \ + PYTHONUNBUFFERED=1 \ + GITPILOT_PROVIDER=ollabridge \ + OLLABRIDGE_BASE_URL=https://app.ollabridge.com \ + GITPILOT_OLLABRIDGE_MODEL=qwen2.5:1.5b \ + CORS_ORIGINS="*" \ + GITPILOT_CONFIG_DIR=/tmp/gitpilot \ + GITPILOT_PUBLIC_BASE_URL=https://gitpilot.ruslanmv.com + +# Sign-in (docs/auth.md, docs/GITLAB_INTEGRATION.md). This Space is also the +# backend of the production site, so GITPILOT_PUBLIC_BASE_URL points there: +# email links and the default OAuth redirect go to gitpilot.ruslanmv.com, while +# the Space's own UI (https://$SPACE_HOST) gets its OAuth redirect back on +# itself automatically. Register BOTH https://gitpilot.ruslanmv.com/auth and +# https://ruslanmv-gitpilot.hf.space/auth as callback URLs in the GitHub App and +# the GitLab application. Set as Space secrets, not here: +# GITPILOT_TOKEN_ENCRYPTION_KEY, GITPILOT_AUTH_SECRET, +# GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET, GITLAB_CLIENT_ID / GITLAB_CLIENT_SECRET. +# Space variables override every default above (e.g. for a duplicated Space). + +WORKDIR $HOME/app + +# ── Install Python dependencies BEFORE copying source code ────────── +# This ensures pip install layers are cached even when code changes. +COPY --chown=user pyproject.toml README.md ./ + +# Step 1: lightweight deps (cached layer) +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir \ + "fastapi>=0.111.0" \ + "uvicorn[standard]>=0.30.0" \ + "httpx>=0.27.0" \ + "python-dotenv>=1.1.0,<1.2.0" \ + "typer>=0.12.0,<0.24.0" \ + "pydantic>=2.7.0,<2.12.0" \ + "rich>=13.0.0" \ + "pyjwt[crypto]>=2.8.0" \ + "cryptography>=42.0" \ + "psycopg[binary]>=3.1" \ + "resend>=2.0.0" + +# Step 2: heavy ML/agent deps (separate layer for better caching) +RUN pip install --no-cache-dir \ + "litellm" \ + "crewai[anthropic]>=0.76.9" \ + "crewai-tools>=0.13.4" \ + "anthropic>=0.39.0" \ + "ibm-watsonx-ai>=1.1.0" \ + "langchain-ibm>=0.3.0" + +# ── Now copy source code (cache-busting only affects layers below) ── +COPY --chown=user gitpilot ./gitpilot + +# Copy built frontend into gitpilot/web/ +COPY --chown=user --from=frontend-builder /build/dist/ ./gitpilot/web/ + +# Step 3: editable install of gitpilot itself (deps already satisfied) +RUN pip install --no-cache-dir --no-deps -e . + +EXPOSE 7860 + +# NOTE: Do NOT add a Docker HEALTHCHECK here. +# HF Spaces has its own HTTP probe on app_port (7860) and ignores the +# 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. +# +# --limit-concurrency counts every open connection, idle keep-alive ones +# included. At 10, a burst of scanner requests plus each user's polling filled +# it and real chat streams got "503 Exceeded concurrency limit". 200 is still a +# memory guard for this one async worker. Keep-alive (75 s) must stay longer +# than the Hugging Face proxy's idle timeout: if uvicorn closed an idle +# connection the proxy then reused, that request would fail with a 502. +CMD ["python", "-m", "uvicorn", "gitpilot.api:app", \ + "--host", "0.0.0.0", \ + "--port", "7860", \ + "--workers", "1", \ + "--limit-concurrency", "200", \ + "--timeout-keep-alive", "75"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..095274a3084027a51b81287f57ff918f5d8f726f --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +--- +title: GitPilot +emoji: "\U0001F916" +colorFrom: blue +colorTo: indigo +sdk: docker +app_port: 7860 +startup_duration_timeout: 5m +pinned: true +license: mit +short_description: Enterprise AI Coding Assistant for GitHub Repositories +--- + +# GitPilot — Hugging Face Spaces + +**Enterprise-grade AI coding assistant** for GitHub repositories with multi-LLM support, visual workflow insights, and intelligent code analysis. + +## What This Does + +This Space runs the full GitPilot stack: +1. **React Frontend** — Professional dark-theme UI with chat, file browser, and workflow visualization +2. **FastAPI Backend** — 80+ API endpoints for repository management, AI chat, planning, and execution +3. **Multi-Agent AI** — CrewAI orchestration with 7 switchable agent topologies + +## LLM Providers + +GitPilot connects to your favorite LLM provider. Configure in **Admin / LLM Settings**: + +| Provider | Default | API Key Required | +|---|---|---| +| **OllaBridge Cloud** (default) | `qwen2.5:1.5b` | No | +| OpenAI | `gpt-4o-mini` | Yes | +| Anthropic Claude | `claude-sonnet-4-5` | Yes | +| Ollama (local) | `llama3` | No | +| Custom endpoint | Any model | Optional | + +## Quick Start + +1. Open the Space UI +2. Enter your **GitHub Token** (Settings -> GitHub) +3. Select a repository from the sidebar +4. Start chatting with your AI coding assistant + +## API Endpoints + +| Endpoint | Description | +|---|---| +| `GET /api/health` | Health check | +| `POST /api/chat/message` | Chat with AI assistant | +| `POST /api/chat/plan` | Generate implementation plan | +| `GET /api/repos` | List repositories | +| `GET /api/settings` | View/update settings | +| `GET /docs` | Interactive API docs (Swagger) | + +## Connect to OllaBridge Cloud + +By default, GitPilot connects to [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge) for LLM inference. This provides free access to open-source models without needing API keys. + +To use your own OllaBridge instance: +1. Go to **Admin / LLM Settings** +2. Select **OllaBridge** provider +3. Enter your OllaBridge URL and model + +## Environment Variables + +Configure via HF Spaces secrets: + +| Variable | Description | Default | +|---|---|---| +| `GITPILOT_PROVIDER` | LLM provider | `ollabridge` | +| `OLLABRIDGE_BASE_URL` | OllaBridge Cloud URL | `https://app.ollabridge.com` | +| `GITHUB_TOKEN` | GitHub personal access token | - | +| `OPENAI_API_KEY` | OpenAI API key (if using OpenAI) | - | +| `ANTHROPIC_API_KEY` | Anthropic API key (if using Claude) | - | + +## Links + +- [GitPilot Repository](https://github.com/ruslanmv/gitpilot) +- [OllaBridge Cloud](https://huggingface.co/spaces/ruslanmv/ollabridge) +- [Documentation](https://github.com/ruslanmv/gitpilot#readme) diff --git a/REPO_README.md b/REPO_README.md new file mode 100644 index 0000000000000000000000000000000000000000..1d928232506286ad21d8dfba72567a04b26bfc8b --- /dev/null +++ b/REPO_README.md @@ -0,0 +1,470 @@ +
+ +GitPilot + +# GitPilot + +### The first open-source multi-agent AI coding assistant. + +Ten specialized agents — Explorer, Planner, Coder, Reviewer and six more — collaborate on every task, coordinated by a router that picks the right ones for the request. By default, GitPilot requests confirmation before executing high-impact actions. Switch to Auto or Plan mode at any time. + +[![PyPI](https://img.shields.io/pypi/v/gitcopilot?style=flat-square&color=D95C3D&labelColor=1C1C1F&label=pypi)](https://pypi.org/project/gitcopilot/) +[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12-D95C3D?style=flat-square&labelColor=1C1C1F)](https://www.python.org/) +[![License](https://img.shields.io/badge/license-Apache%202.0-D95C3D?style=flat-square&labelColor=1C1C1F)](LICENSE) +[![VS Code](https://img.shields.io/badge/VS%20Code-Extension-D95C3D?style=flat-square&labelColor=1C1C1F)](https://marketplace.visualstudio.com/) +[![Tests](https://img.shields.io/badge/tests-2218%20passing-D95C3D?style=flat-square&labelColor=1C1C1F)](#contributing) + +[**Get Started**](#get-started)  ·  [VS Code](#vs-code-extension)  ·  [Web App](#web-app)  ·  [How It Works](#how-it-works)  ·  [Providers](#supported-ai-providers) + +
+ +--- + +

+ + + GitPilot loop: Ask, Plan, Code, Ship — you approve every change. + +

+ +## Why GitPilot? + +Most AI coding tools are a **single model behind a chat box**. GitPilot is fundamentally different: a **request router** inspects each task and dispatches it to the specialists that fit — 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. + +### The agent roster + +Ten specialists. Which ones run depends on the request and the +[topology](docs/agents.md) in force — a small change may use one, a feature +build uses five. + +| Agent | Role | What it does | +|---|---|---| +| **Explorer** | Context | Reads your repo, git log, test suite, and dependencies so the plan starts with real knowledge — not guesses | +| **Planner** | Strategy | Drafts a safe, step-by-step plan with diffs and surfaces risks before any file is touched | +| **Coder** | Execution | Writes code, runs your tests, and self-corrects on failure — iterating until the suite passes | +| **Reviewer** | Quality | Audits changes for security, quality, test coverage and performance, ranked by severity | +| **Issue Manager** | GitHub / GitLab | Creates, updates, triages and closes issues | +| **PR Manager** | GitHub / GitLab | Opens branches, commits, pushes, and drafts pull requests (GitLab: merge requests) with a test plan | +| **Search & Discovery** | GitHub / GitLab | Searches code, repositories, issues and users (GitLab: within the project) | +| **Learning & Guidance** | GitHub | Explains GitHub features and best practices | +| **Local Editor** | Workspace | Reads and writes files directly in your local workspace | +| **Terminal** | Workspace | Runs shell commands inside the sandbox | + +**Full details:** [Agent architecture and topologies](docs/agents.md). + +**You control how the agent runs.** Three execution modes — selectable per session from the VS Code compose bar or backend API: + +| Mode | Default? | Behavior | +|---|---|---| +| **Ask** | Yes | Prompts you before each dangerous action (write, edit, run, commit). You see the diff and click Allow / Deny. | +| **Auto** | | Executes all tools automatically. Fastest for experienced users who trust the plan. | +| **Plan** | | Read-only. Generates and displays the plan but blocks all file writes and commands. | + +Diffs are shown before they're applied. Tests run before anything is committed. No surprises. + +### What else sets GitPilot apart + +- 🧭 **Works where you work** — VS Code, web app, and CLI share one login, one history, and one set of approvals. +- 🧠 **Any LLM, zero lock-in** — OpenAI, Anthropic Claude, IBM Watsonx, Ollama (local & free) or OllaBridge. Switch in settings, no code change. +- 🔐 **Private by default** — run the entire stack locally with Ollama. No telemetry, no data leaves your machine. +- 🏢 **Enterprise-ready, Apache 2.0 open source** — 2,218 passing tests, Docker & Hugging Face deployment recipes, audit the code yourself. +- 🌍 **Runs anywhere** — laptop, private cloud, air-gapped environments, or managed hosting. Your repo, your rules. + +--- + +## What is GitPilot? + +GitPilot is an AI assistant that helps you ship better code, faster — without giving up control. It understands your project, plans changes you can read before they happen, writes the code, runs your tests, and drafts the commit message and pull request for you. + +**Works with any language. Runs on any LLM.** Start free and local with Ollama, or bring your own OpenAI, Claude, or Watsonx key. + +``` +You: "Add input validation to the login form" + +GitPilot: + 1. Reading src/auth/login.ts... + 2. Planning 3 changes... + 3. Editing login.ts → [Apply Patch] [Revert] + 4. Running npm test... 3 passed + 5. Done — files written to your workspace. +``` + +--- + +## Get Started + +### Option 1: VS Code Extension (recommended) + +Install the extension, configure your LLM, and start chatting: + +``` +1. Open VS Code +2. Install "GitPilot Workspace" from Extensions +3. Click the GitPilot icon in the sidebar +4. Choose your AI provider (OpenAI, Claude, Ollama...) +5. Start asking questions about your code +``` + +### Option 2: Web App +![assets/2026-04-07-14-22-32.png](assets/2026-04-07-14-22-32.png) +Run the full web interface with Docker: + +```bash +git clone https://github.com/ruslanmv/gitpilot.git +cd gitpilot +docker compose up +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +### Live Demo on Hugging Face + +Experience the application in action through our hosted demo environment: + +[![Live Demo Preview](assets/2026-04-07-16-17-56.png)](https://huggingface.co/spaces/ruslanmv/gitpilot) + +🔗 **Access the live demo:** +[https://huggingface.co/spaces/ruslanmv/gitpilot](https://huggingface.co/spaces/ruslanmv/gitpilot) + +### Option 3: Python CLI (fastest) + +```bash +pip install gitcopilot +gitpilot serve +``` + +Open [http://localhost:8000](http://localhost:8000) and you're done. + +If something else already holds :8000, GitPilot starts on the next free port and +tells you which — no `make stop` first. A port you ask for yourself +(`gitpilot serve --port 9000`, or `GITPILOT_PORT=9000`) is honoured strictly +instead, because a proxy or container map may depend on that exact number; add +`--no-strict-port` to let it drift anyway. See +[docs/PORTS.md](docs/PORTS.md). + +> **Heads up:** the PyPI package is published as **`gitcopilot`** (the name `gitpilot` was already taken) but the command you run is `gitpilot`. Python **3.11** or **3.12** required. + +--- + +## VS Code Extension +![assets/2026-04-07-14-15-33.png](assets/2026-04-07-14-15-33.png) +The sidebar panel gives you everything in one place: + +| Feature | What it does | +|---|---| +| **Chat** | Ask questions, request changes, review code | +| **Execution Modes** | Bottom bar: `Auto` / `Ask` / `Plan` — controls agent permissions per session | +| **Plan View** | See the step-by-step plan before changes are made | +| **Plan Approval** | "Approve & Execute" / "Dismiss" bar — execution waits for your OK | +| **Tool Approvals** | Per-action Allow / Allow for session / Deny cards (Ask mode) | +| **Diff Preview** | Review proposed edits in VS Code's native diff viewer | +| **Apply / Revert** | One click to apply changes, one click to undo | +| **Quick Actions** | Explain, Review, Fix, Generate Tests, Security Scan | +| **Smart Commit** | AI-generated commit messages | +| **Code Lens** | Inline "Explain / Review" hints on functions | +| **Settings Tab** | Branded settings page (General, Provider, Agent, Editor) | +| **GitPilot Home** | Landing page in the editor — one composer, contextual to the file you have open | +| **New Task** | One click from the sidebar to Home, ready for the next thing | + +### Execution modes + +The compose bar carries a mode picker that controls how the multi-agent pipeline runs: + +``` +[+] [@] [/] Ask ⌄ [ ↑ ] +``` + +| Mode | VS Code setting | Backend value | What happens | +|---|---|---|---| +| **Ask** (default) | `gitpilot.permissionMode: "normal"` | `"normal"` | Each dangerous tool (write, edit, run, commit) shows an approval card | +| **Agent** (shown as `Auto` in older builds) | `gitpilot.permissionMode: "auto"` | `"auto"` | Tools execute automatically — no approval prompts | +| **Plan** | `gitpilot.permissionMode: "plan"` | `"plan"` | Plan is generated and displayed, all writes/commands blocked | + +Mode changes are persisted to VS Code settings and synced to the backend via `PUT /api/permissions/mode`. + +### How approvals work + +``` +You send a request + → Explorer reads repo context + → Planner drafts step-by-step plan + → Plan appears in sidebar (Approve & Execute / Dismiss) + → You click Approve + → Coder begins execution + → Dangerous tool requested (e.g. write_file) + → Ask mode: approval card shown (Allow / Allow for session / Deny) + → Auto mode: executes immediately + → Plan mode: blocked + → Tests run + → Done — Apply Patch or Revert +``` + +The default flow stops at Coder. To add a review step — or a PR step — pick a +topology that includes one (Settings → Agent → Agent topology, or +`GitPilot: Select Topology`). **Feature Builder** runs the full +Explorer → Planner → Coder → Reviewer → PR Manager sequence. See +[Agent architecture and topologies](docs/agents.md). + +``` +``` + +> **Note:** Simple questions (e.g. "explain this code") may return a direct answer without generating a multi-step plan. This is expected — the planner activates for tasks that require file changes or multi-step execution. + +### Code generation and Apply Patch + +When you ask GitPilot to create or edit files, the response includes structured `edits` — not just text. The **Apply Patch** button writes them directly to your workspace. + +``` +You: "Create a Flask app with app.py, requirements.txt, and README.md" + +GitPilot: + → LLM generates 3 files with content + → Backend extracts structured edits (path + content) + → VS Code shows [Apply Patch] [Revert] + → You click Apply Patch + → 3 files written to disk + → Project context refreshes automatically + → First file opens in the editor +``` + +How it works under the hood: +- The LLM is instructed to output code blocks with the filename on the fence line (` ```python hello.py`) +- The backend parses these blocks into `ProposedEdit` objects with file path, kind, and content +- All paths are sanitized (rejects `../` traversal, absolute paths, drive letters) +- The extension stores edits in `activeTask.edits` and shows Apply / Revert +- `PatchApplier` writes files via `vscode.workspace.fs.writeFile` +- After apply, project context refreshes and the first file opens + +> **Note:** For folder-only sessions (no GitHub remote), code generation uses the LLM directly with structured output instructions. For GitHub-connected sessions, the CrewAI pipeline (Explorer → Planner → Coder) handles planning and execution. Review and PR stages are added by selecting a topology that includes them — see [Agent architecture and topologies](docs/agents.md). + +### Supported AI Providers + +Configure any of these from **GitPilot: Settings → AI Providers** inside VS Code — +no browser, no config files. See the [provider setup guide](docs/vscode/ai-providers.md). + +| Provider | Setup | Free? | +|---|---|---| +| **OllaBridge** | Works out of the box; sign in for more models | Yes | +| **Ollama** | Install Ollama, run `ollama pull llama3` | Yes | +| **Open WebUI** | Point GitPilot at your instance URL | Yes (self-hosted) | +| **OpenAI** | Add your API key | Paid | +| **Claude** | Add your Anthropic API key | Paid | +| **Watsonx** | Add IBM API key and project ID | Paid | +| **Custom endpoint** | Any OpenAI-compatible gateway — URL, key, headers | Depends | + +--- + +## Web App + +The web interface includes: + +- Chat with real-time responses +- GitHub and GitLab (GitLab.com or one self-managed instance): sign in with either or + both, browse repositories and nested-group projects, plan and execute changes, and + open pull requests or merge requests +- File tree browser, branch picker with branch creation +- Diff viewer with line-by-line changes +- Pull request / merge request creation +- Session history with checkpoints +- Multi-repo workspaces (repositories from both providers can be mixed) +- Enterprise sign-in: OAuth with PKCE, server-side encrypted provider tokens, + HttpOnly session cookies with CSRF protection, optional email/password accounts + +See [GitLab integration](docs/GITLAB_INTEGRATION.md) and +[Authentication and sessions](docs/auth.md). + + + +### Example: File Deletion +![](assets/2025-11-16-00-25-49.png) + +### Example: Content Generation +![](assets/2025-11-16-00-29-47.png) + +### Example: File Creation +![](assets/2025-11-16-01-01-40.png) + +### Example multiple operations +![](assets/2025-11-27-00-25-53.png) + +### Example of multiagent topologies +![](assets/2026-04-07-16-11-47.png) + +--- + +## How It Works + +

+ + + GitPilot architecture: Web, VS Code and CLI share one FastAPI backend that orchestrates a CrewAI multi-agent pipeline (Explorer, Planner, Executor, Reviewer) over any LLM provider. + +

+ +GitPilot uses a multi-agent system powered by CrewAI. A **request router** +classifies each request and dispatches it to the specialists that fit; the +default plan-and-execute flow is: + +1. **Explorer** reads your repo structure, git log, and key files +2. **Planner** creates a safe step-by-step plan with diffs +3. **Coder** writes code and runs tests, self-correcting on failure + +Other requests skip that chain entirely — a code-review request goes straight +to the **Reviewer**, an issue request to the **Issue Manager**. Selecting a +**topology** replaces the routing with a fixed sequence, which is how you add +a review or PR stage to every task. Ten agents and nine topologies are +documented in [Agent architecture and topologies](docs/agents.md). + +In **Ask** mode (default), you approve every change before it's applied. In **Auto** mode, tools execute without prompts. In **Plan** mode, only the plan is generated — no files are touched. + +--- + +## Project Structure + +``` +gitpilot/ + gitpilot/ Python backend (FastAPI) + frontend/ React web app + extensions/vscode/ VS Code extension + docs/ Documentation and assets + tests/ Test suite +``` + +--- + +## Configuration + +GitPilot works with environment variables or the settings UI. + +**Minimal setup** (Ollama, free, local): + +```bash +# .env +GITPILOT_PROVIDER=ollama +OLLAMA_BASE_URL=http://localhost:11434 +GITPILOT_OLLAMA_MODEL=llama3 +``` + +**Cloud setup** (OpenAI): + +```bash +# .env +GITPILOT_PROVIDER=openai +OPENAI_API_KEY=sk-... +GITPILOT_OPENAI_MODEL=gpt-4o-mini +``` + +**Cloud setup** (Claude): + +```bash +# .env +GITPILOT_PROVIDER=claude +ANTHROPIC_API_KEY=sk-ant-... +GITPILOT_CLAUDE_MODEL=claude-sonnet-4-5 +``` + +All settings can also be changed from the VS Code extension or web UI without editing files. + +--- + +## API + +GitPilot exposes a REST + WebSocket API: + +| Endpoint | What it does | +|---|---| +| `GET /api/status` | Server health check | +| `POST /api/chat/send` | Send a message, get a response | +| `POST /api/v2/chat/stream` | Stream agent events (SSE) — accepts `permission_mode` | +| `WS /ws/v2/sessions/{id}` | Real-time WebSocket streaming | +| `POST /api/chat/plan` | Generate an execution plan | +| `POST /api/chat/execute` | Execute a plan | +| `GET /api/repos` | List connected repositories | +| `GET /api/sessions` | List chat sessions | +| `GET /api/permissions` | Current permission policy | +| `PUT /api/permissions/mode` | Set execution mode: `normal` / `auto` / `plan` | +| `POST /api/v2/approval/respond` | Approve or deny a tool execution request | + +Full API docs at `http://localhost:8000/docs` (Swagger UI). + +--- + +## Deployment + +### Hugging Face Spaces + +GitPilot runs on Hugging Face Spaces with OllaBridge (free): + +``` +Runtime: Docker +Port: 7860 +Provider: OllaBridge (cloud Ollama) +``` + +### Docker Compose + +```bash +docker compose up -d +# Backend: http://localhost:8000 +# Frontend: http://localhost:3000 +``` + +### Vercel + +The frontend deploys to Vercel. Set `VITE_BACKEND_URL` to your backend. + +--- + +## Contributing + +```bash +# Standard install: runtime backend + frontend + MCP stack +make install +# WSL note: the Makefile defaults uv to UV_LINK_MODE=copy to avoid +# hardlink fallback warnings on /mnt/c checkouts. For best install speed, +# clone the repo inside the native WSL filesystem (for example ~/workspace). + +# Developer/test tooling +make install-dev +make test + +# Frontend only +cd frontend +npm ci +npm run dev + +# VS Code Extension +cd extensions/vscode +npm install +make compile +# Press F5 in VS Code to launch debug host +``` + +--- + +## License + +Apache License 2.0. See [LICENSE](LICENSE). + +--- + +
+ +**GitPilot** is made by [Ruslan Magana Vsevolodovna](https://github.com/ruslanmv) + +[Star on GitHub](https://github.com/ruslanmv/gitpilot) • [Report a Bug](https://github.com/ruslanmv/gitpilot/issues) • [Request a Feature](https://github.com/ruslanmv/gitpilot/issues) + +
+ +--- +**MCP Context Forge integration** — GitPilot ships a default MCP stack (Forge + PostgreSQL / Milvus / Inspector servers) wired into the agents like Claude Code's built-ins; `make run` brings everything up. No Docker? Use `make run-bare` to start GitPilot core without MCP. See [docs/deploy/install-mcp.md](./docs/deploy/install-mcp.md) and [docs/deploy/production-mcp.md](./docs/deploy/production-mcp.md). + +--- + +## What's New + +> **Enterprise-ready foundation:** GitPilot now ships with safer defaults and production-grade controls, including thread-safe feature flags, strict typing, CI coverage enforcement, structured error handling, and a fast `gitpilot doctor` health check. All upgrades are additive, flag-gated, and disabled by default, so existing installations remain stable while teams can adopt new capabilities gradually. + +> **Performance, onboarding, and release confidence:** GitPilot now improves runtime efficiency with prompt caching, lazy tool loading, context memoisation, SSE streaming, and safe model warmup. First-time setup is easier with `gitpilot init --wizard`, which creates configuration files atomically with rollback protection and no secret exposure. The platform also adds a stable public API, deprecation handling, MkDocs documentation, broken-link checks, SBOM generation, npm auditing, and Sigstore-based release signing. diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..9da1acab57b3a83f0649dc5deb28b33600fe4ad3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,39 @@ +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Build +dist/ +build/ + +# Environment +.env +.env.local +.env.development +.env.test +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Testing +coverage/ +.nyc_output/ + +# Misc +*.log diff --git a/frontend/App.jsx b/frontend/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1d0e107010a634554bbfcd14d25e736b719d94af --- /dev/null +++ b/frontend/App.jsx @@ -0,0 +1,1381 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import StartupScreen from "./components/StartupScreen.jsx"; +import LandingPage from "./components/LandingPage.jsx"; +import AuthPage from "./components/AuthPage.jsx"; +import RepoSelector from "./components/RepoSelector.jsx"; +import ProjectContextPanel from "./components/ProjectContextPanel.jsx"; +import ChatPanel from "./components/ChatPanel.jsx"; +import LlmSettings from "./components/LlmSettings.jsx"; +import FlowViewer from "./components/FlowViewer.jsx"; +import Footer from "./components/Footer.jsx"; +import ProjectSettingsModal from "./components/ProjectSettingsModal.jsx"; +import SessionSidebar from "./components/SessionSidebar.jsx"; +import ContextBar from "./components/ContextBar.jsx"; +import AddRepoModal from "./components/AddRepoModal.jsx"; +import UserMenu from "./components/UserMenu.jsx"; +import AboutModal from "./components/AboutModal.jsx"; +import { + WorkspaceModesTab, + SecurityTab, + IntegrationsTab, + MCPServersTab, + SkillsTab, + SessionsTab, + AdvancedTab, + SandboxTab, + AccountTab, +} from "./components/AdminTabs"; +import { + apiUrl, safeFetchJSON, fetchStatus, startSession, authFetch, + fetchSession, migrateLegacyTokens, hasLegacyTokens, logoutSession, +} from "./utils/api.js"; +import { initApp } from "./utils/appInit.js"; +import { + repoKey as makeRepoKey, repoFullName, repoProvider, providerLabel, splitFullName, treeUrl, +} from "./utils/repoApi.js"; + +// Non-secret hint that this browser had a signed-in session, so a reload shows +// the startup screen (not the landing page) while the HttpOnly session cookie, +// which scripts cannot read, is being checked. +const SIGNED_IN_HINT = "gitpilot_signed_in"; + +function setSignedInHint(on) { + try { + if (on) localStorage.setItem(SIGNED_IN_HINT, "1"); + else localStorage.removeItem(SIGNED_IN_HINT); + } catch { /* storage may be blocked */ } +} + +function hasSignedInHint() { + try { return localStorage.getItem(SIGNED_IN_HINT) === "1"; } catch { return false; } +} + +// The identity shown in the UI: the GitPilot account if there is one, else +// the first connected provider's profile. +function userFromSession(info) { + if (!info) return null; + const providers = info.providers || {}; + const linked = Object.entries(providers).map(([name, p]) => ({ ...(p.user || {}), provider: name })); + if (info.account) { + return { + login: info.account.email, name: info.account.name || info.account.email, + email: info.account.email, providers: linked, + }; + } + const first = linked[0]; + return first ? { ...first, providers: linked } : null; +} + +function uniq(arr) { + return Array.from(new Set((arr || []).filter(Boolean))); +} + +function getProviderLabel(status) { + if (!status) return "Checking..."; + return ( + status?.provider?.name || + status?.provider_name || + status?.provider?.provider || + "Checking..." + ); +} + +function getBackendVersion(status) { + if (!status) return "Checking..."; + return status?.version || status?.app_version || "Checking..."; +} + +export default function App() { + const frontendVersion = __APP_VERSION__ || "unknown"; + + // ---- Multi-repo context state ---- + const [contextRepos, setContextRepos] = useState([]); + // Each entry: { repoKey: "owner/repo", repo: {...}, branch: "main" } + const [activeRepoKey, setActiveRepoKey] = useState(null); + const [addRepoOpen, setAddRepoOpen] = useState(false); + + const [activePage, setActivePage] = useState("workspace"); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [userInfo, setUserInfo] = useState(null); + // Public view of the server session: account + connected providers (no tokens). + const [sessionInfo, setSessionInfo] = useState(null); + + // Startup / enterprise loader state + const [startupPhase, setStartupPhase] = useState("booting"); + const [startupStatusMessage, setStartupStatusMessage] = useState("Starting application..."); + const [startupDetailMessage, setStartupDetailMessage] = useState( + "Initializing authentication, provider, and workspace context." + ); + const [startupStatusSnapshot, setStartupStatusSnapshot] = useState(null); + + // Repo + Session State Machine + const [repoStateByKey, setRepoStateByKey] = useState({}); + const [toast, setToast] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + const [adminTab, setAdminTab] = useState("overview"); + const [adminStatus, setAdminStatus] = useState(null); + + // Fetch admin status when overview tab is active + useEffect(() => { + if (activePage === "admin" && adminTab === "overview") { + fetchStatus() + .then((data) => setAdminStatus(data)) + .catch(() => setAdminStatus(null)); + } + }, [activePage, adminTab]); + + // Claude-Code-on-Web: Session sidebar + Environment state + const [activeSessionId, setActiveSessionId] = useState(null); + const [activeEnvId, setActiveEnvId] = useState("default"); + const [sessionRefreshNonce, setSessionRefreshNonce] = useState(0); + + // Sidebar collapse state (persisted in localStorage) + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + try { + return localStorage.getItem("gitpilot_sidebar_collapsed") === "true"; + } catch { + return false; + } + }); + + const toggleSidebar = useCallback(() => { + setSidebarCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem("gitpilot_sidebar_collapsed", String(next)); + } catch {} + return next; + }); + }, []); + + // Keyboard shortcut: Cmd/Ctrl + B to toggle sidebar + useEffect(() => { + const handler = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "b") { + e.preventDefault(); + toggleSidebar(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [toggleSidebar]); + + // ---- Derived `repo` — keeps all downstream consumers unchanged ---- + const repo = useMemo(() => { + const entry = contextRepos.find((r) => r.repoKey === activeRepoKey); + return entry?.repo || null; + }, [contextRepos, activeRepoKey]); + + const repoKey = activeRepoKey; + + // Convenient selectors + const currentRepoState = repoKey ? repoStateByKey[repoKey] : null; + + const defaultBranch = currentRepoState?.defaultBranch || repo?.default_branch || "main"; + const currentBranch = currentRepoState?.currentBranch || defaultBranch; + const sessionBranches = currentRepoState?.sessionBranches || []; + const lastExecution = currentRepoState?.lastExecution || null; + const pulseNonce = currentRepoState?.pulseNonce || 0; + const chatByBranch = currentRepoState?.chatByBranch || {}; + + // --------------------------------------------------------------------------- + // Multi-repo context management + // --------------------------------------------------------------------------- + const addRepoToContext = useCallback((r) => { + const key = makeRepoKey(r); + if (!key) return; + + setContextRepos((prev) => { + if (prev.some((e) => e.repoKey === key)) { + setActiveRepoKey(key); + return prev; + } + const entry = { repoKey: key, repo: r, branch: r.default_branch || "main" }; + return [...prev, entry]; + }); + + setActiveRepoKey(key); + setAddRepoOpen(false); + }, []); + + const removeRepoFromContext = useCallback((key) => { + setContextRepos((prev) => { + const next = prev.filter((e) => e.repoKey !== key); + setActiveRepoKey((curActive) => { + if (curActive === key) { + return next.length > 0 ? next[0].repoKey : null; + } + return curActive; + }); + return next; + }); + }, []); + + const clearAllContext = useCallback(() => { + setContextRepos([]); + setActiveRepoKey(null); + }, []); + + const handleContextBranchChange = useCallback((targetRepoKey, newBranch) => { + setContextRepos((prev) => + prev.map((e) => + e.repoKey === targetRepoKey ? { ...e, branch: newBranch } : e + ) + ); + + setRepoStateByKey((prev) => { + const cur = prev[targetRepoKey]; + if (!cur) return prev; + return { + ...prev, + [targetRepoKey]: { ...cur, currentBranch: newBranch }, + }; + }); + }, []); + + // Init / reconcile repo state when active repo changes + useEffect(() => { + if (!repoKey || !repo) return; + + setRepoStateByKey((prev) => { + const existing = prev[repoKey]; + const d = repo.default_branch || "main"; + + if (!existing) { + return { + ...prev, + [repoKey]: { + defaultBranch: d, + currentBranch: d, + sessionBranches: [], + lastExecution: null, + pulseNonce: 0, + chatByBranch: { + [d]: { messages: [], plan: null }, + }, + }, + }; + } + + const next = { ...existing }; + next.defaultBranch = d; + + if (!next.chatByBranch?.[d]) { + next.chatByBranch = { + ...(next.chatByBranch || {}), + [d]: { messages: [], plan: null }, + }; + } + + if (!next.currentBranch) next.currentBranch = d; + + return { ...prev, [repoKey]: next }; + }); + }, [repoKey, repo?.id, repo?.default_branch]); + + const showToast = (title, message) => { + setToast({ title, message }); + window.setTimeout(() => setToast(null), 5000); + }; + + // --------------------------------------------------------------------------- + // Session management — every chat is backed by a Session (Claude Code parity) + // --------------------------------------------------------------------------- + + const _creatingSessionRef = useRef(false); + + const [chatBySession, setChatBySession] = useState({}); + + // A session records its repository by full path *and* provider + stable id, + // so it reopens against the right provider and lists under the right repo. + const sessionPayload = (sessionName) => ({ + repo_full_name: repoFullName(repo), + provider: repoProvider(repo), + repo_id: repo && repo.id !== undefined ? String(repo.id) : undefined, + branch: currentBranch, + name: sessionName || undefined, + repos: contextRepos.map((e) => ({ + full_name: repoFullName(e.repo), + provider: repoProvider(e.repo), + repo_id: e.repo && e.repo.id !== undefined ? String(e.repo.id) : undefined, + branch: e.branch, + mode: e.repoKey === activeRepoKey ? "write" : "read", + })), + active_repo: repoFullName(repo), + }); + + const ensureSession = useCallback( + async (sessionName, seedMessages) => { + if (activeSessionId) return activeSessionId; + if (!repo) return null; + if (_creatingSessionRef.current) return null; + _creatingSessionRef.current = true; + + try { + const res = await authFetch("/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sessionPayload(sessionName)), + }); + + if (!res.ok) return null; + const data = await res.json(); + const newId = data.session_id; + + if (seedMessages && seedMessages.length > 0) { + setChatBySession((prev) => ({ + ...prev, + [newId]: { messages: seedMessages, plan: null }, + })); + } + + setActiveSessionId(newId); + setSessionRefreshNonce((n) => n + 1); + return newId; + } catch (err) { + console.warn("Failed to create session:", err); + return null; + } finally { + _creatingSessionRef.current = false; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeSessionId, repo, repoKey, currentBranch, contextRepos, activeRepoKey] + ); + + const handleNewSession = async () => { + setActiveSessionId(null); + + try { + const res = await authFetch("/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sessionPayload()), + }); + + if (!res.ok) return; + const data = await res.json(); + setActiveSessionId(data.session_id); + setSessionRefreshNonce((n) => n + 1); + showToast("Session Created", "New session started."); + } catch (err) { + console.warn("Failed to create session:", err); + } + }; + + /** + * Convert a backend Message object to the frontend chat UI shape. + * Backend: { role: "user|assistant|system", content: "...", timestamp, metadata } + * Frontend: { from: "user|ai", role: "user|assistant|system", content, answer, ... } + */ + const normalizeBackendMessage = (m) => { + const role = m.role || "assistant"; + const content = m.content || ""; + if (role === "user") { + return { from: "user", role: "user", content, text: content }; + } + if (role === "system") { + return { from: "ai", role: "system", content }; + } + // assistant + return { + from: "ai", + role: "assistant", + content, + answer: content, + // Preserve any structured metadata the backend stored (plan, diff, etc.) + ...(m.metadata && typeof m.metadata === "object" ? m.metadata : {}), + }; + }; + + /** + * Fetch persisted messages for a session from the backend. + * Returns an array of normalized frontend messages (ready for ChatPanel), + * or an empty array on failure. + */ + const fetchSessionMessages = useCallback(async (sessionId) => { + if (!sessionId) return []; + try { + const res = await authFetch(`/api/sessions/${sessionId}/messages`); + if (!res.ok) { + console.warn(`[fetchSessionMessages] ${res.status} for ${sessionId}`); + return []; + } + const data = await res.json(); + const backendMessages = Array.isArray(data.messages) ? data.messages : []; + return backendMessages.map(normalizeBackendMessage); + } catch (err) { + console.warn(`[fetchSessionMessages] Failed to fetch ${sessionId}:`, err); + return []; + } + }, []); + + /** + * Handle click on a session in the sidebar. + * + * Critical ordering: we must hydrate chatBySession BEFORE setting + * activeSessionId, because ChatPanel's session-sync useEffect reads + * sessionChatState only when sessionId changes (it does NOT depend on + * chatBySession to avoid prop/state loops). If we set activeSessionId + * first, ChatPanel would see an empty messages array, then our async + * hydration would complete but ChatPanel wouldn't re-sync. + */ + // Resolve the branch we should jump to when reopening a session. + // Preference order: + // 1. session.repos[i].branch for the active_repo (multi-repo) + // 2. session.branch (legacy single-repo field) + // Returns ``null`` when nothing is recorded. + const resolveSessionBranch = (session) => { + if (!session) return null; + if (Array.isArray(session.repos) && session.repos.length > 0) { + const target = + session.repos.find( + (r) => session.active_repo && r?.full_name === session.active_repo, + ) || session.repos[0]; + if (target?.branch) return target.branch; + } + return session.branch || null; + }; + + // Probe whether a branch still exists on the repository's provider. We + // deliberately reuse the tree endpoint instead of adding a new one — a + // 200 means the ref resolves, anything else (most importantly 404) + // means the branch is gone or otherwise unreachable. Failure + // degrades to "branch unknown" so a transient network blip falls + // back gracefully rather than misleading the user. + const probeBranchExists = async (targetRepo, branch) => { + if (!targetRepo || !branch) return false; + try { + const res = await authFetch(treeUrl(targetRepo, branch)); + return res.ok; + } catch { + return false; + } + }; + + const handleSelectSession = useCallback(async (session) => { + // 1. Fetch persisted messages first + const messages = await fetchSessionMessages(session.id); + + // 2. Seed the chat cache (ChatPanel will read this via sessionChatState) + setChatBySession((prev) => ({ + ...prev, + [session.id]: { + ...(prev[session.id] || { plan: null }), + messages, + }, + })); + + // 3. NOW activate the session — ChatPanel's sync effect will read + // the hydrated messages from chatBySession[session.id] + setActiveSessionId(session.id); + + // 4. Jump to the branch this session last published to, but verify + // it still exists on GitHub first. When the branch was deleted + // (rebased away, merged-and-pruned, …) fall back to the + // repository's default branch and tell the user what happened — + // silently landing on the default would mask data loss. + const target = resolveSessionBranch(session); + if (target && target !== currentBranch) { + // The sidebar only lists the active repository's sessions, so the + // session's repository is the active one (same path and provider). + const exists = await probeBranchExists(repo, target); + if (exists) { + handleBranchChange(target); + } else { + const fallback = defaultBranch || "main"; + showToast( + "Branch not found", + `'${target}' was not found on ${providerLabel(repo)}. Switched to ${fallback}.`, + ); + if (fallback !== currentBranch) handleBranchChange(fallback); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchSessionMessages, currentBranch, defaultBranch, repo]); + + const handleDeleteSession = useCallback( + (deletedId) => { + if (deletedId === activeSessionId) { + setActiveSessionId(null); + + setChatBySession((prev) => { + const next = { ...prev }; + delete next[deletedId]; + return next; + }); + + if (repoKey) { + setRepoStateByKey((prev) => { + const cur = prev[repoKey]; + if (!cur) return prev; + const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch; + return { + ...prev, + [repoKey]: { + ...cur, + chatByBranch: { + ...(cur.chatByBranch || {}), + [branchKey]: { messages: [], plan: null }, + }, + }, + }; + }); + } + } + }, + [activeSessionId, repoKey, defaultBranch] + ); + + // --------------------------------------------------------------------------- + // Chat persistence helpers + // --------------------------------------------------------------------------- + const updateChatForCurrentBranch = (patch) => { + if (!repoKey) return; + + setRepoStateByKey((prev) => { + const cur = prev[repoKey]; + if (!cur) return prev; + + const branchKey = cur.currentBranch || cur.defaultBranch || defaultBranch; + + const existing = cur.chatByBranch?.[branchKey] || { + messages: [], + plan: null, + }; + + return { + ...prev, + [repoKey]: { + ...cur, + chatByBranch: { + ...(cur.chatByBranch || {}), + [branchKey]: { ...existing, ...patch }, + }, + }, + }; + }); + }; + + const currentChatState = useMemo(() => { + const b = currentBranch || defaultBranch; + return chatByBranch[b] || { messages: [], plan: null }; + }, [chatByBranch, currentBranch, defaultBranch]); + + const sessionChatState = useMemo(() => { + if (!activeSessionId) { + return currentChatState; + } + return chatBySession[activeSessionId] || { messages: [], plan: null }; + }, [activeSessionId, chatBySession, currentChatState]); + + const updateSessionChat = (patch) => { + if (activeSessionId) { + setChatBySession((prev) => ({ + ...prev, + [activeSessionId]: { + ...(prev[activeSessionId] || { messages: [], plan: null }), + ...patch, + }, + })); + } else { + updateChatForCurrentBranch(patch); + } + }; + + // --------------------------------------------------------------------------- + // Branch change (manual — for active repo) + // --------------------------------------------------------------------------- + const handleBranchChange = (nextBranch) => { + if (!repoKey) return; + if (!nextBranch || nextBranch === currentBranch) return; + + setRepoStateByKey((prev) => { + const cur = prev[repoKey]; + if (!cur) return prev; + + const nextState = { ...cur, currentBranch: nextBranch }; + + if (nextBranch === cur.defaultBranch) { + nextState.chatByBranch = { + ...nextState.chatByBranch, + [nextBranch]: { messages: [], plan: null }, + }; + } + + return { ...prev, [repoKey]: nextState }; + }); + + setContextRepos((prev) => + prev.map((e) => + e.repoKey === repoKey ? { ...e, branch: nextBranch } : e + ) + ); + + if (nextBranch === defaultBranch) { + showToast("New Session", `Switched to ${defaultBranch}. Chat cleared.`); + } else { + showToast("Context Switched", `Now viewing ${nextBranch}.`); + } + }; + + // --------------------------------------------------------------------------- + // Execution complete + // --------------------------------------------------------------------------- + const handleExecutionComplete = ({ + branch, + mode, + commit_url, + completionMsg, + sourceBranch, + }) => { + if (!repoKey || !branch) return; + + // Clear the session-keyed chat cache's ``plan`` AND append the + // completion message synchronously, before any branch change can + // trigger ChatPanel's session-sync effect. Two bugs need to be + // fixed in the same write: + // + // 1. Stale plan: without clearing, the sync effect re-reads the + // old approved plan and restores the Approve & execute / Reject + // plan buttons, enabling accidental double-execution. + // + // 2. Wiped completion: in hard-switch mode the sync effect runs + // BEFORE the persistence effect (declared earlier in + // ChatPanel), so it overwrites local ``messages`` with + // ``sessionChatState.messages`` — which doesn't yet contain + // completionMsg. The user's "Answer / Execution Log" block + // then vanishes from the session view. + // + // By appending normalizedCompletion here, sessionChatState already + // carries the completion when the sync effect reads it. No + // duplicate is introduced: local ``messages`` already has the same + // entry, so the subsequent persistence pass is a no-op write. + if (activeSessionId) { + const normalizedCompletion = + completionMsg && + (completionMsg.answer || completionMsg.content || completionMsg.executionLog) + ? { + from: completionMsg.from || "ai", + role: completionMsg.role || "assistant", + answer: completionMsg.answer, + content: completionMsg.content, + executionLog: completionMsg.executionLog, + diff: completionMsg.diff, + } + : null; + setChatBySession((prev) => { + const existing = prev[activeSessionId]; + if (!existing) return prev; + const noPlanChange = existing.plan == null; + if (noPlanChange && !normalizedCompletion) return prev; + return { + ...prev, + [activeSessionId]: { + ...existing, + messages: normalizedCompletion + ? [...(existing.messages || []), normalizedCompletion] + : existing.messages, + plan: null, + }, + }; + }); + } + + setRepoStateByKey((prev) => { + const cur = + prev[repoKey] || { + defaultBranch, + currentBranch: defaultBranch, + sessionBranches: [], + lastExecution: null, + pulseNonce: 0, + chatByBranch: { [defaultBranch]: { messages: [], plan: null } }, + }; + + const next = { ...cur }; + next.lastExecution = { mode, branch, ts: Date.now() }; + + if (!next.chatByBranch) next.chatByBranch = {}; + + const prevBranchKey = + sourceBranch || cur.currentBranch || cur.defaultBranch || defaultBranch; + + const successSystemMsg = { + role: "system", + isSuccess: true, + link: commit_url, + content: + mode === "hard-switch" + ? `🌱 **Session Started:** Created branch \`${branch}\`.` + : `✅ **Update Published:** Commits pushed to \`${branch}\`.`, + }; + + const normalizedCompletion = + completionMsg && + (completionMsg.answer || completionMsg.content || completionMsg.executionLog) + ? { + from: completionMsg.from || "ai", + role: completionMsg.role || "assistant", + answer: completionMsg.answer, + content: completionMsg.content, + executionLog: completionMsg.executionLog, + } + : null; + + if (mode === "hard-switch") { + next.sessionBranches = uniq([...(next.sessionBranches || []), branch]); + next.currentBranch = branch; + next.pulseNonce = (next.pulseNonce || 0) + 1; + + const existingTargetChat = next.chatByBranch[branch]; + const isExistingSession = + existingTargetChat && (existingTargetChat.messages || []).length > 0; + + if (isExistingSession) { + const appended = [ + ...(existingTargetChat.messages || []), + ...(normalizedCompletion ? [normalizedCompletion] : []), + successSystemMsg, + ]; + + next.chatByBranch[branch] = { + ...existingTargetChat, + messages: appended, + plan: null, + }; + } else { + const prevChat = + (cur.chatByBranch && cur.chatByBranch[prevBranchKey]) || { + messages: [], + plan: null, + }; + + next.chatByBranch[branch] = { + messages: [ + ...(prevChat.messages || []), + ...(normalizedCompletion ? [normalizedCompletion] : []), + successSystemMsg, + ], + plan: null, + }; + } + + if (!next.chatByBranch[next.defaultBranch]) { + next.chatByBranch[next.defaultBranch] = { messages: [], plan: null }; + } + } else if (mode === "sticky") { + next.currentBranch = cur.currentBranch || branch; + + const targetChat = next.chatByBranch[branch] || { messages: [], plan: null }; + + next.chatByBranch[branch] = { + messages: [ + ...(targetChat.messages || []), + ...(normalizedCompletion ? [normalizedCompletion] : []), + successSystemMsg, + ], + plan: null, + }; + } + + return { ...prev, [repoKey]: next }; + }); + + if (mode === "hard-switch") { + showToast("Context Switched", `Active on ${branch}.`); + } else { + showToast("Changes Committed", `Updated ${branch}.`); + } + }; + + // --------------------------------------------------------------------------- + // Auth & startup render + // --------------------------------------------------------------------------- + useEffect(() => { + checkAuthentication(); + }, []); + + const checkAuthentication = async () => { + setStartupPhase("booting"); + setStartupStatusMessage("Starting application..."); + setStartupDetailMessage( + "Initializing authentication, provider, and workspace context." + ); + + try { + setStartupPhase("checking-backend"); + setStartupStatusMessage("Connecting to backend..."); + setStartupDetailMessage( + "Preparing your workspace. First launch may take a few seconds." + ); + + // Single-source-of-truth init: combines /api/status + /api/auth/status + // in one request. Runs exactly once per page load (StrictMode-safe). + const initResult = await initApp(); + const status = initResult.status; + if (status) { + setStartupStatusSnapshot(status); + setAdminStatus(status); + } + + // Provider tokens are held server-side; the browser only has a session + // (HttpOnly cookie, or a header token on cross-site deployments). + // Tokens an earlier version left in localStorage are migrated once. + setStartupPhase("validating-auth"); + setStartupStatusMessage("Validating authentication..."); + setStartupDetailMessage("Restoring your session and confirming repository access."); + try { + if (hasLegacyTokens()) await migrateLegacyTokens(); + const info = await fetchSession(); + if (info && info.authenticated) { + setStartupPhase("restoring-session"); + setStartupStatusMessage("Restoring workspace..."); + setStartupDetailMessage( + "Loading user profile, reconnecting provider state, and preparing the workspace." + ); + setSessionInfo(info); + setIsAuthenticated(true); + setUserInfo(userFromSession(info)); + setSignedInHint(true); + setIsLoading(false); + return; + } + } catch (err) { + console.warn("Session check failed:", err); + } + setSignedInHint(false); + + setStartupPhase("ready"); + setStartupStatusMessage("Preparing sign-in..."); + setStartupDetailMessage( + "GitPilot is ready. Please authenticate to continue." + ); + + setIsAuthenticated(false); + setIsLoading(false); + } catch (err) { + console.error(err); + setStartupPhase("fallback"); + setStartupStatusMessage("Starting application..."); + setStartupDetailMessage( + "Continuing with basic startup while backend status is still loading." + ); + setIsAuthenticated(false); + setIsLoading(false); + } + }; + + const handleAuthenticated = async (session) => { + // Re-read the session so the UI reflects every connected provider (a + // GitLab sign-in on top of a GitHub one keeps both). + let info = null; + try { info = await fetchSession(); } catch { /* use what the sign-in returned */ } + setSessionInfo(info); + setIsAuthenticated(true); + setUserInfo(userFromSession(info) || session?.user || null); + setSignedInHint(true); + }; + + const handleLogout = async () => { + // Revokes the server session and its provider grants, then clears the + // local hint; failures still sign the browser out. + await logoutSession(); + setSignedInHint(false); + setSessionInfo(null); + setIsAuthenticated(false); + setUserInfo(null); + clearAllContext(); + }; + + // In-workspace "Connect GitHub / GitLab", and every OAuth return to /auth: + // the sign-in page links the provider to the current session (or starts + // one), then returns to the workspace. This renders even when authenticated + // (unlike the sign-in screen below), so a provider can be added to an + // existing session. + if (typeof window !== "undefined") { + const qs = new URLSearchParams(window.location.search); + const onAuth = window.location.pathname.startsWith("/auth"); + const connect = ["github", "gitlab"].includes(qs.get("connect")); + const oauthReturn = !!qs.get("state") && !!(qs.get("code") || qs.get("error")); + if (onAuth && (connect || oauthReturn)) { + return ( + + ); + } + } + + // Public pages must render instantly — never gated behind backend startup. + // (A marketing landing page that shows "Connecting to backend…" is bad for + // users and for SEO crawlers.) Only a returning GitHub-token session, which + // startup is about to validate into the workspace, defers to the loader. + if (!isAuthenticated) { + const path = typeof window !== "undefined" ? window.location.pathname : "/"; + const hasGithubToken = + typeof window !== "undefined" && (hasSignedInHint() || hasLegacyTokens()); + + // The auth/device-flow screen lives at "/auth"; it manages its own backend + // readiness via the backendReady prop, so render it immediately. + if (path.startsWith("/auth")) { + return ( + + ); + } + + // Public landing page at "/". Render instantly for anonymous visitors; + // only show the loader if a stored GitHub session is still resolving. + if (!hasGithubToken || !isLoading) { + return ; + } + } + + if (isLoading) { + return ( + + ); + } + + const hasContext = contextRepos.length > 0; + + return ( +
+
+ + +
+ {activePage === "admin" && ( +
+
+ {["overview", "account", "providers", "workspace-modes", "integrations", "mcp-servers", "sandbox", "sessions", "skills", "security", "advanced"].map((tab) => ( + + ))} +
+ + {adminTab === "overview" && ( +
+
+
Server
+
+ {adminStatus?.server_ready ? "Connected" : "Checking..."} +
+
127.0.0.1:8000
+
+ +
+
Provider
+
+ {adminStatus?.provider?.name || "Loading..."} +
+
+ {adminStatus?.provider?.configured + ? `${adminStatus.provider.model || "Ready"}` + : "Not configured"} +
+
+ +
+
Workspace Modes
+
+ Folder: {adminStatus?.workspace?.folder_mode_available ? "Yes" : "—"} +
+
+ Local Git: {adminStatus?.workspace?.local_git_available ? "Yes" : "—"} +
+
+ GitHub: {adminStatus?.workspace?.github_mode_available ? "Yes" : "Optional"} +
+
+ +
+
GitHub
+
+ {adminStatus?.github?.connected ? "Connected" : "Optional"} +
+
+ {adminStatus?.github?.username || "Not linked"} +
+
+ +
+
Sessions
+
—
+
+ +
+
Get Started
+ +
+
+ )} + + {adminTab === "account" && ( + + )} + + {adminTab === "providers" && ( +
+

AI Providers

+ +
+ )} + + {adminTab === "workspace-modes" && ( + { + setActiveSessionId(result.session_id); + setSessionRefreshNonce((n) => n + 1); + setActivePage("workspace"); + }} + /> + )} + + {adminTab === "integrations" && ( + { + setSessionInfo(info); + if (info && !info.authenticated) handleLogout(); + else if (info) setUserInfo(userFromSession(info)); + }} + /> + )} + + {adminTab === "mcp-servers" && ( + + )} + + {adminTab === "sandbox" && ( + + )} + + {adminTab === "security" && ( + + )} + + {adminTab === "sessions" && ( + { + handleSelectSession(s); + setActivePage("workspace"); + }} + /> + )} + + {adminTab === "skills" && } + + {adminTab === "advanced" && ( + setSettingsOpen(true)} + /> + )} +
+ )} + + {activePage === "flow" && } + + {activePage === "workspace" && + (repo ? ( +
+ setAddRepoOpen(true)} + onBranchChange={handleContextBranchChange} + /> + +
+ + +
+
+ GitPilot chat +
+ + +
+
+
+ ) : ( +
+
🤖
+

Select a repository

+

Select a repo to begin agentic workflow.

+
+ ))} +
+
+ +
+ + {repo && ( + setSettingsOpen(false)} + activeEnvId={activeEnvId} + onEnvChange={setActiveEnvId} + /> + )} + + setAddRepoOpen(false)} + excludeKeys={contextRepos.map((e) => e.repoKey)} + /> + + setAboutOpen(false)} + /> + + {toast && ( +
+
{toast.title}
+
{toast.message}
+
+ )} + + +
+ ); +} \ No newline at end of file diff --git a/frontend/components/AboutModal.jsx b/frontend/components/AboutModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e80dd1a98dd3d6caa062310469b6135ec24800dc --- /dev/null +++ b/frontend/components/AboutModal.jsx @@ -0,0 +1,488 @@ +// frontend/components/AboutModal.jsx +import React, { useEffect, useCallback, useState } from "react"; +import { apiUrl, safeFetchJSON } from "../utils/api.js"; + +/** + * AboutModal — "About GitPilot" dialog shown from the user menu. + * + * Enterprise design goals: + * - Prominent brand mark matching docs/logo.svg (orange ring + GP monogram) + * - Clear identity: name, tagline, version (frontend + backend) + * - Credits the creator (Ruslan Magana Vsevolodovna) as a link to GitHub + * - Open-source positioning: Apache 2.0 license + GitHub repo link + * - Action row: View on GitHub, Report Issue, Documentation + * - Accessible: role="dialog", aria-modal, aria-labelledby, Escape to close, + * focus trap via initial focus on close button + * - Brand palette: #D95C3D accent, #1C1C1F card, #27272A border, #EDEDED text + */ + +const FRONTEND_VERSION = + typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.1.5"; + +export default function AboutModal({ isOpen, onClose }) { + const [backendVersion, setBackendVersion] = useState(null); + + // Fetch backend version when opened + useEffect(() => { + if (!isOpen) return; + let cancelled = false; + (async () => { + try { + const data = await safeFetchJSON(apiUrl("/api/ping"), { timeout: 4000 }); + if (!cancelled) { + setBackendVersion(data?.version || null); + } + } catch { + if (!cancelled) setBackendVersion(null); + } + })(); + return () => { + cancelled = true; + }; + }, [isOpen]); + + // Escape to close + useEffect(() => { + if (!isOpen) return; + const handleKey = (e) => { + if (e.key === "Escape") onClose?.(); + }; + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + // Lock body scroll while open + useEffect(() => { + if (!isOpen) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = prev; + }; + }, [isOpen]); + + const handleBackdropClick = useCallback( + (e) => { + if (e.target === e.currentTarget) onClose?.(); + }, + [onClose] + ); + + if (!isOpen) return null; + + return ( +
+
+ {/* Close button */} + + + {/* Hero: brand mark + name */} +
+ + +

+ GitPilot +

+
+ Enterprise Workspace Copilot +
+ +
+
+
+ + {/* Body */} +
+

+ An agentic AI coding companion for your repositories. Ask, plan, + code, and ship — with multi-LLM support, security scanning, and + VS Code integration. +

+ + {/* Meta table */} +
+ + + + + (e.currentTarget.style.textDecoration = "underline") + } + onMouseLeave={(e) => + (e.currentTarget.style.textDecoration = "none") + } + > + Ruslan Magana Vsevolodovna + + } + isLast + /> +
+
+ + {/* Action row */} +
+ } + label="GitHub" + /> + } + label="Docs" + /> + } + label="Report" + /> +
+ + {/* Footer */} +
+ © {new Date().getFullYear()} GitPilot · Made with care for + developers everywhere +
+
+ + +
+ ); +} + +// ── Brand mark (mirrors docs/logo.svg) ────────────────────────────── +function BrandMark() { + return ( +