diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4df3609e2c..be14af27878 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: required: false default: prerelease push: - branches: [master, feature/*] + branches: [master, feature/*, release/*] # tags: # - v[0-9]+.[0-9]+.[0-9]+ @@ -40,12 +40,16 @@ jobs: # run: echo 'TAG_NAME=prerelease' >> $GITHUB_ENV - if: github.event_name == 'workflow_dispatch' run: echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV - - if: github.ref_name != 'master' + - if: startsWith(github.ref_name, 'feature/') run: | - TAG_NAME=${{ github.ref_name }} - FEAT_NAME=$(echo $TAG_NAME | sed 's/feature\///') + FEAT_NAME=$(echo ${{ github.ref_name }} | sed 's/feature\///') echo "FEAT_NAME=$FEAT_NAME" >> $GITHUB_ENV echo "TAG_NAME=pre-$FEAT_NAME" >> $GITHUB_ENV + - if: startsWith(github.ref_name, 'release/') + run: | + RC_NAME=$(echo ${{ github.ref_name }} | sed 's/release\///') + echo "FEAT_NAME=" >> $GITHUB_ENV + echo "TAG_NAME=rc-$RC_NAME" >> $GITHUB_ENV - if: github.ref_name == 'master' run: | echo "FEAT_NAME=" >> $GITHUB_ENV @@ -105,10 +109,14 @@ jobs: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 - name: Delete existing prerelease - # "prerelease" (main branch) or "pre-" - if: "env.TAG_NAME == 'prerelease' || startsWith(env.TAG_NAME, 'pre-')" + # "prerelease" (main branch), "pre-", or "rc-" + if: env.TAG_NAME == 'prerelease' || startsWith(env.TAG_NAME, 'pre-') || startsWith(env.TAG_NAME, 'rc-') run: | - echo "SUBJECT=AWS IDE Extensions: ${FEAT_NAME:-${TAG_NAME}}" >> $GITHUB_ENV + if [[ "$TAG_NAME" == rc-* ]]; then + echo "SUBJECT=AWS IDE Extensions Release Candidate: ${TAG_NAME#rc-}" >> $GITHUB_ENV + else + echo "SUBJECT=AWS IDE Extensions: ${FEAT_NAME:-${TAG_NAME}}" >> $GITHUB_ENV + fi gh release delete "$TAG_NAME" --cleanup-tag --yes || true # git push origin :"$TAG_NAME" || true - name: Publish Prerelease diff --git a/.github/workflows/setup-release-candidate.yml b/.github/workflows/setup-release-candidate.yml new file mode 100644 index 00000000000..827a6ec81ab --- /dev/null +++ b/.github/workflows/setup-release-candidate.yml @@ -0,0 +1,108 @@ +name: Setup Release Candidate + +on: + workflow_dispatch: + inputs: + versionIncrement: + description: 'Release Version Increment' + default: 'Minor' + required: true + type: choice + options: + - Major + - Minor + - Patch + - Custom + customVersion: + description: "Custom Release Version (only used if release increment is 'Custom') - Format: 3.15.0" + default: '' + required: false + type: string + commitId: + description: 'Commit ID to create RC from' + required: true + type: string + +jobs: + setup-rc: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.commitId }} + token: ${{ secrets.RELEASE_CANDIDATE_BRANCH_CREATION_PAT }} + persist-credentials: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Calculate Release Versions + id: release-version + run: | + customVersion="${{ inputs.customVersion }}" + versionIncrement="${{ inputs.versionIncrement }}" + + increment_version() { + local currentVersion=$1 + if [[ "$versionIncrement" == "Custom" && -n "$customVersion" ]]; then + echo "$customVersion" + else + IFS='.' read -r major minor patch <<< "$currentVersion" + case "$versionIncrement" in + "Major") + major=$((major + 1)) + minor=0 + patch=0 + ;; + "Minor") + minor=$((minor + 1)) + patch=0 + ;; + "Patch") + patch=$((patch + 1)) + ;; + esac + echo "$major.$minor.$patch" + fi + } + + # Read and increment toolkit version + toolkitCurrentVersion=$(node -e "console.log(require('./packages/toolkit/package.json').version)" | sed 's/-SNAPSHOT//') + toolkitNewVersion=$(increment_version "$toolkitCurrentVersion") + + # Read and increment amazonq version + amazonqCurrentVersion=$(node -e "console.log(require('./packages/amazonq/package.json').version)" | sed 's/-SNAPSHOT//') + amazonqNewVersion=$(increment_version "$amazonqCurrentVersion") + + echo "TOOLKIT_VERSION=$toolkitNewVersion" >> $GITHUB_OUTPUT + echo "AMAZONQ_VERSION=$amazonqNewVersion" >> $GITHUB_OUTPUT + # Use date-based branch naming instead of version-based because we release + # both extensions (toolkit and amazonq) from the same branch, and they may + # have different version numbers. We can change this in the future + echo "BRANCH_NAME=rc-$(date +%Y%m%d)" >> $GITHUB_OUTPUT + + - name: Create RC Branch and Update Versions + env: + TOOLKIT_VERSION: ${{ steps.release-version.outputs.TOOLKIT_VERSION }} + AMAZONQ_VERSION: ${{ steps.release-version.outputs.AMAZONQ_VERSION }} + BRANCH_NAME: ${{ steps.release-version.outputs.BRANCH_NAME }} + run: | + git config user.name "aws-toolkit-automation" + git config user.email "<>" + + # Create RC branch using date-based naming + git checkout -b $BRANCH_NAME + + # Update package versions individually + npm version --no-git-tag-version $TOOLKIT_VERSION -w packages/toolkit + npm version --no-git-tag-version $AMAZONQ_VERSION -w packages/amazonq + + # Commit version changes + git add packages/toolkit/package.json packages/amazonq/package.json package-lock.json + git commit -m "chore: bump versions - toolkit=$TOOLKIT_VERSION, amazonq=$AMAZONQ_VERSION" + + # Push RC branch + git push origin $BRANCH_NAME diff --git a/packages/amazonq/src/app/inline/EditRendering/displayImage.ts b/packages/amazonq/src/app/inline/EditRendering/displayImage.ts index 73aecaa9b7c..f25284c6b5a 100644 --- a/packages/amazonq/src/app/inline/EditRendering/displayImage.ts +++ b/packages/amazonq/src/app/inline/EditRendering/displayImage.ts @@ -3,18 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { getLogger, setContext } from 'aws-core-vscode/shared' +import { getContext, getLogger, setContext } from 'aws-core-vscode/shared' import * as vscode from 'vscode' -import { diffLines } from 'diff' +import { applyPatch, diffLines } from 'diff' import { LanguageClient } from 'vscode-languageclient' import { CodeWhispererSession } from '../sessionManager' import { LogInlineCompletionSessionResultsParams } from '@aws/language-server-runtimes/protocol' import { InlineCompletionItemWithReferences } from '@aws/language-server-runtimes/protocol' import path from 'path' import { imageVerticalOffset } from './svgGenerator' -import { AmazonQInlineCompletionItemProvider } from '../completion' +import { EditSuggestionState } from '../editSuggestionState' +import type { AmazonQInlineCompletionItemProvider } from '../completion' import { vsCodeState } from 'aws-core-vscode/codewhisperer' +const autoRejectEditCursorDistance = 25 + export class EditDecorationManager { private imageDecorationType: vscode.TextEditorDecorationType private removedCodeDecorationType: vscode.TextEditorDecorationType @@ -136,6 +139,7 @@ export class EditDecorationManager { await this.clearDecorations(editor) await setContext('aws.amazonq.editSuggestionActive' as any, true) + EditSuggestionState.setEditSuggestionActive(true) this.acceptHandler = onAccept this.rejectHandler = onReject @@ -166,6 +170,7 @@ export class EditDecorationManager { this.acceptHandler = undefined this.rejectHandler = undefined await setContext('aws.amazonq.editSuggestionActive' as any, false) + EditSuggestionState.setEditSuggestionActive(false) } /** @@ -270,6 +275,28 @@ function getEndOfEditPosition(originalCode: string, newCode: string): vscode.Pos return editor ? editor.selection.active : new vscode.Position(0, 0) } +/** + * Helper function to create discard telemetry params + */ +function createDiscardTelemetryParams( + session: CodeWhispererSession, + item: InlineCompletionItemWithReferences +): LogInlineCompletionSessionResultsParams { + return { + sessionId: session.sessionId, + completionSessionResult: { + [item.itemId]: { + seen: false, + accepted: false, + discarded: true, + }, + }, + totalSessionDisplayTime: Date.now() - session.requestStartTime, + firstCompletionDisplayLatency: session.firstCompletionDisplayLatency, + isInlineEdit: true, + } +} + /** * Helper function to display SVG decorations */ @@ -286,6 +313,54 @@ export async function displaySvgDecoration( ) { const originalCode = editor.document.getText() + // Check if a completion suggestion is currently active - if so, discard edit suggestion + if (inlineCompletionProvider && (await inlineCompletionProvider.isCompletionActive())) { + // Emit DISCARD telemetry for edit suggestion that can't be shown due to active completion + const params = createDiscardTelemetryParams(session, item) + languageClient.sendNotification('aws/logInlineCompletionSessionResults', params) + getLogger().info('Edit suggestion discarded due to active completion suggestion') + return + } + + const isPatchValid = applyPatch(editor.document.getText(), item.insertText as string) + if (!isPatchValid) { + const params = createDiscardTelemetryParams(session, item) + // TODO: this session is closed on flare side hence discarded is not emitted in flare + languageClient.sendNotification('aws/logInlineCompletionSessionResults', params) + return + } + const documentChangeListener = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.contentChanges.length <= 0) { + return + } + if (e.document !== editor.document) { + return + } + if (vsCodeState.isCodeWhispererEditing) { + return + } + if (getContext('aws.amazonq.editSuggestionActive') === false) { + return + } + + const isPatchValid = applyPatch(e.document.getText(), item.insertText as string) + if (!isPatchValid) { + void vscode.commands.executeCommand('aws.amazonq.inline.rejectEdit') + } + }) + const cursorChangeListener = vscode.window.onDidChangeTextEditorSelection((e) => { + if (!EditSuggestionState.isEditSuggestionActive()) { + return + } + if (e.textEditor !== editor) { + return + } + const currentPosition = e.selections[0].active + const distance = Math.abs(currentPosition.line - startLine) + if (distance > autoRejectEditCursorDistance) { + void vscode.commands.executeCommand('aws.amazonq.inline.rejectEdit') + } + }) await decorationManager.displayEditSuggestion( editor, svgImage, @@ -310,6 +385,8 @@ export async function displaySvgDecoration( editor.selection = new vscode.Selection(endPosition, endPosition) await decorationManager.clearDecorations(editor) + documentChangeListener.dispose() + cursorChangeListener.dispose() const params: LogInlineCompletionSessionResultsParams = { sessionId: session.sessionId, completionSessionResult: { @@ -343,6 +420,8 @@ export async function displaySvgDecoration( // Handle reject getLogger().info('Edit suggestion rejected') await decorationManager.clearDecorations(editor) + documentChangeListener.dispose() + cursorChangeListener.dispose() const params: LogInlineCompletionSessionResultsParams = { sessionId: session.sessionId, completionSessionResult: { diff --git a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts index 5f204343b5e..6c52dc2d6a0 100644 --- a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts +++ b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts @@ -10,7 +10,7 @@ import { getLogger } from 'aws-core-vscode/shared' import { LanguageClient } from 'vscode-languageclient' import { InlineCompletionItemWithReferences } from '@aws/language-server-runtimes/protocol' import { CodeWhispererSession } from '../sessionManager' -import { AmazonQInlineCompletionItemProvider } from '../completion' +import type { AmazonQInlineCompletionItemProvider } from '../completion' export async function showEdits( item: InlineCompletionItemWithReferences, diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts index 3f8725f8579..9a5f5522468 100644 --- a/packages/amazonq/src/app/inline/completion.ts +++ b/packages/amazonq/src/app/inline/completion.ts @@ -237,6 +237,53 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem await vscode.commands.executeCommand(`aws.amazonq.checkInlineSuggestionVisibility`) } + /** + * Check if a completion suggestion is currently active/displayed + */ + public async isCompletionActive(): Promise { + const session = this.sessionManager.getActiveSession() + if (session === undefined || !session.displayed || session.suggestions.some((item) => item.isInlineEdit)) { + return false + } + + // Use VS Code command to check if inline suggestion is actually visible on screen + // This command only executes when inlineSuggestionVisible context is true + await vscode.commands.executeCommand('aws.amazonq.checkInlineSuggestionVisibility') + const isInlineSuggestionVisible = performance.now() - session.lastVisibleTime < 50 + return isInlineSuggestionVisible + } + + /** + * Batch discard telemetry for completion suggestions when edit suggestion is active + */ + public batchDiscardTelemetryForEditSuggestion(items: any[], session: any): void { + // Emit DISCARD telemetry for completion suggestions that can't be shown due to active edit + const completionSessionResult: { + [key: string]: { seen: boolean; accepted: boolean; discarded: boolean } + } = {} + + for (const item of items) { + if (!item.isInlineEdit && item.itemId) { + completionSessionResult[item.itemId] = { + seen: false, + accepted: false, + discarded: true, + } + } + } + + // Send single telemetry event for all discarded items + if (Object.keys(completionSessionResult).length > 0) { + const params: LogInlineCompletionSessionResultsParams = { + sessionId: session.sessionId, + completionSessionResult, + firstCompletionDisplayLatency: session.firstCompletionDisplayLatency, + totalSessionDisplayTime: performance.now() - session.requestStartTime, + } + this.languageClient.sendNotification(this.logSessionResultMessageName, params) + } + } + // this method is automatically invoked by VS Code as user types async provideInlineCompletionItems( document: TextDocument, diff --git a/packages/amazonq/src/app/inline/editSuggestionState.ts b/packages/amazonq/src/app/inline/editSuggestionState.ts new file mode 100644 index 00000000000..66a9211bdcf --- /dev/null +++ b/packages/amazonq/src/app/inline/editSuggestionState.ts @@ -0,0 +1,19 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manages the state of edit suggestions to avoid circular dependencies + */ +export class EditSuggestionState { + private static isEditSuggestionCurrentlyActive = false + + static setEditSuggestionActive(active: boolean): void { + this.isEditSuggestionCurrentlyActive = active + } + + static isEditSuggestionActive(): boolean { + return this.isEditSuggestionCurrentlyActive + } +} diff --git a/packages/amazonq/src/app/inline/recommendationService.ts b/packages/amazonq/src/app/inline/recommendationService.ts index 51eb696b119..55c08c820fe 100644 --- a/packages/amazonq/src/app/inline/recommendationService.ts +++ b/packages/amazonq/src/app/inline/recommendationService.ts @@ -24,6 +24,7 @@ import { getLogger } from 'aws-core-vscode/shared' import { DocumentEventListener } from './documentEventListener' import { getOpenFilesInWindow } from 'aws-core-vscode/utils' import { asyncCallWithTimeout } from '../../util/timeoutUtil' +import { EditSuggestionState } from './editSuggestionState' export interface GetAllRecommendationsOptions { emitTelemetry?: boolean @@ -132,7 +133,7 @@ export class RecommendationService { * Completions use PartialResultToken with single 1 call of [getAllRecommendations]. * Edits leverage partialResultToken to achieve EditStreak such that clients can pull all continuous suggestions generated by the model within 1 EOS block. */ - if (!isTriggerByDeletion && !request.partialResultToken) { + if (!isTriggerByDeletion && !request.partialResultToken && !EditSuggestionState.isEditSuggestionActive()) { const completionPromise: Promise = languageClient.sendRequest( inlineCompletionWithReferencesRequestType.method, request, diff --git a/packages/amazonq/src/app/inline/sessionManager.ts b/packages/amazonq/src/app/inline/sessionManager.ts index 7decf035b9a..15d7dbbb8d0 100644 --- a/packages/amazonq/src/app/inline/sessionManager.ts +++ b/packages/amazonq/src/app/inline/sessionManager.ts @@ -26,6 +26,8 @@ export interface CodeWhispererSession { triggerOnAcceptance?: boolean // whether any suggestion in this session was displayed on screen displayed: boolean + // timestamp when the suggestion was last visible + lastVisibleTime: number } export class SessionManager { @@ -52,6 +54,7 @@ export class SessionManager { firstCompletionDisplayLatency, diagnosticsBeforeAccept, displayed: false, + lastVisibleTime: 0, } this._currentSuggestionIndex = 0 } @@ -134,6 +137,7 @@ export class SessionManager { public checkInlineSuggestionVisibility() { if (this.activeSession) { this.activeSession.displayed = true + this.activeSession.lastVisibleTime = performance.now() } } diff --git a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts index 6572edffddc..7f2bcbb40ea 100644 --- a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts +++ b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts @@ -15,6 +15,7 @@ import { CursorUpdateManager } from '../../../../../src/app/inline/cursorUpdateM import { CodeWhispererStatusBarManager } from 'aws-core-vscode/codewhisperer' import { globals } from 'aws-core-vscode/shared' import { DocumentEventListener } from '../../../../../src/app/inline/documentEventListener' +import { EditSuggestionState } from '../../../../../src/app/inline/editSuggestionState' const completionApi = 'aws/textDocument/inlineCompletionWithReferences' const editApi = 'aws/textDocument/editCompletion' @@ -128,6 +129,9 @@ describe('RecommendationService', () => { describe('getAllRecommendations', () => { it('should handle single request with no partial result token', async () => { + // Mock EditSuggestionState to return false (no edit suggestion active) + sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(false) + const mockFirstResult = { sessionId: 'test-session', items: [mockInlineCompletionItemOne], @@ -171,6 +175,9 @@ describe('RecommendationService', () => { }) it('should handle multiple request with partial result token', async () => { + // Mock EditSuggestionState to return false (no edit suggestion active) + sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(false) + const mockFirstResult = { sessionId: 'test-session', items: [mockInlineCompletionItemOne], @@ -325,5 +332,77 @@ describe('RecommendationService', () => { console.error = originalConsoleError } }) + + it('should not make completion request when edit suggestion is active', async () => { + // Mock EditSuggestionState to return true (edit suggestion is active) + const isEditSuggestionActiveStub = sandbox.stub(EditSuggestionState, 'isEditSuggestionActive').returns(true) + + const mockResult = { + sessionId: 'test-session', + items: [mockInlineCompletionItemOne], + partialResultToken: undefined, + } + + sendRequestStub.resolves(mockResult) + + await service.getAllRecommendations( + languageClient, + mockDocument, + mockPosition, + mockContext, + mockToken, + true, + mockDocumentEventListener + ) + + // Verify sendRequest was called only for edit API, not completion API + const cs = sendRequestStub.getCalls() + const completionCalls = cs.filter((c) => c.firstArg === completionApi) + const editCalls = cs.filter((c) => c.firstArg === editApi) + + assert.strictEqual(cs.length, 1) // Only edit call + assert.strictEqual(completionCalls.length, 0) // No completion calls + assert.strictEqual(editCalls.length, 1) // One edit call + + // Verify the stub was called + sinon.assert.calledOnce(isEditSuggestionActiveStub) + }) + + it('should make completion request when edit suggestion is not active', async () => { + // Mock EditSuggestionState to return false (no edit suggestion active) + const isEditSuggestionActiveStub = sandbox + .stub(EditSuggestionState, 'isEditSuggestionActive') + .returns(false) + + const mockResult = { + sessionId: 'test-session', + items: [mockInlineCompletionItemOne], + partialResultToken: undefined, + } + + sendRequestStub.resolves(mockResult) + + await service.getAllRecommendations( + languageClient, + mockDocument, + mockPosition, + mockContext, + mockToken, + true, + mockDocumentEventListener + ) + + // Verify sendRequest was called for both APIs + const cs = sendRequestStub.getCalls() + const completionCalls = cs.filter((c) => c.firstArg === completionApi) + const editCalls = cs.filter((c) => c.firstArg === editApi) + + assert.strictEqual(cs.length, 2) // Both calls + assert.strictEqual(completionCalls.length, 1) // One completion call + assert.strictEqual(editCalls.length, 1) // One edit call + + // Verify the stub was called + sinon.assert.calledOnce(isEditSuggestionActiveStub) + }) }) }) diff --git a/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts b/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts index b88e30487a5..0a8cde5bacf 100644 --- a/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts +++ b/packages/amazonq/test/unit/app/inline/EditRendering/displayImage.test.ts @@ -6,7 +6,30 @@ import * as vscode from 'vscode' import * as sinon from 'sinon' import assert from 'assert' -import { EditDecorationManager } from '../../../../../src/app/inline/EditRendering/displayImage' +import { EditDecorationManager, displaySvgDecoration } from '../../../../../src/app/inline/EditRendering/displayImage' +import { EditSuggestionState } from '../../../../../src/app/inline/editSuggestionState' + +// Shared helper function to create common stubs +function createCommonStubs(sandbox: sinon.SinonSandbox) { + const documentStub = { + getText: sandbox.stub().returns('Original code content'), + uri: vscode.Uri.file('/test/file.ts'), + lineAt: sandbox.stub().returns({ + text: 'Line text content', + range: new vscode.Range(0, 0, 0, 18), + rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 19), + firstNonWhitespaceCharacterIndex: 0, + isEmptyOrWhitespace: false, + }), + } as unknown as sinon.SinonStubbedInstance + + const editorStub = { + document: documentStub, + setDecorations: sandbox.stub(), + } as unknown as sinon.SinonStubbedInstance + + return { documentStub, editorStub } +} describe('EditDecorationManager', function () { let sandbox: sinon.SinonSandbox @@ -25,23 +48,13 @@ describe('EditDecorationManager', function () { dispose: sandbox.stub(), } as unknown as sinon.SinonStubbedInstance - documentStub = { - getText: sandbox.stub().returns('Original code content'), - lineCount: 5, - lineAt: sandbox.stub().returns({ - text: 'Line text content', - range: new vscode.Range(0, 0, 0, 18), - rangeIncludingLineBreak: new vscode.Range(0, 0, 0, 19), - firstNonWhitespaceCharacterIndex: 0, - isEmptyOrWhitespace: false, - }), - } as unknown as sinon.SinonStubbedInstance - - editorStub = { - document: documentStub, - setDecorations: sandbox.stub(), - edit: sandbox.stub().resolves(true), - } as unknown as sinon.SinonStubbedInstance + const commonStubs = createCommonStubs(sandbox) + documentStub = commonStubs.documentStub + editorStub = commonStubs.editorStub + + // Add additional properties needed for this test suite - extend the stub objects + Object.assign(documentStub, { lineCount: 5 }) + Object.assign(editorStub, { edit: sandbox.stub().resolves(true) }) windowStub = sandbox.stub(vscode.window) windowStub.createTextEditorDecorationType.returns(decorationTypeStub as any) @@ -174,3 +187,124 @@ describe('EditDecorationManager', function () { sinon.assert.calledWith(editorStub.setDecorations.secondCall, manager['removedCodeDecorationType'], []) }) }) + +describe('displaySvgDecoration cursor distance auto-reject', function () { + let sandbox: sinon.SinonSandbox + let editorStub: sinon.SinonStubbedInstance + let windowStub: sinon.SinonStub + let commandsStub: sinon.SinonStub + let editSuggestionStateStub: sinon.SinonStub + let onDidChangeTextEditorSelectionStub: sinon.SinonStub + let selectionChangeListener: (e: vscode.TextEditorSelectionChangeEvent) => void + + // Helper function to setup displaySvgDecoration + async function setupDisplaySvgDecoration(startLine: number) { + return await displaySvgDecoration( + editorStub as unknown as vscode.TextEditor, + vscode.Uri.parse('data:image/svg+xml;base64,test'), + startLine, + 'new code', + [], + {} as any, + {} as any, + { itemId: 'test', insertText: 'patch' } as any + ) + } + + // Helper function to create selection change event + function createSelectionChangeEvent(line: number): vscode.TextEditorSelectionChangeEvent { + const position = new vscode.Position(line, 0) + const selection = new vscode.Selection(position, position) + return { + textEditor: editorStub, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + } as vscode.TextEditorSelectionChangeEvent + } + + beforeEach(function () { + sandbox = sinon.createSandbox() + + const commonStubs = createCommonStubs(sandbox) + editorStub = commonStubs.editorStub + + // Mock vscode.window.onDidChangeTextEditorSelection + onDidChangeTextEditorSelectionStub = sandbox.stub() + onDidChangeTextEditorSelectionStub.returns({ dispose: sandbox.stub() }) + windowStub = sandbox.stub(vscode.window, 'onDidChangeTextEditorSelection') + windowStub.callsFake((callback) => { + selectionChangeListener = callback + return { dispose: sandbox.stub() } + }) + + // Mock vscode.commands.executeCommand + commandsStub = sandbox.stub(vscode.commands, 'executeCommand') + + // Mock EditSuggestionState + editSuggestionStateStub = sandbox.stub(EditSuggestionState, 'isEditSuggestionActive') + editSuggestionStateStub.returns(true) + + // Mock other required dependencies + sandbox.stub(vscode.workspace, 'onDidChangeTextDocument').returns({ dispose: sandbox.stub() }) + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should not reject when cursor moves less than 25 lines away', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 24)) + + sinon.assert.notCalled(commandsStub) + }) + + it('should not reject when cursor moves exactly 25 lines away', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 25)) + + sinon.assert.notCalled(commandsStub) + }) + + it('should reject when cursor moves more than 25 lines away', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 26)) + + sinon.assert.calledOnceWithExactly(commandsStub, 'aws.amazonq.inline.rejectEdit') + }) + + it('should reject when cursor moves more than 25 lines before the edit', async function () { + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine - 26)) + + sinon.assert.calledOnceWithExactly(commandsStub, 'aws.amazonq.inline.rejectEdit') + }) + + it('should not reject when edit is near beginning of file and cursor cannot move far enough', async function () { + const startLine = 10 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(0)) + + sinon.assert.notCalled(commandsStub) + }) + + it('should not reject when edit suggestion is not active', async function () { + editSuggestionStateStub.returns(false) + + const startLine = 50 + await setupDisplaySvgDecoration(startLine) + + selectionChangeListener(createSelectionChangeEvent(startLine + 100)) + + sinon.assert.notCalled(commandsStub) + }) +}) diff --git a/packages/amazonq/test/unit/app/inline/completion.test.ts b/packages/amazonq/test/unit/app/inline/completion.test.ts new file mode 100644 index 00000000000..bd38b1c95af --- /dev/null +++ b/packages/amazonq/test/unit/app/inline/completion.test.ts @@ -0,0 +1,210 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import * as sinon from 'sinon' +import { AmazonQInlineCompletionItemProvider } from '../../../../src/app/inline/completion' + +describe('AmazonQInlineCompletionItemProvider', function () { + let provider: AmazonQInlineCompletionItemProvider + let mockLanguageClient: any + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockLanguageClient = { + sendNotification: sandbox.stub(), + } + + // Create provider with minimal mocks + provider = new AmazonQInlineCompletionItemProvider( + mockLanguageClient, + {} as any, // recommendationService + {} as any, // sessionManager + {} as any, // inlineTutorialAnnotation + {} as any // documentEventListener + ) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('batchDiscardTelemetryForEditSuggestion', function () { + it('should batch multiple completion items into single telemetry event', function () { + const items = [ + { itemId: 'item1', isInlineEdit: false }, + { itemId: 'item2', isInlineEdit: false }, + { itemId: 'item3', isInlineEdit: false }, + ] + + const session = { + sessionId: 'test-session', + firstCompletionDisplayLatency: 100, + requestStartTime: performance.now() - 1000, + } + + provider.batchDiscardTelemetryForEditSuggestion(items, session) + + // Verify single telemetry notification was sent + assert.strictEqual(mockLanguageClient.sendNotification.callCount, 1) + + // Verify the notification contains all items + const call = mockLanguageClient.sendNotification.getCall(0) + const params = call.args[1] + + assert.strictEqual(params.sessionId, 'test-session') + assert.strictEqual(Object.keys(params.completionSessionResult).length, 3) + assert.deepStrictEqual(params.completionSessionResult.item1, { + seen: false, + accepted: false, + discarded: true, + }) + assert.deepStrictEqual(params.completionSessionResult.item2, { + seen: false, + accepted: false, + discarded: true, + }) + assert.deepStrictEqual(params.completionSessionResult.item3, { + seen: false, + accepted: false, + discarded: true, + }) + }) + + it('should filter out inline edit items', function () { + const items = [ + { itemId: 'item1', isInlineEdit: false }, + { itemId: 'item2', isInlineEdit: true }, // Should be filtered out + { itemId: 'item3', isInlineEdit: false }, + ] + + const session = { + sessionId: 'test-session', + firstCompletionDisplayLatency: 100, + requestStartTime: performance.now() - 1000, + } + + provider.batchDiscardTelemetryForEditSuggestion(items, session) + + const call = mockLanguageClient.sendNotification.getCall(0) + const params = call.args[1] + + // Should only include 2 items (item2 filtered out) + assert.strictEqual(Object.keys(params.completionSessionResult).length, 2) + assert.ok(params.completionSessionResult.item1) + assert.ok(params.completionSessionResult.item3) + assert.ok(!params.completionSessionResult.item2) + }) + + it('should not send notification when no valid items', function () { + const items = [ + { itemId: 'item1', isInlineEdit: true }, // Filtered out + { itemId: undefined, isInlineEdit: false }, // No itemId + ] + + const session = { + sessionId: 'test-session', + firstCompletionDisplayLatency: 100, + requestStartTime: performance.now() - 1000, + } + + provider.batchDiscardTelemetryForEditSuggestion(items, session) + + // No notification should be sent + assert.strictEqual(mockLanguageClient.sendNotification.callCount, 0) + }) + }) + + describe('isCompletionActive', function () { + let mockSessionManager: any + let mockVscodeCommands: any + + beforeEach(function () { + mockSessionManager = { + getActiveSession: sandbox.stub(), + } + + // Mock vscode.commands.executeCommand + mockVscodeCommands = sandbox.stub(require('vscode').commands, 'executeCommand') + + // Create provider with mocked session manager + provider = new AmazonQInlineCompletionItemProvider( + mockLanguageClient, + {} as any, // recommendationService + mockSessionManager, + {} as any, // inlineTutorialAnnotation + {} as any // documentEventListener + ) + }) + + it('should return false when no active session', async function () { + mockSessionManager.getActiveSession.returns(undefined) + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 0) + }) + + it('should return false when session not displayed', async function () { + mockSessionManager.getActiveSession.returns({ + displayed: false, + suggestions: [{ isInlineEdit: false }], + lastVisibleTime: 0, + }) + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 0) + }) + + it('should return false when session has inline edit suggestions', async function () { + mockSessionManager.getActiveSession.returns({ + displayed: true, + suggestions: [{ isInlineEdit: true }], + lastVisibleTime: performance.now(), + }) + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 0) + }) + + it('should return true when VS Code command executes successfully', async function () { + const currentTime = performance.now() + mockSessionManager.getActiveSession.returns({ + displayed: true, + suggestions: [{ isInlineEdit: false }], + lastVisibleTime: currentTime, // Recent timestamp + }) + mockVscodeCommands.resolves() + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, true) + assert.strictEqual(mockVscodeCommands.callCount, 1) + assert.strictEqual(mockVscodeCommands.getCall(0).args[0], 'aws.amazonq.checkInlineSuggestionVisibility') + }) + + it('should return false when VS Code command fails', async function () { + const oldTime = performance.now() - 100 // Old timestamp (>50ms ago) + mockSessionManager.getActiveSession.returns({ + displayed: true, + suggestions: [{ isInlineEdit: false }], + lastVisibleTime: oldTime, + }) + mockVscodeCommands.resolves() // Command doesn't fail, but timestamp is old + + const result = await provider.isCompletionActive() + + assert.strictEqual(result, false) + assert.strictEqual(mockVscodeCommands.callCount, 1) + assert.strictEqual(mockVscodeCommands.getCall(0).args[0], 'aws.amazonq.checkInlineSuggestionVisibility') + }) + }) +}) diff --git a/packages/core/src/amazonqGumby/chat/controller/controller.ts b/packages/core/src/amazonqGumby/chat/controller/controller.ts index 90706cbf731..a3d047bbbdc 100644 --- a/packages/core/src/amazonqGumby/chat/controller/controller.ts +++ b/packages/core/src/amazonqGumby/chat/controller/controller.ts @@ -58,7 +58,7 @@ import { import { getAuthType } from '../../../auth/utils' import fs from '../../../shared/fs/fs' import { setContext } from '../../../shared/vscode/setContext' -import { readHistoryFile } from '../../../codewhisperer/service/transformByQ/transformationHubViewProvider' +import { readHistoryFile } from '../../../codewhisperer/service/transformByQ/transformationHistoryHandler' // These events can be interactions within the chat, // or elsewhere in the IDE diff --git a/packages/core/src/codewhisperer/commands/startTransformByQ.ts b/packages/core/src/codewhisperer/commands/startTransformByQ.ts index 209b9628a73..aa8bea11da2 100644 --- a/packages/core/src/codewhisperer/commands/startTransformByQ.ts +++ b/packages/core/src/codewhisperer/commands/startTransformByQ.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode' import * as fs from 'fs' // eslint-disable-line no-restricted-imports -import os from 'os' import path from 'path' import { getLogger } from '../../shared/logger/logger' import * as CodeWhispererConstants from '../models/constants' @@ -79,6 +78,12 @@ import { convertDateToTimestamp } from '../../shared/datetime' import { findStringInDirectory } from '../../shared/utilities/workspaceUtils' import { makeTemporaryToolkitFolder } from '../../shared/filesystemUtilities' import { AuthUtil } from '../util/authUtil' +import { + cleanupTempJobFiles, + createMetadataFile, + JobMetadata, + writeToHistoryFile, +} from '../service/transformByQ/transformationHistoryHandler' export function getFeedbackCommentData() { const jobId = transformByQState.getJobId() @@ -477,28 +482,21 @@ export async function startTransformationJob( }) // create local history folder(s) and store metadata - const jobHistoryPath = path.join(os.homedir(), '.aws', 'transform', transformByQState.getProjectName(), jobId) - if (!fs.existsSync(jobHistoryPath)) { - fs.mkdirSync(jobHistoryPath, { recursive: true }) + const metadata: JobMetadata = { + jobId: jobId, + projectName: transformByQState.getProjectName(), + transformationType: transformByQState.getTransformationType() ?? TransformationType.LANGUAGE_UPGRADE, + sourceJDKVersion: transformByQState.getSourceJDKVersion() ?? JDKVersion.JDK8, + targetJDKVersion: transformByQState.getTargetJDKVersion() ?? JDKVersion.JDK17, + customDependencyVersionFilePath: transformByQState.getCustomDependencyVersionFilePath(), + customBuildCommand: transformByQState.getCustomBuildCommand(), + targetJavaHome: transformByQState.getTargetJavaHome() ?? '', + projectPath: transformByQState.getProjectPath(), + startTime: transformByQState.getStartTime(), } - transformByQState.setJobHistoryPath(jobHistoryPath) - // save a copy of the upload zip - fs.copyFileSync(transformByQState.getPayloadFilePath(), path.join(jobHistoryPath, 'zipped-code.zip')) - const fields = [ - jobId, - transformByQState.getTransformationType(), - transformByQState.getSourceJDKVersion(), - transformByQState.getTargetJDKVersion(), - transformByQState.getCustomDependencyVersionFilePath(), - transformByQState.getCustomBuildCommand(), - transformByQState.getTargetJavaHome(), - transformByQState.getProjectPath(), - transformByQState.getStartTime(), - ] - - const jobDetails = fields.join('\t') - fs.writeFileSync(path.join(jobHistoryPath, 'metadata.txt'), jobDetails) + const jobHistoryPath = await createMetadataFile(transformByQState.getPayloadFilePath(), metadata) + transformByQState.setJobHistoryPath(jobHistoryPath) } catch (error) { getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToStartJobNotification}`, error) const errorMessage = (error as Error).message.toLowerCase() @@ -749,24 +747,11 @@ export async function postTransformationJob() { }) } - // delete original upload ZIP at very end of transformation - fs.rmSync(transformByQState.getPayloadFilePath(), { force: true }) - - if ( - transformByQState.isSucceeded() || - transformByQState.isPartiallySucceeded() || - transformByQState.isCancelled() - ) { - // delete the copy of the upload ZIP - fs.rmSync(path.join(transformByQState.getJobHistoryPath(), 'zipped-code.zip'), { force: true }) - // delete transformation job metadata file (no longer needed) - fs.rmSync(path.join(transformByQState.getJobHistoryPath(), 'metadata.txt'), { force: true }) - } - // delete temporary build logs file - const logFilePath = path.join(os.tmpdir(), 'build-logs.txt') - if (fs.existsSync(logFilePath)) { - fs.rmSync(logFilePath, { force: true }) - } + await cleanupTempJobFiles( + transformByQState.getJobHistoryPath(), + transformByQState.getPolledJobStatus(), + transformByQState.getPayloadFilePath() + ) // attempt download for user // TODO: refactor as explained here https://github.com/aws/aws-toolkit-vscode/pull/6519/files#r1946873107 @@ -777,35 +762,14 @@ export async function postTransformationJob() { // store job details and diff path locally (history) // TODO: ideally when job is cancelled, should be stored as CANCELLED instead of FAILED (remove this if statement after bug is fixed) if (!transformByQState.isCancelled()) { - const historyLogFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') - // create transform folder if necessary - if (!fs.existsSync(historyLogFilePath)) { - fs.mkdirSync(path.dirname(historyLogFilePath), { recursive: true }) - // create headers of new transformation history file - fs.writeFileSync(historyLogFilePath, 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n') - } const latest = sessionJobHistory[transformByQState.getJobId()] - const fields = [ + await writeToHistoryFile( latest.startTime, latest.projectName, latest.status, latest.duration, - transformByQState.isSucceeded() || transformByQState.isPartiallySucceeded() - ? path.join(transformByQState.getJobHistoryPath(), 'diff.patch') - : '', - transformByQState.isSucceeded() || transformByQState.isPartiallySucceeded() - ? path.join(transformByQState.getJobHistoryPath(), 'summary', 'summary.md') - : '', transformByQState.getJobId(), - ] - - const jobDetails = fields.join('\t') + '\n' - fs.writeFileSync(historyLogFilePath, jobDetails, { flag: 'a' }) // 'a' flag used to append to file - await vscode.commands.executeCommand( - 'aws.amazonq.transformationHub.updateContent', - 'job history', - undefined, - true + transformByQState.getJobHistoryPath() ) } } diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index 3e72ca1de19..f3bbfb07d85 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -547,7 +547,7 @@ export const noChangesMadeMessage = "I didn't make any changes for this transfor export const noOngoingJobMessage = 'No ongoing job.' -export const nothingToShowMessage = 'Nothing to show' +export const noJobHistoryMessage = 'No job history' export const jobStartedNotification = 'Amazon Q is transforming your code. It can take 10 to 30 minutes to upgrade your code, depending on the size of your project. To monitor progress, go to the Transformation Hub.' @@ -941,13 +941,3 @@ export const displayFindingsSuffix = '_displayFindings' export const displayFindingsDetectorName = 'DisplayFindings' export const findingsSuffix = '_codeReviewFindings' - -export interface HistoryObject { - startTime: string - projectName: string - status: string - duration: string - diffPath: string - summaryPath: string - jobId: string -} diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts index 6aad4fd15f6..400acd5fa7a 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformFileHandler.ts @@ -17,6 +17,8 @@ import { AbsolutePathDetectedError } from '../../../amazonqGumby/errors' import { getLogger } from '../../../shared/logger/logger' import AdmZip from 'adm-zip' import { IManifestFile } from './humanInTheLoopManager' +import { ExportResultArchiveStructure } from '../../../shared/utilities/download' +import { isFileNotFoundError } from '../../../shared/errors' export async function getDependenciesFolderInfo(): Promise { const dependencyFolderName = `${CodeWhispererConstants.dependencyFolderName}${globals.clock.Date.now()}` @@ -348,3 +350,40 @@ export async function parseVersionsListFromPomFile(xmlString: string): Promise { + const history: HistoryObject[] = [] + const jobHistoryFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') + + if (!(await fs.existsFile(jobHistoryFilePath))) { + return history + } + + const historyFile = await fs.readFileText(jobHistoryFilePath) + const jobs = historyFile.split('\n') + jobs.shift() // removes headers + + // Process from end, stop at 10 valid entries + for (let i = jobs.length - 1; i >= 0 && history.length < 10; i--) { + const job = jobs[i] + if (job && isWithin30Days(job.split('\t')[0])) { + const jobInfo = job.split('\t') + history.push({ + startTime: jobInfo[0], + projectName: jobInfo[1], + status: jobInfo[2], + duration: jobInfo[3], + diffPath: jobInfo[4], + summaryPath: jobInfo[5], + jobId: jobInfo[6], + }) + } + } + return history +} + +/** + * Creates temporary metadata JSON file with transformation config info and saves a copy of upload zip + * + * These files are used when a job is resumed after interruption + * + * @param payloadFilePath path to upload zip + * @param metadata + * @returns + */ +export async function createMetadataFile(payloadFilePath: string, metadata: JobMetadata): Promise { + const jobHistoryPath = path.join(os.homedir(), '.aws', 'transform', metadata.projectName, metadata.jobId) + + // create job history folders + await fs.mkdir(jobHistoryPath) + + // save a copy of the upload zip + try { + await fs.copy(payloadFilePath, path.join(jobHistoryPath, 'zipped-code.zip')) + } catch (error) { + getLogger().error('Code Transformation: error saving copy of upload zip: %s', (error as Error).message) + } + + // create metadata file with transformation config info + try { + await fs.writeFile(path.join(jobHistoryPath, 'metadata.json'), JSON.stringify(metadata)) + } catch (error) { + getLogger().error('Code Transformation: error creating metadata file: %s', (error as Error).message) + } + + return jobHistoryPath +} + +/** + * Writes job details to history file + * + * @param startTime job start timestamp (ex. "01/01/23, 12:00 AM") + * @param projectName + * @param status + * @param duration job duration in hr / min / sec format (ex. "1 hr 15 min") + * @param jobId + * @param jobHistoryPath path to where job's history details are stored (ex. "~/.aws/transform/proj_name/job_id") + */ +export async function writeToHistoryFile( + startTime: string, + projectName: string, + status: string, + duration: string, + jobId: string, + jobHistoryPath: string +) { + const historyLogFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') + // create transform folder if necessary + if (!(await fs.existsFile(historyLogFilePath))) { + await fs.mkdir(path.dirname(historyLogFilePath)) + // create headers of new transformation history file + await fs.writeFile(historyLogFilePath, 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n') + } + const artifactsExist = status === 'COMPLETED' || status === 'PARTIALLY_COMPLETED' + const fields = [ + startTime, + projectName, + status, + duration, + artifactsExist ? path.join(jobHistoryPath, 'diff.patch') : '', + artifactsExist ? path.join(jobHistoryPath, 'summary', 'summary.md') : '', + jobId, + ] + + const jobDetails = fields.join('\t') + '\n' + await fs.appendFile(historyLogFilePath, jobDetails) + + // update Transformation Hub table + await vscode.commands.executeCommand('aws.amazonq.transformationHub.updateContent', 'job history', undefined, true) +} + +/** + * Delete temporary files at the end of a transformation + * + * @param jobHistoryPath path to history directory for this job + * @param jobStatus final transformation status + * @param payloadFilePath path to original upload zip; providing this param will also delete any temp build logs + */ +export async function cleanupTempJobFiles(jobHistoryPath: string, jobStatus: string, payloadFilePath?: string) { + if (payloadFilePath) { + // delete original upload ZIP + await fs.delete(payloadFilePath, { force: true }) + // delete temporary build logs file + const logFilePath = path.join(os.tmpdir(), 'build-logs.txt') + await fs.delete(logFilePath, { force: true }) + } + + // delete metadata file and upload zip copy if no longer need them (i.e. will not be resuming) + if (jobStatus !== 'FAILED') { + await fs.delete(path.join(jobHistoryPath, 'metadata.json'), { force: true }) + await fs.delete(path.join(jobHistoryPath, 'zipped-code.zip'), { force: true }) + } +} + +/* Job refresh-related functions */ + +export async function refreshJob(jobId: string, currentStatus: string, projectName: string) { + // fetch status from server + let status = '' + let duration = '' + if (currentStatus === 'COMPLETED' || currentStatus === 'PARTIALLY_COMPLETED') { + // job is already completed, no need to fetch status + status = currentStatus + } else { + try { + const response = await codeWhispererClient.codeModernizerGetCodeTransformation({ + transformationJobId: jobId, + profileArn: undefined, + }) + status = response.transformationJob.status ?? currentStatus + if (response.transformationJob.endExecutionTime && response.transformationJob.creationTime) { + duration = convertToTimeString( + response.transformationJob.endExecutionTime.getTime() - + response.transformationJob.creationTime.getTime() + ) + } + + getLogger().debug( + 'Code Transformation: Job refresh - Fetched status for job id: %s\n{Status: %s; Duration: %s}', + jobId, + status, + duration + ) + } catch (error) { + const errorMessage = (error as Error).message + getLogger().error('Code Transformation: Error fetching status (job id: %s): %s', jobId, errorMessage) + if (errorMessage.includes('not authorized to make this call')) { + // job not available on backend + status = 'FAILED' // won't allow retries for this job + } else { + // some other error (e.g. network error) + return + } + } + } + + // retrieve artifacts and updated duration if available + let jobHistoryPath: string = '' + if (status === 'COMPLETED' || status === 'PARTIALLY_COMPLETED') { + // artifacts should be available to download + jobHistoryPath = await retrieveArtifacts(jobId, projectName) + + await cleanupTempJobFiles(path.join(os.homedir(), '.aws', 'transform', projectName, jobId), status) + } else if (CodeWhispererConstants.validStatesForBuildSucceeded.includes(status)) { + // still in progress on server side + if (transformByQState.isRunning()) { + getLogger().warn( + 'Code Transformation: There is a job currently running (id: %s). Cannot resume another job (id: %s)', + transformByQState.getJobId(), + jobId + ) + return + } + transformByQState.setRefreshInProgress(true) + const messenger = transformByQState.getChatMessenger() + const tabID = ChatSessionManager.Instance.getSession().tabID + messenger?.sendJobRefreshInProgressMessage(tabID!, jobId) + await vscode.commands.executeCommand('aws.amazonq.transformationHub.updateContent', 'job history') // refreshing the table disables all jobs' refresh buttons while this one is resuming + + // resume job and bring to completion + try { + status = await resumeJob(jobId, projectName, status) + } catch (error) { + getLogger().error('Code Transformation: Error resuming job (id: %s): %s', jobId, (error as Error).message) + transformByQState.setJobDefaults() + messenger?.sendJobFinishedMessage(tabID!, CodeWhispererConstants.refreshErrorChatMessage) + void vscode.window.showErrorMessage(CodeWhispererConstants.refreshErrorNotification(jobId)) + await vscode.commands.executeCommand('aws.amazonq.transformationHub.updateContent', 'job history') + return + } + + // download artifacts if available + if ( + CodeWhispererConstants.validStatesForCheckingDownloadUrl.includes(status) && + !CodeWhispererConstants.failureStates.includes(status) + ) { + duration = convertToTimeString(Date.now() - new Date(transformByQState.getStartTime()).getTime()) + jobHistoryPath = await retrieveArtifacts(jobId, projectName) + } + + // reset state + transformByQState.setJobDefaults() + messenger?.sendJobFinishedMessage(tabID!, CodeWhispererConstants.refreshCompletedChatMessage) + } else { + // FAILED or STOPPED job + getLogger().info('Code Transformation: No artifacts available to download (job status = %s)', status) + if (status === 'FAILED') { + // if job failed on backend, mark it to disable the refresh button + status = 'FAILED_BE' // this will be truncated to just 'FAILED' in the table + } + await cleanupTempJobFiles(path.join(os.homedir(), '.aws', 'transform', projectName, jobId), status) + } + + if (status === currentStatus && !jobHistoryPath) { + // no changes, no need to update file/table + void vscode.window.showInformationMessage(CodeWhispererConstants.refreshNoUpdatesNotification(jobId)) + return + } + + void vscode.window.showInformationMessage(CodeWhispererConstants.refreshCompletedNotification(jobId)) + // update local file and history table + + await updateHistoryFile(status, duration, jobHistoryPath, jobId) +} + +async function retrieveArtifacts(jobId: string, projectName: string) { + const resultsPath = path.join(os.homedir(), '.aws', 'transform', projectName, 'results') // temporary directory for extraction + let jobHistoryPath = path.join(os.homedir(), '.aws', 'transform', projectName, jobId) + + if (await fs.existsFile(path.join(jobHistoryPath, 'diff.patch'))) { + getLogger().info('Code Transformation: Diff patch already exists for job id: %s', jobId) + jobHistoryPath = '' + } else { + try { + await downloadAndExtractResultArchive(jobId, resultsPath) + await copyArtifacts(resultsPath, jobHistoryPath) + } catch (error) { + jobHistoryPath = '' + } finally { + // delete temporary extraction directory + await fs.delete(resultsPath, { recursive: true, force: true }) + } + } + return jobHistoryPath +} + +async function updateHistoryFile(status: string, duration: string, jobHistoryPath: string, jobId: string) { + const history: string[][] = [] + const historyLogFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') + if (await fs.existsFile(historyLogFilePath)) { + const historyFile = await fs.readFileText(historyLogFilePath) + const jobs = historyFile.split('\n') + jobs.shift() // removes headers + if (jobs.length > 0) { + for (const job of jobs) { + if (job) { + const jobInfo = job.split('\t') + // startTime: jobInfo[0], projectName: jobInfo[1], status: jobInfo[2], duration: jobInfo[3], diffPath: jobInfo[4], summaryPath: jobInfo[5], jobId: jobInfo[6] + if (jobInfo[6] === jobId) { + // update any values if applicable + jobInfo[2] = status + if (duration) { + jobInfo[3] = duration + } + if (jobHistoryPath) { + jobInfo[4] = path.join(jobHistoryPath, 'diff.patch') + jobInfo[5] = path.join(jobHistoryPath, 'summary', 'summary.md') + } + } + history.push(jobInfo) + } + } + } + } + + if (history.length === 0) { + return + } + + // rewrite file + await fs.writeFile(historyLogFilePath, 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n') + const tsvContent = history.map((row) => row.join('\t')).join('\n') + '\n' + await fs.appendFile(historyLogFilePath, tsvContent) + + // update table content + await vscode.commands.executeCommand('aws.amazonq.transformationHub.updateContent', 'job history', undefined, true) +} + +async function resumeJob(jobId: string, projectName: string, status: string) { + // set state to prepare to resume job + await setupTransformationState(jobId, projectName, status) + // resume polling the job + return await pollAndCompleteTransformation(jobId) +} + +async function setupTransformationState(jobId: string, projectName: string, status: string) { + transformByQState.setJobId(jobId) + transformByQState.setPolledJobStatus(status) + transformByQState.setJobHistoryPath(path.join(os.homedir(), '.aws', 'transform', projectName, jobId)) + + const metadata: JobMetadata = JSON.parse( + await fs.readFileText(path.join(transformByQState.getJobHistoryPath(), 'metadata.json')) + ) + transformByQState.setTransformationType(metadata.transformationType) + transformByQState.setSourceJDKVersion(metadata.sourceJDKVersion) + transformByQState.setTargetJDKVersion(metadata.targetJDKVersion) + transformByQState.setCustomDependencyVersionFilePath(metadata.customDependencyVersionFilePath) + transformByQState.setPayloadFilePath( + path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip') + ) + setMaven() + transformByQState.setCustomBuildCommand(metadata.customBuildCommand) + transformByQState.setTargetJavaHome(metadata.targetJavaHome) + transformByQState.setProjectPath(metadata.projectPath) + transformByQState.setStartTime(metadata.startTime) +} + +async function pollAndCompleteTransformation(jobId: string) { + const status = await pollTransformationJob( + jobId, + CodeWhispererConstants.validStatesForCheckingDownloadUrl, + AuthUtil.instance.regionProfileManager.activeRegionProfile + ) + await cleanupTempJobFiles(transformByQState.getJobHistoryPath(), status, transformByQState.getPayloadFilePath()) + return status +} diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts index fe09e203919..35e8319ab46 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode' import globals from '../../../shared/extensionGlobals' import * as CodeWhispererConstants from '../../models/constants' import { - JDKVersion, StepProgress, TransformationType, jobPlanProgress, @@ -15,29 +14,24 @@ import { transformByQState, } from '../../models/model' import { getLogger } from '../../../shared/logger/logger' -import { getTransformationSteps, downloadAndExtractResultArchive } from './transformApiHandler' +import { getTransformationSteps } from './transformApiHandler' import { TransformationSteps, ProgressUpdates, TransformationStatus, } from '../../../codewhisperer/client/codewhispereruserclient' -import { codeWhispererClient } from '../../../codewhisperer/client/codewhisperer' -import { startInterval, pollTransformationStatusUntilComplete } from '../../commands/startTransformByQ' +import { startInterval } from '../../commands/startTransformByQ' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' -import { convertToTimeString, isWithin30Days } from '../../../shared/datetime' +import { convertToTimeString } from '../../../shared/datetime' import { AuthUtil } from '../../util/authUtil' -import fs from '../../../shared/fs/fs' -import path from 'path' -import os from 'os' -import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSession' -import { setMaven } from './transformFileHandler' +import { refreshJob, readHistoryFile, HistoryObject } from './transformationHistoryHandler' export class TransformationHubViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'aws.amazonq.transformationHub' private _view?: vscode.WebviewView private lastClickedButton: string = '' private _extensionUri: vscode.Uri = globals.context.extensionUri - private transformationHistory: CodeWhispererConstants.HistoryObject[] = [] + private transformationHistory: HistoryObject[] = [] constructor() {} static #instance: TransformationHubViewProvider @@ -84,7 +78,7 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider this._view.webview.onDidReceiveMessage((message) => { switch (message.command) { case 'refreshJob': - void this.refreshJob(message.jobId, message.currentStatus, message.projectName) + void refreshJob(message.jobId, message.currentStatus, message.projectName) break case 'openSummaryPreview': void vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(message.filePath)) @@ -115,7 +109,7 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider } private showJobHistory(): string { - const jobsToDisplay: CodeWhispererConstants.HistoryObject[] = [...this.transformationHistory] + const jobsToDisplay: HistoryObject[] = [...this.transformationHistory] if (transformByQState.isRunning()) { const current = sessionJobHistory[transformByQState.getJobId()] jobsToDisplay.unshift({ @@ -143,7 +137,7 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider

${CodeWhispererConstants.transformationHistoryTableDescription}

${ jobsToDisplay.length === 0 - ? `


${CodeWhispererConstants.nothingToShowMessage}

` + ? `


${CodeWhispererConstants.noJobHistoryMessage}

` : this.getTableMarkup(jobsToDisplay) }