diff --git a/packages/db/src/repositories-migration.test.ts b/packages/db/src/repositories-migration.test.ts new file mode 100644 index 00000000000..a7c69c169c1 --- /dev/null +++ b/packages/db/src/repositories-migration.test.ts @@ -0,0 +1,114 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs"; +import { afterEach, describe, expect, it } from "vitest"; +import postgres from "postgres"; +import { applyPendingMigrations } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; + +const MIGRATION_FILE = "0213_lowly_chameleon.sql"; +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +async function migrationHash() { + const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8"); + return createHash("sha256").update(content).digest("hex"); +} + +describeEmbeddedPostgres("first-class repository migration", () => { + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + it("deduplicates legacy remotes per company, redacts credentials, and preserves local workspaces", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-repositories-migration-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1 }); + cleanups.push(async () => sql.end()); + + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; + await sql`ALTER TABLE "project_workspaces" DROP COLUMN "repository_id"`; + await sql`DROP TABLE "agent_repository_grants"`; + await sql`DROP TABLE "project_repositories"`; + await sql`DROP TABLE "repositories"`; + await sql`DROP TABLE "repository_connections"`; + + const companyA = randomUUID(); + const companyB = randomUUID(); + const projectA1 = randomUUID(); + const projectA2 = randomUUID(); + const projectB = randomUUID(); + const remoteWorkspaceA1 = randomUUID(); + const remoteWorkspaceA2 = randomUUID(); + const localWorkspaceA = randomUUID(); + const remoteWorkspaceB = randomUUID(); + + await sql` + INSERT INTO "companies" ("id", "name", "issue_prefix") + VALUES (${companyA}, 'A', 'RA'), (${companyB}, 'B', 'RB') + `; + await sql` + INSERT INTO "projects" ("id", "company_id", "name") + VALUES + (${projectA1}, ${companyA}, 'A One'), + (${projectA2}, ${companyA}, 'A Two'), + (${projectB}, ${companyB}, 'B One') + `; + await sql` + INSERT INTO "project_workspaces" ( + "id", "company_id", "project_id", "name", "source_type", "cwd", "repo_url", "is_primary" + ) VALUES + (${remoteWorkspaceA1}, ${companyA}, ${projectA1}, 'Remote One', 'git_repo', NULL, + 'https://oauth-user:top-secret@github.com/Acme/Shared.git?access_token=also-secret', true), + (${remoteWorkspaceA2}, ${companyA}, ${projectA2}, 'Remote Two', 'git_repo', NULL, + 'git@github.com:Acme/Shared.git', true), + (${localWorkspaceA}, ${companyA}, ${projectA1}, 'Local', 'local_path', '/workspace/local', NULL, false), + (${remoteWorkspaceB}, ${companyB}, ${projectB}, 'Remote Other Company', 'git_repo', NULL, + 'https://github.com/Acme/Shared.git', true) + `; + + await applyPendingMigrations(database.connectionString); + + const repositoryCounts = await sql<{ company_id: string; count: string }[]>` + SELECT "company_id", count(*)::text AS "count" + FROM "repositories" + GROUP BY "company_id" + ORDER BY "company_id" + `; + expect(new Map(repositoryCounts.map((row) => [row.company_id, row.count]))).toEqual( + new Map([[companyA, "1"], [companyB, "1"]]), + ); + + const companyALinks = await sql<{ project_id: string }[]>` + SELECT "project_id" FROM "project_repositories" + WHERE "company_id" = ${companyA} + ORDER BY "project_id" + `; + expect(companyALinks.map((row) => row.project_id).sort()).toEqual([projectA1, projectA2].sort()); + + const workspaces = await sql<{ id: string; repository_id: string | null; repo_url: string | null }[]>` + SELECT "id", "repository_id", "repo_url" + FROM "project_workspaces" + WHERE "company_id" = ${companyA} + ORDER BY "id" + `; + const byId = new Map(workspaces.map((row) => [row.id, row])); + expect(byId.get(remoteWorkspaceA1)?.repository_id).toBeTruthy(); + expect(byId.get(remoteWorkspaceA2)?.repository_id).toBe(byId.get(remoteWorkspaceA1)?.repository_id); + expect(byId.get(remoteWorkspaceA1)?.repo_url).toBe("https://github.com/Acme/Shared.git"); + expect(byId.get(remoteWorkspaceA1)?.repo_url).not.toContain("secret"); + expect(byId.get(remoteWorkspaceA2)?.repo_url).toBe("git@github.com:Acme/Shared.git"); + expect(byId.get(localWorkspaceA)?.repository_id).toBeNull(); + expect(byId.get(localWorkspaceA)?.repo_url).toBeNull(); + + const [metadata] = await sql<{ metadata: { credentialRedacted?: boolean } }[]>` + SELECT "provider_metadata" AS "metadata" + FROM "repositories" + WHERE "company_id" = ${companyA} + `; + expect(metadata?.metadata.credentialRedacted).toBe(true); + }, 30_000); +}); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7a4ab64948b..f87dd748d9a 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -27,6 +27,12 @@ export { projects } from "./projects.js"; export { projectMemberships } from "./project_memberships.js"; export { documentMemberships } from "./document_memberships.js"; export { projectWorkspaces } from "./project_workspaces.js"; +export { + repositories, + repositoryConnections, + projectRepositories, + agentRepositoryGrants, +} from "./repositories.js"; export { executionWorkspaces } from "./execution_workspaces.js"; export { environments } from "./environments.js"; export { environmentLeases } from "./environment_leases.js"; diff --git a/packages/db/src/schema/project_workspaces.ts b/packages/db/src/schema/project_workspaces.ts index 7f247ffe3d7..2d160c61779 100644 --- a/packages/db/src/schema/project_workspaces.ts +++ b/packages/db/src/schema/project_workspaces.ts @@ -10,6 +10,7 @@ import { } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { projects } from "./projects.js"; +import { repositories } from "./repositories.js"; export const projectWorkspaces = pgTable( "project_workspaces", @@ -17,6 +18,7 @@ export const projectWorkspaces = pgTable( id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), + repositoryId: uuid("repository_id").references(() => repositories.id, { onDelete: "set null" }), name: text("name").notNull(), sourceType: text("source_type").notNull().default("local_path"), cwd: text("cwd"), @@ -36,6 +38,7 @@ export const projectWorkspaces = pgTable( }, (table) => ({ companyProjectIdx: index("project_workspaces_company_project_idx").on(table.companyId, table.projectId), + companyRepositoryIdx: index("project_workspaces_company_repository_idx").on(table.companyId, table.repositoryId), projectPrimaryIdx: index("project_workspaces_project_primary_idx").on(table.projectId, table.isPrimary), projectSourceTypeIdx: index("project_workspaces_project_source_type_idx").on(table.projectId, table.sourceType), companySharedKeyIdx: index("project_workspaces_company_shared_key_idx").on(table.companyId, table.sharedWorkspaceKey), diff --git a/packages/db/src/schema/repositories.ts b/packages/db/src/schema/repositories.ts new file mode 100644 index 00000000000..c237a9d0f2f --- /dev/null +++ b/packages/db/src/schema/repositories.ts @@ -0,0 +1,133 @@ +import { sql } from "drizzle-orm"; +import { + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; +import { companies } from "./companies.js"; +import { projects } from "./projects.js"; + +export const repositoryConnections = pgTable( + "repository_connections", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + provider: text("provider").notNull(), + host: text("host").notNull(), + installationId: text("installation_id"), + accountId: text("account_id"), + accountName: text("account_name"), + status: text("status").notNull().default("active"), + syncStatus: text("sync_status").notNull().default("idle"), + syncCursor: text("sync_cursor"), + syncError: text("sync_error"), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }), + providerMetadata: jsonb("provider_metadata").$type>(), + disconnectedAt: timestamp("disconnected_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("repository_connections_company_idx").on(table.companyId), + providerHostIdx: index("repository_connections_company_provider_host_idx").on( + table.companyId, + table.provider, + table.host, + ), + installationIdx: uniqueIndex("repository_connections_company_installation_idx") + .on(table.companyId, table.provider, table.host, table.installationId) + .where(sql`${table.installationId} is not null`), + }), +); + +export const repositories = pgTable( + "repositories", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + connectionId: uuid("connection_id").references(() => repositoryConnections.id, { onDelete: "set null" }), + provider: text("provider").notNull().default("manual"), + providerRepositoryId: text("provider_repository_id"), + identityKey: text("identity_key").notNull(), + host: text("host").notNull(), + owner: text("owner").notNull(), + name: text("name").notNull(), + cloneUrl: text("clone_url").notNull(), + webUrl: text("web_url"), + defaultBranch: text("default_branch"), + visibility: text("visibility").notNull().default("unknown"), + state: text("state").notNull().default("active"), + unavailableReason: text("unavailable_reason"), + providerMetadata: jsonb("provider_metadata").$type>(), + archivedAt: timestamp("archived_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyStateIdx: index("repositories_company_state_idx").on(table.companyId, table.state), + companyConnectionIdx: index("repositories_company_connection_idx").on(table.companyId, table.connectionId), + identityIdx: uniqueIndex("repositories_company_identity_idx").on(table.companyId, table.identityKey), + providerRepositoryIdx: uniqueIndex("repositories_company_provider_repository_idx") + .on(table.companyId, table.provider, table.host, table.providerRepositoryId) + .where(sql`${table.providerRepositoryId} is not null`), + }), +); + +export const projectRepositories = pgTable( + "project_repositories", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), + repositoryId: uuid("repository_id").notNull().references(() => repositories.id, { onDelete: "cascade" }), + displayOrder: integer("display_order").notNull().default(0), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyProjectIdx: index("project_repositories_company_project_idx").on(table.companyId, table.projectId), + companyRepositoryIdx: index("project_repositories_company_repository_idx").on( + table.companyId, + table.repositoryId, + ), + relationshipIdx: uniqueIndex("project_repositories_company_relationship_idx").on( + table.companyId, + table.projectId, + table.repositoryId, + ), + }), +); + +export const agentRepositoryGrants = pgTable( + "agent_repository_grants", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }), + repositoryId: uuid("repository_id").notNull().references(() => repositories.id, { onDelete: "cascade" }), + grantedByAgentId: uuid("granted_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + grantedByUserId: text("granted_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyAgentIdx: index("agent_repository_grants_company_agent_idx").on(table.companyId, table.agentId), + companyRepositoryIdx: index("agent_repository_grants_company_repository_idx").on( + table.companyId, + table.repositoryId, + ), + relationshipIdx: uniqueIndex("agent_repository_grants_company_relationship_idx").on( + table.companyId, + table.agentId, + table.repositoryId, + ), + }), +); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bcce7f48db3..09799af06ca 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -520,6 +520,12 @@ export const PROJECT_STATUSES = [ ] as const; export type ProjectStatus = (typeof PROJECT_STATUSES)[number]; +export const REPOSITORY_PROVIDERS = ["manual", "github"] as const; +export const REPOSITORY_STATES = ["active", "unavailable", "archived"] as const; +export const REPOSITORY_VISIBILITIES = ["public", "private", "internal", "unknown"] as const; +export const REPOSITORY_CONNECTION_STATUSES = ["active", "error", "disconnected"] as const; +export const REPOSITORY_SYNC_STATUSES = ["idle", "syncing", "succeeded", "failed"] as const; + export const ENVIRONMENT_DRIVERS = ["local", "ssh", "sandbox", "plugin"] as const; export type EnvironmentDriver = (typeof ENVIRONMENT_DRIVERS)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 47f730567a4..d59d4747921 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -328,6 +328,11 @@ export { GOAL_LEVELS, GOAL_STATUSES, PROJECT_STATUSES, + REPOSITORY_PROVIDERS, + REPOSITORY_STATES, + REPOSITORY_VISIBILITIES, + REPOSITORY_CONNECTION_STATUSES, + REPOSITORY_SYNC_STATUSES, ENVIRONMENT_DRIVERS, ENVIRONMENT_STATUSES, ENVIRONMENT_LEASE_STATUSES, @@ -2372,6 +2377,23 @@ export { type EnvironmentCustomImageTerminalSessionToken, } from "./validators/environment-custom-images.js"; export * from "./validators/skill-policy.js"; +export * from "./validators/repository.js"; +export type { + RepositoryProvider, + RepositoryState, + RepositoryVisibility, + Repository, + RepositoryCatalogItem, + RepositoryConnectionStatus, + RepositoryConnectionSyncStatus, + RepositoryConnection, + ProjectRepositoryLink, + ProjectRepositoryEntry, + AgentRepositoryGrant, + AgentRepositoryGrantEntry, + EffectiveRepositoryProjectSource, + EffectiveRepositoryAccess, +} from "./types/repository.js"; export { FEATURE_TIERS, INSTANCE_FEATURE_CATALOG, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index d1f51148227..955717a9276 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -29,6 +29,7 @@ export interface ProjectWorkspace { id: string; companyId: string; projectId: string; + repositoryId: string | null; name: string; sourceType: ProjectWorkspaceSourceType; cwd: string | null; diff --git a/packages/shared/src/types/repository.ts b/packages/shared/src/types/repository.ts new file mode 100644 index 00000000000..e6ed3b76580 --- /dev/null +++ b/packages/shared/src/types/repository.ts @@ -0,0 +1,99 @@ +export type RepositoryProvider = "manual" | "github" | (string & {}); + +export type RepositoryState = "active" | "unavailable" | "archived"; + +export type RepositoryVisibility = "public" | "private" | "internal" | "unknown"; + +export interface Repository { + id: string; + companyId: string; + connectionId: string | null; + provider: RepositoryProvider; + providerRepositoryId: string | null; + host: string; + owner: string; + name: string; + cloneUrl: string; + webUrl: string | null; + defaultBranch: string | null; + visibility: RepositoryVisibility; + state: RepositoryState; + unavailableReason: string | null; + providerMetadata: Record | null; + archivedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface RepositoryCatalogItem extends Repository { + projectCount: number; + directGrantCount: number; +} + +export type RepositoryConnectionStatus = "active" | "error" | "disconnected"; +export type RepositoryConnectionSyncStatus = "idle" | "syncing" | "succeeded" | "failed"; + +export interface RepositoryConnection { + id: string; + companyId: string; + provider: RepositoryProvider; + host: string; + installationId: string | null; + accountId: string | null; + accountName: string | null; + status: RepositoryConnectionStatus; + syncStatus: RepositoryConnectionSyncStatus; + syncCursor: string | null; + syncError: string | null; + lastSyncedAt: Date | null; + providerMetadata: Record | null; + disconnectedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ProjectRepositoryLink { + id: string; + companyId: string; + projectId: string; + repositoryId: string; + displayOrder: number; + createdByAgentId: string | null; + createdByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface ProjectRepositoryEntry { + link: ProjectRepositoryLink; + repository: Repository; +} + +export interface AgentRepositoryGrant { + id: string; + companyId: string; + agentId: string; + repositoryId: string; + grantedByAgentId: string | null; + grantedByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface AgentRepositoryGrantEntry { + grant: AgentRepositoryGrant; + repository: Repository; +} + +export interface EffectiveRepositoryProjectSource { + id: string; + name: string; +} + +export interface EffectiveRepositoryAccess { + repository: Repository; + sources: { + direct: boolean; + projects: EffectiveRepositoryProjectSource[]; + }; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 5bc25bd431a..5c15c6017a6 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -921,3 +921,4 @@ export { } from "./tool-access.js"; export * from "./skill-policy.js"; export * from "./app-definition.js"; +export * from "./repository.js"; diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index d56eb3a3b9b..ea33afe022a 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -44,6 +44,7 @@ const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]); const projectWorkspaceFields = { name: z.string().min(1).optional(), + repositoryId: z.string().uuid().optional().nullable(), sourceType: projectWorkspaceSourceTypeSchema.optional(), cwd: z.string().min(1).optional().nullable(), repoUrl: z.string().url().optional().nullable(), @@ -63,23 +64,24 @@ function validateProjectWorkspace(value: Record, ctx: z.Refinem const sourceType = value.sourceType ?? "local_path"; const hasCwd = typeof value.cwd === "string" && value.cwd.trim().length > 0; const hasRepo = typeof value.repoUrl === "string" && value.repoUrl.trim().length > 0; + const hasRepository = typeof value.repositoryId === "string" && value.repositoryId.trim().length > 0; const hasRemoteRef = typeof value.remoteWorkspaceRef === "string" && value.remoteWorkspaceRef.trim().length > 0; if (sourceType === "remote_managed") { - if (!hasRemoteRef && !hasRepo) { + if (!hasRemoteRef && !hasRepo && !hasRepository) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Remote-managed workspace requires remoteWorkspaceRef or repoUrl.", + message: "Remote-managed workspace requires remoteWorkspaceRef, repositoryId, or repoUrl.", path: ["remoteWorkspaceRef"], }); } return; } - if (!hasCwd && !hasRepo) { + if (!hasCwd && !hasRepo && !hasRepository) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "Workspace requires at least one of cwd or repoUrl.", + message: "Workspace requires at least one of cwd, repositoryId, or repoUrl.", path: ["cwd"], }); } @@ -112,6 +114,7 @@ const projectFields = { icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(), env: envConfigSchema.optional().nullable(), executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(), + repositoryIds: z.array(z.string().uuid()).max(1_000).optional(), archivedAt: z.string().datetime().optional().nullable(), }; diff --git a/packages/shared/src/validators/repository.ts b/packages/shared/src/validators/repository.ts new file mode 100644 index 00000000000..3247a4ee474 --- /dev/null +++ b/packages/shared/src/validators/repository.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { + REPOSITORY_CONNECTION_STATUSES, + REPOSITORY_PROVIDERS, + REPOSITORY_STATES, + REPOSITORY_SYNC_STATUSES, + REPOSITORY_VISIBILITIES, +} from "../constants.js"; + +export const repositoryProviderSchema = z.union([ + z.enum(REPOSITORY_PROVIDERS), + z.string().min(1).max(64).regex(/^[a-z][a-z0-9_-]*$/), +]); +export const repositoryStateSchema = z.enum(REPOSITORY_STATES); +export const repositoryVisibilitySchema = z.enum(REPOSITORY_VISIBILITIES); +export const repositoryConnectionStatusSchema = z.enum(REPOSITORY_CONNECTION_STATUSES); +export const repositorySyncStatusSchema = z.enum(REPOSITORY_SYNC_STATUSES); + +export const repositoryLocatorSchema = z + .string() + .trim() + .min(1) + .max(2048) + .refine((value) => !/\s/.test(value), "Repository URL cannot contain whitespace"); + +export const createManualRepositorySchema = z.object({ + cloneUrl: repositoryLocatorSchema, + webUrl: z.string().url().max(2048).optional().nullable(), + defaultBranch: z.string().trim().min(1).max(255).optional().nullable(), + visibility: repositoryVisibilitySchema.optional().default("unknown"), +}).strict(); + +export const updateRepositorySchema = z.object({ + cloneUrl: repositoryLocatorSchema.optional(), + webUrl: z.string().url().max(2048).optional().nullable(), + defaultBranch: z.string().trim().min(1).max(255).optional().nullable(), + visibility: repositoryVisibilitySchema.optional(), +}).strict(); + +export const createRepositoryConnectionSchema = z.object({ + provider: repositoryProviderSchema, + host: z.string().trim().min(1).max(255), + installationId: z.string().trim().min(1).max(512).optional().nullable(), + accountId: z.string().trim().min(1).max(512).optional().nullable(), + accountName: z.string().trim().min(1).max(512).optional().nullable(), +}).strict(); + +export const attachProjectRepositorySchema = z.object({ + displayOrder: z.number().int().min(0).max(1_000_000).optional().default(0), +}).strict(); + +export const grantAgentRepositorySchema = z.object({}).strict(); + +export type CreateManualRepository = z.infer; +export type UpdateRepository = z.infer; +export type CreateRepositoryConnection = z.infer; +export type AttachProjectRepository = z.infer; +export type GrantAgentRepository = z.infer; diff --git a/server/src/__tests__/repositories.test.ts b/server/src/__tests__/repositories.test.ts new file mode 100644 index 00000000000..b469f998623 --- /dev/null +++ b/server/src/__tests__/repositories.test.ts @@ -0,0 +1,527 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentRepositoryGrants, + agents, + companies, + createDb, + projectRepositories, + projectWorkspaces, + projects, + repositories, + repositoryConnections, +} from "@paperclipai/db"; +import { + LOW_TRUST_REVIEW_PRESET, + createProjectSchema, + createProjectWorkspaceSchema, +} from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { repositoryRoutes } from "../routes/repositories.js"; +import { projectService } from "../services/projects.js"; +import { repositoryAccessService } from "../services/repository-access.js"; +import { + repositoryConnectionService, + repositoryProviderRegistry, + type RepositoryProviderAdapter, +} from "../services/repository-connections.js"; +import { + normalizeRepositoryLocator, + sanitizeRepositoryProviderError, + sanitizeRepositoryProviderMetadata, +} from "../services/repository-normalization.js"; +import { repositoryService } from "../services/repositories.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function boardActor(companyId: string): Express.Request["actor"] { + return { + type: "board", + userId: "repository-admin", + source: "session", + isInstanceAdmin: true, + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "admin", status: "active" }], + }; +} + +function createApp( + db: ReturnType, + companyId: string, + actor: Express.Request["actor"] = boardActor(companyId), +) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", repositoryRoutes(db)); + app.use(errorHandler); + return app; +} + +describe("repository normalization", () => { + it("normalizes common remotes and rejects credential-bearing URLs", () => { + expect(normalizeRepositoryLocator("git@GitHub.com:PaperclipAI/Paperclip.git")).toMatchObject({ + host: "github.com", + owner: "PaperclipAI", + name: "Paperclip", + webUrl: "https://github.com/PaperclipAI/Paperclip", + }); + expect(normalizeRepositoryLocator("ssh://git@GitHub.com/PaperclipAI/Paperclip.git")).toMatchObject({ + cloneUrl: "ssh://git@github.com/PaperclipAI/Paperclip.git", + host: "github.com", + }); + expect(() => normalizeRepositoryLocator("https://user:secret@github.com/acme/repo.git")) + .toThrow("must not contain embedded credentials"); + expect(() => normalizeRepositoryLocator("https://github.com/acme/repo.git?token=secret")) + .toThrow("must not contain query parameters"); + }); + + it("removes sensitive provider metadata recursively", () => { + expect(sanitizeRepositoryProviderMetadata({ + account: { login: "acme", accessToken: "secret" }, + private_key: "secret", + installationId: "123", + })).toEqual({ account: { login: "acme" }, installationId: "123" }); + expect(sanitizeRepositoryProviderError("Authorization: Bearer super-secret")) + .toBe("Authorization: [redacted]"); + }); + + it("accepts repository ids as project hints and repository-backed workspace inputs", () => { + const repositoryId = randomUUID(); + expect(createProjectSchema.safeParse({ name: "Cross-repo", repositoryIds: [repositoryId] }).success) + .toBe(true); + expect(createProjectWorkspaceSchema.safeParse({ repositoryId }).success).toBe(true); + }); +}); + +describeEmbeddedPostgres("repository services and routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const unregisterProviders: Array<() => void> = []; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-repositories-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + for (const unregister of unregisterProviders.splice(0).reverse()) unregister(); + await db.delete(activityLog); + await db.delete(agentRepositoryGrants); + await db.delete(projectRepositories); + await db.delete(projectWorkspaces); + await db.delete(repositories); + await db.delete(repositoryConnections); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seed() { + const companyA = await db.insert(companies).values({ + name: "Repository Company A", + issuePrefix: `RA${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning().then((rows) => rows[0]!); + const companyB = await db.insert(companies).values({ + name: "Repository Company B", + issuePrefix: `RB${randomUUID().slice(0, 5).toUpperCase()}`, + }).returning().then((rows) => rows[0]!); + const [projectOne, projectTwo, foreignProject] = await db.insert(projects).values([ + { companyId: companyA.id, name: "One" }, + { companyId: companyA.id, name: "Two" }, + { companyId: companyB.id, name: "Foreign" }, + ]).returning(); + const [agent, foreignAgent] = await db.insert(agents).values([ + { companyId: companyA.id, name: "Agent A", role: "engineer" }, + { companyId: companyB.id, name: "Agent B", role: "engineer" }, + ]).returning(); + const companyARepositories = repositoryService(db); + const repository = (await companyARepositories.createManual(companyA.id, { + cloneUrl: "https://github.com/acme/shared.git", + visibility: "private", + })).repository; + const foreignRepository = (await companyARepositories.createManual(companyB.id, { + cloneUrl: "https://github.com/acme/shared.git", + visibility: "private", + })).repository; + return { + companyA, + companyB, + projectOne: projectOne!, + projectTwo: projectTwo!, + foreignProject: foreignProject!, + agent: agent!, + foreignAgent: foreignAgent!, + repository, + foreignRepository, + }; + } + + it("unions direct and authoritative project sources and removes only the requested source", async () => { + const seeded = await seed(); + const access = repositoryAccessService(db); + expect(await access.listEffectiveRepositories({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + canAccessProject: async () => true, + })).toEqual([]); + + const firstAttach = await access.attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.repository.id, + displayOrder: 0, + }); + const duplicateAttach = await access.attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.repository.id, + displayOrder: 0, + }); + await access.attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectTwo.id, + repositoryId: seeded.repository.id, + displayOrder: 1, + }); + const firstGrant = await access.grantAgentRepository({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.repository.id, + }); + const duplicateGrant = await access.grantAgentRepository({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.repository.id, + }); + expect(firstAttach?.created).toBe(true); + expect(duplicateAttach?.created).toBe(false); + expect(firstGrant?.created).toBe(true); + expect(duplicateGrant?.created).toBe(false); + expect(await db.select().from(projectWorkspaces)).toEqual([]); + + const authorizedProjectIds: string[] = []; + const onlyProjectOne = await access.listEffectiveRepositories({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + canAccessProject: async (project) => { + authorizedProjectIds.push(project.id); + return project.id === seeded.projectOne.id; + }, + }); + expect(authorizedProjectIds.sort()).toEqual([seeded.projectOne.id, seeded.projectTwo.id].sort()); + expect(onlyProjectOne).toHaveLength(1); + expect(onlyProjectOne?.[0]).toMatchObject({ + repository: { id: seeded.repository.id }, + sources: { direct: true, projects: [{ id: seeded.projectOne.id, name: "One" }] }, + }); + + await access.detachProjectRepository(seeded.companyA.id, seeded.projectOne.id, seeded.repository.id); + await access.revokeAgentRepository(seeded.companyA.id, seeded.agent.id, seeded.repository.id); + const lastProjectSource = await access.listEffectiveRepositories({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + canAccessProject: async () => true, + }); + expect(lastProjectSource?.[0]).toMatchObject({ + sources: { direct: false, projects: [{ id: seeded.projectTwo.id, name: "Two" }] }, + }); + + await access.detachProjectRepository(seeded.companyA.id, seeded.projectTwo.id, seeded.repository.id); + expect(await access.listEffectiveRepositories({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + canAccessProject: async () => true, + })).toEqual([]); + }); + + it("uses the authoritative project resolver for effective-access routes", async () => { + const seeded = await seed(); + await repositoryAccessService(db).attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.repository.id, + displayOrder: 0, + }); + const otherRepository = (await repositoryService(db).createManual(seeded.companyA.id, { + cloneUrl: "https://github.com/acme/denied.git", + visibility: "private", + })).repository; + await repositoryAccessService(db).attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectTwo.id, + repositoryId: otherRepository.id, + displayOrder: 0, + }); + await db.update(agents).set({ + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + projectIds: [seeded.projectOne.id], + }, + }, + }, + }).where(eq(agents.id, seeded.agent.id)); + + const app = createApp(db, seeded.companyA.id, { + type: "agent", + agentId: seeded.agent.id, + companyId: seeded.companyA.id, + source: "agent_key", + }); + const response = await request(app) + .get(`/api/agents/${seeded.agent.id}/repositories?effective=true`); + expect(response.status).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toMatchObject({ + repository: { id: seeded.repository.id }, + sources: { projects: [{ id: seeded.projectOne.id }] }, + }); + }); + + it("updates project repository hints atomically without rewriting retained attribution", async () => { + const seeded = await seed(); + const service = projectService(db); + const secondRepository = (await repositoryService(db).createManual(seeded.companyA.id, { + cloneUrl: "https://github.com/acme/second.git", + visibility: "private", + })).repository; + const project = await service.create(seeded.companyA.id, { + name: "Transactional hints", + repositoryIds: [seeded.repository.id, secondRepository.id, seeded.repository.id], + repositoryAttribution: { createdByUserId: "creator" }, + }); + const initialLinks = await db + .select() + .from(projectRepositories) + .where(eq(projectRepositories.projectId, project.id)); + expect(initialLinks).toHaveLength(2); + const firstLink = initialLinks.find((link) => link.repositoryId === seeded.repository.id)!; + + await service.update(project.id, { + repositoryIds: [secondRepository.id, seeded.repository.id], + repositoryAttribution: { createdByUserId: "updater" }, + }); + const reorderedLinks = await db + .select() + .from(projectRepositories) + .where(eq(projectRepositories.projectId, project.id)); + expect(reorderedLinks.find((link) => link.repositoryId === seeded.repository.id)).toMatchObject({ + id: firstLink.id, + displayOrder: 1, + createdByUserId: "creator", + }); + + await expect(service.update(project.id, { + name: "Must roll back", + repositoryIds: [seeded.foreignRepository.id], + })).rejects.toMatchObject({ status: 422 }); + expect(await service.getById(project.id)).toMatchObject({ name: "Transactional hints" }); + expect(await repositoryAccessService(db).listProjectRepositories(seeded.companyA.id, project.id)) + .toHaveLength(2); + }); + + it("fails closed for cross-company UUIDs and archived repositories", async () => { + const seeded = await seed(); + const access = repositoryAccessService(db); + expect(await access.attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.foreignRepository.id, + displayOrder: 0, + })).toBeNull(); + expect(await access.grantAgentRepository({ + companyId: seeded.companyA.id, + agentId: seeded.agent.id, + repositoryId: seeded.foreignRepository.id, + })).toBeNull(); + + await repositoryService(db).archive(seeded.companyA.id, seeded.repository.id); + await expect(access.attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.repository.id, + displayOrder: 0, + })).rejects.toMatchObject({ status: 409 }); + + const app = createApp(db, seeded.companyA.id); + const response = await request(app) + .put(`/api/projects/${seeded.projectOne.id}/repositories/${seeded.foreignRepository.id}`) + .send({ displayOrder: 0 }); + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: "Repository not found" }); + + const [connection] = await db.insert(repositoryConnections).values({ + companyId: seeded.companyA.id, + provider: "test_provider", + host: "git.example.com", + }).returning(); + const providerRepository = await repositoryService(db).upsertProviderRepository( + seeded.companyA.id, + connection!.id, + "test_provider", + { + providerRepositoryId: "provider-repo-archived", + cloneUrl: "https://git.example.com/acme/archived.git", + }, + ); + await repositoryService(db).archive(seeded.companyA.id, providerRepository.id); + await repositoryService(db).upsertProviderRepository( + seeded.companyA.id, + connection!.id, + "test_provider", + { + providerRepositoryId: "provider-repo-archived", + cloneUrl: "https://git.example.com/acme/archived-renamed.git", + }, + ); + expect(await repositoryService(db).getById(providerRepository.id)).toMatchObject({ + state: "archived", + name: "archived-renamed", + }); + }); + + it("keeps repository-backed workspace compatibility without changing the primary checkout", async () => { + const seeded = await seed(); + const projectsService = projectService(db); + const repositoryWorkspace = await projectsService.createWorkspace(seeded.projectOne.id, { + name: "Repository checkout", + sourceType: "git_repo", + repositoryId: seeded.repository.id, + }); + expect(repositoryWorkspace).toMatchObject({ + repositoryId: seeded.repository.id, + repoUrl: "https://github.com/acme/shared.git", + isPrimary: true, + }); + const localWorkspace = await projectsService.createWorkspace(seeded.projectOne.id, { + name: "Local primary", + sourceType: "local_path", + cwd: "/workspace/local-primary", + isPrimary: true, + }); + expect(localWorkspace?.isPrimary).toBe(true); + + await repositoryAccessService(db).attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: seeded.repository.id, + displayOrder: 0, + }); + const afterHint = await projectsService.getById(seeded.projectOne.id); + expect(afterHint?.primaryWorkspace?.id).toBe(localWorkspace?.id); + expect(afterHint?.workspaces).toHaveLength(2); + + await repositoryService(db).update(seeded.companyA.id, seeded.repository.id, { + cloneUrl: "https://github.com/acme/renamed.git", + }); + const refreshed = await projectsService.listWorkspaces(seeded.projectOne.id); + expect(refreshed.find((workspace) => workspace.id === repositoryWorkspace?.id)).toMatchObject({ + repositoryId: seeded.repository.id, + repoUrl: "https://github.com/acme/renamed.git", + }); + expect(refreshed.find((workspace) => workspace.id === localWorkspace?.id)?.isPrimary).toBe(true); + + await repositoryService(db).archive(seeded.companyA.id, seeded.repository.id); + expect(await projectsService.updateWorkspace(seeded.projectOne.id, repositoryWorkspace!.id, { + name: "Archived repository checkout", + })).toMatchObject({ + repositoryId: seeded.repository.id, + name: "Archived repository checkout", + }); + }); + + it("keeps repositories and relationships stable across sync/disconnect failures", async () => { + const seeded = await seed(); + const provider: RepositoryProviderAdapter = { + provider: "test_provider", + sync: async () => ({ + cursor: "cursor-1", + repositories: [{ + providerRepositoryId: "provider-repo-1", + cloneUrl: "https://git.example.com/acme/provider-repo.git", + visibility: "private", + }], + }), + disconnect: async () => {}, + }; + unregisterProviders.push(repositoryProviderRegistry.register(provider)); + const connections = repositoryConnectionService(db); + const created = await connections.create(seeded.companyA.id, { + provider: "test_provider", + host: "git.example.com", + installationId: "installation-1", + accountId: "account-1", + accountName: "Acme", + }); + const duplicate = await connections.create(seeded.companyA.id, { + provider: "test_provider", + host: "git.example.com", + installationId: "installation-1", + accountId: "account-1", + accountName: "Acme", + }); + expect(created.created).toBe(true); + expect(duplicate.created).toBe(false); + + const synced = await connections.sync(seeded.companyA.id, created.connection.id); + expect(synced?.repositories).toHaveLength(1); + const providerRepository = synced!.repositories[0]!; + await repositoryAccessService(db).attachProjectRepository({ + companyId: seeded.companyA.id, + projectId: seeded.projectOne.id, + repositoryId: providerRepository.id, + displayOrder: 0, + }); + expect((await connections.sync(seeded.companyA.id, created.connection.id))?.repositories[0]?.id) + .toBe(providerRepository.id); + + const failingProvider: RepositoryProviderAdapter = { + provider: "test_provider", + sync: async () => { + throw new Error("sync failed at https://secret-token@git.example.com/?token=secret"); + }, + disconnect: async () => { + throw new Error("disconnect failed token=secret"); + }, + }; + unregisterProviders.push(repositoryProviderRegistry.register(failingProvider)); + await expect(connections.sync(seeded.companyA.id, created.connection.id)).rejects.toMatchObject({ status: 422 }); + const failedSync = await connections.getById(created.connection.id); + expect(failedSync).toMatchObject({ status: "error", syncStatus: "failed" }); + expect(failedSync?.syncError).not.toContain("secret-token"); + expect(failedSync?.syncError).not.toContain("token=secret"); + expect((await repositoryService(db).getById(providerRepository.id))?.state).toBe("active"); + + await expect(connections.disconnect(seeded.companyA.id, created.connection.id)).rejects.toMatchObject({ status: 422 }); + expect((await repositoryService(db).getById(providerRepository.id))?.state).toBe("active"); + expect(await repositoryAccessService(db).listProjectRepositories(seeded.companyA.id, seeded.projectOne.id)) + .toHaveLength(1); + + unregisterProviders.push(repositoryProviderRegistry.register(provider)); + const disconnected = await connections.disconnect(seeded.companyA.id, created.connection.id); + expect(disconnected?.connection.status).toBe("disconnected"); + expect((await repositoryService(db).getById(providerRepository.id))?.state).toBe("unavailable"); + expect(await repositoryAccessService(db).listProjectRepositories(seeded.companyA.id, seeded.projectOne.id)) + .toHaveLength(1); + await expect(connections.sync(seeded.companyA.id, created.connection.id)).rejects.toMatchObject({ status: 409 }); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 51f49a1ca32..723ba7cd8cd 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -30,6 +30,7 @@ import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; +import { repositoryRoutes } from "./routes/repositories.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; import { caseRoutes } from "./routes/cases.js"; @@ -387,6 +388,7 @@ export async function createApp( api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); + api.use(repositoryRoutes(db)); api.use(caseRoutes(db, opts.storageService)); api.use(issueTreeControlRoutes(db)); api.use(fileResourceRoutes(db)); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 14924a9a6c9..70ec206d11a 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -10,6 +10,7 @@ export { statusCardRoutes } from "./status-cards.js"; export { teamsCatalogRoutes } from "./teams-catalog.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; +export { repositoryRoutes } from "./repositories.js"; export { issueRoutes } from "./issues.js"; export { issueTreeControlRoutes } from "./issue-tree-control.js"; export { diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 50b4be27853..bd58e76460b 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -158,6 +158,11 @@ export function projectRoutes(db: Db) { }; const { workspace, ...projectData } = req.body as CreateProjectPayload; + const actor = getActorInfo(req); + projectData.repositoryAttribution = { + createdByAgentId: actor.actorType === "agent" ? actor.agentId : null, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + }; await assertProjectEnvironmentSelection( companyId, readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy), @@ -196,7 +201,6 @@ export function projectRoutes(db: Db) { } const hydratedProject = workspace ? await svc.getById(project.id) : project; - const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, @@ -208,6 +212,7 @@ export function projectRoutes(db: Db) { details: { name: project.name, workspaceId: createdWorkspaceId, + repositoryCount: projectData.repositoryIds?.length ?? 0, envKeys: project.env ? Object.keys(project.env).sort() : [], }, }); @@ -240,6 +245,11 @@ export function projectRoutes(db: Db) { fieldPath: "env", }); } + const actor = getActorInfo(req); + body.repositoryAttribution = { + createdByAgentId: actor.actorType === "agent" ? actor.agentId : null, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + }; const project = await svc.update(id, body); if (!project) { res.status(404).json({ error: "Project not found" }); @@ -253,7 +263,6 @@ export function projectRoutes(db: Db) { ); } - const actor = getActorInfo(req); await logActivity(db, { companyId: project.companyId, actorType: actor.actorType, @@ -307,6 +316,7 @@ export function projectRoutes(db: Db) { entityId: id, details: { workspaceId: workspace.id, + repositoryId: workspace.repositoryId, name: workspace.name, cwd: workspace.cwd, isPrimary: workspace.isPrimary, diff --git a/server/src/routes/repositories.ts b/server/src/routes/repositories.ts new file mode 100644 index 00000000000..bf898ddfd23 --- /dev/null +++ b/server/src/routes/repositories.ts @@ -0,0 +1,443 @@ +import { eq } from "drizzle-orm"; +import { Router, type Request } from "express"; +import type { Db } from "@paperclipai/db"; +import { agents, projects } from "@paperclipai/db"; +import { + attachProjectRepositorySchema, + createManualRepositorySchema, + createRepositoryConnectionSchema, + grantAgentRepositorySchema, + updateRepositorySchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { + accessService, + logActivity, + repositoryAccessService, + repositoryConnectionService, + repositoryService, +} from "../services/index.js"; +import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js"; + +function activityActor(req: Request) { + const actor = getActorInfo(req); + return { + actor, + attribution: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + }, + }; +} + +export function repositoryRoutes(db: Db) { + const router = Router(); + const repositories = repositoryService(db); + const connections = repositoryConnectionService(db); + const repositoryAccess = repositoryAccessService(db); + const access = accessService(db); + + router.get("/companies/:companyId/repositories", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await repositories.list(companyId, { includeArchived: req.query.includeArchived === "true" })); + }); + + router.post( + "/companies/:companyId/repositories", + validate(createManualRepositorySchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await repositories.createManual(companyId, req.body); + const { attribution } = activityActor(req); + if (result.created) { + await logActivity(db, { + companyId, + ...attribution, + action: "repository.created", + entityType: "repository", + entityId: result.repository.id, + details: { + provider: result.repository.provider, + host: result.repository.host, + owner: result.repository.owner, + name: result.repository.name, + }, + }); + } + res.status(result.created ? 201 : 200).json(result.repository); + }, + ); + + router.get("/repositories/:repositoryId", async (req, res) => { + assertBoard(req); + const repository = await getAccessibleResource( + req, + res, + repositories.getById(req.params.repositoryId as string), + "Repository not found", + ); + if (!repository) return; + res.json(repository); + }); + + router.patch("/repositories/:repositoryId", validate(updateRepositorySchema), async (req, res) => { + assertBoard(req); + const repositoryId = req.params.repositoryId as string; + const existing = await getAccessibleResource(req, res, repositories.getById(repositoryId), "Repository not found"); + if (!existing) return; + const updated = await repositories.update(existing.companyId, repositoryId, req.body); + if (!updated) { + res.status(404).json({ error: "Repository not found" }); + return; + } + const { attribution } = activityActor(req); + await logActivity(db, { + companyId: existing.companyId, + ...attribution, + action: "repository.updated", + entityType: "repository", + entityId: repositoryId, + details: { changedKeys: Object.keys(req.body).sort() }, + }); + res.json(updated); + }); + + router.delete("/repositories/:repositoryId", async (req, res) => { + assertBoard(req); + const repositoryId = req.params.repositoryId as string; + const existing = await getAccessibleResource(req, res, repositories.getById(repositoryId), "Repository not found"); + if (!existing) return; + const archived = await repositories.archive(existing.companyId, repositoryId); + const { attribution } = activityActor(req); + await logActivity(db, { + companyId: existing.companyId, + ...attribution, + action: "repository.archived", + entityType: "repository", + entityId: repositoryId, + details: { retainedRelationships: true }, + }); + res.json(archived); + }); + + router.get("/companies/:companyId/repository-connections", async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await connections.list(companyId)); + }); + + router.post( + "/companies/:companyId/repository-connections", + validate(createRepositoryConnectionSchema), + async (req, res) => { + assertBoard(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const result = await connections.create(companyId, req.body); + const { attribution } = activityActor(req); + if (result.created) { + await logActivity(db, { + companyId, + ...attribution, + action: "repository_connection.created", + entityType: "repository_connection", + entityId: result.connection.id, + details: { provider: result.connection.provider, host: result.connection.host }, + }); + } + res.status(result.created ? 201 : 200).json(result.connection); + }, + ); + + router.get("/repository-connections/:connectionId", async (req, res) => { + assertBoard(req); + const connection = await getAccessibleResource( + req, + res, + connections.getById(req.params.connectionId as string), + "Repository connection not found", + ); + if (!connection) return; + res.json(connection); + }); + + router.post("/repository-connections/:connectionId/sync", async (req, res) => { + assertBoard(req); + const connectionId = req.params.connectionId as string; + const existing = await getAccessibleResource( + req, + res, + connections.getById(connectionId), + "Repository connection not found", + ); + if (!existing) return; + const { attribution } = activityActor(req); + try { + const result = await connections.sync(existing.companyId, connectionId); + await logActivity(db, { + companyId: existing.companyId, + ...attribution, + action: "repository_connection.synced", + entityType: "repository_connection", + entityId: connectionId, + details: { repositoryCount: result?.repositories.length ?? 0 }, + }); + res.json(result); + } catch (error) { + await logActivity(db, { + companyId: existing.companyId, + ...attribution, + action: "repository_connection.sync_failed", + entityType: "repository_connection", + entityId: connectionId, + details: { provider: existing.provider, status: "failed" }, + }); + throw error; + } + }); + + router.delete("/repository-connections/:connectionId", async (req, res) => { + assertBoard(req); + const connectionId = req.params.connectionId as string; + const existing = await getAccessibleResource( + req, + res, + connections.getById(connectionId), + "Repository connection not found", + ); + if (!existing) return; + const { attribution } = activityActor(req); + try { + const result = await connections.disconnect(existing.companyId, connectionId); + await logActivity(db, { + companyId: existing.companyId, + ...attribution, + action: "repository_connection.disconnected", + entityType: "repository_connection", + entityId: connectionId, + details: { unavailableRepositoryCount: result?.repositories.length ?? 0 }, + }); + res.json(result); + } catch (error) { + await logActivity(db, { + companyId: existing.companyId, + ...attribution, + action: "repository_connection.disconnect_failed", + entityType: "repository_connection", + entityId: connectionId, + details: { provider: existing.provider, status: "failed" }, + }); + throw error; + } + }); + + router.get("/projects/:projectId/repositories", async (req, res) => { + const projectId = req.params.projectId as string; + const project = await getAccessibleResource( + req, + res, + db.select({ id: projects.id, companyId: projects.companyId }).from(projects).where(eq(projects.id, projectId)).then((rows) => rows[0] ?? null), + "Project not found", + ); + if (!project) return; + const decision = await access.decide({ + actor: req.actor, + action: "project:read", + resource: { type: "project", companyId: project.companyId, projectId }, + }); + if (!decision.allowed) { + res.status(403).json({ error: "Project is outside this actor's authorization boundary" }); + return; + } + res.json(await repositoryAccess.listProjectRepositories(project.companyId, projectId)); + }); + + router.put( + "/projects/:projectId/repositories/:repositoryId", + validate(attachProjectRepositorySchema), + async (req, res) => { + assertBoard(req); + const projectId = req.params.projectId as string; + const repositoryId = req.params.repositoryId as string; + const project = await getAccessibleResource( + req, + res, + db.select({ id: projects.id, companyId: projects.companyId }).from(projects).where(eq(projects.id, projectId)).then((rows) => rows[0] ?? null), + "Project not found", + ); + if (!project) return; + const { actor, attribution } = activityActor(req); + const result = await repositoryAccess.attachProjectRepository({ + companyId: project.companyId, + projectId, + repositoryId, + displayOrder: req.body.displayOrder, + createdByAgentId: actor.actorType === "agent" ? actor.agentId : null, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + if (!result) { + res.status(404).json({ error: "Repository not found" }); + return; + } + if (result.created) { + await logActivity(db, { + companyId: project.companyId, + ...attribution, + action: "project_repository.attached", + entityType: "project_repository", + entityId: result.entry.link.id, + details: { projectId, repositoryId }, + }); + } + res.status(result.created ? 201 : 200).json(result.entry); + }, + ); + + router.delete("/projects/:projectId/repositories/:repositoryId", async (req, res) => { + assertBoard(req); + const projectId = req.params.projectId as string; + const repositoryId = req.params.repositoryId as string; + const project = await getAccessibleResource( + req, + res, + db.select({ id: projects.id, companyId: projects.companyId }).from(projects).where(eq(projects.id, projectId)).then((rows) => rows[0] ?? null), + "Project not found", + ); + if (!project) return; + const result = await repositoryAccess.detachProjectRepository(project.companyId, projectId, repositoryId); + if (!result) { + res.status(404).json({ error: "Repository not found" }); + return; + } + if (result.removed) { + const { attribution } = activityActor(req); + await logActivity(db, { + companyId: project.companyId, + ...attribution, + action: "project_repository.detached", + entityType: "project_repository", + entityId: result.link!.id, + details: { projectId, repositoryId }, + }); + } + res.json({ removed: result.removed }); + }); + + router.get("/agents/:agentId/repositories", async (req, res) => { + const agentId = req.params.agentId as string; + const agent = await getAccessibleResource( + req, + res, + db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, agentId)).then((rows) => rows[0] ?? null), + "Agent not found", + ); + if (!agent) return; + const decision = await access.decide({ + actor: req.actor, + action: "agent:read", + resource: { type: "agent", companyId: agent.companyId, agentId }, + }); + if (!decision.allowed) { + res.status(403).json({ error: "Agent is outside this actor's authorization boundary" }); + return; + } + if (req.query.effective !== "true") { + res.json(await repositoryAccess.listDirectGrants(agent.companyId, agentId)); + return; + } + + const projectAuthorizationActor = req.actor.type === "agent" && req.actor.agentId === agentId + ? req.actor + : { type: "agent" as const, agentId, companyId: agent.companyId, source: "agent_key" as const }; + const result = await repositoryAccess.listEffectiveRepositories({ + companyId: agent.companyId, + agentId, + canAccessProject: async (project) => (await access.decide({ + actor: projectAuthorizationActor, + action: "project:read", + resource: { type: "project", companyId: project.companyId, projectId: project.id }, + })).allowed, + }); + res.json(result); + }); + + router.put( + "/agents/:agentId/repositories/:repositoryId", + validate(grantAgentRepositorySchema), + async (req, res) => { + assertBoard(req); + const agentId = req.params.agentId as string; + const repositoryId = req.params.repositoryId as string; + const agent = await getAccessibleResource( + req, + res, + db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, agentId)).then((rows) => rows[0] ?? null), + "Agent not found", + ); + if (!agent) return; + const { actor, attribution } = activityActor(req); + const result = await repositoryAccess.grantAgentRepository({ + companyId: agent.companyId, + agentId, + repositoryId, + grantedByAgentId: actor.actorType === "agent" ? actor.agentId : null, + grantedByUserId: actor.actorType === "user" ? actor.actorId : null, + }); + if (!result) { + res.status(404).json({ error: "Repository not found" }); + return; + } + if (result.created) { + await logActivity(db, { + companyId: agent.companyId, + ...attribution, + action: "agent_repository.granted", + entityType: "agent_repository_grant", + entityId: result.entry.grant.id, + details: { agentId, repositoryId }, + }); + } + res.status(result.created ? 201 : 200).json(result.entry); + }, + ); + + router.delete("/agents/:agentId/repositories/:repositoryId", async (req, res) => { + assertBoard(req); + const agentId = req.params.agentId as string; + const repositoryId = req.params.repositoryId as string; + const agent = await getAccessibleResource( + req, + res, + db.select({ id: agents.id, companyId: agents.companyId }).from(agents).where(eq(agents.id, agentId)).then((rows) => rows[0] ?? null), + "Agent not found", + ); + if (!agent) return; + const result = await repositoryAccess.revokeAgentRepository(agent.companyId, agentId, repositoryId); + if (!result) { + res.status(404).json({ error: "Repository not found" }); + return; + } + if (result.removed) { + const { attribution } = activityActor(req); + await logActivity(db, { + companyId: agent.companyId, + ...attribution, + action: "agent_repository.revoked", + entityType: "agent_repository_grant", + entityId: result.grant!.id, + details: { agentId, repositoryId }, + }); + } + res.json({ removed: result.removed }); + }); + + return router; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2ab2994c246..0d2394b1ef0 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -33,6 +33,14 @@ export { refreshIssueContinuationSummary, } from "./issue-continuation-summary.js"; export { projectService } from "./projects.js"; +export { repositoryService, toRepository, type ProviderRepositorySnapshot } from "./repositories.js"; +export { + repositoryConnectionService, + repositoryProviderRegistry, + toRepositoryConnection, + type RepositoryProviderAdapter, +} from "./repository-connections.js"; +export { repositoryAccessService } from "./repository-access.js"; export { clampIssueListLimit, ISSUE_LIST_DEFAULT_LIMIT, diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 5ac1b50e4c1..87b076b9c38 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { projects, @@ -8,7 +8,9 @@ import { budgetPolicies, pluginManagedResources, plugins, + projectRepositories, projectWorkspaces, + repositories, workspaceRuntimeServices, } from "@paperclipai/db"; import { @@ -29,6 +31,7 @@ import { type PluginManagedProjectResolution, } from "@paperclipai/shared"; import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js"; +import { unprocessable } from "../errors.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; @@ -39,6 +42,7 @@ type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; type CreateWorkspaceInput = { name?: string | null; + repositoryId?: string | null; sourceType?: string | null; cwd?: string | null; repoUrl?: string | null; @@ -157,6 +161,7 @@ function toWorkspace( id: row.id, companyId: row.companyId, projectId: row.projectId, + repositoryId: row.repositoryId ?? null, name: row.name, sourceType: row.sourceType as ProjectWorkspace["sourceType"], cwd: normalizeWorkspaceCwd(row.cwd), @@ -541,11 +546,84 @@ async function ensureSinglePrimaryWorkspace( } export function projectService(db: Db) { + type RepositoryAttribution = { + createdByAgentId?: string | null; + createdByUserId?: string | null; + }; + + async function syncRepositoryLinks( + dbOrTx: Db, + input: { + companyId: string; + projectId: string; + repositoryIds: string[]; + attribution?: RepositoryAttribution; + }, + ) { + const repositoryIds = [...new Set(input.repositoryIds)]; + const activeRepositories = repositoryIds.length === 0 + ? [] + : await dbOrTx + .select({ id: repositories.id }) + .from(repositories) + .where(and( + eq(repositories.companyId, input.companyId), + eq(repositories.state, "active"), + inArray(repositories.id, repositoryIds), + )); + if (activeRepositories.length !== repositoryIds.length) { + throw unprocessable("One or more repositories are unavailable for this company"); + } + + const relationshipScope = and( + eq(projectRepositories.companyId, input.companyId), + eq(projectRepositories.projectId, input.projectId), + ); + if (repositoryIds.length === 0) { + await dbOrTx.delete(projectRepositories).where(relationshipScope); + return; + } + + await dbOrTx + .delete(projectRepositories) + .where(and(relationshipScope, notInArray(projectRepositories.repositoryId, repositoryIds))); + const now = new Date(); + for (const [displayOrder, repositoryId] of repositoryIds.entries()) { + await dbOrTx + .insert(projectRepositories) + .values({ + companyId: input.companyId, + projectId: input.projectId, + repositoryId, + displayOrder, + createdByAgentId: input.attribution?.createdByAgentId ?? null, + createdByUserId: input.attribution?.createdByUserId ?? null, + }) + .onConflictDoUpdate({ + target: [ + projectRepositories.companyId, + projectRepositories.projectId, + projectRepositories.repositoryId, + ], + set: { displayOrder, updatedAt: now }, + }); + } + } + const createProject = async ( companyId: string, - data: Omit & { goalIds?: string[] }, + data: Omit & { + goalIds?: string[]; + repositoryIds?: string[]; + repositoryAttribution?: RepositoryAttribution; + }, ): Promise => { - const { goalIds: inputGoalIds, ...projectData } = data; + const { + goalIds: inputGoalIds, + repositoryIds, + repositoryAttribution, + ...projectData + } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); // Note: color is intentionally NOT auto-assigned. New projects default to @@ -560,15 +638,26 @@ export function projectService(db: Db) { // Also write goalId to the legacy column (first goal or null) const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; - const row = await db - .insert(projects) - .values({ ...projectData, goalId: legacyGoalId, companyId }) - .returning() - .then((rows) => rows[0]); + const row = await db.transaction(async (tx) => { + const inserted = await tx + .insert(projects) + .values({ ...projectData, goalId: legacyGoalId, companyId }) + .returning() + .then((rows) => rows[0]!); - if (ids && ids.length > 0) { - await syncGoalLinks(db, row.id, companyId, ids); - } + if (ids && ids.length > 0) { + await syncGoalLinks(tx as unknown as Db, inserted.id, companyId, ids); + } + if (repositoryIds !== undefined) { + await syncRepositoryLinks(tx as unknown as Db, { + companyId, + projectId: inserted.id, + repositoryIds, + attribution: repositoryAttribution, + }); + } + return inserted; + }); const [withGoals] = await attachGoals(db, [row]); const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; @@ -784,9 +873,18 @@ export function projectService(db: Db) { update: async ( id: string, - data: Partial & { goalIds?: string[] }, + data: Partial & { + goalIds?: string[]; + repositoryIds?: string[]; + repositoryAttribution?: RepositoryAttribution; + }, ): Promise => { - const { goalIds: inputGoalIds, ...projectData } = data; + const { + goalIds: inputGoalIds, + repositoryIds, + repositoryAttribution, + ...projectData + } = data; const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); const existingProject = await db .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) @@ -818,17 +916,29 @@ export function projectService(db: Db) { updates.goalId = ids.length > 0 ? ids[0] : null; } - const row = await db - .update(projects) - .set(updates) - .where(eq(projects.id, id)) - .returning() - .then((rows) => rows[0] ?? null); - if (!row) return null; + const row = await db.transaction(async (tx) => { + const updated = await tx + .update(projects) + .set(updates) + .where(eq(projects.id, id)) + .returning() + .then((rows) => rows[0] ?? null); + if (!updated) return null; - if (ids !== undefined) { - await syncGoalLinks(db, id, row.companyId, ids); - } + if (ids !== undefined) { + await syncGoalLinks(tx as unknown as Db, id, updated.companyId, ids); + } + if (repositoryIds !== undefined) { + await syncRepositoryLinks(tx as unknown as Db, { + companyId: updated.companyId, + projectId: id, + repositoryIds, + attribution: repositoryAttribution, + }); + } + return updated; + }); + if (!row) return null; const [withGoals] = await attachGoals(db, [row]); const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; @@ -907,8 +1017,21 @@ export function projectService(db: Db) { .then((rows) => rows[0] ?? null); if (!project) return null; + const linkedRepository = data.repositoryId + ? await db + .select({ id: repositories.id, cloneUrl: repositories.cloneUrl }) + .from(repositories) + .where(and( + eq(repositories.id, data.repositoryId), + eq(repositories.companyId, project.companyId), + eq(repositories.state, "active"), + )) + .then((rows) => rows[0] ?? null) + : null; + if (data.repositoryId && !linkedRepository) return null; + const cwd = normalizeWorkspaceCwd(data.cwd); - const repoUrl = readNonEmptyString(data.repoUrl); + const repoUrl = linkedRepository?.cloneUrl ?? readNonEmptyString(data.repoUrl); const sourceType = readNonEmptyString(data.sourceType) ?? (repoUrl ? "git_repo" : cwd ? "local_path" : "remote_managed"); const remoteWorkspaceRef = readNonEmptyString(data.remoteWorkspaceRef); if (sourceType === "remote_managed") { @@ -948,6 +1071,7 @@ export function projectService(db: Db) { .values({ companyId: project.companyId, projectId, + repositoryId: data.repositoryId ?? null, name, sourceType, cwd: cwd ?? null, @@ -994,12 +1118,30 @@ export function projectService(db: Db) { .then((rows) => rows[0] ?? null); if (!existing) return null; + const nextRepositoryId = data.repositoryId !== undefined ? data.repositoryId : existing.repositoryId; + const linkedRepository = nextRepositoryId + ? await db + .select({ id: repositories.id, cloneUrl: repositories.cloneUrl, state: repositories.state }) + .from(repositories) + .where(and( + eq(repositories.id, nextRepositoryId), + eq(repositories.companyId, existing.companyId), + )) + .then((rows) => rows[0] ?? null) + : null; + const isChangingRepository = nextRepositoryId !== existing.repositoryId; + if (nextRepositoryId && (!linkedRepository || (isChangingRepository && linkedRepository.state !== "active"))) { + return null; + } + const nextCwd = data.cwd !== undefined ? normalizeWorkspaceCwd(data.cwd) : normalizeWorkspaceCwd(existing.cwd); const nextRepoUrl = - data.repoUrl !== undefined + linkedRepository + ? linkedRepository.cloneUrl + : data.repoUrl !== undefined ? readNonEmptyString(data.repoUrl) : readNonEmptyString(existing.repoUrl); const nextSourceType = @@ -1024,7 +1166,10 @@ export function projectService(db: Db) { patch.name = deriveWorkspaceName({ cwd: nextCwd, repoUrl: nextRepoUrl }); } if (data.cwd !== undefined) patch.cwd = nextCwd ?? null; - if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null; + if (data.repositoryId !== undefined) patch.repositoryId = data.repositoryId; + if (data.repoUrl !== undefined || data.repositoryId !== undefined || existing.repositoryId) { + patch.repoUrl = nextRepoUrl ?? null; + } if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(data.repoRef); if (data.sourceType !== undefined && nextSourceType) patch.sourceType = nextSourceType; if (data.defaultRef !== undefined) patch.defaultRef = readNonEmptyString(data.defaultRef); diff --git a/server/src/services/repositories.ts b/server/src/services/repositories.ts new file mode 100644 index 00000000000..77d8210002d --- /dev/null +++ b/server/src/services/repositories.ts @@ -0,0 +1,328 @@ +import { and, asc, count, eq, inArray, ne, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agentRepositoryGrants, + projectRepositories, + projectWorkspaces, + repositories, + repositoryConnections, +} from "@paperclipai/db"; +import type { + CreateManualRepository, + Repository, + RepositoryCatalogItem, + RepositoryVisibility, + UpdateRepository, +} from "@paperclipai/shared"; +import { conflict, unprocessable } from "../errors.js"; +import { + manualRepositoryIdentityKey, + normalizeRepositoryLocator, + normalizeRepositoryWebUrl, + providerRepositoryIdentityKey, + sanitizeRepositoryProviderMetadata, +} from "./repository-normalization.js"; + +type RepositoryRow = typeof repositories.$inferSelect; + +export interface ProviderRepositorySnapshot { + providerRepositoryId: string; + cloneUrl: string; + webUrl?: string | null; + defaultBranch?: string | null; + visibility?: RepositoryVisibility; + state?: "active" | "unavailable"; + unavailableReason?: string | null; + providerMetadata?: Record | null; +} + +export function toRepository(row: RepositoryRow): Repository { + return { + id: row.id, + companyId: row.companyId, + connectionId: row.connectionId ?? null, + provider: row.provider, + providerRepositoryId: row.providerRepositoryId ?? null, + host: row.host, + owner: row.owner, + name: row.name, + cloneUrl: row.cloneUrl, + webUrl: row.webUrl ?? null, + defaultBranch: row.defaultBranch ?? null, + visibility: row.visibility as Repository["visibility"], + state: row.state as Repository["state"], + unavailableReason: row.unavailableReason ?? null, + providerMetadata: sanitizeRepositoryProviderMetadata(row.providerMetadata), + archivedAt: row.archivedAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function repositoryService(db: Db) { + async function getRowById(id: string) { + return db.select().from(repositories).where(eq(repositories.id, id)).then((rows) => rows[0] ?? null); + } + + async function getRowForCompany(companyId: string, id: string) { + return db + .select() + .from(repositories) + .where(and(eq(repositories.companyId, companyId), eq(repositories.id, id))) + .then((rows) => rows[0] ?? null); + } + + return { + list: async ( + companyId: string, + opts: { includeArchived?: boolean } = {}, + ): Promise => { + const rows = await db + .select() + .from(repositories) + .where( + opts.includeArchived + ? eq(repositories.companyId, companyId) + : and(eq(repositories.companyId, companyId), ne(repositories.state, "archived")), + ) + .orderBy(asc(repositories.host), asc(repositories.owner), asc(repositories.name), asc(repositories.id)); + if (rows.length === 0) return []; + const repositoryIds = rows.map((row) => row.id); + const [projectCounts, grantCounts] = await Promise.all([ + db + .select({ repositoryId: projectRepositories.repositoryId, value: count() }) + .from(projectRepositories) + .where(and( + eq(projectRepositories.companyId, companyId), + inArray(projectRepositories.repositoryId, repositoryIds), + )) + .groupBy(projectRepositories.repositoryId), + db + .select({ repositoryId: agentRepositoryGrants.repositoryId, value: count() }) + .from(agentRepositoryGrants) + .where(and( + eq(agentRepositoryGrants.companyId, companyId), + inArray(agentRepositoryGrants.repositoryId, repositoryIds), + )) + .groupBy(agentRepositoryGrants.repositoryId), + ]); + const projectCountById = new Map(projectCounts.map((row) => [row.repositoryId, Number(row.value)])); + const grantCountById = new Map(grantCounts.map((row) => [row.repositoryId, Number(row.value)])); + return rows.map((row) => ({ + ...toRepository(row), + projectCount: projectCountById.get(row.id) ?? 0, + directGrantCount: grantCountById.get(row.id) ?? 0, + })); + }, + + getById: async (id: string): Promise => { + const row = await getRowById(id); + return row ? toRepository(row) : null; + }, + + getForCompany: async (companyId: string, id: string): Promise => { + const row = await getRowForCompany(companyId, id); + return row ? toRepository(row) : null; + }, + + createManual: async (companyId: string, input: CreateManualRepository) => { + const normalized = normalizeRepositoryLocator(input.cloneUrl); + const identityKey = manualRepositoryIdentityKey(normalized); + const existing = await db + .select() + .from(repositories) + .where(and(eq(repositories.companyId, companyId), eq(repositories.identityKey, identityKey))) + .then((rows) => rows[0] ?? null); + if (existing) return { repository: toRepository(existing), created: false }; + + const webUrl = input.webUrl ? normalizeRepositoryWebUrl(input.webUrl) : normalized.webUrl; + const row = await db + .insert(repositories) + .values({ + companyId, + provider: "manual", + identityKey, + host: normalized.host, + owner: normalized.owner, + name: normalized.name, + cloneUrl: normalized.cloneUrl, + webUrl, + defaultBranch: input.defaultBranch ?? null, + visibility: input.visibility, + }) + .onConflictDoNothing({ target: [repositories.companyId, repositories.identityKey] }) + .returning() + .then((rows) => rows[0] ?? null); + if (row) return { repository: toRepository(row), created: true }; + const raced = await db + .select() + .from(repositories) + .where(and(eq(repositories.companyId, companyId), eq(repositories.identityKey, identityKey))) + .then((rows) => rows[0]); + return { repository: toRepository(raced!), created: false }; + }, + + update: async (companyId: string, id: string, input: UpdateRepository) => { + const existing = await getRowForCompany(companyId, id); + if (!existing) return null; + if (existing.state === "archived") { + throw conflict("Archived repository cannot be updated"); + } + if (existing.provider !== "manual") { + throw conflict("Connected repository identity is managed by its provider"); + } + + const patch: Partial = { updatedAt: new Date() }; + if (input.cloneUrl !== undefined) { + const normalized = normalizeRepositoryLocator(input.cloneUrl); + const identityKey = manualRepositoryIdentityKey(normalized); + const collision = await db + .select({ id: repositories.id }) + .from(repositories) + .where(and( + eq(repositories.companyId, companyId), + eq(repositories.identityKey, identityKey), + ne(repositories.id, id), + )) + .then((rows) => rows[0] ?? null); + if (collision) throw conflict("A repository with this remote already exists"); + patch.identityKey = identityKey; + patch.host = normalized.host; + patch.owner = normalized.owner; + patch.name = normalized.name; + patch.cloneUrl = normalized.cloneUrl; + if (input.webUrl === undefined) patch.webUrl = normalized.webUrl; + } + if (input.webUrl !== undefined) patch.webUrl = input.webUrl ? normalizeRepositoryWebUrl(input.webUrl) : null; + if (input.defaultBranch !== undefined) patch.defaultBranch = input.defaultBranch; + if (input.visibility !== undefined) patch.visibility = input.visibility; + + const row = await db.transaction(async (tx) => { + const updated = await tx + .update(repositories) + .set(patch) + .where(and(eq(repositories.companyId, companyId), eq(repositories.id, id))) + .returning() + .then((rows) => rows[0] ?? null); + if (updated && patch.cloneUrl) { + await tx + .update(projectWorkspaces) + .set({ repoUrl: patch.cloneUrl, updatedAt: new Date() }) + .where(and( + eq(projectWorkspaces.companyId, companyId), + eq(projectWorkspaces.repositoryId, id), + )); + } + return updated; + }); + return row ? toRepository(row) : null; + }, + + archive: async (companyId: string, id: string) => { + const row = await db + .update(repositories) + .set({ state: "archived", archivedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(repositories.companyId, companyId), eq(repositories.id, id))) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toRepository(row) : null; + }, + + upsertProviderRepository: async ( + companyId: string, + connectionId: string, + provider: string, + input: ProviderRepositorySnapshot, + ) => { + const connection = await db + .select() + .from(repositoryConnections) + .where(and( + eq(repositoryConnections.id, connectionId), + eq(repositoryConnections.companyId, companyId), + eq(repositoryConnections.provider, provider), + )) + .then((rows) => rows[0] ?? null); + if (!connection || connection.status === "disconnected") { + throw unprocessable("Repository connection is unavailable"); + } + const normalized = normalizeRepositoryLocator(input.cloneUrl); + if (normalized.host !== connection.host) { + throw unprocessable("Provider repository host does not match its connection"); + } + const identityKey = providerRepositoryIdentityKey({ + provider, + host: connection.host, + providerRepositoryId: input.providerRepositoryId, + }); + const webUrl = input.webUrl ? normalizeRepositoryWebUrl(input.webUrl) : normalized.webUrl; + const now = new Date(); + const row = await db.transaction(async (tx) => { + const updated = await tx + .insert(repositories) + .values({ + companyId, + connectionId, + provider, + providerRepositoryId: input.providerRepositoryId, + identityKey, + host: normalized.host, + owner: normalized.owner, + name: normalized.name, + cloneUrl: normalized.cloneUrl, + webUrl, + defaultBranch: input.defaultBranch ?? null, + visibility: input.visibility ?? "unknown", + state: input.state ?? "active", + unavailableReason: input.unavailableReason ?? null, + providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata), + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [repositories.companyId, repositories.identityKey], + set: { + connectionId, + host: normalized.host, + owner: normalized.owner, + name: normalized.name, + cloneUrl: normalized.cloneUrl, + webUrl, + defaultBranch: input.defaultBranch ?? null, + visibility: input.visibility ?? "unknown", + state: sql`CASE + WHEN ${repositories.state} = 'archived' THEN ${repositories.state} + ELSE ${input.state ?? "active"} + END`, + unavailableReason: input.unavailableReason ?? null, + providerMetadata: sanitizeRepositoryProviderMetadata(input.providerMetadata), + updatedAt: now, + }, + }) + .returning() + .then((rows) => rows[0]!); + await tx + .update(projectWorkspaces) + .set({ repoUrl: normalized.cloneUrl, updatedAt: now }) + .where(and( + eq(projectWorkspaces.companyId, companyId), + eq(projectWorkspaces.repositoryId, updated.id), + )); + return updated; + }); + return toRepository(row!); + }, + + markConnectionUnavailable: async (companyId: string, connectionId: string, reason: string) => { + const rows = await db + .update(repositories) + .set({ state: "unavailable", unavailableReason: reason, updatedAt: new Date() }) + .where(and( + eq(repositories.companyId, companyId), + eq(repositories.connectionId, connectionId), + ne(repositories.state, "archived"), + )) + .returning(); + return rows.map(toRepository); + }, + }; +} diff --git a/server/src/services/repository-access.ts b/server/src/services/repository-access.ts new file mode 100644 index 00000000000..4eb94ba95c2 --- /dev/null +++ b/server/src/services/repository-access.ts @@ -0,0 +1,304 @@ +import { and, asc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agentRepositoryGrants, + agents, + projectRepositories, + projects, + repositories, +} from "@paperclipai/db"; +import type { + AgentRepositoryGrantEntry, + EffectiveRepositoryAccess, + ProjectRepositoryEntry, + Repository, +} from "@paperclipai/shared"; +import { conflict } from "../errors.js"; +import { toRepository } from "./repositories.js"; + +type ProjectAccessCandidate = { id: string; name: string; companyId: string }; + +function toProjectEntry(row: { + link: typeof projectRepositories.$inferSelect; + repository: typeof repositories.$inferSelect; +}): ProjectRepositoryEntry { + return { + link: row.link, + repository: toRepository(row.repository), + }; +} + +function toGrantEntry(row: { + grant: typeof agentRepositoryGrants.$inferSelect; + repository: typeof repositories.$inferSelect; +}): AgentRepositoryGrantEntry { + return { + grant: row.grant, + repository: toRepository(row.repository), + }; +} + +export function repositoryAccessService(db: Db) { + async function getProject(companyId: string, projectId: string) { + return db + .select({ id: projects.id, name: projects.name, companyId: projects.companyId }) + .from(projects) + .where(and(eq(projects.companyId, companyId), eq(projects.id, projectId))) + .then((rows) => rows[0] ?? null); + } + + async function getAgent(companyId: string, agentId: string) { + return db + .select({ id: agents.id, companyId: agents.companyId }) + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))) + .then((rows) => rows[0] ?? null); + } + + async function getRepository(companyId: string, repositoryId: string) { + return db + .select() + .from(repositories) + .where(and(eq(repositories.companyId, companyId), eq(repositories.id, repositoryId))) + .then((rows) => rows[0] ?? null); + } + + async function requireActiveRepository(companyId: string, repositoryId: string) { + const repository = await getRepository(companyId, repositoryId); + if (!repository) return null; + if (repository.state !== "active") throw conflict("Archived or unavailable repository cannot receive new access sources"); + return repository; + } + + return { + listProjectRepositories: async (companyId: string, projectId: string): Promise => { + if (!(await getProject(companyId, projectId))) return null; + const rows = await db + .select({ link: projectRepositories, repository: repositories }) + .from(projectRepositories) + .innerJoin(repositories, eq(projectRepositories.repositoryId, repositories.id)) + .where(and( + eq(projectRepositories.companyId, companyId), + eq(projectRepositories.projectId, projectId), + eq(repositories.companyId, companyId), + )) + .orderBy(asc(projectRepositories.displayOrder), asc(repositories.host), asc(repositories.owner), asc(repositories.name)); + return rows.map(toProjectEntry); + }, + + attachProjectRepository: async (input: { + companyId: string; + projectId: string; + repositoryId: string; + displayOrder: number; + createdByAgentId?: string | null; + createdByUserId?: string | null; + }) => { + const [project, repository] = await Promise.all([ + getProject(input.companyId, input.projectId), + requireActiveRepository(input.companyId, input.repositoryId), + ]); + if (!project || !repository) return null; + const existing = await db + .select() + .from(projectRepositories) + .where(and( + eq(projectRepositories.companyId, input.companyId), + eq(projectRepositories.projectId, input.projectId), + eq(projectRepositories.repositoryId, input.repositoryId), + )) + .then((rows) => rows[0] ?? null); + const now = new Date(); + const link = await db + .insert(projectRepositories) + .values({ + companyId: input.companyId, + projectId: input.projectId, + repositoryId: input.repositoryId, + displayOrder: input.displayOrder, + createdByAgentId: input.createdByAgentId ?? null, + createdByUserId: input.createdByUserId ?? null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [projectRepositories.companyId, projectRepositories.projectId, projectRepositories.repositoryId], + set: { displayOrder: input.displayOrder, updatedAt: now }, + }) + .returning() + .then((rows) => rows[0]!); + return { entry: toProjectEntry({ link, repository }), created: !existing }; + }, + + detachProjectRepository: async (companyId: string, projectId: string, repositoryId: string) => { + const [project, repository] = await Promise.all([ + getProject(companyId, projectId), + getRepository(companyId, repositoryId), + ]); + if (!project || !repository) return null; + const removed = await db + .delete(projectRepositories) + .where(and( + eq(projectRepositories.companyId, companyId), + eq(projectRepositories.projectId, projectId), + eq(projectRepositories.repositoryId, repositoryId), + )) + .returning() + .then((rows) => rows[0] ?? null); + return { removed: Boolean(removed), link: removed }; + }, + + listDirectGrants: async (companyId: string, agentId: string): Promise => { + if (!(await getAgent(companyId, agentId))) return null; + const rows = await db + .select({ grant: agentRepositoryGrants, repository: repositories }) + .from(agentRepositoryGrants) + .innerJoin(repositories, eq(agentRepositoryGrants.repositoryId, repositories.id)) + .where(and( + eq(agentRepositoryGrants.companyId, companyId), + eq(agentRepositoryGrants.agentId, agentId), + eq(repositories.companyId, companyId), + )) + .orderBy(asc(repositories.host), asc(repositories.owner), asc(repositories.name)); + return rows.map(toGrantEntry); + }, + + grantAgentRepository: async (input: { + companyId: string; + agentId: string; + repositoryId: string; + grantedByAgentId?: string | null; + grantedByUserId?: string | null; + }) => { + const [agent, repository] = await Promise.all([ + getAgent(input.companyId, input.agentId), + requireActiveRepository(input.companyId, input.repositoryId), + ]); + if (!agent || !repository) return null; + const existing = await db + .select() + .from(agentRepositoryGrants) + .where(and( + eq(agentRepositoryGrants.companyId, input.companyId), + eq(agentRepositoryGrants.agentId, input.agentId), + eq(agentRepositoryGrants.repositoryId, input.repositoryId), + )) + .then((rows) => rows[0] ?? null); + if (existing) return { entry: toGrantEntry({ grant: existing, repository }), created: false }; + const grant = await db + .insert(agentRepositoryGrants) + .values({ + companyId: input.companyId, + agentId: input.agentId, + repositoryId: input.repositoryId, + grantedByAgentId: input.grantedByAgentId ?? null, + grantedByUserId: input.grantedByUserId ?? null, + }) + .onConflictDoNothing({ + target: [agentRepositoryGrants.companyId, agentRepositoryGrants.agentId, agentRepositoryGrants.repositoryId], + }) + .returning() + .then((rows) => rows[0] ?? null); + const resolved = grant ?? await db + .select() + .from(agentRepositoryGrants) + .where(and( + eq(agentRepositoryGrants.companyId, input.companyId), + eq(agentRepositoryGrants.agentId, input.agentId), + eq(agentRepositoryGrants.repositoryId, input.repositoryId), + )) + .then((rows) => rows[0]!); + return { entry: toGrantEntry({ grant: resolved, repository }), created: Boolean(grant) }; + }, + + revokeAgentRepository: async (companyId: string, agentId: string, repositoryId: string) => { + const [agent, repository] = await Promise.all([ + getAgent(companyId, agentId), + getRepository(companyId, repositoryId), + ]); + if (!agent || !repository) return null; + const removed = await db + .delete(agentRepositoryGrants) + .where(and( + eq(agentRepositoryGrants.companyId, companyId), + eq(agentRepositoryGrants.agentId, agentId), + eq(agentRepositoryGrants.repositoryId, repositoryId), + )) + .returning() + .then((rows) => rows[0] ?? null); + return { removed: Boolean(removed), grant: removed }; + }, + + listEffectiveRepositories: async (input: { + companyId: string; + agentId: string; + canAccessProject: (project: ProjectAccessCandidate) => Promise; + }): Promise => { + if (!(await getAgent(input.companyId, input.agentId))) return null; + const [directRows, inheritedRows] = await Promise.all([ + db + .select({ repository: repositories }) + .from(agentRepositoryGrants) + .innerJoin(repositories, eq(agentRepositoryGrants.repositoryId, repositories.id)) + .where(and( + eq(agentRepositoryGrants.companyId, input.companyId), + eq(agentRepositoryGrants.agentId, input.agentId), + eq(repositories.companyId, input.companyId), + eq(repositories.state, "active"), + )), + db + .select({ + project: { id: projects.id, name: projects.name, companyId: projects.companyId }, + repository: repositories, + }) + .from(projectRepositories) + .innerJoin(projects, eq(projectRepositories.projectId, projects.id)) + .innerJoin(repositories, eq(projectRepositories.repositoryId, repositories.id)) + .where(and( + eq(projectRepositories.companyId, input.companyId), + eq(projects.companyId, input.companyId), + eq(repositories.companyId, input.companyId), + eq(repositories.state, "active"), + )), + ]); + + const projectsById = new Map(); + for (const row of inheritedRows) projectsById.set(row.project.id, row.project); + const accessDecisions = await Promise.all( + [...projectsById.values()].map(async (project) => [project.id, await input.canAccessProject(project)] as const), + ); + const accessibleProjectIds = new Set(accessDecisions.filter(([, allowed]) => allowed).map(([id]) => id)); + + const effective = new Map(); + const ensure = (repository: typeof repositories.$inferSelect) => { + let entry = effective.get(repository.id); + if (!entry) { + entry = { repository: toRepository(repository), sources: { direct: false, projects: [] } }; + effective.set(repository.id, entry); + } + return entry; + }; + for (const row of directRows) ensure(row.repository).sources.direct = true; + for (const row of inheritedRows) { + if (!accessibleProjectIds.has(row.project.id)) continue; + ensure(row.repository).sources.projects.push({ id: row.project.id, name: row.project.name }); + } + + return [...effective.values()] + .map((entry) => ({ + ...entry, + sources: { + ...entry.sources, + projects: entry.sources.projects.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)), + }, + })) + .sort((a, b) => { + const left: Repository = a.repository; + const right: Repository = b.repository; + return left.host.localeCompare(right.host) + || left.owner.localeCompare(right.owner) + || left.name.localeCompare(right.name) + || left.id.localeCompare(right.id); + }); + }, + }; +} diff --git a/server/src/services/repository-connections.ts b/server/src/services/repository-connections.ts new file mode 100644 index 00000000000..de5d66f9841 --- /dev/null +++ b/server/src/services/repository-connections.ts @@ -0,0 +1,239 @@ +import { and, asc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { repositoryConnections } from "@paperclipai/db"; +import type { CreateRepositoryConnection, RepositoryConnection } from "@paperclipai/shared"; +import { conflict, unprocessable } from "../errors.js"; +import { + normalizeRepositoryHost, + sanitizeRepositoryProviderError, + sanitizeRepositoryProviderMetadata, +} from "./repository-normalization.js"; +import { repositoryService, type ProviderRepositorySnapshot } from "./repositories.js"; + +type RepositoryConnectionRow = typeof repositoryConnections.$inferSelect; + +export interface RepositoryProviderAdapter { + readonly provider: string; + sync(input: { + connection: RepositoryConnection; + cursor: string | null; + }): Promise<{ repositories: ProviderRepositorySnapshot[]; cursor?: string | null }>; + disconnect?(input: { connection: RepositoryConnection }): Promise; +} + +class RepositoryProviderRegistry { + private readonly providers = new Map(); + + register(provider: RepositoryProviderAdapter) { + const key = provider.provider.toLowerCase(); + this.providers.set(key, provider); + return () => { + if (this.providers.get(key) === provider) this.providers.delete(key); + }; + } + + get(provider: string) { + return this.providers.get(provider.toLowerCase()) ?? null; + } +} + +export const repositoryProviderRegistry = new RepositoryProviderRegistry(); + +export function toRepositoryConnection(row: RepositoryConnectionRow): RepositoryConnection { + return { + id: row.id, + companyId: row.companyId, + provider: row.provider, + host: row.host, + installationId: row.installationId ?? null, + accountId: row.accountId ?? null, + accountName: row.accountName ?? null, + status: row.status as RepositoryConnection["status"], + syncStatus: row.syncStatus as RepositoryConnection["syncStatus"], + syncCursor: row.syncCursor ?? null, + syncError: row.syncError ?? null, + lastSyncedAt: row.lastSyncedAt ?? null, + providerMetadata: sanitizeRepositoryProviderMetadata(row.providerMetadata), + disconnectedAt: row.disconnectedAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export function repositoryConnectionService( + db: Db, + registry: Pick = repositoryProviderRegistry, +) { + async function getRowById(id: string) { + return db + .select() + .from(repositoryConnections) + .where(eq(repositoryConnections.id, id)) + .then((rows) => rows[0] ?? null); + } + + async function failOperation(row: RepositoryConnectionRow, error: unknown) { + const syncError = sanitizeRepositoryProviderError(error); + const updated = await db + .update(repositoryConnections) + .set({ status: "error", syncStatus: "failed", syncError, updatedAt: new Date() }) + .where(and( + eq(repositoryConnections.companyId, row.companyId), + eq(repositoryConnections.id, row.id), + )) + .returning() + .then((rows) => rows[0] ?? row); + return { connection: toRepositoryConnection(updated), syncError }; + } + + return { + list: async (companyId: string): Promise => { + const rows = await db + .select() + .from(repositoryConnections) + .where(eq(repositoryConnections.companyId, companyId)) + .orderBy(asc(repositoryConnections.provider), asc(repositoryConnections.host), asc(repositoryConnections.id)); + return rows.map(toRepositoryConnection); + }, + + getById: async (id: string): Promise => { + const row = await getRowById(id); + return row ? toRepositoryConnection(row) : null; + }, + + create: async (companyId: string, input: CreateRepositoryConnection) => { + const provider = input.provider.toLowerCase(); + if (provider === "manual") throw unprocessable("Manual repositories do not use provider connections"); + if (!registry.get(provider)) throw unprocessable(`Repository provider '${provider}' is not available`); + const host = normalizeRepositoryHost(input.host); + + if (input.installationId) { + const existing = await db + .select() + .from(repositoryConnections) + .where(and( + eq(repositoryConnections.companyId, companyId), + eq(repositoryConnections.provider, provider), + eq(repositoryConnections.host, host), + eq(repositoryConnections.installationId, input.installationId), + )) + .then((rows) => rows[0] ?? null); + if (existing) return { connection: toRepositoryConnection(existing), created: false }; + } + + const row = await db + .insert(repositoryConnections) + .values({ + companyId, + provider, + host, + installationId: input.installationId ?? null, + accountId: input.accountId ?? null, + accountName: input.accountName ?? null, + }) + .onConflictDoNothing() + .returning() + .then((rows) => rows[0] ?? null); + if (row) return { connection: toRepositoryConnection(row), created: true }; + const raced = input.installationId + ? await db + .select() + .from(repositoryConnections) + .where(and( + eq(repositoryConnections.companyId, companyId), + eq(repositoryConnections.provider, provider), + eq(repositoryConnections.host, host), + eq(repositoryConnections.installationId, input.installationId), + )) + .then((rows) => rows[0] ?? null) + : null; + if (!raced) throw conflict("Repository connection already exists"); + return { connection: toRepositoryConnection(raced), created: false }; + }, + + sync: async (companyId: string, id: string) => { + const row = await getRowById(id); + if (!row || row.companyId !== companyId) return null; + if (row.status === "disconnected") throw conflict("Disconnected repository connection cannot be synced"); + const provider = registry.get(row.provider); + if (!provider) { + const failed = await failOperation(row, `Repository provider '${row.provider}' is not available`); + throw unprocessable(failed.syncError, { connection: failed.connection }); + } + + const syncing = await db + .update(repositoryConnections) + .set({ syncStatus: "syncing", syncError: null, updatedAt: new Date() }) + .where(and(eq(repositoryConnections.companyId, companyId), eq(repositoryConnections.id, id))) + .returning() + .then((rows) => rows[0] ?? row); + try { + const result = await provider.sync({ + connection: toRepositoryConnection(syncing), + cursor: syncing.syncCursor ?? null, + }); + return await db.transaction(async (tx) => { + const transactionalRepositories = repositoryService(tx as unknown as Db); + const syncedRepositories = []; + for (const snapshot of result.repositories) { + syncedRepositories.push( + await transactionalRepositories.upsertProviderRepository(companyId, id, row.provider, snapshot), + ); + } + const completed = await tx + .update(repositoryConnections) + .set({ + status: "active", + syncStatus: "succeeded", + syncCursor: result.cursor ?? syncing.syncCursor ?? null, + syncError: null, + lastSyncedAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(repositoryConnections.companyId, companyId), eq(repositoryConnections.id, id))) + .returning() + .then((rows) => rows[0] ?? syncing); + return { connection: toRepositoryConnection(completed), repositories: syncedRepositories }; + }); + } catch (error) { + const failed = await failOperation(row, error); + throw unprocessable(failed.syncError, { connection: failed.connection }); + } + }, + + disconnect: async (companyId: string, id: string) => { + const row = await getRowById(id); + if (!row || row.companyId !== companyId) return null; + if (row.status === "disconnected") return { connection: toRepositoryConnection(row), repositories: [] }; + const provider = registry.get(row.provider); + try { + await provider?.disconnect?.({ connection: toRepositoryConnection(row) }); + } catch (error) { + const failed = await failOperation(row, error); + throw unprocessable(failed.syncError, { connection: failed.connection }); + } + + return await db.transaction(async (tx) => { + const transactionalRepositories = repositoryService(tx as unknown as Db); + const unavailableRepositories = await transactionalRepositories.markConnectionUnavailable( + companyId, + id, + "Repository provider connection was disconnected", + ); + const disconnected = await tx + .update(repositoryConnections) + .set({ + status: "disconnected", + syncStatus: "idle", + syncError: null, + disconnectedAt: new Date(), + updatedAt: new Date(), + }) + .where(and(eq(repositoryConnections.companyId, companyId), eq(repositoryConnections.id, id))) + .returning() + .then((rows) => rows[0] ?? row); + return { connection: toRepositoryConnection(disconnected), repositories: unavailableRepositories }; + }); + }, + }; +} diff --git a/server/src/services/repository-normalization.ts b/server/src/services/repository-normalization.ts new file mode 100644 index 00000000000..62eec1825da --- /dev/null +++ b/server/src/services/repository-normalization.ts @@ -0,0 +1,143 @@ +import { unprocessable } from "../errors.js"; + +const SUPPORTED_REPOSITORY_PROTOCOLS = new Set(["http:", "https:", "ssh:", "git:"]); +const SENSITIVE_METADATA_KEY = /(?:^|[_-])(auth(?:orization)?|cookie|credential|key|password|private[_-]?key|secret|token)(?:$|[_-])/i; + +export interface NormalizedRepositoryLocator { + host: string; + owner: string; + name: string; + cloneUrl: string; + webUrl: string; + identityPath: string; +} + +function normalizeRepositoryPath(rawPath: string) { + const path = rawPath.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, ""); + const segments = path.split("/").filter(Boolean); + if (segments.length < 2 || segments.some((segment) => segment === "." || segment === "..")) { + throw unprocessable("Repository URL must include an owner and repository name"); + } + const name = segments.at(-1)!; + const owner = segments.slice(0, -1).join("/"); + return { owner, name, path: `${owner}/${name}` }; +} + +export function normalizeRepositoryLocator(rawLocator: string): NormalizedRepositoryLocator { + const raw = rawLocator.trim(); + const scpMatch = /^([^/@:]+)@([^/:]+):(.+)$/.exec(raw); + if (scpMatch) { + const host = scpMatch[2]!.toLowerCase(); + const normalizedPath = normalizeRepositoryPath(scpMatch[3]!); + const hasGitSuffix = /\.git$/i.test(scpMatch[3]!); + return { + host, + owner: normalizedPath.owner, + name: normalizedPath.name, + cloneUrl: `${scpMatch[1]}@${host}:${normalizedPath.path}${hasGitSuffix ? ".git" : ""}`, + webUrl: `https://${host}/${normalizedPath.path}`, + identityPath: normalizedPath.path.toLowerCase(), + }; + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw unprocessable("Repository URL must be an HTTP(S), SSH, Git, or scp-style URL"); + } + if (!SUPPORTED_REPOSITORY_PROTOCOLS.has(parsed.protocol)) { + throw unprocessable("Repository URL protocol is not supported"); + } + const sshUsername = parsed.protocol === "ssh:" ? parsed.username : ""; + if (parsed.password || (parsed.protocol !== "ssh:" && parsed.username)) { + throw unprocessable("Repository URL must not contain embedded credentials"); + } + if (parsed.search || parsed.hash) { + throw unprocessable("Repository URL must not contain query parameters or fragments"); + } + + const host = parsed.host.toLowerCase(); + if (!host) throw unprocessable("Repository URL must include a host"); + const normalizedPath = normalizeRepositoryPath(parsed.pathname); + const hasGitSuffix = /\.git\/?$/i.test(parsed.pathname); + const cloneUrl = `${parsed.protocol}//${sshUsername ? `${sshUsername}@` : ""}${host}/${normalizedPath.path}${hasGitSuffix ? ".git" : ""}`; + return { + host, + owner: normalizedPath.owner, + name: normalizedPath.name, + cloneUrl, + webUrl: `https://${host}/${normalizedPath.path}`, + identityPath: normalizedPath.path.toLowerCase(), + }; +} + +export function manualRepositoryIdentityKey(locator: Pick) { + return `manual:${locator.host}/${locator.identityPath}`; +} + +export function normalizeRepositoryWebUrl(rawUrl: string) { + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch { + throw unprocessable("Repository web URL is invalid"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw unprocessable("Repository web URL must use HTTP or HTTPS"); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw unprocessable("Repository web URL must not contain credentials, query parameters, or fragments"); + } + parsed.hostname = parsed.hostname.toLowerCase(); + return parsed.toString().replace(/\/$/, ""); +} + +export function normalizeRepositoryHost(rawHost: string) { + const trimmed = rawHost.trim(); + let parsed: URL; + try { + parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + } catch { + throw unprocessable("Repository host is invalid"); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash || (parsed.pathname && parsed.pathname !== "/")) { + throw unprocessable("Repository host must not contain credentials, paths, query parameters, or fragments"); + } + return parsed.host.toLowerCase(); +} + +export function providerRepositoryIdentityKey(input: { + provider: string; + host: string; + providerRepositoryId: string; +}) { + return `${input.provider.toLowerCase()}:${input.host.toLowerCase()}:${input.providerRepositoryId}`; +} + +export function sanitizeRepositoryProviderMetadata(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + + const sanitize = (input: unknown): unknown => { + if (Array.isArray(input)) return input.map(sanitize); + if (!input || typeof input !== "object") return input; + const output: Record = {}; + for (const [key, entry] of Object.entries(input as Record)) { + const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(); + if (SENSITIVE_METADATA_KEY.test(normalizedKey)) continue; + output[key] = sanitize(entry); + } + return output; + }; + + return sanitize(value) as Record; +} + +export function sanitizeRepositoryProviderError(error: unknown) { + const raw = error instanceof Error ? error.message : String(error ?? "Repository provider operation failed"); + return raw + .replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1[redacted]@") + .replace(/([?&](?:access[_-]?token|auth(?:orization)?|credential|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]") + .replace(/\b((?:access[_-]?token|auth(?:orization)?|credential|key|password|secret|token)\s*[:=]\s*)(?:Bearer\s+)?\S+/gi, "$1[redacted]") + .slice(0, 1_000); +} diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 31bc7d54ae1..64f39ea7da3 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -5,6 +5,7 @@ export { accessApi } from "./access"; export { companiesApi } from "./companies"; export { agentsApi } from "./agents"; export { projectsApi } from "./projects"; +export { repositoriesApi } from "./repositories"; export { issuesApi } from "./issues"; export { externalObjectsApi } from "./externalObjects"; export { routinesApi } from "./routines"; diff --git a/ui/src/api/repositories.ts b/ui/src/api/repositories.ts new file mode 100644 index 00000000000..f4d33b35074 --- /dev/null +++ b/ui/src/api/repositories.ts @@ -0,0 +1,71 @@ +import type { + AgentRepositoryGrantEntry, + CreateManualRepository, + CreateRepositoryConnection, + EffectiveRepositoryAccess, + ProjectRepositoryEntry, + Repository, + RepositoryCatalogItem, + RepositoryConnection, + UpdateRepository, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const repositoriesApi = { + list: (companyId: string, opts: { includeArchived?: boolean } = {}) => + api.get( + `/companies/${encodeURIComponent(companyId)}/repositories${opts.includeArchived ? "?includeArchived=true" : ""}`, + ), + get: (repositoryId: string) => api.get(`/repositories/${encodeURIComponent(repositoryId)}`), + createManual: (companyId: string, input: CreateManualRepository) => + api.post(`/companies/${encodeURIComponent(companyId)}/repositories`, input), + update: (repositoryId: string, input: UpdateRepository) => + api.patch(`/repositories/${encodeURIComponent(repositoryId)}`, input), + archive: (repositoryId: string) => + api.delete(`/repositories/${encodeURIComponent(repositoryId)}`), + + listConnections: (companyId: string) => + api.get(`/companies/${encodeURIComponent(companyId)}/repository-connections`), + getConnection: (connectionId: string) => + api.get(`/repository-connections/${encodeURIComponent(connectionId)}`), + createConnection: (companyId: string, input: CreateRepositoryConnection) => + api.post( + `/companies/${encodeURIComponent(companyId)}/repository-connections`, + input, + ), + syncConnection: (connectionId: string) => + api.post<{ connection: RepositoryConnection; repositories: Repository[] }>( + `/repository-connections/${encodeURIComponent(connectionId)}/sync`, + {}, + ), + disconnectConnection: (connectionId: string) => + api.delete<{ connection: RepositoryConnection; repositories: Repository[] }>( + `/repository-connections/${encodeURIComponent(connectionId)}`, + ), + + listProjectRepositories: (projectId: string) => + api.get(`/projects/${encodeURIComponent(projectId)}/repositories`), + attachProjectRepository: (projectId: string, repositoryId: string, displayOrder = 0) => + api.put( + `/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryId)}`, + { displayOrder }, + ), + detachProjectRepository: (projectId: string, repositoryId: string) => + api.delete<{ removed: boolean }>( + `/projects/${encodeURIComponent(projectId)}/repositories/${encodeURIComponent(repositoryId)}`, + ), + + listDirectAgentGrants: (agentId: string) => + api.get(`/agents/${encodeURIComponent(agentId)}/repositories`), + listEffectiveAgentRepositories: (agentId: string) => + api.get(`/agents/${encodeURIComponent(agentId)}/repositories?effective=true`), + grantAgentRepository: (agentId: string, repositoryId: string) => + api.put( + `/agents/${encodeURIComponent(agentId)}/repositories/${encodeURIComponent(repositoryId)}`, + {}, + ), + revokeAgentRepository: (agentId: string, repositoryId: string) => + api.delete<{ removed: boolean }>( + `/agents/${encodeURIComponent(agentId)}/repositories/${encodeURIComponent(repositoryId)}`, + ), +}; diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index f9ac92b65cc..10da9e0cd37 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -325,6 +325,7 @@ function createProject(overrides: Partial = {}): Project { id: "workspace-main", companyId: "company-1", projectId: "project-1", + repositoryId: null, name: "Main", sourceType: "local_path" as const, cwd: "/tmp/paperclip", diff --git a/ui/src/components/WorkspaceFileBrowser.test.tsx b/ui/src/components/WorkspaceFileBrowser.test.tsx index 6277c65fd5f..71e2d33706b 100644 --- a/ui/src/components/WorkspaceFileBrowser.test.tsx +++ b/ui/src/components/WorkspaceFileBrowser.test.tsx @@ -116,6 +116,7 @@ function createWorkspace(overrides: Partial = {}): ProjectWork id: "workspace-content", companyId: "company-1", projectId: "project-content", + repositoryId: null, name: "Paperclip Content", sourceType: "local_path", cwd: "/srv/paperclip/home/paperclipai/paperclip-content", diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3fdf2ef02a9..818a3c6b900 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -229,6 +229,7 @@ function makeProjectWorkspace(overrides: Partial = {}): Projec id: "project-workspace-1", companyId: "company-1", projectId: "project-1", + repositoryId: null, name: "Primary workspace", sourceType: "local_path", cwd: "/tmp/project", diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts index 6bf3632ce94..1ca6677a9e3 100644 --- a/ui/src/lib/project-workspaces-tab.test.ts +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -7,6 +7,7 @@ function createProjectWorkspace(overrides: Partial): ProjectWo id: overrides.id ?? "workspace-default", companyId: overrides.companyId ?? "company-1", projectId: overrides.projectId ?? "project-1", + repositoryId: overrides.repositoryId ?? null, name: overrides.name ?? "paperclip", sourceType: overrides.sourceType ?? "local_path", cwd: overrides.cwd ?? "/repo", diff --git a/ui/src/pages/ProjectWorkspaceDetail.test.tsx b/ui/src/pages/ProjectWorkspaceDetail.test.tsx index 1b761a2eaf6..44d72a01cee 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.test.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.test.tsx @@ -99,6 +99,7 @@ function projectWorkspace(overrides: Partial = {}): ProjectWor id: "workspace-1", companyId: "company-1", projectId: "project-1", + repositoryId: null, name: "Primary checkout", sourceType: "local_path", cwd: "/tmp/paperclip", diff --git a/ui/storybook/fixtures/paperclipData.ts b/ui/storybook/fixtures/paperclipData.ts index ee9d4a35341..813bd4ecad1 100644 --- a/ui/storybook/fixtures/paperclipData.ts +++ b/ui/storybook/fixtures/paperclipData.ts @@ -374,6 +374,7 @@ export const storybookProjectWorkspaces: Project["workspaces"] = [ id: "workspace-board-ui", companyId: "company-storybook", projectId: "project-board-ui", + repositoryId: null, name: "Board UI", sourceType: "local_path" as const, cwd: `${storybookRepoRoot}/ui`, @@ -401,6 +402,7 @@ export const storybookProjectWorkspaces: Project["workspaces"] = [ id: "workspace-docs-remote", companyId: "company-storybook", projectId: "project-board-ui", + repositoryId: null, name: "Docs preview sandbox", sourceType: "remote_managed", cwd: null, @@ -462,6 +464,7 @@ export const storybookProjectWorkspaces: Project["workspaces"] = [ id: "workspace-release-local", companyId: "company-storybook", projectId: "project-board-ui", + repositoryId: null, name: "Release smoke local checkout", sourceType: "local_path", cwd: `${storybookWorkspaceRoot}/release-smoke`,