diff --git a/.github/ISSUE_TEMPLATE/release-readiness.yml b/.github/ISSUE_TEMPLATE/release-readiness.yml new file mode 100644 index 0000000..7635064 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release-readiness.yml @@ -0,0 +1,102 @@ +name: Release Readiness +description: Track release-readiness gates before deploying to production. +title: "Release readiness: " +labels: ["release"] +body: + - type: markdown + attributes: + value: | + Complete every section before approving production deployment. + Items marked ⛔ are **blocking** — the release cannot proceed until they are resolved. + + - type: checkboxes + id: ci + attributes: + label: "CI & Quality" + options: + - label: "⛔ All CI checks pass on the release commit (build, tests, lint, type-check)" + required: true + - label: "⛔ Test coverage meets the configured bar; no unticketted skip/xfail markers" + required: true + - label: "⛔ No plaintext secrets or credentials in the diff" + required: true + + - type: checkboxes + id: correctness + attributes: + label: "Code Correctness" + options: + - label: "⛔ No known correctness bugs" + required: true + - label: "⛔ All new behaviour covered by tests" + required: true + - label: "⛔ Irreversible changes have a documented rollback path" + required: true + + - type: checkboxes + id: security + attributes: + label: "Security" + options: + - label: "⛔ No new security gaps introduced" + required: true + - label: "Sensitive operations are audit-logged" + + - type: checkboxes + id: migrations + attributes: + label: "Database Migrations" + options: + - label: "Migrations follow expand → migrate → contract pattern" + - label: "Migrations are backward-compatible" + - label: "Rollback migration written and tested" + + - type: checkboxes + id: feature_flags + attributes: + label: "Feature Flags & Progressive Rollout" + options: + - label: "Risky changes are behind a feature flag (default off)" + - label: "Kill switch available and tested" + - label: "Rollout plan defined: canary → % rollout → 100%" + + - type: checkboxes + id: ops + attributes: + label: "Operational Readiness" + options: + - label: "Dashboards updated / new metrics added" + - label: "Alerts configured for new failure modes" + - label: "Runbook updated or created" + - label: "On-call team briefed" + + - type: checkboxes + id: docs + attributes: + label: "Documentation & Acceptance" + options: + - label: "API docs / OpenAPI spec updated" + - label: "Release notes / CHANGELOG entry added" + - label: "Acceptance criteria verified" + - label: "Stakeholder / PM sign-off recorded" + + - type: input + id: signoff + attributes: + label: Stakeholder sign-off + description: Link to approval or @mention the approver. + placeholder: "e.g. Approved by @pm in #releases — 2026-01-15" + validations: + required: true + + - type: textarea + id: rollback + attributes: + label: Rollback procedure + description: Step-by-step instructions to revert this release if it fails. + placeholder: | + 1. Run `./scripts/rollback.sh ` + 2. Verify health checks pass + 3. Notify on-call channel + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..f1bce19 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,73 @@ +## What & Why + + + +Closes # + +## How to verify + + + +1. +2. + +## Risk & rollback + + + +- **Risk:** +- **Rollback:** + +--- + +## Release-Readiness Checklist + +> Complete **every** item before requesting review. Items marked ⛔ are +> merge-blocking — the PR cannot land until they are resolved. + +### CI & Quality + +- [ ] ⛔ All CI checks pass (build, tests, lint, type-check) +- [ ] ⛔ Test coverage meets the bar; no new `skip`/`xfail` markers without a linked ticket +- [ ] ⛔ PR diff is under 400 lines — split if larger +- [ ] ⛔ No plaintext secrets or credentials in the diff + +### Code Correctness + +- [ ] ⛔ No known correctness bugs introduced +- [ ] ⛔ New behaviour is covered by at least one test +- [ ] ⛔ Irreversible changes (schema drops, data migrations) have a documented rollback path + +### Security + +- [ ] ⛔ No new security gaps (injection, auth bypass, exposed endpoints) +- [ ] Input validated and sanitised where applicable +- [ ] Sensitive operations are audit-logged + +### Database Migrations + +- [ ] Migration follows expand → migrate → contract pattern +- [ ] Migration is backward-compatible with the previous deploy artifact +- [ ] Rollback migration written and tested locally + +### Feature Flags & Progressive Rollout + +- [ ] Risky changes are behind a feature flag (default **off**) +- [ ] Kill switch is available and tested +- [ ] Rollout plan: canary → % rollout → 100% + +### Operational Readiness + +- [ ] Dashboards updated / new metrics added +- [ ] Alerts configured for new failure modes +- [ ] Runbook updated or created +- [ ] On-call team briefed if behaviour changes + +### Documentation & Acceptance + +- [ ] API docs / OpenAPI spec updated (if endpoints changed) +- [ ] Release notes / CHANGELOG entry added +- [ ] Acceptance criteria from the story verified +- [ ] Stakeholder / PM sign-off recorded (paste link or @mention below) + +**Sign-off:** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fc55dbd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── 1. PR size gate ────────────────────────────────────────────────────────── + pr-size: + name: PR size gate (< 400 lines) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Fetch diff size + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ADDITIONS=$(gh pr view ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --json additions,deletions \ + --jq '.additions + .deletions') + echo "Diff size: ${ADDITIONS} lines" + if [ "$ADDITIONS" -gt 400 ]; then + echo "::error::PR diff is ${ADDITIONS} lines — exceeds the 400-line review limit." + echo "::error::Split this PR into smaller, focused changes. See code-review-standards.md." + exit 1 + fi + + # ── 2. Backend: lint, type-check, tests, coverage ──────────────────────────── + backend: + name: Backend — tests & coverage + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install pytest-cov ruff mypy + + - name: Lint (ruff) + run: ruff check . + + - name: Type-check (mypy) + run: mypy app/ --ignore-missing-imports --strict-optional + + - name: Tests + coverage + run: | + pytest tests/ \ + --cov=app \ + --cov-report=term-missing \ + --cov-report=xml:coverage.xml \ + --cov-fail-under=50 \ + -v + env: + DATABASE_URL: "sqlite+aiosqlite:///:memory:" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: backend/coverage.xml + + # ── 3. Frontend: lint, type-check, build ───────────────────────────────────── + frontend: + name: Frontend — lint, type-check & build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint (eslint) + run: npm run lint + + - name: Type-check (tsc) + run: npx tsc --noEmit + + - name: Build + run: npm run build + + # ── 4. Gate summary — all required checks must pass ────────────────────────── + ci-gate: + name: CI gate (required) + if: always() + needs: [backend, frontend] + runs-on: ubuntu-latest + steps: + - name: Check required jobs + run: | + BACKEND="${{ needs.backend.result }}" + FRONTEND="${{ needs.frontend.result }}" + echo "backend=${BACKEND} frontend=${FRONTEND}" + if [ "$BACKEND" != "success" ] || [ "$FRONTEND" != "success" ]; then + echo "::error::One or more required CI jobs did not succeed." + exit 1 + fi + echo "All required checks passed." diff --git a/backend/agent/tools.py b/backend/agent/tools.py index 5e94f32..d68724e 100644 --- a/backend/agent/tools.py +++ b/backend/agent/tools.py @@ -70,7 +70,7 @@ def _resolve_dns_impl(target_url: str) -> str: for family, _, _, _, sockaddr in socket.getaddrinfo(host, None): ip = sockaddr[0] fam = "IPv6" if family == socket.AF_INET6 else "IPv4" - addrs.append({"family": fam, "address": ip}) + addrs.append({"family": fam, "address": ip}) # type: ignore[dict-item] # mixed str/int values except OSError as e: addrs = [{"error": str(e)}] diff --git a/backend/agent/vecna_litellm.py b/backend/agent/vecna_litellm.py index 7b04ee3..47736b1 100644 --- a/backend/agent/vecna_litellm.py +++ b/backend/agent/vecna_litellm.py @@ -77,7 +77,7 @@ async def _vecna_acompletion(*args: Any, **kwargs: Any): litellm_main.completion = _vecna_completion litellm_main.acompletion = _vecna_acompletion -import litellm as _litellm_pkg +import litellm as _litellm_pkg # noqa: E402 _litellm_pkg.completion = _vecna_completion _litellm_pkg.acompletion = _vecna_acompletion diff --git a/backend/app/main.py b/backend/app/main.py index 23f7cfe..abd3c96 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,12 +6,11 @@ import time import uuid from contextlib import asynccontextmanager -from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect +from fastapi import Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.responses import PlainTextResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import async_sessionmaker from app.config import Settings # loads agent.vecna_litellm (patches litellm for OpenRouter) diff --git a/backend/app/services/operation_context.py b/backend/app/services/operation_context.py index 96d0f5f..6e0b55f 100644 --- a/backend/app/services/operation_context.py +++ b/backend/app/services/operation_context.py @@ -117,4 +117,4 @@ def _done(f: asyncio.Future[None]) -> None: except asyncio.CancelledError: pass - fut.add_done_callback(_done) + fut.add_done_callback(_done) # type: ignore[arg-type] # asyncio.Future vs concurrent.futures.Future diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..ac3d1cc --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,7 @@ +[tool.pytest.ini_options] +# Add backend/ to sys.path so tests can import app.* +pythonpath = ["."] +testpaths = ["tests"] + +[tool.ruff] +exclude = [".venv"] diff --git a/backend/tests/test_errors.py b/backend/tests/test_errors.py index f597ee3..1c5bca3 100644 --- a/backend/tests/test_errors.py +++ b/backend/tests/test_errors.py @@ -2,7 +2,6 @@ Tests error classification and retry delay behavior for the agent runner. """ -import math import pytest from app.services.errors import ( diff --git a/backend/tests/test_http_client.py b/backend/tests/test_http_client.py index d0f8fca..47891f7 100644 --- a/backend/tests/test_http_client.py +++ b/backend/tests/test_http_client.py @@ -2,7 +2,7 @@ import json import time -from unittest.mock import MagicMock, patch +from unittest.mock import patch import httpx import pytest diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 5e6b472..1fef671 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -13,7 +13,8 @@ export default defineConfig([ js.configs.recommended, tseslint.configs.recommended, reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, + // allowConstantExport: shadcn/ui files export both component + variant helpers + { ...reactRefresh.configs.vite, rules: { 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] } }, ], languageOptions: { ecmaVersion: 2020, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d686ce..973a71a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -65,7 +65,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -275,10 +274,31 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -1301,7 +1321,6 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1312,7 +1331,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1323,7 +1341,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1373,7 +1390,6 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -1656,7 +1672,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1765,7 +1780,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1988,7 +2002,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2952,7 +2965,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3013,7 +3025,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3023,7 +3034,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3238,7 +3248,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3324,7 +3333,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3449,7 +3457,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac7751b..352ba26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -365,7 +365,7 @@ export default function App() { const delay = Math.min(WS_BASE_RECONNECT_MS * Math.pow(2, attempt - 1), 32000) setWsReconnecting(true) wsReconnectTimerRef.current = setTimeout(() => { - connectWs(operationId) + connectWs(operationId) // eslint-disable-line react-hooks/immutability -- safe: setTimeout cb runs after const is initialised }, delay) } else if ( !terminalStatuses.includes(current) &&