diff --git a/.gitignore b/.gitignore index 5911d305a05..538ad4f1c2e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ packages/*/resources/css/icons.css .vscode-test-web # Generated by E2E UI Tests -packages/amazonq/test/e2e/amazonq/resources \ No newline at end of file +packages/amazonq/test/e2e/amazonq/resources +packages/amazonq/test/e2e_new/amazonq/resources \ No newline at end of file diff --git a/buildspec/linuxTests.yml b/buildspec/linuxTests.yml index 900b720e61a..241b5bb193a 100644 --- a/buildspec/linuxTests.yml +++ b/buildspec/linuxTests.yml @@ -48,7 +48,7 @@ phases: - VCS_COMMIT_ID="${CODEBUILD_RESOLVED_SOURCE_VERSION}" - CI_BUILD_URL=$(echo $CODEBUILD_BUILD_URL | sed 's/#/%23/g') # Encode `#` in the URL because otherwise the url is clipped in the Codecov.io site - CI_BUILD_ID="${CODEBUILD_BUILD_ID}" - - test -n "${CODECOV_TOKEN}" && [ "$TARGET_BRANCH" = "master" ] && ./codecov --token=${CODECOV_TOKEN} --branch=${CODEBUILD_RESOLVED_SOURCE_VERSION} --repository=${CODEBUILD_SOURCE_REPO_URL} --file=./coverage/amazonq/lcov.info --file=./coverage/toolkit/lcov.info + - test -n "${CODECOV_TOKEN}" && [ "$TARGET_BRANCH" = "master" ] && ./codecov --token=${CODECOV_TOKEN} --branch=${CODEBUILD_RESOLVED_SOURCE_VERSION} --repository=${CODEBUILD_SOURCE_REPO_URL} --file=./coverage/amazonq/lcov.info --file=./coverage/toolkit/lcov.info || true reports: unit-test: diff --git a/package-lock.json b/package-lock.json index fc3d52b78bc..796c251a405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14744,12 +14744,12 @@ } }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.116", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.116.tgz", - "integrity": "sha512-wJoNfbDt/OBEuaseXpeMJTYYndpuoAdPNQkVJdRYAgajzCvWZp/yOdgHu4JNoRo949rLYVRidLTxJo7YVc/LQA==", + "version": "0.2.119", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.119.tgz", + "integrity": "sha512-zHonaOBuZ9K81/EQ1hg6ieu45YK7J5M6kiFD/dpdwJwsU36Ia4rbnN2W5ZIDPryZ9Hx9WYpw72YBl+q8+6BdGQ==", "dev": true, "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.50", + "@aws/language-server-runtimes-types": "^0.1.51", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/core": "^2.0.0", @@ -14776,9 +14776,9 @@ } }, "node_modules/@aws/language-server-runtimes-types": { - "version": "0.1.50", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.50.tgz", - "integrity": "sha512-06JBOKQRJJB/Rg7looY6Xxbab6tIzouZ1QUDdOaFj4zjlbDodeGRXr4W1Oo0N7uz0N24tdoMiNvuky3U5fYmPQ==", + "version": "0.1.51", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.51.tgz", + "integrity": "sha512-TuCA821MSRCpO/1thhHaBRpKzU/CiHM/Bvd6quJRUKwvSb8/gTG1mSBp2YoHYx4p7FUZYBko2DKDmpaB1WfvUw==", "dev": true, "dependencies": { "vscode-languageserver-textdocument": "^1.0.12", @@ -16007,22 +16007,32 @@ }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1", @@ -16031,22 +16041,32 @@ }, "node_modules/@protobufjs/float": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, "node_modules/@redhat-developer/locators": { @@ -24828,7 +24848,9 @@ "license": "MIT" }, "node_modules/long": { - "version": "5.3.2", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", + "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==", "license": "Apache-2.0" }, "node_modules/lowercase-keys": { @@ -26631,7 +26653,9 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.1", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -31268,7 +31292,7 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.2", + "version": "8.17.1", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -31604,7 +31628,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.86.0-SNAPSHOT", + "version": "1.88.0-SNAPSHOT", "license": "Apache-2.0", "engines": { "npm": "^10.1.0", @@ -31710,7 +31734,7 @@ "@aws-sdk/types": "^3.13.1", "@aws/chat-client": "^0.1.4", "@aws/chat-client-ui-types": "^0.1.47", - "@aws/language-server-runtimes": "^0.2.111", + "@aws/language-server-runtimes": "^0.2.119", "@aws/language-server-runtimes-types": "^0.1.47", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", @@ -33437,7 +33461,7 @@ }, "packages/toolkit": { "name": "aws-toolkit-vscode", - "version": "3.70.0-SNAPSHOT", + "version": "3.71.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/.changes/1.86.0.json b/packages/amazonq/.changes/1.86.0.json new file mode 100644 index 00000000000..abe84ce5b5f --- /dev/null +++ b/packages/amazonq/.changes/1.86.0.json @@ -0,0 +1,18 @@ +{ + "date": "2025-07-30", + "version": "1.86.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Let Enter invoke auto completion more consistently" + }, + { + "type": "Bug Fix", + "description": "Faster and more responsive inline completion UX" + }, + { + "type": "Bug Fix", + "description": "Use documentChangeEvent as auto trigger condition" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/1.87.0.json b/packages/amazonq/.changes/1.87.0.json new file mode 100644 index 00000000000..d80e11a2bfa --- /dev/null +++ b/packages/amazonq/.changes/1.87.0.json @@ -0,0 +1,5 @@ +{ + "date": "2025-07-31", + "version": "1.87.0", + "entries": [] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Feature-dffec708-ae10-45d7-bcfd-b1c07a84de12.json b/packages/amazonq/.changes/next-release/Feature-dffec708-ae10-45d7-bcfd-b1c07a84de12.json new file mode 100644 index 00000000000..ec459d083f3 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-dffec708-ae10-45d7-bcfd-b1c07a84de12.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "/transform: Show transformation history in Transformation Hub and allow users to resume jobs" +} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index d96b350db8d..cd8b73bc470 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,13 @@ +## 1.87.0 2025-07-31 + +- Miscellaneous non-user-facing changes + +## 1.86.0 2025-07-30 + +- **Bug Fix** Let Enter invoke auto completion more consistently +- **Bug Fix** Faster and more responsive inline completion UX +- **Bug Fix** Use documentChangeEvent as auto trigger condition + ## 1.85.0 2025-07-19 - Miscellaneous non-user-facing changes diff --git a/packages/amazonq/README.md b/packages/amazonq/README.md index 46091a98d10..e3ec16bb2ac 100644 --- a/packages/amazonq/README.md +++ b/packages/amazonq/README.md @@ -3,39 +3,33 @@ [![Youtube Channel Views](https://img.shields.io/youtube/channel/views/UCd6MoB9NC6uYN2grvUNT-Zg?style=flat-square&logo=youtube&label=Youtube)](https://www.youtube.com/@amazonwebservices) ![Marketplace Installs](https://img.shields.io/vscode-marketplace/i/AmazonWebServices.amazon-q-vscode.svg?label=Installs&style=flat-square) -# Agent capabilities +# Agentic coding experience + +Amazon Q Developer uses information across native and MCP server-based tools to intelligently perform actions beyond code suggestions, such as reading files, generating code diffs, and running commands based on your natural language instruction. Simply type your prompt in your preferred language and Q Developer will provide continuous status updates and iteratively apply changes based on your feedback, helping you accomplish tasks faster. ### Implement new features -`/dev` to task Amazon Q with generating new code across your entire project and implement features. +Generate new code across your entire project and implement features. ### Generate documentation -`/doc` to task Amazon Q with writing API, technical design, and onboarding documentation. +Write API, technical design, and onboarding documentation. ### Automate code reviews -`/review` to ask Amazon Q to perform code reviews, flagging suspicious code patterns and assessing deployment risk. +Perform code reviews, flagging suspicious code patterns and assessing deployment risk. ### Generate unit tests -`/test` to ask Amazon Q to generate unit tests and add them to your project, helping you improve code quality, fast. - -### Transform workloads - -`/transform` to upgrade your Java applications in minutes, not weeks. +Generate unit tests and add them to your project, helping you improve code quality, fast.
# Core features -### Inline chat - -Seamlessly initiate chat within the inline coding experience. Select a section of code that you need assistance with and initiate chat within the editor to request actions such as "Optimize this code", "Add comments", or "Write tests". - -### Chat +### MCP support -Generate code, explain code, and get answers about software development. +Add Model Context Protocol (MCP) servers to give Amazon Q Developer access to important context. ### Inline suggestions @@ -43,9 +37,13 @@ Receive real-time code suggestions ranging from snippets to full functions based [_15+ languages supported including Python, TypeScript, Rust, Terraform, AWS Cloudformation, and more_](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/q-language-ide-support.html) -### Code reference log +### Inline chat + +Seamlessly chat within the inline coding experience. Select a section of code that you need assistance with and initiate chat within the editor to request actions such as "Optimize this code", "Add comments", or "Write tests". -Attribute code from Amazon Q that is similar to training data. When code suggestions similar to training data are accepted, they will be added to the code reference log. +### Chat + +Generate code, explain code, and get answers about software development.
@@ -55,8 +53,6 @@ Attribute code from Amazon Q that is similar to training data. When code suggest **Pro Tier** - if your organization is on the Amazon Q Developer Pro tier, log in with single sign-on. -![Authentication gif](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/auth-Q.gif) - # Troubleshooting & feedback [File a bug](https://github.com/aws/aws-toolkit-vscode/issues/new?assignees=&labels=bug&projects=&template=bug_report.md) or [submit a feature request](https://github.com/aws/aws-toolkit-vscode/issues/new?assignees=&labels=feature-request&projects=&template=feature_request.md) on our Github repository. diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 8ed6b465e49..041e608e84a 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -1,8 +1,8 @@ { "name": "amazon-q-vscode", "displayName": "Amazon Q", - "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.86.0-SNAPSHOT", + "description": "The most capable generative AI–powered assistant for software development.", + "version": "1.88.0-SNAPSHOT", "extensionKind": [ "workspace" ], @@ -743,7 +743,7 @@ }, { "command": "aws.amazonq.showHistoryInHub", - "title": "%AWS.command.q.transform.viewJobStatus%" + "title": "%AWS.command.q.transform.viewJobHistory%" }, { "command": "aws.amazonq.selectCustomization", @@ -849,15 +849,21 @@ }, { "command": "aws.amazonq.inline.acceptEdit", - "title": "%aws.amazonq.inline.acceptEdit%" + "title": "%AWS.amazonq.inline.acceptEdit%", + "category": "%AWS.amazonq.title%", + "enablement": "aws.codewhisperer.connected" }, { "command": "aws.amazonq.inline.rejectEdit", - "title": "%aws.amazonq.inline.rejectEdit%" + "title": "%AWS.amazonq.inline.rejectEdit%", + "category": "%AWS.amazonq.title%", + "enablement": "aws.codewhisperer.connected" }, { "command": "aws.amazonq.toggleNextEditPredictionPanel", - "title": "%aws.amazonq.toggleNextEditPredictionPanel%" + "title": "%AWS.amazonq.toggleNextEditPredictionPanel%", + "category": "%AWS.amazonq.title%", + "enablement": "aws.codewhisperer.connected" } ], "keybindings": [ diff --git a/packages/amazonq/src/app/amazonqScan/app.ts b/packages/amazonq/src/app/amazonqScan/app.ts index 21857163bd2..2b237ab534e 100644 --- a/packages/amazonq/src/app/amazonqScan/app.ts +++ b/packages/amazonq/src/app/amazonqScan/app.ts @@ -19,6 +19,7 @@ import { Messenger } from './chat/controller/messenger/messenger' import { UIMessageListener } from './chat/views/actions/uiMessageListener' import { debounce } from 'lodash' import { Commands, placeholder } from 'aws-core-vscode/shared' +import { codeReviewInChat } from './models/constants' export function init(appContext: AmazonQAppInitContext) { const scanChatControllerEventEmitters: ScanChatControllerEventEmitters = { @@ -74,17 +75,19 @@ export function init(appContext: AmazonQAppInitContext) { return debouncedEvent() }) - Commands.register('aws.amazonq.security.scan-statusbar', async () => { - if (AuthUtil.instance.isConnectionExpired()) { - await AuthUtil.instance.notifyReauthenticate() - } - return focusAmazonQPanel.execute(placeholder, 'amazonq.security.scan').then(() => { - DefaultAmazonQAppInitContext.instance.getAppsToWebViewMessagePublisher().publish({ - sender: 'amazonqCore', - command: 'review', + if (!codeReviewInChat) { + Commands.register('aws.amazonq.security.scan-statusbar', async () => { + if (AuthUtil.instance.isConnectionExpired()) { + await AuthUtil.instance.notifyReauthenticate() + } + return focusAmazonQPanel.execute(placeholder, 'amazonq.security.scan').then(() => { + DefaultAmazonQAppInitContext.instance.getAppsToWebViewMessagePublisher().publish({ + sender: 'amazonqCore', + command: 'review', + }) }) }) - }) + } codeScanState.setChatControllers(scanChatControllerEventEmitters) onDemandFileScanState.setChatControllers(scanChatControllerEventEmitters) diff --git a/packages/amazonq/src/app/amazonqScan/models/constants.ts b/packages/amazonq/src/app/amazonqScan/models/constants.ts index 93e815884e1..4180b130b78 100644 --- a/packages/amazonq/src/app/amazonqScan/models/constants.ts +++ b/packages/amazonq/src/app/amazonqScan/models/constants.ts @@ -97,3 +97,5 @@ const getIconForStep = (targetStep: number, currentStep: number) => { ? checkIcons.done : checkIcons.wait } + +export const codeReviewInChat = true diff --git a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts index 195879ff779..a3421c4be3e 100644 --- a/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts +++ b/packages/amazonq/src/app/inline/EditRendering/imageRenderer.ts @@ -26,8 +26,16 @@ export async function showEdits( const svgGenerationService = new SvgGenerationService() // Generate your SVG image with the file contents const currentFile = editor.document.uri.fsPath - const { svgImage, startLine, newCode, origionalCodeHighlightRange } = - await svgGenerationService.generateDiffSvg(currentFile, item.insertText as string) + const { svgImage, startLine, newCode, originalCodeHighlightRange } = await svgGenerationService.generateDiffSvg( + currentFile, + item.insertText as string + ) + + // TODO: To investigate why it fails and patch [generateDiffSvg] + if (newCode.length === 0) { + getLogger('nextEditPrediction').warn('not able to apply provided edit suggestion, skip rendering') + return + } // TODO: To investigate why it fails and patch [generateDiffSvg] if (newCode.length === 0) { @@ -42,7 +50,7 @@ export async function showEdits( svgImage, startLine, newCode, - origionalCodeHighlightRange, + originalCodeHighlightRange, session, languageClient, item, diff --git a/packages/amazonq/src/app/inline/EditRendering/svgGenerator.ts b/packages/amazonq/src/app/inline/EditRendering/svgGenerator.ts index 45a615e318e..6958be47f36 100644 --- a/packages/amazonq/src/app/inline/EditRendering/svgGenerator.ts +++ b/packages/amazonq/src/app/inline/EditRendering/svgGenerator.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { diffWordsWithSpace } from 'diff' +import { diffWordsWithSpace, diffLines } from 'diff' import * as vscode from 'vscode' import { ToolkitError, getLogger } from 'aws-core-vscode/shared' import { diffUtilities } from 'aws-core-vscode/shared' @@ -11,6 +11,12 @@ type Range = { line: number; start: number; end: number } const logger = getLogger('nextEditPrediction') export const imageVerticalOffset = 1 +export const emptyDiffSvg = { + svgImage: vscode.Uri.parse(''), + startLine: 0, + newCode: '', + originalCodeHighlightRange: [], +} export class SvgGenerationService { /** @@ -27,7 +33,7 @@ export class SvgGenerationService { svgImage: vscode.Uri startLine: number newCode: string - origionalCodeHighlightRange: Range[] + originalCodeHighlightRange: Range[] }> { const textDoc = await vscode.workspace.openTextDocument(filePath) const originalCode = textDoc.getText().replaceAll('\r\n', '\n') @@ -36,10 +42,6 @@ export class SvgGenerationService { throw new ToolkitError('udiff format error') } const newCode = await diffUtilities.getPatchedCode(filePath, udiff) - const modifiedLines = diffUtilities.getModifiedLinesFromUnifiedDiff(udiff) - // TODO remove - // eslint-disable-next-line aws-toolkits/no-json-stringify-in-log - logger.info(`Line mapping: ${JSON.stringify(modifiedLines)}`) const { createSVGWindow } = await import('svgdom') @@ -51,7 +53,27 @@ export class SvgGenerationService { const currentTheme = this.getEditorTheme() // Get edit diffs with highlight - const { addedLines, removedLines } = this.getEditedLinesFromDiff(udiff) + const { addedLines, removedLines } = this.getEditedLinesFromCode(originalCode, newCode) + + const modifiedLines = diffUtilities.getModifiedLinesFromCode(addedLines, removedLines) + // TODO remove + // eslint-disable-next-line aws-toolkits/no-json-stringify-in-log + logger.info(`Line mapping: ${JSON.stringify(modifiedLines)}`) + + // Calculate dimensions based on code content + const { offset, editStartLine, isPositionValid } = this.calculatePosition( + originalCode.split('\n'), + newCode.split('\n'), + addedLines, + currentTheme + ) + + // if the position for the EDITS suggestion is not valid (there is no difference between new + // and current code content), return EMPTY_DIFF_SVG and skip the suggestion. + if (!isPositionValid) { + return emptyDiffSvg + } + const highlightRanges = this.generateHighlightRanges(removedLines, addedLines, modifiedLines) const diffAddedWithHighlight = this.getHighlightEdit(addedLines, highlightRanges.addedRanges) @@ -61,13 +83,6 @@ export class SvgGenerationService { registerWindow(window, document) const draw = SVG(document.documentElement) as any - // Calculate dimensions based on code content - const { offset, editStartLine } = this.calculatePosition( - originalCode.split('\n'), - newCode.split('\n'), - addedLines, - currentTheme - ) const { width, height } = this.calculateDimensions(addedLines, currentTheme) draw.size(width + offset, height) @@ -86,7 +101,7 @@ export class SvgGenerationService { svgImage: vscode.Uri.parse(svgResult), startLine: editStartLine, newCode: newCode, - origionalCodeHighlightRange: highlightRanges.removedRanges, + originalCodeHighlightRange: highlightRanges.removedRanges, } } @@ -161,43 +176,25 @@ export class SvgGenerationService { } /** - * Extract added and removed lines from the unified diff - * @param unifiedDiff The unified diff string + * Extract added and removed lines by comparing original and new code + * @param originalCode The original code string + * @param newCode The new code string * @returns Object containing arrays of added and removed lines */ - private getEditedLinesFromDiff(unifiedDiff: string): { addedLines: string[]; removedLines: string[] } { + private getEditedLinesFromCode( + originalCode: string, + newCode: string + ): { addedLines: string[]; removedLines: string[] } { const addedLines: string[] = [] const removedLines: string[] = [] - const diffLines = unifiedDiff.split('\n') - // Find all hunks in the diff - const hunkStarts = diffLines - .map((line, index) => (line.startsWith('@@ ') ? index : -1)) - .filter((index) => index !== -1) + const changes = diffLines(originalCode, newCode) - // Process each hunk to find added and removed lines - for (const hunkStart of hunkStarts) { - const hunkHeader = diffLines[hunkStart] - const match = hunkHeader.match(/@@ -(\d+),(\d+) \+(\d+),(\d+) @@/) - - if (!match) { - continue - } - - // Extract the content lines for this hunk - let i = hunkStart + 1 - while (i < diffLines.length && !diffLines[i].startsWith('@@')) { - // Include lines that were added (start with '+') - if (diffLines[i].startsWith('+') && !diffLines[i].startsWith('+++')) { - const lineContent = diffLines[i].substring(1) - addedLines.push(lineContent) - } - // Include lines that were removed (start with '-') - else if (diffLines[i].startsWith('-') && !diffLines[i].startsWith('---')) { - const lineContent = diffLines[i].substring(1) - removedLines.push(lineContent) - } - i++ + for (const change of changes) { + if (change.added) { + addedLines.push(...change.value.split('\n').filter((line) => line.length > 0)) + } else if (change.removed) { + removedLines.push(...change.value.split('\n').filter((line) => line.length > 0)) } } @@ -356,12 +353,23 @@ export class SvgGenerationService { newLines: string[], diffLines: string[], theme: editorThemeInfo - ): { offset: number; editStartLine: number } { + ): { offset: number; editStartLine: number; isPositionValid: boolean } { // Determine the starting line of the edit in the original file let editStartLineInOldFile = 0 const maxLength = Math.min(originalLines.length, newLines.length) for (let i = 0; i <= maxLength; i++) { + // if there is no difference between the original lines and the new lines, skip calculating for the start position. + if (i === maxLength && originalLines[i] === newLines[i] && originalLines.length === newLines.length) { + logger.info( + 'There is no difference between current and new code suggestion. Skip calculating for start position.' + ) + return { + offset: 0, + editStartLine: 0, + isPositionValid: false, + } + } if (originalLines[i] !== newLines[i] || i === maxLength) { editStartLineInOldFile = i break @@ -386,7 +394,7 @@ export class SvgGenerationService { const startLineLength = originalLines[startLine]?.length || 0 const offset = (maxLineLength - startLineLength) * theme.fontSize * 0.7 + 10 // padding - return { offset, editStartLine: editStartLineInOldFile } + return { offset, editStartLine: editStartLineInOldFile, isPositionValid: true } } private escapeHtml(text: string): string { diff --git a/packages/amazonq/src/app/inline/completion.ts b/packages/amazonq/src/app/inline/completion.ts index 66668be1849..4ebe37b62cb 100644 --- a/packages/amazonq/src/app/inline/completion.ts +++ b/packages/amazonq/src/app/inline/completion.ts @@ -36,6 +36,7 @@ import { getDiagnosticsDifferences, getDiagnosticsOfCurrentFile, toIdeDiagnostics, + handleExtraBrackets, } from 'aws-core-vscode/codewhisperer' import { LineTracker } from './stateTracker/lineTracker' import { InlineTutorialAnnotation } from './tutorials/inlineTutorialAnnotation' @@ -106,11 +107,12 @@ export class InlineCompletionManager implements Disposable { item: InlineCompletionItemWithReferences, editor: TextEditor, requestStartTime: number, - startLine: number, + position: vscode.Position, firstCompletionDisplayLatency?: number ) => { try { vsCodeState.isCodeWhispererEditing = true + const startLine = position.line // TODO: also log the seen state for other suggestions in session // Calculate timing metrics before diagnostic delay const totalSessionDisplayTime = performance.now() - requestStartTime @@ -119,6 +121,11 @@ export class InlineCompletionManager implements Disposable { this.sessionManager.getActiveSession()?.diagnosticsBeforeAccept, getDiagnosticsOfCurrentFile() ) + // try remove the extra } ) ' " if there is a new reported problem + // the extra } will cause syntax error + if (diagnosticDiff.added.length > 0) { + await handleExtraBrackets(editor, editor.selection.active, position) + } const params: LogInlineCompletionSessionResultsParams = { sessionId: sessionId, completionSessionResult: { @@ -255,7 +262,11 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem return [] } - const isAutoTrigger = context.triggerKind === InlineCompletionTriggerKind.Automatic + // there is a bug in VS Code, when hitting Enter, the context.triggerKind is Invoke (0) + // when hitting other keystrokes, the context.triggerKind is Automatic (1) + // we only mark option + C as manual trigger + // this is a workaround since the inlineSuggest.trigger command take no params + const isAutoTrigger = performance.now() - vsCodeState.lastManualTriggerTime > 50 if (isAutoTrigger && !CodeSuggestionsState.instance.isSuggestionsEnabled()) { // return early when suggestions are disabled with auto trigger return [] @@ -279,14 +290,16 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem const prevSessionId = prevSession?.sessionId const prevItemId = this.sessionManager.getActiveRecommendation()?.[0]?.itemId const prevStartPosition = prevSession?.startPosition - if (prevSession?.triggerOnAcceptance) { + const editsTriggerOnAcceptance = prevSession?.triggerOnAcceptance + if (editsTriggerOnAcceptance) { getAllRecommendationsOptions = { ...getAllRecommendationsOptions, editsStreakToken: prevSession?.editsStreakPartialResultToken, } } const editor = window.activeTextEditor - if (prevSession && prevSessionId && prevItemId && prevStartPosition) { + // Skip prefix matching for Edits suggestions that trigger on acceptance. + if (prevSession && prevSessionId && prevItemId && prevStartPosition && !editsTriggerOnAcceptance) { const prefix = document.getText(new Range(prevStartPosition, position)) const prevItemMatchingPrefix = [] for (const item of this.sessionManager.getActiveRecommendation()) { @@ -304,7 +317,7 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem item, editor, prevSession?.requestStartTime, - position.line, + position, prevSession?.firstCompletionDisplayLatency, ], } @@ -348,7 +361,10 @@ export class AmazonQInlineCompletionItemProvider implements InlineCompletionItem this.languageClient, document, position, - context, + { + triggerKind: isAutoTrigger ? 1 : 0, + selectedCompletionInfo: context.selectedCompletionInfo, + }, token, isAutoTrigger, getAllRecommendationsOptions, @@ -441,7 +457,7 @@ ${itemLog} item, editor, session.requestStartTime, - cursorPosition.line, + cursorPosition, session.firstCompletionDisplayLatency, ], } diff --git a/packages/amazonq/src/app/inline/recommendationService.ts b/packages/amazonq/src/app/inline/recommendationService.ts index 794d6c46183..a722693fa97 100644 --- a/packages/amazonq/src/app/inline/recommendationService.ts +++ b/packages/amazonq/src/app/inline/recommendationService.ts @@ -12,10 +12,17 @@ import { import { CancellationToken, InlineCompletionContext, Position, TextDocument } from 'vscode' import { LanguageClient } from 'vscode-languageclient' import { SessionManager } from './sessionManager' -import { AuthUtil, CodeWhispererStatusBarManager, vsCodeState } from 'aws-core-vscode/codewhisperer' +import { + AuthUtil, + CodeWhispererConstants, + CodeWhispererStatusBarManager, + vsCodeState, +} from 'aws-core-vscode/codewhisperer' import { TelemetryHelper } from './telemetryHelper' import { ICursorUpdateRecorder } from './cursorUpdateManager' import { getLogger } from 'aws-core-vscode/shared' +import { getOpenFilesInWindow } from 'aws-core-vscode/utils' +import { asyncCallWithTimeout } from '../../util/timeoutUtil' export interface GetAllRecommendationsOptions { emitTelemetry?: boolean @@ -35,6 +42,23 @@ export class RecommendationService { this.cursorUpdateRecorder = recorder } + async getRecommendationsWithTimeout( + languageClient: LanguageClient, + request: InlineCompletionWithReferencesParams, + token: CancellationToken + ) { + const resultPromise: Promise = languageClient.sendRequest( + inlineCompletionWithReferencesRequestType.method, + request, + token + ) + return await asyncCallWithTimeout( + resultPromise, + `${inlineCompletionWithReferencesRequestType.method} time out`, + CodeWhispererConstants.promiseTimeoutLimit * 1000 + ) + } + async getAllRecommendations( languageClient: LanguageClient, document: TextDocument, @@ -56,7 +80,7 @@ export class RecommendationService { contentChanges: documentChangeEvent.contentChanges.map((x) => x as TextDocumentContentChangeEvent), } : undefined - + const openTabs = await getOpenFilesInWindow() let request: InlineCompletionWithReferencesParams = { textDocument: { uri: document.uri.toString(), @@ -64,6 +88,7 @@ export class RecommendationService { position, context, documentChangeParams: documentChangeParams, + openTabFilepaths: openTabs, } if (options.editsStreakToken) { request = { ...request, partialResultToken: options.editsStreakToken } @@ -93,11 +118,9 @@ export class RecommendationService { }, }) const t0 = performance.now() - const result: InlineCompletionListWithReferences = await languageClient.sendRequest( - inlineCompletionWithReferencesRequestType.method, - request, - token - ) + + const result = await this.getRecommendationsWithTimeout(languageClient, request, token) + getLogger().info('Received inline completion response from LSP: %O', { sessionId: result.sessionId, latency: performance.now() - t0, @@ -181,11 +204,7 @@ export class RecommendationService { while (nextToken) { const request = { ...initialRequest, partialResultToken: nextToken } - const result: InlineCompletionListWithReferences = await languageClient.sendRequest( - inlineCompletionWithReferencesRequestType.method, - request, - token - ) + const result = await this.getRecommendationsWithTimeout(languageClient, request, token) // when pagination is in progress, but user has already accepted or rejected an inline completion // then stop pagination if (this.sessionManager.getActiveSession() === undefined || vsCodeState.isCodeWhispererEditing) { diff --git a/packages/amazonq/src/lsp/chat/commands.ts b/packages/amazonq/src/lsp/chat/commands.ts index 83e70b7bae3..fca3a132f90 100644 --- a/packages/amazonq/src/lsp/chat/commands.ts +++ b/packages/amazonq/src/lsp/chat/commands.ts @@ -6,10 +6,12 @@ import { Commands, globals } from 'aws-core-vscode/shared' import { window } from 'vscode' import { AmazonQChatViewProvider } from './webviewProvider' -import { CodeScanIssue } from 'aws-core-vscode/codewhisperer' +import { CodeScanIssue, AuthUtil } from 'aws-core-vscode/codewhisperer' import { getLogger } from 'aws-core-vscode/shared' import * as vscode from 'vscode' import * as path from 'path' +import { codeReviewInChat } from '../../app/amazonqScan/models/constants' +import { telemetry, AmazonqCodeReviewTool } from 'aws-core-vscode/telemetry' /** * TODO: Re-enable these once we can figure out which path they're going to live in @@ -29,7 +31,8 @@ export function registerCommands(provider: AmazonQChatViewProvider) { filePath, 'Explain', 'Provide a small description of the issue. You must not attempt to fix the issue. You should only give a small summary of it to the user.', - provider + provider, + 'explainIssue' ) ), Commands.register('aws.amazonq.generateFix', (issue: CodeScanIssue, filePath: string) => @@ -38,7 +41,8 @@ export function registerCommands(provider: AmazonQChatViewProvider) { filePath, 'Fix', 'Generate a fix for the following code issue. You must not explain the issue, just generate and explain the fix. The user should have the option to accept or reject the fix before any code is changed.', - provider + provider, + 'applyFix' ) ), Commands.register('aws.amazonq.sendToPrompt', (data) => { @@ -64,6 +68,11 @@ export function registerCommands(provider: AmazonQChatViewProvider) { registerShellCommandShortCut('aws.amazonq.rejectCmdExecution', 'reject-shell-command', provider), registerShellCommandShortCut('aws.amazonq.stopCmdExecution', 'stop-shell-command', provider) ) + if (codeReviewInChat) { + globals.context.subscriptions.push( + registerGenericCommand('aws.amazonq.security.scan-statusbar', 'Review', provider) + ) + } } async function handleIssueCommand( @@ -71,7 +80,8 @@ async function handleIssueCommand( filePath: string, action: string, contextPrompt: string, - provider: AmazonQChatViewProvider + provider: AmazonQChatViewProvider, + metricName: string ) { await focusAmazonQPanel() @@ -95,6 +105,16 @@ async function handleIssueCommand( autoSubmit: true, }, }) + + telemetry.amazonq_codeReviewTool.emit({ + findingId: issue.findingId, + detectorId: issue.detectorId, + ruleId: issue.ruleId, + credentialStartUrl: AuthUtil.instance.startUrl, + autoDetected: issue.autoDetected, + result: 'Succeeded', + reason: metricName, + } as AmazonqCodeReviewTool) } async function openFileWithSelection(issue: CodeScanIssue, filePath: string) { diff --git a/packages/amazonq/src/lsp/chat/messages.ts b/packages/amazonq/src/lsp/chat/messages.ts index a95b99b442c..16965e2f41f 100644 --- a/packages/amazonq/src/lsp/chat/messages.ts +++ b/packages/amazonq/src/lsp/chat/messages.ts @@ -732,7 +732,11 @@ async function handlePartialResult( // This is to filter out the message containing findings from CodeReview tool to update CodeIssues panel decryptedMessage.additionalMessages = decryptedMessage.additionalMessages?.filter( (message) => - !(message.messageId !== undefined && message.messageId.endsWith(CodeWhispererConstants.findingsSuffix)) + !( + message.messageId !== undefined && + (message.messageId.endsWith(CodeWhispererConstants.codeReviewFindingsSuffix) || + message.messageId.endsWith(CodeWhispererConstants.displayFindingsSuffix)) + ) ) if (decryptedMessage.body !== undefined) { @@ -784,7 +788,11 @@ async function handleSecurityFindings( } for (let i = decryptedMessage.additionalMessages.length - 1; i >= 0; i--) { const message = decryptedMessage.additionalMessages[i] - if (message.messageId !== undefined && message.messageId.endsWith(CodeWhispererConstants.findingsSuffix)) { + if ( + message.messageId !== undefined && + (message.messageId.endsWith(CodeWhispererConstants.codeReviewFindingsSuffix) || + message.messageId.endsWith(CodeWhispererConstants.displayFindingsSuffix)) + ) { if (message.body !== undefined) { try { const aggregatedCodeScanIssues: AggregatedCodeScanIssue[] = JSON.parse(message.body) @@ -803,7 +811,12 @@ async function handleSecurityFindings( issue.visible = !isIssueTitleIgnored && !isSingleIssueIgnored } } - initSecurityScanRender(aggregatedCodeScanIssues, undefined, CodeAnalysisScope.PROJECT) + initSecurityScanRender( + aggregatedCodeScanIssues, + undefined, + CodeAnalysisScope.AGENTIC, + message.messageId.endsWith(CodeWhispererConstants.codeReviewFindingsSuffix) + ) SecurityIssueTreeViewProvider.focus() } catch (e) { languageClient.info('Failed to parse findings') diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts index d335dae40ef..58b5a6ee7e7 100644 --- a/packages/amazonq/src/lsp/client.ts +++ b/packages/amazonq/src/lsp/client.ts @@ -23,6 +23,7 @@ import { CodeWhispererSettings, getSelectedCustomization, TelemetryHelper, + vsCodeState, } from 'aws-core-vscode/codewhisperer' import { Settings, @@ -51,6 +52,7 @@ import { SessionManager } from '../app/inline/sessionManager' import { LineTracker } from '../app/inline/stateTracker/lineTracker' import { InlineTutorialAnnotation } from '../app/inline/tutorials/inlineTutorialAnnotation' import { InlineChatTutorialAnnotation } from '../app/inline/tutorials/inlineChatTutorialAnnotation' +import { codeReviewInChat } from '../app/amazonqScan/models/constants' const localize = nls.loadMessageBundle() const logger = getLogger('amazonqLsp.lspClient') @@ -179,7 +181,9 @@ export async function startLanguageServer( reroute: true, modelSelection: true, workspaceFilePath: vscode.workspace.workspaceFile?.fsPath, - codeReviewInChat: false, + codeReviewInChat: codeReviewInChat, + // feature flag for displaying findings found not through CodeReview in the Code Issues Panel + displayFindings: true, }, window: { notifications: true, @@ -365,6 +369,7 @@ async function onLanguageServerReady( sessionManager.checkInlineSuggestionVisibility() }), Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { + vsCodeState.lastManualTriggerTime = performance.now() await vscode.commands.executeCommand('editor.action.inlineSuggest.trigger') }), Commands.register('aws.amazonq.refreshAnnotation', async (forceProceed: boolean) => { diff --git a/packages/amazonq/src/util/timeoutUtil.ts b/packages/amazonq/src/util/timeoutUtil.ts new file mode 100644 index 00000000000..c42d1e3be01 --- /dev/null +++ b/packages/amazonq/src/util/timeoutUtil.ts @@ -0,0 +1,15 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export function asyncCallWithTimeout(asyncPromise: Promise, message: string, timeLimit: number): Promise { + let timeoutHandle: NodeJS.Timeout + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(message)), timeLimit) + }) + return Promise.race([asyncPromise, timeoutPromise]).then((result) => { + clearTimeout(timeoutHandle) + return result as T + }) +} 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 54eea8347c5..559ecdb2102 100644 --- a/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts +++ b/packages/amazonq/test/unit/amazonq/apps/inline/recommendationService.test.ts @@ -147,6 +147,7 @@ describe('RecommendationService', () => { position: mockPosition, context: mockContext, documentChangeParams: undefined, + openTabFilepaths: [], }) // Verify session management @@ -189,6 +190,7 @@ describe('RecommendationService', () => { position: mockPosition, context: mockContext, documentChangeParams: undefined, + openTabFilepaths: [], } const secondRequestArgs = sendRequestStub.secondCall.args[1] assert.deepStrictEqual(firstRequestArgs, expectedRequestArgs) diff --git a/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts b/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts index 8a625fe3544..e1c32778d83 100644 --- a/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts +++ b/packages/amazonq/test/unit/app/inline/EditRendering/imageRenderer.test.ts @@ -30,7 +30,7 @@ describe('showEdits', function () { svgImage: vscode.Uri.file('/path/to/generated.svg'), startLine: 5, newCode: 'console.log("Hello World");', - origionalCodeHighlightRange: [{ line: 5, start: 0, end: 10 }], + originalCodeHighlightRange: [{ line: 5, start: 0, end: 10 }], ...overrides, } } @@ -167,7 +167,7 @@ describe('showEdits', function () { mockSvgResult.svgImage, mockSvgResult.startLine, mockSvgResult.newCode, - mockSvgResult.origionalCodeHighlightRange, + mockSvgResult.originalCodeHighlightRange, sessionStub, languageClientStub, itemStub diff --git a/packages/core/package.json b/packages/core/package.json index d446a1bdf41..7be37423006 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -471,7 +471,7 @@ "@aws-sdk/types": "^3.13.1", "@aws/chat-client": "^0.1.4", "@aws/chat-client-ui-types": "^0.1.47", - "@aws/language-server-runtimes": "^0.2.111", + "@aws/language-server-runtimes": "^0.2.119", "@aws/language-server-runtimes-types": "^0.1.47", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 498a3583a00..06343f17c75 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -281,7 +281,7 @@ "AWS.command.q.transform.rejectChanges": "Reject", "AWS.command.q.transform.stopJobInHub": "Stop job", "AWS.command.q.transform.viewJobProgress": "View job progress", - "AWS.command.q.transform.viewJobStatus": "View job status", + "AWS.command.q.transform.viewJobHistory": "View job history", "AWS.command.q.transform.showTransformationPlan": "View plan", "AWS.command.q.transform.showChangeSummary": "View summary", "AWS.command.threatComposer.createNew": "Create New Threat Composer File", @@ -358,6 +358,7 @@ "AWS.amazonq.codewhisperer.title": "Amazon Q", "AWS.amazonq.toggleCodeSuggestion": "Toggle Auto-Suggestions", "AWS.amazonq.toggleCodeScan": "Toggle Auto-Scans", + "AWS.amazonq.toggleNextEditPredictionPanel": "Toggle next edit suggestion", "AWS.amazonq.scans.scanProgress": "Sure. This may take a few minutes. I will send a notification when it’s complete if you navigate away from this panel.", "AWS.amazonq.scans.waitingForInput": "Waiting on your inputs...", "AWS.amazonq.scans.chooseScan.description": "Would you like to review your active file or the workspace you have open?", @@ -465,6 +466,8 @@ "AWS.amazonq.doc.pillText.reject": "Reject", "AWS.amazonq.doc.pillText.makeChanges": "Make changes", "AWS.amazonq.inline.invokeChat": "Inline chat", + "AWS.amazonq.inline.acceptEdit": "Accept edit suggestion", + "AWS.amazonq.inline.rejectEdit": "Reject edit suggestion", "AWS.amazonq.opensettings:": "Open settings", "AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough", "AWS.toolkit.lambda.walkthrough.title": "Get started building your application", diff --git a/packages/core/src/amazonqGumby/activation.ts b/packages/core/src/amazonqGumby/activation.ts index 74823f6fbc6..8ab47f5697e 100644 --- a/packages/core/src/amazonqGumby/activation.ts +++ b/packages/core/src/amazonqGumby/activation.ts @@ -21,7 +21,7 @@ import { setContext } from '../shared/vscode/setContext' export async function activate(context: ExtContext) { void setContext('gumby.wasQCodeTransformationUsed', false) - const transformationHubViewProvider = new TransformationHubViewProvider() + const transformationHubViewProvider = TransformationHubViewProvider.instance new ProposedTransformationExplorer(context.extensionContext) // Register an activation event listener to determine when the IDE opens, closes or users // select to open a new workspace @@ -72,6 +72,13 @@ export async function activate(context: ExtContext) { ) }), + Commands.register( + 'aws.amazonq.transformationHub.updateContent', + async (button, startTime, historyFileUpdated) => { + await transformationHubViewProvider.updateContent(button, startTime, historyFileUpdated) + } + ), + workspaceChangeEvent ) } diff --git a/packages/core/src/amazonqGumby/chat/controller/controller.ts b/packages/core/src/amazonqGumby/chat/controller/controller.ts index 7e3e799a046..ae277ca24f9 100644 --- a/packages/core/src/amazonqGumby/chat/controller/controller.ts +++ b/packages/core/src/amazonqGumby/chat/controller/controller.ts @@ -57,6 +57,8 @@ import { } from '../../../codewhisperer/service/transformByQ/transformFileHandler' import { getAuthType } from '../../../auth/utils' import fs from '../../../shared/fs/fs' +import { setContext } from '../../../shared/vscode/setContext' +import { readHistoryFile } from '../../../codewhisperer/service/transformByQ/transformationHubViewProvider' // These events can be interactions within the chat, // or elsewhere in the IDE @@ -188,6 +190,15 @@ export class GumbyController { } private async transformInitiated(message: any) { + // check if any jobs potentially still in progress on backend + const history = await readHistoryFile() + const numInProgress = history.filter((job) => job.status === 'FAILED').length + this.messenger.sendViewHistoryMessage(message.tabID, numInProgress) + if (transformByQState.isRefreshInProgress()) { + this.messenger.sendMessage(CodeWhispererConstants.refreshInProgressChatMessage, message.tabID, 'ai-prompt') + return + } + // silently check for projects eligible for SQL conversion let embeddedSQLProjects: TransformationCandidateProject[] = [] try { @@ -383,6 +394,11 @@ export class GumbyController { case ButtonActions.VIEW_TRANSFORMATION_HUB: await vscode.commands.executeCommand(GumbyCommands.FOCUS_TRANSFORMATION_HUB, CancelActionPositions.Chat) break + case ButtonActions.VIEW_JOB_HISTORY: + await setContext('gumby.wasQCodeTransformationUsed', true) + await vscode.commands.executeCommand(GumbyCommands.FOCUS_TRANSFORMATION_HUB) + await vscode.commands.executeCommand(GumbyCommands.FOCUS_JOB_HISTORY, CancelActionPositions.Chat) + break case ButtonActions.VIEW_SUMMARY: await vscode.commands.executeCommand('aws.amazonq.transformationHub.summary.reveal') break @@ -452,6 +468,10 @@ export class GumbyController { } private async handleUserLanguageUpgradeProjectChoice(message: any) { + if (transformByQState.isRefreshInProgress()) { + this.messenger.sendMessage(CodeWhispererConstants.refreshInProgressChatMessage, message.tabID, 'ai-prompt') + return + } await telemetry.codeTransform_submitSelection.run(async () => { const pathToProject: string = message.formSelectedValues['GumbyTransformLanguageUpgradeProjectForm'] const toJDKVersion: JDKVersion = message.formSelectedValues['GumbyTransformJdkToForm'] @@ -484,6 +504,10 @@ export class GumbyController { } private async handleUserSQLConversionProjectSelection(message: any) { + if (transformByQState.isRefreshInProgress()) { + this.messenger.sendMessage(CodeWhispererConstants.refreshInProgressChatMessage, message.tabID, 'ai-prompt') + return + } await telemetry.codeTransform_submitSelection.run(async () => { const pathToProject: string = message.formSelectedValues['GumbyTransformSQLConversionProjectForm'] const schema: string = message.formSelectedValues['GumbyTransformSQLSchemaForm'] diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts index 699e3b77938..59c144a8605 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts @@ -377,6 +377,38 @@ export class Messenger { this.dispatcher.sendChatMessage(jobSubmittedMessage) } + public sendViewHistoryMessage(tabID: string, numInProgress: number) { + const buttons: ChatItemButton[] = [] + + buttons.push({ + keepCardAfterClick: true, + text: CodeWhispererConstants.jobHistoryButtonText, + id: ButtonActions.VIEW_JOB_HISTORY, + disabled: false, + }) + + const messageText = CodeWhispererConstants.viewHistoryMessage(numInProgress) + + const message = new ChatMessage( + { + message: messageText, + messageType: 'ai-prompt', + buttons, + }, + tabID + ) + this.dispatcher.sendChatMessage(message) + } + + public sendJobRefreshInProgressMessage(tabID: string, jobId: string) { + this.dispatcher.sendAsyncEventProgress( + new AsyncEventProgressMessage(tabID, { + inProgress: true, + message: CodeWhispererConstants.refreshingJobChatMessage(jobId), + }) + ) + } + public sendMessage(prompt: string, tabID: string, type: 'prompt' | 'ai-prompt') { this.dispatcher.sendChatMessage( new ChatMessage( diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts index 4df65fe9d1d..2c64a050547 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts @@ -13,6 +13,7 @@ import DependencyVersions from '../../../models/dependencies' export enum ButtonActions { STOP_TRANSFORMATION_JOB = 'gumbyStopTransformationJob', VIEW_TRANSFORMATION_HUB = 'gumbyViewTransformationHub', + VIEW_JOB_HISTORY = 'gumbyViewJobHistory', VIEW_SUMMARY = 'gumbyViewSummary', CONFIRM_LANGUAGE_UPGRADE_TRANSFORMATION_FORM = 'gumbyLanguageUpgradeTransformFormConfirm', CONFIRM_SQL_CONVERSION_TRANSFORMATION_FORM = 'gumbySQLConversionTransformFormConfirm', @@ -33,6 +34,7 @@ export enum GumbyCommands { CLEAR_CHAT = 'aws.awsq.clearchat', START_TRANSFORMATION_FLOW = 'aws.awsq.transform', FOCUS_TRANSFORMATION_HUB = 'aws.amazonq.showTransformationHub', + FOCUS_JOB_HISTORY = 'aws.amazonq.showHistoryInHub', } export default class MessengerUtils { diff --git a/packages/core/src/applicationcomposer/constants.ts b/packages/core/src/applicationcomposer/constants.ts new file mode 100644 index 00000000000..1eb3852ad6a --- /dev/null +++ b/packages/core/src/applicationcomposer/constants.ts @@ -0,0 +1,10 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +const isLocalDev = false +const localhost = 'http://127.0.0.1:3000' +const cdn = 'https://ide-toolkits.app-composer.aws.dev' + +export { isLocalDev, localhost, cdn } diff --git a/packages/core/src/applicationcomposer/messageHandlers/generateResourceHandler.ts b/packages/core/src/applicationcomposer/messageHandlers/generateResourceHandler.ts index 6d3e96b81c3..fe31e40ef27 100644 --- a/packages/core/src/applicationcomposer/messageHandlers/generateResourceHandler.ts +++ b/packages/core/src/applicationcomposer/messageHandlers/generateResourceHandler.ts @@ -2,12 +2,6 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { - GenerateAssistantResponseRequest, - SupplementaryWebLink, - Reference, - UserIntent, -} from '@amzn/codewhisperer-streaming' import { GenerateResourceRequestMessage, @@ -16,15 +10,13 @@ import { Command, MessageType, } from '../types' -import globals from '../../shared/extensionGlobals' import { getLogger } from '../../shared/logger/logger' -import { AmazonqNotFoundError, getAmazonqApi } from '../../amazonq/extApi' - -const TIMEOUT = 30_000 +import request from '../../shared/request' +import { isLocalDev, localhost, cdn } from '../constants' export async function generateResourceHandler(request: GenerateResourceRequestMessage, context: WebviewContext) { try { - const { chatResponse, references, metadata, isSuccess } = await generateResource(request.cfnType) + const { chatResponse, references, metadata, isSuccess } = await fetchExampleResource(request.cfnType) const responseMessage: GenerateResourceResponseMessage = { command: Command.GENERATE_RESOURCE, @@ -54,116 +46,18 @@ export async function generateResourceHandler(request: GenerateResourceRequestMe } } -async function generateResource(cfnType: string) { - let startTime = globals.clock.Date.now() - +async function fetchExampleResource(cfnType: string) { try { - const amazonqApi = await getAmazonqApi() - if (!amazonqApi) { - throw new AmazonqNotFoundError() - } - const request: GenerateAssistantResponseRequest = { - conversationState: { - currentMessage: { - userInputMessage: { - content: cfnType, - userIntent: UserIntent.GENERATE_CLOUDFORMATION_TEMPLATE, - }, - }, - chatTriggerType: 'MANUAL', - }, - } - - let response = '' - let metadata - let conversationId - let supplementaryWebLinks: SupplementaryWebLink[] = [] - let references: Reference[] = [] - - await amazonqApi.authApi.reauthIfNeeded() - - startTime = globals.clock.Date.now() - // TODO-STARLING - Revisit to see if timeout still needed prior to launch - const data = await timeout(amazonqApi.chatApi.chat(request), TIMEOUT) - const initialResponseTime = globals.clock.Date.now() - startTime - getLogger().debug(`CW Chat initial response: %O, ${initialResponseTime} ms`, data) - if (data['$metadata']) { - metadata = data['$metadata'] - } - - if (data.generateAssistantResponseResponse === undefined) { - getLogger().debug(`Error: Unexpected model response: %O`, data) - throw new Error('No model response') - } - - for await (const value of data.generateAssistantResponseResponse) { - if (value?.assistantResponseEvent?.content) { - try { - response += value.assistantResponseEvent.content - } catch (error: any) { - getLogger().debug(`Warning: Failed to parse content response: ${error.message}`) - throw new Error('Invalid model response') - } - } - if (value?.messageMetadataEvent?.conversationId) { - conversationId = value.messageMetadataEvent.conversationId - } - - const newWebLinks = value?.supplementaryWebLinksEvent?.supplementaryWebLinks - - if (newWebLinks && newWebLinks.length > 0) { - supplementaryWebLinks = supplementaryWebLinks.concat(newWebLinks) - } - - if (value.codeReferenceEvent?.references && value.codeReferenceEvent.references.length > 0) { - references = references.concat(value.codeReferenceEvent.references) - - // Code References are not expected for these single resource prompts - // As we don't yet have the workflows needed to accept references, create the properly structured - // CW Reference log event, we will reject responses that have code references - let errorMessage = 'Code references found for this response, rejecting.' - - if (conversationId) { - errorMessage += ` cID(${conversationId})` - } - - if (metadata?.requestId) { - errorMessage += ` rID(${metadata.requestId})` - } - - throw new Error(errorMessage) - } - } - - const elapsedTime = globals.clock.Date.now() - startTime - - getLogger().debug( - `CW Chat Debug message: - cfnType = "${cfnType}", - conversationId = ${conversationId}, - metadata = %O, - supplementaryWebLinks = %O, - references = %O, - response = "${response}", - initialResponse = ${initialResponseTime} ms, - elapsed time = ${elapsedTime} ms`, - metadata, - supplementaryWebLinks, - references - ) - + const source = isLocalDev ? localhost : cdn + const resp = request.fetch('GET', `${source}/examples/${convertCFNType(cfnType)}.json`, {}) return { - chatResponse: response, + chatResponse: await (await resp.response).text(), references: [], - metadata: { - ...metadata, - conversationId, - queryTime: elapsedTime, - }, + metadata: {}, isSuccess: true, } } catch (error: any) { - getLogger().debug(`CW Chat error: ${error.name} - ${error.message}`) + getLogger().debug(`Resource fetch error: ${error.name} - ${error.message}`) if (error.$metadata) { const { requestId, cfId, extendedRequestId } = error.$metadata getLogger().debug('%O', { requestId, cfId, extendedRequestId }) @@ -173,11 +67,11 @@ async function generateResource(cfnType: string) { } } -function timeout(promise: Promise, ms: number, timeoutError = new Error('Promise timed out')): Promise { - const _timeout = new Promise((_, reject) => { - globals.clock.setTimeout(() => { - reject(timeoutError) - }, ms) - }) - return Promise.race([promise, _timeout]) +function convertCFNType(cfnType: string): string { + const resourceParts = cfnType.split('::') + if (resourceParts.length !== 3) { + throw new Error('CFN type did not contain three parts') + } + + return resourceParts.join('_') } diff --git a/packages/core/src/applicationcomposer/webviewManager.ts b/packages/core/src/applicationcomposer/webviewManager.ts index 884039f907b..92bcbb55593 100644 --- a/packages/core/src/applicationcomposer/webviewManager.ts +++ b/packages/core/src/applicationcomposer/webviewManager.ts @@ -7,16 +7,11 @@ import * as vscode from 'vscode' import * as nls from 'vscode-nls' import request from '../shared/request' import { ApplicationComposer } from './composerWebview' +import { isLocalDev, localhost, cdn } from './constants' import { getLogger } from '../shared/logger/logger' const localize = nls.loadMessageBundle() -// TODO turn this into a flag to make local dev easier -// Change this to true for local dev -const isLocalDev = false -const localhost = 'http://127.0.0.1:3000' -const cdn = 'https://ide-toolkits.app-composer.aws.dev' - const enabledFeatures = ['ide-only', 'anything-resource', 'sfnV2', 'starling'] export class ApplicationComposerManager { diff --git a/packages/core/src/codewhisperer/commands/startSecurityScan.ts b/packages/core/src/codewhisperer/commands/startSecurityScan.ts index 5ff6d13bd91..bd081face38 100644 --- a/packages/core/src/codewhisperer/commands/startSecurityScan.ts +++ b/packages/core/src/codewhisperer/commands/startSecurityScan.ts @@ -108,6 +108,9 @@ export async function startSecurityScan( zipUtil: ZipUtil = new ZipUtil(), scanUuid?: string ) { + if (scope === CodeAnalysisScope.AGENTIC) { + throw new CreateCodeScanFailedError('Cannot use Agentic scope') + } const profile = AuthUtil.instance.regionProfileManager.activeRegionProfile const logger = getLoggerForScope(scope) /** diff --git a/packages/core/src/codewhisperer/commands/startTransformByQ.ts b/packages/core/src/codewhisperer/commands/startTransformByQ.ts index 56e54a97a8a..209b9628a73 100644 --- a/packages/core/src/codewhisperer/commands/startTransformByQ.ts +++ b/packages/core/src/codewhisperer/commands/startTransformByQ.ts @@ -20,6 +20,7 @@ import { TransformationType, TransformationCandidateProject, RegionProfile, + sessionJobHistory, } from '../models/model' import { createZipManifest, @@ -474,6 +475,30 @@ export async function startTransformationJob( codeTransformRunTimeLatency: calculateTotalLatency(transformStartTime), }) }) + + // 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 }) + } + 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) } catch (error) { getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToStartJobNotification}`, error) const errorMessage = (error as Error).message.toLowerCase() @@ -724,9 +749,18 @@ export async function postTransformationJob() { }) } - if (transformByQState.getPayloadFilePath()) { - // delete original upload ZIP at very end of transformation - fs.rmSync(transformByQState.getPayloadFilePath(), { force: true }) + // 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') @@ -739,31 +773,52 @@ export async function postTransformationJob() { if (transformByQState.isSucceeded() || transformByQState.isPartiallySucceeded()) { await vscode.commands.executeCommand('aws.amazonq.transformationHub.reviewChanges.startReview') } + + // 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 = [ + 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 + ) + } } export async function transformationJobErrorHandler(error: any) { if (!transformByQState.isCancelled()) { // means some other error occurred; cancellation already handled by now with stopTransformByQ - await stopJob(transformByQState.getJobId()) transformByQState.setToFailed() transformByQState.setPolledJobStatus('FAILED') // jobFailureErrorNotification should always be defined here - const displayedErrorMessage = - transformByQState.getJobFailureErrorNotification() ?? CodeWhispererConstants.failedToCompleteJobNotification transformByQState.setJobFailureErrorChatMessage( transformByQState.getJobFailureErrorChatMessage() ?? CodeWhispererConstants.failedToCompleteJobChatMessage ) - void vscode.window - .showErrorMessage(displayedErrorMessage, CodeWhispererConstants.amazonQFeedbackText) - .then((choice) => { - if (choice === CodeWhispererConstants.amazonQFeedbackText) { - void submitFeedback( - placeholder, - CodeWhispererConstants.amazonQFeedbackKey, - getFeedbackCommentData() - ) - } - }) } else { transformByQState.setToCancelled() transformByQState.setPolledJobStatus('CANCELLED') diff --git a/packages/core/src/codewhisperer/index.ts b/packages/core/src/codewhisperer/index.ts index d782b2abefe..ac43fba46aa 100644 --- a/packages/core/src/codewhisperer/index.ts +++ b/packages/core/src/codewhisperer/index.ts @@ -68,6 +68,7 @@ export * from './util/importAdderUtil' export * from './util/zipUtil' export * from './util/diagnosticsUtil' export * from './util/commonUtil' +export * from './util/closingBracketUtil' export * from './util/codewhispererSettings' export * from './service/diagnosticsProvider' export * as diagnosticsProvider from './service/diagnosticsProvider' diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index 9e1eb2b7f94..4db98727765 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -799,6 +799,34 @@ export const formattedStringMap = new Map([ ['numChangedFiles', 'Files to be changed'], ]) +export const refreshInProgressChatMessage = 'A job refresh is currently in progress. Please wait for it to complete.' + +export const refreshingJobChatMessage = (jobId: string) => + `I am now resuming your job (id: ${jobId}). This can take 10 to 30 minutes to complete.` + +export const jobHistoryButtonText = 'Open job history' + +export const viewHistoryMessage = (numInProgress: number) => + numInProgress > 0 + ? `You have ${numInProgress} job${numInProgress > 1 ? 's' : ''} in progress. You can resume ${numInProgress > 1 ? 'them' : 'it'} in the transformation history table.` + : 'View previous transformations run from the IDE' + +export const transformationHistoryTableDescription = + 'This table lists the most recent jobs that you have run in the past 30 days. To open the diff patch and summary files, click the provided links. To get an updated job status, click the refresh icon. The diff patch and summary will appear once they are available.

' + + 'Jobs with a status of FAILED may still be in progress. Resume these jobs within 12 hours of starting the job to get an updated job status and artifacts.' + +export const refreshErrorChatMessage = + "Sorry, I couldn't refresh the job. Please try again or start a new transformation." + +export const refreshErrorNotification = (jobId: string) => `There was an error refreshing this job. Job Id: ${jobId}` + +export const refreshCompletedChatMessage = + 'Job refresh completed. Please see the transformation history table for the updated status and artifacts.' + +export const refreshCompletedNotification = (jobId: string) => `Job refresh completed. (Job Id: ${jobId})` + +export const refreshNoUpdatesNotification = (jobId: string) => `No updates. (Job Id: ${jobId})` + // end of QCT Strings export enum UserGroup { @@ -841,6 +869,7 @@ export enum CodeAnalysisScope { FILE_AUTO = 'FILE_AUTO', FILE_ON_DEMAND = 'FILE_ON_DEMAND', PROJECT = 'PROJECT', + AGENTIC = 'AGENTIC', } export enum TestGenerationJobStatus { @@ -907,4 +936,18 @@ export const predictionTrackerDefaultConfig = { maxSupplementalContext: 15, } +export const codeReviewFindingsSuffix = '_codeReviewFindings' +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/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index 70f520440fa..bcfa50c6a71 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -42,6 +42,8 @@ interface VsCodeState { lastUserModificationTime: number isFreeTierLimitReached: boolean + + lastManualTriggerTime: number } export const vsCodeState: VsCodeState = { @@ -52,6 +54,7 @@ export const vsCodeState: VsCodeState = { isRecommendationsActive: false, lastUserModificationTime: 0, isFreeTierLimitReached: false, + lastManualTriggerTime: 0, } export interface CodeWhispererConfig { @@ -727,6 +730,7 @@ export class TransformByQState { private planFilePath: string = '' private summaryFilePath: string = '' private preBuildLogFilePath: string = '' + private jobHistoryPath: string = '' private resultArchiveFilePath: string = '' private projectCopyFilePath: string = '' @@ -758,6 +762,8 @@ export class TransformByQState { private intervalId: NodeJS.Timeout | undefined = undefined + private refreshInProgress: boolean = false + public isNotStarted() { return this.transformByQState === TransformByQStatus.NotStarted } @@ -782,6 +788,10 @@ export class TransformByQState { return this.transformByQState === TransformByQStatus.PartiallySucceeded } + public isRefreshInProgress() { + return this.refreshInProgress + } + public getHasSeenTransforming() { return this.hasSeenTransforming } @@ -878,6 +888,10 @@ export class TransformByQState { return this.summaryFilePath } + public getJobHistoryPath() { + return this.jobHistoryPath + } + public getResultArchiveFilePath() { return this.resultArchiveFilePath } @@ -972,6 +986,10 @@ export class TransformByQState { this.transformByQState = TransformByQStatus.PartiallySucceeded } + public setRefreshInProgress(inProgress: boolean) { + this.refreshInProgress = inProgress + } + public setHasSeenTransforming(hasSeen: boolean) { this.hasSeenTransforming = hasSeen } @@ -1052,6 +1070,10 @@ export class TransformByQState { this.summaryFilePath = filePath } + public setJobHistoryPath(filePath: string) { + this.jobHistoryPath = filePath + } + public setResultArchiveFilePath(filePath: string) { this.resultArchiveFilePath = filePath } @@ -1118,6 +1140,7 @@ export class TransformByQState { public setJobDefaults() { this.setToNotStarted() + this.refreshInProgress = false this.hasSeenTransforming = false this.jobFailureErrorNotification = undefined this.jobFailureErrorChatMessage = undefined @@ -1134,6 +1157,7 @@ export class TransformByQState { this.buildLog = '' this.customBuildCommand = '' this.intervalId = undefined + this.jobHistoryPath = '' } } diff --git a/packages/core/src/codewhisperer/service/diagnosticsProvider.ts b/packages/core/src/codewhisperer/service/diagnosticsProvider.ts index f181bdb146d..b7a950bba5c 100644 --- a/packages/core/src/codewhisperer/service/diagnosticsProvider.ts +++ b/packages/core/src/codewhisperer/service/diagnosticsProvider.ts @@ -26,8 +26,13 @@ export const securityScanRender: SecurityScanRender = { export function initSecurityScanRender( securityRecommendationList: AggregatedCodeScanIssue[], editor: vscode.TextEditor | undefined, - scope: CodeAnalysisScope + scope: CodeAnalysisScope, + fromQCA: boolean = true ) { + // fromQCA parameter is used to determine if the findings are coming from QCA or from displayFindings tool. + // if the incoming findings are from QCA review, then keep only existing findings from displayFindings + // if the incoming findings are not from QCA review, then keep only the existing QCA findings + securityScanRender.securityDiagnosticCollection = createSecurityDiagnosticCollection() securityScanRender.initialized = false if (scope === CodeAnalysisScope.FILE_ON_DEMAND && editor) { securityScanRender.securityDiagnosticCollection?.delete(editor.document.uri) @@ -36,22 +41,20 @@ export function initSecurityScanRender( } for (const securityRecommendation of securityRecommendationList) { updateSecurityDiagnosticCollection(securityRecommendation) - updateSecurityIssuesForProviders(securityRecommendation, scope === CodeAnalysisScope.FILE_AUTO) + updateSecurityIssuesForProviders(securityRecommendation, scope === CodeAnalysisScope.FILE_AUTO, fromQCA) } securityScanRender.initialized = true } -function updateSecurityIssuesForProviders(securityRecommendation: AggregatedCodeScanIssue, isAutoScope?: boolean) { +function updateSecurityIssuesForProviders( + securityRecommendation: AggregatedCodeScanIssue, + isAutoScope?: boolean, + fromQCA: boolean = true +) { if (isAutoScope) { SecurityIssueProvider.instance.mergeIssues(securityRecommendation) } else { - const updatedSecurityRecommendationList = [ - ...SecurityIssueProvider.instance.issues.filter( - (group) => group.filePath !== securityRecommendation.filePath - ), - securityRecommendation, - ] - SecurityIssueProvider.instance.issues = updatedSecurityRecommendationList + SecurityIssueProvider.instance.mergeIssuesDisplayFindings(securityRecommendation, fromQCA) } SecurityIssueTreeViewProvider.instance.refresh() } diff --git a/packages/core/src/codewhisperer/service/securityIssueProvider.ts b/packages/core/src/codewhisperer/service/securityIssueProvider.ts index d055cb0a7d5..01f1cd880bd 100644 --- a/packages/core/src/codewhisperer/service/securityIssueProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueProvider.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode' import { AggregatedCodeScanIssue, CodeScanIssue, SuggestedFix } from '../models/model' import { randomUUID } from '../../shared/crypto' +import { displayFindingsDetectorName } from '../models/constants' export class SecurityIssueProvider { static #instance: SecurityIssueProvider @@ -161,6 +162,30 @@ export class SecurityIssueProvider { ) } + public mergeIssuesDisplayFindings(newIssues: AggregatedCodeScanIssue, fromQCA: boolean) { + const existingGroup = this._issues.find((group) => group.filePath === newIssues.filePath) + if (!existingGroup) { + this._issues.push(newIssues) + return + } + + this._issues = this._issues.map((group) => + group.filePath !== newIssues.filePath + ? group + : { + ...group, + issues: [ + ...group.issues.filter( + // if the incoming findings are from QCA review, then keep only existing findings from displayFindings + // if the incoming findings are not from QCA review, then keep only the existing QCA findings + (issue) => fromQCA === (issue.detectorName === displayFindingsDetectorName) + ), + ...newIssues.issues, + ], + } + ) + } + private isExistingIssue(issue: CodeScanIssue, filePath: string) { return this._issues .find((group) => group.filePath === filePath) diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts index 052ef53b56c..fe09e203919 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode' import globals from '../../../shared/extensionGlobals' import * as CodeWhispererConstants from '../../models/constants' import { + JDKVersion, StepProgress, TransformationType, jobPlanProgress, @@ -14,30 +15,41 @@ import { transformByQState, } from '../../models/model' import { getLogger } from '../../../shared/logger/logger' -import { getTransformationSteps } from './transformApiHandler' +import { getTransformationSteps, downloadAndExtractResultArchive } from './transformApiHandler' import { TransformationSteps, ProgressUpdates, TransformationStatus, } from '../../../codewhisperer/client/codewhispereruserclient' -import { startInterval } from '../../commands/startTransformByQ' +import { codeWhispererClient } from '../../../codewhisperer/client/codewhisperer' +import { startInterval, pollTransformationStatusUntilComplete } from '../../commands/startTransformByQ' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' -import { convertToTimeString } from '../../../shared/datetime' +import { convertToTimeString, isWithin30Days } 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' 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[] = [] constructor() {} static #instance: TransformationHubViewProvider public async updateContent( button: 'job history' | 'plan progress', - startTime: number = CodeTransformTelemetryState.instance.getStartTime() + startTime: number = CodeTransformTelemetryState.instance.getStartTime(), + historyFileUpdated?: boolean ) { this.lastClickedButton = button + if (historyFileUpdated) { + this.transformationHistory = await readHistoryFile() + } if (this._view) { if (this.lastClickedButton === 'job history') { clearInterval(transformByQState.getIntervalId()) @@ -62,18 +74,33 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider return (this.#instance ??= new this()) } - public resolveWebviewView( + public async resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken - ): void | Thenable { + ) { this._view = webviewView + this._view.webview.onDidReceiveMessage((message) => { + switch (message.command) { + case 'refreshJob': + void this.refreshJob(message.jobId, message.currentStatus, message.projectName) + break + case 'openSummaryPreview': + void vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(message.filePath)) + break + case 'openDiffFile': + void vscode.commands.executeCommand('vscode.open', vscode.Uri.file(message.filePath)) + break + } + }) + this._view.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri], } + this.transformationHistory = await readHistoryFile() if (this.lastClickedButton === 'job history') { this._view!.webview.html = this.showJobHistory() } else { @@ -88,6 +115,19 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider } private showJobHistory(): string { + const jobsToDisplay: CodeWhispererConstants.HistoryObject[] = [...this.transformationHistory] + if (transformByQState.isRunning()) { + const current = sessionJobHistory[transformByQState.getJobId()] + jobsToDisplay.unshift({ + startTime: current.startTime, + projectName: current.projectName, + status: current.status, + duration: current.duration, + diffPath: '', + summaryPath: '', + jobId: transformByQState.getJobId(), + }) + } return ` @@ -99,18 +139,69 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider -

Transformation Status

+

Transformation History

+

${CodeWhispererConstants.transformationHistoryTableDescription}

${ - Object.keys(sessionJobHistory).length === 0 - ? `

${CodeWhispererConstants.nothingToShowMessage}

` - : this.getTableMarkup(sessionJobHistory[transformByQState.getJobId()]) + jobsToDisplay.length === 0 + ? `


${CodeWhispererConstants.nothingToShowMessage}

` + : this.getTableMarkup(jobsToDisplay) } + ` } - private getTableMarkup(job: { startTime: string; projectName: string; status: string; duration: string }) { + private getTableMarkup(history: CodeWhispererConstants.HistoryObject[]) { return ` + @@ -118,22 +209,288 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider - + + + + - - - - - - - + ${history + .map( + (job) => ` + + + + + + + + + + + ` + ) + .join('')}
Project Status DurationIdDiff PatchSummary FileJob IdRefresh Job
${job.startTime}${job.projectName}${job.status}${job.duration}${transformByQState.getJobId()}
${job.startTime}${job.projectName}${job.status === 'FAILED_BE' ? 'FAILED' : job.status}${job.duration}${job.diffPath ? `diff.patch` : ''}${job.summaryPath ? `summary.md` : ''}${job.jobId} + +
` } + private async 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) { + getLogger().error( + 'Code Transformation: Error fetching status (job id: %s): %s', + jobId, + (error as Error).message + ) + 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 this.retrieveArtifacts(jobId, projectName) + + // delete metadata and zipped code files, if they exist + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'metadata.txt'), { + force: true, + }) + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip'), { + force: true, + }) + } 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) + void this.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 this.resumeJob(jobId, projectName, status) + } catch (e: any) { + getLogger().error('Code Transformation: Error resuming job (id: %s): %s', jobId, (e as Error).message) + transformByQState.setJobDefaults() + messenger?.sendJobFinishedMessage(tabID!, CodeWhispererConstants.refreshErrorChatMessage) + void vscode.window.showErrorMessage(CodeWhispererConstants.refreshErrorNotification(jobId)) + void this.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 this.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 + } + // delete metadata and zipped code files, if they exist + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'metadata.txt'), { + force: true, + }) + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip'), { + force: true, + }) + } + + 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 this.updateHistoryFile(status, duration, jobHistoryPath, jobId) + } + + private async 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) + + if (!(await fs.existsDir(path.join(jobHistoryPath, 'summary')))) { + await fs.mkdir(path.join(jobHistoryPath, 'summary')) + } + await fs.copy(path.join(resultsPath, 'patch', 'diff.patch'), path.join(jobHistoryPath, 'diff.patch')) + await fs.copy( + path.join(resultsPath, 'summary', 'summary.md'), + path.join(jobHistoryPath, 'summary', 'summary.md') + ) + if (await fs.existsFile(path.join(resultsPath, 'summary', 'buildCommandOutput.log'))) { + await fs.copy( + path.join(resultsPath, 'summary', 'buildCommandOutput.log'), + path.join(jobHistoryPath, 'summary', 'buildCommandOutput.log') + ) + } + } catch (error) { + jobHistoryPath = '' + } finally { + // delete temporary extraction directory + await fs.delete(resultsPath, { recursive: true, force: true }) + } + } + return jobHistoryPath + } + + private async 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 this.updateContent('job history', undefined, true) + } + + private async resumeJob(jobId: string, projectName: string, status: string) { + // set state to prepare to resume job + await this.setupTransformationState(jobId, projectName, status) + // resume polling the job + return await this.pollAndCompleteTransformation(jobId) + } + + private async setupTransformationState(jobId: string, projectName: string, status: string) { + transformByQState.setJobId(jobId) + transformByQState.setPolledJobStatus(status) + transformByQState.setJobHistoryPath(path.join(os.homedir(), '.aws', 'transform', projectName, jobId)) + const metadataFile = await fs.readFileText(path.join(transformByQState.getJobHistoryPath(), 'metadata.txt')) + const metadata = metadataFile.split('\t') + transformByQState.setTransformationType(metadata[1] as TransformationType) + transformByQState.setSourceJDKVersion(metadata[2] as JDKVersion) + transformByQState.setTargetJDKVersion(metadata[3] as JDKVersion) + transformByQState.setCustomDependencyVersionFilePath(metadata[4]) + transformByQState.setPayloadFilePath( + path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip') + ) + setMaven() + transformByQState.setCustomBuildCommand(metadata[5]) + transformByQState.setTargetJavaHome(metadata[6]) + transformByQState.setProjectPath(metadata[7]) + transformByQState.setStartTime(metadata[8]) + } + + private async pollAndCompleteTransformation(jobId: string) { + const status = await pollTransformationStatusUntilComplete( + jobId, + AuthUtil.instance.regionProfileManager.activeRegionProfile + ) + // delete payload and metadata files + await fs.delete(transformByQState.getPayloadFilePath(), { force: true }) + await fs.delete(path.join(transformByQState.getJobHistoryPath(), 'metadata.txt'), { force: true }) + // delete temporary build logs file + const logFilePath = path.join(os.tmpdir(), 'build-logs.txt') + await fs.delete(logFilePath, { force: true }) + return status + } + private generateTransformationStepMarkup( name: string, startTime: Date | undefined, @@ -541,3 +898,34 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider } } } + +export async function readHistoryFile(): Promise { + const history: CodeWhispererConstants.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 +} diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts index 0b678f8120d..7bb4427437a 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts @@ -426,6 +426,7 @@ export class ProposedTransformationExplorer { let deserializeErrorMessage = undefined let pathContainingArchive = '' patchFiles = [] // reset patchFiles if there was a previous transformation + try { // Download and deserialize the zip pathContainingArchive = path.dirname(pathToArchive) @@ -433,6 +434,7 @@ export class ProposedTransformationExplorer { zip.extractAllTo(pathContainingArchive) const files = fs.readdirSync(path.join(pathContainingArchive, ExportResultArchiveStructure.PathToPatch)) singlePatchFile = path.join(pathContainingArchive, ExportResultArchiveStructure.PathToPatch, files[0]) + fs.copyFileSync(singlePatchFile, path.join(transformByQState.getJobHistoryPath(), 'diff.patch')) // store diff patch locally patchFiles.push(singlePatchFile) diffModel.parseDiff(patchFiles[0], transformByQState.getProjectPath()) @@ -441,6 +443,25 @@ export class ProposedTransformationExplorer { transformByQState.setSummaryFilePath( path.join(pathContainingArchive, ExportResultArchiveStructure.PathToSummary) ) + // store summary and build log locally for history + if (!fs.existsSync(path.join(transformByQState.getJobHistoryPath(), 'summary'))) { + fs.mkdirSync(path.join(transformByQState.getJobHistoryPath(), 'summary')) + } + fs.copyFileSync( + transformByQState.getSummaryFilePath(), + path.join(transformByQState.getJobHistoryPath(), 'summary', 'summary.md') + ) + if ( + fs.existsSync( + path.join(path.dirname(transformByQState.getSummaryFilePath()), 'buildCommandOutput.log') + ) + ) { + fs.copyFileSync( + path.join(path.dirname(transformByQState.getSummaryFilePath()), 'buildCommandOutput.log'), + path.join(transformByQState.getJobHistoryPath(), 'summary', 'buildCommandOutput.log') + ) + } + transformByQState.setResultArchiveFilePath(pathContainingArchive) await setContext('gumby.isSummaryAvailable', true) diff --git a/packages/core/src/codewhisperer/util/closingBracketUtil.ts b/packages/core/src/codewhisperer/util/closingBracketUtil.ts new file mode 100644 index 00000000000..4892c5694b4 --- /dev/null +++ b/packages/core/src/codewhisperer/util/closingBracketUtil.ts @@ -0,0 +1,263 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * Reference: https://github.com/aws/aws-toolkit-vscode/blob/amazonq/v1.74.0/packages/core/src/codewhisperer/util/closingBracketUtil.ts + */ + +import * as vscode from 'vscode' +import * as CodeWhispererConstants from '../models/constants' + +interface bracketMapType { + [k: string]: string +} + +const quotes = ["'", '"', '`'] +const parenthesis = ['(', '[', '{', ')', ']', '}', '<', '>'] + +const closeToOpen: bracketMapType = { + ')': '(', + ']': '[', + '}': '{', + '>': '<', +} + +const openToClose: bracketMapType = { + '(': ')', + '[': ']', + '{': '}', + '<': '>', +} + +/** + * LeftContext | Recommendation | RightContext + * This function aims to resolve symbols which are redundant and need to be removed + * The high level logic is as followed + * 1. Pair non-paired closing symbols(parenthesis, brackets, quotes) existing in the "recommendation" with non-paired symbols existing in the "leftContext" + * 2. Remove non-paired closing symbols existing in the "rightContext" + * @param endPosition: end position of the effective recommendation written by CodeWhisperer + * @param startPosition: start position of the effective recommendation by CodeWhisperer + * + * for example given file context ('|' is where we trigger the service): + * anArray.pu| + * recommendation returned: "sh(element);" + * typeahead: "sh(" + * the effective recommendation written by CodeWhisperer: "element);" + */ +export async function handleExtraBrackets( + editor: vscode.TextEditor, + endPosition: vscode.Position, + startPosition: vscode.Position +) { + const recommendation = editor.document.getText(new vscode.Range(startPosition, endPosition)) + const endOffset = editor.document.offsetAt(endPosition) + const startOffset = editor.document.offsetAt(startPosition) + const leftContext = editor.document.getText( + new vscode.Range( + startPosition, + editor.document.positionAt(Math.max(startOffset - CodeWhispererConstants.charactersLimit, 0)) + ) + ) + + const rightContext = editor.document.getText( + new vscode.Range( + editor.document.positionAt(endOffset), + editor.document.positionAt(endOffset + CodeWhispererConstants.charactersLimit) + ) + ) + const bracketsToRemove = getBracketsToRemove( + editor, + recommendation, + leftContext, + rightContext, + endPosition, + startPosition + ) + + const quotesToRemove = getQuotesToRemove( + editor, + recommendation, + leftContext, + rightContext, + endPosition, + startPosition + ) + + const symbolsToRemove = [...bracketsToRemove, ...quotesToRemove] + + if (symbolsToRemove.length) { + await removeBracketsFromRightContext(editor, symbolsToRemove, endPosition) + } +} + +const removeBracketsFromRightContext = async ( + editor: vscode.TextEditor, + idxToRemove: number[], + endPosition: vscode.Position +) => { + const offset = editor.document.offsetAt(endPosition) + + await editor.edit( + (editBuilder) => { + for (const idx of idxToRemove) { + const range = new vscode.Range( + editor.document.positionAt(offset + idx), + editor.document.positionAt(offset + idx + 1) + ) + editBuilder.delete(range) + } + }, + { undoStopAfter: false, undoStopBefore: false } + ) +} + +function getBracketsToRemove( + editor: vscode.TextEditor, + recommendation: string, + leftContext: string, + rightContext: string, + end: vscode.Position, + start: vscode.Position +) { + const unpairedClosingsInReco = nonClosedClosingParen(recommendation) + const unpairedOpeningsInLeftContext = nonClosedOpneingParen(leftContext, unpairedClosingsInReco.length) + const unpairedClosingsInRightContext = nonClosedClosingParen(rightContext) + + const toRemove: number[] = [] + + let i = 0 + let j = 0 + let k = 0 + while (i < unpairedOpeningsInLeftContext.length && j < unpairedClosingsInReco.length) { + const opening = unpairedOpeningsInLeftContext[i] + const closing = unpairedClosingsInReco[j] + + const isPaired = closeToOpen[closing.char] === opening.char + const rightContextCharToDelete = unpairedClosingsInRightContext[k] + + if (isPaired) { + if (rightContextCharToDelete && rightContextCharToDelete.char === closing.char) { + const rightContextStart = editor.document.offsetAt(end) + 1 + const symbolPosition = editor.document.positionAt( + rightContextStart + rightContextCharToDelete.strOffset + ) + const lineCnt = recommendation.split('\n').length - 1 + const isSameline = symbolPosition.line - lineCnt === start.line + + if (isSameline) { + toRemove.push(rightContextCharToDelete.strOffset) + } + + k++ + } + } + + i++ + j++ + } + + return toRemove +} + +function getQuotesToRemove( + editor: vscode.TextEditor, + recommendation: string, + leftContext: string, + rightContext: string, + endPosition: vscode.Position, + startPosition: vscode.Position +) { + let leftQuote: string | undefined = undefined + let leftIndex: number | undefined = undefined + for (let i = leftContext.length - 1; i >= 0; i--) { + const char = leftContext[i] + if (quotes.includes(char)) { + leftQuote = char + leftIndex = leftContext.length - i + break + } + } + + let rightQuote: string | undefined = undefined + let rightIndex: number | undefined = undefined + for (let i = 0; i < rightContext.length; i++) { + const char = rightContext[i] + if (quotes.includes(char)) { + rightQuote = char + rightIndex = i + break + } + } + + let quoteCountInReco = 0 + if (leftQuote && rightQuote && leftQuote === rightQuote) { + for (const char of recommendation) { + if (quotes.includes(char) && char === leftQuote) { + quoteCountInReco++ + } + } + } + + if (leftIndex !== undefined && rightIndex !== undefined && quoteCountInReco % 2 !== 0) { + const p = editor.document.positionAt(editor.document.offsetAt(endPosition) + rightIndex) + + if (endPosition.line === startPosition.line && endPosition.line === p.line) { + return [rightIndex] + } + } + + return [] +} + +function nonClosedOpneingParen(str: string, cnt?: number): { char: string; strOffset: number }[] { + const resultSet: { char: string; strOffset: number }[] = [] + const stack: string[] = [] + + for (let i = str.length - 1; i >= 0; i--) { + const char = str[i] + if (char! in parenthesis) { + continue + } + + if (char in closeToOpen) { + stack.push(char) + if (cnt && cnt === resultSet.length) { + return resultSet + } + } else if (char in openToClose) { + if (stack.length !== 0 && stack[stack.length - 1] === openToClose[char]) { + stack.pop() + } else { + resultSet.push({ char: char, strOffset: i }) + } + } + } + + return resultSet +} + +function nonClosedClosingParen(str: string, cnt?: number): { char: string; strOffset: number }[] { + const resultSet: { char: string; strOffset: number }[] = [] + const stack: string[] = [] + + for (let i = 0; i < str.length; i++) { + const char = str[i] + if (char! in parenthesis) { + continue + } + + if (char in openToClose) { + stack.push(char) + if (cnt && cnt === resultSet.length) { + return resultSet + } + } else if (char in closeToOpen) { + if (stack.length !== 0 && stack[stack.length - 1] === closeToOpen[char]) { + stack.pop() + } else { + resultSet.push({ char: char, strOffset: i }) + } + } + } + + return resultSet +} diff --git a/packages/core/src/shared/datetime.ts b/packages/core/src/shared/datetime.ts index 6123421666a..8043f94d343 100644 --- a/packages/core/src/shared/datetime.ts +++ b/packages/core/src/shared/datetime.ts @@ -154,3 +154,25 @@ export function formatDateTimestamp(forceUTC: boolean, d: Date = new Date()): st // trim 'Z' (last char of iso string) and add offset string return `${iso.substring(0, iso.length - 1)}${offsetString}` } + +/** + * Checks if a given timestamp is within 30 days of the current day + * @param timeStamp + * @returns true if timeStamp is within 30 days, false otherwise + */ +export function isWithin30Days(timeStamp: string): boolean { + if (!timeStamp) { + return false // No timestamp given + } + + const startDate = new Date(timeStamp) + const currentDate = new Date() + + // Calculate the difference in milliseconds + const timeDifference = currentDate.getTime() - startDate.getTime() + + // Convert milliseconds to days (1000ms * 60s * 60min * 24hr) + const daysDifference = timeDifference / (1000 * 60 * 60 * 24) + + return daysDifference <= 30 +} diff --git a/packages/core/src/shared/lsp/utils/platform.ts b/packages/core/src/shared/lsp/utils/platform.ts index 6928a6eb0ce..190a29f7ab1 100644 --- a/packages/core/src/shared/lsp/utils/platform.ts +++ b/packages/core/src/shared/lsp/utils/platform.ts @@ -8,7 +8,7 @@ import { ToolkitError } from '../../errors' import { Logger } from '../../logger/logger' import { ChildProcess } from '../../utilities/processUtils' import { waitUntil } from '../../utilities/timeoutUtils' -import { isDebugInstance } from '../../vscode/env' +import { isDebugInstance, isRemoteWorkspace } from '../../vscode/env' import { isSageMaker } from '../../extensionUtilities' import { getLogger } from '../../logger/logger' @@ -124,8 +124,16 @@ export function createServerOptions({ getLogger().info(`[SageMaker Debug] Using SSO auth mode, not setting USE_IAM_AUTH`) } } catch (err) { - getLogger().warn(`[SageMaker Debug] Failed to parse SageMaker cookies, defaulting to IAM auth: ${err}`) - env.USE_IAM_AUTH = 'true' + if (isRemoteWorkspace() && env.SERVICE_NAME !== 'SageMakerUnifiedStudio') { + getLogger().warn( + `[SageMaker Debug] Failed to parse SageMaker cookies in remote space, not SMUS env, not defaulting to IAM auth: ${err}` + ) + } else { + getLogger().warn( + `[SageMaker Debug] Failed to parse SageMaker cookies, defaulting to IAM auth: ${err}` + ) + env.USE_IAM_AUTH = 'true' + } } // Log important environment variables for debugging diff --git a/packages/core/src/shared/lsp/utils/setupStage.ts b/packages/core/src/shared/lsp/utils/setupStage.ts index cd9dcfa319a..8f43ba16f3f 100644 --- a/packages/core/src/shared/lsp/utils/setupStage.ts +++ b/packages/core/src/shared/lsp/utils/setupStage.ts @@ -5,6 +5,7 @@ import { LanguageServerSetup, LanguageServerSetupStage, telemetry } from '../../telemetry/telemetry' import { tryFunctions } from '../../utilities/tsUtils' +import { AuthUtil } from '../../../codewhisperer/util/authUtil' /** * Runs the designated stage within a telemetry span and optionally uses the getMetadata extractor to record metadata from the result of the stage. @@ -20,6 +21,7 @@ export async function lspSetupStage( ) { return await telemetry.languageServer_setup.run(async (span) => { span.record({ languageServerSetupStage: stageName }) + span.record({ credentialStartUrl: AuthUtil.instance.startUrl ?? 'Undefined' }) const result = await runStage() if (getMetadata) { span.record(getMetadata(result)) diff --git a/packages/core/src/shared/telemetry/service-2.json b/packages/core/src/shared/telemetry/service-2.json index 9711b3473cc..a0ca9f7b14e 100644 --- a/packages/core/src/shared/telemetry/service-2.json +++ b/packages/core/src/shared/telemetry/service-2.json @@ -205,7 +205,8 @@ "AWSProduct", "AWSProductVersion", "ClientID", - "MetricData" + "MetricData", + "CredentialStartUrl" ], "members":{ "AWSProduct":{"shape":"AWSProduct"}, @@ -217,7 +218,8 @@ "ComputeEnv": {"shape":"ComputeEnv"}, "ParentProduct":{"shape":"Value"}, "ParentProductVersion":{"shape":"Value"}, - "MetricData":{"shape":"MetricData"} + "MetricData":{"shape":"MetricData"}, + "CredentialStartUrl": {"shape":"Value"} } }, "Sentiment":{ diff --git a/packages/core/src/shared/telemetry/telemetryClient.ts b/packages/core/src/shared/telemetry/telemetryClient.ts index 139b4b48814..97ec2508cfc 100644 --- a/packages/core/src/shared/telemetry/telemetryClient.ts +++ b/packages/core/src/shared/telemetry/telemetryClient.ts @@ -17,6 +17,7 @@ import globals from '../extensionGlobals' import { DevSettings } from '../settings' import { ClassToInterfaceType } from '../utilities/tsUtils' import { getComputeEnvType, getSessionId } from './util' +import { AuthUtil } from '../../codewhisperer/util/authUtil' export const accountMetadataKey = 'awsAccount' export const regionKey = 'awsRegion' @@ -112,6 +113,7 @@ export class DefaultTelemetryClient implements TelemetryClient { ParentProduct: vscode.env.appName, ParentProductVersion: vscode.version, MetricData: batch, + CredentialStartUrl: AuthUtil.instance.startUrl ?? 'Undefined', }) .promise() this.logger.info(`telemetry: sent batch (size=${batch.length})`) diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index 3bc103d81e3..1128eef8ab6 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -1221,6 +1221,42 @@ "required": false } ] + }, + { + "name": "languageServer_setup", + "description": "Sets up a language server", + "unit": "Milliseconds", + "passive": true, + "metadata": [ + { + "type": "id", + "required": true + }, + { + "type": "languageServerSetupStage", + "required": true + }, + { + "type": "languageServerLocation", + "required": false + }, + { + "type": "languageServerVersion", + "required": false + }, + { + "type": "manifestLocation", + "required": false + }, + { + "type": "manifestSchemaVersion", + "required": false + }, + { + "type": "credentialStartUrl", + "required": false + } + ] } ] } diff --git a/packages/core/src/shared/utilities/diffUtils.ts b/packages/core/src/shared/utilities/diffUtils.ts index 439c87dd7e6..994f91a5434 100644 --- a/packages/core/src/shared/utilities/diffUtils.ts +++ b/packages/core/src/shared/utilities/diffUtils.ts @@ -152,24 +152,12 @@ export function getDiffCharsAndLines( } /** - * Extracts modified lines from a unified diff string. - * @param unifiedDiff The unified diff patch as a string. + * Extracts modified lines by comparing added and removed lines. + * @param addedLines The array of added lines. + * @param removedLines The array of removed lines. * @returns A Map where keys are removed lines and values are the corresponding modified (added) lines. */ -export function getModifiedLinesFromUnifiedDiff(unifiedDiff: string): Map { - const removedLines: string[] = [] - const addedLines: string[] = [] - - // Parse the unified diff to extract removed and added lines - const lines = unifiedDiff.split('\n') - for (const line of lines) { - if (line.startsWith('-') && !line.startsWith('---')) { - removedLines.push(line.slice(1)) - } else if (line.startsWith('+') && !line.startsWith('+++')) { - addedLines.push(line.slice(1)) - } - } - +export function getModifiedLinesFromCode(addedLines: string[], removedLines: string[]): Map { const modifiedMap = new Map() let addedIndex = 0 diff --git a/packages/core/src/shared/utilities/index.ts b/packages/core/src/shared/utilities/index.ts index 18d86da4d55..a361834406c 100644 --- a/packages/core/src/shared/utilities/index.ts +++ b/packages/core/src/shared/utilities/index.ts @@ -8,3 +8,4 @@ export { VSCODE_EXTENSION_ID } from '../extensions' export * from './functionUtils' export * as messageUtils from './messages' export * as CommentUtils from './commentUtils' +export * from './editorUtilities' diff --git a/packages/core/src/test/amazonqGumby/transformationJobHistory.test.ts b/packages/core/src/test/amazonqGumby/transformationJobHistory.test.ts new file mode 100644 index 00000000000..4e485553415 --- /dev/null +++ b/packages/core/src/test/amazonqGumby/transformationJobHistory.test.ts @@ -0,0 +1,466 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as CodeWhispererConstants from '../../codewhisperer/models/constants' +import { transformByQState, sessionJobHistory } from '../../codewhisperer/models/model' +import { codeWhispererClient } from '../../codewhisperer/client/codewhisperer' +import { + TransformationHubViewProvider, + readHistoryFile, +} from '../../codewhisperer/service/transformByQ/transformationHubViewProvider' +import fs from '../../shared/fs/fs' +import nodeFs from 'fs' // eslint-disable-line no-restricted-imports +import { postTransformationJob } from '../../codewhisperer/commands/startTransformByQ' +import * as transformApiHandler from '../../codewhisperer/service/transformByQ/transformApiHandler' +import * as vscode from 'vscode' + +describe('Transformation Job History', function () { + let transformationHub: TransformationHubViewProvider + + // Mock job objects + const mockJobs = { + completed: { + startTime: '07/14/25, 09:00 AM', + projectName: 'old-project', + status: 'COMPLETED', + duration: '3 min', + diffPath: '/path/to/diff.patch', + summaryPath: '/path/to/summary.md', + jobId: 'old-job-456', + } as CodeWhispererConstants.HistoryObject, + + transforming: { + startTime: '07/14/25, 10:00 AM', + projectName: 'incomplete-project', + status: 'TRANSFORMING', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'inc-100', + } as CodeWhispererConstants.HistoryObject, + + failed: { + startTime: '07/14/25, 09:00 AM', + projectName: 'old-project', + status: 'FAILED', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'fail-100', + } as CodeWhispererConstants.HistoryObject, + + failedBE: { + startTime: '07/10/25, 10:00 AM', + projectName: 'failed-project', + status: 'FAILED_BE', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'failbe-300', + } as CodeWhispererConstants.HistoryObject, + + stopped: { + startTime: '07/14/25, 10:00 AM', + projectName: 'cancelled-project', + status: 'STOPPED', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'stop-200', + } as CodeWhispererConstants.HistoryObject, + } + + // setup function helpers + function setupRunningJob(jobId = 'running-job-123') { + sinon.stub(transformByQState, 'isRunning').returns(true) + sinon.stub(transformByQState, 'getJobId').returns(jobId) + sessionJobHistory[jobId] = { + startTime: '07/14/25, 11:00 AM', + projectName: 'running-project', + status: 'TRANSFORMING', + duration: '2 min', + } + return jobId + } + + beforeEach(function () { + transformationHub = TransformationHubViewProvider.instance + }) + + afterEach(function () { + sinon.restore() + }) + + describe('Viewing job history in Transformation Hub', function () { + it('Nothing to show message when no history', function () { + transformationHub['transformationHistory'] = [] + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + assert(result.includes('Transformation History')) + assert(result.includes(CodeWhispererConstants.nothingToShowMessage)) + }) + + it('Can see previously run jobs', function () { + transformationHub['transformationHistory'] = [mockJobs.completed, mockJobs.transforming, mockJobs.failedBE] + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + assert(result.includes('old-project')) + assert(result.includes('COMPLETED')) + assert(result.includes('old-job-456')) + assert(result.includes('incomplete-project')) + assert(result.includes('TRANSFORMING')) + assert(result.includes('inc-100')) + assert(!result.includes('FAILED_BE'), 'Table should only say FAILED in the status column') + assert(result.includes(']*disabled`, 'i') + const incompleteJobButtonRegex = new RegExp(`row-id="fail-100"[^>]*disabled`, 'i') + const completedJobButtonRegex = new RegExp(`row-id="old-job-456"[^>]*disabled`, 'i') + assert( + runningJobButtonRegex.test(result) && + incompleteJobButtonRegex.test(result) && + completedJobButtonRegex.test(result), + "All jobs' refresh buttons should be disabled" + ) + }) + + it('Cannot click refresh button of STOPPED jobs', function () { + transformationHub['transformationHistory'] = [mockJobs.completed, mockJobs.stopped] + + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + const runningJobButtonRegex = new RegExp(`row-id="stop-200"[^>]*disabled`, 'i') + assert(runningJobButtonRegex.test(result), "STOPPED job's refresh button should be disabled") + }) + + it('Cannot click refresh button of jobs that failed on backend', function () { + transformationHub['transformationHistory'] = [mockJobs.failed, mockJobs.failedBE] + + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + const runningJobButtonRegex = new RegExp(`row-id="failbe-300"[^>]*disabled`, 'i') + assert(runningJobButtonRegex.test(result), "FAILED_BE job's refresh button should be disabled") + const completedJobButtonRegex = new RegExp(`row-id="fail-100"[^>]*disabled`, 'i') + assert(!completedJobButtonRegex.test(result), "Incomplete (FAILED) job's refresh button should be enabled") + }) + }) + + describe('Refreshing jobs', function () { + describe('Updating status', function () { + let codeWhispererClientStub: sinon.SinonStub + + beforeEach(function () { + codeWhispererClientStub = sinon.stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + }) + + it('Does not fetch status for already completed jobs', async function () { + sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') // TODO: refactor TransformationHubViewProvider and extract private methods + sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'COMPLETED', 'test-project') + sinon.assert.notCalled(codeWhispererClientStub) + + await transformationHub['refreshJob']('job-456', 'PARTIALLY_COMPLETED', 'test-project2') + sinon.assert.notCalled(codeWhispererClientStub) + }) + + it('Fetches updated status', async function () { + const mockResponse = { + transformationJob: { + status: 'COMPLETED', + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - 60000), // 1 minute ago + }, + } + codeWhispererClientStub.resolves(mockResponse) + sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') + sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + sinon.assert.calledOnce(codeWhispererClientStub) + }) + }) + + describe('Downloading artifacts', function () { + it('Does not download artifacts when diff patch already exists', async function () { + const fsExistsStub = sinon.stub(fs, 'existsFile').resolves(true) + const jobHistoryPath = await transformationHub['retrieveArtifacts']('job-123', 'test-project') + + sinon.assert.called(fsExistsStub) + assert.strictEqual(jobHistoryPath, '', 'Should return empty string when diff already exists') + }) + + it('Does not attempt to download artifacts for FAILED/STOPPED jobs', async function () { + const mockResponse = { + transformationJob: { + status: 'STOPPED', + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - 60000), + }, + } as any + const codeWhispererClientStub = sinon + .stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + .resolves(mockResponse) + const retrieveArtifactsStub = sinon.stub(transformationHub as any, 'retrieveArtifacts') + sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + + sinon.assert.calledOnce(codeWhispererClientStub) + sinon.assert.notCalled(retrieveArtifactsStub) + }) + }) + + describe('Updating history file', function () { + let fsWriteStub: sinon.SinonStub + let fsAppendStub: sinon.SinonStub + + // mocks and setup + const mockHistoryContent = + 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n' + + '07/14/25, 09:00 AM\ttest-project\tFAILED\t5 min\t\t\tjob-123\n' + + '07/14/25, 10:00 AM\tother-project\tCOMPLETED\t3 min\t/path/diff.patch\t/path/summary.md\tjob-456\n' + + function createMockTransformationResponse(status: string, timeOffset = 300000) { + return { + transformationJob: { + status, + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - timeOffset), + }, + } as any + } + + function setupRefreshJobTest(mockResponse: any) { + const codeWhispererClientStub = sinon + .stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + .resolves(mockResponse) + const retrieveArtifactsStub = sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') + + return { codeWhispererClientStub, retrieveArtifactsStub } + } + + beforeEach(function () { + fsWriteStub = sinon.stub(fs, 'writeFile').resolves() + fsAppendStub = sinon.stub(fs, 'appendFile').resolves() + sinon.stub(fs, 'readFileText').resolves(mockHistoryContent) + sinon.stub(fs, 'existsFile').resolves(true) + }) + + it('Updates existing job entry in history file', async function () { + const mockResponse = createMockTransformationResponse('STOPPED') + const { codeWhispererClientStub, retrieveArtifactsStub } = setupRefreshJobTest(mockResponse) + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + + sinon.assert.called(fsAppendStub) + const writtenContent = fsAppendStub.args[0][1] + const updatedJobLine = writtenContent.split('\n').find((line: string) => line.includes('job-123')) + assert(updatedJobLine.includes('STOPPED'), 'Status should be updated to STOPPED') + assert(updatedJobLine.includes('5 min'), 'Duration should remain 5 min') + const unchangedJobLine = writtenContent.split('\n').find((line: string) => line.includes('job-456')) + assert(unchangedJobLine) + sinon.assert.calledOnce(codeWhispererClientStub) + sinon.assert.notCalled(retrieveArtifactsStub) + }) + + it('Updates history file when job FAILED on backend', async function () { + const mockResponse = createMockTransformationResponse('FAILED') + const { codeWhispererClientStub, retrieveArtifactsStub } = setupRefreshJobTest(mockResponse) + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + + sinon.assert.called(fsWriteStub) + sinon.assert.called(fsAppendStub) + const writtenContent = fsAppendStub.args[0][1] + const updatedJobLine = writtenContent.split('\n').find((line: string) => line.includes('job-123')) + assert(updatedJobLine.includes('FAILED_BE'), 'Status should be updated to FAILED_BE') + assert(updatedJobLine.includes('5 min'), 'Duration should remain 5 min') + sinon.assert.calledOnce(codeWhispererClientStub) + sinon.assert.notCalled(retrieveArtifactsStub) + }) + + it('Does not update history file when no changes are needed', async function () { + const mockResponse = { + transformationJob: { + status: 'COMPLETED', + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - 60000), + }, + } as any + + const codeWhispererClientStub = sinon + .stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + .resolves(mockResponse) + sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') + const updateHistoryFileStub = sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'COMPLETED', 'test-project') + + sinon.assert.notCalled(codeWhispererClientStub) + sinon.assert.notCalled(updateHistoryFileStub) + }) + + it('Updates content in the UI after updating history file', async function () { + const updateContentStub = sinon.stub(transformationHub, 'updateContent').resolves() + await transformationHub['updateHistoryFile']('COMPLETED', '5 min', '/new/path', 'job-123') + sinon.assert.calledWith(updateContentStub, 'job history', undefined, true) + }) + }) + }) +}) diff --git a/packages/core/src/test/codewhisperer/mergeIssuesDisplayFindings.test.ts b/packages/core/src/test/codewhisperer/mergeIssuesDisplayFindings.test.ts new file mode 100644 index 00000000000..3a8c06a3c7d --- /dev/null +++ b/packages/core/src/test/codewhisperer/mergeIssuesDisplayFindings.test.ts @@ -0,0 +1,88 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { SecurityIssueProvider } from '../../codewhisperer/service/securityIssueProvider' +import { createCodeScanIssue } from './testUtil' +import { displayFindingsDetectorName } from '../../codewhisperer/models/constants' +import { AggregatedCodeScanIssue } from '../../codewhisperer/models/model' + +describe('mergeIssuesDisplayFindings', () => { + let provider: SecurityIssueProvider + const testFilePath = '/test/file.py' + + beforeEach(() => { + provider = Object.create(SecurityIssueProvider.prototype) + provider.issues = [] + }) + + it('should add new issues when no existing group', () => { + const newIssues: AggregatedCodeScanIssue = { + filePath: testFilePath, + issues: [createCodeScanIssue({ findingId: 'new-1' })], + } + + provider.mergeIssuesDisplayFindings(newIssues, true) + + assert.strictEqual(provider.issues.length, 1) + assert.strictEqual(provider.issues[0].filePath, testFilePath) + assert.strictEqual(provider.issues[0].issues.length, 1) + assert.strictEqual(provider.issues[0].issues[0].findingId, 'new-1') + }) + + it('should keep displayFindings when fromQCA is true', () => { + provider.issues = [ + { + filePath: testFilePath, + issues: [ + createCodeScanIssue({ findingId: 'qca-1', detectorName: 'QCA-detector' }), + createCodeScanIssue({ findingId: 'display-1', detectorName: displayFindingsDetectorName }), + ], + }, + ] + + const newIssues: AggregatedCodeScanIssue = { + filePath: testFilePath, + issues: [createCodeScanIssue({ findingId: 'new-qca-1', detectorName: 'QCA-detector' })], + } + + provider.mergeIssuesDisplayFindings(newIssues, true) + + assert.strictEqual(provider.issues.length, 1) + assert.strictEqual(provider.issues[0].issues.length, 2) + + const findingIds = provider.issues[0].issues.map((issue) => issue.findingId) + assert.ok(findingIds.includes('display-1')) + assert.ok(findingIds.includes('new-qca-1')) + assert.ok(!findingIds.includes('qca-1')) + }) + + it('should keep QCA findings when fromQCA is false', () => { + provider.issues = [ + { + filePath: testFilePath, + issues: [ + createCodeScanIssue({ findingId: 'qca-1', detectorName: 'QCA-detector' }), + createCodeScanIssue({ findingId: 'display-1', detectorName: displayFindingsDetectorName }), + ], + }, + ] + + const newIssues: AggregatedCodeScanIssue = { + filePath: testFilePath, + issues: [createCodeScanIssue({ findingId: 'new-display-1', detectorName: displayFindingsDetectorName })], + } + + provider.mergeIssuesDisplayFindings(newIssues, false) + + assert.strictEqual(provider.issues.length, 1) + assert.strictEqual(provider.issues[0].issues.length, 2) + + const findingIds = provider.issues[0].issues.map((issue) => issue.findingId) + assert.ok(findingIds.includes('qca-1')) + assert.ok(findingIds.includes('new-display-1')) + assert.ok(!findingIds.includes('display-1')) + }) +}) diff --git a/packages/core/src/test/shared/lsp/utils/platform.test.ts b/packages/core/src/test/shared/lsp/utils/platform.test.ts new file mode 100644 index 00000000000..862bd06f990 --- /dev/null +++ b/packages/core/src/test/shared/lsp/utils/platform.test.ts @@ -0,0 +1,209 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { createServerOptions } from '../../../../shared/lsp/utils/platform' +import * as extensionUtilities from '../../../../shared/extensionUtilities' +import * as env from '../../../../shared/vscode/env' +import { ChildProcess } from '../../../../shared/utilities/processUtils' + +describe('createServerOptions - SageMaker Authentication', function () { + let sandbox: sinon.SinonSandbox + let isSageMakerStub: sinon.SinonStub + let isRemoteWorkspaceStub: sinon.SinonStub + let executeCommandStub: sinon.SinonStub + + beforeEach(function () { + sandbox = sinon.createSandbox() + + isSageMakerStub = sandbox.stub(extensionUtilities, 'isSageMaker') + isRemoteWorkspaceStub = sandbox.stub(env, 'isRemoteWorkspace') + sandbox.stub(env, 'isDebugInstance').returns(false) + executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand') + + sandbox.stub(ChildProcess.prototype, 'run').resolves() + sandbox.stub(ChildProcess.prototype, 'send').resolves() + sandbox.stub(ChildProcess.prototype, 'proc').returns({} as any) + }) + + afterEach(function () { + sandbox.restore() + }) + + // jscpd:ignore-start + it('sets USE_IAM_AUTH=true when authMode is Iam', async function () { + isSageMakerStub.returns(true) + executeCommandStub.withArgs('sagemaker.parseCookies').resolves({ authMode: 'Iam' }) + + // Capture constructor arguments using sinon stub + let capturedOptions: any = undefined + const childProcessConstructorSpy = sandbox.stub().callsFake((command: string, args: string[], options: any) => { + capturedOptions = options + // Create a fake instance with the methods we need + const fakeInstance = { + run: sandbox.stub().resolves(), + send: sandbox.stub().resolves(), + proc: sandbox.stub().returns({}), + pid: sandbox.stub().returns(12345), + stop: sandbox.stub(), + stopped: false, + } + return fakeInstance + }) + + // Replace ChildProcess constructor + sandbox.replace( + require('../../../../shared/utilities/processUtils'), + 'ChildProcess', + childProcessConstructorSpy + ) + + const serverOptions = createServerOptions({ + encryptionKey: Buffer.from('test-key'), + executable: ['node'], + serverModule: 'test-module.js', + execArgv: ['--stdio'], + }) + + await serverOptions() + + assert(capturedOptions, 'ChildProcess constructor should have been called') + assert(capturedOptions.spawnOptions, 'spawnOptions should be defined') + assert(capturedOptions.spawnOptions.env, 'spawnOptions.env should be defined') + assert.equal(capturedOptions.spawnOptions.env.USE_IAM_AUTH, 'true') + }) + + it('does not set USE_IAM_AUTH when authMode is Sso', async function () { + isSageMakerStub.returns(true) + executeCommandStub.withArgs('sagemaker.parseCookies').resolves({ authMode: 'Sso' }) + + // Capture constructor arguments using sinon stub + let capturedOptions: any = undefined + const childProcessConstructorSpy = sandbox.stub().callsFake((command: string, args: string[], options: any) => { + capturedOptions = options + // Create a fake instance with the methods we need + const fakeInstance = { + run: sandbox.stub().resolves(), + send: sandbox.stub().resolves(), + proc: sandbox.stub().returns({}), + pid: sandbox.stub().returns(12345), + stop: sandbox.stub(), + stopped: false, + } + return fakeInstance + }) + + // Replace ChildProcess constructor + sandbox.replace( + require('../../../../shared/utilities/processUtils'), + 'ChildProcess', + childProcessConstructorSpy + ) + + const serverOptions = createServerOptions({ + encryptionKey: Buffer.from('test-key'), + executable: ['node'], + serverModule: 'test-module.js', + execArgv: ['--stdio'], + }) + + await serverOptions() + + assert(capturedOptions, 'ChildProcess constructor should have been called') + assert(capturedOptions.spawnOptions, 'spawnOptions should be defined') + assert(capturedOptions.spawnOptions.env, 'spawnOptions.env should be defined') + assert.equal(capturedOptions.spawnOptions.env.USE_IAM_AUTH, undefined) + }) + + it('defaults to IAM auth when parseCookies fails', async function () { + isSageMakerStub.returns(true) + isRemoteWorkspaceStub.returns(false) + executeCommandStub.withArgs('sagemaker.parseCookies').rejects(new Error('Command failed')) + + // Capture constructor arguments using sinon stub + let capturedOptions: any = undefined + const childProcessConstructorSpy = sandbox.stub().callsFake((command: string, args: string[], options: any) => { + capturedOptions = options + // Create a fake instance with the methods we need + const fakeInstance = { + run: sandbox.stub().resolves(), + send: sandbox.stub().resolves(), + proc: sandbox.stub().returns({}), + pid: sandbox.stub().returns(12345), + stop: sandbox.stub(), + stopped: false, + } + return fakeInstance + }) + + // Replace ChildProcess constructor + sandbox.replace( + require('../../../../shared/utilities/processUtils'), + 'ChildProcess', + childProcessConstructorSpy + ) + + const serverOptions = createServerOptions({ + encryptionKey: Buffer.from('test-key'), + executable: ['node'], + serverModule: 'test-module.js', + execArgv: ['--stdio'], + }) + + await serverOptions() + + assert(capturedOptions, 'ChildProcess constructor should have been called') + assert(capturedOptions.spawnOptions, 'spawnOptions should be defined') + assert(capturedOptions.spawnOptions.env, 'spawnOptions.env should be defined') + assert.equal(capturedOptions.spawnOptions.env.USE_IAM_AUTH, 'true') + }) + + it('does not default to IAM in remote workspace without SMUS', async function () { + isSageMakerStub.returns(true) + isRemoteWorkspaceStub.returns(true) + process.env.SERVICE_NAME = 'OtherService' + executeCommandStub.withArgs('sagemaker.parseCookies').rejects(new Error('Command failed')) + + // Capture constructor arguments using sinon stub + let capturedOptions: any = undefined + const childProcessConstructorSpy = sandbox.stub().callsFake((command: string, args: string[], options: any) => { + capturedOptions = options + // Create a fake instance with the methods we need + const fakeInstance = { + run: sandbox.stub().resolves(), + send: sandbox.stub().resolves(), + proc: sandbox.stub().returns({}), + pid: sandbox.stub().returns(12345), + stop: sandbox.stub(), + stopped: false, + } + return fakeInstance + }) + + // Replace ChildProcess constructor + sandbox.replace( + require('../../../../shared/utilities/processUtils'), + 'ChildProcess', + childProcessConstructorSpy + ) + + const serverOptions = createServerOptions({ + encryptionKey: Buffer.from('test-key'), + executable: ['node'], + serverModule: 'test-module.js', + execArgv: ['--stdio'], + }) + + await serverOptions() + + assert(capturedOptions, 'ChildProcess constructor should have been called') + assert(capturedOptions.spawnOptions, 'spawnOptions should be defined') + assert(capturedOptions.spawnOptions.env, 'spawnOptions.env should be defined') + assert.equal(capturedOptions.spawnOptions.env.USE_IAM_AUTH, undefined) + }) + // jscpd:ignore-end +}) diff --git a/packages/toolkit/.changes/3.70.0.json b/packages/toolkit/.changes/3.70.0.json new file mode 100644 index 00000000000..a41386724ab --- /dev/null +++ b/packages/toolkit/.changes/3.70.0.json @@ -0,0 +1,10 @@ +{ + "date": "2025-07-30", + "version": "3.70.0", + "entries": [ + { + "type": "Feature", + "description": "Improved connection actions for SSO" + } + ] +} \ No newline at end of file diff --git a/packages/toolkit/CHANGELOG.md b/packages/toolkit/CHANGELOG.md index 83cc14ff4e7..8d1ba6894c6 100644 --- a/packages/toolkit/CHANGELOG.md +++ b/packages/toolkit/CHANGELOG.md @@ -1,3 +1,7 @@ +## 3.70.0 2025-07-30 + +- **Feature** Improved connection actions for SSO + ## 3.69.0 2025-07-16 - **Bug Fix** SageMaker: Enable per-region manual filtering of Spaces diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 34fb02a8bd6..b4500fa9529 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -2,7 +2,7 @@ "name": "aws-toolkit-vscode", "displayName": "AWS Toolkit", "description": "Including CodeCatalyst, Infrastructure Composer, and support for Lambda, S3, CloudWatch Logs, CloudFormation, and many other services.", - "version": "3.70.0-SNAPSHOT", + "version": "3.71.0-SNAPSHOT", "extensionKind": [ "workspace" ],