feat(repositories): add first-class repository foundation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent f16071a290
commit 0c9d8e64ff
30 files changed
+2708 -32

No files matched your search

@@ -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<void>> = [];
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);
});
+6
View File
@@ -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";
@@ -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),
+133
View File
@@ -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<Record<string, unknown>>(),
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<Record<string, unknown>>(),
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,
),
}),
);
+6
View File
@@ -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];
+22
View File
@@ -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,
+1
View File
@@ -29,6 +29,7 @@ export interface ProjectWorkspace {
id: string;
companyId: string;
projectId: string;
repositoryId: string | null;
name: string;
sourceType: ProjectWorkspaceSourceType;
cwd: string | null;
+99
View File
@@ -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<string, unknown> | 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<string, unknown> | 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[];
};
}
+1
View File
@@ -921,3 +921,4 @@ export {
} from "./tool-access.js";
export * from "./skill-policy.js";
export * from "./app-definition.js";
export * from "./repository.js";
+7 -4
View File
@@ -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<string, unknown>, 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(),
};
@@ -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<typeof createManualRepositorySchema>;
export type UpdateRepository = z.infer<typeof updateRepositorySchema>;
export type CreateRepositoryConnection = z.infer<typeof createRepositoryConnectionSchema>;
export type AttachProjectRepository = z.infer<typeof attachProjectRepositorySchema>;
export type GrantAgentRepository = z.infer<typeof grantAgentRepositorySchema>;
+527
View File
@@ -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<typeof createDb>,
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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 });
});
});
+2
View File
@@ -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));
+1
View File
@@ -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 {
+12 -2
View File
@@ -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,
+443
View File
@@ -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;
}
+8
View File
@@ -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,
+171 -26
View File
@@ -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<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
data: Omit<typeof projects.$inferInsert, "companyId"> & {
goalIds?: string[];
repositoryIds?: string[];
repositoryAttribution?: RepositoryAttribution;
},
): Promise<ProjectWithGoals> => {
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<typeof projects.$inferInsert> & { goalIds?: string[] },
data: Partial<typeof projects.$inferInsert> & {
goalIds?: string[];
repositoryIds?: string[];
repositoryAttribution?: RepositoryAttribution;
},
): Promise<ProjectWithGoals | null> => {
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);
+328
View File
@@ -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<string, unknown> | 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<RepositoryCatalogItem[]> => {
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<Repository | null> => {
const row = await getRowById(id);
return row ? toRepository(row) : null;
},
getForCompany: async (companyId: string, id: string): Promise<Repository | null> => {
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<typeof repositories.$inferInsert> = { 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);
},
};
}
+304
View File
@@ -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<ProjectRepositoryEntry[] | null> => {
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<AgentRepositoryGrantEntry[] | null> => {
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<boolean>;
}): Promise<EffectiveRepositoryAccess[] | null> => {
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<string, ProjectAccessCandidate>();
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<string, EffectiveRepositoryAccess>();
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);
});
},
};
}
@@ -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<void>;
}
class RepositoryProviderRegistry {
private readonly providers = new Map<string, RepositoryProviderAdapter>();
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, "get"> = 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<RepositoryConnection[]> => {
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<RepositoryConnection | null> => {
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 };
});
},
};
}
@@ -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<NormalizedRepositoryLocator, "host" | "identityPath">) {
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<string, unknown> | 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<string, unknown> = {};
for (const [key, entry] of Object.entries(input as Record<string, unknown>)) {
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<string, unknown>;
}
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);
}
+1
View File
@@ -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";
+71
View File
@@ -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<RepositoryCatalogItem[]>(
`/companies/${encodeURIComponent(companyId)}/repositories${opts.includeArchived ? "?includeArchived=true" : ""}`,
),
get: (repositoryId: string) => api.get<Repository>(`/repositories/${encodeURIComponent(repositoryId)}`),
createManual: (companyId: string, input: CreateManualRepository) =>
api.post<Repository>(`/companies/${encodeURIComponent(companyId)}/repositories`, input),
update: (repositoryId: string, input: UpdateRepository) =>
api.patch<Repository>(`/repositories/${encodeURIComponent(repositoryId)}`, input),
archive: (repositoryId: string) =>
api.delete<Repository>(`/repositories/${encodeURIComponent(repositoryId)}`),
listConnections: (companyId: string) =>
api.get<RepositoryConnection[]>(`/companies/${encodeURIComponent(companyId)}/repository-connections`),
getConnection: (connectionId: string) =>
api.get<RepositoryConnection>(`/repository-connections/${encodeURIComponent(connectionId)}`),
createConnection: (companyId: string, input: CreateRepositoryConnection) =>
api.post<RepositoryConnection>(
`/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<ProjectRepositoryEntry[]>(`/projects/${encodeURIComponent(projectId)}/repositories`),
attachProjectRepository: (projectId: string, repositoryId: string, displayOrder = 0) =>
api.put<ProjectRepositoryEntry>(
`/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<AgentRepositoryGrantEntry[]>(`/agents/${encodeURIComponent(agentId)}/repositories`),
listEffectiveAgentRepositories: (agentId: string) =>
api.get<EffectiveRepositoryAccess[]>(`/agents/${encodeURIComponent(agentId)}/repositories?effective=true`),
grantAgentRepository: (agentId: string, repositoryId: string) =>
api.put<AgentRepositoryGrantEntry>(
`/agents/${encodeURIComponent(agentId)}/repositories/${encodeURIComponent(repositoryId)}`,
{},
),
revokeAgentRepository: (agentId: string, repositoryId: string) =>
api.delete<{ removed: boolean }>(
`/agents/${encodeURIComponent(agentId)}/repositories/${encodeURIComponent(repositoryId)}`,
),
};
@@ -325,6 +325,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
id: "workspace-main",
companyId: "company-1",
projectId: "project-1",
repositoryId: null,
name: "Main",
sourceType: "local_path" as const,
cwd: "/tmp/paperclip",
@@ -116,6 +116,7 @@ function createWorkspace(overrides: Partial<ProjectWorkspace> = {}): 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",
+1
View File
@@ -229,6 +229,7 @@ function makeProjectWorkspace(overrides: Partial<ProjectWorkspace> = {}): Projec
id: "project-workspace-1",
companyId: "company-1",
projectId: "project-1",
repositoryId: null,
name: "Primary workspace",
sourceType: "local_path",
cwd: "/tmp/project",
@@ -7,6 +7,7 @@ function createProjectWorkspace(overrides: Partial<ProjectWorkspace>): 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",
@@ -99,6 +99,7 @@ function projectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWor
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
repositoryId: null,
name: "Primary checkout",
sourceType: "local_path",
cwd: "/tmp/paperclip",
+3
View File
@@ -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`,