diff --git a/packages/amazonq/test/unit/amazonqGumby/resources/files/diff.json b/packages/amazonq/test/unit/amazonqGumby/resources/files/diff.json new file mode 100644 index 00000000000..5b73cdd201b --- /dev/null +++ b/packages/amazonq/test/unit/amazonqGumby/resources/files/diff.json @@ -0,0 +1,9 @@ +{ + "content": [ + { + "name": "Added file", + "fileName": "resources/files/addedFile.diff", + "isSuccessful": true + } + ] +} diff --git a/packages/amazonq/test/unit/amazonqGumby/transformationResultsHandler.test.ts b/packages/amazonq/test/unit/amazonqGumby/transformationResultsHandler.test.ts index f7e0b29a35e..4e1ce627bd3 100644 --- a/packages/amazonq/test/unit/amazonqGumby/transformationResultsHandler.test.ts +++ b/packages/amazonq/test/unit/amazonqGumby/transformationResultsHandler.test.ts @@ -4,13 +4,19 @@ */ import assert from 'assert' import sinon from 'sinon' -import os from 'os' import { DiffModel, AddedChangeNode, ModifiedChangeNode } from 'aws-core-vscode/codewhisperer/node' +import { DescriptionContent } from 'aws-core-vscode/codewhisperer' import path from 'path' import { getTestResourceFilePath } from './amazonQGumbyUtil' import { fs } from 'aws-core-vscode/shared' +import { createTestWorkspace } from 'aws-core-vscode/test' describe('DiffModel', function () { + let parsedTestDescriptions: DescriptionContent + beforeEach(async () => { + parsedTestDescriptions = JSON.parse(await fs.readFileText(getTestResourceFilePath('resources/files/diff.json'))) + }) + afterEach(() => { sinon.restore() }) @@ -28,11 +34,19 @@ describe('DiffModel', function () { return true }) + testDiffModel.parseDiff( + getTestResourceFilePath('resources/files/addedFile.diff'), + workspacePath, + parsedTestDescriptions.content[0], + 1 + ) - testDiffModel.parseDiff(getTestResourceFilePath('resources/files/addedFile.diff'), workspacePath) - - assert.strictEqual(testDiffModel.changes.length, 1) - const change = testDiffModel.changes[0] + assert.strictEqual( + testDiffModel.patchFileNodes[0].patchFilePath, + getTestResourceFilePath('resources/files/addedFile.diff') + ) + assert(testDiffModel.patchFileNodes[0].label.includes(parsedTestDescriptions.content[0].name)) + const change = testDiffModel.patchFileNodes[0].children[0] assert.strictEqual(change instanceof AddedChangeNode, true) }) @@ -40,22 +54,56 @@ describe('DiffModel', function () { it('WHEN parsing a diff patch where a file was modified THEN returns an array representing the modified file', async function () { const testDiffModel = new DiffModel() - const workspacePath = os.tmpdir() - - sinon.replace(fs, 'exists', async (path) => true) + const fileAmount = 1 + const workspaceFolder = await createTestWorkspace(fileAmount, { fileContent: '' }) await fs.writeFile( - path.join(workspacePath, 'README.md'), + path.join(workspaceFolder.uri.fsPath, 'README.md'), 'This guide walks you through using Gradle to build a simple Java project.' ) - testDiffModel.parseDiff(getTestResourceFilePath('resources/files/modifiedFile.diff'), workspacePath) + testDiffModel.parseDiff( + getTestResourceFilePath('resources/files/modifiedFile.diff'), + workspaceFolder.uri.fsPath, + parsedTestDescriptions.content[0], + 1 + ) - assert.strictEqual(testDiffModel.changes.length, 1) - const change = testDiffModel.changes[0] + assert.strictEqual( + testDiffModel.patchFileNodes[0].patchFilePath, + getTestResourceFilePath('resources/files/modifiedFile.diff') + ) + assert(testDiffModel.patchFileNodes[0].label.includes(parsedTestDescriptions.content[0].name)) + const change = testDiffModel.patchFileNodes[0].children[0] assert.strictEqual(change instanceof ModifiedChangeNode, true) + }) + + it('WHEN parsing a diff patch where diff.json is not present and a file was modified THEN returns an array representing the modified file', async function () { + const testDiffModel = new DiffModel() - await fs.delete(path.join(workspacePath, 'README.md'), { recursive: true }) + const fileAmount = 1 + const workspaceFolder = await createTestWorkspace(fileAmount, { fileContent: '' }) + + await fs.writeFile( + path.join(workspaceFolder.uri.fsPath, 'README.md'), + 'This guide walks you through using Gradle to build a simple Java project.' + ) + + testDiffModel.parseDiff( + getTestResourceFilePath('resources/files/modifiedFile.diff'), + workspaceFolder.uri.fsPath, + undefined, + 1 + ) + + assert.strictEqual( + testDiffModel.patchFileNodes[0].patchFilePath, + getTestResourceFilePath('resources/files/modifiedFile.diff') + ) + assert(testDiffModel.patchFileNodes[0].label.endsWith('modifiedFile.diff')) + const change = testDiffModel.patchFileNodes[0].children[0] + + assert.strictEqual(change instanceof ModifiedChangeNode, true) }) }) diff --git a/packages/core/src/amazonqGumby/chat/controller/controller.ts b/packages/core/src/amazonqGumby/chat/controller/controller.ts index 37d139f5f1d..a99631e29c1 100644 --- a/packages/core/src/amazonqGumby/chat/controller/controller.ts +++ b/packages/core/src/amazonqGumby/chat/controller/controller.ts @@ -62,6 +62,7 @@ import { getStringHash } from '../../../shared/utilities/textUtilities' import { getVersionData } from '../../../codewhisperer/service/transformByQ/transformMavenHandler' import AdmZip from 'adm-zip' import { AuthError } from '../../../auth/sso/server' +import { isSelectiveTransformationReady } from '../../../dev/config' // These events can be interactions within the chat, // or elsewhere in the IDE @@ -358,6 +359,7 @@ export class GumbyController { this.transformationFinished({ message: CodeWhispererConstants.jobCancelledChatMessage, tabID: message.tabID, + includeStartNewTransformationButton: true, }) break case ButtonActions.CONFIRM_SKIP_TESTS_FORM: @@ -366,6 +368,12 @@ export class GumbyController { case ButtonActions.CANCEL_SKIP_TESTS_FORM: this.messenger.sendJobFinishedMessage(message.tabID, CodeWhispererConstants.jobCancelledChatMessage) break + case ButtonActions.CONFIRM_SELECTIVE_TRANSFORMATION_FORM: + await this.handleOneOrMultipleDiffs(message) + break + case ButtonActions.CANCEL_SELECTIVE_TRANSFORMATION_FORM: + this.messenger.sendJobFinishedMessage(message.tabID, CodeWhispererConstants.jobCancelledChatMessage) + break case ButtonActions.CONFIRM_SQL_CONVERSION_TRANSFORMATION_FORM: await this.handleUserSQLConversionProjectSelection(message) break @@ -416,6 +424,20 @@ export class GumbyController { result: MetadataResult.Pass, }) this.messenger.sendSkipTestsSelectionMessage(skipTestsSelection, message.tabID) + if (!isSelectiveTransformationReady) { + // perform local build + await this.validateBuildWithPromptOnError(message) + } else { + await this.messenger.sendOneOrMultipleDiffsPrompt(message.tabID) + } + } + + private async handleOneOrMultipleDiffs(message: any) { + const oneOrMultipleDiffsSelection = message.formSelectedValues['GumbyTransformOneOrMultipleDiffsForm'] + if (oneOrMultipleDiffsSelection === CodeWhispererConstants.multipleDiffsMessage) { + transformByQState.setMultipleDiffs(true) + } + this.messenger.sendOneOrMultipleDiffsMessage(oneOrMultipleDiffsSelection, message.tabID) // perform local build await this.validateBuildWithPromptOnError(message) } @@ -452,7 +474,6 @@ export class GumbyController { this.messenger.sendUnrecoverableErrorResponse('unsupported-source-jdk-version', message.tabID) return } - await processLanguageUpgradeTransformFormInput(pathToProject, fromJDKVersion, toJDKVersion) await this.messenger.sendSkipTestsPrompt(message.tabID) }) @@ -563,6 +584,7 @@ export class GumbyController { this.transformationFinished({ message: CodeWhispererConstants.jobCancelledChatMessage, tabID: message.tabID, + includeStartNewTransformationButton: true, }) return } @@ -591,11 +613,15 @@ export class GumbyController { ) } - private transformationFinished(data: { message: string | undefined; tabID: string }) { + private transformationFinished(data: { + message: string | undefined + tabID: string + includeStartNewTransformationButton: boolean + }) { this.resetTransformationChatFlow() // at this point job is either completed, partially_completed, cancelled, or failed if (data.message) { - this.messenger.sendJobFinishedMessage(data.tabID, data.message) + this.messenger.sendJobFinishedMessage(data.tabID, data.message, data.includeStartNewTransformationButton) } } @@ -701,7 +727,11 @@ export class GumbyController { try { await finishHumanInTheLoop() } catch (err: any) { - this.transformationFinished({ tabID: message.tabID, message: (err as Error).message }) + this.transformationFinished({ + tabID: message.tabID, + message: (err as Error).message, + includeStartNewTransformationButton: true, + }) } this.messenger.sendStaticTextResponse('end-HIL-early', message.tabID) diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts index 0acee2a3260..a3f0f9741a0 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts @@ -155,6 +155,45 @@ export class Messenger { ) } + public async sendOneOrMultipleDiffsPrompt(tabID: string) { + const formItems: ChatItemFormItem[] = [] + formItems.push({ + id: 'GumbyTransformOneOrMultipleDiffsForm', + type: 'select', + title: CodeWhispererConstants.selectiveTransformationFormTitle, + mandatory: true, + options: [ + { + value: CodeWhispererConstants.oneDiffMessage, + label: CodeWhispererConstants.oneDiffMessage, + }, + { + value: CodeWhispererConstants.multipleDiffsMessage, + label: CodeWhispererConstants.multipleDiffsMessage, + }, + ], + }) + + this.dispatcher.sendAsyncEventProgress( + new AsyncEventProgressMessage(tabID, { + inProgress: true, + message: CodeWhispererConstants.userPatchDescriptionChatMessage, + }) + ) + + this.dispatcher.sendChatPrompt( + new ChatPrompt( + { + message: 'Q Code Transformation', + formItems: formItems, + }, + 'TransformOneOrMultipleDiffsForm', + tabID, + false + ) + ) + } + public async sendLanguageUpgradeProjectPrompt(projects: TransformationCandidateProject[], tabID: string) { const projectFormOptions: { value: any; label: string }[] = [] const detectedJavaVersions = new Array() @@ -367,7 +406,6 @@ export class Messenger { }, tabID ) - this.dispatcher.sendChatMessage(jobSubmittedMessage) } @@ -477,13 +515,15 @@ export class Messenger { this.dispatcher.sendCommandMessage(new SendCommandMessage(message.command, message.tabID, message.eventId)) } - public sendJobFinishedMessage(tabID: string, message: string) { + public sendJobFinishedMessage(tabID: string, message: string, includeStartNewTransformationButton: boolean = true) { const buttons: ChatItemButton[] = [] - buttons.push({ - keepCardAfterClick: false, - text: CodeWhispererConstants.startTransformationButtonText, - id: ButtonActions.CONFIRM_START_TRANSFORMATION_FLOW, - }) + if (includeStartNewTransformationButton) { + buttons.push({ + keepCardAfterClick: false, + text: CodeWhispererConstants.startTransformationButtonText, + id: ButtonActions.CONFIRM_START_TRANSFORMATION_FLOW, + }) + } this.dispatcher.sendChatMessage( new ChatMessage( @@ -562,6 +602,11 @@ export class Messenger { this.dispatcher.sendChatMessage(new ChatMessage({ message, messageType: 'ai-prompt' }, tabID)) } + public sendOneOrMultipleDiffsMessage(selectiveTransformationSelection: string, tabID: string) { + const message = `Okay, I will create ${selectiveTransformationSelection.toLowerCase()} when providing the proposed changes.` + this.dispatcher.sendChatMessage(new ChatMessage({ message, messageType: 'ai-prompt' }, tabID)) + } + public sendHumanInTheLoopInitialMessage(tabID: string, codeSnippet: string) { let message = `I was not able to upgrade all dependencies. To resolve it, I will try to find an updated depedency in your local Maven repository. I will need additional information from you to continue.` diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts index 43c833f5e86..a7b15810312 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts @@ -18,6 +18,8 @@ export enum ButtonActions { CANCEL_TRANSFORMATION_FORM = 'gumbyTransformFormCancel', // shared between Language Upgrade & SQL Conversion CONFIRM_SKIP_TESTS_FORM = 'gumbyTransformSkipTestsFormConfirm', CANCEL_SKIP_TESTS_FORM = 'gumbyTransformSkipTestsFormCancel', + CONFIRM_SELECTIVE_TRANSFORMATION_FORM = 'gumbyTransformOneOrMultipleDiffsFormConfirm', + CANCEL_SELECTIVE_TRANSFORMATION_FORM = 'gumbyTransformOneOrMultipleDiffsFormCancel', SELECT_SQL_CONVERSION_METADATA_FILE = 'gumbySQLConversionMetadataTransformFormConfirm', CONFIRM_DEPENDENCY_FORM = 'gumbyTransformDependencyFormConfirm', CANCEL_DEPENDENCY_FORM = 'gumbyTransformDependencyFormCancel', diff --git a/packages/core/src/codewhisperer/commands/startTransformByQ.ts b/packages/core/src/codewhisperer/commands/startTransformByQ.ts index 089eadd5ba1..76e51fa9a9b 100644 --- a/packages/core/src/codewhisperer/commands/startTransformByQ.ts +++ b/packages/core/src/codewhisperer/commands/startTransformByQ.ts @@ -811,15 +811,17 @@ export async function postTransformationJob() { } let chatMessage = transformByQState.getJobFailureErrorChatMessage() + const diffMessage = CodeWhispererConstants.diffMessage(transformByQState.getMultipleDiffs()) if (transformByQState.isSucceeded()) { - chatMessage = CodeWhispererConstants.jobCompletedChatMessage + chatMessage = CodeWhispererConstants.jobCompletedChatMessage(diffMessage) } else if (transformByQState.isPartiallySucceeded()) { - chatMessage = CodeWhispererConstants.jobPartiallyCompletedChatMessage + chatMessage = CodeWhispererConstants.jobPartiallyCompletedChatMessage(diffMessage) } - transformByQState - .getChatControllers() - ?.transformationFinished.fire({ message: chatMessage, tabID: ChatSessionManager.Instance.getSession().tabID }) + transformByQState.getChatControllers()?.transformationFinished.fire({ + message: chatMessage, + tabID: ChatSessionManager.Instance.getSession().tabID, + }) const durationInMs = calculateTotalLatency(CodeTransformTelemetryState.instance.getStartTime()) const resultStatusMessage = transformByQState.getStatus() @@ -842,11 +844,11 @@ export async function postTransformationJob() { } if (transformByQState.isSucceeded()) { - void vscode.window.showInformationMessage(CodeWhispererConstants.jobCompletedNotification) + void vscode.window.showInformationMessage(CodeWhispererConstants.jobCompletedNotification(diffMessage)) } else if (transformByQState.isPartiallySucceeded()) { void vscode.window .showInformationMessage( - CodeWhispererConstants.jobPartiallyCompletedNotification, + CodeWhispererConstants.jobPartiallyCompletedNotification(diffMessage), CodeWhispererConstants.amazonQFeedbackText ) .then((choice) => { diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index d39771bc8ba..055fd15923f 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -22,6 +22,21 @@ export const AWSTemplateKeyWords = ['AWSTemplateFormatVersion', 'Resources', 'AW export const AWSTemplateCaseInsensitiveKeyWords = ['cloudformation', 'cfn', 'template', 'description'] +const patchDescriptions: { [key: string]: string } = { + 'Prepare minimal upgrade to Java 17': + 'This diff patch covers the set of upgrades for Springboot, JUnit, and PowerMockito frameworks.', + 'Popular Enterprise Specifications and Application Frameworks upgrade': + 'This diff patch covers the set of upgrades for Jakarta EE 10, Hibernate 6.2, and Micronaut 3.', + 'HTTP Client Utilities, Apache Commons Utilities, and Web Frameworks': + 'This diff patch covers the set of upgrades for Apache HTTP Client 5, Apache Commons utilities (Collections, IO, Lang, Math), Struts 6.0.', + 'Testing Tools and Frameworks upgrade': + 'This diff patch covers the set of upgrades for ArchUnit, Mockito, TestContainers, Cucumber, and additionally, Jenkins plugins and the Maven Wrapper.', + 'Miscellaneous Processing Documentation upgrade': + 'This diff patch covers a diverse set of upgrades spanning ORMs, XML processing, API documentation, and more.', + 'Deprecated API replacement and dependency upgrades': + 'This diff patch replaces deprecated APIs and makes additional dependency version upgrades.', +} + export const JsonConfigFileNamingConvention = new Set([ 'app.json', 'appsettings.json', @@ -454,6 +469,22 @@ export const chooseTransformationObjective = `I can help you with the following export const chooseTransformationObjectivePlaceholder = 'Enter "language upgrade" or "sql conversion"' +export const userPatchDescriptionChatMessage = ` +I can now divide the transformation results into diff patches (if applicable to the app) if you would like to review and accept each diff with fewer changes: + +• Minimal Compatible Library Upgrade to Java 17: Dependencies to the minimum compatible versions in Java 17, including Springboot, JUnit, and PowerMockito. + +• Popular Enterprise Specifications Application Frameworks: Popular enterprise and application frameworks like Jakarta EE, Hibernate, and Micronaut 3. + +• HTTP Client Utilities Web Frameworks: HTTP client libraries, Apache Commons utilities, and Struts frameworks. + +• Testing Tools Frameworks: Testing tools like ArchUnit, Mockito, and TestContainers and build tools like Jenkins and Maven Wrapper. + +• Miscellaneous Processing Documentation: Upgrades ORMs, XML processing, and Swagger to SpringDoc/OpenAPI. + +• Deprecated API replacement and dependency upgrades: Replaces deprecated APIs and makes additional dependency version upgrades. +` + export const uploadingCodeStepMessage = 'Upload your code' export const buildCodeStepMessage = 'Build uploaded code in secure build environment' @@ -583,13 +614,27 @@ export const jobCancelledChatMessage = export const jobCancelledNotification = 'You cancelled the transformation.' -export const jobCompletedChatMessage = `I transformed your code. You can review the diff to see my proposed changes and accept or reject them. The transformation summary has details about the files I updated.` +export const diffMessage = (multipleDiffs: boolean) => { + return multipleDiffs + ? 'You can review the diff to see my proposed changes and accept or reject them. If you reject the diff, you will not be able to see the diffs later.' + : 'You can review the diff to see my proposed changes and accept or reject them.' +} + +export const jobCompletedChatMessage = (multipleDiffsString: string) => { + return `I transformed your code. ${multipleDiffsString} The transformation summary has details about the files I updated.` +} -export const jobCompletedNotification = `Amazon Q transformed your code. You can review the diff to see my proposed changes and accept or reject them. The transformation summary has details about the files I updated.` +export const jobCompletedNotification = (multipleDiffsString: string) => { + return `Amazon Q transformed your code. ${multipleDiffsString} The transformation summary has details about the files I updated.` +} -export const jobPartiallyCompletedChatMessage = `I transformed part of your code. You can review the diff to see my proposed changes and accept or reject them. The transformation summary has details about the files I updated and the errors that prevented a complete transformation.` +export const jobPartiallyCompletedChatMessage = (multipleDiffsString: string) => { + return `I transformed part of your code. ${multipleDiffsString} The transformation summary has details about the files I updated and the errors that prevented a complete transformation.` +} -export const jobPartiallyCompletedNotification = `Amazon Q transformed part of your code. You can review the diff to see my proposed changes and accept or reject them. The transformation summary has details about the files I updated and the errors that prevented a complete transformation.` +export const jobPartiallyCompletedNotification = (multipleDiffsString: string) => { + return `Amazon Q transformed part of your code. ${multipleDiffsString} The transformation summary has details about the files I updated and the errors that prevented a complete transformation.` +} export const noPomXmlFoundChatMessage = `I couldn\'t find a project that I can upgrade. I couldn\'t find a pom.xml file in any of your open projects, nor could I find any embedded SQL statements. Currently, I can upgrade Java 8 or Java 11 projects built on Maven, or Oracle SQL to PostgreSQL statements in Java projects. For more information, see the [Amazon Q documentation](${codeTransformPrereqDoc}).` @@ -627,9 +672,26 @@ export const viewProposedChangesChatMessage = export const viewProposedChangesNotification = 'Download complete. You can view a summary of the transformation and accept or reject the proposed changes in the Transformation Hub.' -export const changesAppliedChatMessage = 'I applied the changes to your project.' +export const changesAppliedChatMessageOneDiff = 'I applied the changes to your project.' -export const changesAppliedNotification = 'Amazon Q applied the changes to your project.' +export const changesAppliedChatMessageMultipleDiffs = ( + currentPatchIndex: number, + totalPatchFiles: number, + description: string | undefined +) => + description + ? `I applied the changes in diff patch ${currentPatchIndex + 1} of ${totalPatchFiles} to your project. ${patchDescriptions[description]}` + : 'I applied the changes to your project.' + +export const changesAppliedNotificationOneDiff = 'Amazon Q applied the changes to your project' + +export const changesAppliedNotificationMultipleDiffs = (currentPatchIndex: number, totalPatchFiles: number) => { + if (totalPatchFiles === 1) { + return 'Amazon Q applied the changes to your project.' + } else { + return `Amazon Q applied the changes in diff patch ${currentPatchIndex + 1} of ${totalPatchFiles} to your project.` + } +} export const noOpenProjectsFoundChatMessage = `I couldn\'t find a project that I can upgrade. Currently, I support Java 8, Java 11, and Java 17 projects built on Maven. Make sure your project is open in the IDE. For more information, see the [Amazon Q documentation](${codeTransformPrereqDoc}).` @@ -684,15 +746,21 @@ export const chooseProjectSchemaFormMessage = 'To continue, choose the project a export const skipUnitTestsFormTitle = 'Choose to skip unit tests' +export const selectiveTransformationFormTitle = 'Choose how to receive proposed changes' + export const skipUnitTestsFormMessage = 'I will build your project using `mvn clean test` by default. If you would like me to build your project without running unit tests, I will use `mvn clean test-compile`.' export const runUnitTestsMessage = 'Run unit tests' +export const oneDiffMessage = 'One diff' + export const doNotSkipUnitTestsBuildCommand = 'clean test' export const skipUnitTestsMessage = 'Skip unit tests' +export const multipleDiffsMessage = 'Multiple diffs' + export const skipUnitTestsBuildCommand = 'clean test-compile' export const planTitle = 'Code Transformation plan by Amazon Q' diff --git a/packages/core/src/codewhisperer/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index b066a78ba0e..51a1d3b6a33 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -51,6 +51,16 @@ export type CrossFileStrategy = 'opentabs' | 'codemap' | 'bm25' | 'default' export type SupplementalContextStrategy = CrossFileStrategy | UtgStrategy | 'Empty' +export type PatchInfo = { + name: string + filename: string + isSuccessful: boolean +} + +export type DescriptionContent = { + content: PatchInfo[] +} + export interface CodeWhispererSupplementalContext { isUtg: boolean isProcessTimeout: boolean @@ -399,6 +409,8 @@ export class TransformByQState { private targetJDKVersion: JDKVersion = JDKVersion.JDK17 + private produceMultipleDiffs: boolean = false + private customBuildCommand: string = '' private sourceDB: DB | undefined = undefined @@ -491,6 +503,10 @@ export class TransformByQState { return this.linesOfCodeSubmitted } + public getMultipleDiffs() { + return this.produceMultipleDiffs + } + public getPreBuildLogFilePath() { return this.preBuildLogFilePath } @@ -655,6 +671,10 @@ export class TransformByQState { this.linesOfCodeSubmitted = lines } + public setMultipleDiffs(produceMultipleDiffs: boolean) { + this.produceMultipleDiffs = produceMultipleDiffs + } + public setStartTime(time: string) { this.startTime = time } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts index dc599a1dcd0..db9dca790db 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts @@ -322,6 +322,10 @@ export async function zipCode( getLogger().info(`CodeTransformation: source code files size = ${sourceFilesSize}`) } + if (transformByQState.getMultipleDiffs() && zipManifest instanceof ZipManifest) { + zipManifest.transformCapabilities.push('SELECTIVE_TRANSFORMATION_V1') + } + if ( transformByQState.getTransformationType() === TransformationType.SQL_CONVERSION && zipManifest instanceof ZipManifest diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts index 8bea3486bd3..3b851094aed 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts @@ -10,7 +10,13 @@ import { parsePatch, applyPatches, ParsedDiff } from 'diff' import path from 'path' import vscode from 'vscode' import { ExportIntent } from '@amzn/codewhisperer-streaming' -import { TransformationType, TransformByQReviewStatus, transformByQState } from '../../models/model' +import { + TransformByQReviewStatus, + transformByQState, + PatchInfo, + DescriptionContent, + TransformationType, +} from '../../models/model' import { ExportResultArchiveStructure, downloadExportResultArchive } from '../../../shared/utilities/download' import { getLogger } from '../../../shared/logger' import { telemetry } from '../../../shared/telemetry/telemetry' @@ -107,6 +113,19 @@ export class AddedChangeNode extends ProposedChangeNode { } } +export class PatchFileNode { + label: string + readonly patchFilePath: string + children: ProposedChangeNode[] = [] + + constructor(description: PatchInfo | undefined = undefined, patchFilePath: string) { + this.patchFilePath = patchFilePath + this.label = description + ? `${description.name} (${description.isSuccessful ? 'Success' : 'Failure'})` + : path.basename(patchFilePath) + } +} + enum ReviewState { ToReview, Reviewed_Accepted, @@ -114,7 +133,8 @@ enum ReviewState { } export class DiffModel { - changes: ProposedChangeNode[] = [] + patchFileNodes: PatchFileNode[] = [] + currentPatchIndex: number = 0 /** * This function creates a copy of the changed files of the user's project so that the diff.patch can be applied to them @@ -143,7 +163,13 @@ export class DiffModel { * @param pathToWorkspace Path to the project that was transformed * @returns List of nodes containing the paths of files that were modified, added, or removed */ - public parseDiff(pathToDiff: string, pathToWorkspace: string): ProposedChangeNode[] { + public parseDiff( + pathToDiff: string, + pathToWorkspace: string, + diffDescription: PatchInfo | undefined, + totalDiffPatches: number + ): PatchFileNode { + this.patchFileNodes = [] const diffContents = fs.readFileSync(pathToDiff, 'utf8') if (!diffContents.trim()) { @@ -184,7 +210,9 @@ export class DiffModel { } }, }) - this.changes = changedFiles.flatMap((file) => { + const patchFileNode = new PatchFileNode(diffDescription, pathToDiff) + patchFileNode.label = `Patch ${this.currentPatchIndex + 1} of ${totalDiffPatches}: ${patchFileNode.label}` + patchFileNode.children = changedFiles.flatMap((file) => { /* ex. file.oldFileName = 'a/src/java/com/project/component/MyFile.java' * ex. file.newFileName = 'b/src/java/com/project/component/MyFile.java' * use substring(2) to ignore the 'a/' and 'b/' @@ -202,24 +230,24 @@ export class DiffModel { } return [] }) - - return this.changes + this.patchFileNodes.push(patchFileNode) + return patchFileNode } public getChanges() { - return this.changes + return this.patchFileNodes.flatMap((patchFileNode) => patchFileNode.children) } public getRoot() { - return this.changes[0] + return this.patchFileNodes.length > 0 ? this.patchFileNodes[0] : undefined } public saveChanges() { - this.changes.forEach((file) => { - file.saveChange() + this.patchFileNodes.forEach((patchFileNode) => { + patchFileNode.children.forEach((changeNode) => { + changeNode.saveChange() + }) }) - - this.clearChanges() } public rejectChanges() { @@ -227,11 +255,12 @@ export class DiffModel { } public clearChanges() { - this.changes = [] + this.patchFileNodes = [] + this.currentPatchIndex = 0 } } -export class TransformationResultsProvider implements vscode.TreeDataProvider { +export class TransformationResultsProvider implements vscode.TreeDataProvider { public static readonly viewType = 'aws.amazonq.transformationProposedChangesTree' private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter() @@ -243,26 +272,49 @@ export class TransformationResultsProvider implements vscode.TreeDataProvider { - return element ? Promise.resolve([]) : this.model.getChanges() + /* + Here we check if the element is a PatchFileNode instance. If it is, we return its + children array, which contains ProposedChangeNode instances. This ensures that when the user expands a + PatchFileNode (representing a diff.patch file), its children (proposed change nodes) are displayed as indented nodes under it. + */ + public getChildren( + element?: ProposedChangeNode | PatchFileNode + ): (ProposedChangeNode | PatchFileNode)[] | Thenable<(ProposedChangeNode | PatchFileNode)[]> { + if (!element) { + return this.model.patchFileNodes + } else if (element instanceof PatchFileNode) { + return element.children + } else { + return Promise.resolve([]) + } } - public getParent(element: ProposedChangeNode): ProposedChangeNode | undefined { + public getParent(element: ProposedChangeNode | PatchFileNode): PatchFileNode | undefined { + if (element instanceof ProposedChangeNode) { + const patchFileNode = this.model.patchFileNodes.find((p) => p.children.includes(element)) + return patchFileNode + } return undefined } } export class ProposedTransformationExplorer { - private changeViewer: vscode.TreeView + private changeViewer: vscode.TreeView public static TmpDir = os.tmpdir() @@ -273,6 +325,10 @@ export class ProposedTransformationExplorer { treeDataProvider: transformDataProvider, }) + const patchFiles: string[] = [] + let singlePatchFile: string = '' + let patchFilesDescriptions: DescriptionContent | undefined = undefined + const reset = async () => { await setContext('gumby.transformationProposalReviewInProgress', false) await setContext('gumby.reviewState', TransformByQReviewStatus.NotStarted) @@ -379,10 +435,47 @@ export class ProposedTransformationExplorer { pathContainingArchive = path.dirname(pathToArchive) const zip = new AdmZip(pathToArchive) zip.extractAllTo(pathContainingArchive) + const files = fs.readdirSync(path.join(pathContainingArchive, ExportResultArchiveStructure.PathToPatch)) + if (files.length === 1) { + singlePatchFile = path.join( + pathContainingArchive, + ExportResultArchiveStructure.PathToPatch, + files[0] + ) + } else { + const jsonFile = files.find((file) => file.endsWith('.json')) + if (!jsonFile) { + throw new Error('Expected JSON file not found') + } + const filePath = path.join( + pathContainingArchive, + ExportResultArchiveStructure.PathToPatch, + jsonFile + ) + const jsonData = fs.readFileSync(filePath, 'utf-8') + patchFilesDescriptions = JSON.parse(jsonData) + } + if (patchFilesDescriptions !== undefined) { + for (const patchInfo of patchFilesDescriptions.content) { + patchFiles.push( + path.join( + pathContainingArchive, + ExportResultArchiveStructure.PathToPatch, + patchInfo.filename + ) + ) + } + } else { + patchFiles.push(singlePatchFile) + } + //Because multiple patches are returned once the ZIP is downloaded, we want to show the first one to start diffModel.parseDiff( - path.join(pathContainingArchive, ExportResultArchiveStructure.PathToDiffPatch), - transformByQState.getProjectPath() + patchFiles[0], + transformByQState.getProjectPath(), + patchFilesDescriptions ? patchFilesDescriptions.content[0] : undefined, + patchFiles.length ) + await setContext('gumby.reviewState', TransformByQReviewStatus.InReview) transformDataProvider.refresh() transformByQState.setSummaryFilePath( @@ -440,13 +533,53 @@ export class ProposedTransformationExplorer { vscode.commands.registerCommand('aws.amazonq.transformationHub.reviewChanges.acceptChanges', async () => { diffModel.saveChanges() + telemetry.codeTransform_submitSelection.emit({ + codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), + userChoice: `acceptChanges-${patchFilesDescriptions?.content[diffModel.currentPatchIndex].name}`, + }) telemetry.ui_click.emit({ elementId: 'transformationHub_acceptChanges' }) - void vscode.window.showInformationMessage(CodeWhispererConstants.changesAppliedNotification) + if (transformByQState.getMultipleDiffs()) { + void vscode.window.showInformationMessage( + CodeWhispererConstants.changesAppliedNotificationMultipleDiffs( + diffModel.currentPatchIndex, + patchFiles.length + ) + ) + } else { + void vscode.window.showInformationMessage(CodeWhispererConstants.changesAppliedNotificationOneDiff) + } + + //We do this to ensure that the changesAppliedChatMessage is only sent to user when they accept the first diff.patch transformByQState.getChatControllers()?.transformationFinished.fire({ - message: CodeWhispererConstants.changesAppliedChatMessage, + message: CodeWhispererConstants.changesAppliedChatMessageMultipleDiffs( + diffModel.currentPatchIndex, + patchFiles.length, + patchFilesDescriptions + ? patchFilesDescriptions.content[diffModel.currentPatchIndex].name + : undefined + ), tabID: ChatSessionManager.Instance.getSession().tabID, + includeStartNewTransformationButton: diffModel.currentPatchIndex === patchFiles.length - 1, }) - await reset() + + // Load the next patch file + diffModel.currentPatchIndex++ + if (diffModel.currentPatchIndex < patchFiles.length) { + const nextPatchFile = patchFiles[diffModel.currentPatchIndex] + const nextPatchFileDescription = patchFilesDescriptions + ? patchFilesDescriptions.content[diffModel.currentPatchIndex] + : undefined + diffModel.parseDiff( + nextPatchFile, + transformByQState.getProjectPath(), + nextPatchFileDescription, + patchFiles.length + ) + transformDataProvider.refresh() + } else { + // All patches have been applied, reset the state + await reset() + } telemetry.codeTransform_viewArtifact.emit({ codeTransformArtifactType: 'ClientInstructions', @@ -464,6 +597,10 @@ export class ProposedTransformationExplorer { await reset() telemetry.ui_click.emit({ elementId: 'transformationHub_rejectChanges' }) + transformByQState.getChatControllers()?.transformationFinished.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + }) + telemetry.codeTransform_viewArtifact.emit({ codeTransformArtifactType: 'ClientInstructions', codeTransformVCSViewerSrcComponents: 'toastNotification', diff --git a/packages/core/src/dev/config.ts b/packages/core/src/dev/config.ts index d5fa49b2426..ece2f09fed7 100644 --- a/packages/core/src/dev/config.ts +++ b/packages/core/src/dev/config.ts @@ -10,3 +10,6 @@ export const betaUrl = { amazonq: '', toolkit: '', } + +//feature flag for Selective Transformation +export const isSelectiveTransformationReady = false diff --git a/packages/core/src/shared/utilities/download.ts b/packages/core/src/shared/utilities/download.ts index dfb4c551427..19c0adcae4f 100644 --- a/packages/core/src/shared/utilities/download.ts +++ b/packages/core/src/shared/utilities/download.ts @@ -13,6 +13,7 @@ import fs from '../fs/fs' export class ExportResultArchiveStructure { static readonly PathToSummary = 'summary/summary.md' static readonly PathToDiffPatch = 'patch/diff.patch' + static readonly PathToPatch = 'patch' static readonly PathToMetrics = 'metrics/metrics.json' static readonly PathToManifest = 'manifest.json' }