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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- Queue-backed processing through Cloudflare Queues
- GitHub OAuth dashboard authentication
- External PostgreSQL storage through Cloudflare Hyperdrive
- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models
- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, NVIDIA, and Cloudflare models
- Repository settings for labels, skipped globs, custom rules, and model routing

## How It Works
Expand All @@ -66,7 +66,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts
- **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV
- **Queues**: Cloudflare Queues and Workflows
- **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers
- **Models**: OpenAI, OpenRouter, Anthropic, Google, NVIDIA, and Cloudflare providers
- **GitHub**: GitHub App webhooks, checks, reviews, and OAuth
- **Quality**: TypeScript, Zod, Vitest, Playwright browser tests

Expand All @@ -77,6 +77,7 @@ The full setup and operations guides live at [codra.run/docs](https://codra.run/
- [Installation guide](https://codra.run/docs/installation)
- [Configuration guide](https://codra.run/docs/configuration)
- [Deploy with Neon](https://codra.run/docs/neon)
- [Deploy with Railway](https://codra.run/docs/railway)
- [Contributing](CONTRIBUTING.md)
- [Security policy](SECURITY.md)

Expand Down
9 changes: 9 additions & 0 deletions db/migrations/003_grounding.sql
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,12 @@ ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS batch_size INTEGER;
CREATE INDEX IF NOT EXISTS file_reviews_batch_size_idx
ON file_reviews (batch_size)
WHERE batch_size > 1;

-- Top level, not inside the $backfill$ block above: that block returns early on databases that
-- predate consolidation, and this seed has to reach every fresh database.
INSERT INTO llm_providers (name, api_format, base_url, enabled)
VALUES ('NVIDIA', 'openai', 'https://integrate.api.nvidia.com/v1', FALSE)
ON CONFLICT (name) DO UPDATE SET
api_format = EXCLUDED.api_format,
base_url = EXCLUDED.base_url,
updated_at = now();
18 changes: 6 additions & 12 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ export default tseslint.config(
'import-x/no-cycle': 'error',

// 400 lines, counting neither blanks nor comments, so adding an explanation never pushes a file
// over. Two files carry an explicit override below, each for a stated reason -- a third should
// be a split, not a third override.
// over. One file carries an explicit override below, for a stated reason -- a second should
// be a split, not a second override.
'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }],

// An error, not a warning: the four places whose dependency array is deliberately narrower
// An error, not a warning: the three places whose dependency array is deliberately narrower
// than their closure now carry a line-level disable stating why. A new violation should fail.
'react-hooks/exhaustive-deps': 'error',

Expand Down Expand Up @@ -107,19 +107,13 @@ export default tseslint.config(
},
},
{
// The two files still over the limit, each for a stated reason. Both are known work, not
// permanent carve-outs -- delete the entry rather than raising `max` when either is split.
//
// settings.tsx (552): SettingsPage is one component whose JSX reads nearly all of its 19 pieces
// of state, so its remaining sections do not align with extractable units -- lifting the
// providers section out means threading ~23 props. The support module, provider row, About
// section, review section and review-settings hook are already out (1096 -> 612 lines); what
// is left needs the provider load/CRUD split into a hook first.
// The one file still over the limit, for a stated reason. Known work, not a permanent
// carve-out -- delete the entry rather than raising `max` when it is split.
//
// test/api/auth.spec.ts (422): the review-settings suites here read-modify-write the same
// singleton `global_settings` row set and race across files once `fileParallelism` is on. See
// the DO-NOT-SPLIT header on the file itself.
files: ['src/client/pages/settings.tsx', 'test/api/auth.spec.ts'],
files: ['test/api/auth.spec.ts'],
rules: {
'max-lines': 'off',
},
Expand Down
1 change: 1 addition & 0 deletions scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ async function ensureModelCatalog() {
('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE),
('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE),
('xAI', 'openai', 'https://api.x.ai/v1', FALSE),
('NVIDIA', 'openai', 'https://integrate.api.nvidia.com/v1', FALSE),
('Vertex AI', 'vertex', NULL, FALSE)
ON CONFLICT (name) DO UPDATE SET
api_format = EXCLUDED.api_format,
Expand Down
127 changes: 127 additions & 0 deletions src/client/components/features/account/details-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { Mail } from 'lucide-react';
import { Text } from '@client/components/ui/text';
import { Select } from '@client/components/ui/select';
import { Skeleton } from '@client/components/shared/skeleton';
import { SectionCard } from '@client/components/shared/section-card';
import {
COMMON_TIME_ZONES,
DEFAULT_TIME_ZONE,
browserTimeZone,
formatDateTime,
resolvedTimeZone,
timeZoneOffsetLabel,
} from '@client/lib/timezone';
import type { AccountSettings, AuthSessionUser } from '@shared/api';

import { DetailGroup, RevealOnClick, DetailRow } from './detail-rows';

// No "Automatic" option: defaults to UTC so timestamps read the same for everyone; the browser's own zone is folded into the list.
function zoneOptions() {
const zones = Array.from(new Set([DEFAULT_TIME_ZONE, ...COMMON_TIME_ZONES, browserTimeZone()]))
.sort((a, b) => a.localeCompare(b));
return zones.map((zone) => {
const offset = timeZoneOffsetLabel(zone);
return { value: zone, label: offset ? `${zone} · ${offset}` : zone };
});
}

function formatDate(value: string) {
return formatDateTime(value, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
});
}

export function AccountDetailsSection({
user,
account,
pending,
displayName,
zonePref,
onZoneChange,
}: {
user: AuthSessionUser | null;
account: AccountSettings | null;
/** Render chrome with skeletons in place of content, so the card doesn't reflow when data lands. */
pending: boolean;
displayName: string;
zonePref: string | null;
onZoneChange: (zone: string) => void;
}) {
// Built once - a fresh array each render gave Select a new `options` identity, re-firing its highlight/measure effects and jittering the open panel.
const zoneOpts = useMemo(() => zoneOptions(), []);

return (
<SectionCard
title="Details"
>
<div className="space-y-4 p-5">
<DetailGroup caption="Profile">
<DetailRow label="Name" loading={pending} skeletonWidth={140}>
{displayName}
</DetailRow>
<DetailRow label="Email" loading={pending} skeletonWidth={170}>
{user?.email ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Mail size={13} className="shrink-0 text-ui-subtle" />
<span className="truncate">{user.email}</span>
</span>
) : (
<span className="font-normal text-ui-subtle">Not provided</span>
)}
</DetailRow>
</DetailGroup>

<DetailGroup caption="GitHub">
<DetailRow label="GitHub username" loading={pending} skeletonWidth={120}>
@{user?.login}
</DetailRow>
<DetailRow label="GitHub user ID" mono loading={pending} skeletonWidth={80}>
{user?.githubUserId}
</DetailRow>
</DetailGroup>

<DetailGroup caption="Codra account">
{(pending || account) && (
<DetailRow label="Account ID" mono loading={pending} skeletonWidth={230}>
<RevealOnClick label="account ID">{account?.id}</RevealOnClick>
</DetailRow>
)}
<DetailRow label="Signed in" mono loading={pending} skeletonWidth={190}>
{user ? formatDate(user.signedInAt) : null}
</DetailRow>

{/* Timestamps are stored absolute (UTC); this only controls how they're rendered. */}
<div className="flex items-center justify-between gap-4 px-4 py-3">
<span className="min-w-0 shrink-0">
<Text variant="body" size="sm" bold as="span" className="text-[13px] dark:text-ui-subtle">
Date &amp; time zone
</Text>
<span className="mt-0.5 block text-[11px] leading-tight text-ui-subtle">
Stored in UTC, shown in {resolvedTimeZone()}
</span>
</span>
{pending ? (
<Skeleton height={32} width={200} borderRadius={7} />
) : (
<div className="w-[15rem] shrink-0">
<Select
value={zonePref ?? DEFAULT_TIME_ZONE}
onValueChange={onZoneChange}
options={zoneOpts}
variant="card"
triggerClassName="h-8 px-2.5 text-[13px]"
/>
</div>
)}
</div>
</DetailGroup>
</div>
</SectionCard>
);
}
171 changes: 171 additions & 0 deletions src/client/components/features/account/profile-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@client/lib/api';
import { Button, LinkButton } from '@client/components/ui/button';
import { Input } from '@client/components/ui/input';
import { Badge } from '@client/components/ui/badge';
import { Skeleton } from '@client/components/shared/skeleton';
import { GithubMark } from '@client/components/shared/github-mark';
import { ExternalLink, Pencil, Check, X } from 'lucide-react';
import type { AccountSettings, AuthSessionUser } from '@shared/api';

export function ProfileCard({
user,
pending,
displayName,
initial,
profileUrl,
onAccountChange,
}: {
user: AuthSessionUser | null;
/** Render chrome with skeletons in place of content, so the card doesn't reflow when data lands. */
pending: boolean;
displayName: string;
initial: string;
profileUrl: string;
onAccountChange: (account: AccountSettings) => void;
}) {
const [editingName, setEditingName] = useState(false);
const [nameDraft, setNameDraft] = useState('');
const [savingName, setSavingName] = useState(false);

const startEditName = () => {
setNameDraft(displayName);
setEditingName(true);
};

const saveName = async () => {
const trimmed = nameDraft.trim();
if (!trimmed) {
toast.error('Name cannot be empty.');
return;
}
setSavingName(true);
try {
const res = await api.updateAccountName(trimmed);
onAccountChange(res.account);
setEditingName(false);
toast.success('Account name updated');
} catch (e) {
toast.error('Could not update name', {
description: e instanceof Error ? e.message : undefined,
});
} finally {
setSavingName(false);
}
};

return (
<section className="ui-panel min-w-0 overflow-hidden">
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:gap-5 sm:p-6">
{pending ? (
<Skeleton width={56} height={56} className="shrink-0 rounded-full" />
) : user!.avatarUrl ? (
<img
src={user!.avatarUrl}
alt=""
className="h-14 w-14 shrink-0 rounded-full object-cover ring-1 ring-ui-line"
/>
) : (
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-ui-fill text-xl font-semibold text-ui-strong ring-1 ring-ui-line">
{initial}
</span>
)}

<div className="min-w-0 flex-1">
{pending ? (
<div className="space-y-2.5">
<Skeleton height={17} width={190} borderRadius={5} />
<Skeleton height={12} width={120} borderRadius={4} />
</div>
) : editingName ? (
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Input
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void saveName();
if (e.key === 'Escape') setEditingName(false);
}}
autoFocus
maxLength={120}
aria-label="Account name"
className="h-9 w-full max-w-xs"
/>
<div className="flex items-center gap-1.5">
<Button
variant="primary"
size="sm"
onClick={saveName}
loading={savingName}
icon={<Check size={13} />}
>
Save
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingName(false)}
disabled={savingName}
icon={<X size={13} />}
className="text-ui-subtle hover:text-ui-default"
>
Cancel
</Button>
</div>
</div>
<p className="mt-2 text-[11px] text-ui-subtle">
Enter to save · Esc to cancel - this name is used across Codra.
</p>
</div>
) : (
<>
<div className="group/name flex min-w-0 items-center gap-1.5">
<h2
className="truncate text-lg font-bold text-ui-strong"
style={{ letterSpacing: '-0.01em' }}
>
{displayName}
</h2>
<button
type="button"
onClick={startEditName}
aria-label="Edit account name"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-ui-subtle transition-colors hover:bg-ui-fill hover:text-ui-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil size={13} />
</button>
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-[13px] text-ui-default dark:text-ui-subtle">
@{user!.login}
</span>
<Badge variant="secondary" className="shrink-0 gap-1.5">
<GithubMark size={11} />
GitHub
</Badge>
</div>
</>
)}
</div>

{pending ? (
<Skeleton width={148} height={32} borderRadius={6} className="shrink-0" />
) : (
<LinkButton
href={profileUrl}
external
variant="secondary"
size="sm"
icon={<GithubMark size={14} />}
className="shrink-0 self-start sm:self-auto"
>
View on GitHub
<ExternalLink size={12} className="text-ui-subtle" />
</LinkButton>
)}
</div>
</section>
);
}
Loading