Fix company import file selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5d6dadda83
commit
cf8bfe8d8e
5 changed files with 172 additions and 12 deletions
|
|
@ -146,6 +146,7 @@ export interface CompanyPortabilityPreviewRequest {
|
||||||
agents?: CompanyPortabilityAgentSelection;
|
agents?: CompanyPortabilityAgentSelection;
|
||||||
collisionStrategy?: CompanyPortabilityCollisionStrategy;
|
collisionStrategy?: CompanyPortabilityCollisionStrategy;
|
||||||
nameOverrides?: Record<string, string>;
|
nameOverrides?: Record<string, string>;
|
||||||
|
selectedFiles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityPreviewAgentPlan {
|
export interface CompanyPortabilityPreviewAgentPlan {
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ export const companyPortabilityPreviewSchema = z.object({
|
||||||
agents: portabilityAgentSelectionSchema.optional(),
|
agents: portabilityAgentSelectionSchema.optional(),
|
||||||
collisionStrategy: portabilityCollisionStrategySchema.optional(),
|
collisionStrategy: portabilityCollisionStrategySchema.optional(),
|
||||||
nameOverrides: z.record(z.string().min(1), z.string().min(1)).optional(),
|
nameOverrides: z.record(z.string().min(1), z.string().min(1)).optional(),
|
||||||
|
selectedFiles: z.array(z.string().min(1)).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
|
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
|
||||||
|
|
|
||||||
|
|
@ -394,4 +394,81 @@ describe("company portability", () => {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports only selected files and leaves unchecked company metadata alone", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: false,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
companySvc.getById.mockResolvedValue({
|
||||||
|
id: "company-1",
|
||||||
|
name: "Paperclip",
|
||||||
|
description: "Existing company",
|
||||||
|
brandColor: "#123456",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
agentSvc.create.mockResolvedValue({
|
||||||
|
id: "agent-cmo",
|
||||||
|
name: "CMO",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await portability.importBundle({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: exported.rootPath,
|
||||||
|
files: exported.files,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
},
|
||||||
|
selectedFiles: ["agents/cmo/AGENTS.md"],
|
||||||
|
target: {
|
||||||
|
mode: "existing_company",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(companySvc.update).not.toHaveBeenCalled();
|
||||||
|
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
"COMPANY.md": expect.any(String),
|
||||||
|
"agents/cmo/AGENTS.md": expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith(
|
||||||
|
"company-1",
|
||||||
|
expect.not.objectContaining({
|
||||||
|
"agents/claudecoder/AGENTS.md": expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(agentSvc.create).toHaveBeenCalledTimes(1);
|
||||||
|
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||||
|
name: "CMO",
|
||||||
|
}));
|
||||||
|
expect(result.company.action).toBe("unchanged");
|
||||||
|
expect(result.agents).toEqual([
|
||||||
|
{
|
||||||
|
slug: "cmo",
|
||||||
|
id: "agent-cmo",
|
||||||
|
action: "created",
|
||||||
|
name: "CMO",
|
||||||
|
reason: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -612,6 +612,81 @@ function buildMarkdown(frontmatter: Record<string, unknown>, body: string) {
|
||||||
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedFiles(selectedFiles?: string[]) {
|
||||||
|
if (!selectedFiles) return null;
|
||||||
|
return new Set(
|
||||||
|
selectedFiles
|
||||||
|
.map((entry) => normalizePortablePath(entry))
|
||||||
|
.filter((entry) => entry.length > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterCompanyMarkdownIncludes(
|
||||||
|
companyPath: string,
|
||||||
|
markdown: string,
|
||||||
|
selectedFiles: Set<string>,
|
||||||
|
) {
|
||||||
|
const parsed = parseFrontmatterMarkdown(markdown);
|
||||||
|
const includeEntries = readIncludeEntries(parsed.frontmatter);
|
||||||
|
const filteredIncludes = includeEntries.filter((entry) =>
|
||||||
|
selectedFiles.has(resolvePortablePath(companyPath, entry.path)),
|
||||||
|
);
|
||||||
|
const nextFrontmatter: Record<string, unknown> = { ...parsed.frontmatter };
|
||||||
|
if (filteredIncludes.length > 0) {
|
||||||
|
nextFrontmatter.includes = filteredIncludes.map((entry) => entry.path);
|
||||||
|
} else {
|
||||||
|
delete nextFrontmatter.includes;
|
||||||
|
}
|
||||||
|
return buildMarkdown(nextFrontmatter, parsed.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: string[]): ResolvedSource {
|
||||||
|
const normalizedSelection = normalizeSelectedFiles(selectedFiles);
|
||||||
|
if (!normalizedSelection) return source;
|
||||||
|
|
||||||
|
const companyPath = source.manifest.company
|
||||||
|
? ensureMarkdownPath(source.manifest.company.path)
|
||||||
|
: Object.keys(source.files).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md") ?? null;
|
||||||
|
if (!companyPath) {
|
||||||
|
throw unprocessable("Company package is missing COMPANY.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyMarkdown = source.files[companyPath];
|
||||||
|
if (typeof companyMarkdown !== "string") {
|
||||||
|
throw unprocessable("Company package is missing COMPANY.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveFiles: Record<string, string> = {};
|
||||||
|
for (const [filePath, content] of Object.entries(source.files)) {
|
||||||
|
const normalizedPath = normalizePortablePath(filePath);
|
||||||
|
if (!normalizedSelection.has(normalizedPath)) continue;
|
||||||
|
effectiveFiles[normalizedPath] = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveFiles[companyPath] = filterCompanyMarkdownIncludes(
|
||||||
|
companyPath,
|
||||||
|
companyMarkdown,
|
||||||
|
normalizedSelection,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = buildManifestFromPackageFiles(effectiveFiles, {
|
||||||
|
sourceLabel: source.manifest.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!normalizedSelection.has(companyPath)) {
|
||||||
|
filtered.manifest.company = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.manifest.includes = {
|
||||||
|
company: filtered.manifest.company !== null,
|
||||||
|
agents: filtered.manifest.agents.length > 0,
|
||||||
|
projects: filtered.manifest.projects.length > 0,
|
||||||
|
issues: filtered.manifest.issues.length > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveBundledSkillsCommit() {
|
async function resolveBundledSkillsCommit() {
|
||||||
if (!bundledSkillsCommitPromise) {
|
if (!bundledSkillsCommitPromise) {
|
||||||
bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], {
|
bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], {
|
||||||
|
|
@ -1796,9 +1871,15 @@ export function companyPortabilityService(db: Db) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPreview(input: CompanyPortabilityPreview): Promise<ImportPlanInternal> {
|
async function buildPreview(input: CompanyPortabilityPreview): Promise<ImportPlanInternal> {
|
||||||
const include = normalizeInclude(input.include);
|
const requestedInclude = normalizeInclude(input.include);
|
||||||
const source = await resolveSource(input.source);
|
const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles);
|
||||||
const manifest = source.manifest;
|
const manifest = source.manifest;
|
||||||
|
const include: CompanyPortabilityInclude = {
|
||||||
|
company: requestedInclude.company && manifest.company !== null,
|
||||||
|
agents: requestedInclude.agents && manifest.agents.length > 0,
|
||||||
|
projects: requestedInclude.projects && manifest.projects.length > 0,
|
||||||
|
issues: requestedInclude.issues && manifest.issues.length > 0,
|
||||||
|
};
|
||||||
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
|
||||||
const warnings = [...source.warnings];
|
const warnings = [...source.warnings];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,11 @@ export function CompanyImport() {
|
||||||
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSelectedFiles(): string[] | undefined {
|
||||||
|
const selected = Array.from(checkedFiles).sort();
|
||||||
|
return selected.length > 0 ? selected : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Apply mutation
|
// Apply mutation
|
||||||
const importMutation = useMutation({
|
const importMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
|
|
@ -614,25 +619,20 @@ export function CompanyImport() {
|
||||||
: { mode: "existing_company", companyId: selectedCompanyId! },
|
: { mode: "existing_company", companyId: selectedCompanyId! },
|
||||||
collisionStrategy: "rename",
|
collisionStrategy: "rename",
|
||||||
nameOverrides: buildFinalNameOverrides(),
|
nameOverrides: buildFinalNameOverrides(),
|
||||||
|
selectedFiles: buildSelectedFiles(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||||
if (result.company.action === "created") {
|
const importedCompany = await companiesApi.get(result.company.id);
|
||||||
setSelectedCompanyId(result.company.id);
|
setSelectedCompanyId(importedCompany.id);
|
||||||
}
|
|
||||||
pushToast({
|
pushToast({
|
||||||
tone: "success",
|
tone: "success",
|
||||||
title: "Import complete",
|
title: "Import complete",
|
||||||
body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`,
|
body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`,
|
||||||
});
|
});
|
||||||
// Reset
|
// Force a fresh dashboard load so newly imported agents are immediately visible.
|
||||||
setImportPreview(null);
|
window.location.assign(`/${importedCompany.issuePrefix}/dashboard`);
|
||||||
setLocalPackage(null);
|
|
||||||
setImportUrl("");
|
|
||||||
setNameOverrides({});
|
|
||||||
setSkippedSlugs(new Set());
|
|
||||||
setConfirmedSlugs(new Set());
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
pushToast({
|
pushToast({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue