diff --git a/.apm/instructions/dev-commands.instructions.md b/.apm/instructions/dev-commands.instructions.md new file mode 100644 index 000000000..af1cafd40 --- /dev/null +++ b/.apm/instructions/dev-commands.instructions.md @@ -0,0 +1,22 @@ +--- +description: "Common dev commands for database migration, linting, and testing" +applyTo: "**" +--- + +### Database migration + +Run migrations: `go run ./cmd/sippy migrate --database-dsn $SIPPY_DATABASE_DSN` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: `postgresql://postgres:password@localhost:5432/postgres` + +### Linting + +Run lint: `CI=true make lint` + +`CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +### Testing + +Run unit tests: `make test` + +This runs Go tests via gotestsum and sippy-ng Jest tests. diff --git a/.apm/instructions/mcp.instructions.md b/.apm/instructions/mcp.instructions.md new file mode 100644 index 000000000..d91d16f42 --- /dev/null +++ b/.apm/instructions/mcp.instructions.md @@ -0,0 +1,8 @@ +--- +description: "MCP server (sippy-dev) for AI-callable dev tasks" +applyTo: "mcp/**" +--- + +Shared MCP server for AI-callable dev tasks (migrate, serve, lint, test, e2e). Configuration, tool list, logs, and extension notes: **[README.md](../../mcp/README.md)**. + +When adding or modifying MCP tools, follow existing patterns in `server.py` (subprocess, `_repo_path`, `_ensure_dev_log_dir`, credentials helpers). Restart the MCP server after changes. diff --git a/.apm/prompts/sippy-dev-app.prompt.md b/.apm/prompts/sippy-dev-app.prompt.md new file mode 100644 index 000000000..4046af10e --- /dev/null +++ b/.apm/prompts/sippy-dev-app.prompt.md @@ -0,0 +1,12 @@ +--- +description: "Start both Sippy backend and frontend dev servers" +--- + +# Sippy dev — backend + frontend + +Start the full local Sippy stack using two MCP tool calls (server: **`sippy-dev`**). Run them in order — backend first, then frontend. + +1. **`sippy_serve`** — pass **`bigquery_credentials_file`** when `SIPPY_BIGQUERY_CREDENTIALS_FILE` / `GOOGLE_APPLICATION_CREDENTIALS` are not set. +2. **`sippy_ng_start`** + +Each tool returns listen hints (typically **8080** / **3000**) and log paths. If a tool reports already running, ask the user if they want to restart it. If yes, call the tool again with **`restart=True`**. diff --git a/.apm/prompts/sippy-dev-frontend.prompt.md b/.apm/prompts/sippy-dev-frontend.prompt.md new file mode 100644 index 000000000..3574586dd --- /dev/null +++ b/.apm/prompts/sippy-dev-frontend.prompt.md @@ -0,0 +1,13 @@ +--- +description: "Start the sippy-ng React dev server via the sippy-dev MCP tool" +--- + +# Sippy dev — frontend (sippy-ng) + +Use the **`sippy_ng_start`** MCP tool (server: **`sippy-dev`**). Do not run `npm start` in `sippy-ng` manually — the MCP tool handles background process management, log routing, and duplicate detection. + +**`open_browser`** defaults to **`false`**. Typical URL: **`http://127.0.0.1:3000`**. Log: **`sippy-dev-logs/sippy_ng_start.log`**. + +If the dev server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**. + +See **`mcp/server.py`** for all parameters. diff --git a/.apm/prompts/sippy-dev-migrate.prompt.md b/.apm/prompts/sippy-dev-migrate.prompt.md new file mode 100644 index 000000000..6798f8cf7 --- /dev/null +++ b/.apm/prompts/sippy-dev-migrate.prompt.md @@ -0,0 +1,17 @@ +--- +description: "Run Sippy PostgreSQL schema migration" +--- + +# Sippy dev — migrate + +Run the migration command directly: + +```bash +go run ./cmd/sippy migrate --database-dsn "$SIPPY_DATABASE_DSN" +``` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: + +```bash +go run ./cmd/sippy migrate --database-dsn "postgresql://postgres:password@localhost:5432/postgres" +``` diff --git a/.apm/prompts/sippy-dev-regression-cache.prompt.md b/.apm/prompts/sippy-dev-regression-cache.prompt.md new file mode 100644 index 000000000..243d8f9ba --- /dev/null +++ b/.apm/prompts/sippy-dev-regression-cache.prompt.md @@ -0,0 +1,11 @@ +--- +description: "Run the Sippy regression-cache loader (BigQuery + Redis + DB)" +--- + +# Sippy dev — regression-cache + +Use the **`regression_cache`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy load --loader regression-cache` manually — the MCP tool handles credentials, logging, and timeouts. + +**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set. + +See **`mcp/server.py`** for all parameters. Log: **`sippy-dev-logs/regression_cache.log`**. Typical duration is many minutes. diff --git a/.apm/prompts/sippy-dev-serve.prompt.md b/.apm/prompts/sippy-dev-serve.prompt.md new file mode 100644 index 000000000..1a875d768 --- /dev/null +++ b/.apm/prompts/sippy-dev-serve.prompt.md @@ -0,0 +1,13 @@ +--- +description: "Start the Sippy HTTP API server via the sippy-dev MCP tool" +--- + +# Sippy dev — serve + +Use the **`sippy_serve`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy serve` manually — the MCP tool handles background process management, log routing, and duplicate detection. + +**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set. + +If the server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**. + +See **`mcp/server.py`** for all parameters. Typical listen address: **`:8080`**. Log: **`sippy-dev-logs/sippy_serve.log`**. diff --git a/.apm/prompts/sippy-dev-setup.prompt.md b/.apm/prompts/sippy-dev-setup.prompt.md new file mode 100644 index 000000000..e9dfa9c8c --- /dev/null +++ b/.apm/prompts/sippy-dev-setup.prompt.md @@ -0,0 +1,113 @@ +--- +description: "Set up the Sippy devcontainer (Podman, env, GCP auth)" +--- + +# Sippy dev — devcontainer setup + +Interactive setup for the Sippy devcontainer. Automates what can be detected, prompts for what can't. + +## Workflow + +### 1. Detect OS + +```bash +uname -s +``` + +- **Darwin** = macOS +- **Linux** = Linux + +### 2. Check prerequisites + +Verify each tool is installed. Report any that are missing and stop. + +```bash +command -v podman +command -v devcontainer +``` + +If `devcontainer` is missing, tell the user: `npm install -g @devcontainers/cli` + +### 3. macOS: Podman machine + +If macOS, check if podman machine is running: + +```bash +podman machine info +``` + +If not initialized or not running, run: + +```bash +podman machine init # only if no machine exists +podman machine start +``` + +### 4. Linux: Podman socket + +If Linux, check if the socket is active: + +```bash +systemctl --user is-active podman.socket +``` + +If not active, run: + +```bash +systemctl --user enable --now podman.socket +``` + +Also check for `podman-docker`: + +```bash +command -v docker +``` + +If missing, suggest installing `podman-docker` (dnf or apt depending on `/etc/os-release`). + +### 5. Environment file + +Check if `.devcontainer/.env` exists: + +```bash +test -f .devcontainer/.env +``` + +If missing, copy from the example: + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` + +Then read `.devcontainer/.env` and check for empty required values. If any are blank (e.g. `ANTHROPIC_VERTEX_PROJECT_ID`), tell the user which values need to be filled in and ask them to edit `.devcontainer/.env` and let you know when they're done. **Do not** ask for the values directly or write to the file yourself — the user should edit it. Wait for them to confirm before continuing. + +### 6. Start the container + +Determine the right command based on OS: + +- **macOS**: `devcontainer up --workspace-folder . --docker-path podman` +- **Linux** (with `podman-docker`): `devcontainer up --workspace-folder .` +- **Linux** (without `podman-docker`): `devcontainer up --workspace-folder . --docker-path podman` + +Run it. This triggers `init-services.sh` (PostgreSQL + Redis) and `post-create.sh` (Go tools, npm, MCP venv). + +### 7. GCP authentication + +GCP credentials are mounted from the host's `~/.config/gcloud` directory. If the user hasn't authenticated on the host yet, tell them to run on the host (not inside the container): + +```bash +gcloud auth application-default login +``` + +The credentials will be available inside the container automatically after restart. + +### 8. Summary + +Print a summary of what was set up: + +- Container status +- PostgreSQL: `localhost:5432` +- Redis: `localhost:6379` +- API server: `localhost:8080` (start with `/sippy-dev-serve`) +- React dev server: `localhost:3000` (start with `/sippy-dev-frontend`) +- GCP auth status diff --git a/.apm/prompts/sippy-dev-tests.prompt.md b/.apm/prompts/sippy-dev-tests.prompt.md new file mode 100644 index 000000000..b24b50c7e --- /dev/null +++ b/.apm/prompts/sippy-dev-tests.prompt.md @@ -0,0 +1,25 @@ +--- +description: "Run lint, unit tests, and e2e in order (full local CI suite)" +--- + +# Sippy dev — full test suite + +Run these three steps in order. Stop if any step fails. + +1. **Lint** — run directly: + + ```bash + CI=true make lint + ``` + + `CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +2. **Unit tests** — run directly: + + ```bash + make test + ``` + +3. **E2e** — use the **`run_e2e`** MCP tool (server: **`sippy-dev`**). Pass **`bigquery_credentials_file`** when cred env vars are unset. E2e requires Podman/Docker and **does not work inside the devcontainer** — run it on the host. + +E2e log: **`sippy-dev-logs/run_e2e.log`**. Timeouts: **`mcp/server.py`**. diff --git a/.claude/commands/sippy-dev-app.md b/.claude/commands/sippy-dev-app.md new file mode 100644 index 000000000..85d2be678 --- /dev/null +++ b/.claude/commands/sippy-dev-app.md @@ -0,0 +1,12 @@ +--- +description: Start both Sippy backend and frontend dev servers +--- + +# Sippy dev — backend + frontend + +Start the full local Sippy stack using two MCP tool calls (server: **`sippy-dev`**). Run them in order — backend first, then frontend. + +1. **`sippy_serve`** — pass **`bigquery_credentials_file`** when `SIPPY_BIGQUERY_CREDENTIALS_FILE` / `GOOGLE_APPLICATION_CREDENTIALS` are not set. +2. **`sippy_ng_start`** + +Each tool returns listen hints (typically **8080** / **3000**) and log paths. If a tool reports already running, ask the user if they want to restart it. If yes, call the tool again with **`restart=True`**. \ No newline at end of file diff --git a/.claude/commands/sippy-dev-frontend.md b/.claude/commands/sippy-dev-frontend.md new file mode 100644 index 000000000..53b16d603 --- /dev/null +++ b/.claude/commands/sippy-dev-frontend.md @@ -0,0 +1,13 @@ +--- +description: Start the sippy-ng React dev server via the sippy-dev MCP tool +--- + +# Sippy dev — frontend (sippy-ng) + +Use the **`sippy_ng_start`** MCP tool (server: **`sippy-dev`**). Do not run `npm start` in `sippy-ng` manually — the MCP tool handles background process management, log routing, and duplicate detection. + +**`open_browser`** defaults to **`false`**. Typical URL: **`http://127.0.0.1:3000`**. Log: **`sippy-dev-logs/sippy_ng_start.log`**. + +If the dev server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**. + +See **`mcp/server.py`** for all parameters. \ No newline at end of file diff --git a/.claude/commands/sippy-dev-migrate.md b/.claude/commands/sippy-dev-migrate.md new file mode 100644 index 000000000..2430ba319 --- /dev/null +++ b/.claude/commands/sippy-dev-migrate.md @@ -0,0 +1,17 @@ +--- +description: Run Sippy PostgreSQL schema migration +--- + +# Sippy dev — migrate + +Run the migration command directly: + +```bash +go run ./cmd/sippy migrate --database-dsn "$SIPPY_DATABASE_DSN" +``` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: + +```bash +go run ./cmd/sippy migrate --database-dsn "postgresql://postgres:password@localhost:5432/postgres" +``` \ No newline at end of file diff --git a/.claude/commands/sippy-dev-regression-cache.md b/.claude/commands/sippy-dev-regression-cache.md new file mode 100644 index 000000000..0ca7f4d75 --- /dev/null +++ b/.claude/commands/sippy-dev-regression-cache.md @@ -0,0 +1,11 @@ +--- +description: Run the Sippy regression-cache loader (BigQuery + Redis + DB) +--- + +# Sippy dev — regression-cache + +Use the **`regression_cache`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy load --loader regression-cache` manually — the MCP tool handles credentials, logging, and timeouts. + +**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set. + +See **`mcp/server.py`** for all parameters. Log: **`sippy-dev-logs/regression_cache.log`**. Typical duration is many minutes. \ No newline at end of file diff --git a/.claude/commands/sippy-dev-serve.md b/.claude/commands/sippy-dev-serve.md new file mode 100644 index 000000000..14c7eb8cd --- /dev/null +++ b/.claude/commands/sippy-dev-serve.md @@ -0,0 +1,13 @@ +--- +description: Start the Sippy HTTP API server via the sippy-dev MCP tool +--- + +# Sippy dev — serve + +Use the **`sippy_serve`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy serve` manually — the MCP tool handles background process management, log routing, and duplicate detection. + +**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set. + +If the server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**. + +See **`mcp/server.py`** for all parameters. Typical listen address: **`:8080`**. Log: **`sippy-dev-logs/sippy_serve.log`**. \ No newline at end of file diff --git a/.claude/commands/sippy-dev-setup.md b/.claude/commands/sippy-dev-setup.md new file mode 100644 index 000000000..0e4f11dd4 --- /dev/null +++ b/.claude/commands/sippy-dev-setup.md @@ -0,0 +1,113 @@ +--- +description: Set up the Sippy devcontainer (Podman, env, GCP auth) +--- + +# Sippy dev — devcontainer setup + +Interactive setup for the Sippy devcontainer. Automates what can be detected, prompts for what can't. + +## Workflow + +### 1. Detect OS + +```bash +uname -s +``` + +- **Darwin** = macOS +- **Linux** = Linux + +### 2. Check prerequisites + +Verify each tool is installed. Report any that are missing and stop. + +```bash +command -v podman +command -v devcontainer +``` + +If `devcontainer` is missing, tell the user: `npm install -g @devcontainers/cli` + +### 3. macOS: Podman machine + +If macOS, check if podman machine is running: + +```bash +podman machine info +``` + +If not initialized or not running, run: + +```bash +podman machine init # only if no machine exists +podman machine start +``` + +### 4. Linux: Podman socket + +If Linux, check if the socket is active: + +```bash +systemctl --user is-active podman.socket +``` + +If not active, run: + +```bash +systemctl --user enable --now podman.socket +``` + +Also check for `podman-docker`: + +```bash +command -v docker +``` + +If missing, suggest installing `podman-docker` (dnf or apt depending on `/etc/os-release`). + +### 5. Environment file + +Check if `.devcontainer/.env` exists: + +```bash +test -f .devcontainer/.env +``` + +If missing, copy from the example: + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` + +Then read `.devcontainer/.env` and check for empty required values. If any are blank (e.g. `ANTHROPIC_VERTEX_PROJECT_ID`), tell the user which values need to be filled in and ask them to edit `.devcontainer/.env` and let you know when they're done. **Do not** ask for the values directly or write to the file yourself — the user should edit it. Wait for them to confirm before continuing. + +### 6. Start the container + +Determine the right command based on OS: + +- **macOS**: `devcontainer up --workspace-folder . --docker-path podman` +- **Linux** (with `podman-docker`): `devcontainer up --workspace-folder .` +- **Linux** (without `podman-docker`): `devcontainer up --workspace-folder . --docker-path podman` + +Run it. This triggers `init-services.sh` (PostgreSQL + Redis) and `post-create.sh` (Go tools, npm, MCP venv). + +### 7. GCP authentication + +GCP credentials are mounted from the host's `~/.config/gcloud` directory. If the user hasn't authenticated on the host yet, tell them to run on the host (not inside the container): + +```bash +gcloud auth application-default login +``` + +The credentials will be available inside the container automatically after restart. + +### 8. Summary + +Print a summary of what was set up: + +- Container status +- PostgreSQL: `localhost:5432` +- Redis: `localhost:6379` +- API server: `localhost:8080` (start with `/sippy-dev-serve`) +- React dev server: `localhost:3000` (start with `/sippy-dev-frontend`) +- GCP auth status \ No newline at end of file diff --git a/.claude/commands/sippy-dev-tests.md b/.claude/commands/sippy-dev-tests.md new file mode 100644 index 000000000..46eaf431b --- /dev/null +++ b/.claude/commands/sippy-dev-tests.md @@ -0,0 +1,25 @@ +--- +description: Run lint, unit tests, and e2e in order (full local CI suite) +--- + +# Sippy dev — full test suite + +Run these three steps in order. Stop if any step fails. + +1. **Lint** — run directly: + + ```bash + CI=true make lint + ``` + + `CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +2. **Unit tests** — run directly: + + ```bash + make test + ``` + +3. **E2e** — use the **`run_e2e`** MCP tool (server: **`sippy-dev`**). Pass **`bigquery_credentials_file`** when cred env vars are unset. E2e requires Podman/Docker and **does not work inside the devcontainer** — run it on the host. + +E2e log: **`sippy-dev-logs/run_e2e.log`**. Timeouts: **`mcp/server.py`**. \ No newline at end of file diff --git a/.claude/rules/dev-commands.md b/.claude/rules/dev-commands.md new file mode 100644 index 000000000..9f5e1eb2a --- /dev/null +++ b/.claude/rules/dev-commands.md @@ -0,0 +1,22 @@ +--- +paths: + - "**" +--- + +### Database migration + +Run migrations: `go run ./cmd/sippy migrate --database-dsn $SIPPY_DATABASE_DSN` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: `postgresql://postgres:password@localhost:5432/postgres` + +### Linting + +Run lint: `CI=true make lint` + +`CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +### Testing + +Run unit tests: `make test` + +This runs Go tests via gotestsum and sippy-ng Jest tests. diff --git a/.claude/rules/mcp.md b/.claude/rules/mcp.md new file mode 100644 index 000000000..95448fee4 --- /dev/null +++ b/.claude/rules/mcp.md @@ -0,0 +1,8 @@ +--- +paths: + - "mcp/**" +--- + +Shared MCP server for AI-callable dev tasks (migrate, serve, lint, test, e2e). Configuration, tool list, logs, and extension notes: **[README.md](../../mcp/README.md)**. + +When adding or modifying MCP tools, follow existing patterns in `server.py` (subprocess, `_repo_path`, `_ensure_dev_log_dir`, credentials helpers). Restart the MCP server after changes. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..55a5ff820 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "model": "claude-opus-4-6" +} diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..924cb0693 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "sippy-dev": { + "command": "mcp/run.sh", + "args": [] + } + } +} diff --git a/.cursor/rules/dev-commands.mdc b/.cursor/rules/dev-commands.mdc new file mode 100644 index 000000000..68583ab44 --- /dev/null +++ b/.cursor/rules/dev-commands.mdc @@ -0,0 +1,22 @@ +--- +description: Common dev commands for database migration, linting, and testing +globs: "**" +--- + +### Database migration + +Run migrations: `go run ./cmd/sippy migrate --database-dsn $SIPPY_DATABASE_DSN` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: `postgresql://postgres:password@localhost:5432/postgres` + +### Linting + +Run lint: `CI=true make lint` + +`CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +### Testing + +Run unit tests: `make test` + +This runs Go tests via gotestsum and sippy-ng Jest tests. diff --git a/.cursor/rules/mcp.mdc b/.cursor/rules/mcp.mdc new file mode 100644 index 000000000..c72638e72 --- /dev/null +++ b/.cursor/rules/mcp.mdc @@ -0,0 +1,8 @@ +--- +description: MCP server (sippy-dev) for AI-callable dev tasks +globs: "mcp/**" +--- + +Shared MCP server for AI-callable dev tasks (migrate, serve, lint, test, e2e). Configuration, tool list, logs, and extension notes: **[README.md](../../mcp/README.md)**. + +When adding or modifying MCP tools, follow existing patterns in `server.py` (subprocess, `_repo_path`, `_ensure_dev_log_dir`, credentials helpers). Restart the MCP server after changes. diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 000000000..80f1fa845 --- /dev/null +++ b/.devcontainer/.env.example @@ -0,0 +1,7 @@ +# Claude Code (Vertex AI auth) +CLAUDE_CODE_USE_VERTEX=1 +ANTHROPIC_VERTEX_PROJECT_ID= +CLOUD_ML_REGION=global + +# Google Cloud (BigQuery, GCS) - optional, for data loading +# GOOGLE_APPLICATION_CREDENTIALS=/workspace/path-to-service-account.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..bf37e6d19 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,56 @@ +FROM registry.access.redhat.com/ubi9/ubi:latest + +ARG GOLANGCI_LINT_VERSION=1.64.8 + +RUN dnf module enable nodejs:18 -y && \ + dnf install -y --allowerasing \ + git \ + make \ + jq \ + tar \ + gzip \ + wget \ + vim-minimal \ + findutils \ + procps-ng \ + sudo \ + go \ + npm \ + python3.12 \ + libstdc++ \ + && dnf clean all + +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "aarch64" ]; then LINT_ARCH="arm64"; else LINT_ARCH="amd64"; fi && \ + rpm -i "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${LINT_ARCH}.rpm" + +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "aarch64" ]; then GCLOUD_ARCH="arm"; else GCLOUD_ARCH="x86_64"; fi && \ + curl -fsSL "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-linux-${GCLOUD_ARCH}.tar.gz" \ + | tar xz -C /opt && \ + /opt/google-cloud-sdk/install.sh --quiet --path-update=false && \ + ln -s /opt/google-cloud-sdk/bin/gcloud /usr/local/bin/gcloud && \ + ln -s /opt/google-cloud-sdk/bin/gsutil /usr/local/bin/gsutil && \ + ln -s /opt/google-cloud-sdk/bin/bq /usr/local/bin/bq + +RUN groupadd -g 1000 vscode && \ + useradd -u 1000 -g 1000 -m -s /bin/bash vscode && \ + echo "vscode ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/vscode + +ENV GOPATH=/home/vscode/go +ENV PATH="/home/vscode/.local/bin:${GOPATH}/bin:/usr/local/go/bin:${PATH}" + +USER vscode + +RUN go install gotest.tools/gotestsum@latest + +# Cursor's install script calls base64 -D (macOS flag) but GNU coreutils uses -d +RUN sudo cp /usr/bin/base64 /usr/bin/base64.gnu && \ + printf '#!/bin/sh\nfor a in "$@"; do shift; case "$a" in -D) set -- "$@" -d;; *) set -- "$@" "$a";; esac; done\nexec /usr/bin/base64.gnu "$@"\n' | sudo tee /usr/bin/base64 > /dev/null && \ + sudo chmod +x /usr/bin/base64 + +# No versioned URL available; bust cache with CLAUDE_INSTALLER_VERSION to force a rebuild +ARG CLAUDE_INSTALLER_VERSION=latest +RUN curl -fsSL https://claude.ai/install.sh | sh + +WORKDIR /workspace diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 000000000..e33a87fb8 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,81 @@ +# Devcontainer Setup + +The devcontainer provides a full-stack development environment with Go, Node.js, +PostgreSQL, Redis, and all build tools. It runs on **Podman** and works with both +**Cursor** and **Claude Code**. + +## Quick start + +Run the `/sippy-dev-setup` slash command in Claude Code or Cursor. It detects your +OS, checks prerequisites, creates the `.env` file, starts the container, and walks +you through GCP authentication. + +## What you'll need to provide + +- **GCP project ID** (`ANTHROPIC_VERTEX_PROJECT_ID`) — for Claude Code via Vertex AI +- **GCP auth** — run `gcloud auth application-default login` on the host before starting the container (credentials are mounted read-only) + +## Manual setup + +If you prefer to set up manually: + +1. Install [Podman](https://podman.io/) v4+ and [devcontainer CLI](https://github.com/devcontainers/cli) (`npm install -g @devcontainers/cli`) +2. **macOS**: run `podman machine init && podman machine start` +3. **Linux**: run `systemctl --user enable --now podman.socket` and install `podman-docker` +4. Copy `.devcontainer/.env.example` to `.devcontainer/.env` and fill in your values +5. Run `devcontainer up --workspace-folder .` (add `--docker-path podman` on macOS) +6. Exec in: `podman exec -it sippy-dev bash` and run `gcloud auth application-default login` + +## Services + +| Service | Container | Port | Access from devcontainer | +| ---------------- | ------------------- | ---- | ------------------------ | +| PostgreSQL | `sippy-postgres` | 5432 | `$SIPPY_DATABASE_DSN` | +| Redis | `sippy-redis` | 6379 | `$REDIS_URL` | +| Sippy API | inside devcontainer | 8080 | `http://localhost:8080` | +| React dev server | inside devcontainer | 3000 | `http://localhost:3000` | + +Ports 8080 and 3000 are published to the host. + +## Starting the container + +### Claude Code + +```bash +devcontainer up --workspace-folder . # add --docker-path podman on macOS +podman exec -it sippy-dev bash +claude +``` + +### Cursor + +Open the command palette and run "Dev Containers: Attach to Running Container" > `sippy-dev`. + +## Lint and e2e + +`make lint` (without `CI=true`) and `make e2e` require nested containers and +**do not work inside the devcontainer**. Run them on the host, or use `CI=true make lint` +inside the container for the Go linter. + +### TODO + +- [ ] Make **`make lint`** reliable inside the devcontainer without nested Podman + (e.g. always use host `golangci-lint` when present, or default to `CI=true`). +- [ ] Make **`make e2e`** reliable inside the devcontainer without nested Podman + (e.g. reuse the `sippy-postgres` / `sippy-redis` services instead of spawning new containers). + +## Rebuilding + +```bash +podman rm -f sippy-dev 2>/dev/null +devcontainer up --workspace-folder . --remove-existing-container # add --docker-path podman on macOS +``` + +Or from Cursor: "Dev Containers: Rebuild Container Without Cache". + +## Cleanup + +```bash +podman rm -f sippy-dev sippy-postgres sippy-redis 2>/dev/null +podman network rm sippy-net 2>/dev/null +``` diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..6c0ff96dd --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,56 @@ +// Podman users: +// 1. In Cursor/VS Code settings, set: "dev.containers.dockerPath": "podman" +// 2. Ensure "podman machine" is running (macOS/Windows) +{ + "name": "Sippy", + "build": { + "dockerfile": "Dockerfile" + }, + "runArgs": ["--name", "sippy-dev", "--network", "sippy-net", "--env-file", ".devcontainer/.env", "-p", "8080:8080", "-p", "3000:3000"], + "mounts": [ + "source=${localEnv:HOME}/.config/gcloud,target=/home/vscode/.config/gcloud,type=bind,readonly" + ], + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", + "workspaceFolder": "/workspace", + "remoteUser": "vscode", + "initializeCommand": ".devcontainer/init-services.sh", + "forwardPorts": [8080, 3000, 5432, 6379], + "portsAttributes": { + "8080": { "label": "Sippy API" }, + "3000": { "label": "React Dev Server" }, + "5432": { "label": "PostgreSQL" }, + "6379": { "label": "Redis" } + }, + "containerEnv": { + "SIPPY_DATABASE_DSN": "postgresql://postgres:password@sippy-postgres:5432/postgres", + "REDIS_URL": "redis://sippy-redis:6379" + }, + "postCreateCommand": "bash .devcontainer/post-create.sh", + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-azuretools.vscode-docker", + "redhat.vscode-yaml" + ], + "settings": { + "go.gopath": "/home/vscode/go", + "go.lintTool": "golangci-lint", + "go.lintFlags": ["--fast"], + "editor.formatOnSave": true, + "[go]": { + "editor.defaultFormatter": "golang.go" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "eslint.workingDirectories": ["sippy-ng"] + } + } + } +} diff --git a/.devcontainer/init-services.sh b/.devcontainer/init-services.sh new file mode 100755 index 000000000..9bdf70a5e --- /dev/null +++ b/.devcontainer/init-services.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu +# Starts PostgreSQL and Redis as standalone Podman containers (runs on the host before the devcontainer starts) + +podman network create sippy-net 2>/dev/null || true + +podman start sippy-postgres 2>/dev/null || \ + podman run -d --name sippy-postgres \ + --network sippy-net \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_HOST_AUTH_METHOD=trust \ + -p 127.0.0.1:5432:5432 \ + quay.io/enterprisedb/postgresql \ + -c listen_addresses='*' + +podman start sippy-redis 2>/dev/null || \ + podman run -d --name sippy-redis \ + --network sippy-net \ + -p 127.0.0.1:6379:6379 \ + redis:7-alpine + +echo "Waiting for PostgreSQL..." +pg_ready=false +for i in $(seq 1 30); do + if podman exec sippy-postgres pg_isready -U postgres >/dev/null 2>&1; then + pg_ready=true + break + fi + sleep 1 +done +if [ "$pg_ready" = false ]; then + echo "ERROR: PostgreSQL did not become ready within 30 seconds." + exit 1 +fi + +echo "Waiting for Redis..." +redis_ready=false +for i in $(seq 1 15); do + if podman exec sippy-redis redis-cli -p 6379 PING >/dev/null 2>&1; then + redis_ready=true + break + fi + sleep 1 +done +if [ "$redis_ready" = false ]; then + echo "ERROR: Redis did not become ready within 15 seconds." + exit 1 +fi + +echo "Services ready." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 000000000..2745025d5 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -eu + +echo "==> Installing Go IDE tools..." +go install golang.org/x/tools/gopls@latest +go install github.com/go-delve/delve/cmd/dlv@latest +go install honnef.co/go/tools/cmd/staticcheck@latest + +echo "==> Downloading Go module dependencies..." +go mod download + +echo "==> Installing frontend dependencies..." +make npm + +echo "==> Setting up MCP server venv..." +python3.12 -m venv mcp/.venv +mcp/.venv/bin/pip install --upgrade pip -q +mcp/.venv/bin/pip install -r mcp/requirements.txt -q + +echo "==> Dev environment ready." diff --git a/.gemini/commands/sippy-dev-app.toml b/.gemini/commands/sippy-dev-app.toml new file mode 100644 index 000000000..ad1be5411 --- /dev/null +++ b/.gemini/commands/sippy-dev-app.toml @@ -0,0 +1,2 @@ +description = "Start both Sippy backend and frontend dev servers" +prompt = "# Sippy dev — backend + frontend\n\nStart the full local Sippy stack using two MCP tool calls (server: **`sippy-dev`**). Run them in order — backend first, then frontend.\n\n1. **`sippy_serve`** — pass **`bigquery_credentials_file`** when `SIPPY_BIGQUERY_CREDENTIALS_FILE` / `GOOGLE_APPLICATION_CREDENTIALS` are not set.\n2. **`sippy_ng_start`**\n\nEach tool returns listen hints (typically **8080** / **3000**) and log paths. If a tool reports already running, ask the user if they want to restart it. If yes, call the tool again with **`restart=True`**." diff --git a/.gemini/commands/sippy-dev-frontend.toml b/.gemini/commands/sippy-dev-frontend.toml new file mode 100644 index 000000000..1af041ac8 --- /dev/null +++ b/.gemini/commands/sippy-dev-frontend.toml @@ -0,0 +1,2 @@ +description = "Start the sippy-ng React dev server via the sippy-dev MCP tool" +prompt = "# Sippy dev — frontend (sippy-ng)\n\nUse the **`sippy_ng_start`** MCP tool (server: **`sippy-dev`**). Do not run `npm start` in `sippy-ng` manually — the MCP tool handles background process management, log routing, and duplicate detection.\n\n**`open_browser`** defaults to **`false`**. Typical URL: **`http://127.0.0.1:3000`**. Log: **`sippy-dev-logs/sippy_ng_start.log`**.\n\nIf the dev server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**.\n\nSee **`mcp/server.py`** for all parameters." diff --git a/.gemini/commands/sippy-dev-migrate.toml b/.gemini/commands/sippy-dev-migrate.toml new file mode 100644 index 000000000..2000275c3 --- /dev/null +++ b/.gemini/commands/sippy-dev-migrate.toml @@ -0,0 +1,2 @@ +description = "Run Sippy PostgreSQL schema migration" +prompt = "# Sippy dev — migrate\n\nRun the migration command directly:\n\n```bash\ngo run ./cmd/sippy migrate --database-dsn \"$SIPPY_DATABASE_DSN\"\n```\n\nIf `SIPPY_DATABASE_DSN` is not set, use the dev default:\n\n```bash\ngo run ./cmd/sippy migrate --database-dsn \"postgresql://postgres:password@localhost:5432/postgres\"\n```" diff --git a/.gemini/commands/sippy-dev-regression-cache.toml b/.gemini/commands/sippy-dev-regression-cache.toml new file mode 100644 index 000000000..9a1fc6af9 --- /dev/null +++ b/.gemini/commands/sippy-dev-regression-cache.toml @@ -0,0 +1,2 @@ +description = "Run the Sippy regression-cache loader (BigQuery + Redis + DB)" +prompt = "# Sippy dev — regression-cache\n\nUse the **`regression_cache`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy load --loader regression-cache` manually — the MCP tool handles credentials, logging, and timeouts.\n\n**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set.\n\nSee **`mcp/server.py`** for all parameters. Log: **`sippy-dev-logs/regression_cache.log`**. Typical duration is many minutes." diff --git a/.gemini/commands/sippy-dev-serve.toml b/.gemini/commands/sippy-dev-serve.toml new file mode 100644 index 000000000..3258c5fbd --- /dev/null +++ b/.gemini/commands/sippy-dev-serve.toml @@ -0,0 +1,2 @@ +description = "Start the Sippy HTTP API server via the sippy-dev MCP tool" +prompt = "# Sippy dev — serve\n\nUse the **`sippy_serve`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy serve` manually — the MCP tool handles background process management, log routing, and duplicate detection.\n\n**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set.\n\nIf the server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**.\n\nSee **`mcp/server.py`** for all parameters. Typical listen address: **`:8080`**. Log: **`sippy-dev-logs/sippy_serve.log`**." diff --git a/.gemini/commands/sippy-dev-setup.toml b/.gemini/commands/sippy-dev-setup.toml new file mode 100644 index 000000000..01497d026 --- /dev/null +++ b/.gemini/commands/sippy-dev-setup.toml @@ -0,0 +1,2 @@ +description = "Set up the Sippy devcontainer (Podman, env, GCP auth)" +prompt = "# Sippy dev — devcontainer setup\n\nInteractive setup for the Sippy devcontainer. Automates what can be detected, prompts for what can't.\n\n## Workflow\n\n### 1. Detect OS\n\n```bash\nuname -s\n```\n\n- **Darwin** = macOS\n- **Linux** = Linux\n\n### 2. Check prerequisites\n\nVerify each tool is installed. Report any that are missing and stop.\n\n```bash\ncommand -v podman\ncommand -v devcontainer\n```\n\nIf `devcontainer` is missing, tell the user: `npm install -g @devcontainers/cli`\n\n### 3. macOS: Podman machine\n\nIf macOS, check if podman machine is running:\n\n```bash\npodman machine info\n```\n\nIf not initialized or not running, run:\n\n```bash\npodman machine init # only if no machine exists\npodman machine start\n```\n\n### 4. Linux: Podman socket\n\nIf Linux, check if the socket is active:\n\n```bash\nsystemctl --user is-active podman.socket\n```\n\nIf not active, run:\n\n```bash\nsystemctl --user enable --now podman.socket\n```\n\nAlso check for `podman-docker`:\n\n```bash\ncommand -v docker\n```\n\nIf missing, suggest installing `podman-docker` (dnf or apt depending on `/etc/os-release`).\n\n### 5. Environment file\n\nCheck if `.devcontainer/.env` exists:\n\n```bash\ntest -f .devcontainer/.env\n```\n\nIf missing, copy from the example:\n\n```bash\ncp .devcontainer/.env.example .devcontainer/.env\n```\n\nThen read `.devcontainer/.env` and check for empty required values. If any are blank (e.g. `ANTHROPIC_VERTEX_PROJECT_ID`), tell the user which values need to be filled in and ask them to edit `.devcontainer/.env` and let you know when they're done. **Do not** ask for the values directly or write to the file yourself — the user should edit it. Wait for them to confirm before continuing.\n\n### 6. Start the container\n\nDetermine the right command based on OS:\n\n- **macOS**: `devcontainer up --workspace-folder . --docker-path podman`\n- **Linux** (with `podman-docker`): `devcontainer up --workspace-folder .`\n- **Linux** (without `podman-docker`): `devcontainer up --workspace-folder . --docker-path podman`\n\nRun it. This triggers `init-services.sh` (PostgreSQL + Redis) and `post-create.sh` (Go tools, npm, MCP venv).\n\n### 7. GCP authentication\n\nGCP credentials are mounted from the host's `~/.config/gcloud` directory. If the user hasn't authenticated on the host yet, tell them to run on the host (not inside the container):\n\n```bash\ngcloud auth application-default login\n```\n\nThe credentials will be available inside the container automatically after restart.\n\n### 8. Summary\n\nPrint a summary of what was set up:\n\n- Container status\n- PostgreSQL: `localhost:5432`\n- Redis: `localhost:6379`\n- API server: `localhost:8080` (start with `/sippy-dev-serve`)\n- React dev server: `localhost:3000` (start with `/sippy-dev-frontend`)\n- GCP auth status" diff --git a/.gemini/commands/sippy-dev-tests.toml b/.gemini/commands/sippy-dev-tests.toml new file mode 100644 index 000000000..c78fcc213 --- /dev/null +++ b/.gemini/commands/sippy-dev-tests.toml @@ -0,0 +1,2 @@ +description = "Run lint, unit tests, and e2e in order (full local CI suite)" +prompt = "# Sippy dev — full test suite\n\nRun these three steps in order. Stop if any step fails.\n\n1. **Lint** — run directly:\n\n ```bash\n CI=true make lint\n ```\n\n `CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container.\n\n2. **Unit tests** — run directly:\n\n ```bash\n make test\n ```\n\n3. **E2e** — use the **`run_e2e`** MCP tool (server: **`sippy-dev`**). Pass **`bigquery_credentials_file`** when cred env vars are unset. E2e requires Podman/Docker and **does not work inside the devcontainer** — run it on the host.\n\nE2e log: **`sippy-dev-logs/run_e2e.log`**. Timeouts: **`mcp/server.py`**." diff --git a/.gitattributes b/.gitattributes index 764c88c4a..2b740f8fc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,8 @@ AGENTS.md linguist-generated=true CLAUDE.md linguist-generated=true GEMINI.md linguist-generated=true +mcp/AGENTS.md linguist-generated=true +mcp/CLAUDE.md linguist-generated=true +sippy-ng/AGENTS.md linguist-generated=true +sippy-ng/CLAUDE.md linguist-generated=true +.mcp.json linguist-generated=true diff --git a/.gitignore b/.gitignore index 1a04b31db..4036be693 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,12 @@ report.sh .idea/ /sippy-ng/build/* .env +.devcontainer/.env *.log +sippy-dev-logs/ +mcp/.venv/ +__pycache__/ +*.py[cod] e2e-coverage* diff --git a/.opencode/commands/sippy-dev-app.md b/.opencode/commands/sippy-dev-app.md new file mode 100644 index 000000000..85d2be678 --- /dev/null +++ b/.opencode/commands/sippy-dev-app.md @@ -0,0 +1,12 @@ +--- +description: Start both Sippy backend and frontend dev servers +--- + +# Sippy dev — backend + frontend + +Start the full local Sippy stack using two MCP tool calls (server: **`sippy-dev`**). Run them in order — backend first, then frontend. + +1. **`sippy_serve`** — pass **`bigquery_credentials_file`** when `SIPPY_BIGQUERY_CREDENTIALS_FILE` / `GOOGLE_APPLICATION_CREDENTIALS` are not set. +2. **`sippy_ng_start`** + +Each tool returns listen hints (typically **8080** / **3000**) and log paths. If a tool reports already running, ask the user if they want to restart it. If yes, call the tool again with **`restart=True`**. \ No newline at end of file diff --git a/.opencode/commands/sippy-dev-frontend.md b/.opencode/commands/sippy-dev-frontend.md new file mode 100644 index 000000000..53b16d603 --- /dev/null +++ b/.opencode/commands/sippy-dev-frontend.md @@ -0,0 +1,13 @@ +--- +description: Start the sippy-ng React dev server via the sippy-dev MCP tool +--- + +# Sippy dev — frontend (sippy-ng) + +Use the **`sippy_ng_start`** MCP tool (server: **`sippy-dev`**). Do not run `npm start` in `sippy-ng` manually — the MCP tool handles background process management, log routing, and duplicate detection. + +**`open_browser`** defaults to **`false`**. Typical URL: **`http://127.0.0.1:3000`**. Log: **`sippy-dev-logs/sippy_ng_start.log`**. + +If the dev server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**. + +See **`mcp/server.py`** for all parameters. \ No newline at end of file diff --git a/.opencode/commands/sippy-dev-migrate.md b/.opencode/commands/sippy-dev-migrate.md new file mode 100644 index 000000000..2430ba319 --- /dev/null +++ b/.opencode/commands/sippy-dev-migrate.md @@ -0,0 +1,17 @@ +--- +description: Run Sippy PostgreSQL schema migration +--- + +# Sippy dev — migrate + +Run the migration command directly: + +```bash +go run ./cmd/sippy migrate --database-dsn "$SIPPY_DATABASE_DSN" +``` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: + +```bash +go run ./cmd/sippy migrate --database-dsn "postgresql://postgres:password@localhost:5432/postgres" +``` \ No newline at end of file diff --git a/.opencode/commands/sippy-dev-regression-cache.md b/.opencode/commands/sippy-dev-regression-cache.md new file mode 100644 index 000000000..0ca7f4d75 --- /dev/null +++ b/.opencode/commands/sippy-dev-regression-cache.md @@ -0,0 +1,11 @@ +--- +description: Run the Sippy regression-cache loader (BigQuery + Redis + DB) +--- + +# Sippy dev — regression-cache + +Use the **`regression_cache`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy load --loader regression-cache` manually — the MCP tool handles credentials, logging, and timeouts. + +**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set. + +See **`mcp/server.py`** for all parameters. Log: **`sippy-dev-logs/regression_cache.log`**. Typical duration is many minutes. \ No newline at end of file diff --git a/.opencode/commands/sippy-dev-serve.md b/.opencode/commands/sippy-dev-serve.md new file mode 100644 index 000000000..14c7eb8cd --- /dev/null +++ b/.opencode/commands/sippy-dev-serve.md @@ -0,0 +1,13 @@ +--- +description: Start the Sippy HTTP API server via the sippy-dev MCP tool +--- + +# Sippy dev — serve + +Use the **`sippy_serve`** MCP tool (server: **`sippy-dev`**). Do not run `go run ./cmd/sippy serve` manually — the MCP tool handles background process management, log routing, and duplicate detection. + +**`bigquery_credentials_file`**: path to BigQuery-capable SA JSON (e.g. `sippy-bigquery-job-importer-key.json`); optional if `SIPPY_BIGQUERY_CREDENTIALS_FILE` or `GOOGLE_APPLICATION_CREDENTIALS` is set. + +If the server is already running, the tool will report it. Ask the user if they want to restart, and if so call again with **`restart=True`**. + +See **`mcp/server.py`** for all parameters. Typical listen address: **`:8080`**. Log: **`sippy-dev-logs/sippy_serve.log`**. \ No newline at end of file diff --git a/.opencode/commands/sippy-dev-setup.md b/.opencode/commands/sippy-dev-setup.md new file mode 100644 index 000000000..0e4f11dd4 --- /dev/null +++ b/.opencode/commands/sippy-dev-setup.md @@ -0,0 +1,113 @@ +--- +description: Set up the Sippy devcontainer (Podman, env, GCP auth) +--- + +# Sippy dev — devcontainer setup + +Interactive setup for the Sippy devcontainer. Automates what can be detected, prompts for what can't. + +## Workflow + +### 1. Detect OS + +```bash +uname -s +``` + +- **Darwin** = macOS +- **Linux** = Linux + +### 2. Check prerequisites + +Verify each tool is installed. Report any that are missing and stop. + +```bash +command -v podman +command -v devcontainer +``` + +If `devcontainer` is missing, tell the user: `npm install -g @devcontainers/cli` + +### 3. macOS: Podman machine + +If macOS, check if podman machine is running: + +```bash +podman machine info +``` + +If not initialized or not running, run: + +```bash +podman machine init # only if no machine exists +podman machine start +``` + +### 4. Linux: Podman socket + +If Linux, check if the socket is active: + +```bash +systemctl --user is-active podman.socket +``` + +If not active, run: + +```bash +systemctl --user enable --now podman.socket +``` + +Also check for `podman-docker`: + +```bash +command -v docker +``` + +If missing, suggest installing `podman-docker` (dnf or apt depending on `/etc/os-release`). + +### 5. Environment file + +Check if `.devcontainer/.env` exists: + +```bash +test -f .devcontainer/.env +``` + +If missing, copy from the example: + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` + +Then read `.devcontainer/.env` and check for empty required values. If any are blank (e.g. `ANTHROPIC_VERTEX_PROJECT_ID`), tell the user which values need to be filled in and ask them to edit `.devcontainer/.env` and let you know when they're done. **Do not** ask for the values directly or write to the file yourself — the user should edit it. Wait for them to confirm before continuing. + +### 6. Start the container + +Determine the right command based on OS: + +- **macOS**: `devcontainer up --workspace-folder . --docker-path podman` +- **Linux** (with `podman-docker`): `devcontainer up --workspace-folder .` +- **Linux** (without `podman-docker`): `devcontainer up --workspace-folder . --docker-path podman` + +Run it. This triggers `init-services.sh` (PostgreSQL + Redis) and `post-create.sh` (Go tools, npm, MCP venv). + +### 7. GCP authentication + +GCP credentials are mounted from the host's `~/.config/gcloud` directory. If the user hasn't authenticated on the host yet, tell them to run on the host (not inside the container): + +```bash +gcloud auth application-default login +``` + +The credentials will be available inside the container automatically after restart. + +### 8. Summary + +Print a summary of what was set up: + +- Container status +- PostgreSQL: `localhost:5432` +- Redis: `localhost:6379` +- API server: `localhost:8080` (start with `/sippy-dev-serve`) +- React dev server: `localhost:3000` (start with `/sippy-dev-frontend`) +- GCP auth status \ No newline at end of file diff --git a/.opencode/commands/sippy-dev-tests.md b/.opencode/commands/sippy-dev-tests.md new file mode 100644 index 000000000..46eaf431b --- /dev/null +++ b/.opencode/commands/sippy-dev-tests.md @@ -0,0 +1,25 @@ +--- +description: Run lint, unit tests, and e2e in order (full local CI suite) +--- + +# Sippy dev — full test suite + +Run these three steps in order. Stop if any step fails. + +1. **Lint** — run directly: + + ```bash + CI=true make lint + ``` + + `CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +2. **Unit tests** — run directly: + + ```bash + make test + ``` + +3. **E2e** — use the **`run_e2e`** MCP tool (server: **`sippy-dev`**). Pass **`bigquery_credentials_file`** when cred env vars are unset. E2e requires Podman/Docker and **does not work inside the devcontainer** — run it on the host. + +E2e log: **`sippy-dev-logs/run_e2e.log`**. Timeouts: **`mcp/server.py`**. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 33c159e76..856934fe6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,30 @@ # AGENTS.md - + ## Files matching `**` + +### Database migration + +Run migrations: `go run ./cmd/sippy migrate --database-dsn $SIPPY_DATABASE_DSN` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: `postgresql://postgres:password@localhost:5432/postgres` + +### Linting + +Run lint: `CI=true make lint` + +`CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +### Testing + +Run unit tests: `make test` + +This runs Go tests via gotestsum and sippy-ng Jest tests. + **Sippy (CIPI - Continuous Integration Private Investigator)** is a tool used within the OpenShift engineering organization to analyze CI job results. Its primary goals are to: @@ -37,4 +56,4 @@ Favor clarity and maintainability over cleverness. Comments should be minimal, h --- *This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `specify apm compile`* +*To regenerate: `apm compile`* diff --git a/CLAUDE.md b/CLAUDE.md index 03536901c..131da6b9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,12 +1,31 @@ # CLAUDE.md - + # Project Standards ## Files matching `**` + +### Database migration + +Run migrations: `go run ./cmd/sippy migrate --database-dsn $SIPPY_DATABASE_DSN` + +If `SIPPY_DATABASE_DSN` is not set, use the dev default: `postgresql://postgres:password@localhost:5432/postgres` + +### Linting + +Run lint: `CI=true make lint` + +`CI=true` makes `hack/go-lint.sh` use the locally installed `golangci-lint` instead of spawning a container. + +### Testing + +Run unit tests: `make test` + +This runs Go tests via gotestsum and sippy-ng Jest tests. + **Sippy (CIPI - Continuous Integration Private Investigator)** is a tool used within the OpenShift engineering organization to analyze CI job results. Its primary goals are to: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 161cca07f..5de747cfb 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,3 +1,16 @@ +## Devcontainer (Recommended) + +The easiest way to get a full development environment is the +[devcontainer](.devcontainer/README.md). It provides a pre-configured +UBI9 container with Go, Node.js, PostgreSQL, Redis, and all build tools +— no manual dependency installation required. It works with both +**Cursor** and **Claude Code**, and includes a shared MCP server for +AI-callable dev tasks (migrations, loaders, local servers, tests). See +**[mcp/README.md](mcp/README.md)** for tools, configuration, and logs. + +See [.devcontainer/README.md](.devcontainer/README.md) for setup +instructions. + ## Building Sippy Running `make` will build an all-in-one binary that contains both the go app and frontend. diff --git a/GEMINI.md b/GEMINI.md index fe1730f9c..123ba5eb3 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -1,6 +1,6 @@ # GEMINI.md - + @./AGENTS.md diff --git a/Makefile b/Makefile index 33152ac4f..05a0f8386 100644 --- a/Makefile +++ b/Makefile @@ -61,13 +61,13 @@ clean: rm -rf sippy-ng/node_modules apm: - uvx --from apm-cli@0.10.0 apm install - uvx --from apm-cli@0.10.0 apm compile + uvx --from apm-cli@0.11.0 apm install + uvx --from apm-cli@0.11.0 apm compile verify-apm: apm - @if ! git diff --quiet HEAD -- .claude .cursor .gemini .opencode AGENTS.md CLAUDE.md GEMINI.md sippy-ng/AGENTS.md sippy-ng/CLAUDE.md; then \ + @if ! git diff --quiet HEAD -- .claude .cursor .gemini .opencode AGENTS.md CLAUDE.md GEMINI.md sippy-ng/AGENTS.md sippy-ng/CLAUDE.md mcp/AGENTS.md mcp/CLAUDE.md; then \ echo "ERROR: Generated APM files are out of date. Run 'make apm' and commit the results."; \ - git diff --stat HEAD -- .claude .cursor .gemini .opencode AGENTS.md CLAUDE.md GEMINI.md sippy-ng/AGENTS.md sippy-ng/CLAUDE.md; \ + git diff --stat HEAD -- .claude .cursor .gemini .opencode AGENTS.md CLAUDE.md GEMINI.md sippy-ng/AGENTS.md sippy-ng/CLAUDE.md mcp/AGENTS.md mcp/CLAUDE.md; \ exit 1; \ fi diff --git a/apm.lock.yaml b/apm.lock.yaml index 7cb398789..d7ab533ab 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -3,38 +3,88 @@ generated_at: '2026-04-27T13:58:06.737227+00:00' apm_version: 0.10.0 dependencies: [] local_deployed_files: +- .claude/commands/sippy-dev-app.md +- .claude/commands/sippy-dev-frontend.md +- .claude/commands/sippy-dev-migrate.md +- .claude/commands/sippy-dev-regression-cache.md +- .claude/commands/sippy-dev-serve.md +- .claude/commands/sippy-dev-setup.md +- .claude/commands/sippy-dev-tests.md - .claude/commands/sippy-generate-release-views.md - .claude/commands/sippy-update-ga-release-views.md - .claude/commands/sippy-update-job-variant.md - .claude/rules/backend.md +- .claude/rules/dev-commands.md - .claude/rules/frontend.md - .claude/rules/general.md +- .claude/rules/mcp.md - .claude/rules/testing.md - .cursor/rules/backend.mdc +- .cursor/rules/dev-commands.mdc - .cursor/rules/frontend.mdc - .cursor/rules/general.mdc +- .cursor/rules/mcp.mdc - .cursor/rules/testing.mdc +- .gemini/commands/sippy-dev-app.toml +- .gemini/commands/sippy-dev-frontend.toml +- .gemini/commands/sippy-dev-migrate.toml +- .gemini/commands/sippy-dev-regression-cache.toml +- .gemini/commands/sippy-dev-serve.toml +- .gemini/commands/sippy-dev-setup.toml +- .gemini/commands/sippy-dev-tests.toml - .gemini/commands/sippy-generate-release-views.toml - .gemini/commands/sippy-update-ga-release-views.toml - .gemini/commands/sippy-update-job-variant.toml +- .opencode/commands/sippy-dev-app.md +- .opencode/commands/sippy-dev-frontend.md +- .opencode/commands/sippy-dev-migrate.md +- .opencode/commands/sippy-dev-regression-cache.md +- .opencode/commands/sippy-dev-serve.md +- .opencode/commands/sippy-dev-setup.md +- .opencode/commands/sippy-dev-tests.md - .opencode/commands/sippy-generate-release-views.md - .opencode/commands/sippy-update-ga-release-views.md - .opencode/commands/sippy-update-job-variant.md local_deployed_file_hashes: + .claude/commands/sippy-dev-app.md: sha256:44a111c7cbd34d2480658b39f9b01a18cb80e0f50b4a652cae4c255d123595a4 + .claude/commands/sippy-dev-frontend.md: sha256:42eae4b3bc610c9fcb43533a6fed229a6d1c409d279f3d6f93672986ede62e3a + .claude/commands/sippy-dev-migrate.md: sha256:5704945283027398bbe4a54eb4d97295059bd64a47e86e97a58665338ec62c8b + .claude/commands/sippy-dev-regression-cache.md: sha256:4e9fc3ed63927c84c30554929768a3b0e760264925f55c9dbbdafabf2361bee2 + .claude/commands/sippy-dev-serve.md: sha256:0571085d7f4017334c08a6feadd94ab7ba016cce0556dbd9999c93b5449f420a + .claude/commands/sippy-dev-setup.md: sha256:276c708371bebcdbe8602df22ca482e59c2eab0bbc7ecacf1b49acd0a99d966f + .claude/commands/sippy-dev-tests.md: sha256:2f631ca56790cd9c4441e60ee1e1e2deff5a32bf7517c051d053b8e645242a64 .claude/commands/sippy-generate-release-views.md: sha256:e8a283e43f84777e6da34c10f1b18de2c41baa4525a7e40317bddd0c61b75553 .claude/commands/sippy-update-ga-release-views.md: sha256:c7adb0f649455fa7c12c94454220fc87f182d49b820a2683ca733401cbfeb9b0 .claude/commands/sippy-update-job-variant.md: sha256:ef00ec3a3b01556b33243d3e17ab1ace23879a8ef10dfd2e501a0fda2291fb97 .claude/rules/backend.md: sha256:44d4119d9234bdaf026b615bc91c386cfb6930783e3b676e31ea2ec8e2ea930a + .claude/rules/dev-commands.md: sha256:4508eaad3d835305b68106ed5b270ced97c93e4a42ee94d1bcac5ea7b056bc5d .claude/rules/frontend.md: sha256:ff22046c5b951769218bbdf36499e67c70896811b8ef161ca6d3729a3423997d .claude/rules/general.md: sha256:9238d053e2d6a1703728c2bb165de93658b47c3152dfac16169ff9db9c6f716c + .claude/rules/mcp.md: sha256:73735078b3b1a8597b91b49821ba05cdb338144ea00c1eea9e9f4de38d0241a3 .claude/rules/testing.md: sha256:0a4c2f1b2484a70c1973315821ba777e7236dc187c6511973503075e0df0617d .cursor/rules/backend.mdc: sha256:b79264fe113886feaf6dc7eb7e37c31d753dba5989b34a7948fd9cdb05d4d363 + .cursor/rules/dev-commands.mdc: sha256:c69ca93db9b6c68f9aeb6567289dc5baef805520e8636a1e3e848793f67450e6 .cursor/rules/frontend.mdc: sha256:497f39372724f1ae127181fe3dac9ea9a95a51c532b68ccfee6080832cf9c556 .cursor/rules/general.mdc: sha256:671e94d8251783ecfb70d16e6e8d60dc42752ecfbe90919006b908b8a751595d + .cursor/rules/mcp.mdc: sha256:268557cc3bdd9c8f7401f9ea80ac342ff2688f1d563b10880177ca586d7d30bb .cursor/rules/testing.mdc: sha256:e177fa738c0b3b607e947986ba9b0a902a9483bf34d621289f615416084b2509 + .gemini/commands/sippy-dev-app.toml: sha256:afc6fe75647832aa571c63d8229d151820c54535cdb695b96f8400c1d7b3bd16 + .gemini/commands/sippy-dev-frontend.toml: sha256:ec4ab5e1fb7581f09473e33b3ed4f53ce40509f23aac581e3b100ad1f59de5e5 + .gemini/commands/sippy-dev-migrate.toml: sha256:e699558eb27c294327a092bfcd3e76b576087ad29d9a3a9b000cfd9e7c6b4774 + .gemini/commands/sippy-dev-regression-cache.toml: sha256:cd2c9017e5e976750839c712b42d10046d1d04fabaa749ffda6fd1abaf776d7a + .gemini/commands/sippy-dev-serve.toml: sha256:774bedf16a0ac5c2c464a29c8264d2588c48eb2a1d8380e694e22f09c6a95715 + .gemini/commands/sippy-dev-setup.toml: sha256:bb56074029f5a73075f7d8b5e971ad2621d0517ea29a90bbb1e11f989bd6295c + .gemini/commands/sippy-dev-tests.toml: sha256:0d19aaff911d478859fc948650a92e46142071a8c5d1a29c5b40c25d1d1e495b .gemini/commands/sippy-generate-release-views.toml: sha256:4759f4547c05631d2e83a5859172421ea3d6ec794dd887756a90bbe0fd2732c1 .gemini/commands/sippy-update-ga-release-views.toml: sha256:71a60dfe068bdc59645b8d0069c93c65f2df30c5172da3389df2a80d86410ae9 .gemini/commands/sippy-update-job-variant.toml: sha256:71c9c7548a1a4b829e970d76504ef981ea05fecf8052cdb86859d96ad52cb2b2 + .opencode/commands/sippy-dev-app.md: sha256:44a111c7cbd34d2480658b39f9b01a18cb80e0f50b4a652cae4c255d123595a4 + .opencode/commands/sippy-dev-frontend.md: sha256:42eae4b3bc610c9fcb43533a6fed229a6d1c409d279f3d6f93672986ede62e3a + .opencode/commands/sippy-dev-migrate.md: sha256:5704945283027398bbe4a54eb4d97295059bd64a47e86e97a58665338ec62c8b + .opencode/commands/sippy-dev-regression-cache.md: sha256:4e9fc3ed63927c84c30554929768a3b0e760264925f55c9dbbdafabf2361bee2 + .opencode/commands/sippy-dev-serve.md: sha256:0571085d7f4017334c08a6feadd94ab7ba016cce0556dbd9999c93b5449f420a + .opencode/commands/sippy-dev-setup.md: sha256:276c708371bebcdbe8602df22ca482e59c2eab0bbc7ecacf1b49acd0a99d966f + .opencode/commands/sippy-dev-tests.md: sha256:2f631ca56790cd9c4441e60ee1e1e2deff5a32bf7517c051d053b8e645242a64 .opencode/commands/sippy-generate-release-views.md: sha256:e8a283e43f84777e6da34c10f1b18de2c41baa4525a7e40317bddd0c61b75553 .opencode/commands/sippy-update-ga-release-views.md: sha256:c7adb0f649455fa7c12c94454220fc87f182d49b820a2683ca733401cbfeb9b0 .opencode/commands/sippy-update-job-variant.md: sha256:ef00ec3a3b01556b33243d3e17ab1ace23879a8ef10dfd2e501a0fda2291fb97 diff --git a/mcp/AGENTS.md b/mcp/AGENTS.md new file mode 100644 index 000000000..6b1ea53f2 --- /dev/null +++ b/mcp/AGENTS.md @@ -0,0 +1,16 @@ +# AGENTS.md + + + + + +## Files matching `mcp/**` + + +Shared MCP server for AI-callable dev tasks (migrate, serve, lint, test, e2e). Configuration, tool list, logs, and extension notes: **[README.md](../../mcp/README.md)**. + +When adding or modifying MCP tools, follow existing patterns in `server.py` (subprocess, `_repo_path`, `_ensure_dev_log_dir`, credentials helpers). Restart the MCP server after changes. + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/mcp/CLAUDE.md b/mcp/CLAUDE.md new file mode 100644 index 000000000..11b6b2fa2 --- /dev/null +++ b/mcp/CLAUDE.md @@ -0,0 +1,17 @@ +# CLAUDE.md + + + + +# Project Standards + +## Files matching `mcp/**` + + +Shared MCP server for AI-callable dev tasks (migrate, serve, lint, test, e2e). Configuration, tool list, logs, and extension notes: **[README.md](../../mcp/README.md)**. + +When adding or modifying MCP tools, follow existing patterns in `server.py` (subprocess, `_repo_path`, `_ensure_dev_log_dir`, credentials helpers). Restart the MCP server after changes. + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 000000000..6ce74b21b --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,76 @@ +# Sippy dev MCP server + +Python [FastMCP](https://github.com/jlowin/fastmcp) server (`server.py`) that exposes Sippy development commands requiring process management to **Cursor** and **Claude Code**. + +Simple commands (migrate, lint, test) are documented in project instructions via [APM](../apm.yml) and can be run directly — see `.apm/instructions/dev-commands.instructions.md`. + +## Setup + +**`mcp/run.sh`** is the entry point. It auto-creates the virtualenv (`mcp/.venv`) and installs dependencies on first run — no manual setup required. The devcontainer's `post-create.sh` pre-warms the venv during build. + +- **Python 3.12** required. The devcontainer image installs it; on macOS use `brew install python@3.12`. +- **Manual venv setup** (if you prefer not to use `run.sh`): + + ```bash + python3.12 -m venv mcp/.venv + mcp/.venv/bin/pip install --upgrade pip + mcp/.venv/bin/pip install -r mcp/requirements.txt + ``` + +## Editor configuration + +| Client | Config file | +| ----------- | ----------------------- | +| Cursor | `.cursor/mcp.json` | +| Claude Code | `.mcp.json` (repo root) | + +Both point to `mcp/run.sh`. This works on the host and inside the devcontainer without path changes. + +## Server id in Cursor + +The MCP server key in config is **`sippy-dev`**. Cursor may expose tools under a **prefixed** server name (e.g. `project-0-workspace-sippy-dev`). Use the server id your client lists when calling tools. + +## Tools + +Commands use the **repo root** as working directory unless noted. Most long outputs go to **`sippy-dev-logs/`** (see `.gitignore`). + +| Tool | What it runs | Default log | +| ------------------ | --------------------------------------------------------------------------- | ------------------------------------- | +| `regression_cache` | `go run ./cmd/sippy load --loader regression-cache` (BigQuery + Redis + DB) | `sippy-dev-logs/regression_cache.log` | +| `sippy_serve` | Background `go run ./cmd/sippy serve` (API/UI, typically port **8080**) | `sippy-dev-logs/sippy_serve.log` | +| `sippy_ng_start` | Background `npm start` in `sippy-ng/` (typically port **3000**) | `sippy-dev-logs/sippy_ng_start.log` | +| `run_e2e` | `make e2e` | `sippy-dev-logs/run_e2e.log` | + +Optional parameters (timeouts, paths, DSNs, etc.) are documented on each function in **`server.py`**. + +> **Cost caution:** `run_e2e` and `regression_cache` issue BigQuery queries that cost real money. Run them only when explicitly needed and never more than once per request. + +### Credentials and environment + +- **Service account JSON** (BigQuery / GCS): pass `bigquery_credentials_file` where supported, or set **`SIPPY_BIGQUERY_CREDENTIALS_FILE`** or **`GOOGLE_APPLICATION_CREDENTIALS`** to an existing file path. Typical local file: `sippy-bigquery-job-importer-key.json` at repo root. +- **`run_e2e`** sets **`GCS_SA_JSON_PATH`** for `scripts/e2e.sh` from that same resolution. +- **Postgres / Redis**: `SIPPY_DATABASE_DSN`, `REDIS_URL`, or per-tool arguments; see `server.py` for defaults. + +### E2E containers + +`scripts/e2e.sh` uses **`DOCKER`** if set; otherwise **Podman** if on `PATH`, else **Docker**. Install one of them, or set `DOCKER` to the CLI you use. + +### Background processes + +`sippy_serve` and `sippy_ng_start` spawn detached processes. A second start is refused if a matching process is already running (see `server.py` for detection logic). + +## Cursor skills + +Agent-oriented shortcuts live under **`.cursor/skills/`**, for example: + +- `sippy-dev-migrate`, `sippy-dev-regression-cache`, `sippy-dev-serve`, `sippy-dev-frontend` +- `sippy-dev-app` (backend + frontend) +- `sippy-dev-tests` (order: `CI=true make lint` → `make test` → `run_e2e`) + +## Changing the server + +After editing **`server.py`**, restart the **sippy-dev** MCP server (or reload the editor) so tool lists stay in sync. + +## Adding tools + +Add `@mcp.tool()` handlers in `server.py`, mirror existing patterns (`subprocess`, `_repo_path`, `_ensure_dev_log_dir`, credentials helpers), then restart MCP. diff --git a/mcp/requirements.txt b/mcp/requirements.txt new file mode 100644 index 000000000..f8981d59b --- /dev/null +++ b/mcp/requirements.txt @@ -0,0 +1 @@ +fastmcp>=3.2.4 diff --git a/mcp/run.sh b/mcp/run.sh new file mode 100755 index 000000000..50a84e767 --- /dev/null +++ b/mcp/run.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV_DIR="$SCRIPT_DIR/.venv" + +if [ ! -x "$VENV_DIR/bin/python" ]; then + rm -rf "$VENV_DIR" + python3.12 -m venv "$VENV_DIR" + "$VENV_DIR/bin/pip" install --upgrade pip -q + "$VENV_DIR/bin/pip" install -r "$SCRIPT_DIR/requirements.txt" -q +fi + +exec "$VENV_DIR/bin/python" "$SCRIPT_DIR/server.py" "$@" diff --git a/mcp/server.py b/mcp/server.py new file mode 100644 index 000000000..287bda65c --- /dev/null +++ b/mcp/server.py @@ -0,0 +1,594 @@ +import os +import signal +import subprocess +import sys +import time +import urllib.request +from collections.abc import Callable +from pathlib import Path + +from fastmcp import FastMCP + +mcp = FastMCP("sippy-dev") + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEV_LOG_DIR = REPO_ROOT / "sippy-dev-logs" + +_MAX_TOOL_CHARS = 28000 + + +def _ensure_dev_log_dir() -> None: + DEV_LOG_DIR.mkdir(parents=True, exist_ok=True) + + +def _repo_path(p: str) -> Path: + """Resolve *p* relative to REPO_ROOT and reject paths that escape it.""" + root = str(REPO_ROOT.resolve()) + sanitized = os.path.normpath(os.path.expanduser(p)) + if os.path.isabs(sanitized): + resolved = Path(sanitized).resolve() + else: + if sanitized.startswith(".."): + raise ValueError(f"path escapes repo root: {p}") + resolved = Path(os.path.normpath(os.path.join(root, sanitized))).resolve() + if not str(resolved).startswith(root + os.sep) and str(resolved) != root: + raise ValueError(f"path escapes repo root: {resolved}") + return resolved + + +def _trim(s: str, max_len: int = _MAX_TOOL_CHARS) -> str: + if len(s) <= max_len: + return s + head = max_len // 2 + tail = max_len - head - 120 + omitted = len(s) - head - tail + return f"{s[:head]}\n... [{omitted} characters omitted] ...\n{s[-tail:]}" + + +def _default_database_dsn() -> str: + return os.environ.get( + "SIPPY_DATABASE_DSN", + "postgresql://postgres:password@localhost:5432/postgres", + ) + + +def _default_redis_url() -> str: + return os.environ.get("REDIS_URL", "redis://localhost:6379") + + +def _resolve_bigquery_creds(explicit: str | None) -> tuple[Path | None, str | None]: + if explicit: + p = Path(explicit).expanduser().resolve() + if not p.is_file(): + return None, f"BigQuery credentials file not found: {p}" + return p, None + for key in ("SIPPY_BIGQUERY_CREDENTIALS_FILE", "GOOGLE_APPLICATION_CREDENTIALS"): + v = os.environ.get(key) + if v: + p = Path(v).expanduser().resolve() + if p.is_file(): + return p, None + return None, f"{key} is set but file not found: {p}" + return ( + None, + "Set bigquery_credentials_file to your GCP service account JSON path " + "(e.g. sippy-bigquery-job-importer-key.json), or set SIPPY_BIGQUERY_CREDENTIALS_FILE " + "or GOOGLE_APPLICATION_CREDENTIALS.", + ) + + +@mcp.tool() +def regression_cache( + bigquery_credentials_file: str | None = None, + database_dsn: str | None = None, + redis_url: str | None = None, + views_file: str = "config/views.yaml", + config_file: str = "config/openshift.yaml", + log_file: str = "sippy-dev-logs/regression_cache.log", + skip_matview_refresh: bool = True, + timeout_seconds: int = 7200, +) -> str: + """Run the regression-cache loader (BigQuery + Redis + DB). + + Equivalent to a line-buffered ``go run ./cmd/sippy load --loader regression-cache`` with + logging merged to ``log_file``. Typical duration is many minutes. + + ``bigquery_credentials_file`` should be a JSON key with BigQuery job permissions (e.g. + ``sippy-bigquery-job-importer-key.json``). Relative paths are resolved from the repo root. + If omitted, ``SIPPY_BIGQUERY_CREDENTIALS_FILE`` or ``GOOGLE_APPLICATION_CREDENTIALS`` must + point to an existing file. + + Other settings default from arguments or ``SIPPY_DATABASE_DSN`` / ``REDIS_URL`` environment + variables with sensible dev fallbacks. + """ + creds_path, err = _resolve_bigquery_creds(bigquery_credentials_file) + if err: + return err + + dsn = database_dsn or _default_database_dsn() + redis = redis_url or _default_redis_url() + try: + views = _repo_path(views_file) + config = _repo_path(config_file) + log_path = _repo_path(log_file) + except ValueError as e: + return str(e) + + for label, p in ("views", views), ("config", config): + if not p.is_file(): + return f"{label} file not found: {p}" + + args = [ + "stdbuf", + "-oL", + "-eL", + "go", + "run", + "./cmd/sippy", + "load", + "--loader", + "regression-cache", + "--views", + str(views), + "--database-dsn", + dsn, + "--redis-url", + redis, + "--config", + str(config), + "--google-service-account-credential-file", + str(creds_path), + ] + if skip_matview_refresh: + args.append("--skip-matview-refresh") + + _ensure_dev_log_dir() + log_path.parent.mkdir(parents=True, exist_ok=True) + try: + with open(log_path, "w", encoding="utf-8") as logf: + r = subprocess.run( + args, + cwd=REPO_ROOT, + env=os.environ.copy(), + stdout=logf, + stderr=subprocess.STDOUT, + timeout=timeout_seconds if timeout_seconds > 0 else None, + ) + except subprocess.TimeoutExpired: + return ( + f"regression_cache timed out after {timeout_seconds}s. " + f"Partial log: {log_path}\n" + "Increase timeout_seconds or check BigQuery / network." + ) + + try: + log_text = log_path.read_text(encoding="utf-8", errors="replace") + except OSError as e: + log_text = f"(could not read log file: {e})" + + tail_lines = log_text.splitlines()[-40:] + tail = "\n".join(tail_lines) + status = "succeeded" if r.returncode == 0 else "failed" + return ( + f"regression_cache {status} (exit {r.returncode}). " + f"Full log: {log_path}\n--- last {len(tail_lines)} lines ---\n{tail}" + ) + + +def _proc_cmdline(pid_dir: Path) -> str: + raw = (pid_dir / "cmdline").read_bytes() + return raw.replace(b"\0", b" ").decode(errors="replace") + + +def _proc_cwd(pid_dir: Path) -> Path | None: + try: + return (pid_dir / "cwd").resolve() + except OSError: + return None + + +def _pgrep_pids(pattern: str) -> list[int]: + try: + r = subprocess.run( + ["pgrep", "-f", pattern], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return [] + if r.returncode != 0: + return [] + return [int(x) for x in r.stdout.split() if x.strip().isdigit()] + + +def _filter_pids_by_cwd(pids: list[int], expected_cwd: Path) -> list[int]: + """Filter PIDs to those whose working directory matches expected_cwd (macOS/lsof fallback).""" + if not pids: + return [] + filtered: list[int] = [] + for pid in pids: + try: + r = subprocess.run( + ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"], + capture_output=True, + text=True, + timeout=5, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return pids + for line in r.stdout.splitlines(): + if line.startswith("n") and Path(line[1:]).resolve() == expected_cwd: + filtered.append(pid) + break + return filtered + + +def _find_pids( + expected_cwd: Path, + cmdline_match: Callable[[str], bool], + pgrep_patterns: list[str], +) -> list[int]: + """Find PIDs with a given cwd whose cmdline passes *cmdline_match*. + + On Linux, scans /proc directly. Falls back to pgrep + lsof filtering. + """ + found: list[int] = [] + if sys.platform.startswith("linux"): + for pid_dir in Path("/proc").iterdir(): + if not pid_dir.name.isdigit(): + continue + try: + if _proc_cwd(pid_dir) != expected_cwd: + continue + cmd = _proc_cmdline(pid_dir) + except OSError: + continue + if cmdline_match(cmd): + found.append(int(pid_dir.name)) + if found: + return sorted(set(found)) + for pat in pgrep_patterns: + p = _filter_pids_by_cwd(_pgrep_pids(pat), expected_cwd) + if p: + return sorted(set(p)) + return [] + + +def _pids_sippy_serve() -> list[int]: + def _match(cmd: str) -> bool: + if " migrate" in cmd or " load" in cmd: + return False + if " serve" not in cmd and not cmd.rstrip().endswith(" serve"): + return False + return "cmd/sippy" in cmd or "exe/sippy" in cmd or "/sippy serve" in cmd + + return _find_pids( + REPO_ROOT.resolve(), + _match, + ["./cmd/sippy serve", "cmd/sippy serve", "exe/sippy serve"], + ) + + +def _pids_sippy_ng_dev() -> list[int]: + def _match(cmd: str) -> bool: + return "react-scripts" in cmd or "npm start" in cmd + + return _find_pids( + (REPO_ROOT / "sippy-ng").resolve(), + _match, + ["react-scripts/scripts/start.js"], + ) + + +def _stop_pids(pids: list[int]) -> str: + """Send SIGTERM then SIGKILL to each PID. Returns a summary.""" + for pid in pids: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + time.sleep(1) + killed = [] + for pid in pids: + try: + os.kill(pid, 0) + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + killed.append(pid) + return ", ".join(str(p) for p in killed) + + +@mcp.tool() +def sippy_serve( + bigquery_credentials_file: str | None = None, + database_dsn: str | None = None, + redis_url: str | None = None, + views_file: str = "config/views.yaml", + config_file: str | None = None, + log_file: str = "sippy-dev-logs/sippy_serve.log", + log_level: str = "debug", + mode: str = "ocp", + listen: str = ":8080", + enable_write_endpoints: bool = True, + restart: bool = False, +) -> str: + """Start the Sippy HTTP server (``go run ./cmd/sippy serve``) in the background. + + Long-running: returns after spawn with PID, log path, and listen address. Uses the same + credential and DSN conventions as ``regression_cache``. Skips starting if a matching + ``sippy serve`` process is already running, unless ``restart`` is True. + """ + creds_path, err = _resolve_bigquery_creds(bigquery_credentials_file) + if err: + return err + + dsn = database_dsn or _default_database_dsn() + redis = redis_url or _default_redis_url() + try: + views = _repo_path(views_file) + log_path = _repo_path(log_file) + except ValueError as e: + return str(e) + + if not views.is_file(): + return f"views file not found: {views}" + + existing = _pids_sippy_serve() + if existing: + if not restart: + host_hint = f"http://127.0.0.1{listen}" if listen.startswith(":") else listen + pids = ", ".join(str(p) for p in existing) + return ( + f"sippy_serve already running (pid(s) {pids}). Listen: {host_hint} " + f"log: {log_path}. Call with restart=True to restart." + ) + _stop_pids(existing) + + args = [ + "stdbuf", + "-oL", + "-eL", + "go", + "run", + "./cmd/sippy", + "serve", + "--views", + str(views), + "--log-level", + log_level, + "--database-dsn", + dsn, + "--mode", + mode, + "--google-service-account-credential-file", + str(creds_path), + "--redis-url", + redis, + "--listen", + listen, + ] + if enable_write_endpoints: + args.append("--enable-write-endpoints") + if config_file: + try: + cfg = _repo_path(config_file) + except ValueError as e: + return str(e) + if not cfg.is_file(): + return f"config file not found: {cfg}" + args.extend(["--config", str(cfg)]) + + host_hint = f"http://127.0.0.1{listen}" if listen.startswith(":") else listen + pid_or_err = _spawn_background( + label="sippy_serve", args=args, cwd=REPO_ROOT, log_path=log_path, + ready_url=host_hint, + ) + if isinstance(pid_or_err, str): + return pid_or_err + return f"sippy_serve started and ready (pid {pid_or_err}). Listen: {host_hint} log: {log_path}" + + +@mcp.tool() +def sippy_ng_start( + log_file: str = "sippy-dev-logs/sippy_ng_start.log", + open_browser: bool = False, + restart: bool = False, +) -> str: + """Start the React dev server (``npm start`` in ``sippy-ng``) in the background. + + CRA defaults to port 3000. ``log_file`` is resolved relative to the repo root; + absolute paths outside the repo are rejected. Skips starting if a matching + ``npm start`` / react-scripts process is already running, unless ``restart`` is True. + """ + ng_dir = REPO_ROOT / "sippy-ng" + if not (ng_dir / "package.json").is_file(): + return f"sippy-ng not found or missing package.json: {ng_dir}" + + try: + log_path = _repo_path(log_file) + except ValueError as e: + return str(e) + + existing = _pids_sippy_ng_dev() + if existing: + if not restart: + pids = ", ".join(str(p) for p in existing) + return ( + f"sippy_ng_start already running (pid(s) {pids}). " + f"Typical URL: http://127.0.0.1:3000 log: {log_path}. " + f"Call with restart=True to restart." + ) + _stop_pids(existing) + + env = os.environ.copy() + if not open_browser: + env["BROWSER"] = "none" + + pid_or_err = _spawn_background( + label="sippy_ng_start", + args=["stdbuf", "-oL", "-eL", "npm", "start"], + cwd=ng_dir, + log_path=log_path, + env=env, + ready_url="http://127.0.0.1:3000", + ) + if isinstance(pid_or_err, str): + return pid_or_err + return ( + f"sippy_ng_start started and ready (pid {pid_or_err}). URL: http://127.0.0.1:3000 " + f"log: {log_path}" + ) + + +def _wait_for_ready(url: str, timeout: int, proc: subprocess.Popen) -> str | None: + """Poll *url* until it responds or *timeout* seconds elapse. Returns an error string or None.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + code = proc.poll() + if code is not None: + return f"process exited (exit {code}) while waiting for readiness" + try: + urllib.request.urlopen(url, timeout=2) + return None + except Exception: + time.sleep(1) + return f"not ready after {timeout}s (checked {url})" + + +def _spawn_background( + label: str, + args: list[str], + cwd: Path, + log_path: Path, + env: dict[str, str] | None = None, + ready_url: str | None = None, + ready_timeout: int = 120, +) -> int | str: + """Spawn a detached process, returning its PID or an error string. + + If *ready_url* is set, polls it until it responds (up to *ready_timeout* seconds). + """ + _ensure_dev_log_dir() + log_path.parent.mkdir(parents=True, exist_ok=True) + logf = open(log_path, "a", encoding="utf-8") + try: + proc = subprocess.Popen( + args, + cwd=cwd, + env=env or os.environ.copy(), + stdout=logf, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as e: + logf.close() + return f"{label} failed to start: {e}" + + logf.close() + time.sleep(0.75) + code = proc.poll() + if code is not None: + try: + tail = log_path.read_text(encoding="utf-8", errors="replace")[-4000:] + except OSError: + tail = "(no log output)" + return f"{label} exited immediately (exit {code}). Log: {log_path}\n--- tail ---\n{tail}" + + if ready_url: + err = _wait_for_ready(ready_url, ready_timeout, proc) + if err: + tail = _tail_file(log_path, 40) + return f"{label} started (pid {proc.pid}) but {err}. Log: {log_path}\n--- tail ---\n{tail}" + + return proc.pid + + +def _tail_file(path: Path, max_lines: int) -> str: + try: + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError as e: + return f"(could not read log: {e})" + return "\n".join(lines[-max_lines:]) + + +def _run_make_phase( + tool_label: str, + make_target: str, + log_filename: str, + timeout_seconds: int, + env_extra: dict[str, str] | None = None, +) -> str: + """Run ``make ``; log to ``sippy-dev-logs/``.""" + _ensure_dev_log_dir() + log_path = DEV_LOG_DIR / log_filename + run_env = os.environ.copy() + if env_extra: + run_env.update(env_extra) + tout = None if timeout_seconds <= 0 else timeout_seconds + with open(log_path, "w", encoding="utf-8") as logf: + proc = subprocess.Popen( + ["make", make_target], + cwd=REPO_ROOT, + env=run_env, + stdout=logf, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + try: + returncode = proc.wait(timeout=tout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + tail = _tail_file(log_path, 80) + return ( + f"{tool_label} timed out after {timeout_seconds}s. log: {log_path}\n" + f"--- tail ---\n{tail}" + ) + if returncode != 0: + tail = _tail_file(log_path, 80) + return ( + f"{tool_label} failed (exit {returncode}). log: {log_path}\n" + f"--- tail ---\n{tail}" + ) + tail = _tail_file(log_path, 40) + return f"{tool_label} succeeded (exit 0). log: {log_path}\n--- last lines ---\n{tail}" + + +@mcp.tool() +def run_e2e( + bigquery_credentials_file: str | None = None, + timeout_seconds: int = 7200, +) -> str: + """Run ``make e2e`` (sets ``GCS_SA_JSON_PATH`` from the same SA JSON as other MCP tools). + + Log: ``sippy-dev-logs/run_e2e.log``. E2e is slow and uses BigQuery; use ``timeout_seconds=0`` + for no limit. + """ + creds_path, err = _resolve_bigquery_creds(bigquery_credentials_file) + if err: + return err + return _run_make_phase( + "run_e2e", + "e2e", + "run_e2e.log", + timeout_seconds, + {"GCS_SA_JSON_PATH": str(creds_path)}, + ) + + +if __name__ == "__main__": + mcp.run() diff --git a/mcp/test_server.py b/mcp/test_server.py new file mode 100644 index 000000000..d45ed78af --- /dev/null +++ b/mcp/test_server.py @@ -0,0 +1,191 @@ +import os +import tempfile +from pathlib import Path +from unittest import mock + +import pytest + +from server import ( + REPO_ROOT, + _default_database_dsn, + _default_redis_url, + _repo_path, + _resolve_bigquery_creds, + _trim, +) + + +class TestRepoPath: + def test_relative_path(self): + result = _repo_path("config/views.yaml") + assert result == (REPO_ROOT / "config" / "views.yaml").resolve() + + def test_nested_relative_path(self): + result = _repo_path("a/b/c") + assert result == (REPO_ROOT / "a" / "b" / "c").resolve() + + def test_dot_relative(self): + result = _repo_path("./config/views.yaml") + assert result == (REPO_ROOT / "config" / "views.yaml").resolve() + + def test_rejects_dotdot_escape(self): + with pytest.raises(ValueError, match="path escapes repo root"): + _repo_path("../etc/passwd") + + def test_rejects_nested_dotdot_escape(self): + with pytest.raises(ValueError, match="path escapes repo root"): + _repo_path("foo/../../..") + + def test_rejects_absolute_outside_repo(self): + with pytest.raises(ValueError, match="path escapes repo root"): + _repo_path("/etc/passwd") + + def test_absolute_inside_repo(self): + inner = str(REPO_ROOT / "config" / "views.yaml") + result = _repo_path(inner) + assert result == Path(inner).resolve() + + def test_tilde_expansion_outside_repo(self): + with pytest.raises(ValueError, match="path escapes repo root"): + _repo_path("~/something") + + def test_repo_root_itself(self): + result = _repo_path(str(REPO_ROOT)) + assert result == REPO_ROOT.resolve() + + def test_normalizes_redundant_slashes(self): + result = _repo_path("config///views.yaml") + assert result == (REPO_ROOT / "config" / "views.yaml").resolve() + + def test_normalizes_inner_dotdot(self): + result = _repo_path("config/../config/views.yaml") + assert result == (REPO_ROOT / "config" / "views.yaml").resolve() + + +class TestTrim: + def test_short_string_unchanged(self): + assert _trim("hello") == "hello" + + def test_at_limit_unchanged(self): + s = "x" * 100 + assert _trim(s, max_len=100) == s + + def test_over_limit_truncated(self): + s = "A" * 30000 + result = _trim(s, max_len=1000) + assert len(result) < len(s) + assert "characters omitted" in result + head = 500 + tail = 1000 - 500 - 120 + assert result.startswith("A" * head) + assert result.endswith("A" * tail) + + def test_omission_count_correct(self): + s = "x" * 30000 + result = _trim(s, max_len=1000) + head = 500 + tail = 1000 - 500 - 120 + expected_omitted = 30000 - head - tail + assert str(expected_omitted) in result + + +class TestResolveBigqueryCreds: + def test_explicit_path_exists(self): + with tempfile.NamedTemporaryFile(suffix=".json") as f: + path, err = _resolve_bigquery_creds(f.name) + assert err is None + assert path == Path(f.name).resolve() + + def test_explicit_path_missing(self): + path, err = _resolve_bigquery_creds("/nonexistent/creds.json") + assert path is None + assert "not found" in err + + def test_env_var_fallback(self): + with tempfile.NamedTemporaryFile(suffix=".json") as f: + with mock.patch.dict( + os.environ, + {"SIPPY_BIGQUERY_CREDENTIALS_FILE": f.name}, + clear=False, + ): + path, err = _resolve_bigquery_creds(None) + assert err is None + assert path == Path(f.name).resolve() + + def test_env_var_second_fallback(self): + with tempfile.NamedTemporaryFile(suffix=".json") as f: + env = { + "GOOGLE_APPLICATION_CREDENTIALS": f.name, + } + with mock.patch.dict(os.environ, env, clear=False): + os.environ.pop("SIPPY_BIGQUERY_CREDENTIALS_FILE", None) + path, err = _resolve_bigquery_creds(None) + assert err is None + assert path == Path(f.name).resolve() + + def test_env_var_file_missing(self): + with mock.patch.dict( + os.environ, + {"SIPPY_BIGQUERY_CREDENTIALS_FILE": "/nonexistent/sa.json"}, + clear=False, + ): + path, err = _resolve_bigquery_creds(None) + assert path is None + assert "SIPPY_BIGQUERY_CREDENTIALS_FILE" in err + assert "not found" in err + + def test_no_creds_anywhere(self): + with mock.patch.dict(os.environ, clear=False): + os.environ.pop("SIPPY_BIGQUERY_CREDENTIALS_FILE", None) + os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None) + path, err = _resolve_bigquery_creds(None) + assert path is None + assert "Set bigquery_credentials_file" in err + + def test_explicit_overrides_env(self): + with tempfile.NamedTemporaryFile(suffix=".json") as explicit: + with tempfile.NamedTemporaryFile(suffix=".json") as env_file: + with mock.patch.dict( + os.environ, + {"SIPPY_BIGQUERY_CREDENTIALS_FILE": env_file.name}, + clear=False, + ): + path, err = _resolve_bigquery_creds(explicit.name) + assert err is None + assert path == Path(explicit.name).resolve() + + def test_first_env_var_takes_priority(self): + with tempfile.NamedTemporaryFile(suffix=".json") as first: + with tempfile.NamedTemporaryFile(suffix=".json") as second: + env = { + "SIPPY_BIGQUERY_CREDENTIALS_FILE": first.name, + "GOOGLE_APPLICATION_CREDENTIALS": second.name, + } + with mock.patch.dict(os.environ, env, clear=False): + path, err = _resolve_bigquery_creds(None) + assert err is None + assert path == Path(first.name).resolve() + + +class TestDefaults: + def test_database_dsn_default(self): + with mock.patch.dict(os.environ, clear=False): + os.environ.pop("SIPPY_DATABASE_DSN", None) + assert _default_database_dsn() == "postgresql://postgres:password@localhost:5432/postgres" + + def test_database_dsn_from_env(self): + with mock.patch.dict( + os.environ, {"SIPPY_DATABASE_DSN": "postgresql://other:5433/db"}, clear=False + ): + assert _default_database_dsn() == "postgresql://other:5433/db" + + def test_redis_url_default(self): + with mock.patch.dict(os.environ, clear=False): + os.environ.pop("REDIS_URL", None) + assert _default_redis_url() == "redis://localhost:6379" + + def test_redis_url_from_env(self): + with mock.patch.dict( + os.environ, {"REDIS_URL": "redis://other:6380"}, clear=False + ): + assert _default_redis_url() == "redis://other:6380" diff --git a/sippy-ng/AGENTS.md b/sippy-ng/AGENTS.md index 7cb53b962..8cc348b84 100644 --- a/sippy-ng/AGENTS.md +++ b/sippy-ng/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md - + ## Files matching `sippy-ng/**` @@ -21,4 +21,4 @@ The frontend uses `npm`. If you must install or update any dependencies, always --- *This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `specify apm compile`* +*To regenerate: `apm compile`* diff --git a/sippy-ng/CLAUDE.md b/sippy-ng/CLAUDE.md index d9d63e658..97779f3d4 100644 --- a/sippy-ng/CLAUDE.md +++ b/sippy-ng/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md - + # Project Standards