diff --git a/package-lock.json b/package-lock.json index 900f85f1ae8..a0dc7fe9bc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "vscode-nls-dev": "^4.0.4" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.289", + "@aws-toolkits/telemetry": "^1.0.293", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", @@ -6047,9 +6047,9 @@ } }, "node_modules/@aws-toolkits/telemetry": { - "version": "1.0.289", - "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.289.tgz", - "integrity": "sha512-srzr3JGMprOX2rrUAhribVBrUMfvR6uOhwksaxu63/GMTBjEWjwfcKzpgQzxu1+InmGioBa4zKdKKV/hAaUCmw==", + "version": "1.0.294", + "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.294.tgz", + "integrity": "sha512-Hy/yj93pFuHhKCqAA9FgNjdJHRi4huUnyl13dZLzzICDlFVl/AHlm9iWmm9LR22KOuXUyu3uX40VtXLdExIHqw==", "dev": true, "dependencies": { "ajv": "^6.12.6", @@ -6083,10 +6083,11 @@ } }, "node_modules/@aws/mynah-ui": { - "version": "4.21.4", - "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.21.4.tgz", - "integrity": "sha512-sYeQHJ8yEQQQsre1soXQFebbqZFcXerIxJ/d9kg/YzZUauCirW7v/0f/kHs9y7xYkYGa8y3exV6b6e4+juO1DQ==", + "version": "4.21.5", + "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.21.5.tgz", + "integrity": "sha512-Ge7/XADBx/Phm9k2pVgjtYRoB5UOsNcTwZ0VOsWOc2JBGblEIasiT4pNNfHGKgMkLf79AKYUKRSH5IAuQRKpaQ==", "hasInstallScript": true, + "license": "Apache License 2.0", "dependencies": { "escape-html": "^1.0.3", "highlight.js": "^11.11.0", @@ -21126,7 +21127,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.43.0-SNAPSHOT", + "version": "1.44.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" @@ -21156,7 +21157,7 @@ "@aws-sdk/property-provider": "3.46.0", "@aws-sdk/smithy-client": "^3.46.0", "@aws-sdk/util-arn-parser": "^3.46.0", - "@aws/mynah-ui": "^4.21.4", + "@aws/mynah-ui": "^4.21.5", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "@iarna/toml": "^2.2.5", "@smithy/middleware-retry": "^2.3.1", @@ -21286,7 +21287,7 @@ }, "packages/toolkit": { "name": "aws-toolkit-vscode", - "version": "3.42.0-SNAPSHOT", + "version": "3.43.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/package.json b/package.json index cb669449f9d..e14641a45c4 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "mergeReports": "ts-node ./scripts/mergeReports.ts" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.289", + "@aws-toolkits/telemetry": "^1.0.293", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", diff --git a/packages/amazonq/.changes/1.43.0.json b/packages/amazonq/.changes/1.43.0.json new file mode 100644 index 00000000000..a4f2376f2e6 --- /dev/null +++ b/packages/amazonq/.changes/1.43.0.json @@ -0,0 +1,42 @@ +{ + "date": "2025-01-15", + "version": "1.43.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Auth: Valid StartURL not accepted at login" + }, + { + "type": "Bug Fix", + "description": "Fix inline completion supplementalContext length exceeding maximum in certain cases" + }, + { + "type": "Bug Fix", + "description": "Amazon Q /test: Unit test generation completed message shows after accept/reject action" + }, + { + "type": "Bug Fix", + "description": "/test: for unsupported languages was sometimes unreliable" + }, + { + "type": "Bug Fix", + "description": "User-selected customizations are sometimes not being persisted." + }, + { + "type": "Bug Fix", + "description": "Amazon q /dev: Remove hard-coded limits and instead rely server-side data to communicate number of code generations remaining" + }, + { + "type": "Feature", + "description": "Adds capability to send new context commands to AB groups" + }, + { + "type": "Feature", + "description": "feat(amazonq): Add error message for updated README too large" + }, + { + "type": "Feature", + "description": "Enhance Q inline completion context fetching for better suggestion quality" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Bug Fix-097e1816-d641-481d-8709-dae31a987473.json b/packages/amazonq/.changes/next-release/Bug Fix-097e1816-d641-481d-8709-dae31a987473.json new file mode 100644 index 00000000000..115337d4de2 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-097e1816-d641-481d-8709-dae31a987473.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Amazon Q: word duplication when pressing tab on context selector fixed" +} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-72b26a2d-3647-4d07-b0ef-92238e3f0050.json b/packages/amazonq/.changes/next-release/Bug Fix-72b26a2d-3647-4d07-b0ef-92238e3f0050.json deleted file mode 100644 index 900d0953d11..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-72b26a2d-3647-4d07-b0ef-92238e3f0050.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Fix inline completion supplementalContext length exceeding maximum in certain cases" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-7545b1a5-9cc7-416e-aafd-0d2733142941.json b/packages/amazonq/.changes/next-release/Bug Fix-7545b1a5-9cc7-416e-aafd-0d2733142941.json new file mode 100644 index 00000000000..ba017644a57 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-7545b1a5-9cc7-416e-aafd-0d2733142941.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Amazon Q: cursor no longer jumps after navigating prompt history" +} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-817a49a4-df99-4bbe-88a1-c906861b6fc1.json b/packages/amazonq/.changes/next-release/Bug Fix-817a49a4-df99-4bbe-88a1-c906861b6fc1.json new file mode 100644 index 00000000000..341a329f12f --- /dev/null +++ b/packages/amazonq/.changes/next-release/Bug Fix-817a49a4-df99-4bbe-88a1-c906861b6fc1.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Improve the text description of workspace index settings" +} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-d8c02c38-bdc4-492a-bf8c-6c35e35087be.json b/packages/amazonq/.changes/next-release/Bug Fix-d8c02c38-bdc4-492a-bf8c-6c35e35087be.json deleted file mode 100644 index 3f2f6330b2f..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-d8c02c38-bdc4-492a-bf8c-6c35e35087be.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "User-selected customizations are sometimes not being persisted." -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-ef93f909-3aa5-4e62-a4fc-850376161d24.json b/packages/amazonq/.changes/next-release/Bug Fix-ef93f909-3aa5-4e62-a4fc-850376161d24.json deleted file mode 100644 index 82b2eb42199..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-ef93f909-3aa5-4e62-a4fc-850376161d24.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon q /dev: Remove hard-coded limits and instead rely server-side data to communicate number of code generations remaining" -} diff --git a/packages/amazonq/.changes/next-release/Feature-31e78111-a241-44e5-9163-a81c5ef380d6.json b/packages/amazonq/.changes/next-release/Feature-31e78111-a241-44e5-9163-a81c5ef380d6.json new file mode 100644 index 00000000000..8ad644c97f8 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-31e78111-a241-44e5-9163-a81c5ef380d6.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "New command \"Log Extension Stats\" to see some runtime performance stats." +} diff --git a/packages/amazonq/.changes/next-release/Feature-37eb706c-57a1-4751-888d-220b2f68ee4d.json b/packages/amazonq/.changes/next-release/Feature-37eb706c-57a1-4751-888d-220b2f68ee4d.json deleted file mode 100644 index b055e2175c3..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-37eb706c-57a1-4751-888d-220b2f68ee4d.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Adds capability to send new context commands to AB groups" -} diff --git a/packages/amazonq/.changes/next-release/Feature-7f17c8e5-2242-4a8a-93bc-f7a6c08899dc.json b/packages/amazonq/.changes/next-release/Feature-7f17c8e5-2242-4a8a-93bc-f7a6c08899dc.json new file mode 100644 index 00000000000..5afafc86aef --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-7f17c8e5-2242-4a8a-93bc-f7a6c08899dc.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q: increase chat current active file context char limit to 40k" +} diff --git a/packages/amazonq/.changes/next-release/Feature-8a43c3c3-9ecb-44b6-bb0c-34d2065aee26.json b/packages/amazonq/.changes/next-release/Feature-8a43c3c3-9ecb-44b6-bb0c-34d2065aee26.json deleted file mode 100644 index 7e1d6e86caa..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-8a43c3c3-9ecb-44b6-bb0c-34d2065aee26.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "feat(amazonq): Add error message for updated README too large" -} diff --git a/packages/amazonq/.changes/next-release/Feature-d0329e3d-65bd-4987-b87c-ee11b86de399.json b/packages/amazonq/.changes/next-release/Feature-d0329e3d-65bd-4987-b87c-ee11b86de399.json deleted file mode 100644 index c8fd74b134e..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-d0329e3d-65bd-4987-b87c-ee11b86de399.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Enhance Q inline completion context fetching for better suggestion quality" -} diff --git a/packages/amazonq/.changes/next-release/Feature-f0d4f2ca-72c5-4434-8cf3-fcd742bf33d7.json b/packages/amazonq/.changes/next-release/Feature-f0d4f2ca-72c5-4434-8cf3-fcd742bf33d7.json new file mode 100644 index 00000000000..bd6c8bf7549 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-f0d4f2ca-72c5-4434-8cf3-fcd742bf33d7.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "/review: Code issues can be grouped by file location or severity" +} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index 9cfaa7fe04f..3f516e16b2d 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,15 @@ +## 1.43.0 2025-01-15 + +- **Bug Fix** Auth: Valid StartURL not accepted at login +- **Bug Fix** Fix inline completion supplementalContext length exceeding maximum in certain cases +- **Bug Fix** Amazon Q /test: Unit test generation completed message shows after accept/reject action +- **Bug Fix** /test: for unsupported languages was sometimes unreliable +- **Bug Fix** User-selected customizations are sometimes not being persisted. +- **Bug Fix** Amazon q /dev: Remove hard-coded limits and instead rely server-side data to communicate number of code generations remaining +- **Feature** Adds capability to send new context commands to AB groups +- **Feature** feat(amazonq): Add error message for updated README too large +- **Feature** Enhance Q inline completion context fetching for better suggestion quality + ## 1.42.0 2025-01-09 - **Bug Fix** Amazon Q /doc: Improve button text phrasing diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 8981ba83502..9481ad0cceb 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "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.43.0-SNAPSHOT", + "version": "1.44.0-SNAPSHOT", "extensionKind": [ "workspace" ], @@ -365,10 +365,15 @@ "when": "view == aws.AmazonQChatView || view == aws.amazonq.AmazonCommonAuth", "group": "y_toolkitMeta@2" }, + { + "command": "aws.amazonq.codescan.showGroupingStrategy", + "when": "view == aws.amazonq.SecurityIssuesTree", + "group": "navigation@1" + }, { "command": "aws.amazonq.security.showFilters", "when": "view == aws.amazonq.SecurityIssuesTree", - "group": "navigation" + "group": "navigation@2" } ], "view/item/context": [ @@ -590,6 +595,11 @@ "title": "%AWS.command.viewLogs%", "category": "%AWS.amazonq.title%" }, + { + "command": "aws.amazonq.showExtStats", + "title": "%AWS.command.showExtStats%", + "category": "%AWS.title%" + }, { "command": "aws.amazonq.github", "title": "%AWS.command.github%", @@ -724,6 +734,12 @@ { "command": "aws.amazonq.security.showFilters", "title": "%AWS.command.amazonq.filterIssues%", + "icon": "$(filter)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.codescan.showGroupingStrategy", + "title": "%AWS.command.amazonq.groupIssues%", "icon": "$(list-filter)", "enablement": "view == aws.amazonq.SecurityIssuesTree" }, diff --git a/packages/amazonq/src/inlineChat/controller/inlineChatController.ts b/packages/amazonq/src/inlineChat/controller/inlineChatController.ts index 4ffa6e1e1de..7ace8d0095e 100644 --- a/packages/amazonq/src/inlineChat/controller/inlineChatController.ts +++ b/packages/amazonq/src/inlineChat/controller/inlineChatController.ts @@ -13,6 +13,7 @@ import { computeDecorations } from '../decorations/computeDecorations' import { CodelensProvider } from '../codeLenses/codeLenseProvider' import { PromptMessage, ReferenceLogController } from 'aws-core-vscode/codewhispererChat' import { CodeWhispererSettings } from 'aws-core-vscode/codewhisperer' +import { UserWrittenCodeTracker } from 'aws-core-vscode/codewhisperer' import { codicon, getIcon, @@ -84,6 +85,7 @@ export class InlineChatController { await this.updateTaskAndLenses(task) this.referenceLogController.addReferenceLog(task.codeReferences, task.replacement ? task.replacement : '') await this.reset() + UserWrittenCodeTracker.instance.onQFinishesEdits() } public async rejectAllChanges(task = this.task, userInvoked: boolean): Promise { @@ -199,7 +201,7 @@ export class InlineChatController { getLogger().info('inlineQuickPick query is empty') return } - + UserWrittenCodeTracker.instance.onQStartsMakingEdits() this.userQuery = query await textDocumentUtil.addEofNewline(editor) this.task = await this.createTask(query, editor.document, editor.selection) diff --git a/packages/amazonq/test/e2e/amazonq/assert.ts b/packages/amazonq/test/e2e/amazonq/assert.ts index 7bc7bb2c22e..5bcec3fc0b4 100644 --- a/packages/amazonq/test/e2e/amazonq/assert.ts +++ b/packages/amazonq/test/e2e/amazonq/assert.ts @@ -28,3 +28,14 @@ export function assertQuickActions(tab: Messenger, commands: string[]) { assert.fail(`Could not find commands: ${missingCommands.join(', ')} for ${tab.tabID}`) } } + +export function assertContextCommands(tab: Messenger, contextCommands: string[]) { + assert.deepStrictEqual( + tab + .getStore() + .contextCommands?.map((x) => x.commands) + .flat() + .map((x) => x.command), + contextCommands + ) +} diff --git a/packages/amazonq/test/e2e/amazonq/chat.test.ts b/packages/amazonq/test/e2e/amazonq/chat.test.ts new file mode 100644 index 00000000000..3021be28782 --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/chat.test.ts @@ -0,0 +1,85 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { Messenger } from './framework/messenger' +import { MynahUIDataModel } from '@aws/mynah-ui' +import { assertContextCommands, assertQuickActions } from './assert' +import { registerAuthHook, using } from 'aws-core-vscode/test' +import { loginToIdC } from './utils/setup' +import { webviewConstants } from 'aws-core-vscode/amazonq' + +describe('Amazon Q Chat', function () { + let framework: qTestingFramework + let tab: Messenger + let store: MynahUIDataModel + + const availableCommands: string[] = ['/dev', '/test', '/review', '/doc', '/transform'] + + before(async function () { + /** + * Login to the amazonq-test-account. When running in CI this has unlimited + * calls to the backend api + */ + await using(registerAuthHook('amazonq-test-account'), async () => { + await loginToIdC() + }) + }) + + // jscpd:ignore-start + beforeEach(() => { + // Make sure you're logged in before every test + registerAuthHook('amazonq-test-account') + framework = new qTestingFramework('cwc', true, []) + tab = framework.createTab() + store = tab.getStore() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + it(`Shows quick actions: ${availableCommands.join(', ')}`, async () => { + assertQuickActions(tab, availableCommands) + }) + + it('Shows @workspace', () => { + assertContextCommands(tab, ['@workspace']) + }) + + // jscpd:ignore-end + + it('Shows title', () => { + assert.deepStrictEqual(store.tabTitle, 'Chat') + }) + + it('Shows placeholder', () => { + assert.deepStrictEqual(store.promptInputPlaceholder, 'Ask a question or enter "/" for quick actions') + }) + + it('Sends message', async () => { + tab.addChatMessage({ + prompt: 'What is a lambda', + }) + await tab.waitForChatFinishesLoading() + const chatItems = tab.getChatItems() + // the last item should be an answer + assert.deepStrictEqual(chatItems[4].type, 'answer') + }) + + describe('Clicks examples', () => { + it('Click help', async () => { + tab.clickButton('help') + await tab.waitForText(webviewConstants.helpMessage) + const chatItems = tab.getChatItems() + assert.deepStrictEqual(chatItems[4].type, 'answer') + assert.deepStrictEqual(chatItems[4].body, webviewConstants.helpMessage) + }) + }) +}) diff --git a/packages/amazonq/test/e2e/amazonq/framework/framework.ts b/packages/amazonq/test/e2e/amazonq/framework/framework.ts index b39dbe4314b..6a29015c06f 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/framework.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/framework.ts @@ -29,7 +29,7 @@ export class qTestingFramework { featureName: TabType, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][], - welcomeCount = 0 + welcomeCount = Number.MAX_VALUE // by default don't show the welcome page ) { /** * Instantiate the UI and override the postMessage to publish using the app message diff --git a/packages/amazonq/test/e2e/amazonq/template.test.ts b/packages/amazonq/test/e2e/amazonq/template.test.ts new file mode 100644 index 00000000000..42857575583 --- /dev/null +++ b/packages/amazonq/test/e2e/amazonq/template.test.ts @@ -0,0 +1,67 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +// jscpd:ignore-start +import assert from 'assert' +import { qTestingFramework } from './framework/framework' +import sinon from 'sinon' +import { Messenger } from './framework/messenger' +import { MynahUIDataModel } from '@aws/mynah-ui' +import { assertQuickActions } from './assert' +import { registerAuthHook, using } from 'aws-core-vscode/test' +import { loginToIdC } from './utils/setup' + +describe.skip('Amazon Q Test Template', function () { + let framework: qTestingFramework + let tab: Messenger + let store: MynahUIDataModel + + const availableCommands: string[] = [] + + before(async function () { + /** + * Login to the amazonq-test-account. When running in CI this has unlimited + * calls to the backend api + */ + await using(registerAuthHook('amazonq-test-account'), async () => { + await loginToIdC() + }) + }) + + beforeEach(() => { + // Make sure you're logged in before every test + registerAuthHook('amazonq-test-account') + + // TODO change unknown to the tab type you want to test + framework = new qTestingFramework('unknown', true, []) + tab = framework.getTabs()[0] // use the default tab that gets created + framework.createTab() // alternatively you can create a new tab + store = tab.getStore() + }) + + afterEach(() => { + framework.removeTab(tab.tabID) + framework.dispose() + sinon.restore() + }) + + it(`Shows quick actions: ${availableCommands.join(', ')}`, async () => { + assertQuickActions(tab, availableCommands) + }) + + it('Shows title', () => { + assert.deepStrictEqual(store.tabTitle, '') + }) + + it('Shows placeholder', () => { + assert.deepStrictEqual(store.promptInputPlaceholder, '') + }) + + describe('clicks examples', () => {}) + + describe('sends message', async () => {}) +}) + +// jscpd:ignore-end diff --git a/packages/amazonq/test/e2e/amazonq/welcome.test.ts b/packages/amazonq/test/e2e/amazonq/welcome.test.ts index 3f9929cf062..d9f0ccd66bf 100644 --- a/packages/amazonq/test/e2e/amazonq/welcome.test.ts +++ b/packages/amazonq/test/e2e/amazonq/welcome.test.ts @@ -8,8 +8,8 @@ import { qTestingFramework } from './framework/framework' import sinon from 'sinon' import { Messenger } from './framework/messenger' import { MynahUIDataModel } from '@aws/mynah-ui' -import { assertQuickActions } from './assert' import { FeatureContext } from 'aws-core-vscode/shared' +import { assertContextCommands, assertQuickActions } from './assert' describe('Amazon Q Welcome page', function () { let framework: qTestingFramework @@ -42,13 +42,7 @@ describe('Amazon Q Welcome page', function () { }) it('Shows context commands', async () => { - assert.deepStrictEqual( - store.contextCommands - ?.map((x) => x.commands) - .flat() - .map((x) => x.command), - ['@workspace', '@highlight'] - ) + assertContextCommands(tab, ['@workspace', '@highlight']) }) describe('shows 3 times', async () => { diff --git a/packages/amazonq/test/unit/codewhisperer/models/model.test.ts b/packages/amazonq/test/unit/codewhisperer/models/model.test.ts index ae7114a22c8..7b0888521f4 100644 --- a/packages/amazonq/test/unit/codewhisperer/models/model.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/models/model.test.ts @@ -4,7 +4,12 @@ */ import assert from 'assert' import sinon from 'sinon' -import { SecurityIssueFilters, SecurityTreeViewFilterState } from 'aws-core-vscode/codewhisperer' +import { + CodeIssueGroupingStrategy, + CodeIssueGroupingStrategyState, + SecurityIssueFilters, + SecurityTreeViewFilterState, +} from 'aws-core-vscode/codewhisperer' import { globals } from 'aws-core-vscode/shared' describe('model', function () { @@ -70,4 +75,100 @@ describe('model', function () { assert.deepStrictEqual(hiddenSeverities, ['High', 'Low']) }) }) + + describe('CodeIssueGroupingStrategyState', function () { + let sandbox: sinon.SinonSandbox + let state: CodeIssueGroupingStrategyState + + beforeEach(function () { + sandbox = sinon.createSandbox() + state = CodeIssueGroupingStrategyState.instance + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('instance', function () { + it('should return the same instance when called multiple times', function () { + const instance1 = CodeIssueGroupingStrategyState.instance + const instance2 = CodeIssueGroupingStrategyState.instance + assert.strictEqual(instance1, instance2) + }) + }) + + describe('getState', function () { + it('should return fallback when no state is stored', function () { + const result = state.getState() + + assert.equal(result, CodeIssueGroupingStrategy.Severity) + }) + + it('should return stored state when valid', async function () { + const validStrategy = CodeIssueGroupingStrategy.FileLocation + await state.setState(validStrategy) + + const result = state.getState() + + assert.equal(result, validStrategy) + }) + + it('should return fallback when stored state is invalid', async function () { + const invalidStrategy = 'invalid' + await state.setState(invalidStrategy) + + const result = state.getState() + + assert.equal(result, CodeIssueGroupingStrategy.Severity) + }) + }) + + describe('setState', function () { + it('should update state and fire change event for valid strategy', async function () { + const validStrategy = CodeIssueGroupingStrategy.FileLocation + + // Create a spy to watch for event emissions + const eventSpy = sandbox.spy() + state.onDidChangeState(eventSpy) + + await state.setState(validStrategy) + + sinon.assert.calledWith(eventSpy, validStrategy) + }) + + it('should use fallback and fire change event for invalid strategy', async function () { + const invalidStrategy = 'invalid' + + // Create a spy to watch for event emissions + const eventSpy = sandbox.spy() + state.onDidChangeState(eventSpy) + + await state.setState(invalidStrategy) + + sinon.assert.calledWith(eventSpy, CodeIssueGroupingStrategy.Severity) + }) + }) + + describe('reset', function () { + it('should set state to fallback value', async function () { + const setStateStub = sandbox.stub(state, 'setState').resolves() + + await state.reset() + + sinon.assert.calledWith(setStateStub, CodeIssueGroupingStrategy.Severity) + }) + }) + + describe('onDidChangeState', function () { + it('should allow subscribing to state changes', async function () { + const listener = sandbox.spy() + const disposable = state.onDidChangeState(listener) + + await state.setState(CodeIssueGroupingStrategy.Severity) + + sinon.assert.calledWith(listener, CodeIssueGroupingStrategy.Severity) + disposable.dispose() + }) + }) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts index bd7c3aab8de..4d973735c9f 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts @@ -10,17 +10,24 @@ import { SecurityTreeViewFilterState, SecurityIssueProvider, SeverityItem, + CodeIssueGroupingStrategyState, + CodeIssueGroupingStrategy, } from 'aws-core-vscode/codewhisperer' import { createCodeScanIssue } from 'aws-core-vscode/test' import assert from 'assert' import sinon from 'sinon' +import path from 'path' describe('SecurityIssueTreeViewProvider', function () { - let securityIssueProvider: SecurityIssueProvider let securityIssueTreeViewProvider: SecurityIssueTreeViewProvider beforeEach(function () { - securityIssueProvider = SecurityIssueProvider.instance + SecurityIssueProvider.instance.issues = [ + { filePath: 'file/path/a', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + { filePath: 'file/path/b', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + { filePath: 'file/path/c', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + { filePath: 'file/path/d', issues: [createCodeScanIssue(), createCodeScanIssue()] }, + ] securityIssueTreeViewProvider = new SecurityIssueTreeViewProvider() }) @@ -44,13 +51,6 @@ describe('SecurityIssueTreeViewProvider', function () { describe('getChildren', function () { it('should return sorted list of severities if element is undefined', function () { - securityIssueProvider.issues = [ - { filePath: 'file/path/c', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - { filePath: 'file/path/d', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - { filePath: 'file/path/a', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - { filePath: 'file/path/b', issues: [createCodeScanIssue(), createCodeScanIssue()] }, - ] - const element = undefined const result = securityIssueTreeViewProvider.getChildren(element) as SeverityItem[] assert.strictEqual(result.length, 5) @@ -102,5 +102,55 @@ describe('SecurityIssueTreeViewProvider', function () { const result = securityIssueTreeViewProvider.getChildren(element) as IssueItem[] assert.strictEqual(result.length, 0) }) + + it('should return severity-grouped items when grouping strategy is Severity', function () { + sinon.stub(CodeIssueGroupingStrategyState.instance, 'getState').returns(CodeIssueGroupingStrategy.Severity) + + const severityItems = securityIssueTreeViewProvider.getChildren() as SeverityItem[] + for (const [index, [severity, expectedIssueCount]] of [ + ['Critical', 0], + ['High', 8], + ['Medium', 0], + ['Low', 0], + ['Info', 0], + ].entries()) { + const currentSeverityItem = severityItems[index] + assert.strictEqual(currentSeverityItem.label, severity) + assert.strictEqual(currentSeverityItem.issues.length, expectedIssueCount) + + const issueItems = securityIssueTreeViewProvider.getChildren(currentSeverityItem) as IssueItem[] + assert.ok(issueItems.every((item) => item.iconPath === undefined)) + assert.ok( + issueItems.every((item) => item.description?.toString().startsWith(path.basename(item.filePath))) + ) + } + }) + + it('should return file-grouped items when grouping strategy is FileLocation', function () { + sinon + .stub(CodeIssueGroupingStrategyState.instance, 'getState') + .returns(CodeIssueGroupingStrategy.FileLocation) + + const result = securityIssueTreeViewProvider.getChildren() as FileItem[] + for (const [index, [fileName, expectedIssueCount]] of [ + ['a', 2], + ['b', 2], + ['c', 2], + ['d', 2], + ].entries()) { + const currentFileItem = result[index] + assert.strictEqual(currentFileItem.label, fileName) + assert.strictEqual(currentFileItem.issues.length, expectedIssueCount) + assert.strictEqual(currentFileItem.description, 'file/path') + + const issueItems = securityIssueTreeViewProvider.getChildren(currentFileItem) as IssueItem[] + assert.ok( + issueItems.every((item) => + item.iconPath?.toString().includes(`${item.issue.severity.toLowerCase()}.svg`) + ) + ) + assert.ok(issueItems.every((item) => item.description?.toString().startsWith('[Ln '))) + } + }) }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/tracker/userWrittenCodeTracker.test.ts b/packages/amazonq/test/unit/codewhisperer/tracker/userWrittenCodeTracker.test.ts new file mode 100644 index 00000000000..1d9b878133f --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/tracker/userWrittenCodeTracker.test.ts @@ -0,0 +1,194 @@ +/*! + * 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 { UserWrittenCodeTracker, TelemetryHelper, AuthUtil } from 'aws-core-vscode/codewhisperer' +import { createMockDocument, resetCodeWhispererGlobalVariables } from 'aws-core-vscode/test' + +describe('userWrittenCodeTracker', function () { + describe('isActive()', function () { + afterEach(async function () { + await resetCodeWhispererGlobalVariables() + UserWrittenCodeTracker.instance.reset() + sinon.restore() + }) + + it('inactive case: telemetryEnable = true, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + assert.strictEqual(UserWrittenCodeTracker.instance.isActive(), false) + }) + + it('inactive case: telemetryEnabled = false, isConnected = false', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(false) + sinon.stub(AuthUtil.instance, 'isConnected').returns(false) + assert.strictEqual(UserWrittenCodeTracker.instance.isActive(), false) + }) + + it('active case: telemetryEnabled = true, isConnected = true', function () { + sinon.stub(TelemetryHelper.instance, 'isTelemetryEnabled').returns(true) + sinon.stub(AuthUtil.instance, 'isConnected').returns(true) + assert.strictEqual(UserWrittenCodeTracker.instance.isActive(), true) + }) + }) + + describe('onDocumentChange', function () { + let tracker: UserWrittenCodeTracker | undefined + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + tracker = UserWrittenCodeTracker.instance + if (tracker) { + sinon.stub(tracker, 'isActive').returns(true) + } + }) + + afterEach(function () { + sinon.restore() + UserWrittenCodeTracker.instance.reset() + }) + + it('Should skip when content change size is more than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.onQFeatureInvoked() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 600), + rangeOffset: 0, + rangeLength: 600, + text: 'def twoSum(nums, target):\nfor '.repeat(20), + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 0) + assert.strictEqual(tracker.getUserWrittenLines('python'), 0) + }) + + it('Should not skip when content change size is less than 50', function () { + if (!tracker) { + assert.fail() + } + tracker.onQFeatureInvoked() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 49), + rangeOffset: 0, + rangeLength: 49, + text: 'a = 123'.repeat(7), + }, + ], + }) + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument('', 'test.java', 'java'), + contentChanges: [ + { + range: new vscode.Range(0, 0, 1, 3), + rangeOffset: 0, + rangeLength: 11, + text: 'a = 123\nbcd', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 49) + assert.strictEqual(tracker.getUserWrittenLines('python'), 0) + assert.strictEqual(tracker.getUserWrittenCharacters('java'), 11) + assert.strictEqual(tracker.getUserWrittenLines('java'), 1) + assert.strictEqual(tracker.getUserWrittenLines('cpp'), 0) + }) + + it('Should skip when Q is editing', function () { + if (!tracker) { + assert.fail() + } + tracker.onQFeatureInvoked() + tracker.onQStartsMakingEdits() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 30), + rangeOffset: 0, + rangeLength: 30, + text: 'def twoSum(nums, target):\nfor', + }, + ], + }) + tracker.onQFinishesEdits() + tracker.onTextDocumentChange({ + reason: undefined, + document: createMockDocument(), + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 2), + rangeOffset: 0, + rangeLength: 2, + text: '\na', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 2) + assert.strictEqual(tracker.getUserWrittenLines('python'), 1) + }) + + it('Should not reduce tokens when delete', function () { + if (!tracker) { + assert.fail() + } + const doc = createMockDocument('import math', 'test.py', 'python') + + tracker.onQFeatureInvoked() + tracker.onTextDocumentChange({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'a', + }, + ], + }) + tracker.onTextDocumentChange({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 0, + rangeLength: 0, + text: 'b', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 2) + tracker.onTextDocumentChange({ + reason: undefined, + document: doc, + contentChanges: [ + { + range: new vscode.Range(0, 0, 0, 1), + rangeOffset: 1, + rangeLength: 1, + text: '', + }, + ], + }) + assert.strictEqual(tracker.getUserWrittenCharacters('python'), 2) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/ui/prompters.test.ts b/packages/amazonq/test/unit/codewhisperer/ui/prompters.test.ts new file mode 100644 index 00000000000..9c5e00cd6f7 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/ui/prompters.test.ts @@ -0,0 +1,45 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { createQuickPickPrompterTester, QuickPickPrompterTester } from 'aws-core-vscode/test' +import { + CodeIssueGroupingStrategy, + CodeIssueGroupingStrategyState, + createCodeIssueGroupingStrategyPrompter, +} from 'aws-core-vscode/codewhisperer' +import sinon from 'sinon' +import assert from 'assert' +import vscode from 'vscode' + +const severity = { data: CodeIssueGroupingStrategy.Severity, label: 'Severity' } +const fileLocation = { data: CodeIssueGroupingStrategy.FileLocation, label: 'File Location' } + +describe('createCodeIssueGroupingStrategyPrompter', function () { + let tester: QuickPickPrompterTester + + beforeEach(function () { + tester = createQuickPickPrompterTester(createCodeIssueGroupingStrategyPrompter()) + }) + + afterEach(function () { + sinon.restore() + }) + + it('should list grouping strategies', async function () { + tester.assertItems([severity, fileLocation]) + tester.hide() + await tester.result() + }) + + it('should update state on selection', async function () { + const originalState = CodeIssueGroupingStrategyState.instance.getState() + assert.equal(originalState, CodeIssueGroupingStrategy.Severity) + + tester.selectItems(fileLocation) + tester.addCallback(() => vscode.commands.executeCommand('workbench.action.acceptSelectedQuickOpenItem')) + + await tester.result() + assert.equal(CodeIssueGroupingStrategyState.instance.getState(), fileLocation.data) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 5f5e59bfa13..0736c9e476b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -508,7 +508,7 @@ "@aws-sdk/property-provider": "3.46.0", "@aws-sdk/smithy-client": "^3.46.0", "@aws-sdk/util-arn-parser": "^3.46.0", - "@aws/mynah-ui": "^4.21.4", + "@aws/mynah-ui": "^4.21.5", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "@iarna/toml": "^2.2.5", "@smithy/middleware-retry": "^2.3.1", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index d0e31cbcb33..6a672f21121 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -74,7 +74,7 @@ "AWS.configuration.description.amazonq": "Amazon Q creates a code reference when you insert a code suggestion from Amazon Q that is similar to training data. When unchecked, Amazon Q will not show code suggestions that have code references. If you authenticate through IAM Identity Center, this setting is controlled by your Amazon Q administrator. [Learn More](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/code-reference.html)", "AWS.configuration.description.amazonq.shareContentWithAWS": "When checked, your content processed by Amazon Q may be used for service improvement (except for content processed for users with the Amazon Q Developer Pro Tier). Unchecking this box will cause AWS to delete any of your content used for that purpose. The information used to provide the Amazon Q service to you will not be affected. See the [Service Terms](https://aws.amazon.com/service-terms) for more details.", "AWS.configuration.description.amazonq.importRecommendation": "Amazon Q will add import statements with inline code suggestions when necessary.", - "AWS.configuration.description.amazonq.workspaceIndex": "When you add @workspace to your question in Amazon Q chat, Amazon Q will index your open workspace files locally to use as context for its response. Extra CPU usage is expected while indexing a workspace. This will not impact Amazon Q features or your IDE, but you may manage CPU usage by setting the number of local threads in 'Local Workspace Index Threads'.", + "AWS.configuration.description.amazonq.workspaceIndex": "When you add @workspace to your question in Amazon Q chat, Amazon Q will index your workspace files locally to use as context for its response. Extra CPU usage is expected while indexing a workspace. This will not impact Amazon Q features or your IDE, but you may manage CPU usage by setting the number of local threads in 'Local Workspace Index Threads'.", "AWS.configuration.description.amazonq.workspaceIndexWorkerThreads": "Number of worker threads of Amazon Q local index process. '0' will use the system default worker threads for balance performance. You may increase this number to more quickly index your workspace, but only up to your hardware's number of CPU cores. Please restart VS Code or reload the VS Code window after changing worker threads.", "AWS.configuration.description.amazonq.workspaceIndexUseGPU": "Enable GPU to help index your local workspace files. Only applies to Linux and Windows.", "AWS.configuration.description.amazonq.workspaceIndexMaxSize": "The maximum size of local workspace files to be indexed in MB", @@ -133,6 +133,7 @@ "AWS.command.amazonq.acceptFix": "Accept Fix", "AWS.command.amazonq.regenerateFix": "Regenerate Fix", "AWS.command.amazonq.filterIssues": "Filter Issues", + "AWS.command.amazonq.groupIssues": "Group Issues", "AWS.command.deploySamApplication": "Deploy SAM Application", "AWS.command.aboutToolkit": "About", "AWS.command.downloadLambda": "Download...", @@ -176,6 +177,7 @@ "AWS.command.submitFeedback": "Send Feedback...", "AWS.command.downloadSchemaItemCode": "Download Code Bindings", "AWS.command.viewLogs": "View Logs", + "AWS.command.showExtStats": "Log Extension Stats", "AWS.command.cloudWatchLogs.searchLogGroup": "Search Log Group", "AWS.command.cloudWatchLogs.tailLogGroup": "Tail Log Group", "AWS.command.sam.newTemplate": "Create new SAM Template", @@ -309,6 +311,10 @@ "AWS.amazonq.scans.projectScanInProgress": "Workspace review is in progress...", "AWS.amazonq.scans.fileScanInProgress": "File review is in progress...", "AWS.amazonq.scans.noGitRepo": "Your workspace is not in a git repository. I'll review your project files for security issues, and your in-flight changes for code quality issues.", + "AWS.amazonq.scans.severity": "Severity", + "AWS.amazonq.scans.fileLocation": "File Location", + "AWS.amazonq.scans.groupIssues": "Group Issues", + "AWS.amazonq.scans.groupIssues.placeholder": "Select how to group code issues", "AWS.amazonq.featureDev.error.conversationIdNotFoundError": "Conversation id must exist before starting code generation", "AWS.amazonq.featureDev.error.contentLengthError": "The folder you selected is too large for me to use as context. Please choose a smaller folder to work on. For more information on quotas, see the Amazon Q Developer documentation.", "AWS.amazonq.featureDev.error.illegalStateTransition": "Illegal transition between states, restart the conversation", diff --git a/packages/core/src/amazonq/commons/controllers/contentController.ts b/packages/core/src/amazonq/commons/controllers/contentController.ts index cdf19fe86b4..1380253f8eb 100644 --- a/packages/core/src/amazonq/commons/controllers/contentController.ts +++ b/packages/core/src/amazonq/commons/controllers/contentController.ts @@ -16,6 +16,7 @@ import { getSelectionFromRange, } from '../../../shared/utilities/textDocumentUtilities' import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, ToolkitError } from '../../../shared' +import { UserWrittenCodeTracker } from '../../../codewhisperer/tracker/userWrittenCodeTracker' export class ContentProvider implements vscode.TextDocumentContentProvider { constructor(private uri: vscode.Uri) {} @@ -41,6 +42,7 @@ export class EditorContentController { ) { const editor = window.activeTextEditor if (editor) { + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const cursorStart = editor.selection.active const indentRange = new vscode.Range(new vscode.Position(cursorStart.line, 0), cursorStart) // use the user editor intent if the position to the left of cursor is just space or tab @@ -66,9 +68,11 @@ export class EditorContentController { if (appliedEdits) { trackCodeEdit(editor, cursorStart) } + UserWrittenCodeTracker.instance.onQFinishesEdits() }, (e) => { getLogger().error('TextEditor.edit failed: %s', (e as Error).message) + UserWrittenCodeTracker.instance.onQFinishesEdits() } ) } @@ -97,6 +101,7 @@ export class EditorContentController { if (filePath && message?.code?.trim().length > 0 && selection) { try { + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const doc = await vscode.workspace.openTextDocument(filePath) const code = getIndentedCode(message, doc, selection) @@ -130,6 +135,8 @@ export class EditorContentController { const wrappedError = ChatDiffError.chain(error, `Failed to Accept Diff`, { code: chatDiffCode }) getLogger().error('%s: Failed to open diff view %s', chatDiffCode, getErrorMsg(wrappedError, true)) throw wrappedError + } finally { + UserWrittenCodeTracker.instance.onQFinishesEdits() } } } diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index cd4ec424365..9ca9af7687c 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -25,6 +25,7 @@ export { init as gumbyChatAppInit } from '../amazonqGumby/app' export { init as testChatAppInit } from '../amazonqTest/app' export { init as docChatAppInit } from '../amazonqDoc/app' export { amazonQHelpUrl } from '../shared/constants' +export * as webviewConstants from './webview/ui/texts/constants' export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/statusBarMenu' export { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands' export { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens' diff --git a/packages/core/src/amazonqFeatureDev/client/featureDev.ts b/packages/core/src/amazonqFeatureDev/client/featureDev.ts index ceef0b616e7..01be84828b3 100644 --- a/packages/core/src/amazonqFeatureDev/client/featureDev.ts +++ b/packages/core/src/amazonqFeatureDev/client/featureDev.ts @@ -25,6 +25,7 @@ import { createCodeWhispererChatStreamingClient } from '../../shared/clients/cod import { getClientId, getOptOutPreference, getOperatingSystem } from '../../shared/telemetry/util' import { extensionVersion } from '../../shared/vscode/env' import apiConfig = require('./codewhispererruntime-2022-11-11.json') +import { UserWrittenCodeTracker } from '../../codewhisperer' import { FeatureDevCodeAcceptanceEvent, FeatureDevCodeGenerationEvent, @@ -260,6 +261,7 @@ export class FeatureDevClient { references?: CodeReference[] } } + UserWrittenCodeTracker.instance.onQFeatureInvoked() const newFileContents: { zipFilePath: string; fileContent: string }[] = [] for (const [filePath, fileContent] of Object.entries(newFiles)) { diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts index 9ad24069f70..aad70595366 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts @@ -566,7 +566,7 @@ export class FeatureDevController { if (isStoppedGeneration) { this.messenger.sendAnswer({ message: ((remainingIterations) => { - if (remainingIterations && totalIterations) { + if (totalIterations !== undefined) { if (remainingIterations <= 0) { return "I stopped generating your code. You don't have more iterations left, however, you can start a new session." } else if (remainingIterations <= 2) { diff --git a/packages/core/src/amazonqGumby/chat/controller/controller.ts b/packages/core/src/amazonqGumby/chat/controller/controller.ts index bd13a2d9a95..647c5682184 100644 --- a/packages/core/src/amazonqGumby/chat/controller/controller.ts +++ b/packages/core/src/amazonqGumby/chat/controller/controller.ts @@ -53,11 +53,9 @@ import { import { CodeTransformTelemetryState } from '../../telemetry/codeTransformTelemetryState' import DependencyVersions from '../../models/dependencies' import { getStringHash } from '../../../shared/utilities/textUtilities' -import { getVersionData } from '../../../codewhisperer/service/transformByQ/transformMavenHandler' import AdmZip from 'adm-zip' import { AuthError } from '../../../auth/sso/server' import { - setMaven, openBuildLogFile, parseBuildFile, validateSQLMetadataFile, @@ -321,12 +319,6 @@ export class GumbyController { telemetryJavaVersion = JDKToTelemetryValue(javaVersion) as CodeTransformJavaSourceVersionsAllowed } telemetry.record({ codeTransformLocalJavaVersion: telemetryJavaVersion }) - - await setMaven() - const versionInfo = await getVersionData() - const mavenVersionInfoMessage = `${versionInfo[0]} (${transformByQState.getMavenName()})` - telemetry.record({ buildSystemVersion: mavenVersionInfoMessage }) - return validProjects }) return validProjects diff --git a/packages/core/src/amazonqTest/chat/controller/controller.ts b/packages/core/src/amazonqTest/chat/controller/controller.ts index 35e234cc01a..ab1157dca38 100644 --- a/packages/core/src/amazonqTest/chat/controller/controller.ts +++ b/packages/core/src/amazonqTest/chat/controller/controller.ts @@ -20,7 +20,9 @@ import { TelemetryHelper, TestGenerationBuildStep, testGenState, + tooManyRequestErrorMessage, unitTestGenerationCancelMessage, + UserWrittenCodeTracker, } from '../../../codewhisperer' import { fs, @@ -241,71 +243,76 @@ export class TestController { // eslint-disable-next-line unicorn/no-null this.messenger.sendUpdatePromptProgress(data.tabID, null) const session = this.sessionStorage.getSession() - const isCancel = data.error.message === unitTestGenerationCancelMessage - + const isCancel = data.error.uiMessage === unitTestGenerationCancelMessage + let telemetryErrorMessage = getTelemetryReasonDesc(data.error) + if (session.stopIteration) { + telemetryErrorMessage = getTelemetryReasonDesc(data.error.uiMessage.replaceAll('```', '')) + } TelemetryHelper.instance.sendTestGenerationToolkitEvent( session, true, + true, isCancel ? 'Cancelled' : 'Failed', session.startTestGenerationRequestId, performance.now() - session.testGenerationStartTime, - getTelemetryReasonDesc(data.error), + telemetryErrorMessage, session.isCodeBlockSelected, session.artifactsUploadDuration, session.srcPayloadSize, session.srcZipFileSize ) - if (session.stopIteration) { // Error from Science - this.messenger.sendMessage(data.error.message.replaceAll('```', ''), data.tabID, 'answer') + this.messenger.sendMessage(data.error.uiMessage.replaceAll('```', ''), data.tabID, 'answer') } else { isCancel - ? this.messenger.sendMessage(data.error.message, data.tabID, 'answer') + ? this.messenger.sendMessage(data.error.uiMessage, data.tabID, 'answer') : this.sendErrorMessage(data) } await this.sessionCleanUp() return } // Client side error messages - private sendErrorMessage(data: { tabID: string; error: { code: string; message: string } }) { + private sendErrorMessage(data: { + tabID: string + error: { uiMessage: string; message: string; code: string; statusCode: string } + }) { const { error, tabID } = data + // If user reached monthly limit for builderId + if (error.code === 'CreateTestJobError') { + if (error.message.includes(CodeWhispererConstants.utgLimitReached)) { + getLogger().error('Monthly quota reached for QSDA actions.') + return this.messenger.sendMessage( + i18n('AWS.amazonq.featureDev.error.monthlyLimitReached'), + tabID, + 'answer' + ) + } + if (error.message.includes('Too many requests')) { + getLogger().error(error.message) + return this.messenger.sendErrorMessage(tooManyRequestErrorMessage, tabID) + } + } if (isAwsError(error)) { if (error.code === 'ThrottlingException') { - // TODO: use the explicitly modeled exception reason for quota vs throttle - if (error.message.includes(CodeWhispererConstants.utgLimitReached)) { - getLogger().error('Monthly quota reached for QSDA actions.') - return this.messenger.sendMessage( - i18n('AWS.amazonq.featureDev.error.monthlyLimitReached'), - tabID, - 'answer' - ) - } else { - getLogger().error('Too many requests.') - // TODO: move to constants file - this.messenger.sendErrorMessage('Too many requests. Please wait before retrying.', tabID) - } - } else { - // other service errors: - // AccessDeniedException - should not happen because access is validated before this point in the client - // ValidationException - shouldn't happen because client should not send malformed requests - // ConflictException - should not happen because the client will maintain proper state - // InternalServerException - shouldn't happen but needs to be caught - getLogger().error('Other error message: %s', error.message) - this.messenger.sendErrorMessage( - 'Encountered an unexpected error when generating tests. Please try again', - tabID - ) + // TODO: use the explicitly modeled exception reason for quota vs throttle{ + getLogger().error(error.message) + this.messenger.sendErrorMessage(tooManyRequestErrorMessage, tabID) + return } - } else { - // other unexpected errors (TODO enumerate all other failure cases) + // other service errors: + // AccessDeniedException - should not happen because access is validated before this point in the client + // ValidationException - shouldn't happen because client should not send malformed requests + // ConflictException - should not happen because the client will maintain proper state + // InternalServerException - shouldn't happen but needs to be caught getLogger().error('Other error message: %s', error.message) - this.messenger.sendErrorMessage( - 'Encountered an unexpected error when generating tests. Please try again', - tabID - ) + this.messenger.sendErrorMessage('', tabID) + return } + // other unexpected errors (TODO enumerate all other failure cases) + getLogger().error('Other error message: %s', error.uiMessage) + this.messenger.sendErrorMessage('', tabID) } // This function handles actions if user clicked on any Button one of these cases will be executed @@ -456,7 +463,14 @@ export class TestController { unsupportedMessage = `I'm sorry, but /test only supports Python and Java
I will still generate a suggestion below.` } this.messenger.sendMessage(unsupportedMessage, tabID, 'answer') - await this.onCodeGeneration(session, message.prompt, tabID, fileName, filePath) + await this.onCodeGeneration( + session, + message.prompt, + tabID, + fileName, + filePath, + workspaceFolder !== undefined + ) } else { this.messenger.sendCapabilityCard({ tabID }) this.messenger.sendMessage(testGenSummaryMessage(fileName), message.tabID, 'answer-part') @@ -656,12 +670,14 @@ export class TestController { acceptedLines = acceptedLines < 0 ? 0 : acceptedLines acceptedChars -= originalContent.length acceptedChars = acceptedChars < 0 ? 0 : acceptedChars + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const document = await vscode.workspace.openTextDocument(absolutePath) await applyChanges( document, new vscode.Range(document.lineAt(0).range.start, document.lineAt(document.lineCount - 1).range.end), updatedContent ) + UserWrittenCodeTracker.instance.onQFinishesEdits() } else { await fs.writeFile(absolutePath, updatedContent) } @@ -719,9 +735,13 @@ export class TestController { // this.messenger.sendMessage('Accepted', message.tabID, 'prompt') telemetry.ui_click.emit({ elementId: 'unitTestGeneration_acceptDiff' }) + getLogger().info( + `Generated unit tests are accepted for ${session.fileLanguage ?? 'plaintext'} language with jobId: ${session.listOfTestGenerationJobId[0]}, jobGroupName: ${session.testGenerationJobGroupName}, result: Succeeded` + ) TelemetryHelper.instance.sendTestGenerationToolkitEvent( session, true, + true, 'Succeeded', session.startTestGenerationRequestId, session.latencyOfTestGeneration, @@ -739,7 +759,6 @@ export class TestController { ) await this.endSession(message, FollowUpTypes.SkipBuildAndFinish) - await this.sessionCleanUp() return if (session.listOfTestGenerationJobId.length === 1) { @@ -799,20 +818,21 @@ export class TestController { message: string, tabID: string, fileName: string, - filePath: string + filePath: string, + fileInWorkspace: boolean ) { try { // TODO: Write this entire gen response to basiccommands and call here. const editorText = await fs.readFileText(filePath) const triggerPayload = { - query: `Generate unit tests for the following part of my code: ${message}`, + query: `Generate unit tests for the following part of my code: ${message?.trim() || fileName}`, codeSelection: undefined, trigger: ChatTriggerType.ChatMessage, fileText: editorText, fileLanguage: session.fileLanguage, filePath: filePath, - message: `Generate unit tests for the following part of my code: ${message}`, + message: `Generate unit tests for the following part of my code: ${message?.trim() || fileName}`, matchPolicy: undefined, codeQuery: undefined, userIntent: UserIntent.GENERATE_UNIT_TESTS, @@ -821,13 +841,15 @@ export class TestController { const chatRequest = triggerPayloadToChatRequest(triggerPayload) const client = await createCodeWhispererChatStreamingClient() const response = await client.generateAssistantResponse(chatRequest) + UserWrittenCodeTracker.instance.onQFeatureInvoked() await this.messenger.sendAIResponse( response, session, tabID, randomUUID.toString(), triggerPayload, - fileName + fileName, + fileInWorkspace ) } finally { this.messenger.sendChatInputEnabled(tabID, true) @@ -838,11 +860,14 @@ export class TestController { // TODO: Check if there are more cases to endSession if yes create a enum or type for step private async endSession(data: any, step: FollowUpTypes) { + this.messenger.sendMessage('Unit test generation completed.', data.tabID, 'answer') + const session = this.sessionStorage.getSession() if (step === FollowUpTypes.RejectCode) { TelemetryHelper.instance.sendTestGenerationToolkitEvent( session, true, + true, 'Succeeded', session.startTestGenerationRequestId, session.latencyOfTestGeneration, @@ -858,16 +883,12 @@ export class TestController { session.numberOfTestsGenerated, session.linesOfCodeGenerated ) - telemetry.ui_click.emit({ elementId: 'unitTestGeneration_rejectDiff' }) } await this.sessionCleanUp() - // TODO: revert 'Accepted' to 'Skip build and finish' once supported - const message = step === FollowUpTypes.RejectCode ? 'Rejected' : 'Accepted' - this.messenger.sendMessage(message, data.tabID, 'prompt') - this.messenger.sendMessage(`Unit test generation workflow is completed.`, data.tabID, 'answer') + // this.messenger.sendMessage(`Unit test generation workflow is completed.`, data.tabID, 'answer') this.messenger.sendChatInputEnabled(data.tabID, true) return } @@ -1302,8 +1323,18 @@ export class TestController { 'Deleting output.log and temp result directory. testGenerationLogsDir: %s', testGenerationLogsDir ) - await fs.delete(path.join(testGenerationLogsDir, 'output.log')) - await fs.delete(this.tempResultDirPath, { recursive: true }) + const outputLogPath = path.join(testGenerationLogsDir, 'output.log') + if (await fs.existsFile(outputLogPath)) { + await fs.delete(outputLogPath) + } + if ( + await fs + .stat(this.tempResultDirPath) + .then(() => true) + .catch(() => false) + ) { + await fs.delete(this.tempResultDirPath, { recursive: true }) + } } // TODO: return build command when product approves diff --git a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts index 10b496b69d3..f842a6c1808 100644 --- a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts @@ -183,7 +183,8 @@ export class Messenger { tabID: string, triggerID: string, triggerPayload: TriggerPayload, - fileName: string + fileName: string, + fileInWorkspace: boolean ) { let message = '' let messageId = response.$metadata.requestId ?? '' @@ -277,12 +278,25 @@ export class Messenger { TelemetryHelper.instance.sendTestGenerationToolkitEvent( session, false, + fileInWorkspace, 'Cancelled', messageId, performance.now() - session.testGenerationStartTime, - getTelemetryReasonDesc(CodeWhispererConstants.unitTestGenerationCancelMessage) + getTelemetryReasonDesc( + `TestGenCancelled: ${CodeWhispererConstants.unitTestGenerationCancelMessage}` + ), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + 'TestGenCancelled' ) - this.dispatcher.sendUpdatePromptProgress( new UpdatePromptProgressMessage(tabID, cancellingProgressField) ) @@ -291,11 +305,12 @@ export class Messenger { TelemetryHelper.instance.sendTestGenerationToolkitEvent( session, false, + fileInWorkspace, 'Succeeded', messageId, - performance.now() - session.testGenerationStartTime + performance.now() - session.testGenerationStartTime, + undefined ) - this.dispatcher.sendUpdatePromptProgress( new UpdatePromptProgressMessage(tabID, testGenCompletedField) ) diff --git a/packages/core/src/amazonqTest/error.ts b/packages/core/src/amazonqTest/error.ts new file mode 100644 index 00000000000..a6694b35863 --- /dev/null +++ b/packages/core/src/amazonqTest/error.ts @@ -0,0 +1,67 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { ToolkitError } from '../shared/errors' + +export const technicalErrorCustomerFacingMessage = + 'I am experiencing technical difficulties at the moment. Please try again in a few minutes.' +const defaultTestGenErrorMessage = 'Amazon Q encountered an error while generating tests. Try again later.' +export class TestGenError extends ToolkitError { + constructor( + error: string, + code: string, + public uiMessage: string + ) { + super(error, { code }) + } +} +export class ProjectZipError extends TestGenError { + constructor(error: string) { + super(error, 'ProjectZipError', defaultTestGenErrorMessage) + } +} +export class InvalidSourceZipError extends TestGenError { + constructor() { + super('Failed to create valid source zip', 'InvalidSourceZipError', defaultTestGenErrorMessage) + } +} +export class CreateUploadUrlError extends TestGenError { + constructor(errorMessage: string) { + super(errorMessage, 'CreateUploadUrlError', technicalErrorCustomerFacingMessage) + } +} +export class UploadTestArtifactToS3Error extends TestGenError { + constructor(error: string) { + super(error, 'UploadTestArtifactToS3Error', technicalErrorCustomerFacingMessage) + } +} +export class CreateTestJobError extends TestGenError { + constructor(error: string) { + super(error, 'CreateTestJobError', technicalErrorCustomerFacingMessage) + } +} +export class TestGenTimedOutError extends TestGenError { + constructor() { + super( + 'Test generation failed. Amazon Q timed out.', + 'TestGenTimedOutError', + technicalErrorCustomerFacingMessage + ) + } +} +export class TestGenStoppedError extends TestGenError { + constructor() { + super('Unit test generation cancelled.', 'TestGenCancelled', 'Unit test generation cancelled.') + } +} +export class TestGenFailedError extends TestGenError { + constructor(error?: string) { + super(error ?? 'Test generation failed', 'TestGenFailedError', error ?? technicalErrorCustomerFacingMessage) + } +} +export class ExportResultsArchiveError extends TestGenError { + constructor(error?: string) { + super(error ?? 'Test generation failed', 'ExportResultsArchiveError', technicalErrorCustomerFacingMessage) + } +} diff --git a/packages/core/src/auth/sso/constants.ts b/packages/core/src/auth/sso/constants.ts index 4b0e781ceaa..0e6bb082d7e 100644 --- a/packages/core/src/auth/sso/constants.ts +++ b/packages/core/src/auth/sso/constants.ts @@ -11,8 +11,15 @@ export const builderIdStartUrl = 'https://view.awsapps.com/start' export const internalStartUrl = 'https://amzn.awsapps.com/start' +/** + * Doc: https://docs.aws.amazon.com/singlesignon/latest/userguide/howtochangeURL.html + */ export const ssoUrlFormatRegex = /^(https?:\/\/(.+)\.awsapps\.com\/start|https?:\/\/identitycenter\.amazonaws\.com\/ssoins-[\da-zA-Z]{16})\/?$/ -export const ssoUrlFormatMessage = - 'URLs must start with http:// or https://. Example: https://d-xxxxxxxxxx.awsapps.com/start' +/** + * It is possible for a start url to be a completely custom url that redirects to something that matches the format + * below, so this message is only a warning. + */ +export const ssoUrlFormatMessage = 'URL possibly invalid. Expected format: https://xxxxxxxxxx.awsapps.com/start' +export const urlInvalidFormatMessage = 'URL format invalid. Expected format: https://xxxxxxxxxx.com/yyyy' diff --git a/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts b/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts index f17bec9213a..511217481b3 100644 --- a/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts +++ b/packages/core/src/awsService/appBuilder/explorer/detectSamProjects.ts @@ -57,7 +57,7 @@ export async function getFiles( return await vscode.workspace.findFiles(globPattern, excludePattern) } catch (error) { - getLogger().error(`Failed to get files with pattern ${pattern}:`, error) + getLogger().error(`Failed to find files with pattern ${pattern}:`, error) return [] } } diff --git a/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts b/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts index 5f8c6b4a81e..d7d5e51bb51 100644 --- a/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts +++ b/packages/core/src/awsService/appBuilder/explorer/nodes/appNode.ts @@ -73,7 +73,7 @@ export class AppNode implements TreeNode { createPlaceholderItem( localize( 'AWS.appBuilder.explorerNode.app.noResourceTree', - '[Unable to load Resource tree for this App. Update SAM template]' + '[Unable to load resource tree for this app. Ensure SAM template is correct.]' ) ), ] diff --git a/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts b/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts index 913cdd067e0..cb0d1a669c8 100644 --- a/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts +++ b/packages/core/src/awsService/appBuilder/explorer/nodes/deployedNode.ts @@ -96,7 +96,7 @@ export async function generateDeployedNode( .Configuration as Lambda.FunctionConfiguration newDeployedResource = new LambdaFunctionNode(lambdaNode, regionCode, configuration) } catch (error: any) { - getLogger().error('Error getting Lambda configuration %O', error) + getLogger().error('Error getting Lambda configuration: %O', error) throw ToolkitError.chain(error, 'Error getting Lambda configuration', { code: 'lambdaClientError', }) @@ -107,7 +107,7 @@ export async function generateDeployedNode( createPlaceholderItem( localize( 'AWS.appBuilder.explorerNode.unavailableDeployedResource', - '[Failed to retrive deployed resource.]' + '[Failed to retrive deployed resource. Ensure your AWS account is connected.]' ) ), ] @@ -119,8 +119,8 @@ export async function generateDeployedNode( try { v3configuration = (await v3Client.send(v3command)).Configuration as FunctionConfiguration logGroupName = v3configuration.LoggingConfig?.LogGroup - } catch { - getLogger().error('Error getting Lambda V3 configuration') + } catch (error: any) { + getLogger().error('Error getting Lambda V3 configuration: %O', error) } newDeployedResource.configuration = { ...newDeployedResource.configuration, @@ -156,7 +156,10 @@ export async function generateDeployedNode( getLogger().info('Details are missing or are incomplete for: %O', deployedResource) return [ createPlaceholderItem( - localize('AWS.appBuilder.explorerNode.noApps', '[This resource is not yet supported.]') + localize( + 'AWS.appBuilder.explorerNode.noApps', + '[This resource is not yet supported in AppBuilder.]' + ) ), ] } @@ -166,7 +169,7 @@ export async function generateDeployedNode( createPlaceholderItem( localize( 'AWS.appBuilder.explorerNode.unavailableDeployedResource', - '[Failed to retrive deployed resource.]' + '[Failed to retrieve deployed resource. Ensure correct stack name and region are in the samconfig.toml, and that your account is connected.]' ) ), ] diff --git a/packages/core/src/awsService/appBuilder/explorer/samProject.ts b/packages/core/src/awsService/appBuilder/explorer/samProject.ts index fd571cd6be8..ce8d0c4878a 100644 --- a/packages/core/src/awsService/appBuilder/explorer/samProject.ts +++ b/packages/core/src/awsService/appBuilder/explorer/samProject.ts @@ -42,14 +42,17 @@ export async function getStackName(projectRoot: vscode.Uri): Promise { } catch (error: any) { switch (error.code) { case SamConfigErrorCode.samNoConfigFound: - getLogger().info('No stack name or region information available in samconfig.toml: %O', error) + getLogger().info('Stack name and/or region information not found in samconfig.toml: %O', error) break case SamConfigErrorCode.samConfigParseError: - getLogger().error(`Error getting stack name or region information: ${error.message}`, error) + getLogger().error( + `Error parsing stack name and/or region information from samconfig.toml: ${error.message}. Ensure the information is correct.`, + error + ) void showViewLogsMessage('Encountered an issue reading samconfig.toml') break default: - getLogger().warn(`Error getting stack name or region information: ${error.message}`, error) + getLogger().warn(`Error parsing stack name and/or region information: ${error.message}`, error) } return {} } diff --git a/packages/core/src/awsService/appBuilder/utils.ts b/packages/core/src/awsService/appBuilder/utils.ts index de3dee8770d..63b116b20eb 100644 --- a/packages/core/src/awsService/appBuilder/utils.ts +++ b/packages/core/src/awsService/appBuilder/utils.ts @@ -24,14 +24,14 @@ const localize = nls.loadMessageBundle() export async function runOpenTemplate(arg?: TreeNode) { const templateUri = arg ? (arg.resource as SamAppLocation).samTemplateUri : await promptUserForTemplate() if (!templateUri || !(await fs.exists(templateUri))) { - throw new ToolkitError('No template provided', { code: 'NoTemplateProvided' }) + throw new ToolkitError('SAM Template not found, cannot open template', { code: 'NoTemplateProvided' }) } const document = await vscode.workspace.openTextDocument(templateUri) await vscode.window.showTextDocument(document) } /** - * Find and open the lambda handler with given ResoruceNode + * Find and open the lambda handler with given ResourceNode * If not found, a NoHandlerFound error will be raised * @param arg ResourceNode */ @@ -56,9 +56,12 @@ export async function runOpenHandler(arg: ResourceNode): Promise { arg.resource.resource.Runtime ) if (!handlerFile) { - throw new ToolkitError(`No handler file found with name "${arg.resource.resource.Handler}"`, { - code: 'NoHandlerFound', - }) + throw new ToolkitError( + `No handler file found with name "${arg.resource.resource.Handler}". Ensure the file exists in the expected location."`, + { + code: 'NoHandlerFound', + } + ) } await vscode.workspace.openTextDocument(handlerFile).then(async (doc) => await vscode.window.showTextDocument(doc)) } @@ -90,7 +93,7 @@ export async function getLambdaHandlerFile( ): Promise { const family = getFamily(runtime) if (!supportedRuntimeForHandler.has(family)) { - throw new ToolkitError(`Runtime ${runtime} is not supported for open handler button`, { + throw new ToolkitError(`Runtime ${runtime} is not supported for the 'Open handler' button`, { code: 'RuntimeNotSupported', }) } diff --git a/packages/core/src/awsService/appBuilder/walkthrough.ts b/packages/core/src/awsService/appBuilder/walkthrough.ts index 04f43d61878..26760d896aa 100644 --- a/packages/core/src/awsService/appBuilder/walkthrough.ts +++ b/packages/core/src/awsService/appBuilder/walkthrough.ts @@ -148,13 +148,13 @@ export async function getTutorial( const appSelected = appMap.get(project + runtime) telemetry.record({ action: project + runtime, source: source ?? 'AppBuilderWalkthrough' }) if (!appSelected) { - throw new ToolkitError(`Tried to get template '${project}+${runtime}', but it hasn't been registered.`) + throw new ToolkitError(`Template '${project}+${runtime}' does not exist, choose another template.`) } try { await getPattern(serverlessLandOwner, serverlessLandRepo, appSelected.asset, outputDir, true) } catch (error) { - throw new ToolkitError(`Error occurred while fetching the pattern from serverlessland: ${error}`) + throw new ToolkitError(`An error occurred while fetching this pattern from Serverless Land: ${error}`) } } @@ -190,7 +190,7 @@ export async function genWalkthroughProject( 'No' ) if (choice !== 'Yes') { - throw new ToolkitError(`${defaultTemplateName} already exist`) + throw new ToolkitError(`A file named ${defaultTemplateName} already exists in this path.`) } } @@ -256,9 +256,9 @@ export async function initWalkthroughProjectCommand() { let runtimeSelected: TutorialRuntimeOptions | undefined = undefined try { if (!walkthroughSelected || !(typeof walkthroughSelected === 'string')) { - getLogger().info('exit on no walkthrough selected') + getLogger().info('No walkthrough selected - exiting') void vscode.window.showErrorMessage( - localize('AWS.toolkit.lambda.walkthroughNotSelected', 'Please select a template first') + localize('AWS.toolkit.lambda.walkthroughNotSelected', 'Select a template in the walkthrough.') ) return } @@ -322,7 +322,7 @@ export async function getOrUpdateOrInstallSAMCli(source: string) { } } } catch (err) { - throw ToolkitError.chain(err, 'Failed to install or detect SAM') + throw ToolkitError.chain(err, 'Failed to install or detect SAM.') } finally { telemetry.record({ source: source, toolId: 'sam-cli' }) } diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts index 4ea655fc4da..72516c06537 100644 --- a/packages/core/src/codewhisperer/activation.ts +++ b/packages/core/src/codewhisperer/activation.ts @@ -18,6 +18,7 @@ import { SecurityTreeViewFilterState, AggregatedCodeScanIssue, CodeScanIssue, + CodeIssueGroupingStrategyState, } from './models/model' import { invokeRecommendation } from './commands/invokeRecommendation' import { acceptSuggestion } from './commands/onInlineAcceptance' @@ -60,6 +61,7 @@ import { ignoreAllIssues, focusIssue, showExploreAgentsView, + showCodeIssueGroupingQuickPick, } from './commands/basicCommands' import { sleep } from '../shared/utilities/timeoutUtils' import { ReferenceLogViewProvider } from './service/referenceLogViewProvider' @@ -99,6 +101,7 @@ import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewPr import { setContext } from '../shared/vscode/setContext' import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview' import { detectCommentAboveLine } from '../shared/utilities/commentUtils' +import { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' let localize: nls.LocalizeFunc @@ -288,6 +291,8 @@ export async function activate(context: ExtContext): Promise { listCodeWhispererCommands.register(), // quick pick with security issues tree filters showSecurityIssueFilters.register(), + // quick pick code issue grouping strategy + showCodeIssueGroupingQuickPick.register(), // reset security issue filters clearFilters.register(), // handle security issues tree item clicked @@ -296,6 +301,10 @@ export async function activate(context: ExtContext): Promise { SecurityTreeViewFilterState.instance.onDidChangeState((e) => { SecurityIssueTreeViewProvider.instance.refresh() }), + // refresh treeview when grouping strategy changes + CodeIssueGroupingStrategyState.instance.onDidChangeState((e) => { + SecurityIssueTreeViewProvider.instance.refresh() + }), // show a no match state SecurityIssueTreeViewProvider.instance.onDidChangeTreeData((e) => { const noMatches = @@ -552,7 +561,7 @@ export async function activate(context: ExtContext): Promise { } CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) - + UserWrittenCodeTracker.instance.onTextDocumentChange(e) /** * Handle this keystroke event only when * 1. It is not a backspace diff --git a/packages/core/src/codewhisperer/client/user-service-2.json b/packages/core/src/codewhisperer/client/user-service-2.json index 123160fb0b3..8a847e603d7 100644 --- a/packages/core/src/codewhisperer/client/user-service-2.json +++ b/packages/core/src/codewhisperer/client/user-service-2.json @@ -626,7 +626,9 @@ "timestamp": { "shape": "Timestamp" }, "unmodifiedAcceptedCharacterCount": { "shape": "PrimitiveInteger" }, "totalNewCodeCharacterCount": { "shape": "PrimitiveInteger" }, - "totalNewCodeLineCount": { "shape": "PrimitiveInteger" } + "totalNewCodeLineCount": { "shape": "PrimitiveInteger" }, + "userWrittenCodeCharacterCount": { "shape": "PrimitiveInteger" }, + "userWrittenCodeLineCount": { "shape": "PrimitiveInteger" } } }, "CodeFixAcceptanceEvent": { diff --git a/packages/core/src/codewhisperer/commands/basicCommands.ts b/packages/core/src/codewhisperer/commands/basicCommands.ts index 999c2c53ac5..f35bb859a0d 100644 --- a/packages/core/src/codewhisperer/commands/basicCommands.ts +++ b/packages/core/src/codewhisperer/commands/basicCommands.ts @@ -66,7 +66,9 @@ import { cancel, confirm } from '../../shared' import { startCodeFixGeneration } from './startCodeFixGeneration' import { DefaultAmazonQAppInitContext } from '../../amazonq/apps/initContext' import path from 'path' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' import { parsePatch } from 'diff' +import { createCodeIssueGroupingStrategyPrompter } from '../ui/prompters' const MessageTimeOut = 5_000 @@ -451,6 +453,7 @@ export const applySecurityFix = Commands.declare( } let languageId = undefined try { + UserWrittenCodeTracker.instance.onQStartsMakingEdits() const document = await vscode.workspace.openTextDocument(targetFilePath) languageId = document.languageId const updatedContent = await getPatchedCode(targetFilePath, suggestedFix.code) @@ -565,6 +568,7 @@ export const applySecurityFix = Commands.declare( applyFixTelemetryEntry.result, !!targetIssue.suggestedFixes.length ) + UserWrittenCodeTracker.instance.onQFinishesEdits() } } ) @@ -884,6 +888,14 @@ export const showSecurityIssueFilters = Commands.declare({ id: 'aws.amazonq.secu } }) +export const showCodeIssueGroupingQuickPick = Commands.declare( + { id: 'aws.amazonq.codescan.showGroupingStrategy' }, + () => async () => { + const prompter = createCodeIssueGroupingStrategyPrompter() + await prompter.prompt() + } +) + export const focusIssue = Commands.declare( { id: 'aws.amazonq.security.focusIssue' }, () => async (issue: CodeScanIssue, filePath: string) => { diff --git a/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts b/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts index 3fd91d0f996..da581d1aacc 100644 --- a/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts +++ b/packages/core/src/codewhisperer/commands/onInlineAcceptance.ts @@ -32,6 +32,7 @@ import { RecommendationService } from '../service/recommendationService' import { Container } from '../service/serviceContainer' import { telemetry } from '../../shared/telemetry' import { TelemetryHelper } from '../util/telemetryHelper' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' export const acceptSuggestion = Commands.declare( 'aws.amazonq.accept', @@ -126,6 +127,7 @@ export async function onInlineAcceptance(acceptanceEntry: OnRecommendationAccept acceptanceEntry.editor.document.getText(insertedCoderange), acceptanceEntry.editor.document.fileName ) + UserWrittenCodeTracker.instance.onQFinishesEdits() if (acceptanceEntry.references !== undefined) { const referenceLog = ReferenceLogViewProvider.getReferenceLog( acceptanceEntry.recommendation, diff --git a/packages/core/src/codewhisperer/commands/startTestGeneration.ts b/packages/core/src/codewhisperer/commands/startTestGeneration.ts index 8480ee3184e..429e3585d36 100644 --- a/packages/core/src/codewhisperer/commands/startTestGeneration.ts +++ b/packages/core/src/codewhisperer/commands/startTestGeneration.ts @@ -21,7 +21,7 @@ import { ChildProcess, spawn } from 'child_process' // eslint-disable-line no-re import { BuildStatus } from '../../amazonqTest/chat/session/session' import { fs } from '../../shared/fs/fs' import { TestGenerationJobStatus } from '../models/constants' -import { TestGenFailedError } from '../models/errors' +import { TestGenFailedError } from '../../amazonqTest/error' import { Range } from '../client/codewhispereruserclient' // eslint-disable-next-line unicorn/no-null @@ -75,8 +75,9 @@ export async function startTestGenerationProcess( try { artifactMap = await getPresignedUrlAndUploadTestGen(zipMetadata) } finally { - if (await fs.existsFile(path.join(testGenerationLogsDir, 'output.log'))) { - await fs.delete(path.join(testGenerationLogsDir, 'output.log')) + const outputLogPath = path.join(testGenerationLogsDir, 'output.log') + if (await fs.existsFile(outputLogPath)) { + await fs.delete(outputLogPath) } await zipUtil.removeTmpFiles(zipMetadata) session.artifactsUploadDuration = performance.now() - uploadStartTime diff --git a/packages/core/src/codewhisperer/index.ts b/packages/core/src/codewhisperer/index.ts index 235da682ec9..5484411a7ac 100644 --- a/packages/core/src/codewhisperer/index.ts +++ b/packages/core/src/codewhisperer/index.ts @@ -102,3 +102,5 @@ export * as CodeWhispererConstants from '../codewhisperer/models/constants' export { getSelectedCustomization, setSelectedCustomization, baseCustomization } from './util/customizationUtil' export { Container } from './service/serviceContainer' export * from './util/gitUtil' +export * from './ui/prompters' +export { UserWrittenCodeTracker } from './tracker/userWrittenCodeTracker' diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index d0b75b204db..46dfc83e026 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -726,6 +726,8 @@ export const noOpenProjectsFoundChatTestGenMessage = `Sorry, I couldn\'t find a export const unitTestGenerationCancelMessage = 'Unit test generation cancelled.' +export const tooManyRequestErrorMessage = 'Too many requests. Please wait before retrying.' + export const noJavaProjectsFoundChatMessage = `I couldn\'t find a project that I can upgrade. Currently, I support Java 8, Java 11, and Java 17 projects built on Maven. Make sure your project is open in the IDE. For more information, see the [Amazon Q documentation](${codeTransformPrereqDoc}).` export const linkToDocsHome = 'https://docs.aws.amazon.com/amazonq/latest/aws-builder-use-ug/code-transformation.html' @@ -861,7 +863,7 @@ export enum TestGenerationJobStatus { COMPLETED = 'COMPLETED', } -export enum ZipUseCase { +export enum FeatureUseCase { TEST_GENERATION = 'TEST_GENERATION', CODE_SCAN = 'CODE_SCAN', } diff --git a/packages/core/src/codewhisperer/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index c9b3ca7c51a..5c5e945b14b 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -20,6 +20,7 @@ import { TransformationSteps } from '../client/codewhispereruserclient' import { Messenger } from '../../amazonqGumby/chat/controller/messenger/messenger' import { TestChatControllerEventEmitters } from '../../amazonqTest/chat/controller/controller' import { ScanChatControllerEventEmitters } from '../../amazonqScan/controller' +import { localize } from '../../shared/utilities/vsCodeUtils' // unavoidable global variables interface VsCodeState { @@ -564,6 +565,52 @@ export class SecurityTreeViewFilterState { } } +export enum CodeIssueGroupingStrategy { + Severity = 'Severity', + FileLocation = 'FileLocation', +} +const defaultCodeIssueGroupingStrategy = CodeIssueGroupingStrategy.Severity + +export const codeIssueGroupingStrategies = Object.values(CodeIssueGroupingStrategy) +export const codeIssueGroupingStrategyLabel: Record = { + [CodeIssueGroupingStrategy.Severity]: localize('AWS.amazonq.scans.severity', 'Severity'), + [CodeIssueGroupingStrategy.FileLocation]: localize('AWS.amazonq.scans.fileLocation', 'File Location'), +} + +export class CodeIssueGroupingStrategyState { + #fallback: CodeIssueGroupingStrategy + #onDidChangeState = new vscode.EventEmitter() + onDidChangeState = this.#onDidChangeState.event + + static #instance: CodeIssueGroupingStrategyState + static get instance() { + return (this.#instance ??= new this()) + } + + protected constructor(fallback: CodeIssueGroupingStrategy = defaultCodeIssueGroupingStrategy) { + this.#fallback = fallback + } + + public getState(): CodeIssueGroupingStrategy { + const state = globals.globalState.tryGet('aws.amazonq.codescan.groupingStrategy', String) + return this.isValidGroupingStrategy(state) ? state : this.#fallback + } + + public async setState(_state: unknown) { + const state = this.isValidGroupingStrategy(_state) ? _state : this.#fallback + await globals.globalState.update('aws.amazonq.codescan.groupingStrategy', state) + this.#onDidChangeState.fire(state) + } + + private isValidGroupingStrategy(strategy: unknown): strategy is CodeIssueGroupingStrategy { + return Object.values(CodeIssueGroupingStrategy).includes(strategy as CodeIssueGroupingStrategy) + } + + public reset() { + return this.setState(this.#fallback) + } +} + /** * Q - Transform */ diff --git a/packages/core/src/codewhisperer/service/codeFixHandler.ts b/packages/core/src/codewhisperer/service/codeFixHandler.ts index 0358d8d3ed9..b707ee01583 100644 --- a/packages/core/src/codewhisperer/service/codeFixHandler.ts +++ b/packages/core/src/codewhisperer/service/codeFixHandler.ts @@ -34,7 +34,7 @@ export async function getPresignedUrlAndUpload( getLogger().verbose(`CreateUploadUrlRequest requestId: ${srcResp.$response.requestId}`) getLogger().verbose(`Complete Getting presigned Url for uploading src context.`) getLogger().verbose(`Uploading src context...`) - await uploadArtifactToS3(zipFilePath, srcResp) + await uploadArtifactToS3(zipFilePath, srcResp, CodeWhispererConstants.FeatureUseCase.CODE_SCAN) getLogger().verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, diff --git a/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts b/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts index e5ac2212e06..a6c424c321d 100644 --- a/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts +++ b/packages/core/src/codewhisperer/service/inlineCompletionItemProvider.ts @@ -12,6 +12,7 @@ import { ReferenceInlineProvider } from './referenceInlineProvider' import { ImportAdderProvider } from './importAdderProvider' import { application } from '../util/codeWhispererApplication' import path from 'path' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' export class CWInlineCompletionItemProvider implements vscode.InlineCompletionItemProvider { private activeItemIndex: number | undefined @@ -170,6 +171,7 @@ export class CWInlineCompletionItemProvider implements vscode.InlineCompletionIt this.nextMove = 0 TelemetryHelper.instance.setFirstSuggestionShowTime() session.setPerceivedLatency() + UserWrittenCodeTracker.instance.onQStartsMakingEdits() this._onDidShow.fire() if (matchedCount >= 2 || this.nextToken !== '') { const result = [item] diff --git a/packages/core/src/codewhisperer/service/recommendationHandler.ts b/packages/core/src/codewhisperer/service/recommendationHandler.ts index 72e130a5bed..76efdbc6fe0 100644 --- a/packages/core/src/codewhisperer/service/recommendationHandler.ts +++ b/packages/core/src/codewhisperer/service/recommendationHandler.ts @@ -44,6 +44,7 @@ import { openUrl } from '../../shared/utilities/vsCodeUtils' import { indent } from '../../shared/utilities/textUtilities' import path from 'path' import { isIamConnection } from '../../auth/connection' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' /** * This class is for getRecommendation/listRecommendation API calls and its states @@ -318,6 +319,7 @@ export class RecommendationHandler { getLogger().debug(msg) if (invocationResult === 'Succeeded') { CodeWhispererCodeCoverageTracker.getTracker(session.language)?.incrementServiceInvocationCount() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } else { if ( (errorMessage?.includes(invalidCustomizationMessage) && errorCode === 'AccessDeniedException') || diff --git a/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts b/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts index e76a201be87..47490f2427f 100644 --- a/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts @@ -4,7 +4,14 @@ */ import * as vscode from 'vscode' import path from 'path' -import { CodeScanIssue, SecurityTreeViewFilterState, severities, Severity } from '../models/model' +import { + CodeIssueGroupingStrategy, + CodeIssueGroupingStrategyState, + CodeScanIssue, + SecurityTreeViewFilterState, + severities, + Severity, +} from '../models/model' import globals from '../../shared/extensionGlobals' import { getLogger } from '../../shared/logger' import { SecurityIssueProvider } from './securityIssueProvider' @@ -34,6 +41,17 @@ export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider { + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + switch (groupingStrategy) { + case CodeIssueGroupingStrategy.FileLocation: + return this.getChildrenGroupedByFileLocation(element) + case CodeIssueGroupingStrategy.Severity: + default: + return this.getChildrenGroupedBySeverity(element) + } + } + + private getChildrenGroupedBySeverity(element: SecurityViewTreeItem | undefined) { const filterHiddenSeverities = (severity: Severity) => !SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(severity) @@ -64,6 +82,27 @@ export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider + !SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(issue.severity) + + if (element instanceof FileItem) { + return element.issues + .filter(filterHiddenSeverities) + .filter((issue) => issue.visible) + .sort((a, b) => a.startLine - b.startLine) + .map((issue) => new IssueItem(element.filePath, issue)) + } + + const result = this.issueProvider.issues + .filter((group) => group.issues.some(filterHiddenSeverities)) + .filter((group) => group.issues.some((issue) => issue.visible)) + .sort((a, b) => a.filePath.localeCompare(b.filePath)) + .map((group) => new FileItem(group.filePath, group.issues.filter(filterHiddenSeverities))) + this._onDidChangeTreeData.fire(result) + return result + } + public refresh(): void { this._onDidChangeTreeData.fire() } @@ -118,7 +157,8 @@ export class IssueItem extends vscode.TreeItem { public readonly issue: CodeScanIssue ) { super(issue.title, vscode.TreeItemCollapsibleState.None) - this.description = `${path.basename(this.filePath)} [Ln ${this.issue.startLine + 1}, Col 1]` + this.description = this.getDescription() + this.iconPath = this.getSeverityIcon() this.tooltip = this.getTooltipMarkdown() this.command = { title: 'Focus Issue', @@ -132,6 +172,22 @@ export class IssueItem extends vscode.TreeItem { return globals.context.asAbsolutePath(`resources/images/severity-${this.issue.severity.toLowerCase()}.svg`) } + private getSeverityIcon() { + const iconPath = globals.context.asAbsolutePath( + `resources/icons/aws/amazonq/severity-${this.issue.severity.toLowerCase()}.svg` + ) + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + return groupingStrategy !== CodeIssueGroupingStrategy.Severity ? iconPath : undefined + } + + private getDescription() { + const positionStr = `[Ln ${this.issue.startLine + 1}, Col 1]` + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + return groupingStrategy !== CodeIssueGroupingStrategy.FileLocation + ? `${path.basename(this.filePath)} ${positionStr}` + : positionStr + } + private getContextValue() { return this.issue.suggestedFixes.length === 0 || !this.issue.suggestedFixes[0].code ? ContextValue.ISSUE_WITHOUT_FIX diff --git a/packages/core/src/codewhisperer/service/securityScanHandler.ts b/packages/core/src/codewhisperer/service/securityScanHandler.ts index ba70e558733..ab9e637e519 100644 --- a/packages/core/src/codewhisperer/service/securityScanHandler.ts +++ b/packages/core/src/codewhisperer/service/securityScanHandler.ts @@ -43,6 +43,8 @@ import { getTelemetryReasonDesc } from '../../shared/errors' import { CodeWhispererSettings } from '../util/codewhispererSettings' import { detectCommentAboveLine } from '../../shared/utilities/commentUtils' import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { FeatureUseCase } from '../models/constants' +import { UploadTestArtifactToS3Error } from '../../amazonqTest/error' export async function listScanResults( client: DefaultCodeWhispererClient, @@ -287,7 +289,7 @@ export async function getPresignedUrlAndUpload( logger.verbose(`CreateUploadUrlRequest request id: ${srcResp.$response.requestId}`) logger.verbose(`Complete Getting presigned Url for uploading src context.`) logger.verbose(`Uploading src context...`) - await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, scope) + await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, FeatureUseCase.CODE_SCAN, scope) logger.verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, @@ -343,6 +345,7 @@ export function throwIfCancelled(scope: CodeWhispererConstants.CodeAnalysisScope export async function uploadArtifactToS3( fileName: string, resp: CreateUploadUrlResponse, + featureUseCase: FeatureUseCase, scope?: CodeWhispererConstants.CodeAnalysisScope ) { const logger = getLoggerForScope(scope) @@ -365,14 +368,23 @@ export async function uploadArtifactToS3( }).response logger.debug(`StatusCode: ${response.status}, Text: ${response.statusText}`) } catch (error) { + let errorMessage = '' + const isCodeScan = featureUseCase === FeatureUseCase.CODE_SCAN + const featureType = isCodeScan ? 'security scans' : 'unit test generation' + const defaultMessage = isCodeScan ? 'Security scan failed.' : 'Test generation failed.' getLogger().error( - `Amazon Q is unable to upload workspace artifacts to Amazon S3 for security scans. For more information, see the Amazon Q documentation or contact your network or organization administrator.` + `Amazon Q is unable to upload workspace artifacts to Amazon S3 for ${featureType}. ` + + 'For more information, see the Amazon Q documentation or contact your network or organization administrator.' ) - const errorMessage = getTelemetryReasonDesc(error)?.includes(`"PUT" request failed with code "403"`) - ? `"PUT" request failed with code "403"` - : (getTelemetryReasonDesc(error) ?? 'Security scan failed.') - - throw new UploadArtifactToS3Error(errorMessage) + const errorDesc = getTelemetryReasonDesc(error) + if (errorDesc?.includes('"PUT" request failed with code "403"')) { + errorMessage = '"PUT" request failed with code "403"' + } else if (errorDesc?.includes('"PUT" request failed with code "503"')) { + errorMessage = '"PUT" request failed with code "503"' + } else { + errorMessage = errorDesc ?? defaultMessage + } + throw isCodeScan ? new UploadArtifactToS3Error(errorMessage) : new UploadTestArtifactToS3Error(errorMessage) } } diff --git a/packages/core/src/codewhisperer/service/testGenHandler.ts b/packages/core/src/codewhisperer/service/testGenHandler.ts index 01be77a834b..48a66fb1f83 100644 --- a/packages/core/src/codewhisperer/service/testGenHandler.ts +++ b/packages/core/src/codewhisperer/service/testGenHandler.ts @@ -13,7 +13,15 @@ import CodeWhispererUserClient, { CreateUploadUrlRequest, TargetCode, } from '../client/codewhispereruserclient' -import { CreateUploadUrlError, InvalidSourceZipError, TestGenFailedError, TestGenTimedOutError } from '../models/errors' +import { + CreateTestJobError, + CreateUploadUrlError, + ExportResultsArchiveError, + InvalidSourceZipError, + TestGenFailedError, + TestGenStoppedError, + TestGenTimedOutError, +} from '../../amazonqTest/error' import { getMd5, uploadArtifactToS3 } from './securityScanHandler' import { fs, randomUUID, sleep, tempDirPath } from '../../shared' import { ShortAnswer, testGenState } from '../models/model' @@ -24,12 +32,13 @@ import AdmZip from 'adm-zip' import path from 'path' import { ExportIntent } from '@amzn/codewhisperer-streaming' import { glob } from 'glob' +import { UserWrittenCodeTracker } from '../tracker/userWrittenCodeTracker' // TODO: Get TestFileName and Framework and to error message export function throwIfCancelled() { // TODO: fileName will be '' if user gives propt without opening if (testGenState.isCancelling()) { - throw Error(CodeWhispererConstants.unitTestGenerationCancelMessage) + throw new TestGenStoppedError() } } @@ -47,12 +56,12 @@ export async function getPresignedUrlAndUploadTestGen(zipMetadata: ZipMetadata) logger.verbose(`Prepare for uploading src context...`) const srcResp = await codeWhisperer.codeWhispererClient.createUploadUrl(srcReq).catch((err) => { getLogger().error(`Failed getting presigned url for uploading src context. Request id: ${err.requestId}`) - throw new CreateUploadUrlError(err) + throw new CreateUploadUrlError(err.message) }) logger.verbose(`CreateUploadUrlRequest requestId: ${srcResp.$response.requestId}`) logger.verbose(`Complete Getting presigned Url for uploading src context.`) logger.verbose(`Uploading src context...`) - await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp) + await uploadArtifactToS3(zipMetadata.zipFilePath, srcResp, CodeWhispererConstants.FeatureUseCase.TEST_GENERATION) logger.verbose(`Complete uploading src context.`) const artifactMap: ArtifactMap = { SourceCode: srcResp.uploadId, @@ -96,7 +105,7 @@ export async function createTestJob( const resp = await codewhispererClient.codeWhispererClient.startTestGeneration(req).catch((err) => { ChatSessionManager.Instance.getSession().startTestGenerationRequestId = err.requestId logger.error(`Failed creating test job. Request id: ${err.requestId}`) - throw err + throw new CreateTestJobError(err.message) }) logger.info('Unit test generation request id: %s', resp.$response.requestId) logger.debug('Unit test generation data: %O', resp.$response.data) @@ -252,7 +261,7 @@ export async function exportResultsArchive( session.numberOfTestsGenerated = 0 downloadErrorMessage = (e as Error).message getLogger().error(`Unit Test Generation: ExportResultArchive error = ${downloadErrorMessage}`) - throw new Error('Error downloading test generation result artifacts: ' + downloadErrorMessage) + throw new ExportResultsArchiveError(downloadErrorMessage) } } @@ -291,8 +300,9 @@ export async function downloadResultArchive( } catch (e: any) { downloadErrorMessage = (e as Error).message getLogger().error(`Unit Test Generation: ExportResultArchive error = ${downloadErrorMessage}`) - throw e + throw new ExportResultsArchiveError(downloadErrorMessage) } finally { cwStreamingClient.destroy() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts index cbf6bf92710..b5f4d2d1447 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformApiHandler.ts @@ -49,6 +49,7 @@ import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSess import { encodeHTML } from '../../../shared/utilities/textUtilities' import { convertToTimeString } from '../../../shared/datetime' import { getAuthType } from '../../../auth/utils' +import { UserWrittenCodeTracker } from '../../tracker/userWrittenCodeTracker' export function getSha256(buffer: Buffer) { const hasher = crypto.createHash('sha256') @@ -447,6 +448,7 @@ export async function startJob(uploadId: string) { target: { language: targetLanguageVersion }, // always JDK17 }, }) + getLogger().info('CodeTransformation: called startJob API successfully') if (response.$response.requestId) { transformByQState.setJobFailureMetadata(` (request ID: ${response.$response.requestId})`) } @@ -670,6 +672,7 @@ export async function pollTransformationJob(jobId: string, validStates: string[] }) } transformByQState.setPolledJobStatus(status) + getLogger().info(`CodeTransformation: polled job status = ${status}`) const errorMessage = response.transformationJob.reason if (errorMessage !== undefined) { @@ -767,6 +770,7 @@ export async function downloadResultArchive( throw e } finally { cwStreamingClient.destroy() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts b/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts index 8ba8504e436..e2bbbc6556b 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformMavenHandler.ts @@ -11,7 +11,7 @@ import { spawnSync } from 'child_process' // eslint-disable-line no-restricted-i import { CodeTransformBuildCommand, telemetry } from '../../../shared/telemetry/telemetry' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' import { ToolkitError } from '../../../shared/errors' -import { writeLogs } from './transformFileHandler' +import { setMaven, writeLogs } from './transformFileHandler' import { throwIfCancelled } from './transformApiHandler' // run 'install' with either 'mvnw.cmd', './mvnw', or 'mvn' (if wrapper exists, we use that, otherwise we use regular 'mvn') @@ -108,6 +108,8 @@ function copyProjectDependencies(dependenciesFolder: FolderInfo, modulePath: str } export async function prepareProjectDependencies(dependenciesFolder: FolderInfo, rootPomPath: string) { + await setMaven() + getLogger().info('CodeTransformation: running Maven copy-dependencies') try { copyProjectDependencies(dependenciesFolder, rootPomPath) } catch (err) { @@ -117,6 +119,7 @@ export async function prepareProjectDependencies(dependenciesFolder: FolderInfo, ) } + getLogger().info('CodeTransformation: running Maven install') try { installProjectDependencies(dependenciesFolder, rootPomPath) } catch (err) { @@ -134,9 +137,9 @@ export async function prepareProjectDependencies(dependenciesFolder: FolderInfo, export async function getVersionData() { const baseCommand = transformByQState.getMavenName() // will be one of: 'mvnw.cmd', './mvnw', 'mvn' - const modulePath = transformByQState.getProjectPath() + const projectPath = transformByQState.getProjectPath() const args = ['-v'] - const spawnResult = spawnSync(baseCommand, args, { cwd: modulePath, shell: true, encoding: 'utf-8' }) + const spawnResult = spawnSync(baseCommand, args, { cwd: projectPath, shell: true, encoding: 'utf-8' }) let localMavenVersion: string | undefined = '' let localJavaVersion: string | undefined = '' diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts index 43c6a1cc08b..9339be10fc9 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts @@ -26,6 +26,7 @@ import { createCodeWhispererChatStreamingClient } from '../../../shared/clients/ import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSession' import { setContext } from '../../../shared/vscode/setContext' import * as codeWhisperer from '../../client/codewhisperer' +import { UserWrittenCodeTracker } from '../../tracker/userWrittenCodeTracker' export abstract class ProposedChangeNode { abstract readonly resourcePath: string @@ -177,6 +178,7 @@ export class DiffModel { } const changedFiles = parsePatch(diffContents) + getLogger().info('CodeTransformation: parsed patch file successfully') // path to the directory containing copy of the changed files in the transformed project const pathToTmpSrcDir = this.copyProject(pathToWorkspace, changedFiles) transformByQState.setProjectCopyFilePath(pathToTmpSrcDir) @@ -401,6 +403,7 @@ export class ProposedTransformationExplorer { pathToArchive ) + getLogger().info('CodeTransformation: downloaded results successfully') // Update downloaded artifact size exportResultsArchiveSize = (await fs.promises.stat(pathToArchive)).size @@ -424,6 +427,7 @@ export class ProposedTransformationExplorer { throw new Error('Error downloading diff') } finally { cwStreamingClient.destroy() + UserWrittenCodeTracker.instance.onQFeatureInvoked() } let deserializeErrorMessage = undefined @@ -532,6 +536,7 @@ export class ProposedTransformationExplorer { vscode.commands.registerCommand('aws.amazonq.transformationHub.reviewChanges.acceptChanges', async () => { telemetry.codeTransform_submitSelection.run(() => { + getLogger().info('CodeTransformation: accepted changes') diffModel.saveChanges() telemetry.record({ codeTransformSessionId: CodeTransformTelemetryState.instance.getSessionId(), @@ -585,6 +590,7 @@ export class ProposedTransformationExplorer { vscode.commands.registerCommand('aws.amazonq.transformationHub.reviewChanges.rejectChanges', async () => { await telemetry.codeTransform_submitSelection.run(async () => { + getLogger().info('CodeTransformation: rejected changes') diffModel.rejectChanges() await reset() telemetry.record({ diff --git a/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts b/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts index d0ad76c26da..39416eafe70 100644 --- a/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts +++ b/packages/core/src/codewhisperer/tracker/codewhispererCodeCoverageTracker.ts @@ -27,6 +27,8 @@ const autoClosingKeystrokeInputs = ['[]', '{}', '()', '""', "''"] /** * This singleton class is mainly used for calculating the code written by codeWhisperer + * TODO: Remove this tracker, uses user written code tracker instead. + * This is kept in codebase for server side backward compatibility until service fully switch to user written code */ export class CodeWhispererCodeCoverageTracker { private _acceptedTokens: { [key: string]: CodeWhispererToken[] } diff --git a/packages/core/src/codewhisperer/tracker/userWrittenCodeTracker.ts b/packages/core/src/codewhisperer/tracker/userWrittenCodeTracker.ts new file mode 100644 index 00000000000..2497006b0a4 --- /dev/null +++ b/packages/core/src/codewhisperer/tracker/userWrittenCodeTracker.ts @@ -0,0 +1,194 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' +import { AuthUtil } from '../util/authUtil' +import { getSelectedCustomization } from '../util/customizationUtil' +import { codeWhispererClient as client } from '../client/codewhisperer' +import { isAwsError } from '../../shared/errors' +import { CodewhispererLanguage, globals, undefinedIfEmpty } from '../../shared' + +/** + * This singleton class is mainly used for calculating the user written code + * for active Amazon Q users. + * It reports the user written code per 5 minutes when the user is coding and using Amazon Q features + */ +export class UserWrittenCodeTracker { + private _userWrittenNewCodeCharacterCount: Map + private _userWrittenNewCodeLineCount: Map + private _qIsMakingEdits: boolean + private _timer?: NodeJS.Timer + private _qUsageCount: number + private _lastQInvocationTime: number + + static #instance: UserWrittenCodeTracker + private static copySnippetThreshold = 50 + private static resetQIsEditingTimeoutMs = 2 * 60 * 1000 + private static defaultCheckPeriodMillis = 5 * 60 * 1000 + + private constructor() { + this._userWrittenNewCodeLineCount = new Map() + this._userWrittenNewCodeCharacterCount = new Map() + this._qUsageCount = 0 + this._qIsMakingEdits = false + this._timer = undefined + this._lastQInvocationTime = 0 + } + + public static get instance() { + return (this.#instance ??= new this()) + } + + public isActive(): boolean { + return globals.telemetry.telemetryEnabled && AuthUtil.instance.isConnected() + } + + // this should be invoked whenever there is a successful Q feature invocation + // for all Q features + public onQFeatureInvoked() { + this._qUsageCount += 1 + this._lastQInvocationTime = performance.now() + } + + public onQStartsMakingEdits() { + this._qIsMakingEdits = true + } + + public onQFinishesEdits() { + this._qIsMakingEdits = false + } + + public getUserWrittenCharacters(language: CodewhispererLanguage) { + return this._userWrittenNewCodeCharacterCount.get(language) || 0 + } + + public getUserWrittenLines(language: CodewhispererLanguage) { + return this._userWrittenNewCodeLineCount.get(language) || 0 + } + + public reset() { + this._userWrittenNewCodeLineCount = new Map() + this._userWrittenNewCodeCharacterCount = new Map() + this._qUsageCount = 0 + this._qIsMakingEdits = false + this._lastQInvocationTime = 0 + if (this._timer !== undefined) { + clearTimeout(this._timer) + this._timer = undefined + } + } + + public emitCodeContributions() { + const selectedCustomization = getSelectedCustomization() + + for (const [language, charCount] of this._userWrittenNewCodeCharacterCount) { + const lineCount = this.getUserWrittenLines(language) + if (charCount > 0) { + client + .sendTelemetryEvent({ + telemetryEvent: { + codeCoverageEvent: { + customizationArn: undefinedIfEmpty(selectedCustomization.arn), + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language), + }, + acceptedCharacterCount: 0, + totalCharacterCount: 0, + timestamp: new Date(Date.now()), + userWrittenCodeCharacterCount: charCount, + userWrittenCodeLineCount: lineCount, + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + getLogger().debug( + `Failed to sendTelemetryEvent, requestId: ${requestId ?? ''}, message: ${error.message}` + ) + }) + } + } + } + + private tryStartTimer() { + if (this._timer !== undefined) { + return + } + if (!this.isActive()) { + getLogger().debug(`Skip emiting code contribution metric. Telemetry disabled or not logged in. `) + this.reset() + return + } + const startTime = performance.now() + this._timer = setTimeout(() => { + try { + const currentTime = performance.now() + const delay: number = UserWrittenCodeTracker.defaultCheckPeriodMillis + const diffTime: number = startTime + delay + if (diffTime <= currentTime) { + if (this._qUsageCount <= 0) { + getLogger().debug(`Skip emiting code contribution metric. There is no active Amazon Q usage. `) + return + } + if (this._userWrittenNewCodeCharacterCount.size === 0) { + getLogger().debug(`Skip emiting code contribution metric. There is no new code added. `) + return + } + this.emitCodeContributions() + } + } catch (e) { + getLogger().verbose(`Exception Thrown from QCodeGenTracker: ${e}`) + } finally { + this.reset() + } + }, UserWrittenCodeTracker.defaultCheckPeriodMillis) + } + + private countNewLines(str: string) { + return str.split('\n').length - 1 + } + + public onTextDocumentChange(e: vscode.TextDocumentChangeEvent) { + // do not count code written by Q as user written code + if ( + !runtimeLanguageContext.isLanguageSupported(e.document.languageId) || + e.contentChanges.length === 0 || + this._qIsMakingEdits + ) { + // if the boolean of qIsMakingEdits was incorrectly set to true + // due to unhandled edge cases or early terminated code paths + // reset it back to false after a reasonable period of time + if (this._qIsMakingEdits) { + if (performance.now() - this._lastQInvocationTime > UserWrittenCodeTracker.resetQIsEditingTimeoutMs) { + getLogger().warn(`Reset Q is editing state to false.`) + this._qIsMakingEdits = false + } + } + return + } + const contentChange = e.contentChanges[0] + // if user copies code into the editor for more than 50 characters + // do not count this as total new code, this will skew the data, + // reporting highly inflated user written code + if (contentChange.text.length > UserWrittenCodeTracker.copySnippetThreshold) { + return + } + const language = runtimeLanguageContext.normalizeLanguage(e.document.languageId) + if (language) { + const charCount = this.getUserWrittenCharacters(language) + this._userWrittenNewCodeCharacterCount.set(language, charCount + contentChange.text.length) + const lineCount = this.getUserWrittenLines(language) + this._userWrittenNewCodeLineCount.set(language, lineCount + this.countNewLines(contentChange.text)) + // start 5 min data reporting once valid user input is detected + this.tryStartTimer() + } + } +} diff --git a/packages/core/src/codewhisperer/ui/prompters.ts b/packages/core/src/codewhisperer/ui/prompters.ts new file mode 100644 index 00000000000..95541d84a82 --- /dev/null +++ b/packages/core/src/codewhisperer/ui/prompters.ts @@ -0,0 +1,34 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + codeIssueGroupingStrategies, + CodeIssueGroupingStrategy, + codeIssueGroupingStrategyLabel, + CodeIssueGroupingStrategyState, +} from '../models/model' +import { createQuickPick, QuickPickPrompter } from '../../shared/ui/pickerPrompter' +import { localize } from '../../shared/utilities/vsCodeUtils' + +export function createCodeIssueGroupingStrategyPrompter(): QuickPickPrompter { + const groupingStrategy = CodeIssueGroupingStrategyState.instance.getState() + const prompter = createQuickPick( + codeIssueGroupingStrategies.map((strategy) => ({ + data: strategy, + label: codeIssueGroupingStrategyLabel[strategy], + })), + { + title: localize('AWS.amazonq.scans.groupIssues', 'Group Issues'), + placeholder: localize('AWS.amazonq.scans.groupIssues.placeholder', 'Select how to group code issues'), + } + ) + prompter.quickPick.activeItems = prompter.quickPick.items.filter((item) => item.data === groupingStrategy) + prompter.quickPick.onDidChangeSelection(async (items) => { + const [item] = items + await CodeIssueGroupingStrategyState.instance.setState(item.data) + prompter.quickPick.hide() + }) + return prompter +} diff --git a/packages/core/src/codewhisperer/util/telemetryHelper.ts b/packages/core/src/codewhisperer/util/telemetryHelper.ts index a34deb0cdca..5276d869bb9 100644 --- a/packages/core/src/codewhisperer/util/telemetryHelper.ts +++ b/packages/core/src/codewhisperer/util/telemetryHelper.ts @@ -70,6 +70,7 @@ export class TelemetryHelper { public sendTestGenerationToolkitEvent( session: Session, isSupportedLanguage: boolean, + isFileInWorkspace: boolean, result: 'Succeeded' | 'Failed' | 'Cancelled', requestId?: string, perfClientLatency?: number, @@ -90,6 +91,7 @@ export class TelemetryHelper { cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', hasUserPromptSupplied: session.hasUserPromptSupplied, isSupportedLanguage: isSupportedLanguage, + isFileInWorkspace: isFileInWorkspace, result: result, artifactsUploadDuration: artifactsUploadDuration, buildPayloadBytes: buildPayloadBytes, diff --git a/packages/core/src/codewhisperer/util/zipUtil.ts b/packages/core/src/codewhisperer/util/zipUtil.ts index ab938aeb643..64a9ccc3b8d 100644 --- a/packages/core/src/codewhisperer/util/zipUtil.ts +++ b/packages/core/src/codewhisperer/util/zipUtil.ts @@ -20,9 +20,10 @@ import { NoSourceFilesError, ProjectSizeExceededError, } from '../models/errors' -import { ZipUseCase } from '../models/constants' +import { FeatureUseCase } from '../models/constants' import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' import { removeAnsi } from '../../shared' +import { ProjectZipError } from '../../amazonqTest/error' export interface ZipMetadata { rootDir: string @@ -135,7 +136,7 @@ export class ZipUtil { if (this.reachSizeLimit(this._totalSize, scope)) { throw new FileSizeExceededError() } - const zipFilePath = this.getZipDirPath(ZipUseCase.CODE_SCAN) + CodeWhispererConstants.codeScanZipExt + const zipFilePath = this.getZipDirPath(FeatureUseCase.CODE_SCAN) + CodeWhispererConstants.codeScanZipExt zip.writeZip(zipFilePath) return zipFilePath } @@ -203,15 +204,15 @@ export class ZipUtil { await processDirectory(metadataDir) } - protected async zipProject(useCase: ZipUseCase, projectPath?: string, metadataDir?: string) { + protected async zipProject(useCase: FeatureUseCase, projectPath?: string, metadataDir?: string) { const zip = new admZip() let projectPaths = [] - if (useCase === ZipUseCase.TEST_GENERATION && projectPath) { + if (useCase === FeatureUseCase.TEST_GENERATION && projectPath) { projectPaths.push(projectPath) } else { projectPaths = this.getProjectPaths() } - if (useCase === ZipUseCase.CODE_SCAN) { + if (useCase === FeatureUseCase.CODE_SCAN) { await this.processCombinedGitDiff(zip, projectPaths, '', CodeWhispererConstants.CodeAnalysisScope.PROJECT) } const languageCount = new Map() @@ -220,7 +221,7 @@ export class ZipUtil { if (metadataDir) { await this.processMetadataDir(zip, metadataDir) } - if (useCase !== ZipUseCase.TEST_GENERATION) { + if (useCase !== FeatureUseCase.TEST_GENERATION) { this.processOtherFiles(zip, languageCount) } @@ -403,7 +404,7 @@ export class ZipUtil { zip: admZip, languageCount: Map, projectPaths: string[] | undefined, - useCase: ZipUseCase + useCase: FeatureUseCase ) { if (!projectPaths || projectPaths.length === 0) { return @@ -420,7 +421,7 @@ export class ZipUtil { const zipEntryPath = this.getZipEntryPath(projectName, file.relativeFilePath) if (ZipConstants.knownBinaryFileExts.includes(path.extname(file.fileUri.fsPath))) { - if (useCase === ZipUseCase.TEST_GENERATION) { + if (useCase === FeatureUseCase.TEST_GENERATION) { continue } await this.processBinaryFile(zip, file.fileUri, zipEntryPath) @@ -511,10 +512,10 @@ export class ZipUtil { return vscode.workspace.textDocuments.some((document) => document.uri.fsPath === uri.fsPath && document.isDirty) } - public getZipDirPath(useCase: ZipUseCase): string { + public getZipDirPath(useCase: FeatureUseCase): string { if (this._zipDir === '') { const prefix = - useCase === ZipUseCase.TEST_GENERATION + useCase === FeatureUseCase.TEST_GENERATION ? CodeWhispererConstants.TestGenerationTruncDirPrefix : CodeWhispererConstants.codeScanTruncDirPrefix @@ -528,7 +529,7 @@ export class ZipUtil { scope: CodeWhispererConstants.CodeAnalysisScope ): Promise { try { - const zipDirPath = this.getZipDirPath(ZipUseCase.CODE_SCAN) + const zipDirPath = this.getZipDirPath(FeatureUseCase.CODE_SCAN) let zipFilePath: string if ( scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || @@ -536,7 +537,7 @@ export class ZipUtil { ) { zipFilePath = await this.zipFile(uri, scope) } else if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) { - zipFilePath = await this.zipProject(ZipUseCase.CODE_SCAN) + zipFilePath = await this.zipProject(FeatureUseCase.CODE_SCAN) } else { throw new ToolkitError(`Unknown code analysis scope: ${scope}`) } @@ -562,7 +563,7 @@ export class ZipUtil { public async generateZipTestGen(projectPath: string, initialExecution: boolean): Promise { try { // const repoMapFile = await LspClient.instance.getRepoMapJSON() - const zipDirPath = this.getZipDirPath(ZipUseCase.TEST_GENERATION) + const zipDirPath = this.getZipDirPath(FeatureUseCase.TEST_GENERATION) const metadataDir = path.join(zipDirPath, 'utgRequiredArtifactsDir') @@ -590,7 +591,7 @@ export class ZipUtil { } } - const zipFilePath: string = await this.zipProject(ZipUseCase.TEST_GENERATION, projectPath, metadataDir) + const zipFilePath: string = await this.zipProject(FeatureUseCase.TEST_GENERATION, projectPath, metadataDir) const zipFileSize = (await fs.stat(zipFilePath)).size return { rootDir: zipDirPath, @@ -604,7 +605,9 @@ export class ZipUtil { } } catch (error) { getLogger().error('Zip error caused by: %s', error) - throw error + throw new ProjectZipError( + error instanceof Error ? error.message : 'Unknown error occurred during zip operation' + ) } } // TODO: Refactor this diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index fc164ebb95c..b849b328bac 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode' import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' +import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' export class ChatSession { private sessionId?: string @@ -48,6 +49,7 @@ export class ChatSession { } } + UserWrittenCodeTracker.instance.onQFeatureInvoked() return response } @@ -67,6 +69,8 @@ export class ChatSession { this.sessionId = response.conversationId + UserWrittenCodeTracker.instance.onQFeatureInvoked() + return response } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 57b45d414c1..a5205be78ca 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -231,17 +231,19 @@ export class ChatController { this.openLinkInExternalBrowser(click) } - private processQuickActionCommand(quickActionCommand: ChatPromptCommandType) { + private processQuickActionCommand(message: PromptMessage) { this.editorContextExtractor .extractContextForTrigger('QuickAction') .then((context) => { const triggerID = randomUUID() + const quickActionCommand = message.command as ChatPromptCommandType + this.messenger.sendQuickActionMessage(quickActionCommand, triggerID) this.triggerEventsStorage.addTriggerEvent({ id: triggerID, - tabID: undefined, + tabID: message.tabID, message: undefined, type: 'quick_action', quickAction: quickActionCommand, @@ -484,7 +486,7 @@ export class ChatController { recordTelemetryChatRunCommand('clear') return default: - this.processQuickActionCommand(message.command) + this.processQuickActionCommand(message) } } diff --git a/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts b/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts index d2b80f2619f..d782f7147ff 100644 --- a/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts +++ b/packages/core/src/codewhispererChat/editor/context/focusArea/focusAreaExtractor.ts @@ -7,7 +7,7 @@ import { TextEditor, Selection, TextDocument, Range } from 'vscode' import { FocusAreaContext, FullyQualifiedName } from './model' -const focusAreaCharLimit = 9_000 +const focusAreaCharLimit = 40_000 export class FocusAreaContextExtractor { public isCodeBlockSelected(editor: TextEditor): boolean { diff --git a/packages/core/src/extension.ts b/packages/core/src/extension.ts index 00fd730b490..dad3cadb202 100644 --- a/packages/core/src/extension.ts +++ b/packages/core/src/extension.ts @@ -52,6 +52,7 @@ import { registerCommands } from './commands' import endpoints from '../resources/endpoints.json' import { getLogger, maybeShowMinVscodeWarning, setupUninstallHandler } from './shared' import { showViewLogsMessage } from './shared/utilities/messages' +import { ChildProcessTracker } from './shared/utilities/processUtils' disableAwsSdkWarning() @@ -171,6 +172,12 @@ export async function activateCommon( await activateViewsShared(extContext.extensionContext) + context.subscriptions.push( + Commands.register( + `aws.${contextPrefix}.showExtStats`, + async () => await ChildProcessTracker.instance.logAllUsage() + ) + ) return extContext } diff --git a/packages/core/src/lambda/commands/downloadLambda.ts b/packages/core/src/lambda/commands/downloadLambda.ts index 16128ce5701..815ff2576e9 100644 --- a/packages/core/src/lambda/commands/downloadLambda.ts +++ b/packages/core/src/lambda/commands/downloadLambda.ts @@ -14,7 +14,7 @@ import { LaunchConfiguration, getReferencedHandlerPaths } from '../../shared/deb import { makeTemporaryToolkitFolder, fileExists, tryRemoveFolder } from '../../shared/filesystemUtilities' import * as localizedText from '../../shared/localizedText' import { getLogger } from '../../shared/logger' -import { HttpResourceFetcher } from '../../shared/resourcefetcher/httpResourceFetcher' +import { HttpResourceFetcher } from '../../shared/resourcefetcher/node/httpResourceFetcher' import { createCodeAwsSamDebugConfig } from '../../shared/sam/debugger/awsSamDebugConfiguration' import * as pathutils from '../../shared/utilities/pathUtils' import { localize } from '../../shared/utilities/vsCodeUtils' diff --git a/packages/core/src/lambda/utils.ts b/packages/core/src/lambda/utils.ts index 63b4325da55..7fa56bc33e9 100644 --- a/packages/core/src/lambda/utils.ts +++ b/packages/core/src/lambda/utils.ts @@ -13,8 +13,6 @@ import { CloudFormationClient } from '../shared/clients/cloudFormationClient' import { LambdaClient } from '../shared/clients/lambdaClient' import { getFamily, getNodeMajorVersion, RuntimeFamily } from './models/samLambdaRuntime' import { getLogger } from '../shared/logger' -import { ResourceFetcher } from '../shared/resourcefetcher/resourcefetcher' -import { CompositeResourceFetcher } from '../shared/resourcefetcher/compositeResourceFetcher' import { HttpResourceFetcher } from '../shared/resourcefetcher/httpResourceFetcher' import { FileResourceFetcher } from '../shared/resourcefetcher/fileResourceFetcher' import { sampleRequestManifestPath } from './constants' @@ -99,7 +97,7 @@ interface SampleRequestManifest { export async function getSampleLambdaPayloads(): Promise { const logger = getLogger() - const sampleInput = await makeSampleRequestManifestResourceFetcher().get() + const sampleInput = await getSampleRequestManifest() if (!sampleInput) { throw new Error('Unable to retrieve Sample Request manifest') @@ -120,9 +118,11 @@ export async function getSampleLambdaPayloads(): Promise { return inputs } -function makeSampleRequestManifestResourceFetcher(): ResourceFetcher { - return new CompositeResourceFetcher( - new HttpResourceFetcher(sampleRequestManifestPath, { showUrl: true }), - new FileResourceFetcher(globals.manifestPaths.lambdaSampleRequests) - ) +async function getSampleRequestManifest(): Promise { + const httpResp = await new HttpResourceFetcher(sampleRequestManifestPath, { showUrl: true }).get() + if (!httpResp) { + const fileResp = new FileResourceFetcher(globals.manifestPaths.lambdaSampleRequests) + return fileResp.get() + } + return httpResp.text() } diff --git a/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts b/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts index 643ea4631e2..d2f2ec912da 100644 --- a/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts +++ b/packages/core/src/lambda/vue/configEditor/samInvokeBackend.ts @@ -170,7 +170,8 @@ export class SamInvokeWebview extends VueWebview { return } const sampleUrl = `${sampleRequestPath}${pickerResponse.filename}` - const sample = (await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get()) ?? '' + const resp = await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get() + const sample = (await resp?.text()) ?? '' return sample } catch (err) { diff --git a/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts b/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts index 501304c1a94..38b3700719c 100644 --- a/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts +++ b/packages/core/src/lambda/vue/remoteInvoke/invokeLambda.ts @@ -219,7 +219,8 @@ export class RemoteInvokeWebview extends VueWebview { return } const sampleUrl = `${sampleRequestPath}${pickerResponse.filename}` - const sample = (await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get()) ?? '' + const resp = await new HttpResourceFetcher(sampleUrl, { showUrl: true }).get() + const sample = (await resp?.text()) ?? '' return sample } catch (err) { diff --git a/packages/core/src/login/webview/vue/backend.ts b/packages/core/src/login/webview/vue/backend.ts index 0c1cbdaebc7..ed467175334 100644 --- a/packages/core/src/login/webview/vue/backend.ts +++ b/packages/core/src/login/webview/vue/backend.ts @@ -31,6 +31,7 @@ import { AuthEnabledFeatures, AuthError, AuthFlowState, AuthUiClick, userCancell import { DevSettings } from '../../../shared/settings' import { AuthSSOServer } from '../../../auth/sso/server' import { getLogger } from '../../../shared/logger/logger' +import { isValidUrl } from '../../../shared/utilities/uriUtils' export abstract class CommonAuthWebview extends VueWebview { private readonly className = 'CommonAuthWebview' @@ -276,4 +277,8 @@ export abstract class CommonAuthWebview extends VueWebview { cancelAuthFlow() { AuthSSOServer.lastInstance?.cancelCurrentFlow() } + + validateUrl(url: string) { + return isValidUrl(url) + } } diff --git a/packages/core/src/login/webview/vue/login.vue b/packages/core/src/login/webview/vue/login.vue index f15848a9069..4c9f65a2f6a 100644 --- a/packages/core/src/login/webview/vue/login.vue +++ b/packages/core/src/login/webview/vue/login.vue @@ -193,6 +193,7 @@ @keydown.enter="handleContinueClick()" />

{{ startUrlError }}

+

{{ startUrlWarning }}

Region
AWS Region that hosts identity directory