Add agent workflows & stuff
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
import { baseConfig } from '@spoon/eslint-config/base';
|
||||
|
||||
export default defineConfig(baseConfig, {
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@spoon/agent-worker",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun with-env src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "vitest run --project unit --passWithNoTests",
|
||||
"test:integration": "vitest run --project integration --passWithNoTests",
|
||||
"test:component": "vitest run --project component --passWithNoTests",
|
||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "^8.2.0",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@openai/agents": "latest",
|
||||
"convex": "catalog:convex",
|
||||
"execa": "latest",
|
||||
"openai": "^6.44.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:test"
|
||||
},
|
||||
"prettier": "@spoon/prettier-config"
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const editSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
summary: { type: 'string' },
|
||||
files: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
content: { type: 'string' },
|
||||
},
|
||||
required: ['path', 'content'],
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
limitations: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
},
|
||||
required: ['summary', 'files', 'commands', 'limitations'],
|
||||
} as const;
|
||||
|
||||
type AgentEdit = {
|
||||
summary: string;
|
||||
files: { path: string; content: string }[];
|
||||
commands: string[];
|
||||
limitations: string[];
|
||||
};
|
||||
|
||||
const maxContextFiles = 40;
|
||||
const maxFileBytes = 12_000;
|
||||
|
||||
const safeContextFile = (file: string) =>
|
||||
!file.includes('node_modules/') &&
|
||||
!file.includes('.git/') &&
|
||||
!file.includes('dist/') &&
|
||||
!file.includes('build/') &&
|
||||
!file.includes('.next/') &&
|
||||
!file.endsWith('.lock') &&
|
||||
!file.endsWith('.png') &&
|
||||
!file.endsWith('.jpg') &&
|
||||
!file.endsWith('.jpeg') &&
|
||||
!file.endsWith('.webp') &&
|
||||
!file.endsWith('.gif') &&
|
||||
!file.endsWith('.pdf');
|
||||
|
||||
const listFiles = async (repoDir: string) => {
|
||||
const result = await execa('git', ['ls-files'], {
|
||||
cwd: repoDir,
|
||||
all: true,
|
||||
reject: false,
|
||||
});
|
||||
return result.all
|
||||
.split('\n')
|
||||
.map((file) => file.trim())
|
||||
.filter(Boolean)
|
||||
.filter(safeContextFile);
|
||||
};
|
||||
|
||||
const chooseContextFiles = (files: string[], prompt: string) => {
|
||||
const promptWords = new Set(
|
||||
prompt
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter((word) => word.length > 3),
|
||||
);
|
||||
const scored = files.map((file) => {
|
||||
const lower = file.toLowerCase();
|
||||
const score = [...promptWords].reduce(
|
||||
(sum, word) => sum + (lower.includes(word) ? 2 : 0),
|
||||
/(readme|package\.json|auth|env|config|route|provider)/i.exec(file)
|
||||
? 3
|
||||
: 0,
|
||||
);
|
||||
return { file, score };
|
||||
});
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, maxContextFiles)
|
||||
.map((item) => item.file);
|
||||
};
|
||||
|
||||
const readContext = async (repoDir: string, files: string[]) => {
|
||||
const chunks = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await readFile(path.join(repoDir, file), 'utf8');
|
||||
chunks.push({
|
||||
path: file,
|
||||
content:
|
||||
content.length > maxFileBytes
|
||||
? `${content.slice(0, maxFileBytes)}\n[truncated]`
|
||||
: content,
|
||||
});
|
||||
} catch {
|
||||
// Ignore files that disappeared while context was being gathered.
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
const parseEdit = (value: string): AgentEdit => {
|
||||
const parsed = JSON.parse(value) as AgentEdit;
|
||||
if (!Array.isArray(parsed.files)) {
|
||||
throw new Error('OpenAI returned an edit without a files array.');
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const safePath = (repoDir: string, filePath: string) => {
|
||||
const resolved = path.resolve(repoDir, filePath);
|
||||
if (!resolved.startsWith(path.resolve(repoDir))) {
|
||||
throw new Error(`Refusing to write outside the repository: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
export const runOpenAiEdit = async (args: {
|
||||
repoDir: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
prompt: string;
|
||||
secretNames: string[];
|
||||
spoonName: string;
|
||||
upstreamFullName: string;
|
||||
forkFullName: string;
|
||||
}) => {
|
||||
const files = await listFiles(args.repoDir);
|
||||
const selectedFiles = chooseContextFiles(files, args.prompt);
|
||||
const contextFiles = await readContext(args.repoDir, selectedFiles);
|
||||
const response = await new OpenAI({ apiKey: args.apiKey }).responses.create({
|
||||
model: args.model,
|
||||
store: false,
|
||||
reasoning:
|
||||
args.reasoningEffort === 'none'
|
||||
? undefined
|
||||
: { effort: args.reasoningEffort },
|
||||
input: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a conservative coding agent working in a fork. Return complete replacement contents only for files that must change. Keep the diff minimal. Do not include secrets. Do not claim commands passed unless they are listed for the worker to run. If the context is insufficient, make the safest small change and describe limitations.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
task: args.prompt,
|
||||
spoon: args.spoonName,
|
||||
upstream: args.upstreamFullName,
|
||||
fork: args.forkFullName,
|
||||
availableSecretNames: args.secretNames,
|
||||
repositoryFiles: files.slice(0, 500),
|
||||
contextFiles,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
name: 'spoon_agent_file_edits',
|
||||
strict: true,
|
||||
schema: editSchema,
|
||||
},
|
||||
},
|
||||
});
|
||||
const edit = parseEdit(response.output_text);
|
||||
for (const file of edit.files) {
|
||||
const target = safePath(args.repoDir, file.path);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, file.content);
|
||||
}
|
||||
return edit;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
const intEnv = (name: string, fallback: number) => {
|
||||
const value = process.env[name];
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
|
||||
const requiredEnv = (name: string) => {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new Error(`${name} is required.`);
|
||||
return value;
|
||||
};
|
||||
|
||||
export const env = {
|
||||
convexUrl:
|
||||
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
||||
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
||||
'http://localhost:3210',
|
||||
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
||||
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
||||
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
||||
jobImage:
|
||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
||||
maxConcurrentJobs: intEnv('SPOON_AGENT_MAX_CONCURRENT_JOBS', 1),
|
||||
jobTimeoutMs: intEnv('SPOON_AGENT_JOB_TIMEOUT_MS', 1_800_000),
|
||||
githubAppId: requiredEnv('GITHUB_APP_ID'),
|
||||
githubPrivateKey: requiredEnv('GITHUB_APP_PRIVATE_KEY').replaceAll(
|
||||
'\\n',
|
||||
'\n',
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
|
||||
export type RunOptions = {
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
export const run = async (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: RunOptions,
|
||||
) => {
|
||||
const result = await execa(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
all: true,
|
||||
reject: false,
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
return {
|
||||
exitCode: result.exitCode ?? 0,
|
||||
output: options.redact(result.all),
|
||||
};
|
||||
};
|
||||
|
||||
export const cloneRepository = async (args: {
|
||||
workdir: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
baseBranch: string;
|
||||
workBranch: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
await mkdir(args.workdir, { recursive: true });
|
||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||
const clone = await run(
|
||||
'git',
|
||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
||||
{
|
||||
cwd: args.workdir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
if (clone.exitCode !== 0) {
|
||||
throw new Error(`git clone failed:\n${clone.output}`);
|
||||
}
|
||||
const repoDir = path.join(args.workdir, 'repo');
|
||||
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
||||
cwd: repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
if (checkout.exitCode !== 0) {
|
||||
throw new Error(`git checkout failed:\n${checkout.output}`);
|
||||
}
|
||||
return repoDir;
|
||||
};
|
||||
|
||||
export const commitAndPush = async (args: {
|
||||
repoDir: string;
|
||||
workBranch: string;
|
||||
message: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
await run('git', ['config', 'user.name', 'Spoon Agent'], {
|
||||
cwd: args.repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
await run(
|
||||
'git',
|
||||
['config', 'user.email', 'spoon-agent@users.noreply.github.com'],
|
||||
{
|
||||
cwd: args.repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
await run('git', ['add', '-A'], {
|
||||
cwd: args.repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
const commit = await run('git', ['commit', '-m', args.message], {
|
||||
cwd: args.repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
if (commit.exitCode !== 0) {
|
||||
throw new Error(`git commit failed:\n${commit.output}`);
|
||||
}
|
||||
const sha = await run('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: args.repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
const push = await run('git', ['push', 'origin', args.workBranch], {
|
||||
cwd: args.repoDir,
|
||||
redact: args.redact,
|
||||
timeoutMs: args.timeoutMs,
|
||||
});
|
||||
if (push.exitCode !== 0) {
|
||||
throw new Error(`git push failed:\n${push.output}`);
|
||||
}
|
||||
return sha.output.trim();
|
||||
};
|
||||
|
||||
export const getDiff = async (
|
||||
repoDir: string,
|
||||
redact: (value: string) => string,
|
||||
) =>
|
||||
await run('git', ['diff', '--cached', '--', '.'], {
|
||||
cwd: repoDir,
|
||||
redact,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
export const getWorktreeDiff = async (
|
||||
repoDir: string,
|
||||
redact: (value: string) => string,
|
||||
) =>
|
||||
await run('git', ['diff', '--', '.'], {
|
||||
cwd: repoDir,
|
||||
redact,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
export const getStatus = async (
|
||||
repoDir: string,
|
||||
redact: (value: string) => string,
|
||||
) =>
|
||||
await run('git', ['status', '--short'], {
|
||||
cwd: repoDir,
|
||||
redact,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createAppAuth } from '@octokit/auth-app';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
import { env } from './env';
|
||||
|
||||
export const getInstallationToken = async (installationId: string) => {
|
||||
const auth = createAppAuth({
|
||||
appId: env.githubAppId,
|
||||
privateKey: env.githubPrivateKey,
|
||||
installationId,
|
||||
});
|
||||
const result = await auth({ type: 'installation' });
|
||||
return result.token;
|
||||
};
|
||||
|
||||
export const getInstallationOctokit = (installationId: string) =>
|
||||
new Octokit({
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: env.githubAppId,
|
||||
privateKey: env.githubPrivateKey,
|
||||
installationId,
|
||||
},
|
||||
userAgent: 'Spoon Agent Worker',
|
||||
request: {
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const openDraftPullRequest = async (args: {
|
||||
installationId: string;
|
||||
forkOwner: string;
|
||||
forkRepo: string;
|
||||
baseBranch: string;
|
||||
workBranch: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => {
|
||||
const octokit = getInstallationOctokit(args.installationId);
|
||||
const result = await octokit.rest.pulls.create({
|
||||
owner: args.forkOwner,
|
||||
repo: args.forkRepo,
|
||||
base: args.baseBranch,
|
||||
head: args.workBranch,
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
draft: true,
|
||||
});
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { startWorker } from './worker';
|
||||
|
||||
await startWorker();
|
||||
@@ -0,0 +1,26 @@
|
||||
const secretPatterns = [
|
||||
/ghs_[A-Za-z0-9_]+/g,
|
||||
/github_pat_[A-Za-z0-9_]+/g,
|
||||
/sk-[A-Za-z0-9_-]+/g,
|
||||
/(client_secret|auth_secret|api_key|token|password)=([^ \n\r]+)/gi,
|
||||
];
|
||||
|
||||
export const createRedactor = (values: string[]) => {
|
||||
const secrets = values.filter((value) => value.length >= 3);
|
||||
return (input: string) => {
|
||||
let output = input;
|
||||
for (const secret of secrets) {
|
||||
output = output.split(secret).join('[redacted]');
|
||||
}
|
||||
for (const pattern of secretPatterns) {
|
||||
output = output.replace(pattern, '$1=[redacted]');
|
||||
}
|
||||
return output;
|
||||
};
|
||||
};
|
||||
|
||||
export const truncate = (value: string, maxBytes: number) => {
|
||||
const buffer = Buffer.from(value);
|
||||
if (buffer.byteLength <= maxBytes) return value;
|
||||
return `${buffer.subarray(0, maxBytes).toString('utf8')}\n[truncated]`;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
|
||||
export const runInJobContainer = async (args: {
|
||||
workdir: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
|
||||
'-e',
|
||||
`${name}=${value}`,
|
||||
]);
|
||||
const networkArgs = env.network ? ['--network', env.network] : [];
|
||||
const result = await execa(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...networkArgs,
|
||||
...envArgs,
|
||||
'-v',
|
||||
`${args.workdir}:/workspace`,
|
||||
'-w',
|
||||
'/workspace/repo',
|
||||
env.jobImage,
|
||||
...args.command,
|
||||
],
|
||||
{
|
||||
all: true,
|
||||
reject: false,
|
||||
timeout: args.timeoutMs,
|
||||
},
|
||||
);
|
||||
return {
|
||||
exitCode: result.exitCode ?? 0,
|
||||
output: args.redact(result.all),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,423 @@
|
||||
import { access, readFile, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { ConvexHttpClient } from 'convex/browser';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
import { runOpenAiEdit } from './agent';
|
||||
import { env } from './env';
|
||||
import {
|
||||
cloneRepository,
|
||||
commitAndPush,
|
||||
getStatus,
|
||||
getWorktreeDiff,
|
||||
run,
|
||||
} from './git';
|
||||
import { getInstallationToken, openDraftPullRequest } from './github';
|
||||
import { createRedactor, truncate } from './redact';
|
||||
import { runInJobContainer } from './runtime/docker';
|
||||
|
||||
type Claim = {
|
||||
job: {
|
||||
_id: Id<'agentJobs'>;
|
||||
prompt: string;
|
||||
baseBranch: string;
|
||||
workBranch: string;
|
||||
forkOwner: string;
|
||||
forkRepo: string;
|
||||
upstreamOwner: string;
|
||||
upstreamRepo: string;
|
||||
};
|
||||
spoon: { name: string };
|
||||
openai: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
reasoningEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
};
|
||||
github: { installationId?: string };
|
||||
agentSettings?: {
|
||||
installCommand?: string;
|
||||
checkCommand?: string;
|
||||
testCommand?: string;
|
||||
} | null;
|
||||
secrets: { name: string; value: string }[];
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const client = new ConvexHttpClient(env.convexUrl);
|
||||
|
||||
const appendEvent = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
level: 'debug' | 'info' | 'warn' | 'error',
|
||||
phase:
|
||||
| 'queued'
|
||||
| 'clone'
|
||||
| 'plan'
|
||||
| 'edit'
|
||||
| 'install'
|
||||
| 'check'
|
||||
| 'test'
|
||||
| 'commit'
|
||||
| 'push'
|
||||
| 'pr'
|
||||
| 'cleanup',
|
||||
message: string,
|
||||
metadata?: string,
|
||||
) =>
|
||||
await client.mutation(api.agentJobs.appendEvent, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
jobId,
|
||||
level,
|
||||
phase,
|
||||
message,
|
||||
metadata,
|
||||
});
|
||||
|
||||
const updateStatus = async (
|
||||
jobId: Id<'agentJobs'>,
|
||||
status:
|
||||
| 'queued'
|
||||
| 'claimed'
|
||||
| 'preparing'
|
||||
| 'running'
|
||||
| 'checks_running'
|
||||
| 'changes_ready'
|
||||
| 'draft_pr_opened'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'timed_out',
|
||||
extra?: { error?: string; summary?: string },
|
||||
) =>
|
||||
await client.mutation(api.agentJobs.updateStatus, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
jobId,
|
||||
status,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const addArtifact = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
kind: 'plan' | 'diff' | 'test_output' | 'summary' | 'error' | 'pr_body';
|
||||
title: string;
|
||||
content: string;
|
||||
contentType:
|
||||
| 'text/markdown'
|
||||
| 'text/plain'
|
||||
| 'application/json'
|
||||
| 'text/x-diff';
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.addArtifact, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const completeWithDraftPr = async (args: {
|
||||
jobId: Id<'agentJobs'>;
|
||||
commitSha: string;
|
||||
pullRequestUrl: string;
|
||||
pullRequestNumber: number;
|
||||
summary: string;
|
||||
}) =>
|
||||
await client.mutation(api.agentJobs.completeWithDraftPr, {
|
||||
workerToken: env.workerToken,
|
||||
workerId: env.workerId,
|
||||
...args,
|
||||
});
|
||||
|
||||
const commandToShell = (command: string) => ['bash', '-lc', command];
|
||||
|
||||
const fileExists = async (filePath: string) => {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const runProjectCommand = async (args: {
|
||||
command: string;
|
||||
phase: 'install' | 'check' | 'test';
|
||||
claim: Claim;
|
||||
workdir: string;
|
||||
repoDir: string;
|
||||
redact: (value: string) => string;
|
||||
}) => {
|
||||
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
|
||||
const result =
|
||||
env.runtime === 'docker'
|
||||
? await runInJobContainer({
|
||||
workdir: args.workdir,
|
||||
command: commandToShell(args.command),
|
||||
environment: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
})
|
||||
: await run('bash', ['-lc', args.command], {
|
||||
cwd: args.repoDir,
|
||||
env: Object.fromEntries(
|
||||
args.claim.secrets.map((secret) => [secret.name, secret.value]),
|
||||
),
|
||||
redact: args.redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
await addArtifact({
|
||||
jobId: args.claim.job._id,
|
||||
kind: args.phase === 'test' ? 'test_output' : 'summary',
|
||||
title: args.command,
|
||||
content: truncate(result.output, 100_000),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`${args.command} failed:\n${result.output}`);
|
||||
}
|
||||
};
|
||||
|
||||
const detectPackageCommands = async (
|
||||
repoDir: string,
|
||||
): Promise<{ install?: string; check?: string; test?: string }> => {
|
||||
const packageJsonPath = path.join(repoDir, 'package.json');
|
||||
try {
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
const scripts = packageJson.scripts ?? {};
|
||||
return {
|
||||
install: (await fileExists(path.join(repoDir, 'bun.lock')))
|
||||
? 'bun install'
|
||||
: (await fileExists(path.join(repoDir, 'pnpm-lock.yaml')))
|
||||
? 'pnpm install'
|
||||
: (await fileExists(path.join(repoDir, 'yarn.lock')))
|
||||
? 'yarn install'
|
||||
: 'npm install',
|
||||
check: scripts.typecheck
|
||||
? 'npm run typecheck'
|
||||
: scripts.lint
|
||||
? 'npm run lint'
|
||||
: undefined,
|
||||
test: scripts.test ? 'npm test' : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const buildPrBody = (args: {
|
||||
prompt: string;
|
||||
summary: string;
|
||||
commands: string[];
|
||||
limitations: string[];
|
||||
}) => `## Spoon agent request
|
||||
|
||||
${args.prompt}
|
||||
|
||||
## Summary
|
||||
|
||||
${args.summary}
|
||||
|
||||
## Validation
|
||||
|
||||
${
|
||||
args.commands.length
|
||||
? args.commands.map((command) => `- \`${command}\``).join('\n')
|
||||
: '- No validation commands were requested by the agent.'
|
||||
}
|
||||
|
||||
## Limitations
|
||||
|
||||
${
|
||||
args.limitations.length
|
||||
? args.limitations.map((item) => `- ${item}`).join('\n')
|
||||
: '- No limitations reported.'
|
||||
}
|
||||
|
||||
Generated by Spoon.`;
|
||||
|
||||
const runClaim = async (claim: Claim) => {
|
||||
const jobId = claim.job._id;
|
||||
const workdir = path.resolve(env.workdir, jobId);
|
||||
const secretValues = [
|
||||
claim.openai.apiKey,
|
||||
...claim.secrets.map((secret) => secret.value),
|
||||
];
|
||||
const redact = createRedactor(secretValues);
|
||||
try {
|
||||
await updateStatus(jobId, 'preparing');
|
||||
await appendEvent(jobId, 'info', 'clone', 'Creating installation token.');
|
||||
if (!claim.github.installationId) {
|
||||
throw new Error('GitHub installation ID is missing.');
|
||||
}
|
||||
const githubToken = await getInstallationToken(claim.github.installationId);
|
||||
const repoDir = await cloneRepository({
|
||||
workdir,
|
||||
token: githubToken,
|
||||
owner: claim.job.forkOwner,
|
||||
repo: claim.job.forkRepo,
|
||||
baseBranch: claim.job.baseBranch,
|
||||
workBranch: claim.job.workBranch,
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
await updateStatus(jobId, 'running');
|
||||
await appendEvent(jobId, 'info', 'plan', 'Gathering repo context.');
|
||||
const edit = await runOpenAiEdit({
|
||||
repoDir,
|
||||
apiKey: claim.openai.apiKey,
|
||||
model: claim.openai.model,
|
||||
reasoningEffort: claim.openai.reasoningEffort,
|
||||
prompt: claim.job.prompt,
|
||||
secretNames: claim.secrets.map((secret) => secret.name),
|
||||
spoonName: claim.spoon.name,
|
||||
upstreamFullName: `${claim.job.upstreamOwner}/${claim.job.upstreamRepo}`,
|
||||
forkFullName: `${claim.job.forkOwner}/${claim.job.forkRepo}`,
|
||||
});
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'plan',
|
||||
title: 'Agent plan',
|
||||
content: edit.summary,
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const status = await getStatus(repoDir, redact);
|
||||
if (!status.output.trim()) {
|
||||
throw new Error('No changes produced by the agent.');
|
||||
}
|
||||
const diff = await getWorktreeDiff(repoDir, redact);
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'diff',
|
||||
title: 'Git diff',
|
||||
content: truncate(diff.output, 200_000),
|
||||
contentType: 'text/x-diff',
|
||||
});
|
||||
await updateStatus(jobId, 'checks_running');
|
||||
const detected = await detectPackageCommands(repoDir);
|
||||
const settings = claim.agentSettings;
|
||||
const installCommand = settings?.installCommand ?? detected.install;
|
||||
const checkCommand = settings?.checkCommand ?? detected.check;
|
||||
const testCommand = settings?.testCommand ?? detected.test;
|
||||
if (installCommand) {
|
||||
await runProjectCommand({
|
||||
command: installCommand,
|
||||
phase: 'install',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (checkCommand) {
|
||||
await runProjectCommand({
|
||||
command: checkCommand,
|
||||
phase: 'check',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
if (testCommand) {
|
||||
await runProjectCommand({
|
||||
command: testCommand,
|
||||
phase: 'test',
|
||||
claim,
|
||||
workdir,
|
||||
repoDir,
|
||||
redact,
|
||||
});
|
||||
}
|
||||
await appendEvent(jobId, 'info', 'commit', 'Committing changes.');
|
||||
const commitSha = await commitAndPush({
|
||||
repoDir,
|
||||
workBranch: claim.job.workBranch,
|
||||
message: `Agent: ${claim.job.prompt.slice(0, 72)}`,
|
||||
redact,
|
||||
timeoutMs: env.jobTimeoutMs,
|
||||
});
|
||||
const prBody = buildPrBody({
|
||||
prompt: claim.job.prompt,
|
||||
summary: edit.summary,
|
||||
commands: [
|
||||
installCommand,
|
||||
checkCommand,
|
||||
testCommand,
|
||||
...edit.commands,
|
||||
].filter((command): command is string => Boolean(command)),
|
||||
limitations: edit.limitations,
|
||||
});
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'pr_body',
|
||||
title: 'Draft PR body',
|
||||
content: prBody,
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'pr', 'Opening draft pull request.');
|
||||
const pullRequest = await openDraftPullRequest({
|
||||
installationId: claim.github.installationId,
|
||||
forkOwner: claim.job.forkOwner,
|
||||
forkRepo: claim.job.forkRepo,
|
||||
baseBranch: claim.job.baseBranch,
|
||||
workBranch: claim.job.workBranch,
|
||||
title: `Agent: ${claim.job.prompt.slice(0, 64)}`,
|
||||
body: prBody,
|
||||
});
|
||||
await completeWithDraftPr({
|
||||
jobId,
|
||||
commitSha,
|
||||
pullRequestUrl: pullRequest.html_url,
|
||||
pullRequestNumber: pullRequest.number,
|
||||
summary: edit.summary,
|
||||
});
|
||||
await appendEvent(jobId, 'info', 'cleanup', 'Agent job completed.');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await appendEvent(
|
||||
jobId,
|
||||
'error',
|
||||
'cleanup',
|
||||
truncate(redact(message), 20_000),
|
||||
);
|
||||
await addArtifact({
|
||||
jobId,
|
||||
kind: 'error',
|
||||
title: 'Failure',
|
||||
content: truncate(redact(message), 50_000),
|
||||
contentType: 'text/plain',
|
||||
});
|
||||
await updateStatus(
|
||||
jobId,
|
||||
message.toLowerCase().includes('timed out') ? 'timed_out' : 'failed',
|
||||
{ error: truncate(redact(message), 10_000) },
|
||||
);
|
||||
} finally {
|
||||
await rm(workdir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
export const startWorker = async () => {
|
||||
console.log(`Spoon agent worker ${env.workerId} polling ${env.convexUrl}`);
|
||||
for (;;) {
|
||||
try {
|
||||
const claim = await client.action(api.agentJobsNode.claimNextForWorker, {
|
||||
workerId: env.workerId,
|
||||
workerToken: env.workerToken,
|
||||
});
|
||||
if (!claim) {
|
||||
await sleep(env.pollMs);
|
||||
continue;
|
||||
}
|
||||
await runClaim(claim);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await sleep(env.pollMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@spoon/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "eslint.config.ts", "vitest.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
import { nodeProject } from '@spoon/vitest-config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
nodeProject('unit', ['tests/unit/**/*.test.ts']),
|
||||
nodeProject('integration', ['tests/integration/**/*.test.ts']),
|
||||
nodeProject('component', ['tests/component/**/*.test.ts']),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -120,7 +120,12 @@ const AgentsPage = () => {
|
||||
<div key={request._id} className='border-border border p-4'>
|
||||
<p className='font-medium'>{request.prompt}</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{request.status.replaceAll('_', ' ')}
|
||||
{request.status.replaceAll('_', ' ')} ·{' '}
|
||||
{(request.requestType ?? 'future_code_change').replaceAll(
|
||||
'_',
|
||||
' ',
|
||||
)}{' '}
|
||||
· {request.source ?? 'user'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
import Link from 'next/link';
|
||||
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { Bot, GitBranch, GitPullRequest, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Bot,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
@@ -14,12 +21,18 @@ const DashboardPage = () => {
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const agentRequests =
|
||||
useQuery(api.agentRequests.listRecent, { limit: 5 }) ?? [];
|
||||
const aiReviews = useQuery(api.aiReviews.listRecent, { limit: 5 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const needsReview = syncRuns.filter(
|
||||
(run) => run.status === 'needs_review',
|
||||
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||
const diverged = spoons.filter(
|
||||
(spoon) => spoon.syncStatus === 'diverged',
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
@@ -49,9 +62,9 @@ const DashboardPage = () => {
|
||||
icon={GitPullRequest}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Needs review'
|
||||
value={needsReview}
|
||||
note='Upstream updates'
|
||||
label='Behind upstream'
|
||||
value={behind}
|
||||
note={`${diverged} diverged`}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
@@ -60,8 +73,19 @@ const DashboardPage = () => {
|
||||
note='Queued and recent'
|
||||
icon={Bot}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Waiting across Spoons'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue spoons={spoons} />
|
||||
</section>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||
@@ -112,6 +136,35 @@ const DashboardPage = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='mt-4 shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base'>AI reviews</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{aiReviews.length ? (
|
||||
<div className='space-y-3'>
|
||||
{aiReviews.map((review) => (
|
||||
<div
|
||||
key={review._id}
|
||||
className='border-border border p-3 text-sm'
|
||||
>
|
||||
<p className='font-medium capitalize'>
|
||||
{review.risk} risk
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{review.outputSummary ?? review.inputSummary}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
OpenAI compatibility reviews will appear here after you run
|
||||
them on a Spoon.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { GitHubConnectClient } from '@/components/github/github-connect-client';
|
||||
|
||||
export default async function Page({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ installation_id?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>
|
||||
Connect GitHub
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||
Spoon stores the GitHub App installation ID and uses short-lived
|
||||
installation tokens for repository automation.
|
||||
</p>
|
||||
</div>
|
||||
<GitHubConnectClient installationId={params.installation_id} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { OpenAiStatusPanel } from '@/components/integrations/openai-status-panel';
|
||||
|
||||
const AiSettingsPage = () => (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>AI</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Configure the OpenAI key, review model, and thinking level used for
|
||||
compatibility reviews.
|
||||
</p>
|
||||
</div>
|
||||
<OpenAiStatusPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default AiSettingsPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GithubIntegrationPanel } from '@/components/integrations/github-integration-panel';
|
||||
|
||||
const IntegrationsPage = () => (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Integrations</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Provider access used by Spoon maintenance workflows.
|
||||
</p>
|
||||
</div>
|
||||
<GithubIntegrationPanel />
|
||||
</section>
|
||||
);
|
||||
|
||||
export default IntegrationsPage;
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Brain, Github, Shield, User } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
const settingsItems = [
|
||||
{ href: '/settings/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
|
||||
{ href: '/settings/ai', label: 'AI', icon: Brain },
|
||||
{ href: '/settings/security', label: 'Security', icon: Shield },
|
||||
];
|
||||
|
||||
const SettingsLayout = ({ children }: { children: ReactNode }) => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<div className='flex flex-col justify-between gap-4 border-b pb-5 lg:flex-row lg:items-end'>
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>Settings</h1>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
Account, provider, AI, and security controls for this Spoon
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-6 xl:grid-cols-[13rem_1fr]'>
|
||||
<nav className='border-border bg-card flex gap-1 overflow-x-auto border p-2 xl:flex-col xl:self-start'>
|
||||
{settingsItems.map(({ href, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'hover:bg-muted flex min-w-fit items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
pathname === href
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className='size-4' />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className='min-w-0'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLayout;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
const SettingsPage = () => {
|
||||
redirect('/settings/profile');
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
@@ -0,0 +1,42 @@
|
||||
'use server';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@spoon/ui';
|
||||
|
||||
const SettingsProfilePage = async () => {
|
||||
const preloadedUser = await preloadQuery(api.auth.getUser, {});
|
||||
const preloadedUserProvider = await preloadQuery(
|
||||
api.auth.getUserProvider,
|
||||
{},
|
||||
);
|
||||
return (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Profile</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Manage your identity, avatar, and account email.
|
||||
</p>
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<ProfileHeader />
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator className='my-6' />
|
||||
<UserInfoForm
|
||||
preloadedUser={preloadedUser}
|
||||
preloadedProvider={preloadedUserProvider}
|
||||
/>
|
||||
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsProfilePage;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SignOutForm } from '@/components/layout/auth/profile';
|
||||
|
||||
import { Card } from '@spoon/ui';
|
||||
|
||||
const SecuritySettingsPage = () => (
|
||||
<section className='max-w-3xl space-y-4'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>Security</h2>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Session controls and security-sensitive account actions.
|
||||
</p>
|
||||
</div>
|
||||
<Card className='shadow-none'>
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default SecuritySettingsPage;
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { AgentJobList } from '@/components/agents/agent-job-list';
|
||||
import { AgentRequestForm } from '@/components/agents/agent-request-form';
|
||||
import { SpoonActivityTimeline } from '@/components/spoons/spoon-activity-timeline';
|
||||
import { SpoonAgentSettingsForm } from '@/components/spoons/spoon-agent-settings-form';
|
||||
import { SpoonAiReviewPanel } from '@/components/spoons/spoon-ai-review-panel';
|
||||
import { SpoonClonePanel } from '@/components/spoons/spoon-clone-panel';
|
||||
import { SpoonCommitList } from '@/components/spoons/spoon-commit-list';
|
||||
import { SpoonDetailHeader } from '@/components/spoons/spoon-detail-header';
|
||||
import { SpoonMetrics } from '@/components/spoons/spoon-metrics';
|
||||
import { SpoonPrList } from '@/components/spoons/spoon-pr-list';
|
||||
import { SpoonSecretsForm } from '@/components/spoons/spoon-secrets-form';
|
||||
import { SpoonSettingsForm } from '@/components/spoons/spoon-settings-form';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@spoon/ui';
|
||||
|
||||
const SpoonDetailPage = () => {
|
||||
const params = useParams<{ spoonId: string }>();
|
||||
const spoonId = params.spoonId as Id<'spoons'>;
|
||||
const details = useQuery(api.spoons.getDetails, { spoonId });
|
||||
const upstreamCommits =
|
||||
useQuery(api.spoonCommits.listForSpoon, {
|
||||
spoonId,
|
||||
side: 'upstream',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const forkCommits =
|
||||
useQuery(api.spoonCommits.listForSpoon, {
|
||||
spoonId,
|
||||
side: 'fork',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const pullRequests =
|
||||
useQuery(api.spoonPullRequests.listForSpoon, { spoonId, limit: 100 }) ?? [];
|
||||
const reviews =
|
||||
useQuery(api.aiReviews.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const syncRuns =
|
||||
useQuery(api.syncRuns.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const agentRequests =
|
||||
useQuery(api.agentRequests.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||
spoonId,
|
||||
});
|
||||
const agentJobs =
|
||||
useQuery(api.agentJobs.listForSpoon, { spoonId, limit: 25 }) ?? [];
|
||||
|
||||
if (details === undefined) {
|
||||
return <main className='text-muted-foreground p-6'>Loading Spoon...</main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className='space-y-6'>
|
||||
<SpoonDetailHeader spoon={details.spoon} state={details.state} />
|
||||
<SpoonMetrics
|
||||
spoon={details.spoon}
|
||||
state={details.state}
|
||||
latestReview={details.latestReview}
|
||||
/>
|
||||
{details.spoon.lastError ? (
|
||||
<Card className='border-destructive shadow-none'>
|
||||
<CardContent className='p-4 text-sm'>
|
||||
{details.spoon.lastError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue='overview' className='flex flex-col gap-5'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='border-border flex h-auto w-full justify-start overflow-x-auto rounded-none border-b p-0'
|
||||
>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='overview'>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='upstream'>
|
||||
Upstream
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='fork'>
|
||||
Fork changes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='pulls'>
|
||||
Pull requests
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='ai'>
|
||||
AI review
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='agent'>
|
||||
Agent work
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='activity'>
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className='h-9 flex-none px-3' value='settings'>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='space-y-4'>
|
||||
<div className='grid gap-4 xl:grid-cols-[1.15fr_0.85fr]'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Repository health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 text-sm md:grid-cols-2'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Drift state</p>
|
||||
<p className='mt-1 text-xl font-semibold capitalize'>
|
||||
{(
|
||||
details.state?.status ??
|
||||
details.spoon.syncStatus ??
|
||||
'unknown'
|
||||
).replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Default branches</p>
|
||||
<p className='mt-1 font-medium'>
|
||||
{details.state?.upstreamDefaultBranch ??
|
||||
details.spoon.upstreamDefaultBranch}{' '}
|
||||
→{' '}
|
||||
{details.state?.forkDefaultBranch ??
|
||||
details.spoon.forkDefaultBranch ??
|
||||
details.spoon.upstreamDefaultBranch}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Merge base</p>
|
||||
<p className='mt-1 truncate font-mono text-xs'>
|
||||
{details.state?.mergeBaseSha ??
|
||||
details.spoon.lastMergeBaseCommit ??
|
||||
'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Cadence</p>
|
||||
<p className='mt-1 font-medium capitalize'>
|
||||
{details.spoon.syncCadence}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Latest AI review</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3 text-sm'>
|
||||
{details.latestReview ? (
|
||||
<>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Risk</p>
|
||||
<p className='mt-1 font-semibold capitalize'>
|
||||
{details.latestReview.risk}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Action</p>
|
||||
<p className='mt-1 font-semibold capitalize'>
|
||||
{details.latestReview.recommendedAction.replaceAll(
|
||||
'_',
|
||||
' ',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-muted-foreground'>
|
||||
{details.latestReview.outputSummary ??
|
||||
details.latestReview.inputSummary}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-muted-foreground'>
|
||||
Run a refresh and AI review to get a compatibility summary
|
||||
for upstream changes.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SpoonClonePanel spoon={details.spoon} />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold'>Upstream waiting</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Commits upstream has that your fork does not.
|
||||
</p>
|
||||
</div>
|
||||
<SpoonCommitList
|
||||
commits={upstreamCommits.slice(0, 5)}
|
||||
empty='No upstream-only commits are cached. Refresh from GitHub to check drift.'
|
||||
/>
|
||||
</section>
|
||||
<section className='space-y-3'>
|
||||
<div>
|
||||
<h2 className='text-base font-semibold'>Fork changes</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Custom commits Spoon should preserve during maintenance.
|
||||
</p>
|
||||
</div>
|
||||
<SpoonCommitList
|
||||
commits={forkCommits.slice(0, 5)}
|
||||
empty='No fork-only commits are cached.'
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='upstream'>
|
||||
<SpoonCommitList
|
||||
commits={upstreamCommits}
|
||||
empty='No upstream changes are waiting, or this Spoon has not been refreshed yet.'
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='fork'>
|
||||
<SpoonCommitList
|
||||
commits={forkCommits}
|
||||
empty='No fork-only commits are cached. Your customizations will appear here after refresh.'
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='pulls'>
|
||||
<SpoonPrList pullRequests={pullRequests} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='ai' className='space-y-4'>
|
||||
<SpoonAiReviewPanel
|
||||
latestReview={details.latestReview}
|
||||
reviews={reviews}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='agent' className='space-y-4'>
|
||||
<AgentRequestForm
|
||||
spoon={details.spoon}
|
||||
agentSettings={agentSettings}
|
||||
/>
|
||||
<AgentJobList jobs={agentJobs} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<SpoonActivityTimeline
|
||||
syncRuns={syncRuns}
|
||||
reviews={reviews}
|
||||
requests={agentRequests}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='settings' className='space-y-4'>
|
||||
<SpoonSettingsForm
|
||||
spoon={details.spoon}
|
||||
settings={details.settings}
|
||||
/>
|
||||
<SpoonAgentSettingsForm
|
||||
spoon={details.spoon}
|
||||
settings={agentSettings}
|
||||
/>
|
||||
<SpoonSecretsForm spoonId={spoonId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpoonDetailPage;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GitHubConnectionPanel } from '@/components/github/github-connection-panel';
|
||||
import { GitHubForkForm } from '@/components/spoons/github-fork-form';
|
||||
import { NewSpoonForm } from '@/components/spoons/new-spoon-form';
|
||||
|
||||
const NewSpoonPage = () => (
|
||||
@@ -5,8 +7,18 @@ const NewSpoonPage = () => (
|
||||
<div>
|
||||
<h1 className='text-3xl font-semibold tracking-normal'>New Spoon</h1>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl'>
|
||||
Create a provider-neutral managed fork record. This does not call a Git
|
||||
provider yet; it prepares the dashboard surface for future automation.
|
||||
Connect GitHub to create real forks, or add an existing managed fork
|
||||
manually.
|
||||
</p>
|
||||
</div>
|
||||
<GitHubConnectionPanel />
|
||||
<GitHubForkForm />
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold tracking-normal'>
|
||||
Add an existing fork manually
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-2 max-w-2xl text-sm'>
|
||||
Use this path for non-GitHub providers or forks that already exist.
|
||||
</p>
|
||||
</div>
|
||||
<NewSpoonForm />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { MaintenanceQueue } from '@/components/updates/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
@@ -76,6 +77,10 @@ const UpdatesPage = () => {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue spoons={spoons} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,51 +1,6 @@
|
||||
'use server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import {
|
||||
AvatarUpload,
|
||||
ProfileHeader,
|
||||
ResetPasswordForm,
|
||||
SignOutForm,
|
||||
UserInfoForm,
|
||||
} from '@/components/layout/auth/profile';
|
||||
import { preloadQuery } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, Separator } from '@spoon/ui';
|
||||
|
||||
const Profile = async () => {
|
||||
const preloadedUser = await preloadQuery(api.auth.getUser, {});
|
||||
const preloadedUserProvider = await preloadQuery(
|
||||
api.auth.getUserProvider,
|
||||
{},
|
||||
);
|
||||
return (
|
||||
<main className='container mx-auto px-4 py-12 md:py-16'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
{/* Page Header */}
|
||||
<div className='mb-8 text-center'>
|
||||
<h1 className='mb-2 text-3xl font-bold tracking-tight sm:text-4xl'>
|
||||
Your Profile
|
||||
</h1>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your personal information and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Card className='border-border/40'>
|
||||
<ProfileHeader />
|
||||
<AvatarUpload preloadedUser={preloadedUser} />
|
||||
<Separator className='my-6' />
|
||||
<UserInfoForm
|
||||
preloadedUser={preloadedUser}
|
||||
preloadedProvider={preloadedUserProvider}
|
||||
/>
|
||||
<ResetPasswordForm preloadedProvider={preloadedUserProvider} />
|
||||
<Separator className='my-6' />
|
||||
<SignOutForm />
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
const Profile = () => {
|
||||
redirect('/settings/profile');
|
||||
};
|
||||
export default Profile;
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AuthentikSignInButton } from '@/components/layout/auth/buttons';
|
||||
import {
|
||||
AuthentikSignInButton,
|
||||
GitHubSignInButton,
|
||||
} from '@/components/layout/auth/buttons';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ConvexError } from 'convex/values';
|
||||
@@ -342,7 +345,8 @@ const SignIn = () => {
|
||||
<Separator className='ml-3 py-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<div className='mt-3 flex flex-col items-center gap-3'>
|
||||
<GitHubSignInButton />
|
||||
<AuthentikSignInButton />
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -450,7 +454,8 @@ const SignIn = () => {
|
||||
<Separator className='ml-3 py-0.5' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3 flex justify-center'>
|
||||
<div className='mt-3 flex flex-col items-center gap-3'>
|
||||
<GitHubSignInButton type='signUp' />
|
||||
<AuthentikSignInButton type='signUp' />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -7,9 +7,6 @@ import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import '@/app/styles.css';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Footer from '@/components/layout/footer';
|
||||
import Header from '@/components/layout/header';
|
||||
import { ConvexClientProvider } from '@/components/providers';
|
||||
import { env } from '@/env';
|
||||
import { generateMetadata } from '@/lib/metadata';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
@@ -59,20 +56,13 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ConvexClientProvider>
|
||||
<main className='flex min-h-screen flex-col items-center'>
|
||||
<Header />
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
<Footer />
|
||||
</main>
|
||||
<main className='flex min-h-[90vh] flex-col items-center'>
|
||||
<Toaster />
|
||||
</main>
|
||||
</ConvexClientProvider>
|
||||
<main className='flex min-h-screen flex-col items-center justify-center gap-4 p-6'>
|
||||
<NextError statusCode={0} />
|
||||
{reset !== undefined && (
|
||||
<Button onClick={() => reset()}>Try Again</Button>
|
||||
)}
|
||||
<Toaster />
|
||||
</main>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button } from '@spoon/ui';
|
||||
|
||||
export const AgentArtifactViewer = ({
|
||||
artifacts,
|
||||
}: {
|
||||
artifacts: Doc<'agentJobArtifacts'>[];
|
||||
}) => {
|
||||
if (!artifacts.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No artifacts captured yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{artifacts.map((artifact) => (
|
||||
<section key={artifact._id} className='border-border rounded-md border'>
|
||||
<div className='flex items-center justify-between gap-3 border-b p-3'>
|
||||
<div>
|
||||
<h3 className='text-sm font-semibold'>{artifact.title}</h3>
|
||||
<p className='text-muted-foreground text-xs'>{artifact.kind}</p>
|
||||
</div>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
aria-label='Copy artifact'
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(artifact.content);
|
||||
toast.success('Artifact copied.');
|
||||
}}
|
||||
>
|
||||
<Copy className='size-4' />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className='bg-muted/40 max-h-96 overflow-auto p-3 text-xs whitespace-pre-wrap'>
|
||||
{artifact.content}
|
||||
</pre>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
|
||||
const formatTime = (value: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
|
||||
export const AgentEventLog = ({
|
||||
events,
|
||||
}: {
|
||||
events: Doc<'agentJobEvents'>[];
|
||||
}) => {
|
||||
if (!events.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>No worker events yet.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{events.map((event) => (
|
||||
<div key={event._id} className='grid gap-1 border-b p-3 text-sm'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-mono text-xs uppercase'>{event.phase}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{formatTime(event.createdAt)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs capitalize'>
|
||||
{event.level}
|
||||
</span>
|
||||
</div>
|
||||
<p className='whitespace-pre-wrap'>{event.message}</p>
|
||||
{event.metadata ? (
|
||||
<pre className='bg-muted overflow-auto rounded p-2 text-xs'>
|
||||
{event.metadata}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from 'convex/react';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
import { AgentArtifactViewer } from './agent-artifact-viewer';
|
||||
import { AgentEventLog } from './agent-event-log';
|
||||
|
||||
export const AgentJobDetail = ({ job }: { job: Doc<'agentJobs'> }) => {
|
||||
const events =
|
||||
useQuery(api.agentJobs.listEvents, { jobId: job._id, limit: 200 }) ?? [];
|
||||
const artifacts =
|
||||
useQuery(api.agentJobs.listArtifacts, { jobId: job._id }) ?? [];
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Job details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-5'>
|
||||
<div className='grid gap-3 text-sm md:grid-cols-3'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Status</p>
|
||||
<p className='font-medium capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Branch</p>
|
||||
<p className='font-mono text-xs'>{job.workBranch}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-xs'>Model</p>
|
||||
<p className='font-medium'>{job.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
{job.pullRequestUrl ? (
|
||||
<a
|
||||
href={job.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary text-sm font-medium underline-offset-4 hover:underline'
|
||||
>
|
||||
Open draft PR #{job.pullRequestNumber}
|
||||
</a>
|
||||
) : null}
|
||||
{job.error ? (
|
||||
<pre className='border-destructive bg-destructive/5 text-destructive overflow-auto rounded-md border p-3 text-xs whitespace-pre-wrap'>
|
||||
{job.error}
|
||||
</pre>
|
||||
) : null}
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>Events</h3>
|
||||
<AgentEventLog events={events} />
|
||||
</section>
|
||||
<section className='space-y-2'>
|
||||
<h3 className='text-sm font-semibold'>Artifacts</h3>
|
||||
<AgentArtifactViewer artifacts={artifacts} />
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from 'convex/react';
|
||||
import { ExternalLink, XCircle } 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 { Badge, Button } from '@spoon/ui';
|
||||
|
||||
import { AgentJobDetail } from './agent-job-detail';
|
||||
|
||||
const formatTime = (value: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
|
||||
export const AgentJobList = ({ jobs }: { jobs: Doc<'agentJobs'>[] }) => {
|
||||
const cancel = useMutation(api.agentJobs.cancel);
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(
|
||||
jobs[0]?._id ?? null,
|
||||
);
|
||||
const selectedJob = jobs.find((job) => job._id === selectedJobId) ?? jobs[0];
|
||||
|
||||
if (!jobs.length) {
|
||||
return (
|
||||
<div className='border-border rounded-md border p-5'>
|
||||
<h3 className='text-sm font-semibold'>No agent jobs yet</h3>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Queue a job to have Spoon open a draft PR against this fork.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-[0.85fr_1.15fr]'>
|
||||
<div className='divide-border overflow-hidden rounded-md border'>
|
||||
{jobs.map((job) => (
|
||||
<button
|
||||
key={job._id}
|
||||
type='button'
|
||||
className='hover:bg-muted/40 data-[selected=true]:bg-muted/60 block w-full border-b p-3 text-left'
|
||||
data-selected={job._id === selectedJob?._id}
|
||||
onClick={() => setSelectedJobId(job._id)}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>{job.prompt}</p>
|
||||
<p className='text-muted-foreground mt-1 font-mono text-xs'>
|
||||
{job.workBranch}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{job.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 flex flex-wrap gap-2 text-xs'>
|
||||
<span>{formatTime(job.createdAt)}</span>
|
||||
{job.pullRequestUrl ? (
|
||||
<a
|
||||
href={job.pullRequestUrl}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary inline-flex items-center gap-1'
|
||||
>
|
||||
PR <ExternalLink className='size-3' />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedJob ? (
|
||||
<div className='space-y-3'>
|
||||
{[
|
||||
'queued',
|
||||
'claimed',
|
||||
'preparing',
|
||||
'running',
|
||||
'checks_running',
|
||||
].includes(selectedJob.status) ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await cancel({ jobId: selectedJob._id });
|
||||
toast.success('Agent job cancelled.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not cancel job.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XCircle className='size-4' />
|
||||
Cancel job
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentJobDetail job={selectedJob} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import type { Doc, 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,
|
||||
Textarea,
|
||||
} from '@spoon/ui';
|
||||
|
||||
import { SecretSelector } from './secret-selector';
|
||||
|
||||
type AgentSettings = {
|
||||
defaultBaseBranch?: string;
|
||||
agentModel: string;
|
||||
reasoningEffort: string;
|
||||
};
|
||||
|
||||
export const AgentRequestForm = ({
|
||||
spoon,
|
||||
agentSettings,
|
||||
}: {
|
||||
spoon: Doc<'spoons'>;
|
||||
agentSettings?: AgentSettings | null;
|
||||
}) => {
|
||||
const secrets = useQuery(api.spoonSecrets.listForSpoon, {
|
||||
spoonId: spoon._id,
|
||||
});
|
||||
const createRequest = useMutation(api.agentRequests.create);
|
||||
const createJob = useMutation(api.agentJobs.createFromRequest);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [baseBranch, setBaseBranch] = useState(
|
||||
agentSettings?.defaultBaseBranch ??
|
||||
spoon.forkDefaultBranch ??
|
||||
spoon.upstreamDefaultBranch,
|
||||
);
|
||||
const [requestedBranchName, setRequestedBranchName] = useState('');
|
||||
const [selectedSecretIds, setSelectedSecretIds] = useState<
|
||||
Id<'spoonSecrets'>[]
|
||||
>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const requestId = await createRequest({
|
||||
spoonId: spoon._id,
|
||||
prompt,
|
||||
targetBranch: baseBranch,
|
||||
});
|
||||
await createJob({
|
||||
requestId,
|
||||
selectedSecretIds,
|
||||
baseBranch,
|
||||
requestedBranchName: requestedBranchName || undefined,
|
||||
});
|
||||
setPrompt('');
|
||||
setRequestedBranchName('');
|
||||
setSelectedSecretIds([]);
|
||||
toast.success('Agent job queued.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not queue agent job.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Bot className='size-4' />
|
||||
Request agent work
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={submit} className='space-y-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='agentPrompt'>Prompt</Label>
|
||||
<Textarea
|
||||
id='agentPrompt'
|
||||
required
|
||||
minLength={12}
|
||||
value={prompt}
|
||||
placeholder='Update this fork to use Authentik as the sole Auth.js provider.'
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='baseBranch'>Base branch</Label>
|
||||
<Input
|
||||
id='baseBranch'
|
||||
value={baseBranch}
|
||||
onChange={(event) => setBaseBranch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='workBranch'>Work branch</Label>
|
||||
<Input
|
||||
id='workBranch'
|
||||
value={requestedBranchName}
|
||||
placeholder='Auto-generated if blank'
|
||||
onChange={(event) => setRequestedBranchName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Secrets exposed to this job</Label>
|
||||
<SecretSelector
|
||||
secrets={secrets ?? []}
|
||||
selectedSecretIds={selectedSecretIds}
|
||||
onChange={setSelectedSecretIds}
|
||||
/>
|
||||
</div>
|
||||
<div className='bg-muted/40 grid gap-1 rounded-md p-3 text-xs'>
|
||||
<span>
|
||||
Model:{' '}
|
||||
<strong>{agentSettings?.agentModel ?? 'gpt-5.1-codex'}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Reasoning:{' '}
|
||||
<strong>{agentSettings?.reasoningEffort ?? 'high'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? 'Queueing...' : 'Queue agent job'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Checkbox, Label } from '@spoon/ui';
|
||||
|
||||
type Secret = {
|
||||
_id: Id<'spoonSecrets'>;
|
||||
name: string;
|
||||
valuePreview?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const SecretSelector = ({
|
||||
secrets,
|
||||
selectedSecretIds,
|
||||
onChange,
|
||||
}: {
|
||||
secrets: Secret[];
|
||||
selectedSecretIds: Id<'spoonSecrets'>[];
|
||||
onChange: (secretIds: Id<'spoonSecrets'>[]) => void;
|
||||
}) => {
|
||||
const toggle = (secretId: Id<'spoonSecrets'>, checked: boolean) => {
|
||||
onChange(
|
||||
checked
|
||||
? [...selectedSecretIds, secretId]
|
||||
: selectedSecretIds.filter((id) => id !== secretId),
|
||||
);
|
||||
};
|
||||
|
||||
if (!secrets.length) {
|
||||
return (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No Spoon secrets saved. Add project secrets in Settings when a job needs
|
||||
environment variables.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-2'>
|
||||
{secrets.map((secret) => (
|
||||
<label
|
||||
key={secret._id}
|
||||
className='border-border flex items-start gap-3 rounded-md border p-3'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedSecretIds.includes(secret._id)}
|
||||
onCheckedChange={(checked) => toggle(secret._id, checked === true)}
|
||||
/>
|
||||
<span className='grid gap-1'>
|
||||
<Label className='font-mono text-xs'>{secret.name}</Label>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{secret.description ?? secret.valuePreview ?? 'Configured'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Bot, GitBranch, LayoutDashboard, RefreshCw, User } from 'lucide-react';
|
||||
import { GitBranch, LayoutDashboard, RefreshCw, Settings } from 'lucide-react';
|
||||
|
||||
import { cn } from '@spoon/ui';
|
||||
|
||||
@@ -11,8 +11,7 @@ const navItems = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/spoons', label: 'My Spoons', icon: GitBranch },
|
||||
{ href: '/updates', label: 'Updates', icon: RefreshCw },
|
||||
{ href: '/agents', label: 'Agents', icon: Bot },
|
||||
{ href: '/profile', label: 'Profile', icon: User },
|
||||
{ href: '/settings/profile', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export const AppShell = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from 'convex/react';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const GitHubConnectClient = ({
|
||||
installationId,
|
||||
}: {
|
||||
installationId?: string;
|
||||
}) => {
|
||||
const connectInstallation = useMutation(api.github.connectInstallation);
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'failed'>(
|
||||
installationId ? 'saving' : 'idle',
|
||||
);
|
||||
const hasSubmitted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!installationId || hasSubmitted.current) return;
|
||||
hasSubmitted.current = true;
|
||||
void connectInstallation({ installationId })
|
||||
.then(() => setStatus('saved'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setStatus('failed');
|
||||
});
|
||||
}, [connectInstallation, installationId]);
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-6'>
|
||||
<p className='text-lg font-medium'>GitHub App connection</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
{status === 'idle'
|
||||
? 'GitHub did not provide an installation ID in this callback.'
|
||||
: status === 'saving'
|
||||
? 'Saving your GitHub installation...'
|
||||
: status === 'saved'
|
||||
? 'GitHub is connected. You can create forks from Spoon now.'
|
||||
: 'Could not save this GitHub installation.'}
|
||||
</p>
|
||||
<Button className='mt-5' asChild>
|
||||
<Link href='/spoons/new'>Create a Spoon</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Github, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
const syncConfiguredInstallationRef = makeFunctionReference<
|
||||
'action',
|
||||
Record<string, never>,
|
||||
string
|
||||
>('githubNode:syncConfiguredInstallation');
|
||||
|
||||
export const GitHubConnectionPanel = () => {
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||
const syncConfiguredInstallation = useAction(syncConfiguredInstallationRef);
|
||||
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
await syncConfiguredInstallation({});
|
||||
toast.success('GitHub installation connected.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not connect the configured GitHub installation.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='flex flex-col gap-4 p-5 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex gap-3'>
|
||||
<div className='bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-lg'>
|
||||
<Github className='size-5' />
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium'>GitHub App connection</p>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{connection
|
||||
? `Connected to ${connection.displayName}`
|
||||
: 'Install or sync the Spoon GitHub App before creating forks.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{installUrl ? (
|
||||
<Button asChild variant='outline'>
|
||||
<Link href={installUrl}>Install GitHub App</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type='button' onClick={handleSync}>
|
||||
<RefreshCw className='size-4' />
|
||||
Sync configured installation
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useAction, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Github, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui';
|
||||
|
||||
const listReposRef = makeFunctionReference<
|
||||
'action',
|
||||
Record<string, never>,
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
fullName: string;
|
||||
owner: string;
|
||||
private: boolean;
|
||||
fork: boolean;
|
||||
url: string;
|
||||
defaultBranch: string;
|
||||
description?: string;
|
||||
}[]
|
||||
>('githubNode:listInstallationRepositories');
|
||||
|
||||
export const GithubIntegrationPanel = () => {
|
||||
const connection = useQuery(api.github.getConnection, {});
|
||||
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||
const listRepos = useAction(listReposRef);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const repos = await listRepos({});
|
||||
toast.success(`GitHub can access ${repos.length} repositories.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not list GitHub repositories.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Github className='size-4' />
|
||||
GitHub App
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{connection ? (
|
||||
<div className='grid gap-2 text-sm'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Connected account</p>
|
||||
<p className='font-medium'>{connection.displayName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Installation ID</p>
|
||||
<p className='font-mono text-xs'>{connection.installationId}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Install the GitHub App to let Spoon create forks and refresh
|
||||
repository state.
|
||||
</p>
|
||||
)}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{installUrl ? (
|
||||
<Button asChild>
|
||||
<a href={installUrl} target='_blank' rel='noreferrer'>
|
||||
Configure GitHub App
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant='outline' onClick={refresh} disabled={!connection}>
|
||||
<RefreshCw className='size-4' />
|
||||
Check repository access
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||
import { makeFunctionReference } from 'convex/server';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@spoon/ui';
|
||||
|
||||
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
const saveOpenAiSettingsRef = makeFunctionReference<
|
||||
'action',
|
||||
{ apiKey: string; model: string; reasoningEffort: ReasoningEffort },
|
||||
{ success: true }
|
||||
>('aiSettingsNode:saveOpenAiSettings');
|
||||
|
||||
const modelOptions = [
|
||||
{ value: 'gpt-5.5', label: 'GPT-5.5' },
|
||||
{ value: 'gpt-5.5-pro', label: 'GPT-5.5 Pro' },
|
||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
||||
{ value: 'gpt-5.4-mini', label: 'GPT-5.4 Mini' },
|
||||
];
|
||||
|
||||
const reasoningOptions: { value: ReasoningEffort; label: string }[] = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'xhigh', label: 'Extra high' },
|
||||
];
|
||||
|
||||
export const OpenAiStatusPanel = () => {
|
||||
const status = useQuery(api.integrations.getStatus, {});
|
||||
const settings = useQuery(api.aiSettings.getMine, {});
|
||||
const saveOpenAiSettings = useAction(saveOpenAiSettingsRef);
|
||||
const updatePreferences = useMutation(api.aiSettings.updatePreferences);
|
||||
const removeOpenAiKey = useMutation(api.aiSettings.removeOpenAiKey);
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [model, setModel] = useState('gpt-5.5');
|
||||
const [reasoningEffort, setReasoningEffort] =
|
||||
useState<ReasoningEffort>('medium');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setModel(settings.model);
|
||||
setReasoningEffort(settings.reasoningEffort as ReasoningEffort);
|
||||
}, [settings]);
|
||||
|
||||
const save = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (apiKey.trim()) {
|
||||
await saveOpenAiSettings({
|
||||
apiKey,
|
||||
model,
|
||||
reasoningEffort,
|
||||
});
|
||||
setApiKey('');
|
||||
} else {
|
||||
await updatePreferences({
|
||||
model,
|
||||
reasoningEffort,
|
||||
});
|
||||
}
|
||||
toast.success('OpenAI settings saved.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not save OpenAI settings.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
try {
|
||||
await removeOpenAiKey({});
|
||||
toast.success('OpenAI API key removed.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not remove OpenAI API key.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<Brain className='size-4' />
|
||||
OpenAI reviews
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-2 text-sm'>
|
||||
<p className='text-muted-foreground'>
|
||||
Compatibility reviews use your own OpenAI API key. Spoon encrypts the
|
||||
key before storing it and only shows a short preview.
|
||||
</p>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>Encryption</p>
|
||||
<p className='font-medium'>
|
||||
{status?.encryptionConfigured ? 'Configured' : 'Missing server key'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>OpenAI API key</p>
|
||||
<p className='font-medium'>
|
||||
{settings?.configured ? settings.apiKeyPreview : 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={save} className='space-y-4 pt-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='openai-api-key'>API key</Label>
|
||||
<Input
|
||||
id='openai-api-key'
|
||||
type='password'
|
||||
value={apiKey}
|
||||
placeholder={
|
||||
settings?.configured
|
||||
? 'Leave blank to keep current key'
|
||||
: 'sk-...'
|
||||
}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid gap-2 md:grid-cols-2'>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Review model</Label>
|
||||
<Select value={model} onValueChange={(value) => setModel(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='grid gap-2'>
|
||||
<Label>Thinking</Label>
|
||||
<Select
|
||||
value={reasoningEffort}
|
||||
onValueChange={(value) =>
|
||||
setReasoningEffort(value as ReasoningEffort)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{reasoningOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button type='submit' disabled={submitting}>
|
||||
{submitting ? 'Saving...' : 'Save OpenAI settings'}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={remove}
|
||||
disabled={!settings?.configured}
|
||||
>
|
||||
Remove key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { VariantProps } from 'class-variance-authority';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { Github, KeyRound } from 'lucide-react';
|
||||
|
||||
import type { buttonVariants } from '@spoon/ui';
|
||||
import { Button } from '@spoon/ui';
|
||||
@@ -33,3 +33,21 @@ export const AuthentikSignInButton = ({
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const GitHubSignInButton = ({ buttonProps, type = 'signIn' }: Props) => {
|
||||
const { signIn } = useAuthActions();
|
||||
return (
|
||||
<Button
|
||||
size='lg'
|
||||
variant='outline'
|
||||
onClick={() => signIn('github')}
|
||||
className='text-lg font-semibold'
|
||||
{...buttonProps}
|
||||
>
|
||||
<div className='my-auto flex flex-row items-center gap-2'>
|
||||
<Github className='size-5' />
|
||||
<p>{type === 'signIn' ? 'Continue' : 'Sign up'} with GitHub</p>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { AuthentikSignInButton } from './gibs-auth';
|
||||
export { AuthentikSignInButton, GitHubSignInButton } from './gibs-auth';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Preloaded } from 'convex/react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { isRemoteImageUrl } from '@/lib/avatar';
|
||||
import { useMutation, usePreloadedQuery, useQuery } from 'convex/react';
|
||||
import { Loader2, Pencil, Upload, XIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -48,10 +49,12 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
|
||||
const updateUser = useMutation(api.auth.updateUser);
|
||||
|
||||
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
||||
);
|
||||
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
@@ -117,7 +120,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl ?? undefined}
|
||||
src={avatarUrl ?? undefined}
|
||||
fullName={user?.name}
|
||||
className='h-42 w-42 text-6xl font-semibold'
|
||||
userIconProps={{ size: 100 }}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const UserInfoForm = ({
|
||||
const providerMap: Record<string, string> = {
|
||||
unknown: 'Provider',
|
||||
authentik: 'Authentik',
|
||||
github: 'GitHub',
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isRemoteImageUrl } from '@/lib/avatar';
|
||||
import { useAuthActions } from '@convex-dev/auth/react';
|
||||
import { useConvexAuth, useQuery } from 'convex/react';
|
||||
|
||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
BasedAvatar,
|
||||
@@ -23,10 +23,12 @@ export const AvatarDropdown = () => {
|
||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||
const { signOut } = useAuthActions();
|
||||
const user = useQuery(api.auth.getUser, {});
|
||||
const remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null;
|
||||
const currentImageUrl = useQuery(
|
||||
api.files.getImageUrl,
|
||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||
user?.image && !remoteImageUrl ? { storageId: user.image } : 'skip',
|
||||
);
|
||||
const avatarUrl = remoteImageUrl ?? currentImageUrl;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -51,7 +53,7 @@ export const AvatarDropdown = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<BasedAvatar
|
||||
src={currentImageUrl}
|
||||
src={avatarUrl}
|
||||
fullName={user?.name}
|
||||
className='h-9 w-9'
|
||||
fallbackProps={{ className: 'text-sm font-semibold' }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from 'next/link';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
|
||||
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||
import { Button, Card, CardContent } from '@spoon/ui';
|
||||
|
||||
export const MaintenanceQueue = ({ spoons }: { spoons: Doc<'spoons'>[] }) => {
|
||||
const queued = spoons
|
||||
.filter((spoon) =>
|
||||
['behind', 'diverged', 'conflict', 'error'].includes(
|
||||
spoon.syncStatus ?? '',
|
||||
),
|
||||
)
|
||||
.sort((a, b) => (b.upstreamAheadBy ?? 0) - (a.upstreamAheadBy ?? 0));
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{queued.length ? (
|
||||
queued.map((spoon) => (
|
||||
<Card key={spoon._id} className='shadow-none'>
|
||||
<CardContent className='grid gap-3 p-4 md:grid-cols-[1fr_auto] md:items-center'>
|
||||
<div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<p className='font-medium'>{spoon.name}</p>
|
||||
<SpoonStatusBadge status={spoon.syncStatus} />
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{spoon.upstreamOwner}/{spoon.upstreamRepo} →{' '}
|
||||
{spoon.forkOwner ?? 'unknown'}/{spoon.forkRepo ?? 'unknown'}
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{spoon.upstreamAheadBy ?? 0} upstream commit(s),{' '}
|
||||
{spoon.forkAheadBy ?? 0} fork-only commit(s)
|
||||
</p>
|
||||
</div>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href={`/spoons/${spoon._id}`}>Open Spoon</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='text-muted-foreground p-6 text-sm'>
|
||||
No Spoons currently need review. Refresh GitHub state to populate
|
||||
this queue.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,16 @@ export const env = createEnv({
|
||||
SKIP_ENV_VALIDATION: z.boolean().default(false),
|
||||
SENTRY_AUTH_TOKEN: z.string(),
|
||||
CI: z.boolean().default(false),
|
||||
AUTH_GITHUB_ID: z.string().optional(),
|
||||
AUTH_GITHUB_SECRET: z.string().optional(),
|
||||
GITHUB_APP_ID: z.string().optional(),
|
||||
GITHUB_APP_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_APP_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_APP_PRIVATE_KEY: z.string().optional(),
|
||||
GITHUB_APP_WEBHOOK_SECRET: z.string().optional(),
|
||||
GITHUB_APP_SLUG: z.string().optional(),
|
||||
GITHUB_APP_INSTALLATION_ID: z.string().optional(),
|
||||
GITHUB_APP_OWNER: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -32,6 +42,16 @@ export const env = createEnv({
|
||||
SKIP_ENV_VALIDATION: process.env.SKIP_ENV_VALIDATION,
|
||||
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
|
||||
CI: process.env.CI,
|
||||
AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
|
||||
AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
|
||||
GITHUB_APP_ID: process.env.GITHUB_APP_ID,
|
||||
GITHUB_APP_CLIENT_ID: process.env.GITHUB_APP_CLIENT_ID,
|
||||
GITHUB_APP_CLIENT_SECRET: process.env.GITHUB_APP_CLIENT_SECRET,
|
||||
GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY,
|
||||
GITHUB_APP_WEBHOOK_SECRET: process.env.GITHUB_APP_WEBHOOK_SECRET,
|
||||
GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG,
|
||||
GITHUB_APP_INSTALLATION_ID: process.env.GITHUB_APP_INSTALLATION_ID,
|
||||
GITHUB_APP_OWNER: process.env.GITHUB_APP_OWNER,
|
||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const isRemoteImageUrl = (value: string | null | undefined) => {
|
||||
if (!value) return false;
|
||||
return value.startsWith('http://') || value.startsWith('https://');
|
||||
};
|
||||
@@ -10,6 +10,8 @@ const isProtectedRoute = createRouteMatcher([
|
||||
'/spoons(.*)',
|
||||
'/updates(.*)',
|
||||
'/agents(.*)',
|
||||
'/github(.*)',
|
||||
'/settings(.*)',
|
||||
'/profile(.*)',
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user