Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 20 additions & 43 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,52 +1,29 @@
# ──────────────────────────────────────────────────────────────────────────────
# Codra Environment Configuration Example
# Copy this file to .dev.vars for local development: cp .dev.vars.example .dev.vars
# ──────────────────────────────────────────────────────────────────────────────
# Codra local development environment example
# Copy this file to .dev.vars for local development.
# Keep real secrets only in .dev.vars or your deployment secret store.

# --- GitHub App Authentication ---
# Create at: https://github.com/settings/apps
APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----"
GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID"
GITHUB_APP_SLUG="REPLACE_WITH_YOUR_APP_SLUG"
GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET"

# --- Dashboard OAuth (GitHub) ---
# Use the same GitHub App's Client ID/Secret or a separate OAuth App
GITHUB_CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID"
GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET"
AUTH_CALLBACK_URL="http://localhost:8787/auth/github/callback"

# --- Authorization ---
# Comma-separated list of GitHub usernames allowed to access the dashboard
DASHBOARD_ALLOWED_USERS="username1,username2"
# --- Integration tests ---
TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra"

# --- AI Intelligence (Gemini) ---
# Generate at: https://aistudio.google.com/app/apikey
# --- AI provider ---
GEMINI_API_KEY="REPLACE_WITH_YOUR_GEMINI_API_KEY"

# --- Database Connections ---

# 1. Local Development (Used by 'wrangler dev' for the HYPERDRIVE binding)
# This usually points to a local Postgres instance or a dev branch in Neon.
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://user:password@localhost:5432/codra_dev"

# 2. Migrations (Used by 'npm run migrate')
# This script runs via Node.js and needs a direct connection to the DB you want to migrate.
DATABASE_URL="postgresql://user:password@localhost:5432/codra_dev"

# 3. Integration Tests (Used by 'npm run test')
# MUST be a separate database to avoid data loss during test sweeps.
TEST_DATABASE_URL="postgresql://user:password@localhost:5432/codra_test"
# --- GitHub App and OAuth ---
GITHUB_APP_WEBHOOK_SECRET="REPLACE_WITH_YOUR_WEBHOOK_SECRET"
GITHUB_APP_ID="REPLACE_WITH_YOUR_APP_ID"
GITHUB_CLIENT_ID="REPLACE_WITH_YOUR_CLIENT_ID"
GITHUB_CLIENT_SECRET="REPLACE_WITH_YOUR_CLIENT_SECRET"
APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nREPLACE_WITH_YOUR_GITHUB_APP_PRIVATE_KEY_CONTENT\n-----END RSA PRIVATE KEY-----"

# --- Cloudflare DLQ / Queue Management (Required) ---
# Required for DLQ inspection, replay, and purge via /api/dlq
# Create or identify the DLQ queue, then set CF_DLQ_ID to that queue's ID.
# Generate token at https://dash.cloudflare.com/profile/api-tokens (Queues:Edit permission)
CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN"
# --- Cloudflare API ---
CF_ACCOUNT_ID="REPLACE_WITH_YOUR_CLOUDFLARE_ACCOUNT_ID"
CF_DLQ_ID="REPLACE_WITH_YOUR_DLQ_QUEUE_ID"
CF_API_TOKEN="REPLACE_WITH_CLOUDFLARE_API_TOKEN"

# --- Application Settings ---
# --- Application URLs and mode ---
APP_URL="http://localhost:8787"
BOT_USERNAME="codra-app-dev"
AUTH_CALLBACK_URL="http://localhost:8787/auth/github/callback"
ENVIRONMENT="development"

# --- Database connections ---
DATABASE_URL="postgresql://user:password@localhost:5432/codra_dev"
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://user:password@localhost:5432/codra_dev"
19 changes: 19 additions & 0 deletions .env.test.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Codra test environment example.
# Copy to .env.test for local tests. These values are fake and must not be
# reused for production, staging, or any real external service.

GITHUB_APP_SLUG="codra-test-app"
GITHUB_APP_WEBHOOK_SECRET="fake-webhook-secret"

GITHUB_CLIENT_ID="fake-dashboard-client-id"
GITHUB_CLIENT_SECRET="fake-dashboard-client-secret"
AUTH_CALLBACK_URL="https://codra.test/auth/github/callback"
DASHBOARD_ALLOWED_USERS="devarshishimpi"

APP_URL="https://codra.test"
BOT_USERNAME="codra-test-app"

# Required. Must point at a disposable Postgres database because tests reset and
# write data while running.
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/codra_test"
TEST_DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/codra_test"
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
name: Code Quality

on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
branches:
- main

Expand All @@ -16,6 +22,31 @@ jobs:
verify:
name: Verify Stability
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: codra_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/codra_test
TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/codra_test
GITHUB_APP_SLUG: codra-test-app
GITHUB_APP_WEBHOOK_SECRET: fake-webhook-secret
GITHUB_CLIENT_ID: fake-dashboard-client-id
GITHUB_CLIENT_SECRET: fake-dashboard-client-secret
AUTH_CALLBACK_URL: https://codra.test/auth/github/callback
APP_URL: https://codra.test
DASHBOARD_ALLOWED_USERS: devarshishimpi
BOT_USERNAME: codra-test-app

steps:
- name: Checkout repository
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ web_modules/
.env
.env.*
!.env.example
!.env.test.example

# parcel-bundler cache (https://parceljs.org/)
.cache
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ npm run dev

## 🧪 Testing

