diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 6c80f1f5004..792fd142c22 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -178,6 +178,19 @@ These rows are company-scoped and user-scoped. A missing row means the user is j Both tables use a unique key on `(company_id, user_id, resource_id)` and keep `state` as `joined` or `left`. Join/leave mutations are idempotent board-user `/me` operations and write activity entries when the effective state changes. +## Repository catalog and access + +First-class repositories use four company-scoped tables: + +- `repository_connections` stores provider/host installation identity, connection health, and sync cursors. It contains no plaintext credential; durable credentials stay in the secret subsystem. +- `repositories` stores normalized catalog identity and secret-free provider metadata. `(company_id, identity_key)` is unique, with an additional partial provider identity index for provider repository ids. +- `project_repositories` stores ordered many-to-many project hints. Its unique company/project/repository key prevents duplicate hints. +- `agent_repository_grants` stores explicit direct grants. Its unique company/agent/repository key prevents duplicate grants. + +Every relationship repeats `company_id` for scoped lookup and requires service-level validation that both endpoints belong to that company. Project visibility contributes inherited effective access only after `project:read` authorization; a relationship row is not itself an authorization bypass. + +`project_workspaces.repository_id` is nullable and uses `ON DELETE SET NULL`. It can identify the catalog repository behind a concrete execution workspace, but the inverse is not true: catalog entries and project hints do not create workspaces or choose a runtime `cwd`. The `0213_lowly_chameleon.sql` backfill normalizes valid legacy workspace `repo_url` values into manual catalog rows, links the original workspace, and adds its project hint while rejecting credential-bearing or malformed URLs. + ## Decision training snapshot retention `decision_training_examples` stores a point-in-time copy of an issue, its comments, relevant runs, and the selected decision. Each row carries the `scrub_deleted_comments_v1` retention policy marker, and JSONL exports include that marker alongside the snapshot. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 2584bdc7244..029c1786be8 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -218,6 +218,24 @@ Invariant: Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent. +### Repository catalog and execution boundaries + +Repositories are company-scoped work objects, not project-owned workspaces: + +- `repository_connections` records provider installations and sync state. Provider credentials remain behind secret references and are never repository metadata. +- `repositories` is the company catalog. A row may be entered manually or synchronized from a provider connection; normalized identity prevents duplicate catalog entries inside one company. +- `project_repositories` is a many-to-many, ordered hint that a project is likely to touch a repository. A hint is non-exclusive and does not grant access by itself beyond the actor's existing `project:read` authorization. +- `agent_repository_grants` is an explicit direct grant. An agent's effective repository set is the union of direct grants and hints from projects that agent may read. + +These concepts stay separate: + +- **Repository availability** means a catalog row is `active`; `unavailable` and `archived` rows cannot resolve clone credentials or accept new relationships. +- **Project work-object visibility** is decided through `project:read`; it controls which project-derived repository hints contribute to an agent's effective set. +- **Repository hints** are secret-free identity and clone metadata supplied for planning and context. They do not select a checkout or working directory. +- **Execution workspaces** are concrete runtime locations and lifecycle records. A workspace may reference a catalog repository, but attaching a repository to a project never creates a workspace, and repository portability never synthesizes one from a hint. + +All repository, connection, relationship, credential, and effective-access APIs derive and enforce company scope. Mutations are board-only and activity-logged. Agent-readable project and effective-access endpoints still apply project/agent authorization. Clone credentials are short-lived, returned with `Cache-Control: no-store`, and available only for an active provider-backed repository in the caller's effective access set. + ## 7.6 `issues` (core task entity) - `id` uuid pk @@ -454,6 +472,7 @@ The current implementation includes additional V1-control-plane tables beyond th - Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes. - Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables. - Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`. +- Repository catalog and access: `repository_connections`, `repositories`, `project_repositories`, and `agent_repository_grants`; `project_workspaces.repository_id` is an optional execution link, not ownership of the catalog row. - Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos. Decision-desk triage uses company-scoped sidecars rather than adding queue fields to every attention source: @@ -1388,13 +1407,17 @@ Export/import behavior in V1: - projects and starter tasks are opt-in export content rather than default package content - recurring `TASK.md` entries use `recurring: true` in the base package and Paperclip routine fidelity in `.paperclip.yaml` - Paperclip imports recurring task packages as routines instead of downgrading them to one-time issues -- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable project repo/workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml` +- export strips environment-specific paths (`cwd`, local instruction file paths, inline prompt duplication) while preserving portable execution-workspace metadata such as `repoUrl`, refs, and workspace-policy references keyed in `.paperclip.yaml` +- `.paperclip.yaml` schema version 8 carries secret-free repository catalog entries plus project-slug and direct-agent-slug relationships; it never carries connection ids, provider credentials, provider configuration, secret refs, or provider metadata - export never includes secret values; env inputs are reported as portable declarations instead - export preserves explicit company skill policy and retained legacy skill grants in `.paperclip.yaml`; absence of policy remains the open default - import supports target modes: - create a new company - import into an existing company - import recreates exported project workspaces and remaps portable workspace keys back to target-local workspace ids +- import matches repositories by normalized clone URL, recreates them as manual metadata, restores resolvable project/direct-agent relationships, and reports unresolved mappings in preview +- provider-backed source repositories are previewed and imported as disconnected manual metadata pending destination re-authorization; an import cannot resurrect the source provider connection +- pre-version-8 packages with only project-workspace `repoUrl` values recover deduplicated manual catalog entries and project hints without creating any workspace not explicitly declared by the package - import forces imported agent timer heartbeats off so packages never start scheduled runs implicitly - import supports collision strategies: `rename`, `skip`, `replace` - import supports preview (dry-run) before apply diff --git a/doc/connections/SECURITY-THREAT-MODEL.md b/doc/connections/SECURITY-THREAT-MODEL.md index c8cce179fc1..d51125599d7 100644 --- a/doc/connections/SECURITY-THREAT-MODEL.md +++ b/doc/connections/SECURITY-THREAT-MODEL.md @@ -216,6 +216,11 @@ Required controls: secret refs are remapped and validated. - Imported profiles/bindings/policies become active only when referenced principals and connections resolve inside the destination company. +- Repository export includes only secret-free catalog identity plus portable + project/agent relationships. It excludes connection ids, provider setup, + provider metadata, and clone credentials. Provider-backed repositories import + as disconnected manual metadata until the destination authorizes and syncs + its own provider connection. ## Required Negative Tests diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 165c2e53ce4..0bc858ec73b 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -677,6 +677,12 @@ Extensions verify their implementation with the conformance suite exported at `@paperclipai/plugin-sdk/repository-provider-conformance`, which the host also runs against the bridge. +Provider installations are deliberately not portable. Company export may carry +secret-free repository identity and portable project/agent relationships, but +never the connection snapshot, provider configuration, metadata, or transient +credential. Import recreates those entries as disconnected manual metadata; a +destination operator must install/authorize the provider again. + ## 14. SDK Surface Plugins do not talk to the DB directly. diff --git a/docs/api/repositories.md b/docs/api/repositories.md new file mode 100644 index 00000000000..08ef631d674 --- /dev/null +++ b/docs/api/repositories.md @@ -0,0 +1,70 @@ +--- +title: Repositories +summary: Company repository catalog, provider connections, relationships, and effective access +--- + +Repositories are company-scoped work objects. They are not execution workspaces: a project relationship is a planning/context hint, while a workspace is a concrete checkout and runtime location. + +The published shared contracts live in `packages/shared/src/types/repository.ts` and their request validators in `packages/shared/src/validators/repository.ts`. + +## Catalog + +Board operators can list and create manual catalog entries: + +```text +GET /api/companies/{companyId}/repositories?includeArchived=true +POST /api/companies/{companyId}/repositories +GET /api/repositories/{repositoryId} +PATCH /api/repositories/{repositoryId} +DELETE /api/repositories/{repositoryId} +GET /api/repositories/{repositoryId}/relationships +``` + +`DELETE` archives rather than hard-deletes. Catalog identity and URLs are normalized; credential-bearing URLs are rejected. The relationships response names linked projects and agents whose effective access comes from a direct grant, a readable linked project, or both. + +## Project hints and agent access + +```text +GET /api/projects/{projectId}/repositories +PUT /api/projects/{projectId}/repositories/{repositoryId} +DELETE /api/projects/{projectId}/repositories/{repositoryId} + +GET /api/agents/{agentId}/repositories +GET /api/agents/{agentId}/repositories?effective=true +PUT /api/agents/{agentId}/repositories/{repositoryId} +DELETE /api/agents/{agentId}/repositories/{repositoryId} +``` + +The project `PUT` body is `{ "displayOrder": 0 }`. Project hints are many-to-many and non-exclusive. The agent `PUT` creates an explicit direct grant. `effective=true` returns the union of direct grants and repositories hinted by projects the agent may read, with the contributing sources attached. Reads apply project/agent authorization; mutations are board-only and activity-logged. + +Project payloads expose `repositoryHints`, a secret-free `ProjectRepositoryHint[]`. Agent heartbeat/resume context exposes only the equivalent secret-free `EffectiveRepositoryContext[]`; neither payload includes provider metadata or a credential. + +## Provider connections + +```text +GET /api/companies/{companyId}/repository-providers +GET /api/companies/{companyId}/repository-connections +POST /api/companies/{companyId}/repository-connections +GET /api/repository-connections/{connectionId} +POST /api/repository-connections/{connectionId}/sync +DELETE /api/repository-connections/{connectionId} + +POST /api/companies/{companyId}/repository-connections/{provider}/install +POST /api/companies/{companyId}/repository-connections/{provider}/callback +GET /api/repository-connections/{connectionId}/discover +POST /api/repository-connections/{connectionId}/import +``` + +Provider identity is `(provider, normalized host)`, allowing hosted and enterprise installations to coexist. The available-provider route is runtime capability discovery: an extension-contributed provider appears only while its trusted plugin is ready, declares that identity, holds `repository.providers.register`, and has a running worker. + +## Clone credentials + +```text +POST /api/repositories/{repositoryId}/clone-credential +``` + +This endpoint is available only for an active provider-backed repository in the caller's effective access set. The credential is short-lived, audited, and returned with `Cache-Control: no-store`; it must not be persisted, logged, placed in prompts, or included in company portability output. + +## Portability + +Company packages use `.paperclip.yaml` schema version 8 for repository entries and portable project/direct-agent slug relationships. Export excludes connection and secret material. Preview reports provider disconnection and unresolved mappings. Import matches normalized clone URLs, recreates missing entries as manual metadata, restores resolvable relationships, and never creates an execution workspace from a repository hint. diff --git a/docs/companies/companies-spec.md b/docs/companies/companies-spec.md index 5f1327dbf1b..755b86e2f23 100644 --- a/docs/companies/companies-spec.md +++ b/docs/companies/companies-spec.md @@ -433,6 +433,7 @@ Example uses: - budgets - approval policies - project execution workspace policies +- repository catalog metadata and portable project/agent relationships - issue/task Paperclip-only metadata Rules: @@ -470,12 +471,21 @@ routines: - kind: schedule cronExpression: "0 9 * * 1" timezone: America/Chicago +repositories: + github-com-paperclipai-paperclip: + provider: github + cloneUrl: git@github.com:paperclipai/paperclip.git + webUrl: https://github.com/paperclipai/paperclip + disconnected: true + projects: [launch, docs] + directAgentGrants: [claudecoder] ``` Additional rules for Paperclip exporters: - do not duplicate `promptTemplate` when `AGENTS.md` already contains the agent instructions - do not export provider-specific secret bindings such as `secretId`, `version`, or `type: secret_ref` +- do not export repository connection ids, provider installation configuration, provider metadata, clone credentials, or tokens; provider-backed repositories must be marked disconnected - export env inputs as portable declarations with `required` or `optional` semantics and optional defaults - warn on system-dependent values such as absolute commands and absolute `PATH` overrides - omit empty and default-valued Paperclip fields when possible @@ -541,7 +551,7 @@ Paperclip can map this spec to its runtime model like this: - `SKILL.md` -> imported skill package - `sources[]` -> provenance and pinned upstream refs - Paperclip extension: - - `.paperclip.yaml` -> adapter config, runtime config, env input declarations, permissions, budgets, routine triggers, and other Paperclip-specific fidelity + - `.paperclip.yaml` -> adapter config, runtime config, repository catalog relationships, env input declarations, permissions, budgets, routine triggers, and other Paperclip-specific fidelity Inline Paperclip-only metadata that must live inside a shared markdown file should use: diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index 1d8e40d241e..f30a288934a 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -21,13 +21,13 @@ my-company/ │ └── review/SKILL.md ├── tasks/ │ └── onboarding/TASK.md -└── .paperclip.yaml # Adapter config, env inputs, routines +└── .paperclip.yaml # Adapter config, repositories, env inputs, routines ``` - **COMPANY.md** defines company name, description, and metadata. - **AGENT.md** files contain agent identity, role, and instructions. - **SKILL.md** files are compatible with the Agent Skills ecosystem. -- **.paperclip.yaml** holds Paperclip-specific config (adapter types, env inputs, budgets) as an optional sidecar. +- **.paperclip.yaml** holds Paperclip-specific config (adapter types, repository relationships, env inputs, budgets) as an optional sidecar. ## Export & Import in the App @@ -75,12 +75,15 @@ paperclipai company export abc123 --out ./skills-only --include skills --skills - Company name, description, and metadata - Agent names, roles, reporting structure, and instructions - Project definitions and workspace config +- Company repository catalog metadata, project hints, and direct agent grants - Task/issue descriptions (when included) - Skill packages (as references or vendored content) - Adapter type and env input declarations in `.paperclip.yaml` Secret values, machine-local paths, and database IDs are **never** exported. +Repository provider installations are environment-local and are never portable. Exported repository entries contain only secret-free identity/clone metadata and portable project or agent slugs. They do not contain connection ids, installation configuration, provider metadata, tokens, secret refs, or clone credentials. + ## Importing a Company Import from a local directory, GitHub URL, or GitHub shorthand: @@ -146,7 +149,11 @@ The preview shows: - **Package contents** — How many agents, projects, tasks, and skills are in the source - **Import plan** — What will be created, renamed, skipped, or replaced - **Env inputs** — Environment variables that may need values after import -- **Warnings** — Potential issues like missing skills or unresolved references +- **Warnings** — Potential issues like missing skills, disconnected repository providers, or unresolved project/agent mappings + +Provider-backed repositories always import as disconnected manual catalog metadata. Re-authorize the provider on the destination and sync it there before expecting provider refresh or clone credentials. Resolvable project hints and direct agent grants are restored; unresolved slugs stay visible in preview and are skipped during apply. + +Repository hints and execution workspaces are intentionally different. A project hint says which repository the work may touch. A workspace is an actual checkout/runtime location. Import recreates only workspaces explicitly declared in the package. Legacy packages that mention a repository only through a workspace `repoUrl` recover a manual catalog row and project hint, but Paperclip does not create any additional workspace from that hint. Imported agents always land with timer heartbeats disabled. Assignment/on-demand wake behavior from the package is preserved, but scheduled runs stay off until a board operator re-enables them. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 096604d47fa..d139f197f9a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1144,6 +1144,7 @@ export type { CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityRepositoryManifestEntry, CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueCommentManifestEntry, @@ -1163,6 +1164,7 @@ export type { CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewProjectPlan, + CompanyPortabilityPreviewRepositoryPlan, CompanyPortabilityPreviewIssuePlan, CompanyPortabilityPreviewResult, CompanyPortabilityAdapterOverride, @@ -2172,6 +2174,7 @@ export { portabilityCompanyManifestEntrySchema, portabilitySidebarOrderSchema, portabilityAgentManifestEntrySchema, + portabilityRepositoryManifestEntrySchema, portabilityManifestSchema, portabilitySourceSchema, portabilityTargetSchema, diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index a32486b42fe..ea3939946e3 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -113,6 +113,30 @@ export interface CompanyPortabilityProjectWorkspaceManifestEntry { isPrimary: boolean; } +/** + * Secret-free repository metadata carried by `.paperclip.yaml`. + * + * Provider connections are intentionally not portable. `provider` and + * `providerRepositoryId` describe source identity only; import materializes a + * disconnected manual repository until the destination authorizes a provider. + */ +export interface CompanyPortabilityRepositoryManifestEntry { + key: string; + provider: string; + providerRepositoryId: string | null; + host: string; + owner: string; + name: string; + cloneUrl: string; + webUrl: string | null; + defaultBranch: string | null; + visibility: string; + state: string; + disconnected: boolean; + projectSlugs: string[]; + directAgentSlugs: string[]; +} + export interface CompanyPortabilityIssueRoutineTriggerManifestEntry { kind: string; label: string | null; @@ -269,6 +293,7 @@ export interface CompanyPortabilityManifest { agents: CompanyPortabilityAgentManifestEntry[]; skills: CompanyPortabilitySkillManifestEntry[]; projects: CompanyPortabilityProjectManifestEntry[]; + repositories: CompanyPortabilityRepositoryManifestEntry[]; issues: CompanyPortabilityIssueManifestEntry[]; envInputs: CompanyPortabilityEnvInput[]; } @@ -367,6 +392,16 @@ export interface CompanyPortabilityPreviewIssuePlan { reason: string | null; } +export interface CompanyPortabilityPreviewRepositoryPlan { + key: string; + action: "create" | "reuse" | "skip"; + existingRepositoryId: string | null; + disconnectedProvider: string | null; + unresolvedProjectSlugs: string[]; + unresolvedAgentSlugs: string[]; + reason: string | null; +} + export interface CompanyPortabilityPreviewResult { include: CompanyPortabilityInclude; targetCompanyId: string | null; @@ -377,6 +412,7 @@ export interface CompanyPortabilityPreviewResult { companyAction: "none" | "create" | "update"; agentPlans: CompanyPortabilityPreviewAgentPlan[]; projectPlans: CompanyPortabilityPreviewProjectPlan[]; + repositoryPlans: CompanyPortabilityPreviewRepositoryPlan[]; issuePlans: CompanyPortabilityPreviewIssuePlan[]; }; manifest: CompanyPortabilityManifest; @@ -426,6 +462,12 @@ export interface CompanyPortabilityImportResult { name: string; reason: string | null; }[]; + repositories: { + key: string; + id: string | null; + action: "created" | "reused" | "skipped"; + reason: string | null; + }[]; routines: { slug: string; id: string | null; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a109fd0be8f..38b05b28db3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -888,6 +888,7 @@ export type { CompanyPortabilitySkillManifestEntry, CompanyPortabilityProjectManifestEntry, CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityRepositoryManifestEntry, CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueCommentManifestEntry, @@ -907,6 +908,7 @@ export type { CompanyPortabilityPreviewRequest, CompanyPortabilityPreviewAgentPlan, CompanyPortabilityPreviewProjectPlan, + CompanyPortabilityPreviewRepositoryPlan, CompanyPortabilityPreviewIssuePlan, CompanyPortabilityPreviewResult, CompanyPortabilityAdapterOverride, diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index dcd1bdc4db5..28040a58c61 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -135,6 +135,23 @@ export const portabilityProjectManifestEntrySchema = z.object({ metadata: z.record(z.string(), z.unknown()).nullable(), }); +export const portabilityRepositoryManifestEntrySchema = z.object({ + key: z.string().min(1), + provider: z.string().min(1), + providerRepositoryId: z.string().nullable(), + host: z.string().min(1), + owner: z.string().min(1), + name: z.string().min(1), + cloneUrl: z.string().min(1), + webUrl: z.string().nullable(), + defaultBranch: z.string().nullable(), + visibility: z.string().min(1), + state: z.string().min(1), + disconnected: z.boolean(), + projectSlugs: z.array(z.string().min(1)).default([]), + directAgentSlugs: z.array(z.string().min(1)).default([]), +}); + export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({ kind: z.string().min(1), label: z.string().nullable(), @@ -254,6 +271,7 @@ export const portabilityManifestSchema = z.object({ agents: z.array(portabilityAgentManifestEntrySchema), skills: z.array(portabilitySkillManifestEntrySchema).default([]), projects: z.array(portabilityProjectManifestEntrySchema).default([]), + repositories: z.array(portabilityRepositoryManifestEntrySchema).default([]), issues: z.array(portabilityIssueManifestEntrySchema).default([]), envInputs: z.array(portabilityEnvInputSchema).default([]), }); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 046db00458f..3ca0eeef313 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -297,6 +297,7 @@ export { portabilitySidebarOrderSchema, portabilityAgentManifestEntrySchema, portabilitySkillManifestEntrySchema, + portabilityRepositoryManifestEntrySchema, portabilityManifestSchema, portabilitySourceSchema, portabilityTargetSchema, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 494087e7045..8436fa06818 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -35,6 +35,19 @@ const projectSvc = { listWorkspaces: vi.fn(), }; +const repositorySvc = { + list: vi.fn(), + createManual: vi.fn(), + archive: vi.fn(), +}; + +const repositoryAccessSvc = { + listProjectRepositories: vi.fn(), + listDirectGrants: vi.fn(), + attachProjectRepository: vi.fn(), + grantAgentRepository: vi.fn(), +}; + const issueSvc = { list: vi.fn(), listComments: vi.fn(), @@ -114,6 +127,14 @@ vi.mock("../services/projects.js", () => ({ projectService: () => projectSvc, })); +vi.mock("../services/repositories.js", () => ({ + repositoryService: () => repositorySvc, +})); + +vi.mock("../services/repository-access.js", () => ({ + repositoryAccessService: () => repositoryAccessSvc, +})); + vi.mock("../services/issues.js", () => ({ issueService: () => issueSvc, })); @@ -176,6 +197,16 @@ describe("company portability", () => { config, secretKeys: new Set(), })); + repositorySvc.list.mockResolvedValue([]); + repositorySvc.createManual.mockResolvedValue({ + repository: { id: "repository-imported" }, + created: true, + }); + repositorySvc.archive.mockResolvedValue({ id: "repository-imported", state: "archived" }); + repositoryAccessSvc.listProjectRepositories.mockResolvedValue([]); + repositoryAccessSvc.listDirectGrants.mockResolvedValue([]); + repositoryAccessSvc.attachProjectRepository.mockResolvedValue({ created: true }); + repositoryAccessSvc.grantAgentRepository.mockResolvedValue({ created: true }); issueSvc.listComments.mockResolvedValue([]); issueSvc.addComment.mockResolvedValue({ id: "comment-imported", @@ -1259,6 +1290,15 @@ describe("company portability", () => { defaultRef: "main", visibility: "default", })); + expect(projectSvc.createWorkspace).toHaveBeenCalledTimes(1); + expect(repositorySvc.createManual).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + cloneUrl: "https://github.com/paperclipai/paperclip.git", + })); + expect(repositoryAccessSvc.attachProjectRepository).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-imported", + projectId: "project-imported", + repositoryId: "repository-imported", + })); expect(projectSvc.update).toHaveBeenCalledWith("project-imported", expect.objectContaining({ executionWorkspacePolicy: expect.objectContaining({ enabled: true, @@ -3528,6 +3568,177 @@ describe("company portability", () => { expect(lastCreateInput.adapterConfig?.dangerouslyBypassApprovalsAndSandbox).toBeUndefined(); }); + it("round-trips repository relationships as disconnected secret-free metadata", async () => { + const portability = companyPortabilityService({} as any); + const projects = [ + { + id: "project-1", + name: "Launch", + urlKey: "launch", + description: null, + leadAgentId: null, + targetDate: null, + color: null, + icon: null, + status: "active", + executionWorkspacePolicy: null, + workspaces: [], + }, + { + id: "project-2", + name: "Docs", + urlKey: "docs", + description: null, + leadAgentId: null, + targetDate: null, + color: null, + icon: null, + status: "active", + executionWorkspacePolicy: null, + workspaces: [], + }, + ]; + const agent = { + id: "agent-1", + name: "ClaudeCoder", + status: "idle", + role: "engineer", + title: "Software Engineer", + icon: "code", + reportsTo: null, + capabilities: "Writes code", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + budgetMonthlyCents: 0, + permissions: {}, + metadata: null, + }; + const repository = { + id: "repository-1", + companyId: "company-1", + connectionId: "connection-1", + provider: "github", + providerRepositoryId: "12345", + host: "github.com", + owner: "paperclipai", + name: "paperclip", + cloneUrl: "git@github.com:paperclipai/paperclip.git", + webUrl: "https://github.com/paperclipai/paperclip", + defaultBranch: "master", + visibility: "private", + state: "active", + unavailableReason: null, + providerMetadata: { + installationToken: "never-export-this-secret", + privateKey: "never-export-this-key", + }, + archivedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + projectCount: 2, + directGrantCount: 1, + }; + + agentSvc.list.mockResolvedValue([agent]); + projectSvc.list.mockResolvedValue(projects); + repositorySvc.list.mockResolvedValue([repository]); + repositoryAccessSvc.listProjectRepositories.mockImplementation(async (_companyId, projectId) => ( + projects.some((project) => project.id === projectId) + ? [{ repository, link: { projectId, repositoryId: repository.id } }] + : [] + )); + repositoryAccessSvc.listDirectGrants.mockImplementation(async (_companyId, agentId) => ( + agentId === agent.id + ? [{ repository, grant: { agentId, repositoryId: repository.id } }] + : [] + )); + + const exported = await portability.exportBundle("company-1", { + include: { company: true, agents: true, projects: true, issues: false }, + }); + + expect(exported.manifest.repositories).toEqual([ + expect.objectContaining({ + provider: "github", + providerRepositoryId: "12345", + cloneUrl: "git@github.com:paperclipai/paperclip.git", + disconnected: true, + projectSlugs: ["docs", "launch"], + directAgentSlugs: ["claudecoder"], + }), + ]); + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).not.toContain("connection-1"); + expect(extension).not.toContain("never-export-this-secret"); + expect(extension).not.toContain("never-export-this-key"); + expect(extension).not.toContain("providerMetadata"); + + const unresolvedPreview = await portability.previewImport({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: false, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Portable Paperclip" }, + agents: "all", + collisionStrategy: "rename", + }); + expect(unresolvedPreview.plan.repositoryPlans).toEqual([ + expect.objectContaining({ + action: "create", + disconnectedProvider: "github", + unresolvedProjectSlugs: ["docs", "launch"], + unresolvedAgentSlugs: ["claudecoder"], + }), + ]); + + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: null, + issuePrefix: "PAP", + brandColor: "#5c5fff", + logoAssetId: null, + logoUrl: null, + requireBoardApprovalForNewAgents: false, + }); + agentSvc.list.mockResolvedValue([agent]); + projectSvc.list.mockResolvedValue(projects); + repositorySvc.list.mockResolvedValue([]); + + const imported = await portability.importBundle({ + source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: false, projects: true, issues: false }, + target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", + collisionStrategy: "skip", + }, "user-1"); + + expect(repositorySvc.createManual).toHaveBeenCalledWith("company-1", { + cloneUrl: "git@github.com:paperclipai/paperclip.git", + webUrl: "https://github.com/paperclipai/paperclip", + defaultBranch: "master", + visibility: "private", + }); + expect(repositoryAccessSvc.attachProjectRepository).toHaveBeenCalledTimes(2); + expect(repositoryAccessSvc.attachProjectRepository).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + projectId: "project-1", + repositoryId: "repository-imported", + })); + expect(repositoryAccessSvc.attachProjectRepository).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + projectId: "project-2", + repositoryId: "repository-imported", + })); + expect(repositoryAccessSvc.grantAgentRepository).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-1", + agentId: "agent-1", + repositoryId: "repository-imported", + })); + expect(imported.repositories).toEqual([ + expect.objectContaining({ key: expect.any(String), id: "repository-imported", action: "created" }), + ]); + }); + it("carries labels by name through export and import round-trip", async () => { const portability = companyPortabilityService({} as any); @@ -3579,8 +3790,8 @@ describe("company portability", () => { expect(extension).not.toContain("labelIds"); expect(extension).not.toContain("label-a"); // Fresh exports declare the current bundle shape end-to-end. - expect(extension).toContain("schemaVersion: 7"); - expect(exported.manifest.schemaVersion).toBe(7); + expect(extension).toContain("schemaVersion: 8"); + expect(exported.manifest.schemaVersion).toBe(8); expect(exported.manifest.labels).toEqual([ { name: "bug", color: "#ff0000" }, { name: "urgent", color: "#00ff00" }, @@ -4098,7 +4309,7 @@ describe("company portability", () => { }); const extension = asTextFile(exported.files[".paperclip.yaml"]); - expect(extension).toContain("schemaVersion: 7"); + expect(extension).toContain("schemaVersion: 8"); expect(extension).toContain('parent: "pap-1"'); expect(extension).toContain('createdAt: "2026-01-01T00:00:00.000Z"'); expect(exported.warnings).toContain( @@ -4875,7 +5086,7 @@ describe("company portability", () => { const portability = companyPortabilityService({} as any); await expect(portability.importBundle({ - source: { type: "inline", rootPath: "future-package", files: legacyPackageFiles(["schemaVersion: 8"]) }, + source: { type: "inline", rootPath: "future-package", files: legacyPackageFiles(["schemaVersion: 9"]) }, include: { company: true, agents: false, projects: false, issues: true }, target: { mode: "new_company", newCompanyName: "Future Import" }, agents: "all", diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index c86c2bbbefd..3931cf3d23b 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -30,6 +30,7 @@ import type { CompanyPortabilityPreviewResult, CompanyPortabilityProjectManifestEntry, CompanyPortabilityProjectWorkspaceManifestEntry, + CompanyPortabilityRepositoryManifestEntry, CompanyPortabilityIssueRoutineManifestEntry, CompanyPortabilityIssueRoutineTriggerManifestEntry, CompanyPortabilityIssueDocumentManifestEntry, @@ -50,6 +51,7 @@ import { ISSUE_STATUSES, PROJECT_ICON_NAMES, PROJECT_STATUSES, + REPOSITORY_VISIBILITIES, ROUTINE_CATCH_UP_POLICIES, ROUTINE_CONCURRENCY_POLICIES, ROUTINE_STATUSES, @@ -86,6 +88,9 @@ import { validateCron } from "./cron.js"; import { documentService } from "./documents.js"; import { issueService } from "./issues.js"; import { projectService } from "./projects.js"; +import { repositoryAccessService } from "./repository-access.js"; +import { repositoryService } from "./repositories.js"; +import { normalizeRepositoryLocator } from "./repository-normalization.js"; import { workProductService } from "./work-products.js"; import { routineService } from "./routines.js"; import { secretService } from "./secrets.js"; @@ -162,9 +167,9 @@ const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename" // The bundle shape this build reads and writes. Bundles began declaring // their schemaVersion in the .paperclip.yaml extension at 6; undeclared // bundles are read as 5, the last unstamped shape. 7 adds preserved task -// timestamps and parent links; 5/6 bundles still import, with those fields -// falling back to import-time defaults. -const BUNDLE_SCHEMA_VERSION = 7; +// timestamps and parent links. 8 adds portable repository metadata and +// relationships. Older bundles still import with safe defaults. +const BUNDLE_SCHEMA_VERSION = 8; const UNSTAMPED_BUNDLE_SCHEMA_VERSION = 5; const DEFAULT_IMPORTED_LABEL_COLOR = "#6366f1"; // Blob entries are content-addressed by sha256; the store itself is @@ -636,6 +641,7 @@ type PaperclipExtensionDoc = { company?: Record | null; agents?: Record> | null; projects?: Record> | null; + repositories?: Record> | null; tasks?: Record> | null; routines?: Record> | null; }; @@ -1288,6 +1294,38 @@ function normalizePortableProjectWorkspaceExtension( }; } +function normalizePortableRepositoryExtension( + repositoryKey: string, + value: unknown, +): CompanyPortabilityRepositoryManifestEntry | null { + if (!isPlainRecord(value)) return null; + const key = normalizeAgentUrlKey(repositoryKey) ?? repositoryKey.trim(); + const cloneUrl = asString(value.cloneUrl); + if (!key || !cloneUrl) return null; + try { + const normalized = normalizeRepositoryLocator(cloneUrl); + const provider = asString(value.provider) ?? "manual"; + return { + key, + provider, + providerRepositoryId: asString(value.providerRepositoryId), + host: asString(value.host) ?? normalized.host, + owner: asString(value.owner) ?? normalized.owner, + name: asString(value.name) ?? normalized.name, + cloneUrl: normalized.cloneUrl, + webUrl: asString(value.webUrl) ?? normalized.webUrl, + defaultBranch: asString(value.defaultBranch), + visibility: asString(value.visibility) ?? "unknown", + state: asString(value.state) ?? "active", + disconnected: asBoolean(value.disconnected) ?? provider !== "manual", + projectSlugs: normalizePortableSlugList(value.projects ?? value.projectSlugs), + directAgentSlugs: normalizePortableSlugList(value.directAgentGrants ?? value.directAgentSlugs), + }; + } catch { + return null; + } +} + function derivePortableProjectWorkspaceKey( workspace: NonNullable[number], usedKeys: Set, @@ -2022,6 +2060,21 @@ function filterPortableExtensionYaml( } } + const repositorySection = parsed.repositories; + if (isPlainRecord(repositorySection)) { + for (const repository of Object.values(repositorySection)) { + if (!isPlainRecord(repository)) continue; + const projectSlugs = normalizePortableSlugList(repository.projects) + .filter((slug) => selected.projects.has(slug)); + const directAgentSlugs = normalizePortableSlugList(repository.directAgentGrants) + .filter((slug) => selected.agents.has(slug)); + if (projectSlugs.length > 0) repository.projects = projectSlugs; + else delete repository.projects; + if (directAgentSlugs.length > 0) repository.directAgentGrants = directAgentSlugs; + else delete repository.directAgentGrants; + } + } + const companySection = parsed.company; if (isPlainRecord(companySection)) { const logoPath = asString(companySection.logoPath) ?? asString(companySection.logo); @@ -3044,6 +3097,7 @@ function buildManifestFromPackageFiles( const paperclipEmbeddedAssets = normalizePortableEmbeddedAssets(paperclipExtension.embeddedAssets); const paperclipAgents = isPlainRecord(paperclipExtension.agents) ? paperclipExtension.agents : {}; const paperclipProjects = isPlainRecord(paperclipExtension.projects) ? paperclipExtension.projects : {}; + const paperclipRepositories = isPlainRecord(paperclipExtension.repositories) ? paperclipExtension.repositories : {}; const paperclipTasks = isPlainRecord(paperclipExtension.tasks) ? paperclipExtension.tasks : {}; const paperclipRoutines = isPlainRecord(paperclipExtension.routines) ? paperclipExtension.routines : {}; const companyName = @@ -3130,6 +3184,7 @@ function buildManifestFromPackageFiles( agents: [], skills: [], projects: [], + repositories: [], issues: [], envInputs: [], }; @@ -3333,6 +3388,57 @@ function buildManifestFromPackageFiles( } } + for (const [repositoryKey, value] of Object.entries(paperclipRepositories)) { + const repository = normalizePortableRepositoryExtension(repositoryKey, value); + if (!repository) { + warnings.push(`Repository ${repositoryKey} was ignored because its portable metadata is invalid.`); + continue; + } + manifest.repositories.push(repository); + } + + // Compatibility for pre-repository bundles: a workspace repoUrl is enough + // to recover company repository metadata and the project hint. The importer + // still creates only the workspaces explicitly present in the legacy + // package; repository hints never synthesize execution workspaces. + if (manifest.repositories.length === 0) { + const byCloneUrl = new Map(); + const usedRepositoryKeys = new Set(); + for (const project of manifest.projects) { + for (const workspace of project.workspaces) { + if (!workspace.repoUrl) continue; + try { + const normalized = normalizeRepositoryLocator(workspace.repoUrl); + let repository = byCloneUrl.get(normalized.cloneUrl); + if (!repository) { + const baseKey = normalizeAgentUrlKey(`${normalized.host}-${normalized.owner}-${normalized.name}`) ?? "repository"; + repository = { + key: uniqueSlug(baseKey, usedRepositoryKeys), + provider: "manual", + providerRepositoryId: null, + host: normalized.host, + owner: normalized.owner, + name: normalized.name, + cloneUrl: normalized.cloneUrl, + webUrl: normalized.webUrl, + defaultBranch: workspace.defaultRef ?? workspace.repoRef, + visibility: "unknown", + state: "active", + disconnected: false, + projectSlugs: [], + directAgentSlugs: [], + }; + byCloneUrl.set(normalized.cloneUrl, repository); + manifest.repositories.push(repository); + } + if (!repository.projectSlugs.includes(project.slug)) repository.projectSlugs.push(project.slug); + } catch { + warnings.push(`Project ${project.slug} workspace ${workspace.key} has an invalid repoUrl and could not be mapped to a repository.`); + } + } + } + } + for (const taskPath of taskPaths) { const markdownRaw = readPortableTextFile(normalizedFiles, taskPath); if (typeof markdownRaw !== "string") { @@ -3484,6 +3590,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const secrets = secretService(db); const documentsSvc = documentService(db); const workProductsSvc = workProductService(db); + const repositoryCatalog = repositoryService(db); + const repositoryAccess = repositoryAccessService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const defaultSecretProvider = getConfiguredSecretProvider(); @@ -4062,6 +4170,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipAgentsOut: Record> = {}; const paperclipProjectsOut: Record> = {}; + const paperclipRepositoriesOut: Record> = {}; const paperclipTasksOut: Record> = {}; const unportableTaskWorkspaceRefs = new Map(); const paperclipRoutinesOut: Record> = {}; @@ -4239,6 +4348,59 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {}; } + if (include.company) { + const catalog = await repositoryCatalog.list(companyId, { includeArchived: true }); + const projectSlugsByRepositoryId = new Map(); + await Promise.all(selectedProjectRows.map(async (project) => { + const projectSlug = projectSlugById.get(project.id); + if (!projectSlug) return; + for (const entry of (await repositoryAccess.listProjectRepositories(companyId, project.id)) ?? []) { + const slugs = projectSlugsByRepositoryId.get(entry.repository.id) ?? []; + slugs.push(projectSlug); + projectSlugsByRepositoryId.set(entry.repository.id, slugs); + } + })); + const directAgentSlugsByRepositoryId = new Map(); + await Promise.all(agentRows.map(async (agent) => { + const agentSlug = idToSlug.get(agent.id); + if (!agentSlug) return; + for (const entry of (await repositoryAccess.listDirectGrants(companyId, agent.id)) ?? []) { + const slugs = directAgentSlugsByRepositoryId.get(entry.repository.id) ?? []; + slugs.push(agentSlug); + directAgentSlugsByRepositoryId.set(entry.repository.id, slugs); + } + })); + + const usedRepositoryKeys = new Set(); + for (const repository of catalog) { + let normalized; + try { + normalized = normalizeRepositoryLocator(repository.cloneUrl); + } catch { + warnings.push(`Repository ${repository.host}/${repository.owner}/${repository.name} was omitted because its clone URL is invalid or contains credentials.`); + continue; + } + const baseKey = normalizeAgentUrlKey(`${repository.host}-${repository.owner}-${repository.name}`) ?? "repository"; + const key = uniqueSlug(baseKey, usedRepositoryKeys); + const portableRepository = stripEmptyValues({ + provider: repository.provider, + providerRepositoryId: repository.providerRepositoryId, + host: normalized.host, + owner: normalized.owner, + name: normalized.name, + cloneUrl: normalized.cloneUrl, + webUrl: repository.webUrl, + defaultBranch: repository.defaultBranch, + visibility: repository.visibility, + state: repository.state, + disconnected: repository.provider !== "manual" ? true : undefined, + projects: (projectSlugsByRepositoryId.get(repository.id) ?? []).sort(), + directAgentGrants: (directAgentSlugsByRepositoryId.get(repository.id) ?? []).sort(), + }); + paperclipRepositoriesOut[key] = isPlainRecord(portableRepository) ? portableRepository : {}; + } + } + const referencedLabelIds = new Set(); for (const issue of selectedIssueRows) { for (const labelId of issue.labelIds ?? []) referencedLabelIds.add(labelId); @@ -4577,6 +4739,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipProjects = Object.fromEntries( Object.entries(paperclipProjectsOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), ); + const paperclipRepositories = Object.fromEntries( + Object.entries(paperclipRepositoriesOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), + ); const paperclipTasks = Object.fromEntries( Object.entries(paperclipTasksOut).filter(([, value]) => isPlainRecord(value) && Object.keys(value).length > 0), ); @@ -4603,6 +4768,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { embeddedAssets: embeddedAssetIndex.length > 0 ? embeddedAssetIndex : undefined, agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, projects: Object.keys(paperclipProjects).length > 0 ? paperclipProjects : undefined, + repositories: Object.keys(paperclipRepositories).length > 0 ? paperclipRepositories : undefined, tasks: Object.keys(paperclipTasks).length > 0 ? paperclipTasks : undefined, routines: Object.keys(paperclipRoutines).length > 0 ? paperclipRoutines : undefined, }, @@ -4869,9 +5035,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const existingAgentIds = new Set(); const existingSlugs = new Set(); const projectPlans: CompanyPortabilityPreviewResult["plan"]["projectPlans"] = []; + const repositoryPlans: CompanyPortabilityPreviewResult["plan"]["repositoryPlans"] = []; const issuePlans: CompanyPortabilityPreviewResult["plan"]["issuePlans"] = []; const existingProjectSlugToProject = new Map(); const existingProjectSlugs = new Set(); + const existingRepositoryByCloneUrl = new Map(); if (input.target.mode === "existing_company") { const existingAgents = await agents.list(input.target.companyId); @@ -4888,6 +5056,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } existingProjectSlugs.add(existing.urlKey); } + for (const existing of await repositoryCatalog.list(input.target.companyId, { includeArchived: true })) { + try { + existingRepositoryByCloneUrl.set(normalizeRepositoryLocator(existing.cloneUrl).cloneUrl, { id: existing.id }); + } catch { + // Invalid legacy rows cannot be safe collision targets. + } + } const existingSkills = await companySkills.listFull(input.target.companyId); const existingSkillKeys = new Set(existingSkills.map((skill) => skill.key)); @@ -5011,6 +5186,34 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } } + const agentPlanSlugs = new Set(agentPlans.map((entry) => entry.slug)); + const projectPlanSlugs = new Set(projectPlans.map((entry) => entry.slug)); + for (const repository of manifest.repositories) { + const existing = existingRepositoryByCloneUrl.get(repository.cloneUrl) ?? null; + const unresolvedProjectSlugs = repository.projectSlugs.filter( + (slug) => !projectPlanSlugs.has(slug) && !existingProjectSlugToProject.has(slug), + ); + const unresolvedAgentSlugs = repository.directAgentSlugs.filter( + (slug) => !agentPlanSlugs.has(slug) && !existingSlugToAgent.has(slug), + ); + const disconnectedProvider = repository.provider === "manual" ? null : repository.provider; + if (disconnectedProvider) { + warnings.push(`Repository ${repository.key} from provider ${repository.provider} will import as disconnected manual metadata; re-authorize that provider on the destination before requesting credentials.`); + } + if (unresolvedProjectSlugs.length > 0 || unresolvedAgentSlugs.length > 0) { + warnings.push(`Repository ${repository.key} has unresolved mappings: projects [${unresolvedProjectSlugs.join(", ") || "none"}], direct agents [${unresolvedAgentSlugs.join(", ") || "none"}].`); + } + repositoryPlans.push({ + key: repository.key, + action: existing ? "reuse" : "create", + existingRepositoryId: existing?.id ?? null, + disconnectedProvider, + unresolvedProjectSlugs, + unresolvedAgentSlugs, + reason: existing ? "A repository with the same normalized clone URL already exists." : null, + }); + } + // Apply user-specified name overrides (keyed by slug) if (input.nameOverrides) { for (const ap of agentPlans) { @@ -5072,6 +5275,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { : "none", agentPlans, projectPlans, + repositoryPlans, issuePlans, }, manifest, @@ -5356,6 +5560,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultAgents: CompanyPortabilityImportResult["agents"] = []; const resultProjects: CompanyPortabilityImportResult["projects"] = []; + const resultRepositories: CompanyPortabilityImportResult["repositories"] = []; const resultRoutines: CompanyPortabilityImportResult["routines"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); @@ -5732,6 +5937,60 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } } + for (const repositoryPlan of plan.preview.plan.repositoryPlans) { + const manifestRepository = sourceManifest.repositories.find((entry) => entry.key === repositoryPlan.key); + if (!manifestRepository) continue; + let repositoryId = repositoryPlan.existingRepositoryId; + let action: "created" | "reused" = repositoryId ? "reused" : "created"; + if (!repositoryId) { + try { + const created = await repositoryCatalog.createManual(targetCompany.id, { + cloneUrl: manifestRepository.cloneUrl, + webUrl: manifestRepository.webUrl, + defaultBranch: manifestRepository.defaultBranch, + visibility: REPOSITORY_VISIBILITIES.includes(manifestRepository.visibility as any) + ? manifestRepository.visibility as typeof REPOSITORY_VISIBILITIES[number] + : "unknown", + }); + repositoryId = created.repository.id; + action = created.created ? "created" : "reused"; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + warnings.push(`Repository ${manifestRepository.key} was skipped: ${reason}`); + resultRepositories.push({ key: manifestRepository.key, id: null, action: "skipped", reason }); + continue; + } + } + + for (const projectSlug of manifestRepository.projectSlugs) { + const projectId = importedSlugToProjectId.get(projectSlug) ?? existingProjectSlugToId.get(projectSlug) ?? null; + if (!projectId) continue; + await repositoryAccess.attachProjectRepository({ + companyId: targetCompany.id, + projectId, + repositoryId, + displayOrder: 0, + createdByAgentId: null, + createdByUserId: actorUserId ?? null, + }); + } + for (const agentSlug of manifestRepository.directAgentSlugs) { + const agentId = importedSlugToAgentId.get(agentSlug) ?? existingSlugToAgentId.get(agentSlug) ?? null; + if (!agentId) continue; + await repositoryAccess.grantAgentRepository({ + companyId: targetCompany.id, + agentId, + repositoryId, + grantedByAgentId: null, + grantedByUserId: actorUserId ?? null, + }); + } + if (manifestRepository.state === "archived") { + await repositoryCatalog.archive(targetCompany.id, repositoryId); + } + resultRepositories.push({ key: manifestRepository.key, id: repositoryId, action, reason: repositoryPlan.reason }); + } + if (include.issues) { const routines = routineService(db); @@ -6283,6 +6542,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { reason: result.reason, })), projects: resultProjects, + repositories: resultRepositories, routines: resultRoutines, envInputs: sourceManifest.envInputs ?? [], warnings, diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx index 3a70ea3a4d0..3edbaaeae7b 100644 --- a/ui/src/pages/CompanyImport.test.tsx +++ b/ui/src/pages/CompanyImport.test.tsx @@ -219,6 +219,7 @@ function buildImportResult(): CompanyPortabilityImportResult { agents: [{ slug: "coder", id: "agent-1", action: "created", name: "Coder", reason: null }], skills: [], projects: [], + repositories: [], routines: [{ slug: "weekly-report", id: "routine-1", action: "created", title: "Weekly Report", status: "paused" }], envInputs: [], warnings: [], diff --git a/ui/src/pages/TeamCatalog.test.tsx b/ui/src/pages/TeamCatalog.test.tsx index fc6e5d0f97a..fd4701185b9 100644 --- a/ui/src/pages/TeamCatalog.test.tsx +++ b/ui/src/pages/TeamCatalog.test.tsx @@ -168,6 +168,7 @@ function makePreview(): CatalogTeamImportPreviewResult { projectPlans: [ { slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null }, ], + repositoryPlans: [], issuePlans: [], }, manifest: { @@ -180,6 +181,7 @@ function makePreview(): CatalogTeamImportPreviewResult { agents: [], skills: [], projects: [], + repositories: [], issues: [], envInputs: [], }, diff --git a/ui/storybook/stories/team-catalog.stories.tsx b/ui/storybook/stories/team-catalog.stories.tsx index 7fd0d1e4c0d..6182f0eeaab 100644 --- a/ui/storybook/stories/team-catalog.stories.tsx +++ b/ui/storybook/stories/team-catalog.stories.tsx @@ -85,6 +85,7 @@ function makePreview(errors: string[] = []): CatalogTeamImportPreviewResult { projectPlans: [ { slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null }, ], + repositoryPlans: [], issuePlans: [ { slug: "kickoff", action: "skip", plannedTitle: "Kickoff", reason: "Starter tasks not selected" }, ], @@ -103,6 +104,7 @@ function makePreview(errors: string[] = []): CatalogTeamImportPreviewResult { ], skills: [], projects: [], + repositories: [], issues: [], envInputs: [], }, @@ -354,6 +356,7 @@ export const InstallSuccess: Story = { ], skills: [], projects: [{ slug: "launch", id: "p1", action: "created", name: "Launch", reason: null }], + repositories: [], routines: [], envInputs: [], warnings: [],