From c41dd2e39361c554f2f12692f20d100dd9b6640e Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 12:35:08 -0500 Subject: [PATCH] Reduce portability warning fan-out Infer portable repo metadata from local git workspaces when repoUrl is missing, and collapse repeated task workspace export warnings into a single summary per missing workspace. Co-Authored-By: Paperclip --- .../src/__tests__/company-portability.test.ts | 192 ++++++++++++++++++ server/src/services/company-portability.ts | 117 ++++++++++- 2 files changed, 299 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 5cf46fa20..fb9a4497d 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from "node:child_process"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; @@ -859,6 +863,194 @@ describe("company portability", () => { })); }); + it("infers portable git metadata from a local checkout without task warning fan-out", async () => { + const portability = companyPortabilityService({} as any); + const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-")); + execFileSync("git", ["init"], { cwd: repoDir, stdio: "ignore" }); + execFileSync("git", ["checkout", "-b", "main"], { cwd: repoDir, stdio: "ignore" }); + execFileSync("git", ["remote", "add", "origin", "https://github.com/paperclipai/paperclip.git"], { + cwd: repoDir, + stdio: "ignore", + }); + + projectSvc.list.mockResolvedValue([ + { + id: "project-1", + name: "Paperclip App", + urlKey: "paperclip-app", + description: "Ship it", + leadAgentId: null, + targetDate: null, + color: null, + status: "planned", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + defaultProjectWorkspaceId: "workspace-1", + }, + workspaces: [ + { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "paperclip", + sourceType: "local_path", + cwd: repoDir, + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + isPrimary: true, + createdAt: new Date("2026-03-01T00:00:00Z"), + updatedAt: new Date("2026-03-01T00:00:00Z"), + }, + ], + archivedAt: null, + }, + ]); + issueSvc.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-1", + title: "Task one", + description: "Task body", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + assigneeAgentId: null, + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: false, + agents: false, + projects: true, + issues: true, + }, + }); + + const extension = asTextFile(exported.files[".paperclip.yaml"]); + expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"'); + expect(extension).toContain('projectWorkspaceKey: "paperclip"'); + expect(exported.warnings).not.toContainEqual(expect.stringContaining("does not have a portable repoUrl")); + expect(exported.warnings).not.toContainEqual(expect.stringContaining("reference workspace workspace-1")); + }); + + it("collapses repeated task workspace warnings into one summary per missing workspace", async () => { + const portability = companyPortabilityService({} as any); + + projectSvc.list.mockResolvedValue([ + { + id: "project-1", + name: "Launch", + urlKey: "launch", + description: "Ship it", + leadAgentId: null, + targetDate: null, + color: null, + status: "planned", + executionWorkspacePolicy: null, + workspaces: [ + { + id: "workspace-1", + companyId: "company-1", + projectId: "project-1", + name: "Local Scratch", + sourceType: "local_path", + cwd: "/tmp/local-only", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + isPrimary: true, + createdAt: new Date("2026-03-01T00:00:00Z"), + updatedAt: new Date("2026-03-01T00:00:00Z"), + }, + ], + archivedAt: null, + }, + ]); + issueSvc.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-1", + title: "Task one", + description: null, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + assigneeAgentId: null, + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + }, + { + id: "issue-2", + identifier: "PAP-2", + title: "Task two", + description: null, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + assigneeAgentId: null, + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + }, + { + id: "issue-3", + identifier: "PAP-3", + title: "Task three", + description: null, + projectId: "project-1", + projectWorkspaceId: "workspace-1", + assigneeAgentId: null, + status: "todo", + priority: "medium", + labelIds: [], + billingCode: null, + executionWorkspaceSettings: null, + assigneeAdapterOverrides: null, + }, + ]); + + const exported = await portability.exportBundle("company-1", { + include: { + company: false, + agents: false, + projects: true, + issues: true, + }, + }); + + expect(exported.warnings).toContain("Project launch workspace Local Scratch was omitted from export because it does not have a portable repoUrl."); + expect(exported.warnings).toContain("Tasks pap-1, pap-2, pap-3 reference workspace workspace-1, but that workspace could not be exported portably."); + expect(exported.warnings.filter((warning) => warning.includes("workspace reference workspace-1 was omitted from export"))).toHaveLength(0); + expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1); + }); + it("reads env inputs back from .paperclip.yaml during preview import", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 19690dc39..21beeb2f4 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -705,7 +705,60 @@ function stripPortableProjectExecutionWorkspaceRefs(policy: Record 0 ? trimmed : null; +} + +async function inferPortableWorkspaceGitMetadata(workspace: NonNullable[number]) { + const cwd = asString(workspace.cwd); + if (!cwd) { + return { + repoUrl: null, + repoRef: null, + defaultRef: null, + }; + } + + let repoUrl: string | null = null; + try { + repoUrl = await readGitOutput(cwd, ["remote", "get-url", "origin"]); + } catch { + try { + const firstRemote = await readGitOutput(cwd, ["remote"]); + const remoteName = firstRemote?.split("\n").map((entry) => entry.trim()).find(Boolean) ?? null; + if (remoteName) { + repoUrl = await readGitOutput(cwd, ["remote", "get-url", remoteName]); + } + } catch { + repoUrl = null; + } + } + + let repoRef: string | null = null; + try { + repoRef = await readGitOutput(cwd, ["branch", "--show-current"]); + } catch { + repoRef = null; + } + + let defaultRef: string | null = null; + try { + const remoteHead = await readGitOutput(cwd, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]); + defaultRef = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead; + } catch { + defaultRef = null; + } + + return { + repoUrl, + repoRef, + defaultRef, + }; +} + +async function buildPortableProjectWorkspaces( projectSlug: string, workspaces: ProjectLike["workspaces"] | undefined, warnings: string[], @@ -713,17 +766,43 @@ function buildPortableProjectWorkspaces( const exportedWorkspaces: Record> = {}; const manifestWorkspaces: CompanyPortabilityProjectWorkspaceManifestEntry[] = []; const workspaceKeyById = new Map(); + const workspaceKeyBySignature = new Map(); + const manifestWorkspaceByKey = new Map(); const usedKeys = new Set(); for (const workspace of workspaces ?? []) { - const repoUrl = asString(workspace.repoUrl); + const inferredGitMetadata = + !asString(workspace.repoUrl) || !asString(workspace.repoRef) || !asString(workspace.defaultRef) + ? await inferPortableWorkspaceGitMetadata(workspace) + : { repoUrl: null, repoRef: null, defaultRef: null }; + const repoUrl = asString(workspace.repoUrl) ?? inferredGitMetadata.repoUrl; if (!repoUrl) { warnings.push(`Project ${projectSlug} workspace ${workspace.name} was omitted from export because it does not have a portable repoUrl.`); continue; } + const repoRef = asString(workspace.repoRef) ?? inferredGitMetadata.repoRef; + const defaultRef = asString(workspace.defaultRef) ?? inferredGitMetadata.defaultRef ?? repoRef; + const workspaceSignature = JSON.stringify({ + name: workspace.name, + repoUrl, + repoRef, + defaultRef, + }); + const existingWorkspaceKey = workspaceKeyBySignature.get(workspaceSignature); + if (existingWorkspaceKey) { + workspaceKeyById.set(workspace.id, existingWorkspaceKey); + const existingManifestWorkspace = manifestWorkspaceByKey.get(existingWorkspaceKey); + if (existingManifestWorkspace && workspace.isPrimary) { + existingManifestWorkspace.isPrimary = true; + const existingExtensionWorkspace = exportedWorkspaces[existingWorkspaceKey]; + if (isPlainRecord(existingExtensionWorkspace)) existingExtensionWorkspace.isPrimary = true; + } + continue; + } const workspaceKey = derivePortableProjectWorkspaceKey(workspace, usedKeys); workspaceKeyById.set(workspace.id, workspaceKey); + workspaceKeyBySignature.set(workspaceSignature, workspaceKey); let setupCommand = asString(workspace.setupCommand); if (setupCommand && containsAbsolutePathFragment(setupCommand)) { @@ -748,8 +827,8 @@ function buildPortableProjectWorkspaces( name: workspace.name, sourceType: workspace.sourceType, repoUrl, - repoRef: asString(workspace.repoRef), - defaultRef: asString(workspace.defaultRef), + repoRef, + defaultRef, visibility: asString(workspace.visibility), setupCommand, cleanupCommand, @@ -759,19 +838,21 @@ function buildPortableProjectWorkspaces( if (!isPlainRecord(portableWorkspace)) continue; exportedWorkspaces[workspaceKey] = portableWorkspace; - manifestWorkspaces.push({ + const manifestWorkspace = { key: workspaceKey, name: workspace.name, sourceType: asString(workspace.sourceType), repoUrl, - repoRef: asString(workspace.repoRef), - defaultRef: asString(workspace.defaultRef), + repoRef, + defaultRef, visibility: asString(workspace.visibility), setupCommand, cleanupCommand, metadata, isPrimary: workspace.isPrimary, - }); + }; + manifestWorkspaces.push(manifestWorkspace); + manifestWorkspaceByKey.set(workspaceKey, manifestWorkspace); } return { @@ -2838,6 +2919,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const paperclipAgentsOut: Record> = {}; const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; + const unportableTaskWorkspaceRefs = new Map(); const paperclipRoutinesOut: Record> = {}; const skillByReference = new Map(); @@ -2971,7 +3053,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const project of selectedProjectRows) { const slug = projectSlugById.get(project.id)!; const projectPath = `projects/${slug}/PROJECT.md`; - const portableWorkspaces = buildPortableProjectWorkspaces(slug, project.workspaces, warnings); + const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings); projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById); files[projectPath] = buildMarkdown( { @@ -3007,7 +3089,16 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ? projectWorkspaceKeyByProjectId.get(issue.projectId)?.get(issue.projectWorkspaceId) ?? null : null; if (issue.projectWorkspaceId && !projectWorkspaceKey) { - warnings.push(`Task ${taskSlug} workspace reference ${issue.projectWorkspaceId} was omitted from export because that workspace is not portable.`); + const aggregateKey = `${issue.projectId ?? "no-project"}:${issue.projectWorkspaceId}`; + const existing = unportableTaskWorkspaceRefs.get(aggregateKey); + if (existing) { + existing.taskSlugs.push(taskSlug); + } else { + unportableTaskWorkspaceRefs.set(aggregateKey, { + workspaceId: issue.projectWorkspaceId, + taskSlugs: [taskSlug], + }); + } } files[taskPath] = buildMarkdown( { @@ -3030,6 +3121,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {}; } + for (const { workspaceId, taskSlugs } of unportableTaskWorkspaceRefs.values()) { + const preview = taskSlugs.slice(0, 4).join(", "); + const remainder = taskSlugs.length > 4 ? ` and ${taskSlugs.length - 4} more` : ""; + warnings.push(`Tasks ${preview}${remainder} reference workspace ${workspaceId}, but that workspace could not be exported portably.`); + } + for (const routine of selectedRoutineRows) { const taskSlug = taskSlugByRoutineId.get(routine.id)!; const projectSlug = projectSlugById.get(routine.projectId) ?? null;