Add agent workflows & stuff
Build and Push Next App / quality (push) Failing after 48s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown committed 2026-06-21 21:15:15 -05:00
1 parent cf7ff2ee4e
commit 2dfa97ee4f
102 files changed
+8488 -161

No files matched your search

@@ -0,0 +1,166 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAction, useQuery } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { GitFork } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, Input, Label, Textarea } from '@spoon/ui';
type FormState = {
upstreamOwner: string;
upstreamRepo: string;
forkName: string;
organization: string;
description: string;
};
const initialState: FormState = {
upstreamOwner: '',
upstreamRepo: '',
forkName: '',
organization: '',
description: '',
};
const createForkRef = makeFunctionReference<
'action',
{
upstreamOwner: string;
upstreamRepo: string;
name?: string;
description?: string;
organization?: string;
},
string
>('githubNode:createFork');
const TextField = ({
id,
label,
value,
onChange,
required,
placeholder,
}: {
id: keyof FormState;
label: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
placeholder?: string;
}) => (
<div className='grid gap-2'>
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
value={value}
required={required}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</div>
);
export const GitHubForkForm = () => {
const router = useRouter();
const connection = useQuery(api.github.getConnection, {});
const createFork = useAction(createForkRef);
const [form, setForm] = useState<FormState>(initialState);
const [submitting, setSubmitting] = useState(false);
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
await createFork({
upstreamOwner: form.upstreamOwner,
upstreamRepo: form.upstreamRepo,
name: form.forkName || undefined,
organization: form.organization || undefined,
description: form.description || undefined,
});
toast.success('Fork created and added as a Spoon.');
router.push('/spoons');
} catch (error) {
console.error(error);
toast.error('Could not create the GitHub fork.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<CardContent className='p-5'>
<div className='mb-5 flex gap-3'>
<div className='bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg'>
<GitFork className='size-5' />
</div>
<div>
<p className='font-medium'>Fork with GitHub</p>
<p className='text-muted-foreground mt-1 text-sm'>
Create a real GitHub fork through the connected GitHub App and
record it as a Spoon.
</p>
</div>
</div>
<form onSubmit={handleSubmit} className='grid gap-5'>
<div className='grid gap-4 md:grid-cols-2'>
<TextField
id='upstreamOwner'
label='Upstream owner'
value={form.upstreamOwner}
required
placeholder='vercel'
onChange={(value) => update('upstreamOwner', value)}
/>
<TextField
id='upstreamRepo'
label='Upstream repository'
value={form.upstreamRepo}
required
placeholder='next.js'
onChange={(value) => update('upstreamRepo', value)}
/>
<TextField
id='forkName'
label='Fork name'
value={form.forkName}
placeholder='Optional custom repository name'
onChange={(value) => update('forkName', value)}
/>
<TextField
id='organization'
label='Organization'
value={form.organization}
placeholder='Optional org target'
onChange={(value) => update('organization', value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='description'>Description</Label>
<Textarea
id='description'
value={form.description}
placeholder='Optional Spoon note'
onChange={(event) => update('description', event.target.value)}
/>
</div>
<div className='flex justify-end'>
<Button type='submit' disabled={!connection || submitting}>
{submitting ? 'Forking...' : 'Create GitHub fork'}
</Button>
</div>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,71 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent } from '@spoon/ui';
const formatDate = (value: number) =>
new Intl.DateTimeFormat('en', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value);
export const SpoonActivityTimeline = ({
syncRuns,
reviews,
requests,
}: {
syncRuns: Doc<'syncRuns'>[];
reviews: Doc<'aiReviews'>[];
requests: Doc<'agentRequests'>[];
}) => {
const items = [
...syncRuns.map((item) => ({
id: item._id,
kind: item.kind.replaceAll('_', ' '),
status: item.status,
summary: item.summary ?? item.error ?? 'Sync run recorded.',
time: item.createdAt,
})),
...reviews.map((item) => ({
id: item._id,
kind: 'AI review',
status: item.status,
summary: item.outputSummary ?? item.inputSummary,
time: item.createdAt,
})),
...requests.map((item) => ({
id: item._id,
kind: 'Agent request',
status: item.status,
summary: item.prompt,
time: item.createdAt,
})),
].sort((a, b) => b.time - a.time);
return (
<div className='space-y-3'>
{items.length ? (
items.map((item) => (
<Card key={item.id} className='shadow-none'>
<CardContent className='p-4'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{item.kind}</p>
<Badge variant='outline'>{item.status}</Badge>
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{item.summary}
</p>
<p className='text-muted-foreground mt-2 text-xs'>
{formatDate(item.time)}
</p>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Refreshes, AI reviews, and queued requests will build this timeline.
</CardContent>
</Card>
)}
</div>
);
};
@@ -0,0 +1,192 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { Bot } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
} from '@spoon/ui';
const efforts = ['minimal', 'low', 'medium', 'high', 'xhigh'] as const;
type AgentSettings = {
enabled: boolean;
defaultBaseBranch?: string;
branchPrefix: string;
installCommand?: string;
checkCommand?: string;
testCommand?: string;
agentModel: string;
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
};
export const SpoonAgentSettingsForm = ({
spoon,
settings,
}: {
spoon: Doc<'spoons'>;
settings?: AgentSettings | null;
}) => {
const update = useMutation(api.spoonAgentSettings.update);
const [enabled, setEnabled] = useState(settings?.enabled ?? true);
const [defaultBaseBranch, setDefaultBaseBranch] = useState(
settings?.defaultBaseBranch ??
spoon.forkDefaultBranch ??
spoon.upstreamDefaultBranch,
);
const [branchPrefix, setBranchPrefix] = useState(
settings?.branchPrefix ?? 'spoon/agent',
);
const [installCommand, setInstallCommand] = useState(
settings?.installCommand ?? '',
);
const [checkCommand, setCheckCommand] = useState(
settings?.checkCommand ?? '',
);
const [testCommand, setTestCommand] = useState(settings?.testCommand ?? '');
const [agentModel, setAgentModel] = useState(
settings?.agentModel ?? 'gpt-5.1-codex',
);
const [reasoningEffort, setReasoningEffort] = useState<
'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
>(
settings?.reasoningEffort === 'none'
? 'minimal'
: (settings?.reasoningEffort ?? 'high'),
);
const save = async () => {
try {
await update({
spoonId: spoon._id,
enabled,
defaultBaseBranch,
branchPrefix,
installCommand: installCommand || undefined,
checkCommand: checkCommand || undefined,
testCommand: testCommand || undefined,
agentModel,
reasoningEffort,
});
toast.success('Agent settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save agent settings.');
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<Bot className='size-4' />
Agent runtime
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<Label htmlFor='agentEnabled'>Enable agent jobs</Label>
<Switch
id='agentEnabled'
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='defaultBaseBranch'>Default base branch</Label>
<Input
id='defaultBaseBranch'
value={defaultBaseBranch}
onChange={(event) => setDefaultBaseBranch(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='branchPrefix'>Branch prefix</Label>
<Input
id='branchPrefix'
value={branchPrefix}
onChange={(event) => setBranchPrefix(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='agentModel'>Model</Label>
<Input
id='agentModel'
value={agentModel}
onChange={(event) => setAgentModel(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label>Reasoning effort</Label>
<Select
value={reasoningEffort}
onValueChange={(value) =>
setReasoningEffort(
value as 'minimal' | 'low' | 'medium' | 'high' | 'xhigh',
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{efforts.map((effort) => (
<SelectItem key={effort} value={effort}>
{effort}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='grid gap-2'>
<Label htmlFor='installCommand'>Install command</Label>
<Input
id='installCommand'
value={installCommand}
placeholder='bun install'
onChange={(event) => setInstallCommand(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='checkCommand'>Check command</Label>
<Input
id='checkCommand'
value={checkCommand}
placeholder='bun typecheck'
onChange={(event) => setCheckCommand(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='testCommand'>Test command</Label>
<Input
id='testCommand'
value={testCommand}
placeholder='bun test'
onChange={(event) => setTestCommand(event.target.value)}
/>
</div>
</div>
<Button type='button' onClick={save}>
Save agent settings
</Button>
</CardContent>
</Card>
);
};
@@ -0,0 +1,75 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
export const SpoonAiReviewPanel = ({
latestReview,
reviews,
}: {
latestReview?: Doc<'aiReviews'> | null;
reviews: Doc<'aiReviews'>[];
}) => (
<div className='space-y-4'>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Latest compatibility review</CardTitle>
</CardHeader>
<CardContent>
{latestReview ? (
<div className='space-y-4'>
<div className='flex flex-wrap gap-2'>
<Badge>{latestReview.risk}</Badge>
<Badge variant='outline'>{latestReview.recommendedAction}</Badge>
{latestReview.requiresHumanReview ? (
<Badge variant='secondary'>Human review required</Badge>
) : null}
</div>
<p className='text-sm'>
{latestReview.outputSummary ?? latestReview.inputSummary}
</p>
{latestReview.reasoningSummary ? (
<p className='text-muted-foreground text-sm'>
{latestReview.reasoningSummary}
</p>
) : null}
{latestReview.potentialConflicts?.length ? (
<div>
<p className='text-sm font-medium'>Potential conflicts</p>
<ul className='text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm'>
{latestReview.potentialConflicts.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
) : null}
</div>
) : (
<p className='text-muted-foreground text-sm'>
Run an AI review after a GitHub refresh to get compatibility notes.
</p>
)}
</CardContent>
</Card>
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Review history</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{reviews.length ? (
reviews.map((review) => (
<div key={review._id} className='border-border border p-3 text-sm'>
<div className='flex flex-wrap gap-2'>
<Badge variant='outline'>{review.status}</Badge>
<Badge variant='secondary'>{review.risk}</Badge>
</div>
<p className='mt-2'>
{review.outputSummary ?? review.inputSummary}
</p>
</div>
))
) : (
<p className='text-muted-foreground text-sm'>No AI reviews yet.</p>
)}
</CardContent>
</Card>
</div>
);
+19 -4
View File
@@ -1,5 +1,8 @@
import Link from 'next/link';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
const formatDate = (value?: number) =>
value
@@ -7,15 +10,19 @@ const formatDate = (value?: number) =>
: 'Never';
export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<Card className='shadow-none'>
<Card className='hover:border-primary/50 shadow-none transition-colors'>
<CardHeader className='flex-row items-start justify-between gap-4'>
<div>
<CardTitle className='text-lg'>{spoon.name}</CardTitle>
<CardTitle className='text-lg'>
<Link href={`/spoons/${spoon._id}`} className='hover:underline'>
{spoon.name}
</Link>
</CardTitle>
<p className='text-muted-foreground mt-1 text-sm'>
{spoon.upstreamOwner}/{spoon.upstreamRepo}
</p>
</div>
<Badge variant='outline'>{spoon.status.replaceAll('_', ' ')}</Badge>
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
</CardHeader>
<CardContent className='grid gap-3 text-sm md:grid-cols-2'>
<div>
@@ -38,6 +45,14 @@ export const SpoonCard = ({ spoon }: { spoon: Doc<'spoons'> }) => (
<p className='text-muted-foreground'>Last checked</p>
<p className='font-medium'>{formatDate(spoon.lastCheckedAt)}</p>
</div>
<div>
<p className='text-muted-foreground'>Upstream waiting</p>
<p className='font-medium'>{spoon.upstreamAheadBy ?? 0}</p>
</div>
<div>
<p className='text-muted-foreground'>Fork-only commits</p>
<p className='font-medium'>{spoon.forkAheadBy ?? 0}</p>
</div>
</CardContent>
</Card>
);
@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery } from 'convex/react';
import { Check, Copy, ExternalLink, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
} from '@spoon/ui';
const RemoteRow = ({
label,
url,
remoteName,
onRemove,
}: {
label: string;
url: string;
remoteName?: string;
onRemove?: () => Promise<void>;
}) => {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success(`${label} URL copied.`);
window.setTimeout(() => setCopied(false), 1800);
} catch (error) {
console.error(error);
toast.error('Could not copy remote URL.');
}
};
return (
<div className='border-border space-y-2 border-t pt-3 first:border-t-0 first:pt-0'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>{label}</p>
{remoteName ? (
<p className='text-muted-foreground text-xs'>
git remote: {remoteName}
</p>
) : null}
</div>
<div className='flex gap-2'>
<Button type='button' variant='outline' size='icon' onClick={copy}>
{copied ? (
<Check className='size-4' />
) : (
<Copy className='size-4' />
)}
</Button>
<Button type='button' variant='outline' size='icon' asChild>
<a
href={url}
target='_blank'
rel='noreferrer'
aria-label={`Open ${label} repository`}
>
<ExternalLink className='size-4' />
</a>
</Button>
{onRemove ? (
<Button
type='button'
variant='outline'
size='icon'
onClick={() => void onRemove()}
>
<Trash2 className='size-4' />
</Button>
) : null}
</div>
</div>
<Input
readOnly
value={url}
className='font-mono text-xs'
onFocus={(event) => event.currentTarget.select()}
/>
</div>
);
};
export const SpoonClonePanel = ({ spoon }: { spoon: Doc<'spoons'> }) => {
const remotes =
useQuery(api.spoonRemotes.listForSpoon, { spoonId: spoon._id }) ?? [];
const createRemote = useMutation(api.spoonRemotes.create);
const removeRemote = useMutation(api.spoonRemotes.remove);
const [label, setLabel] = useState('');
const [remoteName, setRemoteName] = useState('');
const [url, setUrl] = useState('');
const [submitting, setSubmitting] = useState(false);
const cloneUrl = spoon.forkUrl;
const addRemote = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSubmitting(true);
try {
await createRemote({
spoonId: spoon._id,
label,
url,
remoteName: remoteName || undefined,
});
setLabel('');
setRemoteName('');
setUrl('');
toast.success('Remote added.');
} catch (error) {
console.error(error);
toast.error('Could not add remote.');
} finally {
setSubmitting(false);
}
};
return (
<Card className='shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Clone your fork</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{cloneUrl ? (
<>
<RemoteRow
label='Primary fork'
remoteName='origin'
url={cloneUrl}
/>
<p className='text-muted-foreground text-xs'>
This GitHub fork remains Spoon&apos;s source of truth for upstream
maintenance.
</p>
</>
) : (
<p className='text-muted-foreground text-sm'>
Add fork metadata before Spoon can show a clone URL.
</p>
)}
{remotes.length ? (
<div className='space-y-3 pt-2'>
{remotes.map((remote) => (
<RemoteRow
key={remote._id}
label={remote.label}
remoteName={remote.remoteName}
url={remote.url}
onRemove={async () => {
await removeRemote({ remoteId: remote._id });
toast.success('Remote removed.');
}}
/>
))}
</div>
) : null}
<form
onSubmit={addRemote}
className='border-border space-y-3 border-t pt-4'
>
<div className='grid gap-3 md:grid-cols-[1fr_0.7fr]'>
<div className='grid gap-2'>
<Label htmlFor='remote-label'>Label</Label>
<Input
id='remote-label'
value={label}
placeholder='Gitea mirror'
required
onChange={(event) => setLabel(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-name'>Git remote name</Label>
<Input
id='remote-name'
value={remoteName}
placeholder='gitea'
onChange={(event) => setRemoteName(event.target.value)}
/>
</div>
</div>
<div className='grid gap-2'>
<Label htmlFor='remote-url'>Repository URL</Label>
<Input
id='remote-url'
value={url}
placeholder='https://git.example.com/you/project.git'
required
onChange={(event) => setUrl(event.target.value)}
/>
</div>
<Button type='submit' variant='outline' disabled={submitting}>
<Plus className='size-4' />
{submitting ? 'Adding...' : 'Add remote'}
</Button>
</form>
</CardContent>
</Card>
);
};
@@ -0,0 +1,51 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button, Card, CardContent } from '@spoon/ui';
const shortSha = (sha: string) => sha.slice(0, 7);
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Unknown date';
export const SpoonCommitList = ({
commits,
empty,
}: {
commits: Doc<'spoonCommits'>[];
empty: string;
}) => (
<div className='space-y-3'>
{commits.length ? (
commits.map((commit) => (
<Card key={`${commit.side}-${commit.sha}`} className='shadow-none'>
<CardContent className='flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between'>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{commit.message.split('\n')[0]}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{shortSha(commit.sha)} by{' '}
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
{formatDate(commit.committedAt)}
</p>
</div>
{commit.htmlUrl ? (
<Button variant='outline' size='sm' asChild>
<a href={commit.htmlUrl} target='_blank' rel='noreferrer'>
Open
</a>
</Button>
) : null}
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
{empty}
</CardContent>
</Card>
)}
</div>
);
@@ -0,0 +1,126 @@
'use client';
import { useState } from 'react';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { useAction } from 'convex/react';
import { makeFunctionReference } from 'convex/server';
import { Brain, RefreshCw, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import type { Doc, Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { Button } from '@spoon/ui';
const refreshRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{
success: boolean;
status: string;
upstreamAheadBy: number;
forkAheadBy: number;
}
>('githubSync:refreshSpoonGithubState');
const syncRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
unknown
>('githubSync:syncForkWithUpstream');
const reviewRef = makeFunctionReference<
'action',
{ spoonId: Id<'spoons'> },
{ reviewId: Id<'aiReviews'>; risk: string; recommendedAction: string }
>('aiReviewActions:reviewLatestUpstreamChanges');
export const SpoonDetailHeader = ({
spoon,
state,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
}) => {
const refresh = useAction(refreshRef);
const sync = useAction(syncRef);
const review = useAction(reviewRef);
const [busy, setBusy] = useState<string | null>(null);
const canSync =
spoon.provider === 'github' &&
state?.status === 'behind' &&
state.forkAheadBy === 0;
const run = async (label: string, action: () => Promise<unknown>) => {
setBusy(label);
try {
await action();
toast.success(`${label} complete.`);
} catch (error) {
console.error(error);
toast.error(`${label} failed.`);
} finally {
setBusy(null);
}
};
return (
<div className='border-border bg-card flex flex-col justify-between gap-5 rounded-lg border p-5 shadow-sm lg:flex-row lg:items-start'>
<div className='min-w-0 space-y-2'>
<div className='flex flex-wrap items-center gap-3'>
<h1 className='truncate text-3xl font-semibold tracking-normal'>
{spoon.name}
</h1>
<SpoonStatusBadge
status={state?.status ?? spoon.syncStatus ?? spoon.status}
/>
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm'>
<a
href={spoon.upstreamUrl}
target='_blank'
rel='noreferrer'
className='hover:text-foreground'
>
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
</a>
{spoon.forkUrl ? (
<a
href={spoon.forkUrl}
target='_blank'
rel='noreferrer'
className='hover:text-foreground'
>
Fork: {spoon.forkOwner}/{spoon.forkRepo}
</a>
) : (
<span>Fork metadata missing</span>
)}
</div>
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => run('Refresh', () => refresh({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<RefreshCw className='size-4' />
Refresh
</Button>
<Button
variant='outline'
onClick={() => run('AI review', () => review({ spoonId: spoon._id }))}
disabled={Boolean(busy)}
>
<Brain className='size-4' />
Review with AI
</Button>
<Button
onClick={() => run('Sync', () => sync({ spoonId: spoon._id }))}
disabled={Boolean(busy) || !canSync}
>
<RotateCw className='size-4' />
Sync fork
</Button>
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import {
Clock,
GitCommit,
GitPullRequest,
ShieldCheck,
TrendingUp,
} from 'lucide-react';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Card, CardContent } from '@spoon/ui';
const formatDate = (value?: number) =>
value
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
: 'Never';
export const SpoonMetrics = ({
spoon,
state,
latestReview,
}: {
spoon: Doc<'spoons'>;
state?: Doc<'spoonRepositoryStates'> | null;
latestReview?: Doc<'aiReviews'> | null;
}) => {
const metrics = [
{
label: 'Upstream waiting',
value: state?.upstreamAheadBy ?? spoon.upstreamAheadBy ?? 0,
icon: TrendingUp,
},
{
label: 'Fork-only commits',
value: state?.forkAheadBy ?? spoon.forkAheadBy ?? 0,
icon: GitCommit,
},
{
label: 'Open PRs',
value:
(state?.openForkPullRequestCount ?? 0) +
(state?.openUpstreamPullRequestCount ?? 0),
icon: GitPullRequest,
},
{
label: 'Latest AI risk',
value: latestReview?.risk ?? 'unknown',
icon: ShieldCheck,
},
{
label: 'Last check',
value: formatDate(spoon.lastCheckedAt ?? state?.refreshedAt),
icon: Clock,
},
];
return (
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-5'>
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<Card key={metric.label} className='shadow-none'>
<CardContent className='flex items-center justify-between gap-3 p-4'>
<div className='min-w-0'>
<p className='text-muted-foreground text-xs'>{metric.label}</p>
<p className='mt-1 truncate text-lg font-semibold capitalize'>
{metric.value}
</p>
</div>
<div className='bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-md'>
<Icon className='size-4' />
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -0,0 +1,48 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Card, CardContent } from '@spoon/ui';
export const SpoonPrList = ({
pullRequests,
}: {
pullRequests: Doc<'spoonPullRequests'>[];
}) => (
<div className='space-y-3'>
{pullRequests.length ? (
pullRequests.map((pullRequest) => (
<Card
key={`${pullRequest.repoFullName}-${pullRequest.githubId}`}
className='shadow-none'
>
<CardContent className='flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between'>
<div>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>
#{pullRequest.number} {pullRequest.title}
</p>
<Badge variant='outline'>{pullRequest.state}</Badge>
<Badge variant='secondary'>
{pullRequest.scope.replaceAll('_', ' ')}
</Badge>
</div>
<p className='text-muted-foreground mt-1 text-sm'>
{pullRequest.repoFullName}: {pullRequest.headRef} →{' '}
{pullRequest.baseRef}
</p>
</div>
<Button variant='outline' size='sm' asChild>
<a href={pullRequest.htmlUrl} target='_blank' rel='noreferrer'>
Open
</a>
</Button>
</CardContent>
</Card>
))
) : (
<Card className='shadow-none'>
<CardContent className='text-muted-foreground p-6 text-sm'>
Pull requests will appear after the next GitHub refresh.
</CardContent>
</Card>
)}
</div>
);
@@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import { useAction, useMutation, useQuery } from 'convex/react';
import { KeyRound, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
} from '@spoon/ui';
export const SpoonSecretsForm = ({ spoonId }: { spoonId: Id<'spoons'> }) => {
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
const createSecret = useAction(api.spoonSecretsNode.create);
const removeSecret = useMutation(api.spoonSecrets.remove);
const [name, setName] = useState('');
const [value, setValue] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const save = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
try {
await createSecret({
spoonId,
name,
value,
description: description || undefined,
});
setName('');
setValue('');
setDescription('');
toast.success('Spoon secret saved.');
} catch (error) {
console.error(error);
toast.error('Could not save secret.');
} finally {
setSaving(false);
}
};
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<KeyRound className='size-4' />
Project secrets
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<form
onSubmit={save}
className='grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]'
>
<div className='grid gap-2'>
<Label htmlFor='secretName'>Name</Label>
<Input
id='secretName'
value={name}
placeholder='AUTHENTIK_CLIENT_ID'
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='secretValue'>Value</Label>
<Input
id='secretValue'
type='password'
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='secretDescription'>Description</Label>
<Input
id='secretDescription'
value={description}
placeholder='Used for local validation'
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className='flex items-end'>
<Button type='submit' disabled={saving}>
Save
</Button>
</div>
</form>
<div className='divide-border overflow-hidden rounded-md border'>
{secrets.length ? (
secrets.map((secret) => (
<div
key={secret._id}
className='flex items-center justify-between gap-3 border-b p-3'
>
<div>
<p className='font-mono text-sm'>{secret.name}</p>
<p className='text-muted-foreground text-xs'>
{secret.description ?? secret.valuePreview ?? 'Configured'}
</p>
</div>
<Button
type='button'
variant='ghost'
size='icon'
aria-label={`Remove ${secret.name}`}
onClick={async () => {
await removeSecret({ secretId: secret._id });
toast.success('Secret removed.');
}}
>
<Trash2 className='size-4' />
</Button>
</div>
))
) : (
<p className='text-muted-foreground p-3 text-sm'>
No secrets saved for this Spoon.
</p>
)}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { useMutation } from 'convex/react';
import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Label,
Switch,
} from '@spoon/ui';
export const SpoonSettingsForm = ({
spoon,
settings,
}: {
spoon: Doc<'spoons'>;
settings?: Doc<'spoonSettings'> | null;
}) => {
const update = useMutation(api.spoonSettings.update);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(
settings?.autoRefreshEnabled ?? true,
);
const [autoReviewEnabled, setAutoReviewEnabled] = useState(
settings?.autoReviewEnabled ?? true,
);
const [autoSyncEnabled, setAutoSyncEnabled] = useState(
settings?.autoSyncEnabled ?? false,
);
const [requireAiLowRiskForSync, setRequireAiLowRiskForSync] = useState(
settings?.requireAiLowRiskForSync ?? true,
);
const [requireCleanCompareForSync, setRequireCleanCompareForSync] = useState(
settings?.requireCleanCompareForSync ?? true,
);
const save = async () => {
try {
await update({
spoonId: spoon._id,
autoRefreshEnabled,
autoReviewEnabled,
autoSyncEnabled,
requireAiLowRiskForSync,
requireCleanCompareForSync,
});
toast.success('Spoon settings saved.');
} catch (error) {
console.error(error);
toast.error('Could not save settings.');
}
};
const rows = [
{
label: 'Auto refresh',
value: autoRefreshEnabled,
onChange: setAutoRefreshEnabled,
},
{
label: 'Auto AI review',
value: autoReviewEnabled,
onChange: setAutoReviewEnabled,
},
{
label: 'Auto sync',
value: autoSyncEnabled,
onChange: setAutoSyncEnabled,
},
{
label: 'Require low AI risk for sync',
value: requireAiLowRiskForSync,
onChange: setRequireAiLowRiskForSync,
},
{
label: 'Require clean compare for sync',
value: requireCleanCompareForSync,
onChange: setRequireCleanCompareForSync,
},
];
return (
<Card className='shadow-none'>
<CardHeader>
<CardTitle className='text-base'>Maintenance settings</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div>
<p className='text-muted-foreground text-xs'>Sync cadence</p>
<p className='font-medium'>{spoon.syncCadence}</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Maintenance mode</p>
<p className='font-medium'>
{spoon.maintenanceMode.replaceAll('_', ' ')}
</p>
</div>
<div>
<p className='text-muted-foreground text-xs'>Production ref</p>
<p className='font-medium'>
{spoon.productionRefStrategy.replaceAll('_', ' ')}
</p>
</div>
</div>
<div className='space-y-3'>
{rows.map((row) => (
<div
key={row.label}
className='flex items-center justify-between gap-4 border-t pt-3'
>
<Label>{row.label}</Label>
<Switch checked={row.value} onCheckedChange={row.onChange} />
</div>
))}
</div>
<Button onClick={save}>Save settings</Button>
</CardContent>
</Card>
);
};
@@ -0,0 +1,45 @@
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge } from '@spoon/ui';
type Status =
| NonNullable<Doc<'spoons'>['syncStatus']>
| Doc<'spoons'>['status'];
const labels: Record<string, string> = {
up_to_date: 'Up to date',
behind: 'Behind',
ahead: 'Ahead',
diverged: 'Diverged',
checking: 'Checking',
conflict: 'Conflict',
error: 'Error',
unknown: 'Unknown',
active: 'Active',
draft: 'Draft',
needs_connection: 'Needs connection',
paused: 'Paused',
archived: 'Archived',
};
const variants: Record<
string,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
up_to_date: 'default',
behind: 'secondary',
ahead: 'outline',
diverged: 'destructive',
conflict: 'destructive',
error: 'destructive',
checking: 'secondary',
active: 'default',
};
export const SpoonStatusBadge = ({ status }: { status?: Status }) => {
const value = status ?? 'unknown';
return (
<Badge variant={variants[value] ?? 'outline'}>
{labels[value] ?? value.replaceAll('_', ' ')}
</Badge>
);
};