diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index 013812ac..73efb19f 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -16,6 +16,7 @@ import { } from '../../db/repositories/credentialsRepository.js'; import { listProjectsForOrg } from '../../db/repositories/runsRepository.js'; import { + cloneProject, createProject, deleteProject, deleteProjectIntegration, @@ -167,6 +168,44 @@ export const projectsRouter = router({ await deleteProject(input.id, ctx.effectiveOrgId); }), + /** + * Clone a project: copies all settings, integrations, credentials (re-encrypted), + * agent configs, and trigger configs to a new project with a given ID and name. + * The `repo` field is NOT copied — user must configure it after cloning. + */ + clone: protectedProcedure + .input( + z.object({ + sourceId: z.string(), + newId: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/), + newName: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + await verifyProjectOwnership(input.sourceId, ctx.effectiveOrgId); + try { + return await cloneProject(ctx.effectiveOrgId, input.sourceId, input.newId, input.newName); + } catch (err) { + // PostgreSQL unique constraint violation (error code 23505) means the + // new project ID already exists — surface an actionable BAD_REQUEST instead + // of an opaque INTERNAL_SERVER_ERROR. + if ( + err instanceof Error && + 'code' in err && + (err as NodeJS.ErrnoException).code === '23505' + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Project ID '${input.newId}' is already taken. Please choose a different name.`, + }); + } + throw err; + } + }), + // Integrations integrations: router({ list: protectedProcedure diff --git a/src/cli/dashboard/projects/clone.ts b/src/cli/dashboard/projects/clone.ts new file mode 100644 index 00000000..85d680e1 --- /dev/null +++ b/src/cli/dashboard/projects/clone.ts @@ -0,0 +1,45 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsClone extends DashboardCommand { + static override description = + 'Clone a project, copying all settings, integrations, credentials, agent configs, and trigger configs.'; + + static override args = { + sourceId: Args.string({ description: 'Source project ID to clone from', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + 'new-id': Flags.string({ + description: 'New project ID (lowercase letters, numbers, hyphens)', + required: true, + }), + name: Flags.string({ description: 'New project name', required: true }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsClone); + + try { + const result = await this.withSpinner('Cloning project...', () => + this.client.projects.clone.mutate({ + sourceId: args.sourceId, + newId: flags['new-id'], + newName: flags.name, + }), + ); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.success( + `Cloned project '${args.sourceId}' → '${result.id}' (${result.name}). Configure the repository field before using.`, + ); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/db/repositories/projectsRepository.ts b/src/db/repositories/projectsRepository.ts index 8fb43d5e..402ad2ad 100644 --- a/src/db/repositories/projectsRepository.ts +++ b/src/db/repositories/projectsRepository.ts @@ -1,7 +1,14 @@ import { and, eq, sql } from 'drizzle-orm'; import { type EngineSettings, normalizeEngineSettings } from '../../config/engineSettings.js'; import { getDb } from '../client.js'; -import { projects } from '../schema/index.js'; +import { reEncryptCredential } from '../crypto.js'; +import { + agentConfigs, + agentTriggerConfigs, + projectCredentials, + projectIntegrations, + projects, +} from '../schema/index.js'; // ============================================================================ // Projects (full CRUD) @@ -118,3 +125,130 @@ export async function deleteProject(projectId: string, orgId: string) { const db = getDb(); await db.delete(projects).where(and(eq(projects.id, projectId), eq(projects.orgId, orgId))); } + +// ============================================================================ +// Clone Project +// ============================================================================ + +/** + * Clone a project: copy all settings, integrations, credentials (re-encrypted), + * agent configs, and trigger configs into a new project row. + * + * The `repo` field is intentionally NOT copied — it has a unique DB constraint + * and the user must configure it separately after cloning. + */ +export async function cloneProject( + orgId: string, + sourceProjectId: string, + newProjectId: string, + newName: string, +): Promise<{ id: string; name: string }> { + const db = getDb(); + + // 1. Fetch source project row + const [sourceProject] = await db + .select() + .from(projects) + .where(and(eq(projects.id, sourceProjectId), eq(projects.orgId, orgId))); + + if (!sourceProject) { + throw new Error(`Source project not found: ${sourceProjectId}`); + } + + // 2. Fetch related tables in parallel + const [integrations, credentials, agentConfigRows, triggerConfigRows] = await Promise.all([ + db.select().from(projectIntegrations).where(eq(projectIntegrations.projectId, sourceProjectId)), + db + .select({ + envVarKey: projectCredentials.envVarKey, + value: projectCredentials.value, + name: projectCredentials.name, + }) + .from(projectCredentials) + .where(eq(projectCredentials.projectId, sourceProjectId)), + db.select().from(agentConfigs).where(eq(agentConfigs.projectId, sourceProjectId)), + db.select().from(agentTriggerConfigs).where(eq(agentTriggerConfigs.projectId, sourceProjectId)), + ]); + + // 3. Run everything in a transaction + await db.transaction(async (tx) => { + // Insert new project row (repo excluded — unique constraint) + await tx.insert(projects).values({ + id: newProjectId, + orgId, + name: newName, + repo: null, + baseBranch: sourceProject.baseBranch, + branchPrefix: sourceProject.branchPrefix, + model: sourceProject.model, + maxIterations: sourceProject.maxIterations, + watchdogTimeoutMs: sourceProject.watchdogTimeoutMs, + workItemBudgetUsd: sourceProject.workItemBudgetUsd, + agentEngine: sourceProject.agentEngine, + agentEngineSettings: sourceProject.agentEngineSettings, + progressModel: sourceProject.progressModel, + progressIntervalMinutes: sourceProject.progressIntervalMinutes, + runLinksEnabled: sourceProject.runLinksEnabled, + maxInFlightItems: sourceProject.maxInFlightItems, + snapshotEnabled: sourceProject.snapshotEnabled, + snapshotTtlMs: sourceProject.snapshotTtlMs, + }); + + // Insert integrations + if (integrations.length > 0) { + await tx.insert(projectIntegrations).values( + integrations.map((i) => ({ + projectId: newProjectId, + category: i.category, + provider: i.provider, + config: i.config, + triggers: i.triggers, + })), + ); + } + + // Insert credentials re-encrypted with new projectId as AAD + if (credentials.length > 0) { + await tx.insert(projectCredentials).values( + credentials.map((c) => ({ + projectId: newProjectId, + envVarKey: c.envVarKey, + value: reEncryptCredential(c.value, sourceProjectId, newProjectId), + name: c.name, + })), + ); + } + + // Insert agent configs + if (agentConfigRows.length > 0) { + await tx.insert(agentConfigs).values( + agentConfigRows.map((a) => ({ + projectId: newProjectId, + agentType: a.agentType, + model: a.model, + maxIterations: a.maxIterations, + agentEngine: a.agentEngine, + agentEngineSettings: a.agentEngineSettings, + maxConcurrency: a.maxConcurrency, + systemPrompt: a.systemPrompt, + taskPrompt: a.taskPrompt, + })), + ); + } + + // Insert trigger configs + if (triggerConfigRows.length > 0) { + await tx.insert(agentTriggerConfigs).values( + triggerConfigRows.map((t) => ({ + projectId: newProjectId, + agentType: t.agentType, + triggerEvent: t.triggerEvent, + enabled: t.enabled, + parameters: t.parameters, + })), + ); + } + }); + + return { id: newProjectId, name: newName }; +} diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 732185e3..66175cca 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -18,6 +18,7 @@ const { mockCreateProject, mockUpdateProject, mockDeleteProject, + mockCloneProject, mockListProjectIntegrations, mockUpsertProjectIntegration, mockDeleteProjectIntegration, @@ -33,6 +34,7 @@ const { mockCreateProject: vi.fn(), mockUpdateProject: vi.fn(), mockDeleteProject: vi.fn(), + mockCloneProject: vi.fn(), mockListProjectIntegrations: vi.fn(), mockUpsertProjectIntegration: vi.fn(), mockDeleteProjectIntegration: vi.fn(), @@ -53,6 +55,7 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ createProject: mockCreateProject, updateProject: mockUpdateProject, deleteProject: mockDeleteProject, + cloneProject: mockCloneProject, listProjectIntegrations: mockListProjectIntegrations, upsertProjectIntegration: mockUpsertProjectIntegration, deleteProjectIntegration: mockDeleteProjectIntegration, @@ -414,6 +417,76 @@ describe('projectsRouter', () => { }); }); + describe('clone', () => { + it('calls cloneProject with correct args after verifying ownership', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCloneProject.mockResolvedValue({ id: 'new-project', name: 'New Project' }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.clone({ + sourceId: 'source-project', + newId: 'new-project', + newName: 'New Project', + }); + + expect(mockCloneProject).toHaveBeenCalledWith( + 'org-1', + 'source-project', + 'new-project', + 'New Project', + ); + expect(result).toEqual({ id: 'new-project', name: 'New Project' }); + }); + + it('throws NOT_FOUND when source project belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.clone({ sourceId: 'p1', newId: 'new-p1', newName: 'New P1' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(mockCloneProject).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expectTRPCError( + caller.clone({ sourceId: 'p1', newId: 'new-p1', newName: 'New P1' }), + 'UNAUTHORIZED', + ); + }); + + it('rejects invalid newId format', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.clone({ sourceId: 'p1', newId: 'INVALID ID!', newName: 'New P1' }), + ).rejects.toThrow(); + }); + + it('rejects empty newName', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + caller.clone({ sourceId: 'p1', newId: 'valid-id', newName: '' }), + ).rejects.toThrow(); + }); + + it('converts unique constraint violation to BAD_REQUEST with actionable message', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + const uniqueConstraintError = Object.assign(new Error('duplicate key value'), { + code: '23505', + }); + mockCloneProject.mockRejectedValue(uniqueConstraintError); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.clone({ sourceId: 'p1', newId: 'existing-id', newName: 'New P1' }), + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: expect.stringContaining('existing-id'), + }); + }); + }); + // ============================================================================ // Integrations sub-router // ============================================================================ diff --git a/tests/unit/db/repositories/projectsRepository.test.ts b/tests/unit/db/repositories/projectsRepository.test.ts index 61fa07e5..a13ed977 100644 --- a/tests/unit/db/repositories/projectsRepository.test.ts +++ b/tests/unit/db/repositories/projectsRepository.test.ts @@ -4,7 +4,18 @@ import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; vi.mock('../../../../src/db/client.js', () => mockDbClientModule); +const { mockReEncryptCredential } = vi.hoisted(() => ({ + mockReEncryptCredential: vi + .fn() + .mockImplementation((value: string, _oldAad: string, _newAad: string) => `re-enc:${value}`), +})); + +vi.mock('../../../../src/db/crypto.js', () => ({ + reEncryptCredential: mockReEncryptCredential, +})); + import { + cloneProject, createProject, deleteProject, getProjectFull, @@ -113,3 +124,169 @@ describe('projectsRepository', () => { }); }); }); + +describe('cloneProject', () => { + let mockDb: ReturnType; + let mockTxInsertValues: ReturnType; + let mockTxInsert: ReturnType; + + const sourceProject = { + id: 'source-project', + orgId: 'org-1', + name: 'Source Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + model: 'gpt-4', + maxIterations: 50, + watchdogTimeoutMs: 1800000, + workItemBudgetUsd: '5.00', + agentEngine: 'claude-code', + agentEngineSettings: null, + progressModel: null, + progressIntervalMinutes: null, + runLinksEnabled: false, + maxInFlightItems: null, + snapshotEnabled: null, + snapshotTtlMs: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + mockDb = createMockDbWithGetDb({ withUpsert: true, withThenable: true }); + mockReEncryptCredential.mockClear(); + + mockTxInsertValues = vi.fn().mockResolvedValue([]); + mockTxInsert = vi.fn().mockReturnValue({ values: mockTxInsertValues }); + + // Wire transaction to call fn with a mock tx + (mockDb.db as unknown as Record).transaction = vi + .fn() + .mockImplementation(async (fn: (tx: unknown) => Promise) => + fn({ insert: mockTxInsert }), + ); + }); + + it('clones project with all five record groups', async () => { + const integrations = [ + { + id: 1, + projectId: 'source-project', + category: 'pm', + provider: 'trello', + config: { boardId: 'abc' }, + triggers: {}, + }, + ]; + const credentials = [ + { envVarKey: 'GITHUB_TOKEN_IMPLEMENTER', value: 'enc:v1:abc', name: 'GH Implementer' }, + ]; + const agentConfigRows = [ + { + id: 1, + projectId: 'source-project', + agentType: 'implementation', + model: null, + maxIterations: null, + agentEngine: null, + agentEngineSettings: null, + maxConcurrency: null, + systemPrompt: null, + taskPrompt: null, + }, + ]; + const triggerConfigRows = [ + { + id: 1, + projectId: 'source-project', + agentType: 'implementation', + triggerEvent: 'pm:status-changed', + enabled: true, + parameters: {}, + }, + ]; + + // Queue up 5 select results: source project + 4 parallel fetches + mockDb.chain.where + .mockResolvedValueOnce([sourceProject]) // source project fetch + .mockResolvedValueOnce(integrations) // integrations + .mockResolvedValueOnce(credentials) // credentials + .mockResolvedValueOnce(agentConfigRows) // agentConfigs + .mockResolvedValueOnce(triggerConfigRows); // triggerConfigs + + const result = await cloneProject('org-1', 'source-project', 'new-project', 'New Project'); + + expect(result).toEqual({ id: 'new-project', name: 'New Project' }); + + // Transaction should have been called + expect( + (mockDb.db as unknown as Record>).transaction, + ).toHaveBeenCalledTimes(1); + + // Should have 5 inserts: project + integrations + credentials + agentConfigs + triggerConfigs + expect(mockTxInsert).toHaveBeenCalledTimes(5); + + // Verify project insert (no repo field) + const projectInsertCall = mockTxInsertValues.mock.calls[0][0]; + expect(projectInsertCall.id).toBe('new-project'); + expect(projectInsertCall.name).toBe('New Project'); + expect(projectInsertCall.orgId).toBe('org-1'); + expect(projectInsertCall.repo).toBeNull(); + expect(projectInsertCall.baseBranch).toBe('main'); + + // Verify credentials are re-encrypted + const credInsertCall = mockTxInsertValues.mock.calls[2][0]; + expect(credInsertCall[0].projectId).toBe('new-project'); + expect(credInsertCall[0].envVarKey).toBe('GITHUB_TOKEN_IMPLEMENTER'); + expect(mockReEncryptCredential).toHaveBeenCalledWith( + 'enc:v1:abc', + 'source-project', + 'new-project', + ); + + // Verify integrations are cloned with new projectId + const integrationInsertCall = mockTxInsertValues.mock.calls[1][0]; + expect(integrationInsertCall[0].projectId).toBe('new-project'); + expect(integrationInsertCall[0].category).toBe('pm'); + + // Verify agentConfigs are cloned + const agentInsertCall = mockTxInsertValues.mock.calls[3][0]; + expect(agentInsertCall[0].projectId).toBe('new-project'); + expect(agentInsertCall[0].agentType).toBe('implementation'); + + // Verify triggerConfigs are cloned + const triggerInsertCall = mockTxInsertValues.mock.calls[4][0]; + expect(triggerInsertCall[0].projectId).toBe('new-project'); + expect(triggerInsertCall[0].triggerEvent).toBe('pm:status-changed'); + }); + + it('throws when source project not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); // empty result → not found + + await expect(cloneProject('org-1', 'missing-project', 'new-id', 'New')).rejects.toThrow( + 'Source project not found: missing-project', + ); + + // Transaction should not have been called + expect( + (mockDb.db as unknown as Record>).transaction, + ).not.toHaveBeenCalled(); + }); + + it('skips triggerConfigs insert when source has none', async () => { + mockDb.chain.where + .mockResolvedValueOnce([sourceProject]) + .mockResolvedValueOnce([]) // no integrations + .mockResolvedValueOnce([]) // no credentials + .mockResolvedValueOnce([]) // no agentConfigs + .mockResolvedValueOnce([]); // no triggerConfigs + + await cloneProject('org-1', 'source-project', 'new-project', 'New Project'); + + // Only 1 insert: the project itself (all others have 0 items) + expect(mockTxInsert).toHaveBeenCalledTimes(1); + const projectInsertCall = mockTxInsertValues.mock.calls[0][0]; + expect(projectInsertCall.id).toBe('new-project'); + }); +}); diff --git a/web/src/components/projects/clone-project-dialog.tsx b/web/src/components/projects/clone-project-dialog.tsx new file mode 100644 index 00000000..616e04b0 --- /dev/null +++ b/web/src/components/projects/clone-project-dialog.tsx @@ -0,0 +1,121 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); +} + +interface CloneProjectDialogProps { + sourceProjectId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CloneProjectDialog({ + sourceProjectId, + open, + onOpenChange, +}: CloneProjectDialogProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + + const newId = slugify(name); + + const cloneMutation = useMutation({ + mutationFn: (data: { sourceId: string; newId: string; newName: string }) => + trpcClient.projects.clone.mutate(data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: trpc.projects.listFull.queryOptions().queryKey }); + queryClient.invalidateQueries({ queryKey: trpc.projects.list.queryOptions().queryKey }); + toast.success('Project cloned!'); + onOpenChange(false); + resetForm(); + navigate({ to: '/projects/$projectId/general', params: { projectId: result.id } }); + }, + onError: (err) => { + toast.error('Failed to clone project', { description: err.message }); + }, + }); + + function resetForm() { + setName(''); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + cloneMutation.mutate({ sourceId: sourceProjectId, newId, newName: name }); + } + + return ( + { + onOpenChange(v); + if (!v) resetForm(); + }} + > + + + Clone Project + +
+
+ + setName(e.target.value)} + placeholder="My Project Copy" + required + /> +
+
+ + +

+ Auto-generated from the name. Used as a unique identifier. +

+
+

+ All settings, integrations, credentials, agent configs, and trigger configs will be + copied. The repository field will need to be configured separately after cloning. +

+
+ + +
+ {cloneMutation.isError && ( +

{cloneMutation.error.message}

+ )} +
+
+
+ ); +} diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx index 5ee43b5d..5db4d519 100644 --- a/web/src/components/projects/project-general-form.tsx +++ b/web/src/components/projects/project-general-form.tsx @@ -3,6 +3,7 @@ import { useNavigate } from '@tanstack/react-router'; import { HelpCircle } from 'lucide-react'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; +import { CloneProjectDialog } from '@/components/projects/clone-project-dialog.js'; import { ProjectSecretField } from '@/components/projects/project-secret-field.js'; import { useProjectUpdate } from '@/components/projects/use-project-update.js'; import { OpenRouterModelCombobox } from '@/components/settings/openrouter-model-combobox.js'; @@ -66,6 +67,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { const queryClient = useQueryClient(); const updateMutation = useProjectUpdate(project.id); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [cloneDialogOpen, setCloneDialogOpen] = useState(false); const deleteMutation = useMutation({ mutationFn: () => trpcClient.projects.delete.mutate({ id: project.id }), onSuccess: () => { @@ -417,6 +419,32 @@ export function ProjectGeneralForm({ project }: { project: Project }) { + {/* Clone Project */} + + + Clone Project + + +
+
+

Clone this project

+

+ Create a new project with the same settings, integrations, credentials, agent + configs, and trigger configs. The repository field will need to be configured + separately. +

+
+ +
+
+
+ {/* Danger Zone */} @@ -462,6 +490,12 @@ export function ProjectGeneralForm({ project }: { project: Project }) { + + );