mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -705,7 +705,60 @@ function stripPortableProjectExecutionWorkspaceRefs(policy: Record<string, unkno
|
||||
return isPlainRecord(cleaned) ? cleaned : null;
|
||||
}
|
||||
|
||||
function buildPortableProjectWorkspaces(
|
||||
async function readGitOutput(cwd: string, args: string[]) {
|
||||
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], { cwd });
|
||||
const trimmed = stdout.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
async function inferPortableWorkspaceGitMetadata(workspace: NonNullable<ProjectLike["workspaces"]>[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<string, Record<string, unknown>> = {};
|
||||
const manifestWorkspaces: CompanyPortabilityProjectWorkspaceManifestEntry[] = [];
|
||||
const workspaceKeyById = new Map<string, string>();
|
||||
const workspaceKeyBySignature = new Map<string, string>();
|
||||
const manifestWorkspaceByKey = new Map<string, CompanyPortabilityProjectWorkspaceManifestEntry>();
|
||||
const usedKeys = new Set<string>();
|
||||
|
||||
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<string, Record<string, unknown>> = {};
|
||||
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
||||
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
|
||||
const unportableTaskWorkspaceRefs = new Map<string, { workspaceId: string; taskSlugs: string[] }>();
|
||||
const paperclipRoutinesOut: Record<string, Record<string, unknown>> = {};
|
||||
|
||||
const skillByReference = new Map<string, typeof companySkillRows[number]>();
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user