diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..05a7337
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+version: 2
+updates:
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed0bd40..56df09a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+permissions:
+ contents: read
+
jobs:
verify:
name: Verify Stability
@@ -46,10 +49,10 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: 'npm'
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..313d1dd
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,33 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: '0 0 * * 1'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ security-events: write
+
+ strategy:
+ matrix:
+ language: ['javascript-typescript']
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@411c4c9a36b3fca4d674f06b6396b2c6d23522c6 # v3.36.3
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..15205df
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,28 @@
+# Changelog
+
+## v0.9.2
+
+### What's New
+
+- **UI Redesign:** Full visual overhaul of the dashboard, landing page, and auth flows with an updated color system, improved dark mode, and a cleaner layout overall.
+- **Code Splitting:** All pages now use `React.lazy` for async loading, reducing initial bundle size and improving load times.
+- **Custom LLM Providers:** Manage custom OpenAI-compatible API providers directly from the dashboard. Rate limits are optional. No more hardcoded keys in wrangler config.
+- **Cloudflare Setup Script:** A new Node.js script automates the full Cloudflare deployment setup, making self-hosting significantly easier.
+- **Increased Review Capacity:** Max files processed per review raised from 15 to 100.
+
+### Improvements
+
+- Review jobs are now resumable and lease-aware, stalled reviews can recover automatically.
+- Added retry logic for transient model provider failures.
+- Optimized job polling with ETag caching and adaptive delays.
+- Improved settings UI, error reporting, and API robustness across the board.
+- Added GitHub Actions CI with a disposable test environment for the full test suite.
+
+### Bug Fixes
+
+- Fixed `APP_PRIVATE_KEY` parsing for single-line strings with literal `\n` sequences.
+- Fixed database migration failures on existing deployments.
+- Fixed duplicate `/api/auth/updates-email` calls on page load.
+- Fixed file review status bar rendering on the dashboard.
+
+**Full Changelog**: https://github.com/devarshishimpi/codra/commits/v0.9.2
diff --git a/README.md b/README.md
index 13d2b6b..968e084 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,6 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- Mention-triggered reviews for on-demand analysis
- Inline GitHub review comments plus summary reviews and check run updates
- Queue-backed processing through Cloudflare Queues
-- Dead letter queue inspection, replay, and purge workflows
- GitHub OAuth dashboard authentication
- External PostgreSQL storage through Cloudflare Hyperdrive
- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models
@@ -66,7 +65,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- **Worker**: Cloudflare Workers, Hono, Wrangler
- **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts
- **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV
-- **Queues**: Cloudflare Queues with DLQ workflows
+- **Queues**: Cloudflare Queues and Workflows
- **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers
- **GitHub**: GitHub App webhooks, checks, reviews, and OAuth
- **Quality**: TypeScript, Zod, Vitest, Playwright browser tests
diff --git a/db/migrations/004_review_performance_settings.sql b/db/migrations/004_review_performance_settings.sql
new file mode 100644
index 0000000..794c252
--- /dev/null
+++ b/db/migrations/004_review_performance_settings.sql
@@ -0,0 +1,4 @@
+INSERT INTO global_settings (key, value) VALUES
+ ('review_concurrency_level', 'medium'),
+ ('review_max_comments', '10')
+ON CONFLICT (key) DO NOTHING;
diff --git a/db/migrations/005_drop_model_rate_limits.sql b/db/migrations/005_drop_model_rate_limits.sql
new file mode 100644
index 0000000..a0daa35
--- /dev/null
+++ b/db/migrations/005_drop_model_rate_limits.sql
@@ -0,0 +1,3 @@
+ALTER TABLE model_configs DROP COLUMN IF EXISTS rpm;
+ALTER TABLE model_configs DROP COLUMN IF EXISTS tpm;
+ALTER TABLE model_configs DROP COLUMN IF EXISTS rpd;
diff --git a/db/migrations/006_job_continuation_count.sql b/db/migrations/006_job_continuation_count.sql
new file mode 100644
index 0000000..2e2cc8b
--- /dev/null
+++ b/db/migrations/006_job_continuation_count.sql
@@ -0,0 +1,5 @@
+-- Tracks how many times a job has rescheduled the *same* phase without completing any
+-- file review (a "no-progress continuation"). Reset to 0 whenever a chunk makes progress.
+-- A hard ceiling on this counter (see MAX_JOB_CONTINUATIONS in review.ts) stops a job that
+-- can never make headway from churning indefinitely on transient/budget deferrals.
+ALTER TABLE jobs ADD COLUMN IF NOT EXISTS continuation_count INT NOT NULL DEFAULT 0;
diff --git a/db/migrations/007_file_review_async_batch.sql b/db/migrations/007_file_review_async_batch.sql
new file mode 100644
index 0000000..456cff2
--- /dev/null
+++ b/db/migrations/007_file_review_async_batch.sql
@@ -0,0 +1,7 @@
+-- Supports the Workers AI asynchronous Batch API path (see submitCloudflareBatch/pollCloudflareBatch).
+-- A file review submitted to the async queue is persisted with file_status = 'pending' plus the
+-- queue request_id, so a later review-phase invocation can poll for the result across invocations
+-- (decoupling long/reasoning-model inference from any single invocation's timeout & subrequest cap).
+-- Both columns are cleared once the batch completes and the review is persisted as 'done'/'failed'.
+ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS async_request_id TEXT;
+ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS async_model TEXT;
diff --git a/db/migrations/008_job_cancelled_status.sql b/db/migrations/008_job_cancelled_status.sql
new file mode 100644
index 0000000..70555b4
--- /dev/null
+++ b/db/migrations/008_job_cancelled_status.sql
@@ -0,0 +1,6 @@
+-- Adds a terminal 'cancelled' status for jobs that a user explicitly stops (see the /stop
+-- endpoint). Distinct from 'failed' (nothing went wrong) and 'superseded' (replaced by a newer
+-- job). The status column is TEXT + CHECK (not the native enum), so we just widen the CHECK.
+ALTER TABLE jobs DROP CONSTRAINT IF EXISTS jobs_status_check;
+ALTER TABLE jobs ADD CONSTRAINT jobs_status_check
+ CHECK (status IN ('queued', 'running', 'done', 'failed', 'superseded', 'cancelled'));
diff --git a/package-lock.json b/package-lock.json
index 95516b1..cf5aeb8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"clsx": "^2.1.1",
"hono": "^4.12.12",
"jsonrepair": "^3.13.3",
+ "lenis": "^1.3.25",
"lucide-react": "^1.8.0",
"motion": "^12.42.2",
"picomatch": "^4.0.4",
@@ -4772,6 +4773,37 @@
"node": ">=6"
}
},
+ "node_modules/lenis": {
+ "version": "1.3.25",
+ "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.25.tgz",
+ "integrity": "sha512-mOKxayErlaONK8fm4LN3XNd99Qu4plTpn9h9qf8wxzjGrJDzuD84FYzZ81HCd6ZsWp++VWVwOzL286Pf2s2u4A==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*",
+ "playground",
+ "playground/*"
+ ],
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/darkroomengineering"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "react": ">=17.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
diff --git a/package.json b/package.json
index 682e74a..2fce114 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"clsx": "^2.1.1",
"hono": "^4.12.12",
"jsonrepair": "^3.13.3",
+ "lenis": "^1.3.25",
"lucide-react": "^1.8.0",
"motion": "^12.42.2",
"picomatch": "^4.0.4",
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index bff796f..07ae8b5 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -271,15 +271,6 @@ async function ensureModelCatalog() {
await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS provider_id UUID');
await query('ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS model_name TEXT');
- await query('ALTER TABLE model_configs ALTER COLUMN rpm DROP NOT NULL');
- await query('ALTER TABLE model_configs ALTER COLUMN tpm DROP NOT NULL');
- await query('ALTER TABLE model_configs ALTER COLUMN rpd DROP NOT NULL');
- await query(`
- UPDATE model_configs
- SET rpm = NULL, tpm = NULL, rpd = NULL, updated_at = now()
- WHERE rpm = 1 AND tpm = 1 AND rpd = 1
- `);
-
await query(
`
UPDATE model_configs mc
@@ -317,14 +308,11 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT $1, 10, 131072, 300, 'cloudflare', p.id, $1, now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT $1, 'cloudflare', p.id, $1, now()
FROM llm_providers p
WHERE p.name = 'Cloudflare'
ON CONFLICT (model_id) DO UPDATE SET
- rpm = EXCLUDED.rpm,
- tpm = EXCLUDED.tpm,
- rpd = EXCLUDED.rpd,
provider = EXCLUDED.provider,
provider_id = EXCLUDED.provider_id,
model_name = EXCLUDED.model_name,
@@ -335,14 +323,11 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT '@cf/zai-org/glm-4.7-flash', 20, 131072, 600, 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT '@cf/zai-org/glm-4.7-flash', 'cloudflare', p.id, '@cf/zai-org/glm-4.7-flash', now()
FROM llm_providers p
WHERE p.name = 'Cloudflare'
ON CONFLICT (model_id) DO UPDATE SET
- rpm = EXCLUDED.rpm,
- tpm = EXCLUDED.tpm,
- rpd = EXCLUDED.rpd,
provider = EXCLUDED.provider,
provider_id = EXCLUDED.provider_id,
model_name = EXCLUDED.model_name,
@@ -352,8 +337,8 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT 'gemma-4-31b-it', 15, 1000000, 1500, 'gemini', p.id, 'gemma-4-31b-it', now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT 'gemma-4-31b-it', 'gemini', p.id, 'gemma-4-31b-it', now()
FROM llm_providers p
WHERE p.name = 'Google'
ON CONFLICT (model_id) DO UPDATE SET
@@ -366,8 +351,8 @@ async function ensureModelCatalog() {
await query(
`
- INSERT INTO model_configs (model_id, rpm, tpm, rpd, provider, provider_id, model_name, updated_at)
- SELECT 'gemma-4-26b-a4b-it', 30, 1000000, 1500, 'gemini', p.id, 'gemma-4-26b-a4b-it', now()
+ INSERT INTO model_configs (model_id, provider, provider_id, model_name, updated_at)
+ SELECT 'gemma-4-26b-a4b-it', 'gemini', p.id, 'gemma-4-26b-a4b-it', now()
FROM llm_providers p
WHERE p.name = 'Google'
ON CONFLICT (model_id) DO UPDATE SET
diff --git a/src/client/app.css b/src/client/app.css
index b761a31..289d725 100644
--- a/src/client/app.css
+++ b/src/client/app.css
@@ -903,8 +903,7 @@
gap: 0.625rem !important;
padding: 0.75rem 0.875rem !important;
border-radius: 0.625rem !important;
- border-width: 1px !important;
- border-style: solid !important;
+ border: none !important;
font-family: var(--font-sans) !important;
font-size: 0.8125rem !important;
line-height: 1.45 !important;
@@ -915,7 +914,6 @@
/* light defaults (overridden per-variant below) */
background: oklch(99.5% 0.004 115) !important;
- border-color: oklch(88% 0.008 115) !important;
color: oklch(15% 0.02 115) !important;
/* smooth entrance */
@@ -924,7 +922,6 @@
.dark .codra-toast {
background: oklch(13% 0.018 115) !important;
- border-color: oklch(22% 0.022 115) !important;
color: oklch(94% 0.006 115) !important;
box-shadow:
0 6px 24px oklch(0% 0 0 / 0.5),
@@ -983,64 +980,9 @@
background: oklch(28% 0.022 115) !important;
}
-/* ── SUCCESS ─────────────────────────────────────── */
-.codra-toast-success {
- background: oklch(98.5% 0.045 115) !important;
- border-color: oklch(82% 0.16 115) !important;
- color: oklch(28% 0.10 115) !important;
-}
-
-.dark .codra-toast-success {
- background: oklch(16% 0.08 115) !important;
- border-color: oklch(32% 0.14 115) !important;
- color: oklch(90% 0.18 115) !important;
-}
-
-.codra-toast-success .codra-toast-description {
- color: oklch(38% 0.10 115) !important;
- opacity: 0.85 !important;
-}
-
-.dark .codra-toast-success .codra-toast-description {
- color: oklch(72% 0.12 115) !important;
- opacity: 0.9 !important;
-}
-
-/* ── ERROR ───────────────────────────────────────── */
-.codra-toast-error {
- background: oklch(98.5% 0.03 25) !important;
- border-color: oklch(80% 0.14 25) !important;
- color: oklch(32% 0.14 25) !important;
-}
-
-.dark .codra-toast-error {
- background: oklch(15% 0.07 25) !important;
- border-color: oklch(35% 0.14 25) !important;
- color: oklch(85% 0.08 25) !important;
-}
-
-.codra-toast-error .codra-toast-description {
- color: oklch(42% 0.12 25) !important;
- opacity: 0.85 !important;
-}
-
-.dark .codra-toast-error .codra-toast-description {
- color: oklch(68% 0.10 25) !important;
- opacity: 0.9 !important;
-}
-
-/* ── LOADING ─────────────────────────────────────── */
-.codra-toast-loading {
- background: oklch(98% 0.004 115) !important;
- border-color: oklch(86% 0.010 115) !important;
- color: oklch(20% 0.020 115) !important;
-}
-
-.dark .codra-toast-loading {
- background: oklch(14% 0.020 115) !important;
- border-color: oklch(24% 0.025 115) !important;
- color: oklch(88% 0.008 115) !important;
-}
+/* ── SUCCESS / ERROR / LOADING ─────────────────────
+ Use the default toast text color instead of a
+ status tint (icon color already conveys status). */
/* spinner inherits accent color */
.codra-toast-loader svg {
@@ -1049,26 +991,18 @@
/* ── WARNING ─────────────────────────────────────── */
.codra-toast-warning {
- background: oklch(98.5% 0.04 65) !important;
- border-color: oklch(82% 0.13 65) !important;
- color: oklch(35% 0.12 65) !important;
+ color: oklch(35% 0.12 65) !important;
}
.dark .codra-toast-warning {
- background: oklch(16% 0.08 65) !important;
- border-color: oklch(35% 0.14 65) !important;
- color: oklch(82% 0.14 65) !important;
+ color: oklch(82% 0.14 65) !important;
}
/* ── INFO ────────────────────────────────────────── */
.codra-toast-info {
- background: oklch(98.5% 0.03 250) !important;
- border-color: oklch(80% 0.12 250) !important;
- color: oklch(30% 0.12 250) !important;
+ color: oklch(30% 0.12 250) !important;
}
.dark .codra-toast-info {
- background: oklch(15% 0.07 250) !important;
- border-color: oklch(33% 0.12 250) !important;
- color: oklch(80% 0.12 250) !important;
+ color: oklch(80% 0.12 250) !important;
}
diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx
index c972f5e..c048610 100644
--- a/src/client/components/features/job-detail/file-finding.tsx
+++ b/src/client/components/features/job-detail/file-finding.tsx
@@ -23,7 +23,7 @@ export function FileFinding({ file }: FileFindingProps) {
-
+ {file.fileStatus === 'done' && }
{file.parsedComments.length > 0 && (
{file.parsedComments.length}
diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx
index 084eb1f..a1b4b32 100644
--- a/src/client/components/features/job-detail/job-findings-list.tsx
+++ b/src/client/components/features/job-detail/job-findings-list.tsx
@@ -1,8 +1,8 @@
import { useState } from 'react';
import { FileText } from 'lucide-react';
-import { cn } from '@client/lib/utils';
import type { JobDetail } from '@shared/schema';
import { reviewSeverities } from '@shared/schema';
+import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs';
import { FileFinding } from './file-finding';
import { CommentCard } from './comment-card';
import { severityConfig } from './constants';
@@ -24,22 +24,16 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
View by
-
- {(['files', 'severity'] as const).map((view) => (
-
- ))}
-
+
setViewBy(v as 'files' | 'severity')} variant="segment">
+
+
+ Files
+
+
+ Severity
+
+
+
@@ -59,6 +53,30 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
) : (
+ {job.files.some((f) => f.fileStatus === 'failed') && (
+
+ {/* Group header */}
+
+
+
+ Failed Files
+
+
+ {job.files.filter((f) => f.fileStatus === 'failed').length}
+
+
+ {/* Failed files list */}
+
+ {job.files.filter((f) => f.fileStatus === 'failed').map((file) => (
+
+ ))}
+
+
+ )}
+
{reviewSeverities.map((groupName) => {
const comments = job.files.flatMap((f) =>
f.parsedComments
diff --git a/src/client/components/features/job-detail/job-header.tsx b/src/client/components/features/job-detail/job-header.tsx
index 787ff00..6256049 100644
--- a/src/client/components/features/job-detail/job-header.tsx
+++ b/src/client/components/features/job-detail/job-header.tsx
@@ -1,16 +1,43 @@
+import { useState } from 'react';
import { Link } from 'react-router-dom';
-import { ChevronRight, ExternalLink, RotateCcw, Terminal } from 'lucide-react';
+import {
+ ChevronRight,
+ CircleStop,
+ ExternalLink,
+ Loader2,
+ RotateCcw,
+ Terminal,
+ Trash2,
+} from 'lucide-react';
import { Button } from '@client/components/ui/button';
+import { ConfirmDialog } from '@client/components/ui/confirm-dialog';
import { UpdatesEmailPrompt } from '@client/components/features/dashboard/updates-email-prompt';
import type { JobDetail } from '@shared/schema';
interface JobHeaderProps {
job: JobDetail;
- isRetrying: boolean;
- onRetry: () => void;
+ isRerunning: boolean;
+ isStopping: boolean;
+ isDeleting: boolean;
+ onRerun: () => void;
+ onStop: () => void;
+ onDelete: () => void;
}
-export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) {
+export function JobHeader({
+ job,
+ isRerunning,
+ isStopping,
+ isDeleting,
+ onRerun,
+ onStop,
+ onDelete,
+}: JobHeaderProps) {
+ const [stopOpen, setStopOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ const canStop = job.status === 'running' || job.status === 'queued';
+
return (
<>
@@ -43,24 +70,70 @@ export function JobHeader({ job, isRetrying, onRetry }: JobHeaderProps) {
-
-