We use **Vitest** for unit and integration testing. `npm test` runs the non-database tests by default and automatically enables DB integration tests when `TEST_DATABASE_URL` points at a disposable Postgres database.
We use **Vitest** for unit and integration testing. `npm test` requires a disposable Postgres database, runs migrations against it, and then runs the full test suite.

The test runner loads `.env.test`, `.env.local`, `.env`, `.dev.vars`, and then `.env.test.example`. Override `TEST_DATABASE_URL` in one of the private env files when your local test database does not match the example URL.

```bash
# Run all tests
# Run the full test suite
npm test

# Run tests in watch mode
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"dev:worker": "wrangler dev --local",
"start": "npm run dev",
"migrate": "node scripts/migrate.mjs",
"test": "vitest run",
"test": "node scripts/test.mjs",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
Expand Down
77 changes: 77 additions & 0 deletions scripts/test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const envFiles = ['.env.test', '.env.local', '.env', '.dev.vars', '.env.test.example'];

function parseEnvValue(value) {
let trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
trimmed = trimmed.slice(1, -1);
}

return trimmed.replace(/\\n/g, '\n');
}

function usableEnvValue(value) {
return value && value !== 'undefined' && value !== 'null' ? value : null;
}

function loadEnvFiles() {
for (const file of envFiles) {
try {
const content = readFileSync(path.join(rootDir, file), 'utf8');
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;

const separatorIndex = trimmed.indexOf('=');
if (separatorIndex === -1) continue;

const key = trimmed.slice(0, separatorIndex).trim();
if (process.env[key] === undefined) {
process.env[key] = parseEnvValue(trimmed.slice(separatorIndex + 1));
}
}
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
}
}

function run(command, args) {
const result = spawnSync(command, args, {
cwd: rootDir,
env: process.env,
stdio: 'inherit',
});

if (result.error) {
throw result.error;
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}

loadEnvFiles();

if (!usableEnvValue(process.env.TEST_DATABASE_URL)) {
console.error([
'TEST_DATABASE_URL is required to run the full test suite.',
'Copy .env.test.example to .env.test and point TEST_DATABASE_URL at a disposable Postgres database.',
].join('\n'));
process.exit(1);
}

process.env.DATABASE_URL = usableEnvValue(process.env.DATABASE_URL) ?? process.env.TEST_DATABASE_URL;

run(process.execPath, ['scripts/migrate.mjs']);
run(process.execPath, ['node_modules/vitest/vitest.mjs', 'run']);
20 changes: 9 additions & 11 deletions test/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,9 @@ import type {
StatsResponse,
UpdatesEmailResponse,
} from '@shared/api';
import { createTestEnv, hasConfiguredTestDatabaseUrl } from './helpers';
import { createTestEnv } from './helpers';
import { vi } from 'vitest';

const dbIt = hasConfiguredTestDatabaseUrl() ? it : it.skip;

function mockGitHubProfile(login = 'devarshishimpi') {
return {
id: 42,
Expand Down Expand Up @@ -117,7 +115,7 @@ describe('Dashboard API Suite', () => {
expect(response.headers.get('location')).toBe('/login?error=not_allowed');
});

dbIt('allows access to /api/jobs with a valid GitHub session', async () => {
it('allows access to /api/jobs with a valid GitHub session', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);
const response = await app.request('/api/jobs', {
Expand Down Expand Up @@ -264,7 +262,7 @@ describe('Dashboard API Suite', () => {
expect(response.status).toBe(404);
});

dbIt('fetches job details accurately', async () => {
it('fetches job details accurately', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);

Expand Down Expand Up @@ -295,7 +293,7 @@ describe('Dashboard API Suite', () => {
expect(data.job.files).toBeDefined();
});

dbIt('fetches job details when stored comments have null code suggestions', async () => {
it('fetches job details when stored comments have null code suggestions', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);

Expand Down Expand Up @@ -349,7 +347,7 @@ describe('Dashboard API Suite', () => {
expect(data.job.files[0].parsedComments[0].codeSuggestion).toBeNull();
});

dbIt('returns stats successfully', async () => {
it('returns stats successfully', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);

Expand Down Expand Up @@ -428,7 +426,7 @@ describe('Dashboard API Suite', () => {
expect(response.status).toBe(400);
});

dbIt('returns repository list', async () => {
it('returns repository list', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);

Expand All @@ -453,7 +451,7 @@ describe('Dashboard API Suite', () => {
expect(response.headers.get('location')).toBe('https://github.com/apps/my-codra-install/installations/new');
});

dbIt('rejects invalid repository config patches', async () => {
it('rejects invalid repository config patches', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);
const repo = `invalid-config-${Date.now()}`;
Expand Down Expand Up @@ -481,7 +479,7 @@ describe('Dashboard API Suite', () => {
expect(response.status).toBe(400);
});

dbIt('rejects string booleans in repository config patches', async () => {
it('rejects string booleans in repository config patches', async () => {
const env = createTestEnv();
const token = await getAuthCookie(env);
const repo = `invalid-enabled-${Date.now()}`;
Expand Down Expand Up @@ -530,7 +528,7 @@ describe('Dashboard API Suite', () => {
expect(requestedUrl).toBe('https://api.github.com/repos/owner/repo/contents/src/path%20with%20spaces/app.ts');
});

dbIt('keeps repo model settings inherited when loading global strategy', async () => {
it('keeps repo model settings inherited when loading global strategy', async () => {
const env = createTestEnv();
const repo = `global-inherit-${Date.now()}`;

Expand Down
Loading
Loading