Skip to content

Commit 716e9cd

Browse files
Wr/agent version retrieve @W-21000506@ (#1675)
* fix: filter Bot/Version/GenAiPlannerBundle to specified version * chore: fix highest version * chore: maybe fixing * * chore: remove debugging * test: add UT with errors * test: fix UT * test: cleanups * chore: csb simplification * chore: simplifciaton * chore: fix all retrieve, but simpler * chore: dont override parseXml * chore: simplifciaton * chore: fix double filtering * chore: fix : issue
1 parent 6fc10d4 commit 716e9cd

9 files changed

Lines changed: 1077 additions & 29 deletions

File tree

src/client/metadataApiRetrieve.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ export class MetadataApiRetrieve extends MetadataTransfer<
203203

204204
({ componentSet, partialDeleteFileResponses } = await extract({
205205
zip: zipFileContents,
206-
options: this.options,
206+
options: {
207+
...this.options,
208+
botVersionFilters: this.components?.botVersionFilters ?? this.options.botVersionFilters,
209+
},
207210
logger: this.logger,
208211
mainComponents: this.components,
209212
}));

src/client/retrieveExtract.ts

Lines changed: 329 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,24 @@
1515
*/
1616
import * as path from 'node:path';
1717
import { Logger } from '@salesforce/core/logger';
18-
import { isString } from '@salesforce/ts-types';
18+
import { isString, JsonMap } from '@salesforce/ts-types';
1919
import fs from 'graceful-fs';
20-
import { ConvertOutputConfig } from '../convert/types';
21-
import { MetadataConverter } from '../convert/metadataConverter';
22-
import { ComponentSet } from '../collections/componentSet';
23-
import { ZipTreeContainer } from '../resolve/treeContainers';
20+
import { XMLBuilder } from 'fast-xml-parser';
21+
import { XML_DECL } from '../common';
22+
import { ConvertOutputConfig, MetadataConverter } from '../convert';
23+
import { ComponentSet } from '../collections';
24+
import { ZipTreeContainer } from '../resolve';
2425
import { SourceComponent, SourceComponentWithContent } from '../resolve/sourceComponent';
2526
import { fnJoin } from '../utils/path';
26-
import { ComponentStatus, FileResponse, FileResponseSuccess, PackageOption, PackageOptions } from './types';
27+
import { correctComments, handleSpecialEntities } from '../convert/streams';
28+
import {
29+
BotVersionFilter,
30+
ComponentStatus,
31+
FileResponse,
32+
FileResponseSuccess,
33+
PackageOption,
34+
PackageOptions,
35+
} from './types';
2736
import { MetadataApiRetrieveOptions } from './types';
2837

2938
export const extract = async ({
@@ -38,7 +47,7 @@ export const extract = async ({
3847
mainComponents?: ComponentSet;
3948
}): Promise<{ componentSet: ComponentSet; partialDeleteFileResponses: FileResponse[] }> => {
4049
const components: SourceComponent[] = [];
41-
const { merge, output, registry } = options;
50+
const { merge, output, registry, botVersionFilters } = options;
4251
const converter = new MetadataConverter(registry);
4352
const tree = await ZipTreeContainer.create(zip);
4453

@@ -64,13 +73,47 @@ export const extract = async ({
6473
type: 'directory',
6574
outputDirectory: pkg.outputDir,
6675
};
67-
const retrievedComponents = ComponentSet.fromSource({
76+
let retrievedComponents = ComponentSet.fromSource({
6877
fsPaths: [pkg.zipTreeLocation],
6978
registry,
7079
tree,
7180
})
7281
.getSourceComponents()
7382
.toArray();
83+
84+
// Filter BotVersion components and GenAiPlannerBundle components right after retrieval
85+
// This is needed when rootTypesWithDependencies is used, as it will retrieve all BotVersions
86+
// and GenAiPlannerBundles regardless of what's in the manifest.
87+
// Early exit: only process if there are Bot or GenAiPlannerBundle components
88+
const hasRelevantComponents = retrievedComponents.some(
89+
(comp) => comp.type.name === 'Bot' || comp.type.name === 'GenAiPlannerBundle'
90+
);
91+
92+
if (hasRelevantComponents) {
93+
// If botVersionFilters is undefined, default to 'highest' for all Bot components
94+
let filtersToUse = botVersionFilters && Array.isArray(botVersionFilters) ? botVersionFilters : undefined;
95+
if (!filtersToUse || filtersToUse.length === 0) {
96+
// No filters specified - default to 'highest' for all Bot components
97+
const allBotNames = new Set<string>();
98+
for (const comp of retrievedComponents) {
99+
if (comp.type.name === 'Bot') {
100+
allBotNames.add(comp.fullName);
101+
}
102+
}
103+
if (allBotNames.size > 0) {
104+
filtersToUse = Array.from(allBotNames).map((botName) => ({
105+
botName,
106+
versionFilter: 'highest',
107+
}));
108+
}
109+
}
110+
111+
if (filtersToUse && filtersToUse.length > 0) {
112+
// eslint-disable-next-line no-await-in-loop
113+
retrievedComponents = await filterAgentComponents(retrievedComponents, filtersToUse);
114+
}
115+
}
116+
74117
if (merge) {
75118
partialDeleteFileResponses.push(
76119
...handlePartialDeleteMerges({ retrievedComponents, tree, mainComponents, logger })
@@ -204,3 +247,281 @@ const deleteFilePath =
204247

205248
return fr;
206249
};
250+
251+
/**
252+
* Extracts version number from BotVersion fullName.
253+
* BotVersion fullName can be in formats like "v0", "v1", "v2" or "0", "1", "2"
254+
*
255+
* @internal Exported for testing purposes
256+
*/
257+
export function extractVersionNumber(fullName: string): number | null {
258+
// Match patterns like "v0", "v1", "v2" or just "0", "1", "2"
259+
const versionMatch = fullName.match(/^v?(\d+)$/);
260+
if (versionMatch) {
261+
return parseInt(versionMatch[1], 10);
262+
}
263+
return null;
264+
}
265+
266+
/**
267+
* Determines if a version number matches the filter criteria.
268+
* Shared logic for both Bot and GenAiPlannerBundle filtering.
269+
*
270+
* @param versionNum The version number to check
271+
* @param versionFilter The filter criteria ('all', 'highest', or specific number)
272+
* @param highestVersion The highest version number (required when filter is 'highest')
273+
* @returns true if the version should be kept, false otherwise
274+
* @internal Exported for testing purposes
275+
*/
276+
export function versionMatchesFilter(
277+
versionNum: number,
278+
versionFilter: 'all' | 'highest' | number,
279+
highestVersion?: number
280+
): boolean {
281+
if (versionFilter === 'all') {
282+
return true;
283+
}
284+
if (versionFilter === 'highest') {
285+
return highestVersion !== undefined && versionNum === highestVersion;
286+
}
287+
// Specific version number
288+
return versionNum === versionFilter;
289+
}
290+
291+
/**
292+
* Filters BotVersion entries from a Bot XML based on version filter criteria.
293+
*
294+
* @internal Exported for testing purposes
295+
*/
296+
export function filterBotVersionEntries(
297+
botVersions: Array<{ fullName?: string }>,
298+
versionFilter: 'all' | 'highest' | number
299+
): Array<{ fullName?: string }> {
300+
if (versionFilter === 'all') {
301+
return botVersions;
302+
}
303+
304+
// Extract version numbers and find highest if needed
305+
const versionsWithNumbers: Array<{ version: { fullName?: string }; versionNum: number; index: number }> = [];
306+
let highestVersion = -1;
307+
308+
for (let i = 0; i < botVersions.length; i++) {
309+
const version = botVersions[i];
310+
if (version?.fullName) {
311+
const versionNum = extractVersionNumber(version.fullName);
312+
if (versionNum !== null) {
313+
versionsWithNumbers.push({ version, versionNum, index: i });
314+
if (versionNum > highestVersion) {
315+
highestVersion = versionNum;
316+
}
317+
}
318+
}
319+
}
320+
321+
// Filter using shared logic
322+
return versionsWithNumbers
323+
.filter(({ versionNum }) => versionMatchesFilter(versionNum, versionFilter, highestVersion))
324+
.map(({ version }) => version);
325+
}
326+
327+
/**
328+
* Filters Bot and GenAiPlannerBundle components based on botVersionFilters.
329+
* For Bot components: modifies XML to filter BotVersion entries.
330+
* For GenAiPlannerBundle components: removes components that don't match filter criteria.
331+
*
332+
* @param components Retrieved source components
333+
* @param botVersionFilters Version filter rules for bots
334+
* @returns Components with filtered BotVersion entries and GenAiPlannerBundle components
335+
* @internal Exported for testing purposes
336+
*/
337+
// WeakMap to store normalized Bot XML structures for components that have been filtered
338+
// This allows us to return the normalized structure when parseXml is called
339+
const normalizedBotXmlMap = new WeakMap<SourceComponent, JsonMap>();
340+
341+
export async function filterAgentComponents(
342+
components: SourceComponent[],
343+
botVersionFilters: BotVersionFilter[]
344+
): Promise<SourceComponent[]> {
345+
const filterMap = new Map<string, BotVersionFilter>();
346+
for (const filter of botVersionFilters) {
347+
const botFilter: BotVersionFilter = filter;
348+
filterMap.set(botFilter.botName, botFilter);
349+
}
350+
351+
// Pre-compute which bots need 'highest' filtering
352+
const botsNeedingHighest = new Set<string>();
353+
for (const filter of botVersionFilters) {
354+
const botFilter: BotVersionFilter = filter;
355+
if (botFilter.versionFilter === 'highest') {
356+
botsNeedingHighest.add(botFilter.botName);
357+
}
358+
}
359+
360+
// Single pass: pre-compute highest versions, collect Bot components for async processing,
361+
// and collect GenAiPlannerBundle components for filtering
362+
const highestVersions = new Map<string, number>();
363+
const botComponents: SourceComponent[] = [];
364+
const genAiPlannerBundles: SourceComponent[] = [];
365+
const filtered: SourceComponent[] = [];
366+
367+
for (const comp of components) {
368+
if (comp.type.name === 'Bot') {
369+
// Collect Bot components for async processing
370+
botComponents.push(comp);
371+
// Include in result (will be modified in place)
372+
filtered.push(comp);
373+
} else if (comp.type.name === 'GenAiPlannerBundle') {
374+
// Collect for filtering after we know highest versions
375+
genAiPlannerBundles.push(comp);
376+
// Pre-compute highest versions
377+
const nameMatch = comp.fullName.match(/^(.+)_v(\d+)$/);
378+
if (nameMatch) {
379+
const botName = nameMatch[1];
380+
const versionNum = parseInt(nameMatch[2], 10);
381+
if (botsNeedingHighest.has(botName)) {
382+
const currentHighest = highestVersions.get(botName) ?? -1;
383+
if (versionNum > currentHighest) {
384+
highestVersions.set(botName, versionNum);
385+
}
386+
}
387+
}
388+
} else {
389+
// Not a Bot or GenAiPlannerBundle, keep it
390+
filtered.push(comp);
391+
}
392+
}
393+
394+
// Filter GenAiPlannerBundle components now that we have final highest versions
395+
for (const comp of genAiPlannerBundles) {
396+
const nameMatch = comp.fullName.match(/^(.+)_v(\d+)$/);
397+
if (nameMatch) {
398+
const botName = nameMatch[1];
399+
const versionNum = parseInt(nameMatch[2], 10);
400+
const matchingFilter = filterMap.get(botName);
401+
if (matchingFilter) {
402+
const highestVersion = matchingFilter.versionFilter === 'highest' ? highestVersions.get(botName) : undefined;
403+
const shouldKeep = versionMatchesFilter(versionNum, matchingFilter.versionFilter, highestVersion);
404+
if (shouldKeep) {
405+
filtered.push(comp);
406+
}
407+
} else {
408+
// No filter for this bot, keep all GenAiPlannerBundles
409+
filtered.push(comp);
410+
}
411+
} else {
412+
// Name doesn't match expected pattern, keep it
413+
filtered.push(comp);
414+
}
415+
}
416+
417+
// Process Bot components in parallel (XML parsing is async)
418+
const botPromises = botComponents.map(async (comp) => {
419+
const matchingFilter = filterMap.get(comp.fullName);
420+
if (matchingFilter && comp.xml) {
421+
try {
422+
// Parse the Bot XML to get BotVersion entries
423+
const botXml = await comp.parseXml<{
424+
Bot?: { botVersions?: Array<{ fullName?: string }> | { fullName?: string | string[] } };
425+
}>();
426+
const rawBotVersions = botXml.Bot?.botVersions;
427+
428+
// Normalize the structure: XMLParser may group multiple <fullName> elements into { fullName: ['v1', 'v2'] }
429+
// but we need [{ fullName: 'v1' }, { fullName: 'v2' }] format
430+
let normalizedBotVersions: Array<{ fullName?: string }> = [];
431+
if (rawBotVersions) {
432+
if (Array.isArray(rawBotVersions)) {
433+
// Already in the correct format
434+
normalizedBotVersions = rawBotVersions;
435+
} else if (typeof rawBotVersions === 'object' && 'fullName' in rawBotVersions) {
436+
// XMLParser grouped format: { fullName: ['v1', 'v2'] }
437+
const fullNameValue = rawBotVersions.fullName;
438+
if (Array.isArray(fullNameValue)) {
439+
normalizedBotVersions = fullNameValue.map((fn) => ({ fullName: fn }));
440+
} else if (typeof fullNameValue === 'string') {
441+
normalizedBotVersions = [{ fullName: fullNameValue }];
442+
}
443+
}
444+
}
445+
446+
if (normalizedBotVersions.length > 0) {
447+
const filteredVersions = filterBotVersionEntries(normalizedBotVersions, matchingFilter.versionFilter);
448+
449+
// Extract fullNames and reconstruct the object in the correct format
450+
const fullNames = filteredVersions.map((v) => v.fullName).filter((f): f is string => !!f);
451+
452+
// Reconstruct Bot XML with filtered versions
453+
// We manually construct the botVersions section to avoid XMLParser grouping
454+
if (botXml.Bot) {
455+
// Update the component's cached XML content
456+
// Build XML string using XMLBuilder, but manually construct botVersions section
457+
// to avoid XMLParser grouping multiple <fullName> elements
458+
const builder = new XMLBuilder({
459+
format: true,
460+
indentBy: ' ',
461+
ignoreAttributes: false,
462+
});
463+
464+
// Build XML with botVersions structure
465+
// XMLBuilder creates multiple <fullName> elements, XMLParser groups them into { fullName: ['v1', 'v2'] }
466+
// The transformer expects [{ fullName: 'v1' }, { fullName: 'v2' }]
467+
// We need to normalize this when the XML is parsed, but we can't modify the transformer
468+
// So we store a normalized version in pathContentMap and intercept parseXml calls
469+
const botWithVersions = {
470+
Bot: {
471+
...botXml.Bot,
472+
botVersions:
473+
fullNames.length > 0 ? { fullName: fullNames.length === 1 ? fullNames[0] : fullNames } : undefined,
474+
},
475+
};
476+
const builtXml = String(builder.build(botWithVersions));
477+
const xmlContent = correctComments(XML_DECL.concat(handleSpecialEntities(builtXml)));
478+
479+
// Store normalized structure for later parsing
480+
// We'll intercept parseXml to return the normalized structure
481+
const normalizedBotVersionsForXml = fullNames.map((fn) => ({ fullName: fn }));
482+
const normalizedBotXml = {
483+
...botXml,
484+
Bot: {
485+
...botXml.Bot,
486+
botVersions: normalizedBotVersionsForXml,
487+
},
488+
};
489+
490+
// Store both the XML content and the normalized structure
491+
if (comp.pathContentMap && comp.xml) {
492+
comp.pathContentMap.set(comp.xml, xmlContent);
493+
// Store normalized structure in WeakMap for this component
494+
normalizedBotXmlMap.set(comp, normalizedBotXml as JsonMap);
495+
496+
// Intercept parseXml to return normalized structure for Bot components
497+
const originalParseXml = comp.parseXml.bind(comp);
498+
comp.parseXml = async <T extends JsonMap>(xmlFilePath?: string): Promise<T> => {
499+
const xml = xmlFilePath ?? comp.xml;
500+
if (xml === comp.xml) {
501+
const normalized = normalizedBotXmlMap.get(comp);
502+
if (normalized) {
503+
// Return normalized structure for this Bot component
504+
return normalized as T;
505+
}
506+
}
507+
// For other cases, use original parseXml
508+
return originalParseXml<T>(xmlFilePath);
509+
};
510+
}
511+
512+
if (comp.pathContentMap && comp.xml) {
513+
comp.pathContentMap.set(comp.xml, xmlContent);
514+
}
515+
}
516+
}
517+
} catch (error) {
518+
// Continue with unfiltered component if there's an error
519+
}
520+
}
521+
return comp;
522+
});
523+
524+
await Promise.all(botPromises);
525+
526+
return filtered;
527+
}

src/client/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,4 +400,13 @@ export type RetrieveVersionData = {
400400
apiVersion: string;
401401
manifestVersion: string;
402402
};
403-
export type MetadataApiRetrieveOptions = MetadataTransferOptions & RetrieveOptions & { registry?: RegistryAccess };
403+
export type BotVersionFilter = {
404+
botName: string;
405+
versionFilter: 'all' | 'highest' | number;
406+
};
407+
408+
export type MetadataApiRetrieveOptions = MetadataTransferOptions &
409+
RetrieveOptions & {
410+
registry?: RegistryAccess;
411+
botVersionFilters?: BotVersionFilter[];
412+
};

0 commit comments

Comments
 (0)