diff --git a/packages/core/src/codewhisperer/commands/startTransformByQ.ts b/packages/core/src/codewhisperer/commands/startTransformByQ.ts index eb31839686d..88171dd5b83 100644 --- a/packages/core/src/codewhisperer/commands/startTransformByQ.ts +++ b/packages/core/src/codewhisperer/commands/startTransformByQ.ts @@ -27,7 +27,6 @@ import { downloadHilResultArchive, findDownloadArtifactStep, getArtifactsFromProgressUpdate, - getTransformationPlan, getTransformationSteps, pollTransformationJob, resumeTransformationJob, @@ -554,28 +553,6 @@ export async function pollTransformationStatusUntilPlanReady(jobId: string, prof // for now, no plan shown with SQL conversions. later, we may add one return } - let plan = undefined - try { - plan = await getTransformationPlan(jobId, profile) - } catch (error) { - // means API call failed - getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToCompleteJobNotification}`, error) - transformByQState.setJobFailureErrorNotification( - `${CodeWhispererConstants.failedToGetPlanNotification} ${(error as Error).message}` - ) - transformByQState.setJobFailureErrorChatMessage( - `${CodeWhispererConstants.failedToGetPlanChatMessage} ${(error as Error).message}` - ) - throw new Error('Get plan failed') - } - - if (plan !== undefined) { - const planFilePath = path.join(transformByQState.getProjectPath(), 'transformation-plan.md') - fs.writeFileSync(planFilePath, plan) - await vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(planFilePath)) - transformByQState.setPlanFilePath(planFilePath) - await setContext('gumby.isPlanAvailable', true) - } jobPlanProgress['generatePlan'] = StepProgress.Succeeded throwIfCancelled() } diff --git a/packages/core/src/codewhisperer/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index 28072249371..9c48838afe3 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -686,6 +686,7 @@ export class ZipManifest { version: string = '1.0' hilCapabilities: string[] = ['HIL_1pDependency_VersionUpgrade'] // TO-DO: add 'CLIENT_SIDE_BUILD' here when releasing + // TO-DO: add something like AGENTIC_PLAN_V1 here when BE allowlists everyone transformCapabilities: string[] = ['EXPLAINABILITY_V1'] customBuildCommand: string = 'clean test' requestedConversions?: { diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts index 476123f2d6d..9214fb23572 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts @@ -51,6 +51,7 @@ import { encodeHTML } from '../../../shared/utilities/textUtilities' import { convertToTimeString } from '../../../shared/datetime' import { getAuthType } from '../../../auth/utils' import { UserWrittenCodeTracker } from '../../tracker/userWrittenCodeTracker' +import { setContext } from '../../../shared/vscode/setContext' import { AuthUtil } from '../../util/authUtil' import { DiffModel } from './transformationResultsViewProvider' import { spawnSync } from 'child_process' // eslint-disable-line no-restricted-imports @@ -521,20 +522,33 @@ export function getFormattedString(s: string) { return CodeWhispererConstants.formattedStringMap.get(s) ?? s } -export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [key: string]: string }) { - const tableObj = tableMapping[stepId] - if (!tableObj) { - // no table present for this step +export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [key: string]: string[] }) { + const tableObjects = tableMapping[stepId] + if (!tableObjects || tableObjects.length === 0 || tableObjects.every((table: string) => table === '')) { + // no tables for this stepId return plan } - const table = JSON.parse(tableObj) - if (table.rows.length === 0) { - // empty table - plan += `\n\nThere are no ${table.name.toLowerCase()} to display.\n\n` + const tables: any[] = [] + // eslint-disable-next-line unicorn/no-array-for-each + tableObjects.forEach((tableObj: string) => { + try { + const table = JSON.parse(tableObj) + if (table) { + tables.push(table) + } + } catch (e) { + getLogger().error(`CodeTransformation: Failed to parse table JSON, skipping: ${e}`) + } + }) + + if (tables.every((table: any) => table.rows.length === 0)) { + // empty tables for this stepId + plan += `\n\nThere are no ${tables[0].name.toLowerCase()} to display.\n\n` return plan } - plan += `\n\n\n${table.name}\n|` - const columns = table.columnNames + // table name and columns are shared, so only add to plan once + plan += `\n\n\n${tables[0].name}\n|` + const columns = tables[0].columnNames // eslint-disable-next-line unicorn/no-array-for-each columns.forEach((columnName: string) => { plan += ` ${getFormattedString(columnName)} |` @@ -544,16 +558,21 @@ export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [ columns.forEach((_: any) => { plan += '-----|' }) + // add all rows of all tables // eslint-disable-next-line unicorn/no-array-for-each - table.rows.forEach((row: any) => { - plan += '\n|' + tables.forEach((table: any) => { // eslint-disable-next-line unicorn/no-array-for-each - columns.forEach((columnName: string) => { - if (columnName === 'relativePath') { - plan += ` [${row[columnName]}](${row[columnName]}) |` // add MD link only for files - } else { - plan += ` ${row[columnName]} |` - } + table.rows.forEach((row: any) => { + plan += '\n|' + // eslint-disable-next-line unicorn/no-array-for-each + columns.forEach((columnName: string) => { + if (columnName === 'relativePath') { + // add markdown link only for file paths + plan += ` [${row[columnName]}](${row[columnName]}) |` + } else { + plan += ` ${row[columnName]} |` + } + }) }) }) plan += '\n\n' @@ -561,11 +580,13 @@ export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [ } export function getTableMapping(stepZeroProgressUpdates: ProgressUpdates) { - const map: { [key: string]: string } = {} + const map: { [key: string]: string[] } = {} for (const update of stepZeroProgressUpdates) { - // description should never be undefined since even if no data we show an empty table - // but just in case, empty string allows us to skip this table without errors when rendering - map[update.name] = update.description ?? '' + if (!map[update.name]) { + map[update.name] = [] + } + // empty string allows us to skip this table when rendering + map[update.name].push(update.description ?? '') } return map } @@ -604,7 +625,7 @@ export async function getTransformationPlan(jobId: string, profile: RegionProfil // gets a mapping between the ID ('name' field) of each progressUpdate (substep) and the associated table const tableMapping = getTableMapping(stepZeroProgressUpdates) - const jobStatistics = JSON.parse(tableMapping['0']).rows // ID of '0' reserved for job statistics table + const jobStatistics = JSON.parse(tableMapping['0'][0]).rows // ID of '0' reserved for job statistics table; only 1 table there // get logo directly since we only use one logo regardless of color theme const logoIcon = getTransformationIcon('transformLogo') @@ -631,7 +652,7 @@ export async function getTransformationPlan(jobId: string, profile: RegionProfil } plan += `
` plan += `

Appendix
Scroll to top


` - plan = addTableMarkdown(plan, '-1', tableMapping) // ID of '-1' reserved for appendix table + plan = addTableMarkdown(plan, '-1', tableMapping) // ID of '-1' reserved for appendix table; only 1 table there return plan } catch (e: any) { const errorMessage = (e as Error).message @@ -663,6 +684,7 @@ export async function getTransformationSteps(jobId: string, profile: RegionProfi export async function pollTransformationJob(jobId: string, validStates: string[], profile: RegionProfile | undefined) { let status: string = '' + let isPlanComplete = false while (true) { throwIfCancelled() try { @@ -699,6 +721,19 @@ export async function pollTransformationJob(jobId: string, validStates: string[] `${CodeWhispererConstants.failedToCompleteJobGenericNotification} ${errorMessage}` ) } + + if ( + CodeWhispererConstants.validStatesForPlanGenerated.includes(status) && + transformByQState.getTransformationType() === TransformationType.LANGUAGE_UPGRADE && + !isPlanComplete + ) { + const plan = await openTransformationPlan(jobId, profile) + if (plan?.toLowerCase().includes('dependency changes')) { + // final plan is complete; show to user + isPlanComplete = true + } + } + if (validStates.includes(status)) { break } @@ -738,6 +773,32 @@ export async function pollTransformationJob(jobId: string, validStates: string[] return status } +async function openTransformationPlan(jobId: string, profile?: RegionProfile) { + let plan = undefined + try { + plan = await getTransformationPlan(jobId, profile) + } catch (error) { + // means API call failed + getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToCompleteJobNotification}`, error) + transformByQState.setJobFailureErrorNotification( + `${CodeWhispererConstants.failedToGetPlanNotification} ${(error as Error).message}` + ) + transformByQState.setJobFailureErrorChatMessage( + `${CodeWhispererConstants.failedToGetPlanChatMessage} ${(error as Error).message}` + ) + throw new Error('Get plan failed') + } + + if (plan) { + const planFilePath = path.join(transformByQState.getProjectPath(), 'transformation-plan.md') + nodefs.writeFileSync(planFilePath, plan) + await vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(planFilePath)) + transformByQState.setPlanFilePath(planFilePath) + await setContext('gumby.isPlanAvailable', true) + } + return plan +} + async function attemptLocalBuild() { const jobId = transformByQState.getJobId() let artifactId diff --git a/packages/core/src/test/codewhisperer/commands/transformByQ.test.ts b/packages/core/src/test/codewhisperer/commands/transformByQ.test.ts index 4b478e1876e..ea2aefce277 100644 --- a/packages/core/src/test/codewhisperer/commands/transformByQ.test.ts +++ b/packages/core/src/test/codewhisperer/commands/transformByQ.test.ts @@ -247,7 +247,25 @@ dependencyManagement: }, transformationJob: { status: 'COMPLETED' }, } + const mockPlanResponse = { + $response: { + data: { + transformationPlan: { transformationSteps: [] }, + }, + requestId: 'requestId', + hasNextPage: () => false, + error: undefined, + nextPage: () => null, // eslint-disable-line unicorn/no-null + redirectCount: 0, + retryCount: 0, + httpResponse: new HttpResponse(), + }, + transformationPlan: { transformationSteps: [] }, + } sinon.stub(codeWhisperer.codeWhispererClient, 'codeModernizerGetCodeTransformation').resolves(mockJobResponse) + sinon + .stub(codeWhisperer.codeWhispererClient, 'codeModernizerGetCodeTransformationPlan') + .resolves(mockPlanResponse) transformByQState.setToSucceeded() const status = await pollTransformationJob( 'dummyId', @@ -488,12 +506,18 @@ dependencyManagement: const actual = getTableMapping(stepZeroProgressUpdates) const expected = { - '0': '{"columnNames":["name","value"],"rows":[{"name":"Lines of code in your application","value":"3000"},{"name":"Dependencies to be replaced","value":"5"},{"name":"Deprecated code instances to be replaced","value":"10"},{"name":"Files to be updated","value":"7"}]}', - '1-dependency-change-abc': + '0': [ + '{"columnNames":["name","value"],"rows":[{"name":"Lines of code in your application","value":"3000"},{"name":"Dependencies to be replaced","value":"5"},{"name":"Deprecated code instances to be replaced","value":"10"},{"name":"Files to be updated","value":"7"}]}', + ], + '1-dependency-change-abc': [ '{"columnNames":["dependencyName","action","currentVersion","targetVersion"],"rows":[{"dependencyName":"org.springboot.com","action":"Update","currentVersion":"2.1","targetVersion":"2.4"}, {"dependencyName":"com.lombok.java","action":"Remove","currentVersion":"1.7","targetVersion":"-"}]}', - '2-deprecated-code-xyz': + ], + '2-deprecated-code-xyz': [ '{"columnNames":["apiFullyQualifiedName","numChangedFiles"],“rows”:[{"apiFullyQualifiedName":"java.lang.Thread.stop()","numChangedFiles":"6"}, {"apiFullyQualifiedName":"java.math.bad()","numChangedFiles":"3"}]}', - '-1': '{"columnNames":["relativePath","action"],"rows":[{"relativePath":"pom.xml","action":"Update"}, {"relativePath":"src/main/java/com/bhoruka/bloodbank/BloodbankApplication.java","action":"Update"}]}', + ], + '-1': [ + '{"columnNames":["relativePath","action"],"rows":[{"relativePath":"pom.xml","action":"Update"}, {"relativePath":"src/main/java/com/bhoruka/bloodbank/BloodbankApplication.java","action":"Update"}]}', + ], } assert.deepStrictEqual(actual, expected) })