From f66f7ff6cac68be4d6b460c49e53b2c2ac421696 Mon Sep 17 00:00:00 2001 From: Ashwin Govind Ugale Date: Sat, 8 Aug 2026 14:57:26 -0400 Subject: [PATCH] ci: add GitHub Actions workflow (ruff, mypy, pytest, evals, tsc) Adds .github/workflows/ci.yml running on push to main and on PRs, so branch protection can require a status check. Two jobs mirror scripts/check.ps1: - sidecar (Python 3.11 + 3.12): uv sync, ruff check, ruff format --check, mypy strict, pytest, plus the evals suite via PYTHONPATH. - extension (Node 20): npm ci, tsc --noEmit. CI is hermetic and keyless: OpenAI/Qdrant are mocked in tests, so a placeholder OPENAI_API_KEY only satisfies the entrypoint's presence guard, and the live_subprocess tests (which reach for a real Qdrant) are deselected. Also fixes issues the strict CI surfaced that the local --fix pipeline had been masking: - test_ws_auth.py: combine nested with-statements (ruff SIM117) - __main__.py, agent.py: apply ruff formatter (format --check) - uv.lock: relock stale package version (1.0.0 -> 1.0.2) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 102 ++++++++++++++++++++++++ sidecar/src/docchat_sidecar/__main__.py | 2 +- sidecar/src/docchat_sidecar/agent.py | 7 +- sidecar/tests/test_ws_auth.py | 24 +++--- sidecar/uv.lock | 2 +- 5 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..94c9c8b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + # --------------------------------------------------------------------------- + # Python sidecar + evals harness. Mirrors scripts/check.ps1: ruff (no --fix), + # ruff format --check, mypy strict, pytest, then the evals suite run against + # the sidecar package via PYTHONPATH. + # --------------------------------------------------------------------------- + sidecar: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + env: + # The sidecar's entrypoint fail-fasts on a missing OPENAI_API_KEY (so the + # extension can surface a "set your key" action). The test suite mocks the + # OpenAI client, so it never makes a real call — this placeholder only has + # to be non-empty to satisfy that import guard. It is NOT a secret. + OPENAI_API_KEY: sk-ci-placeholder-not-a-real-key + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Sync dependencies + working-directory: sidecar + run: uv sync + + - name: Ruff check + working-directory: sidecar + run: uv run ruff check . + + - name: Ruff format --check + working-directory: sidecar + run: uv run ruff format --check . + + - name: Mypy (strict) + working-directory: sidecar + run: uv run mypy src + + # live_subprocess tests spawn the real uvicorn sidecar, which reaches for a + # live Qdrant on a TCP connect — out of scope for a hermetic, keyless CI. + - name: Pytest + working-directory: sidecar + run: uv run pytest -m "not live_subprocess" + + # Evals live outside the sidecar package and import the agent lazily, so + # they need both the repo root and sidecar/src on PYTHONPATH. The -c flag + # points pytest at the eval-specific ini (asyncio_mode=auto). + - name: Evals ruff check + working-directory: sidecar + run: uv run ruff check ../evals + + - name: Evals pytest + working-directory: sidecar + env: + PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/sidecar/src + run: uv run pytest -c ../evals/pytest.ini ../evals/tests + + # --------------------------------------------------------------------------- + # TypeScript VS Code extension. No bundler: type-check with tsc directly. + # --------------------------------------------------------------------------- + extension: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: extension/package-lock.json + + - name: Install dependencies + working-directory: extension + run: npm ci + + - name: Type-check + working-directory: extension + run: npx --no-install tsc --noEmit + + # ESLint is a placeholder until the config lands; run it only if present, + # matching scripts/check.ps1. + - name: ESLint (if configured) + working-directory: extension + run: | + if [ -f .eslintrc.cjs ]; then + npx --no-install eslint src --max-warnings=0 + else + echo "No .eslintrc.cjs — skipping ESLint (matches scripts/check.ps1)." + fi diff --git a/sidecar/src/docchat_sidecar/__main__.py b/sidecar/src/docchat_sidecar/__main__.py index 4d15d8b..0144588 100644 --- a/sidecar/src/docchat_sidecar/__main__.py +++ b/sidecar/src/docchat_sidecar/__main__.py @@ -52,7 +52,7 @@ ) print( "OPENAI_API_KEY is not set. DocChat uses OpenAI's embeddings API " - "for retrieval. Run \"DocChat: Set OpenAI API Key\" from the " + 'for retrieval. Run "DocChat: Set OpenAI API Key" from the ' "command palette to store one in VS Code SecretStorage.", file=sys.stderr, flush=True, diff --git a/sidecar/src/docchat_sidecar/agent.py b/sidecar/src/docchat_sidecar/agent.py index fc01a44..c1518d2 100644 --- a/sidecar/src/docchat_sidecar/agent.py +++ b/sidecar/src/docchat_sidecar/agent.py @@ -413,9 +413,7 @@ async def _is_library_topic( """ pins_clause = "" if pinned_libraries: - pin_summary = ", ".join( - f"{lib}@{ver}" for lib, ver in sorted(pinned_libraries.items()) - ) + pin_summary = ", ".join(f"{lib}@{ver}" for lib, ver in sorted(pinned_libraries.items())) pins_clause = ( f"\n\nThe user's project pins these libraries: {pin_summary}. " "If the question is plausibly about how to do something in " @@ -525,8 +523,7 @@ def _finalize( the "Sources:" block is appended. """ refused_flag = ( - refused if refused is not None - else _CANONICAL_REFUSAL.lower() in answer_text.lower() + refused if refused is not None else _CANONICAL_REFUSAL.lower() in answer_text.lower() ) if citations: citation_block = " ".join(c.render() for c in _dedupe_citations(citations)) diff --git a/sidecar/tests/test_ws_auth.py b/sidecar/tests/test_ws_auth.py index e48da8d..7aead32 100644 --- a/sidecar/tests/test_ws_auth.py +++ b/sidecar/tests/test_ws_auth.py @@ -28,9 +28,11 @@ def test_rejects_browser_origin(monkeypatch: pytest.MonkeyPatch, origin: str) -> # A malicious web page connects with an http(s) Origin -> rejected even # when no token is configured. monkeypatch.delenv("DOCCHAT_WS_TOKEN", raising=False) - with pytest.raises(WebSocketDisconnect): - with _client().websocket_connect("/chat", headers={"origin": origin}) as ws: - _ping_pong(ws) + with ( + pytest.raises(WebSocketDisconnect), + _client().websocket_connect("/chat", headers={"origin": origin}) as ws, + ): + _ping_pong(ws) def test_allows_when_no_token_configured(monkeypatch: pytest.MonkeyPatch) -> None: @@ -43,16 +45,20 @@ def test_allows_when_no_token_configured(monkeypatch: pytest.MonkeyPatch) -> Non def test_missing_token_rejected_when_configured(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DOCCHAT_WS_TOKEN", "secret-nonce") - with pytest.raises(WebSocketDisconnect): - with _client().websocket_connect("/chat") as ws: - _ping_pong(ws) + with ( + pytest.raises(WebSocketDisconnect), + _client().websocket_connect("/chat") as ws, + ): + _ping_pong(ws) def test_wrong_token_rejected_when_configured(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("DOCCHAT_WS_TOKEN", "secret-nonce") - with pytest.raises(WebSocketDisconnect): - with _client().websocket_connect("/chat?token=wrong") as ws: - _ping_pong(ws) + with ( + pytest.raises(WebSocketDisconnect), + _client().websocket_connect("/chat?token=wrong") as ws, + ): + _ping_pong(ws) def test_correct_token_allowed(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/sidecar/uv.lock b/sidecar/uv.lock index 57e8a39..f2bd289 100644 --- a/sidecar/uv.lock +++ b/sidecar/uv.lock @@ -238,7 +238,7 @@ wheels = [ [[package]] name = "docchat-sidecar" -version = "1.0.0" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },