Skip to content

Commit 3d30dcd

Browse files
dhasani23David Hasani
andauthored
feat(amazonq): parse new transformation plan (#7340)
## Problem Our transformation now looks different, so we want the IDE to be able to handle the new plan response. ## Solution Implement parsing logic. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: David Hasani <davhasan@amazon.com>
1 parent 1d585e9 commit 3d30dcd

File tree

4 files changed

+114
-51
lines changed

4 files changed

+114
-51
lines changed

packages/core/src/codewhisperer/commands/startTransformByQ.ts

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import {
2727
downloadHilResultArchive,
2828
findDownloadArtifactStep,
2929
getArtifactsFromProgressUpdate,
30-
getTransformationPlan,
3130
getTransformationSteps,
3231
pollTransformationJob,
3332
resumeTransformationJob,
@@ -554,28 +553,6 @@ export async function pollTransformationStatusUntilPlanReady(jobId: string, prof
554553
// for now, no plan shown with SQL conversions. later, we may add one
555554
return
556555
}
557-
let plan = undefined
558-
try {
559-
plan = await getTransformationPlan(jobId, profile)
560-
} catch (error) {
561-
// means API call failed
562-
getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToCompleteJobNotification}`, error)
563-
transformByQState.setJobFailureErrorNotification(
564-
`${CodeWhispererConstants.failedToGetPlanNotification} ${(error as Error).message}`
565-
)
566-
transformByQState.setJobFailureErrorChatMessage(
567-
`${CodeWhispererConstants.failedToGetPlanChatMessage} ${(error as Error).message}`
568-
)
569-
throw new Error('Get plan failed')
570-
}
571-
572-
if (plan !== undefined) {
573-
const planFilePath = path.join(transformByQState.getProjectPath(), 'transformation-plan.md')
574-
fs.writeFileSync(planFilePath, plan)
575-
await vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(planFilePath))
576-
transformByQState.setPlanFilePath(planFilePath)
577-
await setContext('gumby.isPlanAvailable', true)
578-
}
579556
jobPlanProgress['generatePlan'] = StepProgress.Succeeded
580557
throwIfCancelled()
581558
}

packages/core/src/codewhisperer/models/model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,7 @@ export class ZipManifest {
686686
version: string = '1.0'
687687
hilCapabilities: string[] = ['HIL_1pDependency_VersionUpgrade']
688688
// TO-DO: add 'CLIENT_SIDE_BUILD' here when releasing
689+
// TO-DO: add something like AGENTIC_PLAN_V1 here when BE allowlists everyone
689690
transformCapabilities: string[] = ['EXPLAINABILITY_V1']
690691
customBuildCommand: string = 'clean test'
691692
requestedConversions?: {

packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { encodeHTML } from '../../../shared/utilities/textUtilities'
5151
import { convertToTimeString } from '../../../shared/datetime'
5252
import { getAuthType } from '../../../auth/utils'
5353
import { UserWrittenCodeTracker } from '../../tracker/userWrittenCodeTracker'
54+
import { setContext } from '../../../shared/vscode/setContext'
5455
import { AuthUtil } from '../../util/authUtil'
5556
import { DiffModel } from './transformationResultsViewProvider'
5657
import { spawnSync } from 'child_process' // eslint-disable-line no-restricted-imports
@@ -521,20 +522,33 @@ export function getFormattedString(s: string) {
521522
return CodeWhispererConstants.formattedStringMap.get(s) ?? s
522523
}
523524

524-
export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [key: string]: string }) {
525-
const tableObj = tableMapping[stepId]
526-
if (!tableObj) {
527-
// no table present for this step
525+
export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [key: string]: string[] }) {
526+
const tableObjects = tableMapping[stepId]
527+
if (!tableObjects || tableObjects.length === 0 || tableObjects.every((table: string) => table === '')) {
528+
// no tables for this stepId
528529
return plan
529530
}
530-
const table = JSON.parse(tableObj)
531-
if (table.rows.length === 0) {
532-
// empty table
533-
plan += `\n\nThere are no ${table.name.toLowerCase()} to display.\n\n`
531+
const tables: any[] = []
532+
// eslint-disable-next-line unicorn/no-array-for-each
533+
tableObjects.forEach((tableObj: string) => {
534+
try {
535+
const table = JSON.parse(tableObj)
536+
if (table) {
537+
tables.push(table)
538+
}
539+
} catch (e) {
540+
getLogger().error(`CodeTransformation: Failed to parse table JSON, skipping: ${e}`)
541+
}
542+
})
543+
544+
if (tables.every((table: any) => table.rows.length === 0)) {
545+
// empty tables for this stepId
546+
plan += `\n\nThere are no ${tables[0].name.toLowerCase()} to display.\n\n`
534547
return plan
535548
}
536-
plan += `\n\n\n${table.name}\n|`
537-
const columns = table.columnNames
549+
// table name and columns are shared, so only add to plan once
550+
plan += `\n\n\n${tables[0].name}\n|`
551+
const columns = tables[0].columnNames
538552
// eslint-disable-next-line unicorn/no-array-for-each
539553
columns.forEach((columnName: string) => {
540554
plan += ` ${getFormattedString(columnName)} |`
@@ -544,28 +558,35 @@ export function addTableMarkdown(plan: string, stepId: string, tableMapping: { [
544558
columns.forEach((_: any) => {
545559
plan += '-----|'
546560
})
561+
// add all rows of all tables
547562
// eslint-disable-next-line unicorn/no-array-for-each
548-
table.rows.forEach((row: any) => {
549-
plan += '\n|'
563+
tables.forEach((table: any) => {
550564
// eslint-disable-next-line unicorn/no-array-for-each
551-
columns.forEach((columnName: string) => {
552-
if (columnName === 'relativePath') {
553-
plan += ` [${row[columnName]}](${row[columnName]}) |` // add MD link only for files
554-
} else {
555-
plan += ` ${row[columnName]} |`
556-
}
565+
table.rows.forEach((row: any) => {
566+
plan += '\n|'
567+
// eslint-disable-next-line unicorn/no-array-for-each
568+
columns.forEach((columnName: string) => {
569+
if (columnName === 'relativePath') {
570+
// add markdown link only for file paths
571+
plan += ` [${row[columnName]}](${row[columnName]}) |`
572+
} else {
573+
plan += ` ${row[columnName]} |`
574+
}
575+
})
557576
})
558577
})
559578
plan += '\n\n'
560579
return plan
561580
}
562581

563582
export function getTableMapping(stepZeroProgressUpdates: ProgressUpdates) {
564-
const map: { [key: string]: string } = {}
583+
const map: { [key: string]: string[] } = {}
565584
for (const update of stepZeroProgressUpdates) {
566-
// description should never be undefined since even if no data we show an empty table
567-
// but just in case, empty string allows us to skip this table without errors when rendering
568-
map[update.name] = update.description ?? ''
585+
if (!map[update.name]) {
586+
map[update.name] = []
587+
}
588+
// empty string allows us to skip this table when rendering
589+
map[update.name].push(update.description ?? '')
569590
}
570591
return map
571592
}
@@ -604,7 +625,7 @@ export async function getTransformationPlan(jobId: string, profile: RegionProfil
604625
// gets a mapping between the ID ('name' field) of each progressUpdate (substep) and the associated table
605626
const tableMapping = getTableMapping(stepZeroProgressUpdates)
606627

607-
const jobStatistics = JSON.parse(tableMapping['0']).rows // ID of '0' reserved for job statistics table
628+
const jobStatistics = JSON.parse(tableMapping['0'][0]).rows // ID of '0' reserved for job statistics table; only 1 table there
608629

609630
// get logo directly since we only use one logo regardless of color theme
610631
const logoIcon = getTransformationIcon('transformLogo')
@@ -631,7 +652,7 @@ export async function getTransformationPlan(jobId: string, profile: RegionProfil
631652
}
632653
plan += `</div><br>`
633654
plan += `<p style="font-size: 18px; margin-bottom: 4px;"><b>Appendix</b><br><a href="#top" style="float: right; font-size: 14px;">Scroll to top <img src="${arrowIcon}" style="vertical-align: middle;"></a></p><br>`
634-
plan = addTableMarkdown(plan, '-1', tableMapping) // ID of '-1' reserved for appendix table
655+
plan = addTableMarkdown(plan, '-1', tableMapping) // ID of '-1' reserved for appendix table; only 1 table there
635656
return plan
636657
} catch (e: any) {
637658
const errorMessage = (e as Error).message
@@ -663,6 +684,7 @@ export async function getTransformationSteps(jobId: string, profile: RegionProfi
663684

664685
export async function pollTransformationJob(jobId: string, validStates: string[], profile: RegionProfile | undefined) {
665686
let status: string = ''
687+
let isPlanComplete = false
666688
while (true) {
667689
throwIfCancelled()
668690
try {
@@ -699,6 +721,19 @@ export async function pollTransformationJob(jobId: string, validStates: string[]
699721
`${CodeWhispererConstants.failedToCompleteJobGenericNotification} ${errorMessage}`
700722
)
701723
}
724+
725+
if (
726+
CodeWhispererConstants.validStatesForPlanGenerated.includes(status) &&
727+
transformByQState.getTransformationType() === TransformationType.LANGUAGE_UPGRADE &&
728+
!isPlanComplete
729+
) {
730+
const plan = await openTransformationPlan(jobId, profile)
731+
if (plan?.toLowerCase().includes('dependency changes')) {
732+
// final plan is complete; show to user
733+
isPlanComplete = true
734+
}
735+
}
736+
702737
if (validStates.includes(status)) {
703738
break
704739
}
@@ -738,6 +773,32 @@ export async function pollTransformationJob(jobId: string, validStates: string[]
738773
return status
739774
}
740775

776+
async function openTransformationPlan(jobId: string, profile?: RegionProfile) {
777+
let plan = undefined
778+
try {
779+
plan = await getTransformationPlan(jobId, profile)
780+
} catch (error) {
781+
// means API call failed
782+
getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToCompleteJobNotification}`, error)
783+
transformByQState.setJobFailureErrorNotification(
784+
`${CodeWhispererConstants.failedToGetPlanNotification} ${(error as Error).message}`
785+
)
786+
transformByQState.setJobFailureErrorChatMessage(
787+
`${CodeWhispererConstants.failedToGetPlanChatMessage} ${(error as Error).message}`
788+
)
789+
throw new Error('Get plan failed')
790+
}
791+
792+
if (plan) {
793+
const planFilePath = path.join(transformByQState.getProjectPath(), 'transformation-plan.md')
794+
nodefs.writeFileSync(planFilePath, plan)
795+
await vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(planFilePath))
796+
transformByQState.setPlanFilePath(planFilePath)
797+
await setContext('gumby.isPlanAvailable', true)
798+
}
799+
return plan
800+
}
801+
741802
async function attemptLocalBuild() {
742803
const jobId = transformByQState.getJobId()
743804
let artifactId

packages/core/src/test/codewhisperer/commands/transformByQ.test.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,25 @@ dependencyManagement:
247247
},
248248
transformationJob: { status: 'COMPLETED' },
249249
}
250+
const mockPlanResponse = {
251+
$response: {
252+
data: {
253+
transformationPlan: { transformationSteps: [] },
254+
},
255+
requestId: 'requestId',
256+
hasNextPage: () => false,
257+
error: undefined,
258+
nextPage: () => null, // eslint-disable-line unicorn/no-null
259+
redirectCount: 0,
260+
retryCount: 0,
261+
httpResponse: new HttpResponse(),
262+
},
263+
transformationPlan: { transformationSteps: [] },
264+
}
250265
sinon.stub(codeWhisperer.codeWhispererClient, 'codeModernizerGetCodeTransformation').resolves(mockJobResponse)
266+
sinon
267+
.stub(codeWhisperer.codeWhispererClient, 'codeModernizerGetCodeTransformationPlan')
268+
.resolves(mockPlanResponse)
251269
transformByQState.setToSucceeded()
252270
const status = await pollTransformationJob(
253271
'dummyId',
@@ -488,12 +506,18 @@ dependencyManagement:
488506

489507
const actual = getTableMapping(stepZeroProgressUpdates)
490508
const expected = {
491-
'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"}]}',
492-
'1-dependency-change-abc':
509+
'0': [
510+
'{"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"}]}',
511+
],
512+
'1-dependency-change-abc': [
493513
'{"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":"-"}]}',
494-
'2-deprecated-code-xyz':
514+
],
515+
'2-deprecated-code-xyz': [
495516
'{"columnNames":["apiFullyQualifiedName","numChangedFiles"],“rows”:[{"apiFullyQualifiedName":"java.lang.Thread.stop()","numChangedFiles":"6"}, {"apiFullyQualifiedName":"java.math.bad()","numChangedFiles":"3"}]}',
496-
'-1': '{"columnNames":["relativePath","action"],"rows":[{"relativePath":"pom.xml","action":"Update"}, {"relativePath":"src/main/java/com/bhoruka/bloodbank/BloodbankApplication.java","action":"Update"}]}',
517+
],
518+
'-1': [
519+
'{"columnNames":["relativePath","action"],"rows":[{"relativePath":"pom.xml","action":"Update"}, {"relativePath":"src/main/java/com/bhoruka/bloodbank/BloodbankApplication.java","action":"Update"}]}',
520+
],
497521
}
498522
assert.deepStrictEqual(actual, expected)
499523
})

0 commit comments

Comments
 (0)