mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(repositories): add first-class repository foundation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ProjectWorkspace {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string;
|
||||
repositoryId: string | null;
|
||||
name: string;
|
||||
sourceType: ProjectWorkspaceSourceType;
|
||||
cwd: string | null;
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
}
|
||||
@@ -921,3 +921,4 @@ export {
|
||||
} from "./tool-access.js";
|
||||
export * from "./skill-policy.js";
|
||||
export * from "./app-definition.js";
|
||||
export * from "./repository.js";
|
||||
@@ -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>;
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user