Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '../../db/repositories/credentialsRepository.js';
import { listProjectsForOrg } from '../../db/repositories/runsRepository.js';
import {
cloneProject,
createProject,
deleteProject,
deleteProjectIntegration,
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions src/cli/dashboard/projects/clone.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
}
136 changes: 135 additions & 1 deletion src/db/repositories/projectsRepository.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 };
}
73 changes: 73 additions & 0 deletions tests/unit/api/routers/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
mockCreateProject,
mockUpdateProject,
mockDeleteProject,
mockCloneProject,
mockListProjectIntegrations,
mockUpsertProjectIntegration,
mockDeleteProjectIntegration,
Expand All @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
Loading
Loading