diff --git a/package-lock.json b/package-lock.json index ac6d71b0088..070ac99e486 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.282", + "@aws-toolkits/telemetry": "^1.0.284", "@playwright/browser-chromium": "^1.43.1", "@types/he": "^1.2.3", "@types/vscode": "^1.68.0", @@ -5135,13 +5135,14 @@ } }, "node_modules/@aws-toolkits/telemetry": { - "version": "1.0.282", - "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.282.tgz", - "integrity": "sha512-MHktYmucYHvEm4Sscr93UmKr83D9pKJIvETo1bZiNtCsE0jxcNglxZwqZruy13Fks5uk523ZhaIALW22TF0Zpg==", + "version": "1.0.285", + "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.285.tgz", + "integrity": "sha512-O5/kbCE9cXF8scL5XmeDjMX9ojmCLvXg6cwcBayTS4URypI6XFat6drmaIF/QoDqxAfnHLHs0zypOdqSWCDr8w==", "dev": true, "license": "Apache-2.0", "dependencies": { "ajv": "^6.12.6", + "cross-spawn": "^7.0.6", "fs-extra": "^11.1.0", "lodash": "^4.17.20", "prettier": "^3.3.2", diff --git a/package.json b/package.json index 70f9d0f4c35..bc03c2b8395 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "generateNonCodeFiles": "npm run generateNonCodeFiles -w packages/ --if-present" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.282", + "@aws-toolkits/telemetry": "^1.0.284", "@playwright/browser-chromium": "^1.43.1", "@types/he": "^1.2.3", "@types/vscode": "^1.68.0", diff --git a/packages/amazonq/.changes/next-release/Feature-389df2e8-de2c-4505-b631-97aa8d5025bb.json b/packages/amazonq/.changes/next-release/Feature-389df2e8-de2c-4505-b631-97aa8d5025bb.json new file mode 100644 index 00000000000..a92a111c70e --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-389df2e8-de2c-4505-b631-97aa8d5025bb.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Added a getting started page for exploring amazon q agents" +} diff --git a/packages/amazonq/.changes/next-release/Feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json b/packages/amazonq/.changes/next-release/Feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json new file mode 100644 index 00000000000..1181f2b0172 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "`/test` in Q chat to generate unit tests for java and python" +} diff --git a/packages/amazonq/.changes/next-release/Feature-5cac73d3-dfc5-4065-a6d9-f093a3c0b258.json b/packages/amazonq/.changes/next-release/Feature-5cac73d3-dfc5-4065-a6d9-f093a3c0b258.json new file mode 100644 index 00000000000..5be0ead47dd --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-5cac73d3-dfc5-4065-a6d9-f093a3c0b258.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "`/doc` in Q chat to generate and update documentation for your project" +} diff --git a/packages/amazonq/.changes/next-release/Feature-6967c79c-041f-4201-b10b-6ccd47568bad.json b/packages/amazonq/.changes/next-release/Feature-6967c79c-041f-4201-b10b-6ccd47568bad.json new file mode 100644 index 00000000000..88d57f1376e --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-6967c79c-041f-4201-b10b-6ccd47568bad.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q Code Scan is now Amazon Q Code Review" +} diff --git a/packages/amazonq/.changes/next-release/Feature-d81e958e-081b-4832-a183-1c863f99d18f.json b/packages/amazonq/.changes/next-release/Feature-d81e958e-081b-4832-a183-1c863f99d18f.json new file mode 100644 index 00000000000..96c6254f001 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-d81e958e-081b-4832-a183-1c863f99d18f.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "`/review` in Q chat to scan your code for vulnerabilities and quality issues, and generate fixes" +} diff --git a/packages/amazonq/.changes/next-release/Feature-ee6b1c26-ea04-4214-8dc4-df09d58c0bdf.json b/packages/amazonq/.changes/next-release/Feature-ee6b1c26-ea04-4214-8dc4-df09d58c0bdf.json new file mode 100644 index 00000000000..eaf6b86a986 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-ee6b1c26-ea04-4214-8dc4-df09d58c0bdf.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Security Scan: New TreeView to display security scan issues and vulnerabilities detected in your project. The TreeView provides an organized and hierarchical view of the scan results, making it easier to navigate and prioritize the issues that need to be addressed." +} diff --git a/packages/amazonq/.changes/next-release/Feature-f5369c80-8c95-4637-82d5-ae6c680aa0e2.json b/packages/amazonq/.changes/next-release/Feature-f5369c80-8c95-4637-82d5-ae6c680aa0e2.json new file mode 100644 index 00000000000..b293d43007c --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-f5369c80-8c95-4637-82d5-ae6c680aa0e2.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Security Scan: Added ability to suppress or ignore security issues" +} diff --git a/packages/amazonq/README.md b/packages/amazonq/README.md index beee7557b6a..6fe90e3fec2 100644 --- a/packages/amazonq/README.md +++ b/packages/amazonq/README.md @@ -3,62 +3,52 @@ [![Youtube Channel Views](https://img.shields.io/youtube/channel/views/UCd6MoB9NC6uYN2grvUNT-Zg?style=flat-square&logo=youtube&label=Youtube)](https://www.youtube.com/@amazonwebservices) ![Marketplace Installs](https://img.shields.io/vscode-marketplace/i/AmazonWebServices.amazon-q-vscode.svg?label=Installs&style=flat-square) -# Getting Started - -**Free Tier** - create or log in with an AWS Builder ID (a personal profile from AWS). +# Agent capabilities -**Pro Tier** - if your organization is on the Amazon Q Developer Pro tier, log in with single sign-on. - -![Authentication gif](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/auth-Q.gif) +### Implement new features +`/dev` to task Amazon Q with generating new code across your entire project and implement features. -# Features - -## Inline code suggestions - -Code faster with inline code suggestions as you type. - -![Inline code suggestion demo](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/inline.gif) - -[_15+ languages supported including Python, TypeScript, Rust, Terraform, AWS Cloudformation, and more_](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/q-language-ide-support.html) +### Generate documentation +`/docs` to task Amazon Q with writing API, technical design, and onboarding documentation. -## Chat +### Automate code reviews +`/review` to ask Amazon Q to perform code reviews, flagging suspicious code patterns and assessing deployment risk. -Generate code, explain code, and get answers to questions about software development. +### Generate unit tests +`/test` to ask Amazon Q to generate unit tests and add them to your project, helping you improve code quality, fast. -![Generate code using chat](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/chat.gif) +### Transform workloads +`/transform` to upgrade your Java applications in minutes, not weeks. -## Security scans +
-Analyze and fix security vulnerabilities in your project. +# Core features -![Fix security vulnerability demo](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/security-scan.gif) +### Inline chat +Seamlessly initiate chat within the inline coding experience. Select a section of code that you need assistance with and initiate chat within the editor to request actions such as "Optimize this code", "Add comments", or "Write tests". -[_10 languages supported including Python, TypeScript, C#, and more_](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security-scans.html) +### Chat +Generate code, explain code, and get answers about software development. -## Agent for software development +### Inline suggestions +Receive real-time code suggestions ranging from snippets to full functions based on your comments and existing code. -Amazon Q can implement new functionality across multiple files in your workspace. - -Type “/” in chat to open the quick actions menu and choose the “/dev” action. - -![Agent for software development demo](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/dev.gif) - -_Note - this demo has been trimmed, Amazon Q can take several minutes to generate code_ +[_15+ languages supported including Python, TypeScript, Rust, Terraform, AWS Cloudformation, and more_](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/q-language-ide-support.html) -## Agent for code transformation +### Code reference log -Upgrade your Java applications in minutes, not weeks. +Attribute code from Amazon Q that is similar to training data. When code suggestions similar to training data are accepted, they will be added to the code reference log. -Type “/” in chat to open the quick actions menu and choose the “/transform” action. +
-![Agent for code transformation demo](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/transform.png) +# Getting Started -[_Currently supports upgrading Java 8 or 11 Maven projects to Java 17_](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/code-transformation.html#prerequisites) +**Free Tier** - create or log in with an AWS Builder ID (a personal profile from AWS). -## Code reference log +**Pro Tier** - if your organization is on the Amazon Q Developer Pro tier, log in with single sign-on. -Attribute code from Amazon Q that is similar to training data. When code suggestions similar to training data are accepted, they will be added to the code reference log. +![Authentication gif](https://raw.githubusercontent.com/aws/aws-toolkit-vscode/HEAD/docs/marketplace/vscode/amazonq/auth-Q.gif) -## Troubleshooting & feedback +# Troubleshooting & feedback -[File a bug](https://github.com/aws/aws-toolkit-vscode/issues/new?assignees=&labels=bug&projects=&template=bug_report.md) or [submit a feature request](https://github.com/aws/aws-toolkit-vscode/issues/new?assignees=&labels=feature-request&projects=&template=feature_request.md) on our Github repository. +[File a bug](https://github.com/aws/aws-toolkit-vscode/issues/new?assignees=&labels=bug&projects=&template=bug_report.md) or [submit a feature request](https://github.com/aws/aws-toolkit-vscode/issues/new?assignees=&labels=feature-request&projects=&template=feature_request.md) on our Github repository. \ No newline at end of file diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 4fbace6f050..6a324892815 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -1,7 +1,7 @@ { "name": "amazon-q-vscode", "displayName": "Amazon Q", - "description": "Amazon Q is your generative AI-powered assistant across the software development lifecycle.", + "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", "version": "1.39.0-SNAPSHOT", "extensionKind": [ "workspace" @@ -161,6 +161,14 @@ "markdownDescription": "%AWS.configuration.description.amazonq.workspaceIndexMaxSize%", "default": 250, "scope": "application" + }, + "amazonQ.ignoredSecurityIssues": { + "type": "array", + "markdownDescription": "%AWS.configuration.description.amazonq.ignoredSecurityIssues%", + "scope": "window", + "items": { + "type": "string" + } } } }, @@ -198,6 +206,12 @@ "name": "%AWS.amazonq.login%", "when": "!aws.isWebExtHost && aws.amazonq.showLoginView" }, + { + "type": "tree", + "id": "aws.amazonq.SecurityIssuesTree", + "name": "%AWS.amazonq.security%", + "when": "!aws.isSageMaker && !aws.isWebExtHost && !aws.amazonq.showLoginView" + }, { "type": "webview", "id": "aws.AmazonQChatView", @@ -241,6 +255,16 @@ "view": "aws.amazonq.transformationProposedChangesTree", "contents": "Project transformation is complete.\n Downloading the proposed changes...", "when": "gumby.reviewState == PreparingReview" + }, + { + "view": "aws.amazonq.SecurityIssuesTree", + "contents": "No code issues have been detected in the workspace.", + "when": "!aws.amazonq.security.noMatches" + }, + { + "view": "aws.amazonq.SecurityIssuesTree", + "contents": "No matches.\n[Clear Filters](command:aws.amazonq.securityIssuesTreeFilter.clearFilters)", + "when": "aws.amazonq.security.noMatches" } ], "submenus": [ @@ -255,6 +279,11 @@ { "label": "%AWS.generic.help%", "id": "aws.amazonq.submenu.help" + }, + { + "label": "%AWS.generic.moreActions%", + "id": "aws.amazonq.submenu.securityIssueMoreActions", + "icon": "$(ellipsis)" } ], "menus": { @@ -339,6 +368,38 @@ "submenu": "aws.amazonq.submenu.help", "when": "view == aws.AmazonQChatView || view == aws.amazonq.AmazonCommonAuth", "group": "y_toolkitMeta@2" + }, + { + "command": "aws.amazonq.security.showFilters", + "when": "view == aws.amazonq.SecurityIssuesTree", + "group": "navigation" + } + ], + "view/item/context": [ + { + "command": "_aws.amazonq.notifications.dismiss", + "when": "viewItem == amazonqNotificationStartUp", + "group": "inline@1" + }, + { + "command": "aws.amazonq.openSecurityIssuePanel", + "when": "view == aws.amazonq.SecurityIssuesTree && (viewItem == issueWithoutFix || viewItem == issueWithFix)", + "group": "inline@4" + }, + { + "command": "aws.amazonq.security.ignore", + "when": "view == aws.amazonq.SecurityIssuesTree && (viewItem == issueWithoutFix || viewItem == issueWithFix)", + "group": "inline@5" + }, + { + "command": "aws.amazonq.security.generateFix", + "when": "view == aws.amazonq.SecurityIssuesTree && viewItem == issueWithoutFix", + "group": "inline@6" + }, + { + "submenu": "aws.amazonq.submenu.securityIssueMoreActions", + "when": "view == aws.amazonq.SecurityIssuesTree && (viewItem == issueWithoutFix || viewItem == issueWithFix)", + "group": "inline@7" } ], "amazonqEditorContextSubmenu": [ @@ -360,8 +421,7 @@ }, { "command": "aws.amazonq.generateUnitTests", - "group": "cw_chat@5", - "when": "aws.codewhisperer.connected && aws.isInternalUser" + "group": "cw_chat@5" }, { "command": "aws.amazonq.sendToPrompt", @@ -378,13 +438,6 @@ "group": "cw_chat" } ], - "view/item/context": [ - { - "command": "_aws.amazonq.notifications.dismiss", - "when": "viewItem == amazonqNotificationStartUp", - "group": "inline@1" - } - ], "aws.amazonq.submenu.feedback": [ { "command": "aws.amazonq.submitFeedback", @@ -397,6 +450,14 @@ } ], "aws.amazonq.submenu.help": [ + { + "command": "aws.amazonq.walkthrough.show", + "group": "1_help@1" + }, + { + "command": "aws.amazonq.exploreAgents", + "group": "1_help@2" + }, { "command": "aws.amazonq.github", "group": "1_help@3" @@ -409,6 +470,26 @@ "command": "aws.amazonq.viewLogs", "group": "1_help@5" } + ], + "aws.amazonq.submenu.securityIssueMoreActions": [ + { + "command": "aws.amazonq.security.explain", + "group": "1_more@1" + }, + { + "command": "aws.amazonq.applySecurityFix", + "when": "view == aws.amazonq.SecurityIssuesTree && viewItem == issueWithFix", + "group": "1_more@3" + }, + { + "command": "aws.amazonq.security.regenerateFix", + "when": "view == aws.amazonq.SecurityIssuesTree && viewItem == issueWithFix", + "group": "1_more@4" + }, + { + "command": "aws.amazonq.security.ignoreAll", + "group": "1_more@5" + } ] }, "commands": [ @@ -426,7 +507,7 @@ "enablement": "aws.codewhisperer.connected" }, { - "command": "aws.amazonq.security.scan", + "command": "aws.amazonq.security.scan-statusbar", "title": "%AWS.command.amazonq.security.scan%", "category": "%AWS.amazonq.title%", "enablement": "aws.codewhisperer.connected" @@ -459,7 +540,7 @@ "command": "aws.amazonq.generateUnitTests", "title": "%AWS.command.amazonq.generateUnitTests%", "category": "%AWS.amazonq.title%", - "enablement": "aws.codewhisperer.connected && aws.isInternalUser" + "enablement": "aws.codewhisperer.connected" }, { "command": "aws.amazonq.reconnect", @@ -599,11 +680,72 @@ "category": "%AWS.amazonq.title%", "enablement": "aws.codewhisperer.connected" }, + { + "command": "aws.amazonq.securityIssuesTreeFilter.clearFilters", + "title": "Clear Filters", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.security.generateFix", + "title": "%AWS.command.amazonq.generateFix%", + "icon": "$(wrench)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.applySecurityFix", + "title": "%AWS.command.amazonq.acceptFix%", + "icon": "$(check)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.security.regenerateFix", + "title": "%AWS.command.amazonq.regenerateFix%", + "icon": "$(lightbulb-autofix)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.openSecurityIssuePanel", + "title": "%AWS.command.amazonq.viewDetails%", + "icon": "$(search)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.security.explain", + "title": "%AWS.command.amazonq.explainIssue%", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.security.ignore", + "title": "%AWS.command.amazonq.ignoreIssue%", + "icon": "$(circle-slash)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.security.ignoreAll", + "title": "%AWS.command.amazonq.ignoreAllIssues%", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, + { + "command": "aws.amazonq.security.showFilters", + "title": "%AWS.command.amazonq.filterIssues%", + "icon": "$(list-filter)", + "enablement": "view == aws.amazonq.SecurityIssuesTree" + }, { "command": "aws.amazonq.inline.invokeChat", "title": "%AWS.amazonq.inline.invokeChat%", "category": "%AWS.amazonq.title%", "enablement": "aws.codewhisperer.connected" + }, + { + "command": "aws.amazonq.exploreAgents", + "title": "%AWS.amazonq.exploreAgents%", + "category": "%AWS.amazonq.title%", + "enablement": "aws.codewhisperer.connected" + }, + { + "command": "aws.amazonq.walkthrough.show", + "title": "%AWS.amazonq.welcomeWalkthrough%" } ], "keybindings": [ @@ -647,8 +789,7 @@ "command": "aws.amazonq.generateUnitTests", "key": "win+alt+t", "mac": "cmd+alt+t", - "linux": "meta+alt+t", - "when": "aws.codewhisperer.connected && aws.isInternalUser" + "linux": "meta+alt+t" }, { "command": "aws.amazonq.invokeInlineCompletion", @@ -718,327 +859,362 @@ "fontCharacter": "\\f1ac" } }, - "aws-amazonq-transform-arrow-dark": { + "aws-amazonq-severity-critical": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ad" } }, - "aws-amazonq-transform-arrow-light": { + "aws-amazonq-severity-high": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ae" } }, - "aws-amazonq-transform-default-dark": { + "aws-amazonq-severity-info": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1af" } }, - "aws-amazonq-transform-default-light": { + "aws-amazonq-severity-low": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b0" } }, - "aws-amazonq-transform-dependencies-dark": { + "aws-amazonq-severity-medium": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b1" } }, - "aws-amazonq-transform-dependencies-light": { + "aws-amazonq-transform-arrow-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b2" } }, - "aws-amazonq-transform-file-dark": { + "aws-amazonq-transform-arrow-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b3" } }, - "aws-amazonq-transform-file-light": { + "aws-amazonq-transform-default-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b4" } }, - "aws-amazonq-transform-logo": { + "aws-amazonq-transform-default-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b5" } }, - "aws-amazonq-transform-step-into-dark": { + "aws-amazonq-transform-dependencies-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b6" } }, - "aws-amazonq-transform-step-into-light": { + "aws-amazonq-transform-dependencies-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b7" } }, - "aws-amazonq-transform-variables-dark": { + "aws-amazonq-transform-file-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b8" } }, - "aws-amazonq-transform-variables-light": { + "aws-amazonq-transform-file-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b9" } }, - "aws-applicationcomposer-icon": { + "aws-amazonq-transform-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ba" } }, - "aws-applicationcomposer-icon-dark": { + "aws-amazonq-transform-step-into-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bb" } }, - "aws-apprunner-service": { + "aws-amazonq-transform-step-into-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bc" } }, - "aws-cdk-logo": { + "aws-amazonq-transform-variables-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bd" } }, - "aws-cloudformation-stack": { + "aws-amazonq-transform-variables-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1be" } }, - "aws-cloudwatch-log-group": { + "aws-applicationcomposer-icon": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bf" } }, - "aws-codecatalyst-logo": { + "aws-applicationcomposer-icon-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c0" } }, - "aws-codewhisperer-icon-black": { + "aws-apprunner-service": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c1" } }, - "aws-codewhisperer-icon-white": { + "aws-cdk-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c2" } }, - "aws-codewhisperer-learn": { + "aws-cloudformation-stack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c3" } }, - "aws-ecr-registry": { + "aws-cloudwatch-log-group": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c4" } }, - "aws-ecs-cluster": { + "aws-codecatalyst-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c5" } }, - "aws-ecs-container": { + "aws-codewhisperer-icon-black": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c6" } }, - "aws-ecs-service": { + "aws-codewhisperer-icon-white": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c7" } }, - "aws-generic-attach-file": { + "aws-codewhisperer-learn": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c8" } }, - "aws-iot-certificate": { + "aws-ecr-registry": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c9" } }, - "aws-iot-policy": { + "aws-ecs-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ca" } }, - "aws-iot-thing": { + "aws-ecs-container": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cb" } }, - "aws-lambda-function": { + "aws-ecs-service": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cc" } }, - "aws-mynah-MynahIconBlack": { + "aws-generic-attach-file": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cd" } }, - "aws-mynah-MynahIconWhite": { + "aws-iot-certificate": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ce" } }, - "aws-mynah-logo": { + "aws-iot-policy": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cf" } }, - "aws-redshift-cluster": { + "aws-iot-thing": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d0" } }, - "aws-redshift-cluster-connected": { + "aws-lambda-function": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d1" } }, - "aws-redshift-database": { + "aws-mynah-MynahIconBlack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d2" } }, - "aws-redshift-redshift-cluster-connected": { + "aws-mynah-MynahIconWhite": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d3" } }, - "aws-redshift-schema": { + "aws-mynah-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d4" } }, - "aws-redshift-table": { + "aws-redshift-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d5" } }, - "aws-s3-bucket": { + "aws-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d6" } }, - "aws-s3-create-bucket": { + "aws-redshift-database": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d7" } }, - "aws-schemas-registry": { + "aws-redshift-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d8" } }, - "aws-schemas-schema": { + "aws-redshift-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d9" } }, - "aws-stepfunctions-preview": { + "aws-redshift-table": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1da" } + }, + "aws-s3-bucket": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1db" + } + }, + "aws-s3-create-bucket": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1dc" + } + }, + "aws-schemas-registry": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1dd" + } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1de" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1df" + } } }, "walkthroughs": [ diff --git a/packages/amazonq/src/app/amazonqScan/app.ts b/packages/amazonq/src/app/amazonqScan/app.ts new file mode 100644 index 00000000000..d639ab6bf2a --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/app.ts @@ -0,0 +1,87 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { + AmazonQAppInitContext, + MessagePublisher, + MessageListener, + focusAmazonQPanel, + DefaultAmazonQAppInitContext, +} from 'aws-core-vscode/amazonq' +import { AuthUtil, codeScanState, onDemandFileScanState } from 'aws-core-vscode/codewhisperer' +import { ScanChatControllerEventEmitters, ChatSessionManager } from 'aws-core-vscode/amazonqScan' +import { ScanController } from './chat/controller/controller' +import { AppToWebViewMessageDispatcher } from './chat/views/connector/connector' +import { Messenger } from './chat/controller/messenger/messenger' +import { UIMessageListener } from './chat/views/actions/uiMessageListener' +import { debounce } from 'lodash' +import { Commands, placeholder } from 'aws-core-vscode/shared' + +export function init(appContext: AmazonQAppInitContext) { + const scanChatControllerEventEmitters: ScanChatControllerEventEmitters = { + authClicked: new vscode.EventEmitter(), + tabOpened: new vscode.EventEmitter(), + tabClosed: new vscode.EventEmitter(), + runScan: new vscode.EventEmitter(), + formActionClicked: new vscode.EventEmitter(), + errorThrown: new vscode.EventEmitter(), + showSecurityScan: new vscode.EventEmitter(), + scanStopped: new vscode.EventEmitter(), + followUpClicked: new vscode.EventEmitter(), + scanProgress: new vscode.EventEmitter(), + processResponseBodyLinkClick: new vscode.EventEmitter(), + fileClicked: new vscode.EventEmitter(), + scanCancelled: new vscode.EventEmitter(), + } + const dispatcher = new AppToWebViewMessageDispatcher(appContext.getAppsToWebViewMessagePublisher()) + const messenger = new Messenger(dispatcher) + + new ScanController(scanChatControllerEventEmitters, messenger, appContext.onDidChangeAmazonQVisibility.event) + + const scanChatUIInputEventEmitter = new vscode.EventEmitter() + + new UIMessageListener({ + chatControllerEventEmitters: scanChatControllerEventEmitters, + webViewMessageListener: new MessageListener(scanChatUIInputEventEmitter), + }) + + appContext.registerWebViewToAppMessagePublisher(new MessagePublisher(scanChatUIInputEventEmitter), 'review') + + const debouncedEvent = debounce(async () => { + const authenticated = (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' + let authenticatingSessionID = '' + + if (authenticated) { + const session = ChatSessionManager.Instance.getSession() + + if (session.isTabOpen() && session.isAuthenticating) { + authenticatingSessionID = session.tabID! + session.isAuthenticating = false + } + } + + messenger.sendAuthenticationUpdate(authenticated, [authenticatingSessionID]) + }, 500) + + AuthUtil.instance.secondaryAuth.onDidChangeActiveConnection(() => { + return debouncedEvent() + }) + + Commands.register('aws.amazonq.security.scan-statusbar', async () => { + if (AuthUtil.instance.isConnectionExpired()) { + await AuthUtil.instance.notifyReauthenticate() + } + return focusAmazonQPanel.execute(placeholder, 'amazonq.security.scan').then(() => { + DefaultAmazonQAppInitContext.instance.getAppsToWebViewMessagePublisher().publish({ + sender: 'amazonqCore', + command: 'review', + }) + }) + }) + + codeScanState.setChatControllers(scanChatControllerEventEmitters) + onDemandFileScanState.setChatControllers(scanChatControllerEventEmitters) +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/controller/controller.ts b/packages/amazonq/src/app/amazonqScan/chat/controller/controller.ts new file mode 100644 index 00000000000..599271f0f3b --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/controller/controller.ts @@ -0,0 +1,358 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for responding to UI events by calling + * the Scan extension. + */ +import * as vscode from 'vscode' +import { AuthController } from 'aws-core-vscode/amazonq' +import { getLogger, placeholder, i18n, openUrl, fs, TabTypeDataMap, randomUUID } from 'aws-core-vscode/shared' +import { ScanChatControllerEventEmitters, Session, ChatSessionManager } from 'aws-core-vscode/amazonqScan' +import { + AggregatedCodeScanIssue, + AuthUtil, + CodeAnalysisScope, + codeScanState, + isGitRepo, + onDemandFileScanState, + SecurityScanError, + SecurityScanStep, + showFileScan, + showSecurityScan, +} from 'aws-core-vscode/codewhisperer' +import { Messenger, ScanNamedMessages } from './messenger/messenger' +import MessengerUtils from './messenger/messengerUtils' +import { + cancellingProgressField, + fileScanProgressField, + projectScanProgressField, + ScanAction, + scanProgressMessage, + scanSummaryMessage, +} from '../../models/constants' +import path from 'path' + +export class ScanController { + private readonly messenger: Messenger + private readonly sessionStorage: ChatSessionManager + private authController: AuthController + + public constructor( + private readonly chatControllerMessageListeners: ScanChatControllerEventEmitters, + messenger: Messenger, + onDidChangeAmazonQVisibility: vscode.Event + ) { + this.messenger = messenger + this.sessionStorage = ChatSessionManager.Instance + this.authController = new AuthController() + + this.chatControllerMessageListeners.tabOpened.event((data) => { + return this.tabOpened(data) + }) + + this.chatControllerMessageListeners.tabClosed.event((data) => { + return this.tabClosed(data) + }) + + this.chatControllerMessageListeners.authClicked.event((data) => { + this.authClicked(data) + }) + + this.chatControllerMessageListeners.runScan.event((data) => { + return this.scanInitiated(data) + }) + + this.chatControllerMessageListeners.formActionClicked.event((data) => { + return this.formActionClicked(data) + }) + + this.chatControllerMessageListeners.errorThrown.event((data) => { + return this.handleError(data) + }) + + this.chatControllerMessageListeners.showSecurityScan.event((data) => { + return this.handleScanResults(data) + }) + + this.chatControllerMessageListeners.scanStopped.event((data) => { + return this.handleScanStopped(data) + }) + + this.chatControllerMessageListeners.followUpClicked.event((data) => { + return this.handleFollowUpClicked(data) + }) + + this.chatControllerMessageListeners.scanProgress.event((data) => { + return this.handleScanProgress(data) + }) + + this.chatControllerMessageListeners.processResponseBodyLinkClick.event((data) => { + return this.processLink(data) + }) + + this.chatControllerMessageListeners.fileClicked.event((data) => { + return this.processFileClick(data) + }) + + this.chatControllerMessageListeners.scanCancelled.event((data) => { + return this.handleScanCancelled(data) + }) + } + + private async tabOpened(message: any) { + const session: Session = this.sessionStorage.getSession() + const tabID = this.sessionStorage.setActiveTab(message.tabID) + + // check if authentication has expired + try { + getLogger().debug(`Q - Review: Session created with id: ${session.tabID}`) + + const authState = await AuthUtil.instance.getChatAuthState() + if (authState.amazonQ !== 'connected') { + void this.messenger.sendAuthNeededExceptionMessage(authState, tabID) + session.isAuthenticating = true + return + } + } catch (err: any) { + this.messenger.sendErrorMessage(err.message, message.tabID) + } + } + + private async tabClosed(data: any) { + this.sessionStorage.removeActiveTab() + } + + private authClicked(message: any) { + this.authController.handleAuth(message.authType) + + this.messenger.sendAnswer({ + type: 'answer', + tabID: message.tabID, + message: 'Follow instructions to re-authenticate ...', + }) + + // Explicitly ensure the user goes through the re-authenticate flow + this.messenger.sendChatInputEnabled(message.tabID, false) + } + + private async scanInitiated(message: any) { + const session: Session = this.sessionStorage.getSession() + try { + // check that a project is open + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders === undefined || workspaceFolders.length === 0) { + this.messenger.sendChatInputEnabled(message.tabID, false) + this.messenger.sendErrorResponse('no-project-found', message.tabID) + return + } + // check that the session is authenticated + const authState = await AuthUtil.instance.getChatAuthState() + if (authState.amazonQ !== 'connected') { + void this.messenger.sendAuthNeededExceptionMessage(authState, message.tabID) + session.isAuthenticating = true + return + } + this.messenger.sendPromptMessage({ + tabID: message.tabID, + message: i18n('AWS.amazonq.scans.runCodeScan'), + }) + this.messenger.sendCapabilityCard({ tabID: message.tabID }) + // Displaying types of scans and wait for user input + this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.scans.waitingForInput')) + + this.messenger.sendScans(message.tabID, i18n('AWS.amazonq.scans.chooseScan.description')) + } catch (e: any) { + this.messenger.sendErrorMessage(e.message, message.tabID) + } + } + + private async formActionClicked(message: any) { + const typedAction = MessengerUtils.stringToEnumValue(ScanAction, message.action as any) + switch (typedAction) { + case ScanAction.STOP_PROJECT_SCAN: + codeScanState.setToCancelling() + this.messenger.sendUpdatePromptProgress(message.tabID, cancellingProgressField) + break + case ScanAction.STOP_FILE_SCAN: + onDemandFileScanState.setToCancelling() + this.messenger.sendUpdatePromptProgress(message.tabID, cancellingProgressField) + break + } + } + + private async handleError(message: { + error: SecurityScanError + tabID: string + scope: CodeAnalysisScope + fileName: string | undefined + scanUuid?: string + }) { + if (this.isNotMatchingId(message)) { + return + } + if (message.error.code === 'NoSourceFilesError') { + this.messenger.sendScanResults(message.tabID, message.scope, message.fileName, true) + this.messenger.sendAnswer({ + tabID: message.tabID, + type: 'answer', + canBeVoted: true, + message: scanSummaryMessage(message.scope, []), + }) + } else { + this.messenger.sendErrorResponse(message.error, message.tabID) + } + } + + private async handleScanResults(message: { + error: Error + totalIssues: number + tabID: string + securityRecommendationCollection: AggregatedCodeScanIssue[] + scope: CodeAnalysisScope + fileName: string + scanUuid?: string + }) { + if (this.isNotMatchingId(message)) { + return + } + this.messenger.sendScanResults(message.tabID, message.scope, message.fileName, true) + this.messenger.sendAnswer({ + tabID: message.tabID, + type: 'answer', + canBeVoted: true, + message: scanSummaryMessage(message.scope, message.securityRecommendationCollection), + }) + } + + private async handleScanStopped(message: { tabID: string }) { + this.messenger.sendUpdatePlaceholder(message.tabID, TabTypeDataMap.review.placeholder) + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(message.tabID, null) + this.messenger.sendChatInputEnabled(message.tabID, true) + } + + private async handleFollowUpClicked(message: any) { + switch (message.followUp.type) { + case ScanAction.RUN_PROJECT_SCAN: { + this.messenger.sendPromptMessage({ + tabID: message.tabID, + message: i18n('AWS.amazonq.scans.projectScan'), + }) + + const workspaceFolders = vscode.workspace.workspaceFolders ?? [] + for (const folder of workspaceFolders) { + if (!(await isGitRepo(folder.uri))) { + this.messenger.sendAnswer({ + tabID: message.tabID, + type: 'answer', + message: i18n('AWS.amazonq.scans.noGitRepo'), + }) + break + } + } + + this.messenger.sendScanInProgress({ + type: 'answer-stream', + tabID: message.tabID, + canBeVoted: true, + message: scanProgressMessage(0, CodeAnalysisScope.PROJECT), + }) + this.messenger.sendUpdatePromptProgress(message.tabID, projectScanProgressField) + const scanUuid = randomUUID() + this.sessionStorage.getSession().scanUuid = scanUuid + void showSecurityScan.execute(placeholder, 'amazonQChat', true, scanUuid) + break + } + case ScanAction.RUN_FILE_SCAN: { + // check if IDE has active file open. + const activeEditor = vscode.window.activeTextEditor + // also check all open editors and allow this to proceed if only one is open (even if not main focus) + const allVisibleEditors = vscode.window.visibleTextEditors + const openFileEditors = allVisibleEditors.filter((editor) => editor.document.uri.scheme === 'file') + const hasOnlyOneOpenFileSplitView = openFileEditors.length === 1 + getLogger().debug(`hasOnlyOneOpenSplitView: ${hasOnlyOneOpenFileSplitView}`) + // is not a file if the currently highlighted window is not a file, and there is either more than one or no file windows open + const isNotFile = activeEditor?.document.uri.scheme !== 'file' && !hasOnlyOneOpenFileSplitView + getLogger().debug(`activeEditor: ${activeEditor}, isNotFile: ${isNotFile}`) + if (!activeEditor || isNotFile) { + this.messenger.sendErrorResponse( + isNotFile ? 'invalid-file-type' : 'no-open-file-found', + message.tabID + ) + this.messenger.sendUpdatePlaceholder( + message.tabID, + 'Please open and highlight a source code file in order run a code scan.' + ) + this.messenger.sendChatInputEnabled(message.tabID, true) + return + } + const fileEditorToTest = hasOnlyOneOpenFileSplitView ? openFileEditors[0] : activeEditor + const fileName = fileEditorToTest.document.uri.fsPath + + this.messenger.sendPromptMessage({ + tabID: message.tabID, + message: i18n('AWS.amazonq.scans.fileScan'), + }) + this.messenger.sendScanInProgress({ + type: 'answer-stream', + tabID: message.tabID, + canBeVoted: true, + message: scanProgressMessage( + SecurityScanStep.GENERATE_ZIP - 1, + CodeAnalysisScope.FILE_ON_DEMAND, + fileName ? path.basename(fileName) : undefined + ), + }) + this.messenger.sendUpdatePromptProgress(message.tabID, fileScanProgressField) + const scanUuid = randomUUID() + this.sessionStorage.getSession().scanUuid = scanUuid + void showFileScan.execute(placeholder, 'amazonQChat', scanUuid) + break + } + } + } + + private async handleScanProgress(message: any) { + if (this.isNotMatchingId(message)) { + return + } + this.messenger.sendAnswer({ + type: 'answer-part', + tabID: message.tabID, + messageID: ScanNamedMessages.SCAN_SUBMISSION_STATUS_MESSAGE, + message: scanProgressMessage( + message.step, + message.scope, + message.fileName ? path.basename(message.fileName) : undefined + ), + }) + } + + private processLink(message: any) { + void openUrl(vscode.Uri.parse(message.link)) + } + + private async processFileClick(message: any) { + const workspaceFolders = vscode.workspace.workspaceFolders ?? [] + for (const workspaceFolder of workspaceFolders) { + const projectPath = workspaceFolder.uri.fsPath + const filePathWithoutProjectName = message.filePath.split('/').slice(1).join('/') + const absolutePath = path.join(projectPath, filePathWithoutProjectName) + if (await fs.existsFile(absolutePath)) { + const document = await vscode.workspace.openTextDocument(absolutePath) + await vscode.window.showTextDocument(document) + } + } + } + + private async handleScanCancelled(message: any) { + this.messenger.sendAnswer({ type: 'answer', tabID: message.tabID, message: 'Cancelled' }) + } + + private isNotMatchingId(data: { scanUuid?: string }): boolean { + const messagescanUuid = data.scanUuid + const currentscanUuid = this.sessionStorage.getSession().scanUuid + return Boolean(messagescanUuid) && Boolean(currentscanUuid) && messagescanUuid !== currentscanUuid + } +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messenger.ts b/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messenger.ts new file mode 100644 index 00000000000..18b05e8bb84 --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messenger.ts @@ -0,0 +1,230 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class controls the presentation of the various chat bubbles presented by the + * Q Security Scans. + * + * As much as possible, all strings used in the experience should originate here. + */ + +import { AuthFollowUpType, AuthMessageDataMap } from 'aws-core-vscode/amazonq' +import { + FeatureAuthState, + SecurityScanError, + CodeWhispererConstants, + SecurityScanStep, + DefaultCodeScanErrorMessage, +} from 'aws-core-vscode/codewhisperer' +import { ChatItemButton, ProgressField } from '@aws/mynah-ui/dist/static' +import { MynahIcons, ChatItemAction } from '@aws/mynah-ui' +import { ChatItemType } from 'aws-core-vscode/amazonq' +import { + AppToWebViewMessageDispatcher, + AuthNeededException, + AuthenticationUpdateMessage, + CapabilityCardMessage, + ChatInputEnabledMessage, + ChatMessage, + ChatPrompt, + ErrorMessage, + UpdatePlaceholderMessage, + UpdatePromptProgressMessage, +} from '../../views/connector/connector' +import { i18n } from 'aws-core-vscode/shared' +import { ScanAction, scanProgressMessage } from '../../../models/constants' +import path from 'path' + +export type UnrecoverableErrorType = 'no-project-found' | 'no-open-file-found' | 'invalid-file-type' + +export enum ScanNamedMessages { + SCAN_SUBMISSION_STATUS_MESSAGE = 'scanSubmissionMessage', +} + +export class Messenger { + public constructor(private readonly dispatcher: AppToWebViewMessageDispatcher) {} + + public sendAnswer(params: { + message?: string + type: ChatItemType + tabID: string + messageID?: string + followUps?: ChatItemAction[] + canBeVoted?: boolean + }) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: params.message, + messageType: params.type, + messageId: params.messageID, + followUps: params.followUps, + canBeVoted: true, + }, + params.tabID + ) + ) + } + + public sendChatInputEnabled(tabID: string, enabled: boolean) { + this.dispatcher.sendChatInputEnabled(new ChatInputEnabledMessage(tabID, enabled)) + } + + public sendUpdatePlaceholder(tabID: string, newPlaceholder: string) { + this.dispatcher.sendUpdatePlaceholder(new UpdatePlaceholderMessage(tabID, newPlaceholder)) + } + + public sendUpdatePromptProgress(tabID: string, progressField: ProgressField | null) { + this.dispatcher.sendUpdatePromptProgress(new UpdatePromptProgressMessage(tabID, progressField)) + } + + public async sendAuthNeededExceptionMessage(credentialState: FeatureAuthState, tabID: string) { + let authType: AuthFollowUpType = 'full-auth' + let message = AuthMessageDataMap[authType].message + + switch (credentialState.amazonQ) { + case 'disconnected': + authType = 'full-auth' + message = AuthMessageDataMap[authType].message + break + case 'unsupported': + authType = 'use-supported-auth' + message = AuthMessageDataMap[authType].message + break + case 'expired': + authType = 're-auth' + message = AuthMessageDataMap[authType].message + break + } + + this.dispatcher.sendAuthNeededExceptionMessage(new AuthNeededException(message, authType, tabID)) + } + + public sendAuthenticationUpdate(scanEnabled: boolean, authenticatingTabIDs: string[]) { + this.dispatcher.sendAuthenticationUpdate(new AuthenticationUpdateMessage(scanEnabled, authenticatingTabIDs)) + } + + public sendScanInProgress(params: { + message?: string + type: ChatItemType + tabID: string + messageID?: string + canBeVoted?: boolean + }) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: params.message, + messageType: params.type, + messageId: ScanNamedMessages.SCAN_SUBMISSION_STATUS_MESSAGE, + canBeVoted: params.canBeVoted, + }, + params.tabID + ) + ) + } + + public sendErrorMessage(errorMessage: string, tabID: string) { + this.dispatcher.sendErrorMessage( + new ErrorMessage(CodeWhispererConstants.genericErrorMessage, errorMessage, tabID) + ) + } + + public sendScanResults( + tabID: string, + scope: CodeWhispererConstants.CodeAnalysisScope, + fileName?: string, + canBeVoted?: boolean + ) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: scanProgressMessage( + SecurityScanStep.PROCESS_SCAN_RESULTS + 1, + scope, + fileName ? path.basename(fileName) : undefined + ), + messageType: 'answer-part', + messageId: ScanNamedMessages.SCAN_SUBMISSION_STATUS_MESSAGE, + canBeVoted: canBeVoted, + }, + tabID + ) + ) + } + + public sendErrorResponse(error: UnrecoverableErrorType | SecurityScanError, tabID: string) { + let message = DefaultCodeScanErrorMessage + const buttons: ChatItemButton[] = [] + if (typeof error === 'string') { + switch (error) { + case 'no-project-found': { + // TODO: If required we can add "Open the Projects" button in the chat panel. + message = CodeWhispererConstants.noOpenProjectsFound + break + } + case 'no-open-file-found': { + message = CodeWhispererConstants.noOpenFileFound + break + } + case 'invalid-file-type': { + message = CodeWhispererConstants.invalidFileTypeChatMessage + break + } + } + } else if (error.code === 'NoActiveFileError') { + message = CodeWhispererConstants.noOpenFileFound + } else if (error.code === 'ContentLengthError') { + message = CodeWhispererConstants.ProjectSizeExceededErrorMessage + } else if (error.code === 'NoSourceFilesError') { + message = CodeWhispererConstants.noSourceFilesErrorMessage + } else { + message = error.customerFacingMessage + } + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message, + messageType: 'answer', + buttons, + }, + tabID + ) + ) + } + + public sendScans(tabID: string, message: string) { + const followUps: ChatItemAction[] = [] + followUps.push({ + pillText: i18n('AWS.amazonq.scans.projectScan'), + status: 'info', + icon: 'folder' as MynahIcons, + type: ScanAction.RUN_PROJECT_SCAN, + }) + followUps.push({ + pillText: i18n('AWS.amazonq.scans.fileScan'), + status: 'info', + icon: 'file' as MynahIcons, + type: ScanAction.RUN_FILE_SCAN, + }) + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message, + messageType: 'ai-prompt', + followUps, + }, + tabID + ) + ) + } + + // This function shows selected scan type in the chat panel as a user input + public sendPromptMessage(params: { tabID: string; message: string }) { + this.dispatcher.sendPromptMessage(new ChatPrompt(params.message, params.tabID)) + } + + public sendCapabilityCard(params: { tabID: string }) { + this.dispatcher.sendChatMessage(new CapabilityCardMessage(params.tabID)) + } +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts b/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts new file mode 100644 index 00000000000..67351c3eb6e --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/controller/messenger/messengerUtils.ts @@ -0,0 +1,19 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ +//TODO: Refactor the common functionality between Transform, FeatureDev, CWSPRChat, Scan and UTG to a new Folder. + +export default class MessengerUtils { + static stringToEnumValue = ( + enumObject: T, + value: `${T[K]}` + ): T[K] => { + if (Object.values(enumObject).includes(value)) { + return value as unknown as T[K] + } else { + throw new Error('Value provided was not found in Enum') + } + } +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/session/session.ts b/packages/amazonq/src/app/amazonqScan/chat/session/session.ts new file mode 100644 index 00000000000..1ca7e8d7362 --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/session/session.ts @@ -0,0 +1,25 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum ConversationState { + IDLE, + JOB_SUBMITTED, +} + +export class Session { + // Used to keep track of whether or not the current session is currently authenticating/needs authenticating + public isAuthenticating: boolean = false + + // A tab may or may not be currently open + public tabID: string | undefined + + public conversationState: ConversationState = ConversationState.IDLE + + constructor() {} + + public isTabOpen(): boolean { + return this.tabID !== undefined + } +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/storages/chatSession.ts b/packages/amazonq/src/app/amazonqScan/chat/storages/chatSession.ts new file mode 100644 index 00000000000..b7df6eb0cc6 --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/storages/chatSession.ts @@ -0,0 +1,54 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { Session } from '../session/session' + +export class SessionNotFoundError extends Error {} + +export class ChatSessionManager { + private static _instance: ChatSessionManager + private activeSession: Session | undefined + + constructor() {} + + public static get Instance() { + return this._instance || (this._instance = new this()) + } + + private createSession(): Session { + this.activeSession = new Session() + return this.activeSession + } + + public getSession(): Session { + if (this.activeSession === undefined) { + return this.createSession() + } + + return this.activeSession + } + + public setActiveTab(tabID: string): string { + if (this.activeSession !== undefined) { + if (!this.activeSession.isTabOpen()) { + this.activeSession.tabID = tabID + return tabID + } + return this.activeSession.tabID! + } + + throw new SessionNotFoundError() + } + + public removeActiveTab(): void { + if (this.activeSession !== undefined) { + if (this.activeSession.isTabOpen()) { + this.activeSession.tabID = undefined + return + } + } + } +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/views/actions/uiMessageListener.ts b/packages/amazonq/src/app/amazonqScan/chat/views/actions/uiMessageListener.ts new file mode 100644 index 00000000000..ede78d1a0bf --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/views/actions/uiMessageListener.ts @@ -0,0 +1,115 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageListener, ExtensionMessage } from 'aws-core-vscode/amazonq' +import { ScanChatControllerEventEmitters } from 'aws-core-vscode/amazonqScan' + +type UIMessage = ExtensionMessage & { + tabID?: string +} + +export interface UIMessageListenerProps { + readonly chatControllerEventEmitters: ScanChatControllerEventEmitters + readonly webViewMessageListener: MessageListener +} + +export class UIMessageListener { + private scanControllerEventsEmitters: ScanChatControllerEventEmitters | undefined + private webViewMessageListener: MessageListener + + constructor(props: UIMessageListenerProps) { + this.scanControllerEventsEmitters = props.chatControllerEventEmitters + this.webViewMessageListener = props.webViewMessageListener + + // Now we are listening to events that get sent from amazonq/webview/actions/actionListener (e.g. the tab) + this.webViewMessageListener.onMessage((msg) => { + this.handleMessage(msg) + }) + } + + private handleMessage(msg: ExtensionMessage) { + switch (msg.command) { + case 'new-tab-was-created': + this.tabOpened(msg) + break + case 'tab-was-removed': + this.tabClosed(msg) + break + case 'auth-follow-up-was-clicked': + this.authClicked(msg) + break + case 'review': + this.scan(msg) + break + case 'form-action-click': + this.formActionClicked(msg) + break + case 'follow-up-was-clicked': + this.followUpClicked(msg) + break + case 'response-body-link-click': + this.processResponseBodyLinkClick(msg) + break + case 'file-click': + this.processFileClick(msg) + break + } + } + + private scan(msg: UIMessage) { + this.scanControllerEventsEmitters?.runScan.fire({ + tabID: msg.tabID, + }) + } + + private formActionClicked(msg: UIMessage) { + this.scanControllerEventsEmitters?.formActionClicked.fire({ + ...msg, + }) + } + + private tabOpened(msg: UIMessage) { + this.scanControllerEventsEmitters?.tabOpened.fire({ + tabID: msg.tabID, + }) + } + + private tabClosed(msg: UIMessage) { + this.scanControllerEventsEmitters?.tabClosed.fire({ + tabID: msg.tabID, + }) + } + + private authClicked(msg: UIMessage) { + this.scanControllerEventsEmitters?.authClicked.fire({ + tabID: msg.tabID, + authType: msg.authType, + }) + } + + private followUpClicked(msg: UIMessage) { + this.scanControllerEventsEmitters?.followUpClicked.fire({ + followUp: msg.followUp, + tabID: msg.tabID, + }) + } + + private processResponseBodyLinkClick(msg: UIMessage) { + this.scanControllerEventsEmitters?.processResponseBodyLinkClick.fire({ + command: msg.command, + messageId: msg.messageId, + tabID: msg.tabID, + link: msg.link, + }) + } + + private processFileClick(msg: UIMessage) { + this.scanControllerEventsEmitters?.fileClicked.fire({ + tabID: msg.tabID, + messageId: msg.messageId, + filePath: msg.filePath, + }) + } +} diff --git a/packages/amazonq/src/app/amazonqScan/chat/views/connector/connector.ts b/packages/amazonq/src/app/amazonqScan/chat/views/connector/connector.ts new file mode 100644 index 00000000000..c906a401f91 --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/chat/views/connector/connector.ts @@ -0,0 +1,192 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthFollowUpType, MessagePublisher, ChatItemType } from 'aws-core-vscode/amazonq' +import { ScanMessageType } from 'aws-core-vscode/amazonqScan' +import { ChatItemButton, ProgressField, ChatItemAction, ChatItemContent } from '@aws/mynah-ui/dist/static' +import { scanChat } from '../../../models/constants' +import { MynahIcons } from '@aws/mynah-ui' + +class UiMessage { + readonly time: number = Date.now() + readonly sender: string = scanChat + readonly type: ScanMessageType = 'chatMessage' + readonly status: string = 'info' + + public constructor(protected tabID: string) {} +} + +export class AuthenticationUpdateMessage { + readonly time: number = Date.now() + readonly sender: string = scanChat + readonly type: ScanMessageType = 'authenticationUpdateMessage' + + constructor( + readonly scanEnabled: boolean, + readonly authenticatingTabIDs: string[] + ) {} +} + +export class AuthNeededException extends UiMessage { + override type: ScanMessageType = 'authNeededException' + + constructor( + readonly message: string, + readonly authType: AuthFollowUpType, + tabID: string + ) { + super(tabID) + } +} + +export interface ChatMessageProps { + readonly message: string | undefined + readonly messageId?: string | undefined + readonly messageType: ChatItemType + readonly canBeVoted?: boolean + readonly buttons?: ChatItemButton[] + readonly followUps?: ChatItemAction[] | undefined + readonly informationCard?: ChatItemContent['informationCard'] + readonly fileList?: ChatItemContent['fileList'] +} + +export class ChatMessage extends UiMessage { + readonly message: string | undefined + readonly messageId?: string | undefined + readonly messageType: ChatItemType + readonly canBeVoted?: boolean + readonly buttons: ChatItemButton[] + readonly followUps: ChatItemAction[] | undefined + readonly informationCard: ChatItemContent['informationCard'] + readonly fileList: ChatItemContent['fileList'] + override type: ScanMessageType = 'chatMessage' + + constructor(props: ChatMessageProps, tabID: string) { + super(tabID) + this.message = props.message + this.messageType = props.messageType + this.buttons = props.buttons || [] + this.messageId = props.messageId || undefined + this.followUps = props.followUps + this.informationCard = props.informationCard || undefined + this.fileList = props.fileList + this.canBeVoted = props.canBeVoted || undefined + } +} + +export class CapabilityCardMessage extends ChatMessage { + constructor(tabID: string) { + super( + { + message: '', + messageType: 'answer', + informationCard: { + title: '/review', + description: 'Included in your Q Developer subscription', + content: { + body: `I can review your workspace for vulnerabilities and issues. + +After you begin a review, I will: +1. Review all relevant code in your workspace or your current file +2. Provide a list of issues for your review + +You can then investigate, fix, or ignore issues. + +To learn more, check out our [User Guide](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security-scans.html).`, + }, + icon: 'bug' as MynahIcons, + }, + }, + tabID + ) + } +} + +export class ChatInputEnabledMessage extends UiMessage { + override type: ScanMessageType = 'chatInputEnabledMessage' + + constructor( + tabID: string, + readonly enabled: boolean + ) { + super(tabID) + } +} + +export class UpdatePlaceholderMessage extends UiMessage { + readonly newPlaceholder: string + override type: ScanMessageType = 'updatePlaceholderMessage' + + constructor(tabID: string, newPlaceholder: string) { + super(tabID) + this.newPlaceholder = newPlaceholder + } +} + +export class UpdatePromptProgressMessage extends UiMessage { + readonly progressField: ProgressField | null + override type: ScanMessageType = 'updatePromptProgress' + constructor(tabID: string, progressField: ProgressField | null) { + super(tabID) + this.progressField = progressField + } +} + +export class ErrorMessage extends UiMessage { + override type: ScanMessageType = 'errorMessage' + constructor( + readonly title: string, + readonly message: string, + tabID: string + ) { + super(tabID) + } +} + +export class ChatPrompt extends UiMessage { + readonly message: string | undefined + readonly messageType = 'system-prompt' + override type: ScanMessageType = 'chatPrompt' + constructor(message: string | undefined, tabID: string) { + super(tabID) + this.message = message + } +} + +export class AppToWebViewMessageDispatcher { + constructor(private readonly appsToWebViewMessagePublisher: MessagePublisher) {} + + public sendChatMessage(message: ChatMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendUpdatePlaceholder(message: UpdatePlaceholderMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendUpdatePromptProgress(message: UpdatePromptProgressMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendAuthenticationUpdate(message: AuthenticationUpdateMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendAuthNeededExceptionMessage(message: AuthNeededException) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendChatInputEnabled(message: ChatInputEnabledMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendErrorMessage(message: ErrorMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendPromptMessage(message: ChatPrompt) { + this.appsToWebViewMessagePublisher.publish(message) + } +} diff --git a/packages/amazonq/src/app/amazonqScan/index.ts b/packages/amazonq/src/app/amazonqScan/index.ts new file mode 100644 index 00000000000..c195193740b --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/index.ts @@ -0,0 +1,7 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export { default as MessengerUtils } from './chat/controller/messenger/messengerUtils' +export { init as scanChatAppInit } from './app' diff --git a/packages/amazonq/src/app/amazonqScan/models/constants.ts b/packages/amazonq/src/app/amazonqScan/models/constants.ts new file mode 100644 index 00000000000..93e815884e1 --- /dev/null +++ b/packages/amazonq/src/app/amazonqScan/models/constants.ts @@ -0,0 +1,99 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { ProgressField, MynahIcons, ChatItemButton } from '@aws/mynah-ui' +import { AggregatedCodeScanIssue, CodeAnalysisScope, SecurityScanStep, severities } from 'aws-core-vscode/codewhisperer' +import { i18n } from 'aws-core-vscode/shared' + +// For uniquely identifiying which chat messages should be routed to Scan +export const scanChat = 'scanChat' + +export enum ScanAction { + RUN_PROJECT_SCAN = 'runProjectScan', + RUN_FILE_SCAN = 'runFileScan', + STOP_PROJECT_SCAN = 'stopProjectScan', + STOP_FILE_SCAN = 'stopFileScan', +} + +export const cancelFileScanButton: ChatItemButton = { + id: ScanAction.STOP_FILE_SCAN, + text: i18n('AWS.generic.cancel'), + icon: 'cancel' as MynahIcons, +} + +export const cancelProjectScanButton: ChatItemButton = { + ...cancelFileScanButton, + id: ScanAction.STOP_PROJECT_SCAN, +} + +export const fileScanProgressField: ProgressField = { + status: 'default', + text: i18n('AWS.amazonq.scans.fileScanInProgress'), + value: -1, + actions: [cancelFileScanButton], +} + +export const projectScanProgressField: ProgressField = { + ...fileScanProgressField, + text: i18n('AWS.amazonq.scans.projectScanInProgress'), + actions: [cancelProjectScanButton], +} + +export const cancellingProgressField: ProgressField = { + status: 'warning', + text: i18n('AWS.generic.cancelling'), + value: -1, + actions: [], +} + +const checkIcons = { + wait: '☐', + current: '☐', + done: '☑', +} +export const scanProgressMessage = ( + currentStep: SecurityScanStep, + scope: CodeAnalysisScope, + fileName?: string +) => `Okay, I'm reviewing ${scope === CodeAnalysisScope.PROJECT ? 'your project' : fileName ? `\`${fileName}\`` : 'your file'} for code issues. + +This may take a few minutes. I'll share my progress here. + +${getIconForStep(SecurityScanStep.CREATE_SCAN_JOB, currentStep)} Initiating code review + +${getIconForStep(SecurityScanStep.POLL_SCAN_STATUS, currentStep)} Reviewing your code + +${getIconForStep(SecurityScanStep.PROCESS_SCAN_RESULTS, currentStep)} Processing review results +` + +export const scanSummaryMessage = ( + scope: CodeAnalysisScope, + securityRecommendationCollection: AggregatedCodeScanIssue[] +) => { + const severityCounts = securityRecommendationCollection.reduce( + (accumulator, current) => ({ + ...Object.fromEntries( + severities.map((severity) => [ + severity, + accumulator[severity] + + current.issues.filter((issue) => issue.severity === severity && issue.visible).length, + ]) + ), + }), + Object.fromEntries(severities.map((severity) => [severity, 0])) + ) + return `I completed the code review. I found the following issues in your ${scope === CodeAnalysisScope.PROJECT ? 'workspace' : 'file'}: +${Object.entries(severityCounts) + .map(([severity, count]) => `- ${severity}: \`${count} ${count === 1 ? 'issue' : 'issues'}\``) + .join('\n')} +` +} + +const getIconForStep = (targetStep: number, currentStep: number) => { + return currentStep === targetStep + ? checkIcons.current + : currentStep > targetStep + ? checkIcons.done + : checkIcons.wait +} diff --git a/packages/amazonq/src/app/chat/activation.ts b/packages/amazonq/src/app/chat/activation.ts index 5edd9affdcd..f7b3f9a0fa5 100644 --- a/packages/amazonq/src/app/chat/activation.ts +++ b/packages/amazonq/src/app/chat/activation.ts @@ -9,6 +9,7 @@ import { telemetry } from 'aws-core-vscode/telemetry' import { AuthUtil, CodeWhispererSettings } from 'aws-core-vscode/codewhisperer' import { Commands, placeholder, funcUtil } from 'aws-core-vscode/shared' import * as amazonq from 'aws-core-vscode/amazonq' +import { scanChatAppInit } from '../amazonqScan' import { init as inlineChatInit } from '../../inlineChat/app' export async function activate(context: ExtensionContext) { @@ -69,6 +70,9 @@ function registerApps(appInitContext: amazonq.AmazonQAppInitContext, context: Ex amazonq.cwChatAppInit(appInitContext) amazonq.featureDevChatAppInit(appInitContext) amazonq.gumbyChatAppInit(appInitContext) + amazonq.testChatAppInit(appInitContext) + scanChatAppInit(appInitContext) + amazonq.docChatAppInit(appInitContext) inlineChatInit(context) } diff --git a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts index a96da96c199..5b830834743 100644 --- a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts +++ b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts @@ -9,7 +9,8 @@ import sinon from 'sinon' import { registerAuthHook, using } from 'aws-core-vscode/test' import { loginToIdC } from './utils/setup' import { Messenger } from './framework/messenger' -import { FollowUpTypes, examples } from 'aws-core-vscode/amazonqFeatureDev' +import { examples } from 'aws-core-vscode/amazonqFeatureDev' +import { FollowUpTypes } from 'aws-core-vscode/amazonq' import { sleep } from 'aws-core-vscode/shared' describe('Amazon Q Feature Dev', function () { diff --git a/packages/amazonq/test/e2e/amazonq/framework/messenger.ts b/packages/amazonq/test/e2e/amazonq/framework/messenger.ts index 28b34aa3bdb..353bd3b4a9c 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/messenger.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/messenger.ts @@ -6,7 +6,7 @@ import assert from 'assert' import { MynahUI, MynahUIProps, MynahUIDataModel } from '@aws/mynah-ui' import { waitUntil } from 'aws-core-vscode/shared' -import { FollowUpTypes } from 'aws-core-vscode/amazonqFeatureDev' +import { FollowUpTypes } from 'aws-core-vscode/amazonq' export interface MessengerOptions { waitIntervalInMs?: number diff --git a/packages/amazonq/test/unit/amazonqFeatureDev/session/chatSessionStorage.test.ts b/packages/amazonq/test/unit/amazonqFeatureDev/session/chatSessionStorage.test.ts index 29a78c552ce..4c6073114f8 100644 --- a/packages/amazonq/test/unit/amazonqFeatureDev/session/chatSessionStorage.test.ts +++ b/packages/amazonq/test/unit/amazonqFeatureDev/session/chatSessionStorage.test.ts @@ -3,18 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ import * as assert from 'assert' - -import { Messenger, ChatSessionStorage } from 'aws-core-vscode/amazonqFeatureDev' +import { FeatureDevChatSessionStorage } from 'aws-core-vscode/amazonqFeatureDev' +import { Messenger } from 'aws-core-vscode/amazonq' import { createMessenger } from 'aws-core-vscode/test' describe('chatSession', () => { const tabID = '1234' - let chatStorage: ChatSessionStorage + let chatStorage: FeatureDevChatSessionStorage let messenger: Messenger beforeEach(() => { messenger = createMessenger() - chatStorage = new ChatSessionStorage(messenger) + chatStorage = new FeatureDevChatSessionStorage(messenger) }) it('locks getSession', async () => { diff --git a/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts b/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts index b79f0c4bf4f..a7a5d831f67 100644 --- a/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts +++ b/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts @@ -18,7 +18,8 @@ import { sessionWriteFile, assertTelemetry, } from 'aws-core-vscode/test' -import { CurrentWsFolders, CodeGenState, FeatureDevClient, Messenger } from 'aws-core-vscode/amazonqFeatureDev' +import { CurrentWsFolders, CodeGenState, FeatureDevClient, featureDevScheme } from 'aws-core-vscode/amazonqFeatureDev' +import { Messenger } from 'aws-core-vscode/amazonq' import path from 'path' import { fs } from 'aws-core-vscode/shared' @@ -36,7 +37,7 @@ describe('session', () => { describe('preloader', () => { it('emits start chat telemetry', async () => { - const session = await createSession({ messenger, conversationID }) + const session = await createSession({ messenger, conversationID, scheme: featureDevScheme }) await session.preloader('implement twosum in typescript') @@ -63,7 +64,7 @@ describe('session', () => { const tabID = '123' const workspaceFolders = [controllerSetup.workspaceFolder] as CurrentWsFolders workspaceFolderUriFsPath = controllerSetup.workspaceFolder.uri.fsPath - uri = generateVirtualMemoryUri(uploadID, notRejectedFileName) + uri = generateVirtualMemoryUri(uploadID, notRejectedFileName, featureDevScheme) const testConfig = { conversationId: conversationID, @@ -90,7 +91,7 @@ describe('session', () => { relativePath: 'rejectedFile.js', fileContent: 'rejectedFileContent', rejected: true, - virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'rejectedFile.js'), + virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'rejectedFile.js', featureDevScheme), workspaceFolder: controllerSetup.workspaceFolder, changeApplied: false, }, @@ -101,7 +102,12 @@ describe('session', () => { 0, {} ) - const session = await createSession({ messenger, sessionState: codeGenState, conversationID }) + const session = await createSession({ + messenger, + sessionState: codeGenState, + conversationID, + scheme: featureDevScheme, + }) encodedContent = new TextEncoder().encode(notRejectedFileContent) await sessionRegisterProvider(session, uri, encodedContent) return session diff --git a/packages/amazonq/test/unit/codewhisperer/models/model.test.ts b/packages/amazonq/test/unit/codewhisperer/models/model.test.ts new file mode 100644 index 00000000000..ae7114a22c8 --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/models/model.test.ts @@ -0,0 +1,73 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import sinon from 'sinon' +import { SecurityIssueFilters, SecurityTreeViewFilterState } from 'aws-core-vscode/codewhisperer' +import { globals } from 'aws-core-vscode/shared' + +describe('model', function () { + describe('SecurityTreeViewFilterState', function () { + let securityTreeViewFilterState: SecurityTreeViewFilterState + + beforeEach(function () { + securityTreeViewFilterState = SecurityTreeViewFilterState.instance + }) + + afterEach(function () { + sinon.restore() + }) + + it('should get the state', async function () { + const state: SecurityIssueFilters = { + severity: { + Critical: false, + High: true, + Medium: true, + Low: true, + Info: true, + }, + } + await globals.globalState.update('aws.amazonq.securityIssueFilters', state) + assert.deepStrictEqual(securityTreeViewFilterState.getState(), state) + }) + + it('should set the state', async function () { + await globals.globalState.update('aws.amazonq.securityIssueFilters', { + severity: { + Critical: true, + High: true, + Medium: true, + Low: true, + Info: true, + }, + } satisfies SecurityIssueFilters) + const state = { + severity: { + Critical: false, + High: true, + Medium: true, + Low: true, + Info: true, + }, + } satisfies SecurityIssueFilters + await securityTreeViewFilterState.setState(state) + assert.deepStrictEqual(globals.globalState.get('aws.amazonq.securityIssueFilters'), state) + }) + + it('should get hidden severities', async function () { + await globals.globalState.update('aws.amazonq.securityIssueFilters', { + severity: { + Critical: true, + High: false, + Medium: true, + Low: false, + Info: true, + }, + } satisfies SecurityIssueFilters) + const hiddenSeverities = securityTreeViewFilterState.getHiddenSeverities() + assert.deepStrictEqual(hiddenSeverities, ['High', 'Low']) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueCodeActionProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueCodeActionProvider.test.ts index 3015fef6ff1..3ac473cbcca 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityIssueCodeActionProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueCodeActionProvider.test.ts @@ -6,15 +6,17 @@ import * as vscode from 'vscode' import { createCodeActionContext, createCodeScanIssue, createMockDocument } from 'aws-core-vscode/test' import assert from 'assert' -import { SecurityIssueCodeActionProvider } from 'aws-core-vscode/codewhisperer' +import { SecurityIssueCodeActionProvider, SecurityIssueProvider } from 'aws-core-vscode/codewhisperer' describe('securityIssueCodeActionProvider', () => { + let securityIssueProvider: SecurityIssueProvider let securityIssueCodeActionProvider: SecurityIssueCodeActionProvider let mockDocument: vscode.TextDocument let context: vscode.CodeActionContext let token: vscode.CancellationTokenSource beforeEach(() => { + securityIssueProvider = SecurityIssueProvider.instance securityIssueCodeActionProvider = new SecurityIssueCodeActionProvider() mockDocument = createMockDocument('def two_sum(nums, target):\nfor', 'test.py', 'python') context = createCodeActionContext() @@ -22,7 +24,7 @@ describe('securityIssueCodeActionProvider', () => { }) it('should provide quick fix for each issue that has a suggested fix', () => { - securityIssueCodeActionProvider.issues = [ + securityIssueProvider.issues = [ { filePath: mockDocument.fileName, issues: [createCodeScanIssue({ title: 'issue 1' }), createCodeScanIssue({ title: 'issue 2' })], @@ -31,23 +33,27 @@ describe('securityIssueCodeActionProvider', () => { const range = new vscode.Range(0, 0, 0, 0) const actual = securityIssueCodeActionProvider.provideCodeActions(mockDocument, range, context, token.token) - assert.strictEqual(actual.length, 6) + assert.strictEqual(actual.length, 10) assert.strictEqual(actual[0].title, 'Amazon Q: Fix "issue 1"') assert.strictEqual(actual[0].kind, vscode.CodeActionKind.QuickFix) assert.strictEqual(actual[1].title, 'Amazon Q: View details for "issue 1"') assert.strictEqual(actual[1].kind, vscode.CodeActionKind.QuickFix) assert.strictEqual(actual[2].title, 'Amazon Q: Explain "issue 1"') assert.strictEqual(actual[2].kind, vscode.CodeActionKind.QuickFix) - assert.strictEqual(actual[3].title, 'Amazon Q: Fix "issue 2"') + assert.strictEqual(actual[3].title, 'Amazon Q: Ignore this "issue 1" issue') assert.strictEqual(actual[3].kind, vscode.CodeActionKind.QuickFix) - assert.strictEqual(actual[4].title, 'Amazon Q: View details for "issue 2"') + assert.strictEqual(actual[4].title, 'Amazon Q: Ignore all "issue 1" issues') assert.strictEqual(actual[4].kind, vscode.CodeActionKind.QuickFix) - assert.strictEqual(actual[5].title, 'Amazon Q: Explain "issue 2"') + assert.strictEqual(actual[5].title, 'Amazon Q: Fix "issue 2"') assert.strictEqual(actual[5].kind, vscode.CodeActionKind.QuickFix) + assert.strictEqual(actual[6].title, 'Amazon Q: View details for "issue 2"') + assert.strictEqual(actual[6].kind, vscode.CodeActionKind.QuickFix) + assert.strictEqual(actual[7].title, 'Amazon Q: Explain "issue 2"') + assert.strictEqual(actual[7].kind, vscode.CodeActionKind.QuickFix) }) it('should not provide quick fix if the issue does not have a suggested fix', () => { - securityIssueCodeActionProvider.issues = [ + securityIssueProvider.issues = [ { filePath: mockDocument.fileName, issues: [createCodeScanIssue({ title: 'issue 1', suggestedFixes: [] })], @@ -56,15 +62,19 @@ describe('securityIssueCodeActionProvider', () => { const range = new vscode.Range(0, 0, 0, 0) const actual = securityIssueCodeActionProvider.provideCodeActions(mockDocument, range, context, token.token) - assert.strictEqual(actual.length, 2) + assert.strictEqual(actual.length, 4) assert.strictEqual(actual[0].title, 'Amazon Q: View details for "issue 1"') assert.strictEqual(actual[0].kind, vscode.CodeActionKind.QuickFix) assert.strictEqual(actual[1].title, 'Amazon Q: Explain "issue 1"') assert.strictEqual(actual[1].kind, vscode.CodeActionKind.QuickFix) + assert.strictEqual(actual[2].title, 'Amazon Q: Ignore this "issue 1" issue') + assert.strictEqual(actual[2].kind, vscode.CodeActionKind.QuickFix) + assert.strictEqual(actual[3].title, 'Amazon Q: Ignore all "issue 1" issues') + assert.strictEqual(actual[3].kind, vscode.CodeActionKind.QuickFix) }) it('should skip issues not in the current file', () => { - securityIssueCodeActionProvider.issues = [ + securityIssueProvider.issues = [ { filePath: 'some/path', issues: [createCodeScanIssue({ title: 'issue 1' })], @@ -77,9 +87,24 @@ describe('securityIssueCodeActionProvider', () => { const range = new vscode.Range(0, 0, 0, 0) const actual = securityIssueCodeActionProvider.provideCodeActions(mockDocument, range, context, token.token) - assert.strictEqual(actual.length, 3) + assert.strictEqual(actual.length, 5) assert.strictEqual(actual[0].title, 'Amazon Q: Fix "issue 2"') assert.strictEqual(actual[1].title, 'Amazon Q: View details for "issue 2"') assert.strictEqual(actual[2].title, 'Amazon Q: Explain "issue 2"') + assert.strictEqual(actual[3].title, 'Amazon Q: Ignore this "issue 2" issue') + assert.strictEqual(actual[4].title, 'Amazon Q: Ignore all "issue 2" issues') + }) + + it('should not show issues that are not visible', () => { + securityIssueProvider.issues = [ + { + filePath: mockDocument.fileName, + issues: [createCodeScanIssue({ visible: false })], + }, + ] + const range = new vscode.Range(0, 0, 0, 0) + const actual = securityIssueCodeActionProvider.provideCodeActions(mockDocument, range, context, token.token) + + assert.strictEqual(actual.length, 0) }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts index 162f7534218..956c3b43d73 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueHoverProvider.test.ts @@ -4,16 +4,18 @@ */ import * as vscode from 'vscode' -import { SecurityIssueHoverProvider } from 'aws-core-vscode/codewhisperer' +import { SecurityIssueHoverProvider, SecurityIssueProvider } from 'aws-core-vscode/codewhisperer' import { createCodeScanIssue, createMockDocument, assertTelemetry } from 'aws-core-vscode/test' import assert from 'assert' describe('securityIssueHoverProvider', () => { + let securityIssueProvider: SecurityIssueProvider let securityIssueHoverProvider: SecurityIssueHoverProvider let mockDocument: vscode.TextDocument let token: vscode.CancellationTokenSource beforeEach(() => { + securityIssueProvider = SecurityIssueProvider.instance securityIssueHoverProvider = new SecurityIssueHoverProvider() mockDocument = createMockDocument('def two_sum(nums, target):\nfor', 'test.py', 'python') token = new vscode.CancellationTokenSource() @@ -30,7 +32,7 @@ describe('securityIssueHoverProvider', () => { }), ] - securityIssueHoverProvider.issues = [ + securityIssueProvider.issues = [ { filePath: mockDocument.fileName, issues, @@ -46,10 +48,16 @@ describe('securityIssueHoverProvider', () => { 'fix\n\n' + `[$(eye) View Details](command:aws.amazonq.openSecurityIssuePanel?${encodeURIComponent( JSON.stringify([issues[0], mockDocument.fileName]) - )} 'Open "Amazon Q Security Issue"')\n` + + )} 'Open "Code Issue Details"')\n` + ` | [$(comment) Explain](command:aws.amazonq.explainIssue?${encodeURIComponent( JSON.stringify([issues[0]]) )} 'Explain with Amazon Q')\n` + + ` | [$(error) Ignore](command:aws.amazonq.security.ignore?${encodeURIComponent( + JSON.stringify([issues[0], mockDocument.fileName, 'hover']) + )} 'Ignore Issue')\n` + + ` | [$(error) Ignore All](command:aws.amazonq.security.ignoreAll?${encodeURIComponent( + JSON.stringify([issues[0], 'hover']) + )} 'Ignore Similar Issues')\n` + ` | [$(wrench) Fix](command:aws.amazonq.applySecurityFix?${encodeURIComponent( JSON.stringify([issues[0], mockDocument.fileName, 'hover']) )} 'Fix with Amazon Q')\n` + @@ -90,10 +98,16 @@ describe('securityIssueHoverProvider', () => { 'recommendationText\n\n' + `[$(eye) View Details](command:aws.amazonq.openSecurityIssuePanel?${encodeURIComponent( JSON.stringify([issues[1], mockDocument.fileName]) - )} 'Open "Amazon Q Security Issue"')\n` + + )} 'Open "Code Issue Details"')\n` + ` | [$(comment) Explain](command:aws.amazonq.explainIssue?${encodeURIComponent( JSON.stringify([issues[1]]) - )} 'Explain with Amazon Q')\n` + )} 'Explain with Amazon Q')\n` + + ` | [$(error) Ignore](command:aws.amazonq.security.ignore?${encodeURIComponent( + JSON.stringify([issues[1], mockDocument.fileName, 'hover']) + )} 'Ignore Issue')\n` + + ` | [$(error) Ignore All](command:aws.amazonq.security.ignoreAll?${encodeURIComponent( + JSON.stringify([issues[1], 'hover']) + )} 'Ignore Similar Issues')\n` ) assertTelemetry('codewhisperer_codeScanIssueHover', [ { findingId: 'finding-1', detectorId: 'language/detector-1', ruleId: 'Rule-123', includesFix: true }, @@ -102,7 +116,7 @@ describe('securityIssueHoverProvider', () => { }) it('should return empty contents if there is no issue on the current position', () => { - securityIssueHoverProvider.issues = [ + securityIssueProvider.issues = [ { filePath: mockDocument.fileName, issues: [createCodeScanIssue()], @@ -114,7 +128,7 @@ describe('securityIssueHoverProvider', () => { }) it('should skip issues not in the current file', () => { - securityIssueHoverProvider.issues = [ + securityIssueProvider.issues = [ { filePath: 'some/path', issues: [createCodeScanIssue()], @@ -130,7 +144,7 @@ describe('securityIssueHoverProvider', () => { it('should not show severity badge if undefined', () => { const issues = [createCodeScanIssue({ severity: undefined, suggestedFixes: [] })] - securityIssueHoverProvider.issues = [ + securityIssueProvider.issues = [ { filePath: mockDocument.fileName, issues, @@ -144,10 +158,16 @@ describe('securityIssueHoverProvider', () => { 'recommendationText\n\n' + `[$(eye) View Details](command:aws.amazonq.openSecurityIssuePanel?${encodeURIComponent( JSON.stringify([issues[0], mockDocument.fileName]) - )} 'Open "Amazon Q Security Issue"')\n` + + )} 'Open "Code Issue Details"')\n` + ` | [$(comment) Explain](command:aws.amazonq.explainIssue?${encodeURIComponent( JSON.stringify([issues[0]]) - )} 'Explain with Amazon Q')\n` + )} 'Explain with Amazon Q')\n` + + ` | [$(error) Ignore](command:aws.amazonq.security.ignore?${encodeURIComponent( + JSON.stringify([issues[0], mockDocument.fileName, 'hover']) + )} 'Ignore Issue')\n` + + ` | [$(error) Ignore All](command:aws.amazonq.security.ignoreAll?${encodeURIComponent( + JSON.stringify([issues[0], 'hover']) + )} 'Ignore Similar Issues')\n` ) }) @@ -162,7 +182,7 @@ describe('securityIssueHoverProvider', () => { ], }), ] - securityIssueHoverProvider.issues = [ + securityIssueProvider.issues = [ { filePath: mockDocument.fileName, issues, @@ -176,10 +196,16 @@ describe('securityIssueHoverProvider', () => { 'fix\n\n' + `[$(eye) View Details](command:aws.amazonq.openSecurityIssuePanel?${encodeURIComponent( JSON.stringify([issues[0], mockDocument.fileName]) - )} 'Open "Amazon Q Security Issue"')\n` + + )} 'Open "Code Issue Details"')\n` + ` | [$(comment) Explain](command:aws.amazonq.explainIssue?${encodeURIComponent( JSON.stringify([issues[0]]) )} 'Explain with Amazon Q')\n` + + ` | [$(error) Ignore](command:aws.amazonq.security.ignore?${encodeURIComponent( + JSON.stringify([issues[0], mockDocument.fileName, 'hover']) + )} 'Ignore Issue')\n` + + ` | [$(error) Ignore All](command:aws.amazonq.security.ignoreAll?${encodeURIComponent( + JSON.stringify([issues[0], 'hover']) + )} 'Ignore Similar Issues')\n` + ` | [$(wrench) Fix](command:aws.amazonq.applySecurityFix?${encodeURIComponent( JSON.stringify([issues[0], mockDocument.fileName, 'hover']) )} 'Fix with Amazon Q')\n` + @@ -216,4 +242,16 @@ describe('securityIssueHoverProvider', () => { '\n\n' ) }) + + it('should not show issues that are not visible', () => { + const issues = [createCodeScanIssue({ visible: false })] + securityIssueProvider.issues = [ + { + filePath: mockDocument.fileName, + issues, + }, + ] + const actual = securityIssueHoverProvider.provideHover(mockDocument, new vscode.Position(0, 0), token.token) + assert.strictEqual(actual.contents.length, 0) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueProvider.test.ts index 35af4440db1..cbe4daed9fb 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityIssueProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueProvider.test.ts @@ -37,14 +37,14 @@ describe('securityIssueProvider', () => { ] assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 1) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 2) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) const changeEvent = createTextDocumentChangeEvent(mockDocument, new vscode.Range(0, 0, 0, 0), '\n') mockProvider.handleDocumentChange(changeEvent) assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 2) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 3) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -2,1 +2,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -2,1 +2,1 @@')) }) it('does not move the issue if the document changed below the line', () => { @@ -52,14 +52,14 @@ describe('securityIssueProvider', () => { assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 0) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 1) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) const changeEvent = createTextDocumentChangeEvent(mockDocument, new vscode.Range(2, 0, 2, 0), '\n') mockProvider.handleDocumentChange(changeEvent) assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 0) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 1) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) }) it('should do nothing if no content changes', () => { @@ -69,7 +69,7 @@ describe('securityIssueProvider', () => { assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 1) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 2) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) const changeEvent = createTextDocumentChangeEvent(mockDocument, new vscode.Range(0, 0, 0, 0), '') changeEvent.contentChanges = [] @@ -77,21 +77,21 @@ describe('securityIssueProvider', () => { assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 1) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 2) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) }) it('should do nothing if file path does not match', () => { mockProvider.issues = [{ filePath: 'some/path', issues: [createCodeScanIssue({ startLine: 1, endLine: 2 })] }] assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 1) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 2) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) const changeEvent = createTextDocumentChangeEvent(mockDocument, new vscode.Range(0, 0, 0, 0), '\n') mockProvider.handleDocumentChange(changeEvent) assert.strictEqual(mockProvider.issues[0].issues[0].startLine, 1) assert.strictEqual(mockProvider.issues[0].issues[0].endLine, 2) - assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code.startsWith('@@ -1,1 +1,1 @@')) + assert.ok(mockProvider.issues[0].issues[0].suggestedFixes[0].code?.startsWith('@@ -1,1 +1,1 @@')) }) describe('removeIssue', () => { diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts new file mode 100644 index 00000000000..bd7c3aab8de --- /dev/null +++ b/packages/amazonq/test/unit/codewhisperer/service/securityIssueTreeViewProvider.test.ts @@ -0,0 +1,106 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + FileItem, + IssueItem, + SecurityIssueTreeViewProvider, + SecurityTreeViewFilterState, + SecurityIssueProvider, + SeverityItem, +} from 'aws-core-vscode/codewhisperer' +import { createCodeScanIssue } from 'aws-core-vscode/test' +import assert from 'assert' +import sinon from 'sinon' + +describe('SecurityIssueTreeViewProvider', function () { + let securityIssueProvider: SecurityIssueProvider + let securityIssueTreeViewProvider: SecurityIssueTreeViewProvider + + beforeEach(function () { + securityIssueProvider = SecurityIssueProvider.instance + securityIssueTreeViewProvider = new SecurityIssueTreeViewProvider() + }) + + afterEach(function () { + sinon.restore() + }) + + describe('getTreeItem', function () { + it('should return the element as a FileItem', function () { + const element = new FileItem('dummy-path', []) + const result = securityIssueTreeViewProvider.getTreeItem(element) + assert.strictEqual(result, element) + }) + + it('should return the element as a IssueItem', function () { + const element = new IssueItem('dummy-path', createCodeScanIssue()) + const result = securityIssueTreeViewProvider.getTreeItem(element) + assert.strictEqual(result, element) + }) + }) + + 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) + assert.strictEqual(result[0].label, 'Critical') + assert.strictEqual(result[0].description, '0 issues') + assert.strictEqual(result[1].label, 'High') + assert.strictEqual(result[1].description, '8 issues') + assert.strictEqual(result[2].label, 'Medium') + assert.strictEqual(result[2].description, '0 issues') + assert.strictEqual(result[3].label, 'Low') + assert.strictEqual(result[3].description, '0 issues') + assert.strictEqual(result[4].label, 'Info') + assert.strictEqual(result[4].description, '0 issues') + }) + + it('should return sorted list of issues if element is SeverityItem', function () { + const element = new SeverityItem('Critical', [ + { + ...createCodeScanIssue({ title: 'Finding A', startLine: 10, severity: 'Critical' }), + filePath: 'file/path/a', + }, + { + ...createCodeScanIssue({ title: 'Finding B', startLine: 2, severity: 'Critical' }), + filePath: 'file/path/b', + }, + ]) + const result = securityIssueTreeViewProvider.getChildren(element) as IssueItem[] + assert.strictEqual(result.length, 2) + assert.strictEqual(result[0].label, 'Finding A') + assert.strictEqual(result[1].label, 'Finding B') + }) + + it('should filter out severities', function () { + const element = undefined + let result = securityIssueTreeViewProvider.getChildren(element) as SeverityItem[] + assert.strictEqual(result.length, 5) + + sinon.stub(SecurityTreeViewFilterState.instance, 'getHiddenSeverities').returns(['Medium']) + + result = securityIssueTreeViewProvider.getChildren(element) as SeverityItem[] + assert.strictEqual(result.length, 4) + assert.ok(result.every((item) => item.severity !== 'Medium')) + }) + + it('should not show issues that are not visible', function () { + const element = new SeverityItem('Critical', [ + { ...createCodeScanIssue({ visible: false }), filePath: 'file/path/a' }, + ]) + const result = securityIssueTreeViewProvider.getChildren(element) as IssueItem[] + assert.strictEqual(result.length, 0) + }) + }) +}) diff --git a/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts b/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts index d9f492e6128..b0086b2a205 100644 --- a/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/service/securityScanHandler.test.ts @@ -19,33 +19,35 @@ import sinon from 'sinon' import * as vscode from 'vscode' import fs from 'fs' // eslint-disable-line no-restricted-imports -const mockCodeScanFindings = JSON.stringify([ - { - filePath: 'workspaceFolder/python3.7-plain-sam-app/hello_world/app.py', - startLine: 1, - endLine: 1, - title: 'title', - description: { +const buildRawCodeScanIssue = (params?: Partial): RawCodeScanIssue => ({ + filePath: 'workspaceFolder/python3.7-plain-sam-app/hello_world/app.py', + startLine: 1, + endLine: 1, + title: 'title', + description: { + text: 'text', + markdown: 'markdown', + }, + detectorId: 'detectorId', + detectorName: 'detectorName', + findingId: 'findingId', + relatedVulnerabilities: [], + severity: 'High', + remediation: { + recommendation: { text: 'text', - markdown: 'markdown', - }, - detectorId: 'detectorId', - detectorName: 'detectorName', - findingId: 'findingId', - relatedVulnerabilities: [], - severity: 'High', - remediation: { - recommendation: { - text: 'text', - url: 'url', - }, - suggestedFixes: [], + url: 'url', }, - codeSnippet: [], - } satisfies RawCodeScanIssue, -]) + suggestedFixes: [], + }, + codeSnippet: [], + ...params, +}) -const mockListCodeScanFindingsResponse: Awaited>> = { +const buildMockListCodeScanFindingsResponse = ( + codeScanFindings: string = JSON.stringify([buildRawCodeScanIssue()]), + nextToken?: boolean +): Awaited>> => ({ $response: { hasNextPage: () => false, nextPage: () => undefined, @@ -56,16 +58,9 @@ const mockListCodeScanFindingsResponse: Awaited> -> = { - ...mockListCodeScanFindingsResponse, - nextToken: 'nextToken', -} + codeScanFindings, + nextToken: nextToken ? 'nextToken' : undefined, +}) describe('securityScanHandler', function () { describe('listScanResults', function () { @@ -81,7 +76,7 @@ describe('securityScanHandler', function () { }) it('should make ListCodeScanFindings request and aggregate findings by file path', async function () { - mockClient.listCodeScanFindings.resolves(mockListCodeScanFindingsResponse) + mockClient.listCodeScanFindings.resolves(buildMockListCodeScanFindingsResponse()) const aggregatedCodeScanIssueList = await listScanResults( mockClient, @@ -100,11 +95,26 @@ describe('securityScanHandler', function () { it('should handle ListCodeScanFindings request with paginated response', async function () { mockClient.listCodeScanFindings .onFirstCall() - .resolves(mockListCodeScanFindingsPaginatedResponse) + .resolves( + buildMockListCodeScanFindingsResponse( + JSON.stringify([buildRawCodeScanIssue({ title: 'title1' })]), + true + ) + ) .onSecondCall() - .resolves(mockListCodeScanFindingsPaginatedResponse) + .resolves( + buildMockListCodeScanFindingsResponse( + JSON.stringify([buildRawCodeScanIssue({ title: 'title2' })]), + true + ) + ) .onThirdCall() - .resolves(mockListCodeScanFindingsResponse) + .resolves( + buildMockListCodeScanFindingsResponse( + JSON.stringify([buildRawCodeScanIssue({ title: 'title3' })]), + false + ) + ) const aggregatedCodeScanIssueList = await listScanResults( mockClient, @@ -154,7 +164,7 @@ describe('securityScanHandler', function () { { filePath: 'file2.ts', startLine: 1, endLine: 1, codeSnippet: [{ number: 1, content: 'line 1' }] }, ]) - mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE) + mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE_AUTO) assert.equal(codeScanIssueMap.size, 2) assert.equal(codeScanIssueMap.get('file1.ts')?.length, 1) @@ -175,7 +185,7 @@ describe('securityScanHandler', function () { { filePath: 'file1.ts', startLine: 3, endLine: 3, codeSnippet: [{ number: 3, content: 'line 3' }] }, ]) - mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE) + mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE_AUTO) assert.equal(codeScanIssueMap.size, 1) assert.equal(codeScanIssueMap.get('file1.ts')?.length, 2) @@ -195,7 +205,36 @@ describe('securityScanHandler', function () { { filePath: 'file1.ts', startLine: 3, endLine: 3, codeSnippet: [{ number: 3, content: '**** **' }] }, ]) - mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE) + mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE_AUTO) + assert.strictEqual(codeScanIssueMap.size, 1) + assert.strictEqual(codeScanIssueMap.get('file1.ts')?.length, 1) + }) + + it('should handle duplicate issues', function () { + const json = JSON.stringify([ + { + filePath: 'file1.ts', + startLine: 1, + endLine: 2, + title: 'duplicate issue', + codeSnippet: [ + { number: 1, content: 'line 1' }, + { number: 2, content: 'line 2' }, + ], + }, + { + filePath: 'file1.ts', + startLine: 1, + endLine: 2, + title: 'duplicate issue', + codeSnippet: [ + { number: 1, content: 'line 1' }, + { number: 2, content: 'line 2' }, + ], + }, + ]) + + mapToAggregatedList(codeScanIssueMap, json, editor, CodeAnalysisScope.FILE_AUTO) assert.strictEqual(codeScanIssueMap.size, 1) assert.strictEqual(codeScanIssueMap.get('file1.ts')?.length, 1) }) diff --git a/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts index a5d79bb2fe3..a4a03a5236a 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/zipUtil.test.ts @@ -11,6 +11,9 @@ import { getTestWorkspaceFolder } from 'aws-core-vscode/test' import { CodeAnalysisScope, ZipUtil } from 'aws-core-vscode/codewhisperer' import { codeScanTruncDirPrefix } from 'aws-core-vscode/codewhisperer' import { ToolkitError } from 'aws-core-vscode/shared' +import { LspClient } from 'aws-core-vscode/amazonq' +import { fs } from 'aws-core-vscode/shared' +import path from 'path' describe('zipUtil', function () { const workspaceFolder = getTestWorkspaceFolder() @@ -22,6 +25,11 @@ describe('zipUtil', function () { const zipUtil = new ZipUtil() assert.deepStrictEqual(zipUtil.getProjectPaths(), [workspaceFolder]) }) + + it('Should return the correct project path for unit test generation', function () { + const zipUtil = new ZipUtil() + assert.deepStrictEqual(zipUtil.getProjectPath(appCodePath), workspaceFolder) + }) }) describe('generateZip', function () { @@ -34,7 +42,7 @@ describe('zipUtil', function () { }) it('Should generate zip for file scan and return expected metadata', async function () { - const zipMetadata = await zipUtil.generateZip(vscode.Uri.file(appCodePath), CodeAnalysisScope.FILE) + const zipMetadata = await zipUtil.generateZip(vscode.Uri.file(appCodePath), CodeAnalysisScope.FILE_AUTO) assert.strictEqual(zipMetadata.lines, 49) assert.ok(zipMetadata.rootDir.includes(codeScanTruncDirPrefix)) assert.ok(zipMetadata.srcPayloadSizeInBytes > 0) @@ -48,7 +56,7 @@ describe('zipUtil', function () { sinon.stub(zipUtil, 'reachSizeLimit').returns(true) await assert.rejects( - () => zipUtil.generateZip(vscode.Uri.file(appCodePath), CodeAnalysisScope.FILE), + () => zipUtil.generateZip(vscode.Uri.file(appCodePath), CodeAnalysisScope.FILE_AUTO), new ToolkitError(`Payload size limit reached`, { code: 'FileSizeExceeded' }) ) }) @@ -105,4 +113,133 @@ describe('zipUtil', function () { assert.equal(zipMetadata2.lines, zipMetadata.lines + 1) }) }) + + describe('generateZipTestGen', function () { + let zipUtil: ZipUtil + let mockFs: sinon.SinonStubbedInstance + const projectPath = '/test/project' + const zipDirPath = '/test/zip' + const zipFilePath = '/test/zip/test.zip' + + beforeEach(function () { + zipUtil = new ZipUtil() + mockFs = sinon.stub(fs) + + const mockRepoMapPath = '/path/to/repoMapData.json' + mockFs.exists.withArgs(mockRepoMapPath).resolves(true) + sinon.stub(LspClient, 'instance').get(() => ({ + getRepoMapJSON: sinon.stub().resolves(mockRepoMapPath), + })) + + sinon.stub(zipUtil, 'getZipDirPath').returns(zipDirPath) + sinon.stub(zipUtil as any, 'zipProject').resolves(zipFilePath) + }) + + afterEach(function () { + sinon.restore() + }) + + it('Should generate zip for test generation successfully', async function () { + mockFs.stat.resolves({ + type: vscode.FileType.File, + size: 1000, + ctime: Date.now(), + mtime: Date.now(), + } as vscode.FileStat) + + mockFs.readFileBytes.resolves(Buffer.from('test content')) + + // Fix: Create a Set from the array + zipUtil['_totalSize'] = 500 + zipUtil['_totalBuildSize'] = 200 + zipUtil['_totalLines'] = 100 + zipUtil['_language'] = 'typescript' + zipUtil['_pickedSourceFiles'] = new Set(['file1.ts', 'file2.ts']) + + const result = await zipUtil.generateZipTestGen(projectPath, false) + + assert.ok(mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir'))) + assert.ok( + mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir', 'buildAndExecuteLogDir')) + ) + assert.ok(mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir', 'repoMapData'))) + assert.ok(mockFs.mkdir.calledWith(path.join(zipDirPath, 'utgRequiredArtifactsDir', 'testCoverageDir'))) + + // assert.ok( + // mockFs.copy.calledWith( + // '/path/to/repoMapData.json', + // path.join(zipDirPath, 'utgRequiredArtifactsDir', 'repoMapData', 'repoMapData.json') + // ) + // ) + + assert.strictEqual(result.rootDir, zipDirPath) + assert.strictEqual(result.zipFilePath, zipFilePath) + assert.strictEqual(result.srcPayloadSizeInBytes, 500) + assert.strictEqual(result.buildPayloadSizeInBytes, 200) + assert.strictEqual(result.zipFileSizeInBytes, 1000) + assert.strictEqual(result.lines, 100) + assert.strictEqual(result.language, 'typescript') + assert.deepStrictEqual(Array.from(result.scannedFiles), ['file1.ts', 'file2.ts']) + }) + + // it('Should handle LSP client error', async function () { + // // Override the default stub with one that rejects + // sinon.stub(LspClient, 'instance').get(() => ({ + // getRepoMapJSON: sinon.stub().rejects(new Error('LSP error')), + // })) + + // await assert.rejects(() => zipUtil.generateZipTestGen(projectPath), /LSP error/) + // }) + + it('Should handle file system errors during directory creation', async function () { + sinon.stub(LspClient, 'instance').get(() => ({ + getRepoMapJSON: sinon.stub().resolves('{"mock": "data"}'), + })) + mockFs.mkdir.rejects(new Error('Directory creation failed')) + + await assert.rejects(() => zipUtil.generateZipTestGen(projectPath, false), /Directory creation failed/) + }) + + it('Should handle zip project errors', async function () { + sinon.stub(LspClient, 'instance').get(() => ({ + getRepoMapJSON: sinon.stub().resolves('{"mock": "data"}'), + })) + ;(zipUtil as any).zipProject.rejects(new Error('Zip failed')) + + await assert.rejects(() => zipUtil.generateZipTestGen(projectPath, false), /Zip failed/) + }) + + it('Should handle file copy to downloads folder error', async function () { + // Mock LSP client + sinon.stub(LspClient, 'instance').get(() => ({ + getRepoMapJSON: sinon.stub().resolves('{"mock": "data"}'), + })) + + // Mock file operations + const mockFs = { + mkdir: sinon.stub().resolves(), + copy: sinon.stub().rejects(new Error('Copy failed')), + exists: sinon.stub().resolves(true), + stat: sinon.stub().resolves({ + type: vscode.FileType.File, + size: 1000, + ctime: Date.now(), + mtime: Date.now(), + } as vscode.FileStat), + } + + // Since the function now uses Promise.all for directory creation and file operations, + // we need to ensure the mkdir succeeds but the copy fails + fs.mkdir = mockFs.mkdir + fs.copy = mockFs.copy + fs.exists = mockFs.exists + fs.stat = mockFs.stat + + await assert.rejects(() => zipUtil.generateZipTestGen(projectPath, false), /Copy failed/) + + // Verify mkdir was called for all directories + assert(mockFs.mkdir.called, 'mkdir should have been called') + assert.strictEqual(mockFs.mkdir.callCount, 4, 'mkdir should have been called 4 times') + }) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/views/securityPanelViewProvider.test.ts b/packages/amazonq/test/unit/codewhisperer/views/securityPanelViewProvider.test.ts index 0b48eac2106..ca163e86208 100644 --- a/packages/amazonq/test/unit/codewhisperer/views/securityPanelViewProvider.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/views/securityPanelViewProvider.test.ts @@ -27,6 +27,9 @@ const codeScanIssue: CodeScanIssue[] = [ severity: 'low', recommendation: { text: 'foo', url: 'foo' }, suggestedFixes: [], + visible: true, + language: 'python', + scanJobId: 'scanJob', }, ] diff --git a/packages/core/package.json b/packages/core/package.json index a59f345d7ed..0550107f734 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,8 @@ "./auth": "./dist/src/auth/index.js", "./amazonqGumby": "./dist/src/amazonqGumby/index.js", "./amazonqFeatureDev": "./dist/src/amazonqFeatureDev/index.js", + "./amazonqScan": "./dist/src/amazonqScan/index.js", + "./amazonqTest": "./dist/src/amazonqTest/index.js", "./codewhispererChat": "./dist/src/codewhispererChat/index.js", "./test": "./dist/src/test/index.js", "./testWeb": "./dist/src/testWeb/index.js", @@ -53,327 +55,362 @@ "fontCharacter": "\\f1ac" } }, - "aws-amazonq-transform-arrow-dark": { + "aws-amazonq-severity-critical": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ad" } }, - "aws-amazonq-transform-arrow-light": { + "aws-amazonq-severity-high": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ae" } }, - "aws-amazonq-transform-default-dark": { + "aws-amazonq-severity-info": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1af" } }, - "aws-amazonq-transform-default-light": { + "aws-amazonq-severity-low": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b0" } }, - "aws-amazonq-transform-dependencies-dark": { + "aws-amazonq-severity-medium": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b1" } }, - "aws-amazonq-transform-dependencies-light": { + "aws-amazonq-transform-arrow-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b2" } }, - "aws-amazonq-transform-file-dark": { + "aws-amazonq-transform-arrow-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b3" } }, - "aws-amazonq-transform-file-light": { + "aws-amazonq-transform-default-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b4" } }, - "aws-amazonq-transform-logo": { + "aws-amazonq-transform-default-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b5" } }, - "aws-amazonq-transform-step-into-dark": { + "aws-amazonq-transform-dependencies-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b6" } }, - "aws-amazonq-transform-step-into-light": { + "aws-amazonq-transform-dependencies-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b7" } }, - "aws-amazonq-transform-variables-dark": { + "aws-amazonq-transform-file-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b8" } }, - "aws-amazonq-transform-variables-light": { + "aws-amazonq-transform-file-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1b9" } }, - "aws-applicationcomposer-icon": { + "aws-amazonq-transform-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ba" } }, - "aws-applicationcomposer-icon-dark": { + "aws-amazonq-transform-step-into-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bb" } }, - "aws-apprunner-service": { + "aws-amazonq-transform-step-into-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bc" } }, - "aws-cdk-logo": { + "aws-amazonq-transform-variables-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bd" } }, - "aws-cloudformation-stack": { + "aws-amazonq-transform-variables-light": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1be" } }, - "aws-cloudwatch-log-group": { + "aws-applicationcomposer-icon": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1bf" } }, - "aws-codecatalyst-logo": { + "aws-applicationcomposer-icon-dark": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c0" } }, - "aws-codewhisperer-icon-black": { + "aws-apprunner-service": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c1" } }, - "aws-codewhisperer-icon-white": { + "aws-cdk-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c2" } }, - "aws-codewhisperer-learn": { + "aws-cloudformation-stack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c3" } }, - "aws-ecr-registry": { + "aws-cloudwatch-log-group": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c4" } }, - "aws-ecs-cluster": { + "aws-codecatalyst-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c5" } }, - "aws-ecs-container": { + "aws-codewhisperer-icon-black": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c6" } }, - "aws-ecs-service": { + "aws-codewhisperer-icon-white": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c7" } }, - "aws-generic-attach-file": { + "aws-codewhisperer-learn": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c8" } }, - "aws-iot-certificate": { + "aws-ecr-registry": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1c9" } }, - "aws-iot-policy": { + "aws-ecs-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ca" } }, - "aws-iot-thing": { + "aws-ecs-container": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cb" } }, - "aws-lambda-function": { + "aws-ecs-service": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cc" } }, - "aws-mynah-MynahIconBlack": { + "aws-generic-attach-file": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cd" } }, - "aws-mynah-MynahIconWhite": { + "aws-iot-certificate": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1ce" } }, - "aws-mynah-logo": { + "aws-iot-policy": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1cf" } }, - "aws-redshift-cluster": { + "aws-iot-thing": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d0" } }, - "aws-redshift-cluster-connected": { + "aws-lambda-function": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d1" } }, - "aws-redshift-database": { + "aws-mynah-MynahIconBlack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d2" } }, - "aws-redshift-redshift-cluster-connected": { + "aws-mynah-MynahIconWhite": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d3" } }, - "aws-redshift-schema": { + "aws-mynah-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d4" } }, - "aws-redshift-table": { + "aws-redshift-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d5" } }, - "aws-s3-bucket": { + "aws-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d6" } }, - "aws-s3-create-bucket": { + "aws-redshift-database": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d7" } }, - "aws-schemas-registry": { + "aws-redshift-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d8" } }, - "aws-schemas-schema": { + "aws-redshift-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1d9" } }, - "aws-stepfunctions-preview": { + "aws-redshift-table": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1da" } + }, + "aws-s3-bucket": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1db" + } + }, + "aws-s3-create-bucket": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1dc" + } + }, + "aws-schemas-registry": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1dd" + } + }, + "aws-schemas-schema": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1de" + } + }, + "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1df" + } } } }, @@ -458,9 +495,9 @@ "dependencies": { "@amzn/amazon-q-developer-streaming-client": "file:../../src.gen/@amzn/amazon-q-developer-streaming-client", "@amzn/codewhisperer-streaming": "file:../../src.gen/@amzn/codewhisperer-streaming", + "@aws-sdk/client-cloudformation": "^3.667.0", "@aws-sdk/client-cognito-identity": "^3.637.0", "@aws-sdk/client-lambda": "^3.637.0", - "@aws-sdk/client-cloudformation": "^3.667.0", "@aws-sdk/client-sso": "^3.342.0", "@aws-sdk/client-sso-oidc": "^3.574.0", "@aws-sdk/credential-provider-ini": "3.46.0", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index ff4f74ab759..a0a79329f70 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -78,6 +78,7 @@ "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", + "AWS.configuration.description.amazonq.ignoredSecurityIssues": "Specifies a list of code issue identifiers that Amazon Q should ignore when reviewing your workspace. Each item in the array should be a unique string identifier for a specific code issue. This allows you to suppress notifications for known issues that you've assessed and determined to be false positives or not applicable to your project. Use this setting with caution, as it may cause you to miss important security alerts.", "AWS.command.apig.copyUrl": "Copy URL", "AWS.command.apig.invokeRemoteRestApi": "Invoke in the cloud", "AWS.command.apig.invokeRemoteRestApi.cn": "Invoke on Amazon", @@ -121,8 +122,17 @@ "AWS.command.amazonq.fixCode": "Fix", "AWS.command.amazonq.optimizeCode": "Optimize", "AWS.command.amazonq.sendToPrompt": "Send to prompt", - "AWS.command.amazonq.generateUnitTests": "Generate Tests (Beta)", - "AWS.command.amazonq.security.scan": "Run Project Scan", + "AWS.command.amazonq.generateUnitTests": "Generate Tests", + "AWS.command.amazonq.security.scan": "Run Project Review", + "AWS.command.amazonq.security.fileScan": "Run File Review", + "AWS.command.amazonq.generateFix": "Generate Fix", + "AWS.command.amazonq.viewDetails": "View Details", + "AWS.command.amazonq.explainIssue": "Explain", + "AWS.command.amazonq.ignoreIssue": "Ignore Issue", + "AWS.command.amazonq.ignoreAllIssues": "Ignore Similar Issues", + "AWS.command.amazonq.acceptFix": "Accept Fix", + "AWS.command.amazonq.regenerateFix": "Regenerate Fix", + "AWS.command.amazonq.filterIssues": "Filter Issues", "AWS.command.deploySamApplication": "Deploy SAM Application", "AWS.command.aboutToolkit": "About", "AWS.command.downloadLambda": "Download...", @@ -247,6 +257,7 @@ "AWS.samcli.deploy.bucket.recentlyUsed": "Buckets recently used for SAM deployments", "AWS.submenu.amazonqEditorContextSubmenu.title": "Amazon Q", "AWS.submenu.auth.title": "Authentication", + "AWS.submenu.amazonqSecurityIssueTree.filters": "Filter Issues", "AWS.generic.feedback": "Feedback", "AWS.generic.help": "Help", "AWS.generic.create": "Create...", @@ -257,7 +268,10 @@ "AWS.generic.promptUpdate": "Update...", "AWS.generic.preview": "Preview", "AWS.generic.viewDocs": "View Documentation", + "AWS.generic.moreActions": "More Actions...", "AWS.generic.dismiss": "Dismiss", + "AWS.generic.cancel": "Cancel", + "AWS.generic.cancelling": "Cancelling...", "AWS.ssmDocument.ssm.maxItemsComputed.desc": "Controls the maximum number of problems produced by the SSM Document language server.", "AWS.walkthrough.gettingStarted.title": "Get started with AWS", "AWS.walkthrough.gettingStarted.description": "These walkthroughs help you set up the AWS Toolkit.", @@ -277,28 +291,40 @@ "AWS.codewhisperer.customization.notification.new_customizations.learn_more": "Learn More", "AWS.amazonq.title": "Amazon Q", "AWS.amazonq.chat": "Chat", + "AWS.amazonq.security": "Code Issues", "AWS.amazonq.login": "Login", "AWS.amazonq.learnMore": "Learn More About Amazon Q", + "AWS.amazonq.exploreAgents": "Explore Agent Capabilities", + "AWS.amazonq.welcomeWalkthrough": "Welcome Walkthrough", "AWS.amazonq.codewhisperer.title": "Amazon Q", "AWS.amazonq.toggleCodeSuggestion": "Toggle Auto-Suggestions", "AWS.amazonq.toggleCodeScan": "Toggle Auto-Scans", + "AWS.amazonq.scans.scanProgress": "Sure. This may take a few minutes. I will send a notification when it’s complete if you navigate away from this panel.", + "AWS.amazonq.scans.waitingForInput": "Waiting on your inputs...", + "AWS.amazonq.scans.chooseScan.description": "Would you like to review your active file or the workspace you have open?", + "AWS.amazonq.scans.runCodeScan": "Run a code review", + "AWS.amazonq.scans.projectScan": "Review workspace", + "AWS.amazonq.scans.fileScan": "Review active file", + "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.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", "AWS.amazonq.featureDev.error.prepareRepoFailedError": "Sorry, I ran into an issue while trying to upload your code. Please try again.", "AWS.amazonq.featureDev.error.promptRefusalException": "I'm sorry, I can't generate code for your request. Please make sure your message and code files comply with the AWS Responsible AI Policy.", - "AWS.amazonq.featureDev.error.noChangeRequiredException": "I’m sorry, I ran into an issue while trying to generate your code.\n\n- `/dev` can generate code to make a change in your project. Provide a detailed description of the new feature or code changes you want to make, including the specifics of what the code should achieve.\n\n- To ask me to explain, debug, or optimize your code, you can close this chat tab to start a new conversation.", + "AWS.amazonq.featureDev.error.noChangeRequiredException": "I'm sorry, I ran into an issue while trying to generate your code.\n\n- `/dev` can generate code to make a change in your project. Provide a detailed description of the new feature or code changes you want to make, including the specifics of what the code should achieve.\n\n- To ask me to explain, debug, or optimize your code, you can close this chat tab to start a new conversation.", "AWS.amazonq.featureDev.error.zipFileError": "The zip file is corrupted", "AWS.amazonq.featureDev.error.codeIterationLimitError": "Sorry, you've reached the quota for number of iterations on code generation. You can insert this code in your files or discuss a new plan. For more information on quotas, see the Amazon Q Developer documentation.", "AWS.amazonq.featureDev.error.tabIdNotFoundError": "I'm sorry, I'm having technical difficulties at the moment. Please try again.", "AWS.amazonq.featureDev.error.codeGen.denyListedError": "I'm sorry, I'm having trouble generating your code and can't continue at the moment. Please try again later, and share feedback to help me improve.", "AWS.amazonq.featureDev.error.codeGen.default": "I'm sorry, I ran into an issue while trying to generate your code. Please try again.", "AWS.amazonq.featureDev.error.codeGen.timeout": "Code generation did not finish within the expected time", - "AWS.amazonq.featureDev.error.uploadURLExpired": "I’m sorry, I wasn’t able to generate code. A connection timed out or became unavailable. Please try again or check the following:\n\n- Exclude non-essential files in your workspace’s `.gitignore.`\n\n- Check that your network connection is stable.", + "AWS.amazonq.featureDev.error.uploadURLExpired": "I’m sorry, I wasn't able to generate code. A connection timed out or became unavailable. Please try again or check the following:\n\n- Exclude non-essential files in your workspace’s `.gitignore.`\n\n- Check that your network connection is stable.", "AWS.amazonq.featureDev.error.workspaceFolderNotFoundError": "I couldn't find a workspace folder. Open a workspace, and then open a new chat tab and enter /dev to start discussing your code task with me.", "AWS.amazonq.featureDev.error.selectedFolderNotInWorkspaceFolderError": "The folder you chose isn't in your open workspace folder. You can add this folder to your workspace, or choose a folder in your open workspace.", "AWS.amazonq.featureDev.error.userMessageNotFoundError": "It looks like you didn't provide an input. Please enter your message in the text bar.", - "AWS.amazonq.featureDev.error.monthlyLimitReached": "You've reached the monthly quota for the Amazon Q agent for software development. You can try again next month. For more information on usage limits, see the Amazon Q Developer pricing page.", + "AWS.amazonq.featureDev.error.monthlyLimitReached": "You've reached the monthly quota for Amazon Q Developer's agent capabilities. You can try again next month. For more information on usage limits, see the Amazon Q Developer pricing page.", "AWS.amazonq.featureDev.error.technicalDifficulties": "I'm sorry, I'm having technical difficulties and can't continue at the moment. Please try again later, and share feedback to help me improve.", "AWS.amazonq.featureDev.error.throttling": "I'm sorry, I'm experiencing high demand at the moment and can't generate your code. This attempt won't count toward usage limits. Please try again.", "AWS.amazonq.featureDev.error.submitFeedback": "'submitFeedback' command was called programmatically, but its not registered.", @@ -325,7 +351,7 @@ "AWS.amazonq.featureDev.pillText.selectOption": "Choose an option to proceed", "AWS.amazonq.featureDev.pillText.unableGenerateChanges": "Unable to generate any file changes", "AWS.amazonq.featureDev.pillText.provideFeedback": "Provide feedback & regenerate", - "AWS.amazonq.featureDev.answer.generateSuggestion": "Would you like to generate a suggestion for this? You’ll review a file diff before inserting into your project.", + "AWS.amazonq.featureDev.answer.generateSuggestion": "Would you like to generate a suggestion for this? You'll review a file diff before inserting into your project.", "AWS.amazonq.featureDev.answer.qGeneratedCode": "The Amazon Q Developer Agent for software development has generated code for you to review", "AWS.amazonq.featureDev.answer.howCodeCanBeImproved": "How can I improve the code for your use case?", "AWS.amazonq.featureDev.answer.updateCode": "Okay, I updated your code files. Would you like to work on another task?", @@ -336,6 +362,28 @@ "AWS.amazonq.featureDev.placeholder.feedback": "Provide feedback or comments", "AWS.amazonq.featureDev.placeholder.describe": "Describe your task or issue in detail", "AWS.amazonq.featureDev.placeholder.sessionClosed": "Open a new chat tab to continue", + "AWS.amazonq.doc.pillText.selectOption": "Choose an option to continue", + "AWS.amazonq.doc.answer.createReadme": "Create a README for this project?", + "AWS.amazonq.doc.answer.updateReadme": "Update the README for this project?", + "AWS.amazonq.doc.answer.editReadme": "Okay, let's work on your README. Describe the changes you would like to make. For example, you can ask me to:\n- Correct something\n- Expand on something\n- Add a section\n- Remove a section", + "AWS.amazonq.doc.answer.readmeCreated": "I've created a README for your code.", + "AWS.amazonq.doc.answer.readmeUpdated": "I've updated your README.", + "AWS.amazonq.doc.answer.codeResult": "You can accept the changes to your files, or describe any additional changes you'd like me to make.", + "AWS.amazonq.doc.answer.scanning": "Scanning source files", + "AWS.amazonq.doc.answer.summarizing": "Summarizing source files", + "AWS.amazonq.doc.answer.generating": "Generating documentation", + "AWS.amazonq.doc.answer.creating": "Okay, I'm creating a README for your project. This may take a few minutes.", + "AWS.amazonq.doc.answer.updating": "Okay, I'm updating the README to reflect your code changes. This may take a few minutes.", + "AWS.amazonq.doc.error.contentLengthError": "Your workspace is too large for me to review. Your workspace must be within the quota, even if you choose a smaller folder. For more information on quotas, see the Amazon Q Developer documentation.", + "AWS.amazonq.doc.error.readmeTooLarge": "The README in your folder is too large for me to review. Try reducing the size of your README, or choose a folder with a smaller README. For more information on quotas, see the Amazon Q Developer documentation.", + "AWS.amazonq.doc.error.workspaceEmpty": "The folder you chose did not contain any source files in a supported language. Choose another folder and try again. For more information on supported languages, see the Amazon Q Developer documentation.", + "AWS.amazonq.doc.error.promptTooVague": "I need more information to make changes to your README. Try providing some of the following details:\n- Which sections you want to modify\n- The content you want to add or remove\n- Specific issues that need correcting\n\nFor more information on prompt best practices, see the Amazon Q Developer documentation.", + "AWS.amazonq.doc.error.promptUnrelated": "These changes don't seem related to documentation. Try describing your changes again, using the following best practices:\n- Changes should relate to how project functionality is reflected in the README\n- Content you refer to should be available in your codebase\n\n For more information on prompt best practices, see the Amazon Q Developer documentation.", + "AWS.amazonq.doc.error.docGen.default": "I'm sorry, I ran into an issue while trying to generate your documentation. Please try again.", + "AWS.amazonq.doc.error.noChangeRequiredException": "I couldn't find any code changes to update in the README. Try another documentation task.", + "AWS.amazonq.doc.error.promptRefusal": "I'm sorry, I can't generate documentation for this folder. Please make sure your message and code files comply with the Please make sure your message and code files comply with the AWS Responsible AI Policy.", + "AWS.amazonq.doc.placeholder.editReadme": "Describe documentation changes", + "AWS.amazonq.doc.pillText.closeSession": "End session", "AWS.amazonq.inline.invokeChat": "Inline chat", "AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough", "AWS.toolkit.lambda.walkthrough.title": "Get started building your application", diff --git a/packages/core/resources/css/base.css b/packages/core/resources/css/base.css index 35a7cc736b9..eab33956f19 100644 --- a/packages/core/resources/css/base.css +++ b/packages/core/resources/css/base.css @@ -142,6 +142,7 @@ button, button:disabled { /* TODO: use VSC webcomponent library instead */ filter: brightness(0.8); + cursor: default; } /* Text area */ diff --git a/packages/core/resources/css/securityIssue.css b/packages/core/resources/css/securityIssue.css index f818ffdf14b..e102f5bc0f6 100644 --- a/packages/core/resources/css/securityIssue.css +++ b/packages/core/resources/css/securityIssue.css @@ -179,9 +179,10 @@ body.wordWrap pre { pre:not(.hljs), pre.hljs code > div { - padding: 0 16px 16px 16px; + padding: 16px; border-radius: 3px; overflow: auto; + margin-bottom: 0; } pre code { @@ -192,9 +193,11 @@ pre code { pre { background-color: var(--vscode-textCodeBlock-background); border: 1px solid var(--vscode-widget-border); + font-size: 12px; + line-height: 1rem; } -code.language-diff { +code[class^='language-'] { background-color: unset; } @@ -222,8 +225,164 @@ code.language-diff { color: var(--vscode-editorOverviewRuler-selectionHighlightForeground); display: inline-block; width: 100%; - margin: 0 -16px; - padding: 8px 16px; + margin: 0 -16px 4px -16px; + padding: 4px 16px; +} + +.hljs-meta * { + color: unset !important; +} + +.hljs-keyword, +.hljs-literal, +.hljs-symbol, +.hljs-name { + color: #569cd6; +} +.hljs-link { + color: #569cd6; + text-decoration: underline; +} + +.hljs-built_in, +.hljs-type { + color: #4ec9b0; +} + +.hljs-number, +.hljs-class { + color: #b8d7a3; +} + +.hljs-string, +.hljs-meta-string { + color: #d69d85; +} + +.hljs-regexp, +.hljs-template-tag { + color: #9a5334; +} + +.hljs-subst, +.hljs-function, +.hljs-title, +.hljs-params, +.hljs-formula { + color: #dcdcdc; +} + +.hljs-comment, +.hljs-quote { + color: #57a64a; + font-style: italic; +} + +.hljs-doctag { + color: #608b4e; +} + +.hljs-meta-keyword, +.hljs-tag { + color: #9b9b9b; +} + +.hljs-variable, +.hljs-template-variable { + color: #bd63c5; +} + +.hljs-attr, +.hljs-attribute, +.hljs-builtin-name { + color: #9cdcfe; +} + +.hljs-section { + color: gold; +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-bullet, +.hljs-selector-tag, +.hljs-selector-id, +.hljs-selector-class, +.hljs-selector-attr, +.hljs-selector-pseudo { + color: #d7ba7d; +} + +.vscode-light .hljs-function, +.vscode-light .hljs-params, +.vscode-light .hljs-number, +.vscode-light .hljs-class { + color: inherit; +} + +.vscode-light .hljs-comment, +.vscode-light .hljs-quote, +.vscode-light .hljs-number, +.vscode-light .hljs-class, +.vscode-light .hljs-variable { + color: #008000; +} + +.vscode-light .hljs-keyword, +.vscode-light .hljs-selector-tag, +.vscode-light .hljs-name, +.vscode-light .hljs-tag { + color: #00f; +} + +.vscode-light .hljs-built_in, +.vscode-light .hljs-builtin-name { + color: #007acc; +} + +.vscode-light .hljs-string, +.vscode-light .hljs-section, +.vscode-light .hljs-attribute, +.vscode-light .hljs-literal, +.vscode-light .hljs-template-tag, +.vscode-light .hljs-template-variable, +.vscode-light .hljs-type { + color: #a31515; +} + +.vscode-light .hljs-subst, +.vscode-light .hljs-selector-attr, +.vscode-light .hljs-selector-pseudo, +.vscode-light .hljs-meta-keyword { + color: #2b91af; +} +.vscode-light .hljs-title, +.vscode-light .hljs-doctag { + color: #808080; +} + +.vscode-light .hljs-attr { + color: #f00; +} + +.vscode-light .hljs-symbol, +.vscode-light .hljs-bullet, +.vscode-light .hljs-link { + color: #00b0e8; +} + +.vscode-light .hljs-emphasis { + font-style: italic; +} + +.vscode-light .hljs-strong { + font-weight: bold; } input[type='submit'] { @@ -270,3 +429,208 @@ hr { img.severity { height: 0.75em; } + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.spinner { + display: inline-block; + animation: spin 1s infinite; +} + +button.button-theme-primary, +button.button-theme-secondary { + padding: 6px 14px; + border-radius: 5px; +} + +.code-diff-actions { + width: 100%; + height: 26px; + background-color: var(--vscode-editorMarkerNavigationInfo-headerBackground); + border-radius: 0 0 3px 3px; + overflow: auto; + display: flex; + flex-direction: row-reverse; +} + +.code-diff-action-button { + font-size: 12px; + padding: 1px 6.5px; + margin: 1px 0; + border-radius: 3px; + color: currentColor; + background-color: rgba(0, 0, 0, 0); + transition: all 600ms cubic-bezier(0.25, 1, 0, 1); + transform: translate3d(0, 0, 0) scale(1.00001); + gap: calc(0.25rem * 1); + filter: brightness(0.925); + border: none; +} + +.code-diff-action-button:hover { + filter: brightness(1); +} + +.code-diff-action-button:hover:after { + transform: translate3d(0%, 0, 0); + opacity: 0.15; +} + +.code-diff-action-button::after { + content: ''; + pointer-events: none; + transition: all 600ms cubic-bezier(0.25, 1, 0, 1); + opacity: 0; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + filter: brightness(1.35) saturate(0.75); + border-radius: inherit; + background-color: currentColor; + transform: translate3d(-5%, 0, 0) scale(0.93); +} + +.container-bottom { + bottom: 0; + justify-content: unset; + border-top: 1px solid var(--vscode-menu-separatorBackground); + border-bottom: none; +} + +.button-container { + padding: 10px 0; + flex-wrap: wrap; +} + +.button-container > button { + white-space: nowrap; + margin-bottom: 8px; +} + +pre.center { + display: flex; + justify-content: center; + padding-top: 16px; +} + +pre.error { + color: var(--vscode-diffEditorOverview-removedForeground); +} + +.dot-typing { + position: relative; + left: -9999px; + width: 8px; + height: 8px; + border-radius: 5px; + background-color: var(--vscode-editor-foreground); + color: var(--vscode-editor-foreground); + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + animation: dot-typing 1.5s infinite linear; +} + +@keyframes dot-typing { + 0% { + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + } + 16.667% { + box-shadow: + 9984px -10px 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + } + 33.333% { + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + } + 50% { + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px -10px 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + } + 66.667% { + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + } + 83.333% { + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px -10px 0 0 var(--vscode-editor-foreground); + } + 100% { + box-shadow: + 9984px 0 0 0 var(--vscode-editor-foreground), + 9999px 0 0 0 var(--vscode-editor-foreground), + 10014px 0 0 0 var(--vscode-editor-foreground); + } +} + +.code-block { + max-width: fit-content; + min-width: 500px; +} + +.code-block pre { + border-radius: 3px 3px 0 0; +} + +.line-number { + display: inline-block; + color: var(--vscode-editorOverviewRuler-selectionHighlightForeground); + width: 16px; + margin-right: 24px; + text-align: right; +} + +.highlight { + display: inline-block; + background-color: var(--vscode-editorOverviewRuler-selectionHighlightForeground); +} + +.reference-tracker { + cursor: help; +} + +.reference-tracker .tooltip { + visibility: hidden; + opacity: 0; + transition: + visibility 0s 0.1s, + opacity 0.1s linear; + position: absolute; + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + border: 1px solid var(--vscode-menu-separatorBackground); + border-radius: 5px; + padding: 16px; + margin-top: -80px; + margin-left: 20px; + cursor: default; +} + +.reference-tracker:hover .tooltip { + visibility: visible; + opacity: 1; + transition: opacity 0.1s linear; +} diff --git a/packages/core/resources/icons/aws/amazonq/severity-critical.svg b/packages/core/resources/icons/aws/amazonq/severity-critical.svg new file mode 100644 index 00000000000..7733994d24e --- /dev/null +++ b/packages/core/resources/icons/aws/amazonq/severity-critical.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/core/resources/icons/aws/amazonq/severity-high.svg b/packages/core/resources/icons/aws/amazonq/severity-high.svg new file mode 100644 index 00000000000..ff92aebc817 --- /dev/null +++ b/packages/core/resources/icons/aws/amazonq/severity-high.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/core/resources/icons/aws/amazonq/severity-info.svg b/packages/core/resources/icons/aws/amazonq/severity-info.svg new file mode 100644 index 00000000000..dbf78609170 --- /dev/null +++ b/packages/core/resources/icons/aws/amazonq/severity-info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/core/resources/icons/aws/amazonq/severity-low.svg b/packages/core/resources/icons/aws/amazonq/severity-low.svg new file mode 100644 index 00000000000..4ca6d96961e --- /dev/null +++ b/packages/core/resources/icons/aws/amazonq/severity-low.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/core/resources/icons/aws/amazonq/severity-medium.svg b/packages/core/resources/icons/aws/amazonq/severity-medium.svg new file mode 100644 index 00000000000..a906d9b4873 --- /dev/null +++ b/packages/core/resources/icons/aws/amazonq/severity-medium.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/core/src/amazonq/commons/baseChatStorage.ts b/packages/core/src/amazonq/commons/baseChatStorage.ts new file mode 100644 index 00000000000..b0a10c8977b --- /dev/null +++ b/packages/core/src/amazonq/commons/baseChatStorage.ts @@ -0,0 +1,38 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import AsyncLock from 'async-lock' + +export abstract class BaseChatSessionStorage { + private lock = new AsyncLock() + protected sessions: Map = new Map() + + abstract createSession(tabID: string): Promise + + public async getSession(tabID: string): Promise { + /** + * The lock here is added in order to mitigate amazon Q's eventing fire & forget design when integrating with mynah-ui that creates a race condition here. + * The race condition happens when handleDevFeatureCommand in src/amazonq/webview/ui/quickActions/handler.ts is firing two events after each other to amazonqFeatureDev controller + * This eventually may make code generation fail as at the moment of that event it may get from the storage a session that has not been properly updated. + */ + return this.lock.acquire(tabID, async () => { + const sessionFromStorage = this.sessions.get(tabID) + if (sessionFromStorage === undefined) { + // If a session doesn't already exist just create it + return this.createSession(tabID) + } + return sessionFromStorage + }) + } + + // Find all sessions that are currently waiting to be authenticated + public getAuthenticatingSessions(): T[] { + return Array.from(this.sessions.values()).filter((session) => session.isAuthenticating) + } + + public deleteSession(tabID: string) { + this.sessions.delete(tabID) + } +} diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts b/packages/core/src/amazonq/commons/connector/baseMessenger.ts similarity index 68% rename from packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts rename to packages/core/src/amazonq/commons/connector/baseMessenger.ts index 29aa741dc80..ab053333432 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/amazonq/commons/connector/baseMessenger.ts @@ -3,29 +3,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { DeletedFileInfo, FollowUpTypes, NewFileInfo } from '../../../types' -import { AuthFollowUpType, AuthMessageDataMap } from '../../../../amazonq/auth/model' +import { ChatItemAction, ProgressField } from '@aws/mynah-ui' +import { AuthFollowUpType, AuthMessageDataMap } from '../../../amazonq/auth/model' +import { FeatureAuthState } from '../../../codewhisperer' +import { i18n } from '../../../shared/i18n-helper' +import { CodeReference } from '../../../amazonq/webview/ui/connector' + +import { MessengerTypes } from '../../../amazonqFeatureDev/controllers/chat/messenger/constants' import { - ChatMessage, + AppToWebViewMessageDispatcher, AsyncEventProgressMessage, - CodeResultMessage, - UpdatePlaceholderMessage, - ChatInputEnabledMessage, AuthenticationUpdateMessage, AuthNeededException, - OpenNewTabMessage, + ChatInputEnabledMessage, + ChatMessage, + CodeResultMessage, FileComponent, + FolderConfirmationMessage, + OpenNewTabMessage, UpdateAnswerMessage, -} from '../../../views/connector/connector' -import { AppToWebViewMessageDispatcher } from '../../../views/connector/connector' -import { ChatItemAction } from '@aws/mynah-ui' -import { messageWithConversationId } from '../../../userFacingText' -import { MessengerTypes } from './constants' -import { FeatureAuthState } from '../../../../codewhisperer' -import { CodeReference } from '../../../../codewhispererChat/view/connector/connector' -import { i18n } from '../../../../shared/i18n-helper' + UpdatePlaceholderMessage, + UpdatePromptProgressMessage, +} from './connectorMessages' +import { FollowUpTypes } from '../types' +import { messageWithConversationId } from '../../../amazonqFeatureDev/userFacingText' +import { DeletedFileInfo, NewFileInfo } from '../../../amazonqFeatureDev/types' + export class Messenger { - public constructor(private readonly dispatcher: AppToWebViewMessageDispatcher) {} + public constructor( + private readonly dispatcher: AppToWebViewMessageDispatcher, + private readonly sender: string + ) {} public sendAnswer(params: { message?: string @@ -35,6 +43,7 @@ export class Messenger { canBeVoted?: boolean snapToTop?: boolean messageId?: string + disableChatInput?: boolean }) { this.dispatcher.sendChatMessage( new ChatMessage( @@ -47,9 +56,13 @@ export class Messenger { snapToTop: params.snapToTop ?? false, messageId: params.messageId, }, - params.tabID + params.tabID, + this.sender ) ) + if (params.disableChatInput) { + this.sendChatInputEnabled(params.tabID, false) + } } public sendFeedback(tabID: string) { @@ -76,6 +89,23 @@ export class Messenger { this.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.placeholder.chatInputDisabled')) } + public sendUpdatePromptProgress(tabID: string, progressField: ProgressField | null) { + this.dispatcher.sendUpdatePromptProgress(new UpdatePromptProgressMessage(tabID, this.sender, progressField)) + } + + public sendFolderConfirmationMessage( + tabID: string, + message: string, + folderPath: string, + followUps?: ChatItemAction[] + ) { + this.dispatcher.sendFolderConfirmationMessage( + new FolderConfirmationMessage(tabID, this.sender, message, folderPath, followUps) + ) + + this.sendChatInputEnabled(tabID, false) + } + public sendErrorMessage( errorMessage: string, tabID: string, @@ -123,12 +153,12 @@ export class Messenger { codeGenerationId: string ) { this.dispatcher.sendCodeResult( - new CodeResultMessage(filePaths, deletedFiles, references, tabID, uploadId, codeGenerationId) + new CodeResultMessage(filePaths, deletedFiles, references, tabID, this.sender, uploadId, codeGenerationId) ) } public sendAsyncEventProgress(tabID: string, inProgress: boolean, message: string | undefined) { - this.dispatcher.sendAsyncEventProgress(new AsyncEventProgressMessage(tabID, inProgress, message)) + this.dispatcher.sendAsyncEventProgress(new AsyncEventProgressMessage(tabID, this.sender, inProgress, message)) } public updateFileComponent( @@ -139,7 +169,7 @@ export class Messenger { disableFileActions: boolean ) { this.dispatcher.updateFileComponent( - new FileComponent(tabID, filePaths, deletedFiles, messageId, disableFileActions) + new FileComponent(tabID, this.sender, filePaths, deletedFiles, messageId, disableFileActions) ) } @@ -148,16 +178,16 @@ export class Messenger { } public sendUpdatePlaceholder(tabID: string, newPlaceholder: string) { - this.dispatcher.sendPlaceholder(new UpdatePlaceholderMessage(tabID, newPlaceholder)) + this.dispatcher.sendPlaceholder(new UpdatePlaceholderMessage(tabID, this.sender, newPlaceholder)) } public sendChatInputEnabled(tabID: string, enabled: boolean) { - this.dispatcher.sendChatInputEnabled(new ChatInputEnabledMessage(tabID, enabled)) + this.dispatcher.sendChatInputEnabled(new ChatInputEnabledMessage(tabID, this.sender, enabled)) } - public sendAuthenticationUpdate(featureDevEnabled: boolean, authenticatingTabIDs: string[]) { + public sendAuthenticationUpdate(enabled: boolean, authenticatingTabIDs: string[]) { this.dispatcher.sendAuthenticationUpdate( - new AuthenticationUpdateMessage(featureDevEnabled, authenticatingTabIDs) + new AuthenticationUpdateMessage(this.sender, enabled, authenticatingTabIDs) ) } @@ -180,10 +210,10 @@ export class Messenger { break } - this.dispatcher.sendAuthNeededExceptionMessage(new AuthNeededException(message, authType, tabID)) + this.dispatcher.sendAuthNeededExceptionMessage(new AuthNeededException(message, authType, tabID, this.sender)) } public openNewTask() { - this.dispatcher.sendOpenNewTask(new OpenNewTabMessage()) + this.dispatcher.sendOpenNewTask(new OpenNewTabMessage(this.sender)) } } diff --git a/packages/core/src/amazonqFeatureDev/views/connector/connector.ts b/packages/core/src/amazonq/commons/connector/connectorMessages.ts similarity index 71% rename from packages/core/src/amazonqFeatureDev/views/connector/connector.ts rename to packages/core/src/amazonq/commons/connector/connectorMessages.ts index a45731cc11a..f5ac0d4b21a 100644 --- a/packages/core/src/amazonqFeatureDev/views/connector/connector.ts +++ b/packages/core/src/amazonq/commons/connector/connectorMessages.ts @@ -3,20 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthFollowUpType } from '../../../amazonq/auth/model' -import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' -import { CodeReference } from '../../../amazonq/webview/ui/connector' -import { featureDevChat, licenseText } from '../../constants' -import { ChatItemAction, SourceLink } from '@aws/mynah-ui' -import { DeletedFileInfo, NewFileInfo } from '../../types' -import { ChatItemType } from '../../../amazonq/commons/model' +import { AuthFollowUpType } from '../../auth/model' +import { MessagePublisher } from '../../messages/messagePublisher' +import { CodeReference } from '../../webview/ui/connector' +import { ChatItemAction, ProgressField, SourceLink } from '@aws/mynah-ui' +import { ChatItemType } from '../model' +import { DeletedFileInfo, NewFileInfo } from '../../../amazonqFeatureDev/types' +import { licenseText } from '../../../amazonqFeatureDev/constants' class UiMessage { readonly time: number = Date.now() - readonly sender: string = featureDevChat readonly type: string = '' - public constructor(protected tabID: string) {} + public constructor( + protected tabID: string, + protected sender: string + ) {} } export class ErrorMessage extends UiMessage { @@ -24,8 +26,8 @@ export class ErrorMessage extends UiMessage { readonly message!: string override type = 'errorMessage' - constructor(title: string, message: string, tabID: string) { - super(tabID) + constructor(title: string, message: string, tabID: string, sender: string) { + super(tabID, sender) this.title = title this.message = message } @@ -49,10 +51,11 @@ export class CodeResultMessage extends UiMessage { readonly deletedFiles: DeletedFileInfo[], references: CodeReference[], tabID: string, + sender: string, conversationID: string, codeGenerationId: string ) { - super(tabID) + super(tabID, sender) this.references = references .filter((ref) => ref.licenseName && ref.repository && ref.url) .map((ref) => { @@ -71,17 +74,51 @@ export class CodeResultMessage extends UiMessage { } } +export class FolderConfirmationMessage extends UiMessage { + readonly folderPath: string + readonly message: string + readonly followUps?: ChatItemAction[] + override type = 'folderConfirmationMessage' + constructor(tabID: string, sender: string, message: string, folderPath: string, followUps?: ChatItemAction[]) { + super(tabID, sender) + this.message = message + this.folderPath = folderPath + this.followUps = followUps + } +} + +export class UpdatePromptProgressMessage extends UiMessage { + readonly progressField: ProgressField | null + override type = 'updatePromptProgress' + constructor(tabID: string, sender: string, progressField: ProgressField | null) { + super(tabID, sender) + this.progressField = progressField + } +} + export class AsyncEventProgressMessage extends UiMessage { readonly inProgress: boolean readonly message: string | undefined override type = 'asyncEventProgressMessage' - constructor(tabID: string, inProgress: boolean, message: string | undefined) { - super(tabID) + constructor(tabID: string, sender: string, inProgress: boolean, message: string | undefined) { + super(tabID, sender) this.inProgress = inProgress this.message = message } } + +export class AuthenticationUpdateMessage { + readonly time: number = Date.now() + readonly type = 'authenticationUpdateMessage' + + constructor( + readonly sender: string, + readonly featureEnabled: boolean, + readonly authenticatingTabIDs: string[] + ) {} +} + export class FileComponent extends UiMessage { readonly filePaths: NewFileInfo[] readonly deletedFiles: DeletedFileInfo[] @@ -91,12 +128,13 @@ export class FileComponent extends UiMessage { constructor( tabID: string, + sender: string, filePaths: NewFileInfo[], deletedFiles: DeletedFileInfo[], messageId: string, disableFileActions: boolean ) { - super(tabID) + super(tabID, sender) this.filePaths = filePaths this.deletedFiles = deletedFiles this.messageId = messageId @@ -108,8 +146,8 @@ export class UpdatePlaceholderMessage extends UiMessage { readonly newPlaceholder: string override type = 'updatePlaceholderMessage' - constructor(tabID: string, newPlaceholder: string) { - super(tabID) + constructor(tabID: string, sender: string, newPlaceholder: string) { + super(tabID, sender) this.newPlaceholder = newPlaceholder } } @@ -118,29 +156,17 @@ export class ChatInputEnabledMessage extends UiMessage { readonly enabled: boolean override type = 'chatInputEnabledMessage' - constructor(tabID: string, enabled: boolean) { - super(tabID) + constructor(tabID: string, sender: string, enabled: boolean) { + super(tabID, sender) this.enabled = enabled } } export class OpenNewTabMessage { readonly time: number = Date.now() - readonly sender: string = featureDevChat readonly type = 'openNewTabMessage' -} - -export class AuthenticationUpdateMessage { - readonly time: number = Date.now() - readonly sender: string = featureDevChat - readonly featureDevEnabled: boolean - readonly authenticatingTabIDs: string[] - readonly type = 'authenticationUpdateMessage' - constructor(featureDevEnabled: boolean, authenticatingTabIDs: string[]) { - this.featureDevEnabled = featureDevEnabled - this.authenticatingTabIDs = authenticatingTabIDs - } + constructor(protected sender: string) {} } export class AuthNeededException extends UiMessage { @@ -148,8 +174,8 @@ export class AuthNeededException extends UiMessage { readonly authType: AuthFollowUpType override type = 'authNeededException' - constructor(message: string, authType: AuthFollowUpType, tabID: string) { - super(tabID) + constructor(message: string, authType: AuthFollowUpType, tabID: string, sender: string) { + super(tabID, sender) this.message = message this.authType = authType } @@ -176,8 +202,8 @@ export class ChatMessage extends UiMessage { readonly messageId: string | undefined override type = 'chatMessage' - constructor(props: ChatMessageProps, tabID: string) { - super(tabID) + constructor(props: ChatMessageProps, tabID: string, sender: string) { + super(tabID, sender) this.message = props.message this.messageType = props.messageType this.followUps = props.followUps @@ -200,8 +226,8 @@ export class UpdateAnswerMessage extends UiMessage { readonly followUps: ChatItemAction[] | undefined override type = 'updateChatAnswer' - constructor(props: UpdateAnswerMessageProps, tabID: string) { - super(tabID) + constructor(props: UpdateAnswerMessageProps, tabID: string, sender: string) { + super(tabID, sender) this.messageId = props.messageId this.messageType = props.messageType this.followUps = props.followUps @@ -223,6 +249,14 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } + public sendUpdatePromptProgress(message: UpdatePromptProgressMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendFolderConfirmationMessage(message: FolderConfirmationMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + public sendAsyncEventProgress(message: AsyncEventProgressMessage) { this.appsToWebViewMessagePublisher.publish(message) } @@ -235,11 +269,11 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } - public sendAuthenticationUpdate(message: AuthenticationUpdateMessage) { + public sendAuthNeededExceptionMessage(message: AuthNeededException) { this.appsToWebViewMessagePublisher.publish(message) } - public sendAuthNeededExceptionMessage(message: AuthNeededException) { + public sendAuthenticationUpdate(message: AuthenticationUpdateMessage) { this.appsToWebViewMessagePublisher.publish(message) } diff --git a/packages/core/src/amazonq/commons/controllers/contentController.ts b/packages/core/src/amazonq/commons/controllers/contentController.ts index 821e2988f96..0af3b317025 100644 --- a/packages/core/src/amazonq/commons/controllers/contentController.ts +++ b/packages/core/src/amazonq/commons/controllers/contentController.ts @@ -17,7 +17,7 @@ import { } from '../../../shared/utilities/textDocumentUtilities' import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, ToolkitError } from '../../../shared' -class ContentProvider implements vscode.TextDocumentContentProvider { +export class ContentProvider implements vscode.TextDocumentContentProvider { constructor(private uri: vscode.Uri) {} provideTextDocumentContent(_uri: vscode.Uri) { diff --git a/packages/core/src/amazonq/commons/diff.ts b/packages/core/src/amazonq/commons/diff.ts index 97fde96c389..beb45d88096 100644 --- a/packages/core/src/amazonq/commons/diff.ts +++ b/packages/core/src/amazonq/commons/diff.ts @@ -4,34 +4,37 @@ */ import * as vscode from 'vscode' -import { featureDevScheme } from '../../amazonqFeatureDev/constants' import { fs } from '../../shared' import { diffLines } from 'diff' -export async function openDiff(leftPath: string, rightPath: string, tabId: string) { - const { left, right } = await getFileDiffUris(leftPath, rightPath, tabId) +export async function openDiff(leftPath: string, rightPath: string, tabId: string, scheme: string) { + const { left, right } = await getFileDiffUris(leftPath, rightPath, tabId, scheme) await vscode.commands.executeCommand('vscode.diff', left, right) } -export async function openDeletedDiff(filePath: string, name: string, tabId: string) { - const left = await getOriginalFileUri(filePath, tabId) - const right = createAmazonQUri('empty', tabId) +export async function openDeletedDiff(filePath: string, name: string, tabId: string, scheme: string) { + const left = await getOriginalFileUri(filePath, tabId, scheme) + const right = createAmazonQUri('empty', tabId, scheme) await vscode.commands.executeCommand('vscode.diff', left, right, `${name} (Deleted)`) } -export async function getOriginalFileUri(fullPath: string, tabId: string) { - return (await fs.exists(fullPath)) ? vscode.Uri.file(fullPath) : createAmazonQUri('empty', tabId) +export async function getOriginalFileUri(fullPath: string, tabId: string, scheme: string) { + return (await fs.exists(fullPath)) ? vscode.Uri.file(fullPath) : createAmazonQUri('empty', tabId, scheme) } -export async function getFileDiffUris(leftPath: string, rightPath: string, tabId: string) { - const left = await getOriginalFileUri(leftPath, tabId) - const right = createAmazonQUri(rightPath, tabId) +export async function getFileDiffUris(leftPath: string, rightPath: string, tabId: string, scheme: string) { + const left = await getOriginalFileUri(leftPath, tabId, scheme) + const right = createAmazonQUri(rightPath, tabId, scheme) return { left, right } } -export async function computeDiff(leftPath: string, rightPath: string, tabId: string) { - const { left, right } = await getFileDiffUris(leftPath, rightPath, tabId) +export function createAmazonQUri(path: string, tabId: string, scheme: string) { + return vscode.Uri.from({ scheme: scheme, path, query: `tabID=${tabId}` }) +} + +export async function computeDiff(leftPath: string, rightPath: string, tabId: string, scheme: string) { + const { left, right } = await getFileDiffUris(leftPath, rightPath, tabId, scheme) const leftFile = await vscode.workspace.openTextDocument(left) const rightFile = await vscode.workspace.openTextDocument(right) @@ -57,8 +60,3 @@ export async function computeDiff(leftPath: string, rightPath: string, tabId: st }) return { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } } - -export function createAmazonQUri(path: string, tabId: string) { - // TODO change the featureDevScheme to a more general amazon q scheme - return vscode.Uri.from({ scheme: featureDevScheme, path, query: `tabID=${tabId}` }) -} diff --git a/packages/core/src/amazonqFeatureDev/session/sessionConfigFactory.ts b/packages/core/src/amazonq/commons/session/sessionConfigFactory.ts similarity index 69% rename from packages/core/src/amazonqFeatureDev/session/sessionConfigFactory.ts rename to packages/core/src/amazonq/commons/session/sessionConfigFactory.ts index 6f98e1b3664..d6dff48fbe5 100644 --- a/packages/core/src/amazonqFeatureDev/session/sessionConfigFactory.ts +++ b/packages/core/src/amazonq/commons/session/sessionConfigFactory.ts @@ -4,11 +4,9 @@ */ import * as vscode from 'vscode' -import { featureDevScheme } from '../constants' -import { VirtualFileSystem } from '../../shared/virtualFilesystem' -import { VirtualMemoryFile } from '../../shared/virtualMemoryFile' -import { WorkspaceFolderNotFoundError } from '../errors' -import { CurrentWsFolders } from '../types' +import { WorkspaceFolderNotFoundError } from '../../../amazonqFeatureDev/errors' +import { VirtualFileSystem, VirtualMemoryFile } from '../../../shared' +import { CurrentWsFolders } from '../../../amazonqFeatureDev/types' export interface SessionConfig { // The paths on disk to where the source code lives @@ -21,7 +19,7 @@ export interface SessionConfig { * Factory method for creating session configurations * @returns An instantiated SessionConfig, using either the arguments provided or the defaults */ -export async function createSessionConfig(): Promise { +export async function createSessionConfig(scheme: string): Promise { const workspaceFolders = vscode.workspace.workspaceFolders const firstFolder = workspaceFolders?.[0] if (workspaceFolders === undefined || workspaceFolders.length === 0 || firstFolder === undefined) { @@ -33,10 +31,7 @@ export async function createSessionConfig(): Promise { const fs = new VirtualFileSystem() // Register an empty featureDev file that's used when a new file is being added by the LLM - fs.registerProvider( - vscode.Uri.from({ scheme: featureDevScheme, path: 'empty' }), - new VirtualMemoryFile(new Uint8Array()) - ) + fs.registerProvider(vscode.Uri.from({ scheme, path: 'empty' }), new VirtualMemoryFile(new Uint8Array())) return Promise.resolve({ workspaceRoots, fs, workspaceFolders: [firstFolder, ...workspaceFolders.slice(1)] }) } diff --git a/packages/core/src/amazonq/commons/types.ts b/packages/core/src/amazonq/commons/types.ts new file mode 100644 index 00000000000..1016f5c0669 --- /dev/null +++ b/packages/core/src/amazonq/commons/types.ts @@ -0,0 +1,38 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum FollowUpTypes { + //UnitTestGeneration + ViewDiff = 'ViewDiff', + AcceptCode = 'AcceptCode', + RejectCode = 'RejectCode', + BuildAndExecute = 'BuildAndExecute', + ModifyCommands = 'ModifyCommands', + SkipBuildAndFinish = 'SkipBuildAndFinish', + InstallDependenciesAndContinue = 'InstallDependenciesAndContinue', + ContinueBuildAndExecute = 'ContinueBuildAndExecute', + ViewCodeDiffAfterIteration = 'ViewCodeDiffAfterIteration', + //FeatureDev + GenerateCode = 'GenerateCode', + InsertCode = 'InsertCode', + ProvideFeedbackAndRegenerateCode = 'ProvideFeedbackAndRegenerateCode', + Retry = 'Retry', + ModifyDefaultSourceFolder = 'ModifyDefaultSourceFolder', + DevExamples = 'DevExamples', + NewTask = 'NewTask', + CloseSession = 'CloseSession', + SendFeedback = 'SendFeedback', + // Doc + CreateDocumentation = 'CreateDocumentation', + ChooseFolder = 'ChooseFolder', + UpdateDocumentation = 'UpdateDocumentation', + SynchronizeDocumentation = 'SynchronizeDocumentation', + EditDocumentation = 'EditDocumentation', + AcceptChanges = 'AcceptChanges', + RejectChanges = 'RejectChanges', + MakeChanges = 'MakeChanges', + ProceedFolderSelection = 'ProceedFolderSelection', + CancelFolderSelection = 'CancelFolderSelection', +} diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index fefa706b382..5bd20e4dfd0 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -22,6 +22,8 @@ export { AmazonQChatViewProvider } from './webview/webView' export { init as cwChatAppInit } from '../codewhispererChat/app' export { init as featureDevChatAppInit } from '../amazonqFeatureDev/app' export { init as gumbyChatAppInit } from '../amazonqGumby/app' +export { init as testChatAppInit } from '../amazonqTest/app' +export { init as docChatAppInit } from '../amazonqDoc/app' export { activateBadge } from './util/viewBadgeHandler' export { amazonQHelpUrl } from '../shared/constants' export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/statusBarMenu' @@ -35,9 +37,12 @@ export { getFileDiffUris, computeDiff, } from './commons/diff' +export { AuthFollowUpType, AuthMessageDataMap } from './auth/model' +export { ChatItemType } from './commons/model' +export { ExtensionMessage } from '../amazonq/webview/ui/commands' export { CodeReference } from '../codewhispererChat/view/connector/connector' -export { AuthMessageDataMap, AuthFollowUpType } from './auth/model' export { extractAuthFollowUp } from './util/authUtils' +export { Messenger } from './commons/connector/baseMessenger' import { FeatureContext } from '../shared' /** @@ -54,7 +59,9 @@ export function createMynahUI( ) { if (typeof window !== 'undefined') { const mynahUI = require('./webview/ui/main') - return mynahUI.createMynahUI(ideApi, amazonQEnabled, featureConfigsSerialized, disabledCommands) + return mynahUI.createMynahUI(ideApi, amazonQEnabled, featureConfigsSerialized, true, disabledCommands) } throw new Error('Not implemented for node') } + +export * from './commons/types' diff --git a/packages/core/src/amazonq/lsp/lspClient.ts b/packages/core/src/amazonq/lsp/lspClient.ts index bfc88125abd..1d3dd2743e9 100644 --- a/packages/core/src/amazonq/lsp/lspClient.ts +++ b/packages/core/src/amazonq/lsp/lspClient.ts @@ -26,6 +26,8 @@ import { QueryVectorIndexRequestType, UpdateIndexV2RequestPayload, UpdateIndexV2RequestType, + QueryRepomapIndexRequestType, + GetRepomapIndexJSONRequestType, Usage, } from './types' import { Writable } from 'stream' @@ -139,6 +141,31 @@ export class LspClient { return undefined } } + async queryRepomapIndex(filePaths: string[]) { + try { + const request = JSON.stringify({ + filePaths: filePaths, + }) + const resp: any = await this.client?.sendRequest(QueryRepomapIndexRequestType, await this.encrypt(request)) + return resp + } catch (e) { + getLogger().error(`LspClient: QueryRepomapIndex error: ${e}`) + throw e + } + } + async getRepoMapJSON() { + try { + const request = JSON.stringify({}) + const resp: any = await this.client?.sendRequest( + GetRepomapIndexJSONRequestType, + await this.encrypt(request) + ) + return resp + } catch (e) { + getLogger().error(`LspClient: queryInlineProjectContext error: ${e}`) + throw e + } + } } /** * Activates the language server, this will start LSP server running over IPC protocol. diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index 29fd0ab68d3..7a74318dd14 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -372,6 +372,9 @@ export class LspController { }) } finally { this._isIndexingInProgress = false + const repomapFile = await LspClient.instance.getRepoMapJSON() + // console.log(repomapFile) + getLogger().info(`File path ${repomapFile}`) } } diff --git a/packages/core/src/amazonq/lsp/types.ts b/packages/core/src/amazonq/lsp/types.ts index 6a2cab57d8e..fe1df5ed3bc 100644 --- a/packages/core/src/amazonq/lsp/types.ts +++ b/packages/core/src/amazonq/lsp/types.ts @@ -65,3 +65,14 @@ export const QueryVectorIndexRequestType: RequestType = new RequestType( + 'lsp/queryRepomapIndex' +) +export type GetRepomapIndexJSONRequest = string +export const GetRepomapIndexJSONRequestType: RequestType = new RequestType( + 'lsp/getRepomapIndexJSON' +) diff --git a/packages/core/src/amazonq/onboardingPage/walkthrough.ts b/packages/core/src/amazonq/onboardingPage/walkthrough.ts index 63c5db3a87f..30e31ac1055 100644 --- a/packages/core/src/amazonq/onboardingPage/walkthrough.ts +++ b/packages/core/src/amazonq/onboardingPage/walkthrough.ts @@ -34,7 +34,7 @@ export async function showAmazonQWalkthroughOnce(showWalkthrough = () => openAma * Opens the Amazon Q Walkthrough. * We wrap the actual command so that we can get telemetry from it. */ -export const openAmazonQWalkthrough = Commands.declare(`_aws.amazonq.walkthrough.show`, () => async () => { +export const openAmazonQWalkthrough = Commands.declare(`aws.amazonq.walkthrough.show`, () => async () => { await vscode.commands.executeCommand( 'workbench.action.openWalkthrough', `${VSCODE_EXTENSION_ID.amazonq}#aws.amazonq.walkthrough` @@ -69,7 +69,7 @@ fake_users = [ export const walkthroughSecurityScanExample = Commands.declare( `_aws.amazonq.walkthrough.securityScanExample`, () => async () => { - const filterText = localize('AWS.command.amazonq.security.scan', 'Run Project Scan') + const filterText = localize('AWS.command.amazonq.security.scan', 'Run Project Review') void vscode.commands.executeCommand('workbench.action.quickOpen', `> ${filterText}`) } ) diff --git a/packages/core/src/amazonq/webview/generators/webViewContent.ts b/packages/core/src/amazonq/webview/generators/webViewContent.ts index da1492f467f..fb83ab895a6 100644 --- a/packages/core/src/amazonq/webview/generators/webViewContent.ts +++ b/packages/core/src/amazonq/webview/generators/webViewContent.ts @@ -23,7 +23,7 @@ export class WebViewContentGenerator { return JSON.stringify(Array.from(featureConfigs.entries())) } - public async generate(extensionURI: Uri, webView: Webview): Promise { + public async generate(extensionURI: Uri, webView: Webview, showWelcomePage: boolean): Promise { const entrypoint = process.env.WEBPACK_DEVELOPER_SERVER ? 'http: localhost' : 'https: file+.vscode-resources.vscode-cdn.net' @@ -47,14 +47,14 @@ export class WebViewContentGenerator { Amazon Q (Preview) - ${await this.generateJS(extensionURI, webView)} + ${await this.generateJS(extensionURI, webView, showWelcomePage)} ` } - private async generateJS(extensionURI: Uri, webView: Webview): Promise { + private async generateJS(extensionURI: Uri, webView: Webview, showWelcomePage: boolean): Promise { const source = path.join('vue', 'src', 'amazonq', 'webview', 'ui', 'amazonq-ui.js') // Sent to dist/vue folder in webpack. const assetsPath = Uri.joinPath(extensionURI) const javascriptUri = Uri.joinPath(assetsPath, 'dist', source) @@ -86,7 +86,7 @@ export class WebViewContentGenerator { const init = () => { createMynahUI(acquireVsCodeApi(), ${ (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' - },${featureConfigsString},${disabledCommandsString}); + },${featureConfigsString},${showWelcomePage},${disabledCommandsString}); } ` diff --git a/packages/core/src/amazonq/webview/messages/messageDispatcher.ts b/packages/core/src/amazonq/webview/messages/messageDispatcher.ts index 38a36ba14d7..244f6ae1583 100644 --- a/packages/core/src/amazonq/webview/messages/messageDispatcher.ts +++ b/packages/core/src/amazonq/webview/messages/messageDispatcher.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Webview } from 'vscode' +import { Webview, Uri } from 'vscode' import { MessagePublisher } from '../../messages/messagePublisher' import { MessageListener } from '../../messages/messageListener' import { TabType } from '../ui/storages/tabsStorage' @@ -11,6 +11,8 @@ import { getLogger } from '../../../shared/logger' import { amazonqMark } from '../../../shared/performance/marks' import { telemetry } from '../../../shared/telemetry' import { AmazonQChatMessageDuration } from '../../messages/chatMessageDuration' +import { openUrl } from '../../../shared' +import { isClickTelemetry, isOpenAgentTelemetry } from '../ui/telemetry/actions' export function dispatchWebViewMessagesToApps( webview: Webview, @@ -46,6 +48,25 @@ export function dispatchWebViewMessagesToApps( AmazonQChatMessageDuration.stopChatMessageTelemetry(msg) return } + case 'open-user-guide': { + const { userGuideLink } = msg + void openUrl(Uri.parse(userGuideLink)) + return + } + case 'send-telemetry': { + if (isOpenAgentTelemetry(msg)) { + telemetry.toolkit_openModule.emit({ + module: msg.module, + source: msg.trigger, + result: 'Succeeded', + }) + } else if (isClickTelemetry(msg)) { + telemetry.ui_click.emit({ + elementId: msg.source, + result: 'Succeeded', + }) + } + } } if (msg.type === 'error') { diff --git a/packages/core/src/amazonq/webview/ui/apps/amazonqCommonsConnector.ts b/packages/core/src/amazonq/webview/ui/apps/amazonqCommonsConnector.ts index c5a87857771..c0f031509d3 100644 --- a/packages/core/src/amazonq/webview/ui/apps/amazonqCommonsConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/amazonqCommonsConnector.ts @@ -3,15 +3,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemAction } from '@aws/mynah-ui' +import { ChatItem, ChatItemAction, ChatItemType, ChatPrompt } from '@aws/mynah-ui' import { ExtensionMessage } from '../commands' import { AuthFollowUpType } from '../followUps/generator' +import { getTabCommandFromTabType, isTabType, TabType } from '../storages/tabsStorage' +import { + docUserGuide, + userGuideURL as featureDevUserGuide, + helpMessage, + reviewGuideUrl, + testGuideUrl, +} from '../texts/constants' +import { linkToDocsHome } from '../../../../codewhisperer/models/constants' +import { createClickTelemetry, createOpenAgentTelemetry } from '../telemetry/actions' export type WelcomeFollowupType = 'continue-to-chat' export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void + onNewTab: (tabType: TabType) => void + handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void + sendStaticMessages: (tabID: string, messages: ChatItem[]) => void } export interface CodeReference { licenseName?: string @@ -26,10 +39,16 @@ export interface CodeReference { export class Connector { private readonly sendMessageToExtension private readonly onWelcomeFollowUpClicked + private readonly onNewTab + private readonly handleCommand + private readonly sendStaticMessage constructor(props: ConnectorProps) { this.sendMessageToExtension = props.sendMessageToExtension this.onWelcomeFollowUpClicked = props.onWelcomeFollowUpClicked + this.onNewTab = props.onNewTab + this.handleCommand = props.handleCommand + this.sendStaticMessage = props.sendStaticMessages } followUpClicked = (tabID: string, followUp: ChatItemAction): void => { @@ -46,4 +65,90 @@ export class Connector { tabType, }) } + + handleMessageReceive = async (messageData: any): Promise => { + if (messageData.command === 'showExploreAgentsView') { + this.onNewTab('agentWalkthrough') + return + } else if (messageData.command === 'review') { + this.onNewTab('review') + return + } + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + const tabType = action.id.split('-')[2] + if (!isTabType(tabType)) { + return + } + + if (action.id.startsWith('user-guide-')) { + this.processUserGuideLink(tabType, action.id) + return + } + + if (action.id.startsWith('quick-start-')) { + this.handleCommand( + { + command: getTabCommandFromTabType(tabType), + }, + tabId + ) + + this.sendMessageToExtension(createOpenAgentTelemetry(tabType, 'quick-start')) + } + } + + private processUserGuideLink(tabType: TabType, actionId: string) { + let userGuideLink = '' + switch (tabType) { + case 'featuredev': + userGuideLink = featureDevUserGuide + break + case 'testgen': + userGuideLink = testGuideUrl + break + case 'review': + userGuideLink = reviewGuideUrl + break + case 'doc': + userGuideLink = docUserGuide + break + case 'gumby': + userGuideLink = linkToDocsHome + break + } + + // e.g. amazonq-explore-user-guide-featuredev + this.sendMessageToExtension(createClickTelemetry(`amazonq-explore-${actionId}`)) + + this.sendMessageToExtension({ + command: 'open-user-guide', + userGuideLink, + }) + } + + sendMessage(tabID: string, message: 'help') { + switch (message) { + case 'help': + this.sendStaticMessage(tabID, [ + { + type: ChatItemType.PROMPT, + body: 'How can Amazon Q help me?', + }, + { + type: ChatItemType.ANSWER, + body: helpMessage, + }, + ]) + break + } + } } diff --git a/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts new file mode 100644 index 00000000000..efabe2be4f5 --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/apps/docChatConnector.ts @@ -0,0 +1,229 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItem, ChatItemType, FeedbackPayload, MynahIcons, ProgressField } from '@aws/mynah-ui' +import { TabType } from '../storages/tabsStorage' +import { DiffTreeFileInfo } from '../diffTree/types' +import { BaseConnectorProps, BaseConnector } from './baseConnector' + +export interface ConnectorProps extends BaseConnectorProps { + onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string) => void + sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined + onFileComponentUpdate: ( + tabID: string, + filePaths: DiffTreeFileInfo[], + deletedFiles: DiffTreeFileInfo[], + messageId: string, + disableFileActions: boolean + ) => void + onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void + onNewTab: (tabType: TabType) => void +} + +export class Connector extends BaseConnector { + private readonly onFileComponentUpdate + private readonly onAsyncEventProgress + private readonly updatePlaceholder + private readonly chatInputEnabled + private readonly onUpdateAuthentication + private readonly onNewTab + private readonly updatePromptProgress + + override getTabType(): TabType { + return 'doc' + } + + constructor(props: ConnectorProps) { + super(props) + this.onFileComponentUpdate = props.onFileComponentUpdate + this.onAsyncEventProgress = props.onAsyncEventProgress + this.updatePlaceholder = props.onUpdatePlaceholder + this.chatInputEnabled = props.onChatInputEnabled + this.onUpdateAuthentication = props.onUpdateAuthentication + this.onNewTab = props.onNewTab + this.updatePromptProgress = props.onUpdatePromptProgress + } + + onOpenDiff = (tabID: string, filePath: string, deleted: boolean): void => { + this.sendMessageToExtension({ + command: 'open-diff', + tabID, + filePath, + deleted, + tabType: this.getTabType(), + }) + } + onFileActionClick = (tabID: string, messageId: string, filePath: string, actionName: string): void => { + this.sendMessageToExtension({ + command: 'file-click', + tabID, + messageId, + filePath, + actionName, + tabType: this.getTabType(), + }) + } + + private processFolderConfirmationMessage = async (messageData: any, folderPath: string): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer: ChatItem = { + type: ChatItemType.ANSWER, + body: messageData.message ?? undefined, + messageId: messageData.messageID ?? messageData.triggerID ?? '', + fileList: { + rootFolderTitle: undefined, + fileTreeTitle: '', + filePaths: [folderPath], + details: { + [folderPath]: { + icon: MynahIcons.FOLDER, + clickable: false, + }, + }, + }, + followUp: { + text: '', + options: messageData.followUps, + }, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + body: messageData.message ?? undefined, + messageId: messageData.messageID ?? messageData.triggerID ?? '', + relatedContent: undefined, + canBeVoted: messageData.canBeVoted, + snapToTop: messageData.snapToTop, + followUp: + messageData.followUps !== undefined && messageData.followUps.length > 0 + ? { + text: + messageData.messageType === ChatItemType.SYSTEM_PROMPT + ? '' + : 'Select one of the following...', + options: messageData.followUps, + } + : undefined, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + private processCodeResultMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived !== undefined) { + const answer: ChatItem = { + type: ChatItemType.ANSWER, + relatedContent: undefined, + followUp: undefined, + canBeVoted: false, + codeReference: messageData.references, + // TODO get the backend to store a message id in addition to conversationID + messageId: + messageData.codeGenerationId ?? + messageData.messageID ?? + messageData.triggerID ?? + messageData.conversationID, + fileList: { + rootFolderTitle: 'Documentation', + fileTreeTitle: 'Documents ready', + filePaths: messageData.filePaths.map((f: DiffTreeFileInfo) => f.zipFilePath), + deletedFiles: messageData.deletedFiles.map((f: DiffTreeFileInfo) => f.zipFilePath), + }, + body: '', + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + handleMessageReceive = async (messageData: any): Promise => { + if (messageData.type === 'updateFileComponent') { + this.onFileComponentUpdate( + messageData.tabID, + messageData.filePaths, + messageData.deletedFiles, + messageData.messageId, + messageData.disableFileActions + ) + return + } + + if (messageData.type === 'chatMessage') { + await this.processChatMessage(messageData) + return + } + + if (messageData.type === 'folderConfirmationMessage') { + await this.processFolderConfirmationMessage(messageData, messageData.folderPath) + return + } + + if (messageData.type === 'codeResultMessage') { + await this.processCodeResultMessage(messageData) + return + } + + if (messageData.type === 'asyncEventProgressMessage') { + this.onAsyncEventProgress(messageData.tabID, messageData.inProgress, messageData.message ?? undefined) + return + } + + if (messageData.type === 'updatePlaceholderMessage') { + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + return + } + + if (messageData.type === 'chatInputEnabledMessage') { + this.chatInputEnabled(messageData.tabID, messageData.enabled) + return + } + + if (messageData.type === 'authenticationUpdateMessage') { + this.onUpdateAuthentication(messageData.featureEnabled, messageData.authenticatingTabIDs) + return + } + + if (messageData.type === 'openNewTabMessage') { + this.onNewTab(this.getTabType()) + return + } + + if (messageData.type === 'updatePromptProgress') { + this.updatePromptProgress(messageData.tabID, messageData.progressField) + return + } + + // For other message types, call the base class handleMessageReceive + await this.baseHandleMessageReceive(messageData) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: 'doc', + tabID: tabId, + }) + } +} diff --git a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts index 83dd25b4889..69eb9f1c716 100644 --- a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts @@ -191,7 +191,7 @@ export class Connector extends BaseConnector { } if (messageData.type === 'authenticationUpdateMessage') { - this.onUpdateAuthentication(messageData.featureDevEnabled, messageData.authenticatingTabIDs) + this.onUpdateAuthentication(messageData.featureEnabled, messageData.authenticatingTabIDs) return } diff --git a/packages/core/src/amazonq/webview/ui/apps/scanChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/scanChatConnector.ts new file mode 100644 index 00000000000..2087be77234 --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/apps/scanChatConnector.ts @@ -0,0 +1,191 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for listening to and processing events + * from the webview and translating them into events to be handled by the extension, + * and events from the extension and translating them into events to be handled by the webview. + */ + +import { ChatItem, ChatItemType, ProgressField } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { TabsStorage, TabType } from '../storages/tabsStorage' +import { ScanMessageType } from '../../../../amazonqScan/connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export interface ConnectorProps extends BaseConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void + onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void + onWarning: (tabID: string, message: string, title: string) => void + onError: (tabID: string, message: string, title: string) => void + onUpdateAuthentication: (scanEnabled: boolean, authenticatingTabIDs: string[]) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + tabsStorage: TabsStorage +} + +export interface MessageData { + tabID: string + type: ScanMessageType +} + +export class Connector extends BaseConnector { + override getTabType(): TabType { + return 'review' + } + readonly onAuthenticationUpdate + override readonly sendMessageToExtension + override readonly onError + override readonly onChatAnswerReceived + private readonly chatInputEnabled + private readonly onQuickHandlerCommand + private readonly updatePlaceholder + private readonly updatePromptProgress + + constructor(props: ConnectorProps) { + super(props) + this.sendMessageToExtension = props.sendMessageToExtension + this.onChatAnswerReceived = props.onChatAnswerReceived + this.onError = props.onError + this.chatInputEnabled = props.onChatInputEnabled + this.updatePlaceholder = props.onUpdatePlaceholder + this.updatePromptProgress = props.onUpdatePromptProgress + this.onQuickHandlerCommand = props.onQuickHandlerCommand + this.onAuthenticationUpdate = props.onUpdateAuthentication + } + + scan = (tabID: string): void => { + this.sendMessageToExtension({ + tabID: tabID, + command: 'review', + chatMessage: '', + tabType: 'review', + }) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: 'review', + tabID: tabId, + }) + } + + private processChatPrompt = async (messageData: any, tabID: string): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + const answer: ChatItem = { + type: ChatItemType.PROMPT, + body: messageData.message, + followUp: undefined, + status: 'info', + canBeVoted: false, + } + this.onChatAnswerReceived(tabID, answer, messageData) + return + } + + private processExecuteCommand = async (messageData: any): Promise => { + this.onQuickHandlerCommand(messageData.tabID, messageData.command, messageData.eventId) + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + buttons: messageData.buttons ?? [], + canBeVoted: messageData.canBeVoted, + followUp: + messageData.followUps !== undefined && messageData.followUps.length > 0 + ? { + text: '', + options: messageData.followUps, + } + : undefined, + informationCard: messageData.informationCard, + fileList: messageData.fileList, + } + + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + + override processAuthNeededException = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: messageData.message, + }, + messageData + ) + } + + // This handles messages received from the extension, to be forwarded to the webview + handleMessageReceive = async (messageData: { type: ScanMessageType } & Record) => { + switch (messageData.type) { + case 'authNeededException': + await this.processAuthNeededException(messageData) + break + case 'authenticationUpdateMessage': + this.onAuthenticationUpdate(messageData.scanEnabled, messageData.authenticatingTabIDs) + break + case 'chatInputEnabledMessage': + this.chatInputEnabled(messageData.tabID, messageData.enabled) + break + case 'chatMessage': + await this.processChatMessage(messageData) + break + case 'updatePlaceholderMessage': + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + break + case 'updatePromptProgress': + this.updatePromptProgress(messageData.tabID, messageData.progressField) + break + case 'chatPrompt': + await this.processChatPrompt(messageData, messageData.tabID) + break + case 'errorMessage': + this.onError(messageData.tabID, messageData.message, messageData.title) + break + case 'sendCommandMessage': + await this.processExecuteCommand(messageData) + break + } + } + + onFileClick = (tabID: string, filePath: string, messageId?: string) => { + this.sendMessageToExtension({ + command: 'file-click', + tabID, + messageId, + filePath, + tabType: 'review', + }) + } +} diff --git a/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts new file mode 100644 index 00000000000..3fa53cd97f8 --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/apps/testChatConnector.ts @@ -0,0 +1,235 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for listening to and processing events + * from the webview and translating them into events to be handled by the extension, + * and events from the extension and translating them into events to be handled by the webview. + */ + +import { ChatItem, ChatItemType, MynahIcons, ProgressField } from '@aws/mynah-ui' +import { ExtensionMessage } from '../commands' +import { TabsStorage, TabType } from '../storages/tabsStorage' +import { TestMessageType } from '../../../../amazonqTest/chat/views/connector/connector' +import { ChatPayload } from '../connector' +import { BaseConnector, BaseConnectorProps } from './baseConnector' + +export interface ConnectorProps extends BaseConnectorProps { + sendMessageToExtension: (message: ExtensionMessage) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void + onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void + onWarning: (tabID: string, message: string, title: string) => void + onError: (tabID: string, message: string, title: string) => void + onUpdateAuthentication: (testEnabled: boolean, authenticatingTabIDs: string[]) => void + onChatInputEnabled: (tabID: string, enabled: boolean) => void + onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void + tabsStorage: TabsStorage +} + +export interface MessageData { + tabID: string + type: TestMessageType +} +//TODO: Refactor testChatConnector, scanChatConnector and other apps connector files post RIV +export class Connector extends BaseConnector { + override getTabType(): TabType { + return 'testgen' + } + readonly onAuthenticationUpdate + override readonly sendMessageToExtension + override readonly onChatAnswerReceived + private readonly onChatAnswerUpdated + private readonly chatInputEnabled + private readonly updatePlaceholder + private readonly updatePromptProgress + override readonly onError + private readonly tabStorage + private readonly runTestMessageReceived + + constructor(props: ConnectorProps) { + super(props) + this.runTestMessageReceived = props.onRunTestMessageReceived + this.sendMessageToExtension = props.sendMessageToExtension + this.onChatAnswerReceived = props.onChatAnswerReceived + this.onChatAnswerUpdated = props.onChatAnswerUpdated + this.chatInputEnabled = props.onChatInputEnabled + this.updatePlaceholder = props.onUpdatePlaceholder + this.updatePromptProgress = props.onUpdatePromptProgress + this.onAuthenticationUpdate = props.onUpdateAuthentication + this.onError = props.onError + this.tabStorage = props.tabsStorage + } + + startTestGen(tabID: string, prompt: string) { + this.sendMessageToExtension({ + tabID: tabID, + command: 'start-test-gen', + tabType: 'testgen', + prompt, + }) + } + + requestAnswer = (tabID: string, payload: ChatPayload) => { + this.tabStorage.updateTabStatus(tabID, 'busy') + this.sendMessageToExtension({ + tabID: tabID, + command: 'chat-prompt', + chatMessage: payload.chatMessage, + chatCommand: payload.chatCommand, + tabType: 'testgen', + }) + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + description?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + + this.sendMessageToExtension({ + command: 'form-action-click', + action: action.id, + formSelectedValues: action.formItemValues, + tabType: 'testgen', + tabID: tabId, + description: action.description, + }) + } + + onFileDiff = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { + // TODO: add this back once we can advance flow from here + // this.sendMessageToExtension({ + // command: 'open-diff', + // tabID, + // filePath, + // deleted, + // messageId, + // tabType: 'testgen', + // }) + } + + private processChatMessage = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + if (messageData.command === 'test' && this.runTestMessageReceived) { + this.runTestMessageReceived(messageData.tabID, true) + return + } + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + canBeVoted: false, + informationCard: messageData.informationCard, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + } + // Displays the test generation summary message in the /test Tab before generating unit tests + private processChatSummaryMessage = async (messageData: any): Promise => { + if (this.onChatAnswerUpdated === undefined) { + return + } + if (messageData.message !== undefined) { + const answer: ChatItem = { + type: messageData.messageType, + messageId: messageData.messageId ?? messageData.triggerID, + body: messageData.message, + canBeVoted: true, + footer: messageData.filePath + ? { + fileList: { + rootFolderTitle: undefined, + fileTreeTitle: '', + filePaths: [messageData.filePath], + details: { + [messageData.filePath]: { + icon: MynahIcons.FILE, + description: `Generating tests in ${messageData.filePath}`, + }, + }, + }, + } + : {}, + } + this.onChatAnswerUpdated(messageData.tabID, answer) + } + } + + override processAuthNeededException = async (messageData: any): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: messageData.message, + }, + messageData + ) + } + + private processBuildProgressMessage = async ( + messageData: { type: TestMessageType } & Record + ): Promise => { + if (this.onChatAnswerReceived === undefined) { + return + } + const answer: ChatItem = { + type: messageData.messageType, + canBeVoted: messageData.canBeVoted, + messageId: messageData.messageId, + followUp: messageData.followUps, + fileList: messageData.fileList, + body: messageData.message, + codeReference: messageData.codeReference, + } + this.onChatAnswerReceived(messageData.tabID, answer, messageData) + } + + // This handles messages received from the extension, to be forwarded to the webview + handleMessageReceive = async (messageData: { type: TestMessageType } & Record) => { + switch (messageData.type) { + case 'authNeededException': + await this.processAuthNeededException(messageData) + break + case 'authenticationUpdateMessage': + this.onAuthenticationUpdate(messageData.testEnabled, messageData.authenticatingTabIDs) + break + case 'chatInputEnabledMessage': + this.chatInputEnabled(messageData.tabID, messageData.enabled) + break + case 'chatMessage': + await this.processChatMessage(messageData) + break + case 'chatSummaryMessage': + await this.processChatSummaryMessage(messageData) + break + case 'updatePlaceholderMessage': + this.updatePlaceholder(messageData.tabID, messageData.newPlaceholder) + break + case 'buildProgressMessage': + await this.processBuildProgressMessage(messageData) + break + case 'updatePromptProgress': + this.updatePromptProgress(messageData.tabID, messageData.progressField) + break + case 'errorMessage': + this.onError(messageData.tabID, messageData.message, messageData.title) + } + } +} diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 94e1fddb251..ea925d93bef 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -36,5 +36,9 @@ type MessageCommand = | 'start-chat-message-telemetry' | 'stop-chat-message-telemetry' | 'store-code-result-message-id' + | 'start-test-gen' + | 'review' + | 'open-user-guide' + | 'send-telemetry' export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 9f265fbfe7e..a0ddb355d87 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -9,12 +9,17 @@ import { Engagement, ChatItemAction, CodeSelectionType, + ProgressField, ReferenceTrackerInformation, + ChatPrompt, } from '@aws/mynah-ui' import { Connector as CWChatConnector } from './apps/cwChatConnector' import { Connector as FeatureDevChatConnector } from './apps/featureDevChatConnector' import { Connector as AmazonQCommonsConnector } from './apps/amazonqCommonsConnector' import { Connector as GumbyChatConnector } from './apps/gumbyChatConnector' +import { Connector as ScanChatConnector } from './apps/scanChatConnector' +import { Connector as TestChatConnector } from './apps/testChatConnector' +import { Connector as docChatConnector } from './apps/docChatConnector' import { ExtensionMessage } from './commands' import { TabType, TabsStorage } from './storages/tabsStorage' import { WelcomeFollowupType } from './apps/amazonqCommonsConnector' @@ -57,6 +62,7 @@ export interface CWCChatItem extends ChatItem { export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void + onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void @@ -74,10 +80,13 @@ export interface ConnectorProps { disableFileActions: boolean ) => void onUpdatePlaceholder: (tabID: string, newPlaceholder: string) => void + onUpdatePromptProgress: (tabID: string, progressField: ProgressField) => void onChatInputEnabled: (tabID: string, enabled: boolean) => void onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void onNewTab: (tabType: TabType) => void onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void + sendStaticMessages: (tabID: string, messages: ChatItem[]) => void tabsStorage: TabsStorage } @@ -87,6 +96,9 @@ export class Connector { private readonly cwChatConnector private readonly featureDevChatConnector private readonly gumbyChatConnector + private readonly scanChatConnector + private readonly testChatConnector + private readonly docChatConnector private readonly tabsStorage private readonly amazonqCommonsConnector: AmazonQCommonsConnector @@ -97,10 +109,16 @@ export class Connector { this.onMessageReceived = props.onMessageReceived this.cwChatConnector = new CWChatConnector(props as ConnectorProps) this.featureDevChatConnector = new FeatureDevChatConnector(props) + this.docChatConnector = new docChatConnector(props) this.gumbyChatConnector = new GumbyChatConnector(props) + this.scanChatConnector = new ScanChatConnector(props) + this.testChatConnector = new TestChatConnector(props) this.amazonqCommonsConnector = new AmazonQCommonsConnector({ sendMessageToExtension: this.sendMessageToExtension, onWelcomeFollowUpClicked: props.onWelcomeFollowUpClicked, + onNewTab: props.onNewTab, + handleCommand: props.handleCommand, + sendStaticMessages: props.sendStaticMessages, }) this.tabsStorage = props.tabsStorage } @@ -123,6 +141,15 @@ export class Connector { break case 'gumby': this.gumbyChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'review': + this.scanChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'testgen': + this.testChatConnector.onResponseBodyLinkClick(tabID, messageId, link) + break + case 'doc': + this.docChatConnector.onResponseBodyLinkClick(tabID, messageId, link) } } @@ -138,6 +165,8 @@ export class Connector { switch (this.tabsStorage.getTab(tabID)?.type) { case 'gumby': return this.gumbyChatConnector.requestAnswer(tabID, payload) + case 'testgen': + return this.testChatConnector.requestAnswer(tabID, payload) } } @@ -147,6 +176,8 @@ export class Connector { switch (this.tabsStorage.getTab(tabID)?.type) { case 'featuredev': return this.featureDevChatConnector.requestGenerativeAIAnswer(tabID, messageId, payload) + case 'doc': + return this.docChatConnector.requestGenerativeAIAnswer(tabID, messageId, payload) default: return this.cwChatConnector.requestGenerativeAIAnswer(tabID, messageId, payload) } @@ -168,15 +199,30 @@ export class Connector { help = (tabID: string): void => { switch (this.tabsStorage.getTab(tabID)?.type) { case 'cwc': + /** + * TODO remove cwc helper and switch to the generic one + * that welcome uses + */ this.cwChatConnector.help(tabID) break + case 'welcome': + this.amazonqCommonsConnector.sendMessage(tabID, 'help') + break } } + startTestGen = (tabID: string, prompt: string): void => { + this.testChatConnector.startTestGen(tabID, prompt) + } + transform = (tabID: string): void => { this.gumbyChatConnector.transform(tabID) } + scans = (tabID: string): void => { + this.scanChatConnector.scan(tabID) + } + onStopChatResponse = (tabID: string): void => { switch (this.tabsStorage.getTab(tabID)?.type) { case 'featuredev': @@ -192,7 +238,6 @@ export class Connector { if (message.data === undefined) { return } - // TODO: potential json parsing error exists. Need to determine the failing case. const messageData = JSON.parse(message.data) @@ -206,6 +251,14 @@ export class Connector { await this.featureDevChatConnector.handleMessageReceive(messageData) } else if (messageData.sender === 'gumbyChat') { await this.gumbyChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'scanChat') { + await this.scanChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'testChat') { + await this.testChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'docChat') { + await this.docChatConnector.handleMessageReceive(messageData) + } else if (messageData.sender === 'amazonqCore') { + await this.amazonqCommonsConnector.handleMessageReceive(messageData) } // Reset lastCommand after message is rendered. @@ -230,6 +283,12 @@ export class Connector { case 'gumby': this.gumbyChatConnector.onTabAdd(tabID) break + case 'review': + this.scanChatConnector.onTabAdd(tabID) + break + case 'testgen': + this.testChatConnector.onTabAdd(tabID) + break } } @@ -238,6 +297,12 @@ export class Connector { case 'featuredev': this.featureDevChatConnector.onTabOpen(tabID) break + case 'doc': + this.docChatConnector.onTabOpen(tabID) + break + case 'review': + this.scanChatConnector.onTabOpen(tabID) + break } } @@ -287,6 +352,9 @@ export class Connector { codeBlockLanguage ) break + case 'testgen': + this.testChatConnector.onCodeInsertToCursorPosition(tabID, messageId, code, type, codeReference) + break } } @@ -402,9 +470,18 @@ export class Connector { case 'featuredev': this.featureDevChatConnector.onTabRemove(tabID) break + case 'doc': + this.docChatConnector.onTabRemove(tabID) + break case 'gumby': this.gumbyChatConnector.onTabRemove(tabID) break + case 'review': + this.scanChatConnector.onTabRemove(tabID) + break + case 'testgen': + this.testChatConnector.onTabRemove(tabID) + break } } @@ -453,6 +530,7 @@ export class Connector { const tabType = this.tabsStorage.getTab(tabID)?.type switch (tabType) { case 'cwc': + case 'doc': case 'featuredev': this.amazonqCommonsConnector.authFollowUpClicked(tabID, tabType, authType) } @@ -469,6 +547,15 @@ export class Connector { case 'featuredev': this.featureDevChatConnector.followUpClicked(tabID, messageId, followUp) break + case 'testgen': + this.testChatConnector.followUpClicked(tabID, messageId, followUp) + break + case 'review': + this.scanChatConnector.followUpClicked(tabID, messageId, followUp) + break + case 'doc': + this.docChatConnector.followUpClicked(tabID, messageId, followUp) + break default: this.cwChatConnector.followUpClicked(tabID, messageId, followUp) break @@ -480,14 +567,26 @@ export class Connector { case 'featuredev': this.featureDevChatConnector.onFileActionClick(tabID, messageId, filePath, actionName) break + case 'doc': + this.docChatConnector.onFileActionClick(tabID, messageId, filePath, actionName) + break } } - onOpenDiff = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { + onFileClick = (tabID: string, filePath: string, deleted: boolean, messageId?: string): void => { switch (this.tabsStorage.getTab(tabID)?.type) { case 'featuredev': this.featureDevChatConnector.onOpenDiff(tabID, filePath, deleted, messageId) break + case 'testgen': + this.testChatConnector.onFileDiff(tabID, filePath, deleted, messageId) + break + case 'review': + this.scanChatConnector.onFileClick(tabID, filePath, messageId) + break + case 'doc': + this.docChatConnector.onOpenDiff(tabID, filePath, deleted) + break } } @@ -510,6 +609,12 @@ export class Connector { case 'featuredev': this.featureDevChatConnector.onChatItemVoted(tabId, messageId, vote) break + case 'review': + this.scanChatConnector.onChatItemVoted(tabId, messageId, vote) + break + case 'testgen': + this.testChatConnector.onChatItemVoted(tabId, messageId, vote) + break } } @@ -523,6 +628,15 @@ export class Connector { case 'gumby': this.gumbyChatConnector.onCustomFormAction(tabId, action) break + case 'testgen': + this.testChatConnector.onCustomFormAction(tabId, action) + break + case 'review': + this.scanChatConnector.onCustomFormAction(tabId, action) + break + case 'doc': + this.docChatConnector.onCustomFormAction(tabId, action) + break case 'cwc': if (action.id === `open-settings`) { this.sendMessageToExtension({ @@ -531,6 +645,11 @@ export class Connector { tabType: 'cwc', }) } + break + case 'agentWalkthrough': { + this.amazonqCommonsConnector.onCustomFormAction(tabId, action) + break + } } } } diff --git a/packages/core/src/amazonq/webview/ui/followUps/generator.ts b/packages/core/src/amazonq/webview/ui/followUps/generator.ts index a653b6ef29a..cce5726398f 100644 --- a/packages/core/src/amazonq/webview/ui/followUps/generator.ts +++ b/packages/core/src/amazonq/webview/ui/followUps/generator.ts @@ -52,6 +52,22 @@ export class FollowUpGenerator { }, ], } + case 'doc': + return { + text: 'Select one of the following...', + options: [ + { + pillText: 'Create a README', + prompt: 'Create a README', + type: 'CreateDocumentation', + }, + { + pillText: 'Update an existing README', + prompt: 'Update an existing README', + type: 'UpdateDocumentation', + }, + ], + } default: return { text: 'Try Examples:', diff --git a/packages/core/src/amazonq/webview/ui/followUps/handler.ts b/packages/core/src/amazonq/webview/ui/followUps/handler.ts index 032ae47d50b..4cb7c8530dc 100644 --- a/packages/core/src/amazonq/webview/ui/followUps/handler.ts +++ b/packages/core/src/amazonq/webview/ui/followUps/handler.ts @@ -3,11 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemAction, ChatItemType, MynahUI } from '@aws/mynah-ui' +import { ChatItemAction, ChatItemType, MynahIcons, MynahUI } from '@aws/mynah-ui' import { Connector } from '../connector' import { TabsStorage } from '../storages/tabsStorage' import { WelcomeFollowupType } from '../apps/amazonqCommonsConnector' import { AuthFollowUpType } from './generator' +import { FollowUpTypes } from '../../../commons/types' export interface FollowUpInteractionHandlerProps { mynahUI: MynahUI @@ -67,6 +68,83 @@ export class FollowUpInteractionHandler { return } } + + const addChatItem = (tabID: string, messageId: string, options: any[]) => { + this.mynahUI.addChatItem(tabID, { + type: ChatItemType.ANSWER_PART, + messageId, + followUp: { + text: '', + options, + }, + }) + } + + const ViewDiffOptions = [ + { + icon: MynahIcons.OK, + pillText: 'Accept', + status: 'success', + type: FollowUpTypes.AcceptCode, + }, + { + icon: MynahIcons.REVERT, + pillText: 'Reject', + status: 'error', + type: FollowUpTypes.RejectCode, + }, + ] + + const AcceptCodeOptions = [ + { + icon: MynahIcons.OK, + pillText: 'Accepted', + status: 'success', + disabled: true, + }, + ] + + const RejectCodeOptions = [ + { + icon: MynahIcons.REVERT, + pillText: 'Rejected', + status: 'error', + disabled: true, + }, + ] + + const ViewCodeDiffAfterIterationOptions = [ + { + icon: MynahIcons.OK, + pillText: 'Accept', + status: 'success', + type: FollowUpTypes.AcceptCode, + }, + { + icon: MynahIcons.REVERT, + pillText: 'Reject', + status: 'error', + type: FollowUpTypes.RejectCode, // TODO: Add new Followup Action for "Reject" + }, + ] + + if (this.tabsStorage.getTab(tabID)?.type === 'testgen') { + switch (followUp.type) { + case FollowUpTypes.ViewDiff: + addChatItem(tabID, messageId, ViewDiffOptions) + break + case FollowUpTypes.AcceptCode: + addChatItem(tabID, messageId, AcceptCodeOptions) + break + case FollowUpTypes.RejectCode: + addChatItem(tabID, messageId, RejectCodeOptions) + break + case FollowUpTypes.ViewCodeDiffAfterIteration: + addChatItem(tabID, messageId, ViewCodeDiffAfterIterationOptions) + break + } + } + this.connector.onFollowUpClicked(tabID, messageId, followUp) } diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index d84af367291..58511517e7a 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -12,6 +12,7 @@ import { MynahUIDataModel, NotificationType, ReferenceTrackerInformation, + ProgressField, } from '@aws/mynah-ui' import { ChatPrompt } from '@aws/mynah-ui/dist/static' import { TabsStorage, TabType } from './storages/tabsStorage' @@ -27,11 +28,15 @@ import { getActions, getDetails } from './diffTree/actions' import { DiffTreeFileInfo } from './diffTree/types' import { FeatureContext } from '../../../shared' import { tryNewMap } from '../../util/functionUtils' +import { welcomeScreenTabData } from './walkthrough/welcome' +import { agentWalkthroughDataModel } from './walkthrough/agent' +import { createClickTelemetry, createOpenAgentTelemetry } from './telemetry/actions' export const createMynahUI = ( ideApi: any, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][], + showWelcomePage: boolean, disabledCommands?: string[] ) => { // eslint-disable-next-line prefer-const @@ -66,7 +71,7 @@ export const createMynahUI = ( tabsStorage.addTab({ id: 'tab-1', status: 'free', - type: 'cwc', + type: showWelcomePage ? 'welcome' : 'cwc', isSelected: true, }) @@ -75,9 +80,17 @@ export const createMynahUI = ( let isGumbyEnabled = amazonQEnabled + let isScanEnabled = amazonQEnabled + let isTestEnabled = amazonQEnabled + + let isDocEnabled = amazonQEnabled + let tabDataGenerator = new TabDataGenerator({ isFeatureDevEnabled, isGumbyEnabled, + isScanEnabled, + isTestEnabled, + isDocEnabled, disabledCommands, }) @@ -93,7 +106,9 @@ export const createMynahUI = ( // @ts-ignore let featureConfigs: Map = tryNewMap(featureConfigsSerialized) - function shouldDisplayDiff(messageData: any) { + function getCodeBlockActions(messageData: any) { + //Show ViewDiff and AcceptDiff for allowedCommands in CWC + const isEnabled = featureConfigs.get('ViewDiffInChat')?.variation === 'TREATMENT' const tab = tabsStorage.getTab(messageData?.tabID || '') const allowedCommands = [ 'aws.amazonq.refactorCode', @@ -101,18 +116,48 @@ export const createMynahUI = ( 'aws.amazonq.optimizeCode', 'aws.amazonq.sendToPrompt', ] - if (tab?.type === 'cwc' && allowedCommands.includes(tab.lastCommand || '')) { - return true + if (isEnabled && tab?.type === 'cwc' && allowedCommands.includes(tab.lastCommand || '')) { + return { + 'insert-to-cursor': undefined, + accept_diff: { + id: 'accept_diff', + label: 'Apply Diff', + icon: MynahIcons.OK_CIRCLED, + data: messageData, + }, + view_diff: { + id: 'view_diff', + label: 'View Diff', + icon: MynahIcons.EYE, + data: messageData, + }, + } + } + //Show only "Copy" option for codeblocks in Q Test Tab + if (tab?.type === 'testgen') { + return { + 'insert-to-cursor': undefined, + } } - return false + //Default will show "Copy" and "Insert at cursor" for codeblocks + return {} } // eslint-disable-next-line prefer-const connector = new Connector({ tabsStorage, + /** + * Proxy for allowing underlying common connectors to call quick action handlers + */ + handleCommand: (chatPrompt: ChatPrompt, tabId: string) => { + quickActionHandler.handle(chatPrompt, tabId) + }, onUpdateAuthentication: (isAmazonQEnabled: boolean, authenticatingTabIDs: string[]): void => { isFeatureDevEnabled = isAmazonQEnabled isGumbyEnabled = isAmazonQEnabled + isScanEnabled = isAmazonQEnabled + isTestEnabled = isAmazonQEnabled + isDocEnabled = isAmazonQEnabled quickActionHandler = new QuickActionHandler({ mynahUI, @@ -120,12 +165,18 @@ export const createMynahUI = ( tabsStorage, isFeatureDevEnabled, isGumbyEnabled, + isScanEnabled, + isTestEnabled, + isDocEnabled, disabledCommands, }) tabDataGenerator = new TabDataGenerator({ isFeatureDevEnabled, isGumbyEnabled, + isScanEnabled, + isTestEnabled, + isDocEnabled, disabledCommands, }) @@ -146,7 +197,11 @@ export const createMynahUI = ( body: 'Authentication successful. Connected to Amazon Q.', }) - if (tabsStorage.getTab(tabID)?.type === 'gumby') { + if ( + tabsStorage.getTab(tabID)?.type === 'gumby' || + tabsStorage.getTab(tabID)?.type === 'review' || + tabsStorage.getTab(tabID)?.type === 'testgen' + ) { mynahUI.updateStore(tabID, { promptInputDisabledState: false, }) @@ -171,7 +226,8 @@ export const createMynahUI = ( return messageController.sendSelectedCodeToTab(message, command) } else { const tabID = messageController.sendMessageToTab(message, 'cwc', command) - if (tabID) { + if (tabID && command) { + ideApi.postMessage(createOpenAgentTelemetry('cwc', 'right-click')) ideApi.postMessage({ command: 'start-chat-message-telemetry', trigger: 'onContextCommand', @@ -191,6 +247,11 @@ export const createMynahUI = ( promptInputDisabledState: tabsStorage.isTabDead(tabID) || !enabled, }) }, + onUpdatePromptProgress(tabID: string, progressField: ProgressField) { + mynahUI.updateStore(tabID, { + promptInputProgress: progressField, + }) + }, onAsyncEventProgress: ( tabID: string, inProgress: boolean, @@ -239,12 +300,16 @@ export const createMynahUI = ( ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + ...(item.footer !== undefined ? { footer: item.footer } : {}), + ...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}), }) } else { mynahUI.updateLastChatAnswer(tabID, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + ...(item.footer !== undefined ? { footer: item.footer } : {}), + ...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}), }) } }, @@ -256,9 +321,8 @@ export const createMynahUI = ( ...(item.codeReference !== undefined ? { codeReference: item.codeReference } : {}), ...(item.body !== undefined ? { body: item.body } : {}), ...(item.relatedContent !== undefined ? { relatedContent: item.relatedContent } : {}), - ...(item.type === ChatItemType.CODE_RESULT - ? { type: ChatItemType.CODE_RESULT, fileList: item.fileList } - : {}), + ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), + ...(item.fileList !== undefined ? { fileList: item.fileList } : {}), }) if ( item.messageId !== undefined && @@ -286,25 +350,7 @@ export const createMynahUI = ( mynahUI.addChatItem(tabID, { ...item, messageId: item.messageId, - codeBlockActions: { - ...(shouldDisplayDiff(messageData) - ? { - 'insert-to-cursor': undefined, - accept_diff: { - id: 'accept_diff', - label: 'Apply Diff', - icon: MynahIcons.OK_CIRCLED, - data: messageData, - }, - view_diff: { - id: 'view_diff', - label: 'View Diff', - icon: MynahIcons.EYE, - data: messageData, - }, - } - : {}), - }, + codeBlockActions: getCodeBlockActions(messageData), }) } @@ -343,6 +389,11 @@ export const createMynahUI = ( }) } }, + onRunTestMessageReceived: (tabID: string, shouldRunTestMessage: boolean) => { + if (shouldRunTestMessage) { + quickActionHandler.handle({ command: '/test' }, tabID) + } + }, onMessageReceived: (tabID: string, messageData: MynahUIDataModel) => { mynahUI.updateStore(tabID, messageData) }, @@ -380,7 +431,7 @@ export const createMynahUI = ( onError: (tabID: string, message: string, title: string) => { const answer: ChatItem = { type: ChatItemType.ANSWER, - body: `**${title}** + body: `**${title}** ${message}`, } @@ -457,6 +508,31 @@ export const createMynahUI = ( }) return }, + /** + * Helps with sending static messages that don't need to be sent through to the + * VSCode side. E.g. help messages + */ + sendStaticMessages(tabID: string, messages: ChatItem[]) { + if (tabsStorage.getTab(tabID)?.type === 'welcome') { + // set the tab type to cwc since its the most general one + tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + + // collapse the ui before adding the message + mynahUI.updateStore(tabID, { + tabHeaderDetails: void 0, + compactMode: false, + tabBackground: false, + promptInputText: '', + promptInputLabel: void 0, + chatItems: [], + tabTitle: 'Chat', + }) + } + + for (const message of messages) { + mynahUI.addChatItem(tabID, message) + } + }, }) mynahUI = new MynahUI({ @@ -484,31 +560,99 @@ export const createMynahUI = ( return } - if (tabsStorage.getTab(tabID)?.type === 'featuredev') { + const tabType = tabsStorage.getTab(tabID)?.type + if (tabType === 'featuredev') { mynahUI.addChatItem(tabID, { type: ChatItemType.ANSWER_STREAM, }) - } else if (tabsStorage.getTab(tabID)?.type === 'gumby') { + } else if (tabType === 'gumby') { connector.requestAnswer(tabID, { chatMessage: prompt.prompt ?? '', }) return } + if (tabType === 'welcome') { + mynahUI.updateStore(tabID, { + tabHeaderDetails: void 0, + compactMode: false, + tabBackground: false, + promptInputText: '', + promptInputLabel: void 0, + chatItems: [], + }) + } + + // handler for the "/" agent commands if (prompt.command !== undefined && prompt.command.trim() !== '') { quickActionHandler.handle(prompt, tabID, eventId) + + const newTabType = tabsStorage.getSelectedTab()?.type + if (newTabType) { + ideApi.postMessage(createOpenAgentTelemetry(newTabType, 'quick-action')) + } return } + /** + * Update the tab title if coming from the welcome page + * non cwc panels will have this updated automatically + */ + if (tabType === 'welcome') { + mynahUI.updateStore(tabID, { + tabTitle: tabDataGenerator.getTabData('cwc', false).tabTitle, + }) + } + + // handler for the cwc panel textMessageHandler.handle(prompt, tabID, eventId as string) }, onVote: connector.onChatItemVoted, onInBodyButtonClicked: (tabId, messageId, action, eventId) => { + if (action.id === 'quick-start') { + /** + * quick start is the action on the welcome page. When its + * clicked it collapses the view and puts it into regular + * "chat" which is cwc + */ + tabsStorage.updateTabTypeFromUnknown(tabId, 'cwc') + + // show quick start in the current tab instead of a new one + mynahUI.updateStore(tabId, { + tabHeaderDetails: undefined, + compactMode: false, + tabBackground: false, + promptInputText: '/', + promptInputLabel: undefined, + chatItems: [], + }) + + ideApi.postMessage(createClickTelemetry('amazonq-welcome-quick-start-button')) + return + } + + if (action.id === 'explore') { + const newTabId = mynahUI.updateStore('', agentWalkthroughDataModel) + if (newTabId === undefined) { + mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } + tabsStorage.updateTabTypeFromUnknown(newTabId, 'agentWalkthrough') + ideApi.postMessage(createClickTelemetry('amazonq-welcome-explore-button')) + return + } + connector.onCustomFormAction(tabId, messageId, action, eventId) }, onCustomFormAction: (tabId, action, eventId) => { connector.onCustomFormAction(tabId, undefined, action, eventId) }, + onChatPromptProgressActionButtonClicked: (tabID, action) => { + connector.onCustomFormAction(tabID, undefined, action) + }, onSendFeedback: (tabId, feedbackPayload) => { connector.sendFeedback(tabId, feedbackPayload) mynahUI.notify({ @@ -638,11 +782,13 @@ export const createMynahUI = ( onFileActionClick: async (tabID: string, messageId: string, filePath: string, actionName: string) => { connector.onFileActionClick(tabID, messageId, filePath, actionName) }, - onOpenDiff: connector.onOpenDiff, + onFileClick: connector.onFileClick, tabs: { 'tab-1': { isSelected: true, - store: tabDataGenerator.getTabData('cwc', true), + store: showWelcomePage + ? welcomeScreenTabData(tabDataGenerator).store + : tabDataGenerator.getTabData('cwc', true), }, }, defaults: { @@ -666,6 +812,9 @@ export const createMynahUI = ( tabsStorage, isFeatureDevEnabled, isGumbyEnabled, + isScanEnabled, + isTestEnabled, + isDocEnabled, }) textMessageHandler = new TextMessageHandler({ mynahUI, @@ -678,6 +827,9 @@ export const createMynahUI = ( tabsStorage, isFeatureDevEnabled, isGumbyEnabled, + isScanEnabled, + isTestEnabled, + isDocEnabled, }) return { diff --git a/packages/core/src/amazonq/webview/ui/messages/controller.ts b/packages/core/src/amazonq/webview/ui/messages/controller.ts index f49b9a631ce..df9e5454ce1 100644 --- a/packages/core/src/amazonq/webview/ui/messages/controller.ts +++ b/packages/core/src/amazonq/webview/ui/messages/controller.ts @@ -15,6 +15,9 @@ export interface MessageControllerProps { tabsStorage: TabsStorage isFeatureDevEnabled: boolean isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean disabledCommands?: string[] } @@ -31,13 +34,20 @@ export class MessageController { this.tabDataGenerator = new TabDataGenerator({ isFeatureDevEnabled: props.isFeatureDevEnabled, isGumbyEnabled: props.isGumbyEnabled, + isScanEnabled: props.isScanEnabled, + isTestEnabled: props.isTestEnabled, + isDocEnabled: props.isDocEnabled, disabledCommands: props.disabledCommands, }) } public sendSelectedCodeToTab(message: ChatItem, command: string = ''): string | undefined { const selectedTab = { ...this.tabsStorage.getSelectedTab() } - if (selectedTab?.id === undefined || selectedTab?.type === 'featuredev') { + if ( + selectedTab?.id === undefined || + selectedTab?.type === undefined || + ['featuredev', 'gumby', 'review', 'testgen', 'doc'].includes(selectedTab.type) + ) { // Create a new tab if there's none const newTabID: string | undefined = this.mynahUI.updateStore( '', diff --git a/packages/core/src/amazonq/webview/ui/quickActions/generator.ts b/packages/core/src/amazonq/webview/ui/quickActions/generator.ts index 7c0bf334c15..2f86f8e2d1c 100644 --- a/packages/core/src/amazonq/webview/ui/quickActions/generator.ts +++ b/packages/core/src/amazonq/webview/ui/quickActions/generator.ts @@ -5,55 +5,105 @@ import { QuickActionCommand, QuickActionCommandGroup } from '@aws/mynah-ui/dist/static' import { TabType } from '../storages/tabsStorage' +import { MynahIcons } from '@aws/mynah-ui' export interface QuickActionGeneratorProps { isFeatureDevEnabled: boolean isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean disableCommands?: string[] } export class QuickActionGenerator { public isFeatureDevEnabled: boolean private isGumbyEnabled: boolean + private isScanEnabled: boolean + private isTestEnabled: boolean + private isDocEnabled: boolean private disabledCommands: string[] constructor(props: QuickActionGeneratorProps) { this.isFeatureDevEnabled = props.isFeatureDevEnabled this.isGumbyEnabled = props.isGumbyEnabled + this.isScanEnabled = props.isScanEnabled + this.isTestEnabled = props.isTestEnabled + this.isDocEnabled = props.isDocEnabled this.disabledCommands = props.disableCommands ?? [] } public generateForTab(tabType: TabType): QuickActionCommandGroup[] { + // agentWalkthrough is static and doesn't have any quick actions + if (tabType === 'agentWalkthrough') { + return [] + } + + //TODO: Update acc to UX const quickActionCommands = [ { + groupName: `Q Developer agentic capabilities`, commands: [ ...(this.isFeatureDevEnabled && !this.disabledCommands.includes('/dev') ? [ { command: '/dev', + icon: MynahIcons.CODE_BLOCK, placeholder: 'Describe your task or issue in as much detail as possible', description: 'Generate code to make a change in your project', }, ] : []), + ...(this.isTestEnabled && !this.disabledCommands.includes('/test') + ? [ + { + command: '/test', + icon: MynahIcons.CHECK_LIST, + placeholder: 'Specify a function(s) in the current file (optional)', + description: 'Generate unit tests (python & java) for selected code', + }, + ] + : []), + ...(this.isScanEnabled && !this.disabledCommands.includes('/review') + ? [ + { + command: '/review', + icon: MynahIcons.BUG, + description: 'Identify and fix code issues before committing', + }, + ] + : []), + ...(this.isDocEnabled && !this.disabledCommands.includes('/doc') + ? [ + { + command: '/doc', + icon: MynahIcons.FILE, + description: 'Generate documentation', + }, + ] + : []), ...(this.isGumbyEnabled && !this.disabledCommands.includes('/transform') ? [ { command: '/transform', - description: 'Transform your Java project', + description: 'Transform your Java 8 or 11 Maven project to Java 17', + icon: MynahIcons.TRANSFORM, }, ] : []), ], }, { + groupName: 'Quick Actions', commands: [ { command: '/help', + icon: MynahIcons.HELP, description: 'Learn more about Amazon Q', }, { command: '/clear', + icon: MynahIcons.TRASH, description: 'Clear this session', }, ], @@ -61,7 +111,7 @@ export class QuickActionGenerator { ].filter((section) => section.commands.length > 0) const commandUnavailability: Record< - TabType, + Exclude, { description: string unavailableItems: string[] @@ -73,11 +123,27 @@ export class QuickActionGenerator { }, featuredev: { description: "This command isn't available in /dev", - unavailableItems: ['/dev', '/transform', '/help', '/clear'], + unavailableItems: ['/help', '/clear'], + }, + review: { + description: "This command isn't available in /review", + unavailableItems: ['/help', '/clear'], }, gumby: { description: "This command isn't available in /transform", - unavailableItems: ['/dev', '/transform'], + unavailableItems: ['/dev', '/test', '/doc', '/review', '/help', '/clear'], + }, + testgen: { + description: "This command isn't available in /test", + unavailableItems: ['/help', '/clear'], + }, + doc: { + description: "This command isn't available in /doc", + unavailableItems: ['/help', '/clear'], + }, + welcome: { + description: '', + unavailableItems: ['/clear'], }, unknown: { description: '', @@ -87,6 +153,7 @@ export class QuickActionGenerator { return quickActionCommands.map((commandGroup) => { return { + groupName: commandGroup.groupName, commands: commandGroup.commands.map((commandItem: QuickActionCommand) => { const commandNotAvailable = commandUnavailability[tabType].unavailableItems.includes( commandItem.command diff --git a/packages/core/src/amazonq/webview/ui/quickActions/handler.ts b/packages/core/src/amazonq/webview/ui/quickActions/handler.ts index dc9ff8e7dba..78ef3d0e7ec 100644 --- a/packages/core/src/amazonq/webview/ui/quickActions/handler.ts +++ b/packages/core/src/amazonq/webview/ui/quickActions/handler.ts @@ -3,10 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemType, ChatPrompt, MynahUI, NotificationType } from '@aws/mynah-ui' +import { ChatItemType, ChatPrompt, MynahUI, NotificationType, MynahIcons } from '@aws/mynah-ui' import { TabDataGenerator } from '../tabs/generator' import { Connector } from '../connector' -import { TabsStorage } from '../storages/tabsStorage' +import { TabsStorage, TabType } from '../storages/tabsStorage' import { uiComponentsTexts } from '../texts/constants' export interface QuickActionsHandlerProps { @@ -15,9 +15,20 @@ export interface QuickActionsHandlerProps { tabsStorage: TabsStorage isFeatureDevEnabled: boolean isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean disabledCommands?: string[] } +export interface HandleCommandProps { + tabID: string + tabType: TabType + isEnabled: boolean + chatPrompt?: ChatPrompt + eventId?: string + taskName?: string +} export class QuickActionHandler { private mynahUI: MynahUI private connector: Connector @@ -25,25 +36,45 @@ export class QuickActionHandler { private tabDataGenerator: TabDataGenerator private isFeatureDevEnabled: boolean private isGumbyEnabled: boolean + private isScanEnabled: boolean + private isTestEnabled: boolean + private isDocEnabled: boolean constructor(props: QuickActionsHandlerProps) { this.mynahUI = props.mynahUI this.connector = props.connector this.tabsStorage = props.tabsStorage + this.isDocEnabled = props.isDocEnabled this.tabDataGenerator = new TabDataGenerator({ isFeatureDevEnabled: props.isFeatureDevEnabled, isGumbyEnabled: props.isGumbyEnabled, + isScanEnabled: props.isScanEnabled, + isTestEnabled: props.isTestEnabled, + isDocEnabled: props.isDocEnabled, disabledCommands: props.disabledCommands, }) this.isFeatureDevEnabled = props.isFeatureDevEnabled this.isGumbyEnabled = props.isGumbyEnabled + this.isScanEnabled = props.isScanEnabled + this.isTestEnabled = props.isTestEnabled } + /** + * Handle commands + * Inside of the welcome page commands update the current tab + * Outside of the welcome page commands create new tabs + */ public handle(chatPrompt: ChatPrompt, tabID: string, eventId?: string) { this.tabsStorage.resetTabTimer(tabID) switch (chatPrompt.command) { case '/dev': - this.handleFeatureDevCommand(chatPrompt, tabID, 'Q - Dev') + this.handleCommand({ + chatPrompt, + tabID, + taskName: 'Q - Dev', + tabType: 'featuredev', + isEnabled: this.isFeatureDevEnabled, + }) break case '/help': this.handleHelpCommand(tabID) @@ -51,37 +82,52 @@ export class QuickActionHandler { case '/transform': this.handleGumbyCommand(tabID, eventId) break + case '/review': + this.handleScanCommand(tabID, eventId) + break + case '/test': + this.handleTestCommand(chatPrompt, tabID, eventId) + break + case '/doc': + this.handleCommand({ + chatPrompt, + tabID, + taskName: 'Q - Doc', + tabType: 'doc', + isEnabled: this.isDocEnabled, + }) + break case '/clear': this.handleClearCommand(tabID) break } } - private handleGumbyCommand(tabID: string, eventId: string | undefined) { - if (!this.isGumbyEnabled) { + private handleScanCommand(tabID: string, eventId: string | undefined) { + if (!this.isScanEnabled) { return } - - let gumbyTabId: string | undefined = undefined + let scanTabId: string | undefined = undefined this.tabsStorage.getTabs().forEach((tab) => { - if (tab.type === 'gumby') { - gumbyTabId = tab.id + if (tab.type === 'review') { + scanTabId = tab.id } }) - if (gumbyTabId !== undefined) { - this.mynahUI.selectTab(gumbyTabId, eventId || '') - this.connector.onTabChange(gumbyTabId) + if (scanTabId !== undefined) { + this.mynahUI.selectTab(scanTabId, eventId || '') + this.connector.onTabChange(scanTabId) + this.connector.scans(scanTabId) return } let affectedTabId: string | undefined = tabID - // if there is no gumby tab, open a new one - if (this.tabsStorage.getTab(affectedTabId)?.type !== 'unknown') { + // if there is no scan tab, open a new one + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { affectedTabId = this.mynahUI.updateStore('', { loadingChat: true, - cancelButtonWhenLoading: false, }) } @@ -92,7 +138,7 @@ export class QuickActionHandler { }) return } else { - this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'gumby') + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'review') this.connector.onKnownTabOpen(affectedTabId) this.connector.onUpdateTabType(affectedTabId) @@ -101,42 +147,74 @@ export class QuickActionHandler { chatItems: [], }) - this.mynahUI.updateStore(affectedTabId, this.tabDataGenerator.getTabData('gumby', true, undefined)) + this.mynahUI.updateStore(affectedTabId, this.tabDataGenerator.getTabData('review', true, undefined)) // creating a new tab and printing some title // disable chat prompt this.mynahUI.updateStore(affectedTabId, { loadingChat: true, - cancelButtonWhenLoading: false, }) - - this.connector.transform(affectedTabId) + this.connector.scans(affectedTabId) } } - private handleClearCommand(tabID: string) { - this.mynahUI.updateStore(tabID, { - chatItems: [], - }) - this.connector.clearChat(tabID) - } + private handleTestCommand(chatPrompt: ChatPrompt, tabID: string, eventId: string | undefined) { + if (!this.isTestEnabled) { + return + } + const testTabId = this.tabsStorage.getTabs().find((tab) => tab.type === 'testgen')?.id + const realPromptText = chatPrompt.escapedPrompt?.trim() ?? '' - private handleHelpCommand(tabID: string) { - // User entered help action, so change the tab type to 'cwc' if it's an unknown tab - if (this.tabsStorage.getTab(tabID)?.type === 'unknown') { - this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') + if (testTabId !== undefined) { + this.mynahUI.selectTab(testTabId, eventId || '') + this.connector.onTabChange(testTabId) + this.connector.startTestGen(testTabId, realPromptText) + return } - this.connector.help(tabID) + let affectedTabId: string | undefined = tabID + // if there is no test tab, open a new one + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { + affectedTabId = this.mynahUI.updateStore('', { + loadingChat: true, + }) + } + + if (affectedTabId === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } else { + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'testgen') + this.connector.onKnownTabOpen(affectedTabId) + this.connector.onUpdateTabType(affectedTabId) + + // reset chat history + this.mynahUI.updateStore(affectedTabId, { + chatItems: [], + }) + + // creating a new tab and printing some title + this.mynahUI.updateStore( + affectedTabId, + this.tabDataGenerator.getTabData('testgen', realPromptText === '', 'Q - Test') + ) + + this.connector.startTestGen(affectedTabId, realPromptText) + } } - private handleFeatureDevCommand(chatPrompt: ChatPrompt, tabID: string, taskName: string) { - if (!this.isFeatureDevEnabled) { + private handleCommand(props: HandleCommandProps) { + if (!props.isEnabled) { return } - let affectedTabId: string | undefined = tabID - const realPromptText = chatPrompt.escapedPrompt?.trim() ?? '' - if (this.tabsStorage.getTab(affectedTabId)?.type !== 'unknown') { + let affectedTabId: string | undefined = props.tabID + const realPromptText = props.chatPrompt?.escapedPrompt?.trim() ?? '' + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { affectedTabId = this.mynahUI.updateStore('', {}) } if (affectedTabId === undefined) { @@ -146,32 +224,137 @@ export class QuickActionHandler { }) return } else { - this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'featuredev') + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, props.tabType) this.connector.onKnownTabOpen(affectedTabId) this.connector.onUpdateTabType(affectedTabId) this.mynahUI.updateStore(affectedTabId, { chatItems: [] }) - this.mynahUI.updateStore( - affectedTabId, - this.tabDataGenerator.getTabData('featuredev', realPromptText === '', taskName) - ) + if (props.tabType === 'featuredev') { + this.mynahUI.updateStore( + affectedTabId, + this.tabDataGenerator.getTabData(props.tabType, false, props.taskName) + ) + } else { + this.mynahUI.updateStore( + affectedTabId, + this.tabDataGenerator.getTabData(props.tabType, realPromptText === '', props.taskName) + ) + } + + const addInformationCard = (tabId: string) => { + if (props.tabType === 'featuredev') { + this.mynahUI.addChatItem(tabId, { + type: ChatItemType.ANSWER, + informationCard: { + title: 'Feature development', + description: 'Amazon Q Developer Agent for Software Development', + icon: MynahIcons.BUG, + content: { + body: [ + 'After you provide a task, I will:', + '1. Generate code based on your description and the code in your workspace', + '2. Provide a list of suggestions for you to review and add to your workspace', + '3. If needed, iterate based on your feedback', + 'To learn more, visit the [user guide](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html)', + ].join('\n'), + }, + }, + }) + } + } if (realPromptText !== '') { this.mynahUI.addChatItem(affectedTabId, { type: ChatItemType.PROMPT, body: realPromptText, }) + addInformationCard(affectedTabId) this.mynahUI.updateStore(affectedTabId, { loadingChat: true, - promptInputDisabledState: true, cancelButtonWhenLoading: false, + promptInputDisabledState: true, }) void this.connector.requestGenerativeAIAnswer(affectedTabId, '', { chatMessage: realPromptText, }) + } else { + addInformationCard(affectedTabId) + } + } + } + + private handleGumbyCommand(tabID: string, eventId: string | undefined) { + if (!this.isGumbyEnabled) { + return + } + + let gumbyTabId: string | undefined = undefined + + this.tabsStorage.getTabs().forEach((tab) => { + if (tab.type === 'gumby') { + gumbyTabId = tab.id } + }) + + if (gumbyTabId !== undefined) { + this.mynahUI.selectTab(gumbyTabId, eventId || '') + this.connector.onTabChange(gumbyTabId) + return + } + + let affectedTabId: string | undefined = tabID + // if there is no gumby tab, open a new one + const currentTabType = this.tabsStorage.getTab(affectedTabId)?.type + if (currentTabType !== 'unknown' && currentTabType !== 'welcome') { + affectedTabId = this.mynahUI.updateStore('', { + loadingChat: true, + cancelButtonWhenLoading: false, + }) + } + + if (affectedTabId === undefined) { + this.mynahUI.notify({ + content: uiComponentsTexts.noMoreTabsTooltip, + type: NotificationType.WARNING, + }) + return + } else { + this.tabsStorage.updateTabTypeFromUnknown(affectedTabId, 'gumby') + this.connector.onKnownTabOpen(affectedTabId) + this.connector.onUpdateTabType(affectedTabId) + + // reset chat history + this.mynahUI.updateStore(affectedTabId, { + chatItems: [], + }) + + this.mynahUI.updateStore(affectedTabId, this.tabDataGenerator.getTabData('gumby', true, undefined)) + + // disable chat prompt + this.mynahUI.updateStore(affectedTabId, { + loadingChat: true, + cancelButtonWhenLoading: false, + }) + + this.connector.transform(affectedTabId) + } + } + + private handleClearCommand(tabID: string) { + this.mynahUI.updateStore(tabID, { + chatItems: [], + }) + this.connector.clearChat(tabID) + } + + private handleHelpCommand(tabID: string) { + // User entered help action, so change the tab type to 'cwc' if it's an unknown tab + if (this.tabsStorage.getTab(tabID)?.type === 'unknown') { + this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') } + + this.connector.help(tabID) } } diff --git a/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts b/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts index 564dd8a7a69..f9a419fed96 100644 --- a/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts +++ b/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts @@ -4,7 +4,39 @@ */ export type TabStatus = 'free' | 'busy' | 'dead' -export type TabType = 'cwc' | 'featuredev' | 'gumby' | 'unknown' +const TabTypes = [ + 'cwc', + 'featuredev', + 'gumby', + 'review', + 'testgen', + 'doc', + 'agentWalkthrough', + 'welcome', + 'unknown', +] as const +export type TabType = (typeof TabTypes)[number] +export function isTabType(value: string): value is TabType { + return (TabTypes as readonly string[]).includes(value) +} + +export function getTabCommandFromTabType(tabType: TabType): string { + switch (tabType) { + case 'featuredev': + return '/dev' + case 'doc': + return '/doc' + case 'gumby': + return '/transform' + case 'review': + return '/review' + case 'testgen': + return '/test' + default: + return '' + } +} + export type TabOpenType = 'click' | 'contextMenu' | 'hotkeys' const TabTimeoutDuration = 172_800_000 // 48hrs @@ -86,7 +118,10 @@ export class TabsStorage { public updateTabTypeFromUnknown(tabID: string, tabType: TabType) { const currentTabValue = this.tabs.get(tabID) - if (currentTabValue === undefined || currentTabValue.type !== 'unknown') { + if ( + currentTabValue === undefined || + (currentTabValue.type !== 'unknown' && currentTabValue.type !== 'welcome') + ) { return } diff --git a/packages/core/src/amazonq/webview/ui/tabs/constants.ts b/packages/core/src/amazonq/webview/ui/tabs/constants.ts index 5e81b132485..efbf700b91d 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/constants.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/constants.ts @@ -4,6 +4,7 @@ */ import { TabType } from '../storages/tabsStorage' import { QuickActionCommandGroup } from '@aws/mynah-ui' +import { userGuideURL } from '../texts/constants' export type TabTypeData = { title: string @@ -27,25 +28,45 @@ const commonTabData: TabTypeData = { placeholder: 'Ask a question or enter "/" for quick actions', welcome: `Hi, I'm Amazon Q. I can answer your software development questions. Ask me to explain, debug, or optimize your code. - You can enter \`/\` to see a list of quick actions. Add @workspace to beginning of your message to include your entire workspace as context.`, + You can enter \`/\` to see a list of quick actions. Add @workspace to the beginning of your message to include your entire workspace as context.`, contextCommands: [workspaceCommand], } -export const TabTypeDataMap: Record = { +export const TabTypeDataMap: Record, TabTypeData> = { unknown: commonTabData, cwc: commonTabData, featuredev: { title: 'Q - Dev', placeholder: 'Describe your task or issue in as much detail as possible', - welcome: `Hi! I'm the Amazon Q Developer Agent for software development. - -I can generate code to implement new functionality across your workspace. To get started, describe the task you're trying to accomplish, and I'll generate code to implement it. If you want to make changes to the code, you can tell me what to improve and I'll generate new code based on your feedback. + welcome: `I can generate code to accomplish a task or resolve an issue. -What would you like to work on?`, +After you provide a description, I will: +1. Generate code based on your description and the code in your workspace +2. Provide a list of suggestions for you to review and add to your workspace +3. If needed, iterate based on your feedback + +To learn more, visit the [User Guide](${userGuideURL}).`, }, gumby: { title: 'Q - Code Transformation', placeholder: 'Open a new tab to chat with Q', welcome: 'Welcome to Code Transformation!', }, + review: { + title: 'Q - Review', + placeholder: `Ask a question or enter "/" for quick actions`, + welcome: `Welcome to code reviews. I can help you identify code issues and provide suggested fixes for the active file or workspace you have opened in your IDE.`, + }, + testgen: { + title: 'Q - Test', + placeholder: `Waiting on your inputs...`, + welcome: `Welcome to unit test generation. I can help you generate unit tests for your active file.`, + }, + doc: { + title: 'Q - Doc Generation', + placeholder: 'Ask Amazon Q to generate documentation for your project', + welcome: `Welcome to doc generation! + +I can help generate documentation for your code. To get started, choose what type of doc update you'd like to make.`, + }, } diff --git a/packages/core/src/amazonq/webview/ui/tabs/generator.ts b/packages/core/src/amazonq/webview/ui/tabs/generator.ts index f998528bb04..8865d00e400 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/generator.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/generator.ts @@ -8,10 +8,14 @@ import { TabType } from '../storages/tabsStorage' import { FollowUpGenerator } from '../followUps/generator' import { QuickActionGenerator } from '../quickActions/generator' import { TabTypeDataMap } from './constants' +import { agentWalkthroughDataModel } from '../walkthrough/agent' export interface TabDataGeneratorProps { isFeatureDevEnabled: boolean isGumbyEnabled: boolean + isScanEnabled: boolean + isTestEnabled: boolean + isDocEnabled: boolean disabledCommands?: string[] } @@ -24,15 +28,26 @@ export class TabDataGenerator { this.quickActionsGenerator = new QuickActionGenerator({ isFeatureDevEnabled: props.isFeatureDevEnabled, isGumbyEnabled: props.isGumbyEnabled, + isScanEnabled: props.isScanEnabled, + isTestEnabled: props.isTestEnabled, + isDocEnabled: props.isDocEnabled, disableCommands: props.disabledCommands, }) } public getTabData(tabType: TabType, needWelcomeMessages: boolean, taskName?: string): MynahUIDataModel { + if (tabType === 'agentWalkthrough') { + return agentWalkthroughDataModel + } + + if (tabType === 'welcome') { + return {} + } + const tabData: MynahUIDataModel = { tabTitle: taskName ?? TabTypeDataMap[tabType].title, promptInputInfo: - 'Amazon Q Developer uses generative AI. You may need to verify responses. See the [AWS Responsible AI Policy](https://aws.amazon.com/machine-learning/responsible-ai/policy/).', + 'Amazon Q Developer uses generative AI. You may need to verify responses. See the [AWS Responsible AI Policy](https://aws.amazon.com/machine-learning/responsible-ai/policy/). Amazon Q Developer processes data across all US Regions. See [here](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/cross-region-inference.html) for more info. Amazon Q may retain chats to provide and maintain the service.', quickActionCommands: this.quickActionsGenerator.generateForTab(tabType), promptInputPlaceholder: TabTypeDataMap[tabType].placeholder, contextCommands: TabTypeDataMap[tabType].contextCommands, diff --git a/packages/core/src/amazonq/webview/ui/telemetry/actions.ts b/packages/core/src/amazonq/webview/ui/telemetry/actions.ts new file mode 100644 index 00000000000..ffd65684ff6 --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/telemetry/actions.ts @@ -0,0 +1,38 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExtensionMessage } from '../commands' +import { TabType } from '../storages/tabsStorage' + +export function createClickTelemetry(source: string): ExtensionMessage { + return { + command: 'send-telemetry', + source, + } +} +export function isClickTelemetry(message: ExtensionMessage): boolean { + return ( + message.command === 'send-telemetry' && typeof message.source === 'string' && Object.keys(message).length === 2 + ) +} + +export function createOpenAgentTelemetry(module: TabType, trigger: Trigger): ExtensionMessage { + return { + command: 'send-telemetry', + module, + trigger, + } +} + +export type Trigger = 'right-click' | 'quick-action' | 'quick-start' + +export function isOpenAgentTelemetry(message: ExtensionMessage): boolean { + return ( + message.command === 'send-telemetry' && + typeof message.module === 'string' && + typeof message.trigger === 'string' && + Object.keys(message).length === 3 + ) +} diff --git a/packages/core/src/amazonq/webview/ui/texts/constants.ts b/packages/core/src/amazonq/webview/ui/texts/constants.ts index 673057666b4..d907308b8c3 100644 --- a/packages/core/src/amazonq/webview/ui/texts/constants.ts +++ b/packages/core/src/amazonq/webview/ui/texts/constants.ts @@ -30,7 +30,42 @@ export const uiComponentsTexts = { rejectChange: 'Reject change', revertRejection: 'Revert rejection', } - +export const docUserGuide = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/doc-generation.html' export const userGuideURL = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html' export const manageAccessGuideURL = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security_iam_manage-access-with-policies.html' +export const testGuideUrl = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/test-generation.html' +export const reviewGuideUrl = 'https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/code-reviews.html' + +export const helpMessage = `I'm Amazon Q, a generative AI assistant. Learn more about me below. Your feedback will help me improve. +\n\n### What I can do: +\n\n- Answer questions about AWS +\n\n- Answer questions about general programming concepts +\n\n- Answer questions about your workspace with @workspace +\n\n- Explain what a line of code or code function does +\n\n- Write unit tests and code +\n\n- Debug and fix code +\n\n- Refactor code +\n\n### What I don't do right now: +\n\n- Answer questions in languages other than English +\n\n- Remember conversations from your previous sessions +\n\n- Have information about your AWS account or your specific AWS resources +\n\n### Examples of questions I can answer: +\n\n- When should I use ElastiCache? +\n\n- How do I create an Application Load Balancer? +\n\n- Explain the and ask clarifying questions about it. +\n\n- What is the syntax of declaring a variable in TypeScript? +\n\n### Special Commands +\n\n- /dev - Get code suggestions across files in your current project. Provide a brief prompt, such as "Implement a GET API." +\n\n- /doc - Create and update documentation for your repository. +\n\n- /review - Discover and address security and code quality issues. +\n\n- /test - Generate unit tests for a file. +\n\n- /transform - Transform your code. Use to upgrade Java code versions. +\n\n- /help - View chat topics and commands. +\n\n- /clear - Clear the conversation. +\n\n### Things to note: +\n\n- I may not always provide completely accurate or current information. +\n\n- Provide feedback by choosing the like or dislike buttons that appear below answers. +\n\n- When you use Amazon Q, AWS may, for service improvement purposes, store data about your usage and content. You can opt-out of sharing this data by following the steps in AI services opt-out policies. See here +\n\n- Do not enter any confidential, sensitive, or personal information. +\n\n*For additional help, visit the [Amazon Q User Guide](${userGuideURL}).*` diff --git a/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts b/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts new file mode 100644 index 00000000000..bb0b4b15896 --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/walkthrough/agent.ts @@ -0,0 +1,196 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemContent, ChatItemType, MynahIcons, MynahUIDataModel } from '@aws/mynah-ui' + +function createdTabbedData(examples: string[], agent: string): ChatItemContent['tabbedContent'] { + const exampleText = examples.map((example) => `- ${example}`).join('\n') + return [ + { + label: 'Examples', + value: 'examples', + content: { + body: `**Example use cases:**\n${exampleText}\n\nEnter ${agent} in Q Chat to get started`, + }, + }, + ] +} + +export const agentWalkthroughDataModel: MynahUIDataModel = { + tabBackground: false, + compactMode: false, + tabTitle: 'Explore', + promptInputVisible: false, + tabHeaderDetails: { + icon: MynahIcons.ASTERISK, + title: 'Amazon Q Developer agents capabilities', + description: '', + }, + chatItems: [ + { + type: ChatItemType.ANSWER, + snapToTop: true, + hoverEffect: true, + body: `### Feature development +Implement features or make changes across your workspace, all from a single prompt. +`, + icon: MynahIcons.CODE_BLOCK, + footer: { + tabbedContent: createdTabbedData( + [ + '/dev update app.py to add a new api', + '/dev fix the error', + '/dev add a new button to sort by ', + ], + '/dev' + ), + }, + buttons: [ + { + status: 'clear', + id: `user-guide-featuredev`, + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-featuredev', + text: `Quick start with **/dev**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Unit test generation +Automatically generate unit tests for your active file. +`, + icon: MynahIcons.BUG, + footer: { + tabbedContent: createdTabbedData( + ['Generate tests for specific functions', 'Generate tests for null and empty inputs'], + '/test' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-testgen', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-testgen', + text: `Quick start with **/test**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Documentation generation +Create and update READMEs for better documented code. +`, + icon: MynahIcons.CHECK_LIST, + footer: { + tabbedContent: createdTabbedData( + [ + 'Generate new READMEs for your project', + 'Update existing READMEs with recent code changes', + 'Request specific changes to a README', + ], + '/doc' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-doc', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-doc', + text: `Quick start with **/doc**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Code reviews +Review code for issues, then get suggestions to fix your code instantaneously. +`, + icon: MynahIcons.TRANSFORM, + footer: { + tabbedContent: createdTabbedData( + [ + 'Review code for security vulnerabilities and code quality issues', + 'Get detailed explanations about code issues', + 'Apply automatic code fixes to your files', + ], + '/review' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-review', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-review', + text: `Quick start with **/review**`, + }, + ], + }, + { + type: ChatItemType.ANSWER, + hoverEffect: true, + body: `### Transformation +Upgrade library and language versions in your codebase. +`, + icon: MynahIcons.TRANSFORM, + footer: { + tabbedContent: createdTabbedData( + ['Upgrade Java language and dependency versions', 'Convert embedded SQL code in Java apps'], + '/transform' + ), + }, + buttons: [ + { + status: 'clear', + id: 'user-guide-gumby', + disabled: false, + text: 'Read user guide', + }, + { + status: 'main', + disabled: false, + flash: 'once', + icon: MynahIcons.RIGHT_OPEN, + id: 'quick-start-gumby', + text: `Quick start with **/transform**`, + }, + ], + }, + ], +} diff --git a/packages/core/src/amazonq/webview/ui/walkthrough/welcome.ts b/packages/core/src/amazonq/webview/ui/walkthrough/welcome.ts new file mode 100644 index 00000000000..77434cc09d6 --- /dev/null +++ b/packages/core/src/amazonq/webview/ui/walkthrough/welcome.ts @@ -0,0 +1,46 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemType, MynahIcons, MynahUITabStoreTab } from '@aws/mynah-ui' +import { TabDataGenerator } from '../tabs/generator' + +export const welcomeScreenTabData = (tabs: TabDataGenerator): MynahUITabStoreTab => ({ + isSelected: true, + store: { + quickActionCommands: tabs.quickActionsGenerator.generateForTab('welcome'), + tabTitle: 'Welcome to Q', + tabBackground: true, + chatItems: [ + { + type: ChatItemType.ANSWER, + icon: MynahIcons.ASTERISK, + messageId: 'new-welcome-card', + body: `#### Work on a task using agentic capabilities +_Generate code, scan for issues, and more._`, + buttons: [ + { + id: 'explore', + disabled: false, + text: 'Explore', + }, + { + id: 'quick-start', + text: 'Quick start', + disabled: false, + status: 'main', + }, + ], + }, + ], + promptInputLabel: 'Or, start a chat', + promptInputPlaceholder: 'Type your question', + compactMode: true, + tabHeaderDetails: { + title: "Hi, I'm Amazon Q.", + description: 'Where would you like to start?', + icon: MynahIcons.Q, + }, + }, +}) diff --git a/packages/core/src/amazonq/webview/webView.ts b/packages/core/src/amazonq/webview/webView.ts index 50b8477847a..d5488e75f16 100644 --- a/packages/core/src/amazonq/webview/webView.ts +++ b/packages/core/src/amazonq/webview/webView.ts @@ -22,6 +22,11 @@ import { TabType } from './ui/storages/tabsStorage' import { deactivateInitialViewBadge, shouldShowBadge } from '../util/viewBadgeHandler' import { telemetry } from '../../shared/telemetry/telemetry' import { amazonqMark } from '../../shared/performance/marks' +import { globals } from '../../shared' +import { AuthUtil } from '../../codewhisperer/util/authUtil' + +// The max number of times we should show the welcome to q chat panel before moving them to the regular one +const maxWelcomeWebviewLoads = 3 export class AmazonQChatViewProvider implements WebviewViewProvider { public static readonly viewType = 'aws.AmazonQChatView' @@ -60,10 +65,33 @@ export class AmazonQChatViewProvider implements WebviewViewProvider { dispatchAppsMessagesToWebView(webviewView.webview, this.appsMessagesListener) - webviewView.webview.html = await this.webViewContentGenerator.generate( - this.extensionContext.extensionUri, - webviewView.webview - ) + /** + * Show the welcome to q chat ${maxWelcomeWebviewLoads} times before showing the normal panel + */ + const welcomeLoadCount = globals.globalState.tryGet('aws.amazonq.welcomeChatShowCount', Number, 0) + if (welcomeLoadCount < maxWelcomeWebviewLoads) { + webviewView.webview.html = await this.webViewContentGenerator.generate( + this.extensionContext.extensionUri, + webviewView.webview, + true + ) + + /** + * resolveWebviewView gets called even when the user isn't logged in and the auth page is showing. + * We don't want to incremenent the show count until the user has fully logged in and resolveWebviewView + * gets called again + */ + const authenticated = (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' + if (authenticated) { + await globals.globalState.update('aws.amazonq.welcomeChatShowCount', welcomeLoadCount + 1) + } + } else { + webviewView.webview.html = await this.webViewContentGenerator.generate( + this.extensionContext.extensionUri, + webviewView.webview, + false + ) + } performance.mark(amazonqMark.open) diff --git a/packages/core/src/amazonqDoc/app.ts b/packages/core/src/amazonqDoc/app.ts new file mode 100644 index 00000000000..4aba1b9e9bc --- /dev/null +++ b/packages/core/src/amazonqDoc/app.ts @@ -0,0 +1,101 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { ChatControllerEventEmitters, DocController } from './controllers/chat/controller' +import { AmazonQAppInitContext } from '../amazonq/apps/initContext' +import { MessagePublisher } from '../amazonq/messages/messagePublisher' +import { MessageListener } from '../amazonq/messages/messageListener' +import { fromQueryToParameters } from '../shared/utilities/uriUtils' +import { getLogger } from '../shared/logger' +import { AuthUtil } from '../codewhisperer/util/authUtil' +import { debounce } from 'lodash' +import { DocChatSessionStorage } from './storages/chatSession' +import { UIMessageListener } from './views/actions/uiMessageListener' +import globals from '../shared/extensionGlobals' +import { AppToWebViewMessageDispatcher } from '../amazonq/commons/connector/connectorMessages' +import { docChat, docScheme } from './constants' +import { TabIdNotFoundError } from '../amazonqFeatureDev/errors' +import { DocMessenger } from './messenger' + +export function init(appContext: AmazonQAppInitContext) { + const docChatControllerEventEmitters: ChatControllerEventEmitters = { + processHumanChatMessage: new vscode.EventEmitter(), + followUpClicked: new vscode.EventEmitter(), + openDiff: new vscode.EventEmitter(), + processChatItemVotedMessage: new vscode.EventEmitter(), + stopResponse: new vscode.EventEmitter(), + tabOpened: new vscode.EventEmitter(), + processChatItemFeedbackMessage: new vscode.EventEmitter(), + tabClosed: new vscode.EventEmitter(), + authClicked: new vscode.EventEmitter(), + formActionClicked: new vscode.EventEmitter(), + processResponseBodyLinkClick: new vscode.EventEmitter(), + insertCodeAtPositionClicked: new vscode.EventEmitter(), + fileClicked: new vscode.EventEmitter(), + } + + const messenger = new DocMessenger( + new AppToWebViewMessageDispatcher(appContext.getAppsToWebViewMessagePublisher()), + docChat + ) + const sessionStorage = new DocChatSessionStorage(messenger) + + new DocController( + docChatControllerEventEmitters, + messenger, + sessionStorage, + appContext.onDidChangeAmazonQVisibility.event + ) + + const docProvider = new (class implements vscode.TextDocumentContentProvider { + async provideTextDocumentContent(uri: vscode.Uri): Promise { + const params = fromQueryToParameters(uri.query) + + const tabID = params.get('tabID') + if (!tabID) { + getLogger().error(`Unable to find tabID from ${uri.toString()}`) + throw new TabIdNotFoundError() + } + + const session = await sessionStorage.getSession(tabID) + const content = await session.config.fs.readFile(uri) + const decodedContent = new TextDecoder().decode(content) + return decodedContent + } + })() + + const textDocumentProvider = vscode.workspace.registerTextDocumentContentProvider(docScheme, docProvider) + + globals.context.subscriptions.push(textDocumentProvider) + + const docChatUIInputEventEmitter = new vscode.EventEmitter() + + new UIMessageListener({ + chatControllerEventEmitters: docChatControllerEventEmitters, + webViewMessageListener: new MessageListener(docChatUIInputEventEmitter), + }) + + appContext.registerWebViewToAppMessagePublisher(new MessagePublisher(docChatUIInputEventEmitter), 'doc') + + const debouncedEvent = debounce(async () => { + const authenticated = (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' + let authenticatingSessionIDs: string[] = [] + if (authenticated) { + const authenticatingSessions = sessionStorage.getAuthenticatingSessions() + + authenticatingSessionIDs = authenticatingSessions.map((session: any) => session.tabID) + + // We've already authenticated these sessions + authenticatingSessions.forEach((session: any) => (session.isAuthenticating = false)) + } + + messenger.sendAuthenticationUpdate(authenticated, authenticatingSessionIDs) + }, 500) + + AuthUtil.instance.secondaryAuth.onDidChangeActiveConnection(() => { + return debouncedEvent() + }) +} diff --git a/packages/core/src/amazonqDoc/constants.ts b/packages/core/src/amazonqDoc/constants.ts new file mode 100644 index 00000000000..ab872bd93e5 --- /dev/null +++ b/packages/core/src/amazonqDoc/constants.ts @@ -0,0 +1,121 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MynahIcons, Status } from '@aws/mynah-ui' +import { FollowUpTypes } from '../amazonq/commons/types' +import { NewFileInfo } from './types' +import { i18n } from '../shared/i18n-helper' + +// For uniquely identifiying which chat messages should be routed to Doc +export const docChat = 'docChat' + +export const docScheme = 'aws-doc' + +export const featureName = 'Amazon Q Doc Generation' + +export function getFileSummaryPercentage(input: string): number { + // Split the input string by newline characters + const lines = input.split('\n') + + // Find the line containing "summarized:" + const summaryLine = lines.find((line) => line.includes('summarized:')) + + // If the line is not found, return null + if (!summaryLine) { + return -1 + } + + // Extract the numbers from the summary line + const [summarized, total] = summaryLine.split(':')[1].trim().split(' of ').map(Number) + + // Calculate the percentage + const percentage = (summarized / total) * 100 + + return percentage +} + +const checkIcons = { + wait: '☐', + current: '☐', + done: '☑', +} + +const getIconForStep = (targetStep: number, currentStep: number) => { + return currentStep === targetStep + ? checkIcons.current + : currentStep > targetStep + ? checkIcons.done + : checkIcons.wait +} + +export enum DocGenerationStep { + UPLOAD_TO_S3, + SUMMARIZING_FILES, + GENERATING_ARTIFACTS, +} + +export const docGenerationProgressMessage = (currentStep: DocGenerationStep, mode: Mode) => ` +${mode === Mode.CREATE ? i18n('AWS.amazonq.doc.answer.creating') : i18n('AWS.amazonq.doc.answer.updating')} + +${getIconForStep(DocGenerationStep.UPLOAD_TO_S3, currentStep)} ${i18n('AWS.amazonq.doc.answer.scanning')} + +${getIconForStep(DocGenerationStep.SUMMARIZING_FILES, currentStep)} ${i18n('AWS.amazonq.doc.answer.summarizing')} + +${getIconForStep(DocGenerationStep.GENERATING_ARTIFACTS, currentStep)} ${i18n('AWS.amazonq.doc.answer.generating')} + + +` + +export const FolderSelectorFollowUps = [ + { + icon: 'ok' as MynahIcons, + pillText: 'Yes', + prompt: 'Yes', + status: 'success' as Status, + type: FollowUpTypes.ProceedFolderSelection, + }, + { + icon: 'refresh' as MynahIcons, + pillText: 'Change folder', + prompt: 'Change folder', + status: 'info' as Status, + type: FollowUpTypes.ChooseFolder, + }, + { + icon: 'cancel' as MynahIcons, + pillText: 'Cancel', + prompt: 'Cancel', + status: 'error' as Status, + type: FollowUpTypes.CancelFolderSelection, + }, +] + +export const SynchronizeDocumentation = { + pillText: 'Update README with recent code changes', + prompt: 'Update README with recent code changes', + type: 'SynchronizeDocumentation', +} + +export const EditDocumentation = { + pillText: 'Make a specific change', + prompt: 'Make a specific change', + type: 'EditDocumentation', +} + +export enum Mode { + NONE = 'None', + CREATE = 'Create', + SYNC = 'Sync', + EDIT = 'Edit', +} + +/** + * + * @param paths file paths + * @returns the path to a README.md, or undefined if none exist + */ +export const findReadmePath = (paths?: NewFileInfo[]) => { + return paths?.find((path) => /readme\.md$/i.test(path.relativePath)) +} diff --git a/packages/core/src/amazonqDoc/controllers/chat/controller.ts b/packages/core/src/amazonqDoc/controllers/chat/controller.ts new file mode 100644 index 00000000000..e8ecceff0ca --- /dev/null +++ b/packages/core/src/amazonqDoc/controllers/chat/controller.ts @@ -0,0 +1,742 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { EventEmitter } from 'vscode' + +import { + DocGenerationStep, + EditDocumentation, + FolderSelectorFollowUps, + Mode, + SynchronizeDocumentation, + docScheme, + featureName, + findReadmePath, +} from '../../constants' +import { AuthUtil } from '../../../codewhisperer/util/authUtil' +import { getLogger } from '../../../shared/logger' + +import { Session } from '../../session/session' +import { i18n } from '../../../shared/i18n-helper' +import { telemetry } from '../../../shared/telemetry' +import path from 'path' +import { createSingleFileDialog } from '../../../shared/ui/common/openDialog' +import { MynahIcons } from '@aws/mynah-ui' + +import { + MonthlyConversationLimitError, + SelectedFolderNotInWorkspaceFolderError, + WorkspaceFolderNotFoundError, + createUserFacingErrorMessage, +} from '../../../amazonqFeatureDev/errors' +import { BaseChatSessionStorage } from '../../../amazonq/commons/baseChatStorage' +import { DocMessenger } from '../../messenger' +import { AuthController } from '../../../amazonq/auth/controller' +import { openUrl } from '../../../shared/utilities/vsCodeUtils' +import { openDeletedDiff, openDiff } from '../../../amazonq/commons/diff' +import { + getWorkspaceFoldersByPrefixes, + getWorkspaceRelativePath, + isMultiRootWorkspace, +} from '../../../shared/utilities/workspaceUtils' +import { getPathsFromZipFilePath } from '../../../amazonqFeatureDev/util/files' +import { FollowUpTypes } from '../../../amazonq/commons/types' +import { DocGenerationTask } from '../docGenerationTask' + +export interface ChatControllerEventEmitters { + readonly processHumanChatMessage: EventEmitter + readonly followUpClicked: EventEmitter + readonly openDiff: EventEmitter + readonly stopResponse: EventEmitter + readonly tabOpened: EventEmitter + readonly tabClosed: EventEmitter + readonly processChatItemVotedMessage: EventEmitter + readonly processChatItemFeedbackMessage: EventEmitter + readonly authClicked: EventEmitter + readonly processResponseBodyLinkClick: EventEmitter + readonly insertCodeAtPositionClicked: EventEmitter + readonly fileClicked: EventEmitter + readonly formActionClicked: EventEmitter +} + +export class DocController { + private readonly scheme = docScheme + private readonly messenger: DocMessenger + private readonly sessionStorage: BaseChatSessionStorage + private authController: AuthController + private folderPath = '' + private mode: Mode = Mode.NONE + public docGenerationTask: DocGenerationTask + + public constructor( + private readonly chatControllerMessageListeners: ChatControllerEventEmitters, + messenger: DocMessenger, + sessionStorage: BaseChatSessionStorage, + _onDidChangeAmazonQVisibility: vscode.Event + ) { + this.messenger = messenger + this.sessionStorage = sessionStorage + this.authController = new AuthController() + this.docGenerationTask = new DocGenerationTask() + + this.chatControllerMessageListeners.processHumanChatMessage.event((data) => { + this.processUserChatMessage(data).catch((e) => { + getLogger().error('processUserChatMessage failed: %s', (e as Error).message) + }) + }) + this.chatControllerMessageListeners.formActionClicked.event((data) => { + return this.formActionClicked(data) + }) + + this.initializeFollowUps() + + this.chatControllerMessageListeners.stopResponse.event((data) => { + return this.stopResponse(data) + }) + this.chatControllerMessageListeners.tabOpened.event((data) => { + return this.tabOpened(data) + }) + this.chatControllerMessageListeners.tabClosed.event((data) => { + this.tabClosed(data) + }) + this.chatControllerMessageListeners.authClicked.event((data) => { + this.authClicked(data) + }) + this.chatControllerMessageListeners.processResponseBodyLinkClick.event((data) => { + this.processLink(data) + }) + this.chatControllerMessageListeners.fileClicked.event(async (data) => { + return await this.fileClicked(data) + }) + this.chatControllerMessageListeners.openDiff.event(async (data) => { + return await this.openDiff(data) + }) + } + + /** Prompts user to choose a folder in current workspace for README creation/update. + * After user chooses a folder, displays confimraiton message to user with selected path. + * + */ + private async folderSelector(data: any) { + const uri = await createSingleFileDialog({ + canSelectFolders: true, + canSelectFiles: false, + }).prompt() + + const retryFollowUps = FolderSelectorFollowUps.filter( + (followUp) => followUp.type !== FollowUpTypes.ProceedFolderSelection + ) + + if (!(uri instanceof vscode.Uri)) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: data.tabID, + message: 'No folder was selected, please try again.', + followUps: retryFollowUps, + disableChatInput: true, + }) + // Check that selected folder is a subfolder of the current workspace + } else if (!vscode.workspace.getWorkspaceFolder(uri)) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: data.tabID, + message: new SelectedFolderNotInWorkspaceFolderError().message, + followUps: retryFollowUps, + disableChatInput: true, + }) + } else { + let displayPath = '' + const relativePath = getWorkspaceRelativePath(uri.fsPath) + + if (relativePath) { + // Display path should always include workspace folder name + displayPath = path.join(relativePath.workspaceFolder.name, relativePath.relativePath) + // Only include workspace folder name in API call if multi-root workspace + this.folderPath = isMultiRootWorkspace() ? displayPath : relativePath.relativePath + + if (!relativePath.relativePath) { + this.docGenerationTask.folderLevel = 'ENTIRE_WORKSPACE' + } else { + this.docGenerationTask.folderLevel = 'SUB_FOLDER' + } + } + + this.messenger.sendFolderConfirmationMessage( + data.tabID, + this.mode === Mode.CREATE + ? i18n('AWS.amazonq.doc.answer.createReadme') + : i18n('AWS.amazonq.doc.answer.updateReadme'), + displayPath, + FolderSelectorFollowUps + ) + this.messenger.sendChatInputEnabled(data.tabID, false) + } + } + + private async openDiff(message: any) { + const tabId: string = message.tabID + const codeGenerationId: string = message.messageId + const zipFilePath: string = message.filePath + const session = await this.sessionStorage.getSession(tabId) + telemetry.amazonq_isReviewedChanges.emit({ + amazonqConversationId: session.conversationId, + enabled: true, + result: 'Succeeded', + credentialStartUrl: AuthUtil.instance.startUrl, + }) + + const workspacePrefixMapping = getWorkspaceFoldersByPrefixes(session.config.workspaceFolders) + const pathInfos = getPathsFromZipFilePath(zipFilePath, workspacePrefixMapping, session.config.workspaceFolders) + + const extension = path.parse(message.filePath).ext + // Only open diffs on files, not directories + if (extension) { + if (message.deleted) { + const name = path.basename(pathInfos.relativePath) + await openDeletedDiff(pathInfos.absolutePath, name, tabId, this.scheme) + } else { + let uploadId = session.uploadId + if (session?.state?.uploadHistory && session.state.uploadHistory[codeGenerationId]) { + uploadId = session?.state?.uploadHistory[codeGenerationId].uploadId + } + const rightPath = path.join(uploadId, zipFilePath) + await openDiff(pathInfos.absolutePath, rightPath, tabId, this.scheme) + } + } + } + + private initializeFollowUps(): void { + this.chatControllerMessageListeners.followUpClicked.event(async (data) => { + const session: Session = await this.sessionStorage.getSession(data.tabID) + + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders === undefined || workspaceFolders.length === 0) { + return + } + + const workspaceFolderName = vscode.workspace.workspaceFolders?.[0].name || '' + + const authState = await AuthUtil.instance.getChatAuthState() + + if (authState.amazonQ !== 'connected') { + await this.messenger.sendAuthNeededExceptionMessage(authState, data.tabID) + session.isAuthenticating = true + return + } + + this.docGenerationTask.userIdentity = AuthUtil.instance.conn?.id + + const sendFolderConfirmationMessage = (message: string) => { + this.messenger.sendFolderConfirmationMessage( + data.tabID, + message, + workspaceFolderName, + FolderSelectorFollowUps + ) + } + + switch (data.followUp.type) { + case FollowUpTypes.Retry: + if (this.mode === Mode.EDIT) { + this.enableUserInput(data?.tabID) + } else { + await this.tabOpened(data) + } + break + case FollowUpTypes.NewTask: + this.messenger.sendAnswer({ + type: 'answer', + tabID: data?.tabID, + message: i18n('AWS.amazonq.featureDev.answer.newTaskChanges'), + disableChatInput: true, + }) + return this.newTask(data) + case FollowUpTypes.CloseSession: + return this.closeSession(data) + case FollowUpTypes.CreateDocumentation: + this.docGenerationTask.interactionType = 'GENERATE_README' + this.mode = Mode.CREATE + sendFolderConfirmationMessage(i18n('AWS.amazonq.doc.answer.createReadme')) + break + case FollowUpTypes.ChooseFolder: + await this.folderSelector(data) + break + case FollowUpTypes.SynchronizeDocumentation: + this.mode = Mode.SYNC + sendFolderConfirmationMessage(i18n('AWS.amazonq.doc.answer.updateReadme')) + break + case FollowUpTypes.UpdateDocumentation: + this.docGenerationTask.interactionType = 'UPDATE_README' + this.messenger.sendAnswer({ + type: 'answer', + tabID: data?.tabID, + followUps: [SynchronizeDocumentation, EditDocumentation], + disableChatInput: true, + }) + break + case FollowUpTypes.EditDocumentation: + this.docGenerationTask.interactionType = 'EDIT_README' + this.mode = Mode.EDIT + sendFolderConfirmationMessage(i18n('AWS.amazonq.doc.answer.updateReadme')) + break + case FollowUpTypes.MakeChanges: + this.mode = Mode.EDIT + this.enableUserInput(data.tabID) + break + case FollowUpTypes.AcceptChanges: + this.docGenerationTask.userDecision = 'ACCEPT' + await this.sendDocGenerationEvent(data) + await this.insertCode(data) + return + case FollowUpTypes.RejectChanges: + this.docGenerationTask.userDecision = 'REJECT' + await this.sendDocGenerationEvent(data) + this.messenger.sendAnswer({ + type: 'answer', + tabID: data?.tabID, + disableChatInput: true, + message: 'Your changes have been discarded.', + followUps: [ + { + pillText: i18n('AWS.amazonq.featureDev.pillText.newTask'), + type: FollowUpTypes.NewTask, + status: 'info', + }, + { + pillText: i18n('AWS.amazonq.doc.pillText.closeSession'), + type: FollowUpTypes.CloseSession, + status: 'info', + }, + ], + }) + break + case FollowUpTypes.ProceedFolderSelection: + // If a user did not change the folder in a multi-root workspace, default to the first workspace folder + if (this.folderPath === '' && isMultiRootWorkspace()) { + this.folderPath = workspaceFolderName + } + if (this.mode === Mode.EDIT) { + this.enableUserInput(data.tabID) + } else { + await this.generateDocumentation({ + message: { + ...data, + message: + this.mode === Mode.CREATE + ? 'Create documentation for a specific folder' + : 'Sync documentation', + }, + session, + }) + } + break + case FollowUpTypes.CancelFolderSelection: + this.docGenerationTask.reset() + return this.tabOpened(data) + } + }) + } + + private enableUserInput(tabID: string) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: tabID, + message: i18n('AWS.amazonq.doc.answer.editReadme'), + }) + this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.doc.placeholder.editReadme')) + this.messenger.sendChatInputEnabled(tabID, true) + } + + private async fileClicked(message: any) { + // TODO: add Telemetry here + const tabId: string = message.tabID + const messageId = message.messageId + const filePathToUpdate: string = message.filePath + + const session = await this.sessionStorage.getSession(tabId) + const filePathIndex = (session.state.filePaths ?? []).findIndex((obj) => obj.relativePath === filePathToUpdate) + if (filePathIndex !== -1 && session.state.filePaths) { + session.state.filePaths[filePathIndex].rejected = !session.state.filePaths[filePathIndex].rejected + } + const deletedFilePathIndex = (session.state.deletedFiles ?? []).findIndex( + (obj) => obj.relativePath === filePathToUpdate + ) + if (deletedFilePathIndex !== -1 && session.state.deletedFiles) { + session.state.deletedFiles[deletedFilePathIndex].rejected = + !session.state.deletedFiles[deletedFilePathIndex].rejected + } + + await session.updateFilesPaths( + tabId, + session.state.filePaths ?? [], + session.state.deletedFiles ?? [], + messageId, + true + ) + } + + private async formActionClicked(message: any) { + switch (message.action) { + case 'cancel-doc-generation': + // eslint-disable-next-line unicorn/no-null + await this.stopResponse(message) + + break + } + } + + private async newTask(message: any) { + // Old session for the tab is ending, delete it so we can create a new one for the message id + this.docGenerationTask = new DocGenerationTask() + const session = await this.sessionStorage.getSession(message.tabID) + telemetry.amazonq_endChat.emit({ + amazonqConversationId: session.conversationId, + amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, + result: 'Succeeded', + }) + this.sessionStorage.deleteSession(message.tabID) + + // Re-run the opening flow, where we check auth + create a session + await this.tabOpened(message) + } + + private async closeSession(message: any) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: message.tabID, + message: i18n('AWS.amazonq.featureDev.answer.sessionClosed'), + disableChatInput: true, + }) + this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.sessionClosed')) + this.messenger.sendChatInputEnabled(message.tabID, false) + + const session = await this.sessionStorage.getSession(message.tabID) + this.docGenerationTask.reset() + + telemetry.amazonq_endChat.emit({ + amazonqConversationId: session.conversationId, + amazonqEndOfTheConversationLatency: performance.now() - session.telemetry.sessionStartTime, + result: 'Succeeded', + }) + } + + private processErrorChatMessage = (err: any, message: any, session: Session | undefined) => { + const errorMessage = createUserFacingErrorMessage(`${err.cause?.message ?? err.message}`) + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(message.tabID, null) + + switch (err.constructor.name) { + case MonthlyConversationLimitError.name: + this.messenger.sendMonthlyLimitError(message.tabID) + break + default: + this.messenger.sendErrorMessage(errorMessage, message.tabID, 0, session?.conversationIdUnsafe, false) + } + } + + private async generateDocumentation({ message, session }: { message: any; session: any }) { + try { + await this.onDocsGeneration(session, message.message, message.tabID) + } catch (err: any) { + this.processErrorChatMessage(err, message, session) + // Lock the chat input until they explicitly click one of the follow ups + this.messenger.sendChatInputEnabled(message.tabID, false) + } + } + + private async processUserChatMessage(message: any) { + if (message.message === undefined) { + this.messenger.sendErrorMessage('chatMessage should be set', message.tabID, 0, undefined) + return + } + + /** + * Don't attempt to process any chat messages when a workspace folder is not set. + * When the tab is first opened we will throw an error and lock the chat if the workspace + * folder is not found + */ + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders === undefined || workspaceFolders.length === 0) { + return + } + + const session: Session = await this.sessionStorage.getSession(message.tabID) + + try { + getLogger().debug(`${featureName}: Processing message: ${message.message}`) + + const authState = await AuthUtil.instance.getChatAuthState() + if (authState.amazonQ !== 'connected') { + await this.messenger.sendAuthNeededExceptionMessage(authState, message.tabID) + session.isAuthenticating = true + return + } + + await this.generateDocumentation({ message, session }) + this.messenger.sendChatInputEnabled(message?.tabID, false) + this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.doc.pillText.selectOption')) + } catch (err: any) { + this.processErrorChatMessage(err, message, session) + // Lock the chat input until they explicitly click one of the follow ups + this.messenger.sendChatInputEnabled(message.tabID, false) + } + } + + private async stopResponse(message: any) { + telemetry.ui_click.emit({ elementId: 'amazonq_stopCodeGeneration' }) + this.messenger.sendAnswer({ + message: i18n('AWS.amazonq.featureDev.pillText.stoppingCodeGeneration'), + type: 'answer-part', + tabID: message.tabID, + }) + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(message.tabID, null) + this.messenger.sendChatInputEnabled(message.tabID, false) + + const session = await this.sessionStorage.getSession(message.tabID) + session.state.tokenSource?.cancel() + } + + private async tabOpened(message: any) { + let session: Session | undefined + try { + session = await this.sessionStorage.getSession(message.tabID) + getLogger().debug(`${featureName}: Session created with id: ${session.tabID}`) + this.folderPath = '' + this.mode = Mode.NONE + + const authState = await AuthUtil.instance.getChatAuthState() + if (authState.amazonQ !== 'connected') { + void this.messenger.sendAuthNeededExceptionMessage(authState, message.tabID) + session.isAuthenticating = true + return + } + this.docGenerationTask.numberOfNavigation += 1 + this.messenger.sendAnswer({ + type: 'answer', + tabID: message.tabID, + followUps: [ + { + pillText: 'Create a README', + prompt: 'Create a README', + type: 'CreateDocumentation', + }, + { + pillText: 'Update an existing README', + prompt: 'Update an existing README', + type: 'UpdateDocumentation', + }, + ], + disableChatInput: true, + }) + this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.doc.pillText.selectOption')) + } catch (err: any) { + if (err instanceof WorkspaceFolderNotFoundError) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: message.tabID, + message: err.message, + disableChatInput: true, + }) + } else { + this.messenger.sendErrorMessage( + createUserFacingErrorMessage(err.message), + message.tabID, + 0, + session?.conversationIdUnsafe + ) + } + } + } + + private async openMarkdownPreview(readmePath: vscode.Uri) { + await vscode.commands.executeCommand('vscode.open', readmePath) + await vscode.commands.executeCommand('markdown.showPreview') + } + + private async onDocsGeneration(session: Session, message: string, tabID: string) { + this.messenger.sendDocProgress(tabID, DocGenerationStep.UPLOAD_TO_S3, 0, this.mode) + + await session.preloader(message) + + try { + await session.send(message, this.mode, this.folderPath) + const filePaths = session.state.filePaths ?? [] + const deletedFiles = session.state.deletedFiles ?? [] + + // Only add the follow up accept/deny buttons when the tab hasn't been closed/request hasn't been cancelled + if (session?.state.tokenSource?.token.isCancellationRequested) { + return + } + + if (filePaths.length === 0 && deletedFiles.length === 0) { + this.messenger.sendAnswer({ + message: i18n('AWS.amazonq.featureDev.pillText.unableGenerateChanges'), + type: 'answer', + tabID: tabID, + canBeVoted: true, + disableChatInput: true, + }) + + return + } + + this.messenger.sendCodeResult( + filePaths, + deletedFiles, + session.state.references ?? [], + tabID, + session.uploadId, + session.state.codeGenerationId ?? '' + ) + + // Automatically open the README diff + const readmePath = findReadmePath(session.state.filePaths) + if (readmePath) { + await this.openDiff({ tabID, filePath: readmePath.zipFilePath }) + } + + const remainingIterations = session.state.codeGenerationRemainingIterationCount + const totalIterations = session.state.codeGenerationTotalIterationCount + + if (remainingIterations !== undefined && totalIterations !== undefined) { + this.messenger.sendAnswer({ + type: 'answer', + tabID: tabID, + message: `${this.mode === Mode.CREATE ? i18n('AWS.amazonq.doc.answer.readmeCreated') : i18n('AWS.amazonq.doc.answer.readmeUpdated')} ${i18n('AWS.amazonq.doc.answer.codeResult')}`, + disableChatInput: true, + }) + } + + this.messenger.sendAnswer({ + message: undefined, + type: 'system-prompt', + disableChatInput: true, + followUps: [ + { + pillText: 'Accept', + prompt: 'Accept', + type: FollowUpTypes.AcceptChanges, + icon: 'ok' as MynahIcons, + status: 'success', + }, + { + pillText: 'Make changes', + prompt: 'Make changes', + type: FollowUpTypes.MakeChanges, + icon: 'refresh' as MynahIcons, + status: 'info', + }, + { + pillText: 'Reject', + prompt: 'Reject', + type: FollowUpTypes.RejectChanges, + icon: 'cancel' as MynahIcons, + status: 'error', + }, + ], + tabID: tabID, + }) + } finally { + if (session?.state?.tokenSource?.token.isCancellationRequested) { + await this.newTask({ tabID }) + } else { + this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.doc.pillText.selectOption')) + + this.messenger.sendChatInputEnabled(tabID, false) + } + } + } + + private authClicked(message: any) { + this.authController.handleAuth(message.authType) + + this.messenger.sendAnswer({ + type: 'answer', + tabID: message.tabID, + message: 'Follow instructions to re-authenticate ...', + }) + + // Explicitly ensure the user goes through the re-authenticate flow + this.messenger.sendChatInputEnabled(message.tabID, false) + } + + private tabClosed(message: any) { + this.sessionStorage.deleteSession(message.tabID) + } + + private async insertCode(message: any) { + let session + try { + session = await this.sessionStorage.getSession(message.tabID) + + const acceptedFiles = (paths?: { rejected: boolean }[]) => (paths || []).filter((i) => !i.rejected).length + + const amazonqNumberOfFilesAccepted = + acceptedFiles(session.state.filePaths) + acceptedFiles(session.state.deletedFiles) + + telemetry.amazonq_isAcceptedCodeChanges.emit({ + credentialStartUrl: AuthUtil.instance.startUrl, + amazonqConversationId: session.conversationId, + amazonqNumberOfFilesAccepted, + enabled: true, + result: 'Succeeded', + }) + await session.insertChanges() + + const readmePath = findReadmePath(session.state.filePaths) + if (readmePath) { + await this.openMarkdownPreview( + vscode.Uri.file(path.join(readmePath.workspaceFolder.uri.fsPath, readmePath.relativePath)) + ) + } + + this.messenger.sendAnswer({ + type: 'answer', + disableChatInput: true, + tabID: message.tabID, + followUps: [ + { + pillText: 'Start a new documentation task', + prompt: 'Start a new documentation task', + type: FollowUpTypes.NewTask, + status: 'info', + }, + { + pillText: 'End session', + prompt: 'End session', + type: FollowUpTypes.CloseSession, + status: 'info', + }, + ], + }) + + this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.doc.pillText.selectOption')) + } catch (err: any) { + this.messenger.sendErrorMessage( + createUserFacingErrorMessage(`Failed to insert code changes: ${err.message}`), + message.tabID, + 0, + session?.conversationIdUnsafe + ) + } + } + private async sendDocGenerationEvent(message: any) { + const session = await this.sessionStorage.getSession(message.tabID) + this.docGenerationTask.conversationId = session.conversationId + const { totalAddedChars, totalAddedLines, totalAddedFiles } = await session.countAddedContent( + this.docGenerationTask.interactionType + ) + this.docGenerationTask.numberOfAddChars = totalAddedChars + this.docGenerationTask.numberOfAddLines = totalAddedLines + this.docGenerationTask.numberOfAddFiles = totalAddedFiles + const docGenerationEvent = this.docGenerationTask.docGenerationEventBase() + + await session.sendDocGenerationTelemetryEvent(docGenerationEvent) + } + private processLink(message: any) { + void openUrl(vscode.Uri.parse(message.link)) + } +} diff --git a/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts b/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts new file mode 100644 index 00000000000..4fc57ba152c --- /dev/null +++ b/packages/core/src/amazonqDoc/controllers/docGenerationTask.ts @@ -0,0 +1,62 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { + DocGenerationEvent, + DocGenerationFolderLevel, + DocGenerationInteractionType, + DocGenerationUserDecision, +} from '../../codewhisperer/client/codewhispereruserclient' +import { getLogger } from '../../shared' + +export class DocGenerationTask { + // Telemetry fields + public conversationId?: string + public numberOfAddChars?: number + public numberOfAddLines?: number + public numberOfAddFiles?: number + public userDecision?: DocGenerationUserDecision + public interactionType?: DocGenerationInteractionType + public userIdentity?: string + public numberOfNavigation = 0 + public folderLevel?: DocGenerationFolderLevel + + constructor(conversationId?: string) { + this.conversationId = conversationId + } + + public docGenerationEventBase() { + const undefinedProps = Object.entries(this) + .filter(([key, value]) => value === undefined) + .map(([key]) => key) + + if (undefinedProps.length > 0) { + getLogger().debug(`DocGenerationEvent has undefined properties: ${undefinedProps.join(', ')}`) + } + const event: DocGenerationEvent = { + conversationId: this.conversationId ?? '', + numberOfAddChars: this.numberOfAddChars, + numberOfAddLines: this.numberOfAddLines, + numberOfAddFiles: this.numberOfAddFiles, + userDecision: this.userDecision, + interactionType: this.interactionType, + userIdentity: this.userIdentity, + numberOfNavigation: this.numberOfNavigation, + folderLevel: this.folderLevel, + } + return event + } + + public reset() { + this.conversationId = undefined + this.numberOfAddChars = undefined + this.numberOfAddLines = undefined + this.numberOfAddFiles = undefined + this.userDecision = undefined + this.interactionType = undefined + this.userIdentity = undefined + this.numberOfNavigation = 0 + this.folderLevel = undefined + } +} diff --git a/packages/core/src/amazonqDoc/errors.ts b/packages/core/src/amazonqDoc/errors.ts new file mode 100644 index 00000000000..fb918ec7c53 --- /dev/null +++ b/packages/core/src/amazonqDoc/errors.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' +import { i18n } from '../shared/i18n-helper' + +export class DocServiceError extends ToolkitError { + constructor(message: string, code: string) { + super(message, { code }) + } +} + +export class ReadmeTooLargeError extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.readmeTooLarge'), { + code: ReadmeTooLargeError.name, + }) + } +} + +export class WorkspaceEmptyError extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.workspaceEmpty'), { + code: WorkspaceEmptyError.name, + }) + } +} + +export class NoChangeRequiredException extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.noChangeRequiredException'), { + code: NoChangeRequiredException.name, + }) + } +} + +export class PromptRefusalException extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.promptRefusal'), { + code: PromptRefusalException.name, + }) + } +} + +export class ContentLengthError extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.contentLengthError'), { code: ContentLengthError.name }) + } +} + +export class PromptTooVagueError extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.promptTooVague'), { + code: PromptTooVagueError.name, + }) + } +} + +export class PromptUnrelatedError extends ToolkitError { + constructor() { + super(i18n('AWS.amazonq.doc.error.promptUnrelated'), { + code: PromptUnrelatedError.name, + }) + } +} diff --git a/packages/core/src/amazonqDoc/index.ts b/packages/core/src/amazonqDoc/index.ts new file mode 100644 index 00000000000..7ba22e4b351 --- /dev/null +++ b/packages/core/src/amazonqDoc/index.ts @@ -0,0 +1,10 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types' +export * from './session/sessionState' +export * from './constants' +export { Session } from './session/session' +export { ChatControllerEventEmitters, DocController } from './controllers/chat/controller' diff --git a/packages/core/src/amazonqDoc/messenger.ts b/packages/core/src/amazonqDoc/messenger.ts new file mode 100644 index 00000000000..09be3dd11fb --- /dev/null +++ b/packages/core/src/amazonqDoc/messenger.ts @@ -0,0 +1,72 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Messenger } from '../amazonq/commons/connector/baseMessenger' +import { AppToWebViewMessageDispatcher } from '../amazonq/commons/connector/connectorMessages' +import { FollowUpTypes } from '../amazonq/commons/types' +import { messageWithConversationId } from '../amazonqFeatureDev' +import { i18n } from '../shared/i18n-helper' +import { docGenerationProgressMessage, DocGenerationStep, Mode } from './constants' +import { inProgress } from './types' + +export class DocMessenger extends Messenger { + public constructor(dispatcher: AppToWebViewMessageDispatcher, sender: string) { + super(dispatcher, sender) + } + + /** Sends a message in the chat and displays a prompt input progress bar to communicate the doc generation progress. + * The text in the progress bar matches the current step shown in the message. + * + */ + public sendDocProgress(tabID: string, step: DocGenerationStep, progress: number, mode: Mode) { + // Hide prompt input progress bar once all steps are completed + if (step > DocGenerationStep.GENERATING_ARTIFACTS) { + // eslint-disable-next-line unicorn/no-null + this.sendUpdatePromptProgress(tabID, null) + } else { + const progressText = + step === DocGenerationStep.UPLOAD_TO_S3 + ? `${i18n('AWS.amazonq.doc.answer.scanning')}...` + : step === DocGenerationStep.SUMMARIZING_FILES + ? `${i18n('AWS.amazonq.doc.answer.summarizing')}...` + : `${i18n('AWS.amazonq.doc.answer.generating')}...` + this.sendUpdatePromptProgress(tabID, inProgress(progress, progressText)) + } + + // The first step is answer-stream type, subequent updates are answer-part + this.sendAnswer({ + type: step === DocGenerationStep.UPLOAD_TO_S3 ? 'answer-stream' : 'answer-part', + tabID: tabID, + disableChatInput: true, + message: docGenerationProgressMessage(step, mode), + }) + } + + public override sendErrorMessage( + errorMessage: string, + tabID: string, + _retries: number, + conversationId?: string, + _showDefaultMessage?: boolean + ) { + this.sendAnswer({ + type: 'answer', + tabID: tabID, + message: errorMessage + messageWithConversationId(conversationId), + }) + + this.sendAnswer({ + message: undefined, + type: 'system-prompt', + followUps: [ + { + pillText: i18n('AWS.amazonq.featureDev.pillText.retry'), + type: FollowUpTypes.Retry, + status: 'warning', + }, + ], + tabID, + }) + } +} diff --git a/packages/core/src/amazonqDoc/session/session.ts b/packages/core/src/amazonqDoc/session/session.ts new file mode 100644 index 00000000000..309a6fd7408 --- /dev/null +++ b/packages/core/src/amazonqDoc/session/session.ts @@ -0,0 +1,268 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { featureName, Mode } from '../constants' +import { DeletedFileInfo, Interaction, NewFileInfo, SessionState, SessionStateConfig } from '../types' +import { PrepareCodeGenState } from './sessionState' +import { telemetry } from '../../shared/telemetry/telemetry' +import { extensionVersion, fs, getLogger, globals } from '../../shared' +import { AuthUtil } from '../../codewhisperer/util/authUtil' +import { SessionConfig } from '../../amazonq/commons/session/sessionConfigFactory' +import { ReferenceLogViewProvider } from '../../codewhisperer' +import path from 'path' +import { FeatureDevClient } from '../../amazonqFeatureDev/client/featureDev' +import { TelemetryHelper } from '../../amazonqFeatureDev/util/telemetryHelper' +import { ConversationNotStartedState } from '../../amazonqFeatureDev/session/sessionState' +import { logWithConversationId } from '../../amazonqFeatureDev/userFacingText' +import { ConversationIdNotFoundError } from '../../amazonqFeatureDev/errors' +import { referenceLogText } from '../../amazonqFeatureDev/constants' +import { + DocGenerationEvent, + DocGenerationInteractionType, + SendTelemetryEventRequest, +} from '../../codewhisperer/client/codewhispereruserclient' +import { getDiffCharsAndLines } from '../../shared/utilities/diffUtils' +import { getClientId, getOperatingSystem, getOptOutPreference } from '../../shared/telemetry/util' +import { DocMessenger } from '../messenger' + +export class Session { + private _state?: SessionState | Omit + private task: string = '' + private proxyClient: FeatureDevClient + private _conversationId?: string + private preloaderFinished = false + private _latestMessage: string = '' + private _telemetry: TelemetryHelper + + // Used to keep track of whether or not the current session is currently authenticating/needs authenticating + public isAuthenticating: boolean + + constructor( + public readonly config: SessionConfig, + private messenger: DocMessenger, + public readonly tabID: string, + initialState: Omit = new ConversationNotStartedState(tabID), + proxyClient: FeatureDevClient = new FeatureDevClient() + ) { + this._state = initialState + this.proxyClient = proxyClient + + this._telemetry = new TelemetryHelper() + this.isAuthenticating = false + } + + /** + * Preload any events that have to run before a chat message can be sent + */ + async preloader(msg: string) { + if (!this.preloaderFinished) { + await this.setupConversation(msg) + this.preloaderFinished = true + } + } + + get state() { + if (!this._state) { + throw new Error("State should be initialized before it's read") + } + return this._state + } + + /** + * setupConversation + * + * Starts a conversation with the backend and uploads the repo for the LLMs to be able to use it. + */ + private async setupConversation(msg: string) { + // Store the initial message when setting up the conversation so that if it fails we can retry with this message + this._latestMessage = msg + + await telemetry.amazonq_startConversationInvoke.run(async (span) => { + this._conversationId = await this.proxyClient.createConversation() + getLogger().info(logWithConversationId(this.conversationId)) + + span.record({ amazonqConversationId: this._conversationId, credentialStartUrl: AuthUtil.instance.startUrl }) + }) + + this._state = new PrepareCodeGenState( + { + ...this.getSessionStateConfig(), + conversationId: this.conversationId, + uploadId: '', + currentCodeGenerationId: undefined, + }, + [], + [], + [], + this.tabID, + 0 + ) + } + + private getSessionStateConfig(): Omit { + return { + workspaceRoots: this.config.workspaceRoots, + workspaceFolders: this.config.workspaceFolders, + proxyClient: this.proxyClient, + conversationId: this.conversationId, + } + } + + async send(msg: string, mode: Mode, folderPath?: string): Promise { + // When the task/"thing to do" hasn't been set yet, we want it to be the incoming message + if (this.task === '' && msg) { + this.task = msg + } + + this._latestMessage = msg + + return this.nextInteraction(msg, mode, folderPath) + } + private async nextInteraction(msg: string, mode: Mode, folderPath?: string) { + const resp = await this.state.interact({ + task: this.task, + msg, + fs: this.config.fs, + mode: mode, + folderPath: folderPath, + messenger: this.messenger, + telemetry: this.telemetry, + tokenSource: this.state.tokenSource, + uploadHistory: this.state.uploadHistory, + }) + + if (resp.nextState) { + if (!this.state?.tokenSource?.token.isCancellationRequested) { + this.state?.tokenSource?.cancel() + } + + // Move to the next state + this._state = resp.nextState + } + + return resp.interaction + } + + public async updateFilesPaths( + tabID: string, + filePaths: NewFileInfo[], + deletedFiles: DeletedFileInfo[], + messageId: string, + disableFileActions: boolean + ) { + this.messenger.updateFileComponent(tabID, filePaths, deletedFiles, messageId, disableFileActions) + } + + public async insertChanges() { + for (const filePath of this.state.filePaths?.filter((i) => !i.rejected) ?? []) { + const absolutePath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) + + const uri = filePath.virtualMemoryUri + const content = await this.config.fs.readFile(uri) + const decodedContent = new TextDecoder().decode(content) + + await fs.mkdir(path.dirname(absolutePath)) + await fs.writeFile(absolutePath, decodedContent) + } + + for (const filePath of this.state.deletedFiles?.filter((i) => !i.rejected) ?? []) { + const absolutePath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) + await fs.delete(absolutePath) + } + + for (const ref of this.state.references ?? []) { + ReferenceLogViewProvider.instance.addReferenceLog(referenceLogText(ref)) + } + } + + public async countAddedContent(interactionType?: DocGenerationInteractionType) { + let totalAddedChars = 0 + let totalAddedLines = 0 + let totalAddedFiles = 0 + + for (const filePath of this.state.filePaths?.filter((i) => !i.rejected) ?? []) { + const absolutePath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) + const uri = filePath.virtualMemoryUri + const content = await this.config.fs.readFile(uri) + const decodedContent = new TextDecoder().decode(content) + totalAddedFiles += 1 + + if ((await fs.exists(absolutePath)) && interactionType === 'UPDATE_README') { + const existingContent = await fs.readFileText(absolutePath) + const { addedChars, addedLines } = getDiffCharsAndLines(existingContent, decodedContent) + totalAddedChars += addedChars + totalAddedLines += addedLines + } else { + totalAddedChars += decodedContent.length + totalAddedLines += decodedContent.split('\n').length + } + } + + return { + totalAddedChars, + totalAddedLines, + totalAddedFiles, + } + } + public async sendDocGenerationTelemetryEvent(docGenerationEvent: DocGenerationEvent) { + const client = await this.proxyClient.getClient() + try { + const params: SendTelemetryEventRequest = { + telemetryEvent: { + docGenerationEvent, + }, + optOutPreference: getOptOutPreference(), + userContext: { + ideCategory: 'VSCODE', + operatingSystem: getOperatingSystem(), + product: 'DocGeneration', // Should be the same as in JetBrains + clientId: getClientId(globals.globalState), + ideVersion: extensionVersion, + }, + } + const response = await client.sendTelemetryEvent(params).promise() + getLogger().debug( + `${featureName}: successfully sent docGenerationEvent: ConversationId: ${docGenerationEvent.conversationId} RequestId: ${response.$response.requestId}` + ) + } catch (e) { + getLogger().error( + `${featureName}: failed to send doc generation telemetry: ${(e as Error).name}: ${ + (e as Error).message + } RequestId: ${(e as any).requestId}` + ) + } + } + + get currentCodeGenerationId() { + return this.state.currentCodeGenerationId + } + + get uploadId() { + if (!('uploadId' in this.state)) { + throw new Error("UploadId has to be initialized before it's read") + } + return this.state.uploadId + } + + get conversationId() { + if (!this._conversationId) { + throw new ConversationIdNotFoundError() + } + return this._conversationId + } + + // Used for cases where it is not needed to have conversationId + get conversationIdUnsafe() { + return this._conversationId + } + + get latestMessage() { + return this._latestMessage + } + + get telemetry() { + return this._telemetry + } +} diff --git a/packages/core/src/amazonqDoc/session/sessionState.ts b/packages/core/src/amazonqDoc/session/sessionState.ts new file mode 100644 index 00000000000..8337d51f184 --- /dev/null +++ b/packages/core/src/amazonqDoc/session/sessionState.ts @@ -0,0 +1,386 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { ToolkitError } from '../../shared/errors' +import globals from '../../shared/extensionGlobals' +import { getLogger } from '../../shared/logger' +import { telemetry } from '../../shared/telemetry/telemetry' +import { VirtualFileSystem } from '../../shared/virtualFilesystem' +import { DocGenerationStep, docScheme, getFileSummaryPercentage, Mode } from '../constants' + +import { CodeReference, UploadHistory } from '../../amazonq/webview/ui/connector' +import { AuthUtil } from '../../codewhisperer/util/authUtil' +import { randomUUID } from '../../shared/crypto' +import { i18n } from '../../shared/i18n-helper' + +import { + CodeGenerationStatus, + CurrentWsFolders, + DeletedFileInfo, + DevPhase, + NewFileInfo, + SessionState, + SessionStateAction, + SessionStateConfig, + SessionStateInteraction, + SessionStatePhase, +} from '../types' +import { + EmptyCodeGenID, + Intent, + TelemetryHelper, + getDeletedFileInfos, + prepareRepoData, + registerNewFiles, +} from '../../amazonqFeatureDev' +import { uploadCode } from '../../amazonqFeatureDev/util/upload' +import { + ContentLengthError, + DocServiceError, + NoChangeRequiredException, + PromptRefusalException, + PromptTooVagueError, + PromptUnrelatedError, + ReadmeTooLargeError, + WorkspaceEmptyError, +} from '../errors' +import { DocMessenger } from '../messenger' + +abstract class CodeGenBase { + private pollCount = 360 + private requestDelay = 5000 + public tokenSource: vscode.CancellationTokenSource + public phase: SessionStatePhase = DevPhase.CODEGEN + public readonly conversationId: string + public readonly uploadId: string + public currentCodeGenerationId?: string + public isCancellationRequested?: boolean + + constructor( + protected config: SessionStateConfig, + public tabID: string + ) { + this.tokenSource = new vscode.CancellationTokenSource() + this.conversationId = config.conversationId + this.uploadId = config.uploadId + this.currentCodeGenerationId = config.currentCodeGenerationId || EmptyCodeGenID + } + + async generateCode({ + messenger, + fs, + codeGenerationId, + telemetry: telemetry, + workspaceFolders, + mode, + }: { + messenger: DocMessenger + fs: VirtualFileSystem + codeGenerationId: string + telemetry: TelemetryHelper + workspaceFolders: CurrentWsFolders + mode: Mode + }): Promise<{ + newFiles: NewFileInfo[] + deletedFiles: DeletedFileInfo[] + references: CodeReference[] + codeGenerationRemainingIterationCount?: number + codeGenerationTotalIterationCount?: number + }> { + for ( + let pollingIteration = 0; + pollingIteration < this.pollCount && !this.isCancellationRequested; + ++pollingIteration + ) { + const codegenResult = await this.config.proxyClient.getCodeGeneration(this.conversationId, codeGenerationId) + const codeGenerationRemainingIterationCount = codegenResult.codeGenerationRemainingIterationCount + const codeGenerationTotalIterationCount = codegenResult.codeGenerationTotalIterationCount + + getLogger().debug(`Codegen response: %O`, codegenResult) + telemetry.setCodeGenerationResult(codegenResult.codeGenerationStatus.status) + switch (codegenResult.codeGenerationStatus.status as CodeGenerationStatus) { + case CodeGenerationStatus.COMPLETE: { + const { newFileContents, deletedFiles, references } = + await this.config.proxyClient.exportResultArchive(this.conversationId) + const newFileInfo = registerNewFiles( + fs, + newFileContents, + this.uploadId, + workspaceFolders, + this.conversationId, + docScheme + ) + telemetry.setNumberOfFilesGenerated(newFileInfo.length) + messenger.sendDocProgress(this.tabID, DocGenerationStep.GENERATING_ARTIFACTS + 1, 100, mode) + + return { + newFiles: newFileInfo, + deletedFiles: getDeletedFileInfos(deletedFiles, workspaceFolders), + references, + codeGenerationRemainingIterationCount: codeGenerationRemainingIterationCount, + codeGenerationTotalIterationCount: codeGenerationTotalIterationCount, + } + } + case CodeGenerationStatus.PREDICT_READY: + case CodeGenerationStatus.IN_PROGRESS: { + if (codegenResult.codeGenerationStatusDetail) { + const progress = getFileSummaryPercentage(codegenResult.codeGenerationStatusDetail) + messenger.sendDocProgress( + this.tabID, + progress === 100 + ? DocGenerationStep.GENERATING_ARTIFACTS + : DocGenerationStep.SUMMARIZING_FILES, + progress, + mode + ) + } + await new Promise((f) => globals.clock.setTimeout(f, this.requestDelay)) + break + } + case CodeGenerationStatus.PREDICT_FAILED: + case CodeGenerationStatus.DEBATE_FAILED: + case CodeGenerationStatus.FAILED: { + // eslint-disable-next-line unicorn/no-null + messenger.sendUpdatePromptProgress(this.tabID, null) + switch (true) { + case codegenResult.codeGenerationStatusDetail?.includes('README_TOO_LARGE'): { + throw new ReadmeTooLargeError() + } + case codegenResult.codeGenerationStatusDetail?.includes('WORKSPACE_TOO_LARGE'): { + throw new ContentLengthError() + } + case codegenResult.codeGenerationStatusDetail?.includes('WORKSPACE_EMPTY'): { + throw new WorkspaceEmptyError() + } + case codegenResult.codeGenerationStatusDetail?.includes('PROMPT_UNRELATED'): { + throw new PromptUnrelatedError() + } + case codegenResult.codeGenerationStatusDetail?.includes('PROMPT_TOO_VAGUE'): { + throw new PromptTooVagueError() + } + case codegenResult.codeGenerationStatusDetail?.includes('PROMPT_REFUSAL'): { + throw new PromptRefusalException() + } + case codegenResult.codeGenerationStatusDetail?.includes('Guardrails'): { + throw new DocServiceError( + i18n('AWS.amazonq.doc.error.docGen.default'), + 'GuardrailsException' + ) + } + case codegenResult.codeGenerationStatusDetail?.includes('EmptyPatch'): { + if (codegenResult.codeGenerationStatusDetail?.includes('NO_CHANGE_REQUIRED')) { + throw new NoChangeRequiredException() + } + throw new DocServiceError( + i18n('AWS.amazonq.doc.error.docGen.default'), + 'EmptyPatchException' + ) + } + case codegenResult.codeGenerationStatusDetail?.includes('Throttling'): { + throw new DocServiceError( + i18n('AWS.amazonq.featureDev.error.throttling'), + 'ThrottlingException' + ) + } + default: { + throw new ToolkitError(i18n('AWS.amazonq.doc.error.docGen.default'), { + code: 'DocGenerationFailed', + }) + } + } + } + default: { + const errorMessage = `Unknown status: ${codegenResult.codeGenerationStatus.status}\n` + throw new ToolkitError(errorMessage, { code: 'UnknownDocGenerationError' }) + } + } + } + if (!this.isCancellationRequested) { + // still in progress + const errorMessage = i18n('AWS.amazonq.featureDev.error.codeGen.timeout') + throw new ToolkitError(errorMessage, { code: 'DocGenerationTimeout' }) + } + return { + newFiles: [], + deletedFiles: [], + references: [], + } + } +} + +export class CodeGenState extends CodeGenBase implements SessionState { + constructor( + config: SessionStateConfig, + public filePaths: NewFileInfo[], + public deletedFiles: DeletedFileInfo[], + public references: CodeReference[], + tabID: string, + public currentIteration: number, + public uploadHistory: UploadHistory, + public codeGenerationRemainingIterationCount?: number, + public codeGenerationTotalIterationCount?: number + ) { + super(config, tabID) + } + + async interact(action: SessionStateAction): Promise { + return telemetry.amazonq_codeGenerationInvoke.run(async (span) => { + try { + action.tokenSource?.token.onCancellationRequested(() => { + this.isCancellationRequested = true + if (action.tokenSource) { + this.tokenSource = action.tokenSource + } + }) + + span.record({ + amazonqConversationId: this.config.conversationId, + credentialStartUrl: AuthUtil.instance.startUrl, + }) + + action.telemetry.setGenerateCodeIteration(this.currentIteration) + action.telemetry.setGenerateCodeLastInvocationTime() + const codeGenerationId = randomUUID() + + action.messenger.sendDocProgress(this.tabID, DocGenerationStep.SUMMARIZING_FILES, 0, action.mode) + + await this.config.proxyClient.startCodeGeneration( + this.config.conversationId, + this.config.uploadId, + action.msg, + Intent.DOC, + codeGenerationId, + undefined, + action.folderPath ? { documentation: { type: 'README', scope: action.folderPath } } : undefined + ) + + const codeGeneration = await this.generateCode({ + messenger: action.messenger, + fs: action.fs, + codeGenerationId, + telemetry: action.telemetry, + workspaceFolders: this.config.workspaceFolders, + mode: action.mode, + }) + + if (codeGeneration && !action.tokenSource?.token.isCancellationRequested) { + this.config.currentCodeGenerationId = codeGenerationId + this.currentCodeGenerationId = codeGenerationId + } + + this.filePaths = codeGeneration.newFiles + this.deletedFiles = codeGeneration.deletedFiles + this.references = codeGeneration.references + this.codeGenerationRemainingIterationCount = codeGeneration.codeGenerationRemainingIterationCount + this.codeGenerationTotalIterationCount = codeGeneration.codeGenerationTotalIterationCount + + if (action.uploadHistory && !action.uploadHistory[codeGenerationId] && codeGenerationId) { + action.uploadHistory[codeGenerationId] = { + timestamp: Date.now(), + uploadId: this.config.uploadId, + filePaths: codeGeneration.newFiles, + deletedFiles: codeGeneration.deletedFiles, + tabId: this.tabID, + } + } + + action.telemetry.setAmazonqNumberOfReferences(this.references.length) + action.telemetry.recordUserCodeGenerationTelemetry(span, this.conversationId) + const nextState = new PrepareCodeGenState( + this.config, + this.filePaths, + this.deletedFiles, + this.references, + this.tabID, + this.currentIteration + 1, + this.codeGenerationRemainingIterationCount, + this.codeGenerationTotalIterationCount, + action.uploadHistory, + this.tokenSource, + this.currentCodeGenerationId, + codeGenerationId + ) + return { + nextState, + interaction: {}, + } + } catch (e) { + throw e instanceof ToolkitError + ? e + : ToolkitError.chain(e, 'Server side error', { code: 'UnhandledCodeGenServerSideError' }) + } + }) + } +} + +export class PrepareCodeGenState implements SessionState { + public tokenSource: vscode.CancellationTokenSource + public readonly phase = DevPhase.CODEGEN + public uploadId: string + public conversationId: string + constructor( + private config: SessionStateConfig, + public filePaths: NewFileInfo[], + public deletedFiles: DeletedFileInfo[], + public references: CodeReference[], + public tabID: string, + public currentIteration: number, + public codeGenerationRemainingIterationCount?: number, + public codeGenerationTotalIterationCount?: number, + public uploadHistory: UploadHistory = {}, + public superTokenSource: vscode.CancellationTokenSource = new vscode.CancellationTokenSource(), + public currentCodeGenerationId?: string, + public codeGenerationId?: string + ) { + this.tokenSource = superTokenSource || new vscode.CancellationTokenSource() + this.uploadId = config.uploadId + this.currentCodeGenerationId = currentCodeGenerationId + this.conversationId = config.conversationId + this.uploadHistory = uploadHistory + this.codeGenerationId = codeGenerationId + } + + updateWorkspaceRoot(workspaceRoot: string) { + this.config.workspaceRoots = [workspaceRoot] + } + + async interact(action: SessionStateAction): Promise { + const uploadId = await telemetry.amazonq_createUpload.run(async (span) => { + span.record({ + amazonqConversationId: this.config.conversationId, + credentialStartUrl: AuthUtil.instance.startUrl, + }) + const { zipFileBuffer, zipFileChecksum } = await prepareRepoData( + this.config.workspaceRoots, + this.config.workspaceFolders, + action.telemetry, + span + ) + const uploadId = randomUUID() + const { uploadUrl, kmsKeyArn } = await this.config.proxyClient.createUploadUrl( + this.config.conversationId, + zipFileChecksum, + zipFileBuffer.length, + uploadId + ) + + await uploadCode(uploadUrl, zipFileBuffer, zipFileChecksum, kmsKeyArn) + + return uploadId + }) + this.uploadId = uploadId + const nextState = new CodeGenState( + { ...this.config, uploadId }, + this.filePaths, + this.deletedFiles, + this.references, + this.tabID, + this.currentIteration, + this.uploadHistory + ) + return nextState.interact(action) + } +} diff --git a/packages/core/src/amazonqDoc/storages/chatSession.ts b/packages/core/src/amazonqDoc/storages/chatSession.ts new file mode 100644 index 00000000000..34fb9f5404e --- /dev/null +++ b/packages/core/src/amazonqDoc/storages/chatSession.ts @@ -0,0 +1,23 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BaseChatSessionStorage } from '../../amazonq/commons/baseChatStorage' +import { createSessionConfig } from '../../amazonq/commons/session/sessionConfigFactory' +import { docScheme } from '../constants' +import { DocMessenger } from '../messenger' +import { Session } from '../session/session' + +export class DocChatSessionStorage extends BaseChatSessionStorage { + constructor(protected readonly messenger: DocMessenger) { + super() + } + + override async createSession(tabID: string): Promise { + const sessionConfig = await createSessionConfig(docScheme) + const session = new Session(sessionConfig, this.messenger, tabID) + this.sessions.set(tabID, session) + return session + } +} diff --git a/packages/core/src/amazonqDoc/types.ts b/packages/core/src/amazonqDoc/types.ts new file mode 100644 index 00000000000..3c9a510e2cb --- /dev/null +++ b/packages/core/src/amazonqDoc/types.ts @@ -0,0 +1,71 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatItemButton, MynahIcons, ProgressField } from '@aws/mynah-ui' +import { + LLMResponseType, + SessionStorage, + SessionInfo, + DeletedFileInfo, + NewFileInfo, + NewFileZipContents, + SessionStateConfig, + SessionStatePhase, + DevPhase, + Interaction, + CurrentWsFolders, + CodeGenerationStatus, + SessionState as FeatureDevSessionState, + SessionStateAction as FeatureDevSessionStateAction, + SessionStateInteraction as FeatureDevSessionStateInteraction, +} from '../amazonqFeatureDev/types' + +import { Mode } from './constants' +import { DocMessenger } from './messenger' + +export const cancelDocGenButton: ChatItemButton = { + id: 'cancel-doc-generation', + text: 'Cancel', + icon: 'cancel' as MynahIcons, +} + +export const inProgress = (progress: number, text: string): ProgressField => { + return { + status: 'default', + text, + value: progress === 100 ? -1 : progress, + actions: [cancelDocGenButton], + } +} + +export interface SessionStateInteraction extends FeatureDevSessionStateInteraction { + nextState: SessionState | Omit | undefined + interaction: Interaction +} + +export interface SessionState extends FeatureDevSessionState { + interact(action: SessionStateAction): Promise +} + +export interface SessionStateAction extends FeatureDevSessionStateAction { + messenger: DocMessenger + mode: Mode + folderPath?: string +} + +export { + LLMResponseType, + SessionStorage, + SessionInfo, + DeletedFileInfo, + NewFileInfo, + NewFileZipContents, + SessionStateConfig, + SessionStatePhase, + DevPhase, + Interaction, + CodeGenerationStatus, + CurrentWsFolders, +} diff --git a/packages/core/src/amazonqDoc/views/actions/uiMessageListener.ts b/packages/core/src/amazonqDoc/views/actions/uiMessageListener.ts new file mode 100644 index 00000000000..c6960b15fcc --- /dev/null +++ b/packages/core/src/amazonqDoc/views/actions/uiMessageListener.ts @@ -0,0 +1,168 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChatControllerEventEmitters } from '../../controllers/chat/controller' +import { MessageListener } from '../../../amazonq/messages/messageListener' +import { ExtensionMessage } from '../../../amazonq/webview/ui/commands' + +export interface UIMessageListenerProps { + readonly chatControllerEventEmitters: ChatControllerEventEmitters + readonly webViewMessageListener: MessageListener +} + +export class UIMessageListener { + private docGenerationControllerEventsEmitters: ChatControllerEventEmitters | undefined + private webViewMessageListener: MessageListener + + constructor(props: UIMessageListenerProps) { + this.docGenerationControllerEventsEmitters = props.chatControllerEventEmitters + this.webViewMessageListener = props.webViewMessageListener + + // Now we are listening to events that get sent from amazonq/webview/actions/actionListener (e.g. the tab) + this.webViewMessageListener.onMessage((msg) => { + this.handleMessage(msg) + }) + } + + private handleMessage(msg: ExtensionMessage) { + switch (msg.command) { + case 'chat-prompt': + this.processChatMessage(msg) + break + case 'follow-up-was-clicked': + this.followUpClicked(msg) + break + case 'open-diff': + this.openDiff(msg) + break + case 'chat-item-voted': + this.chatItemVoted(msg) + break + case 'chat-item-feedback': + this.chatItemFeedback(msg) + break + case 'stop-response': + this.stopResponse(msg) + break + case 'new-tab-was-created': + this.tabOpened(msg) + break + case 'tab-was-removed': + this.tabClosed(msg) + break + case 'auth-follow-up-was-clicked': + this.authClicked(msg) + break + case 'response-body-link-click': + this.processResponseBodyLinkClick(msg) + break + case 'insert_code_at_cursor_position': + this.insertCodeAtPosition(msg) + break + case 'file-click': + this.fileClicked(msg) + break + case 'form-action-click': + this.formActionClicked(msg) + break + } + } + + private chatItemVoted(msg: any) { + this.docGenerationControllerEventsEmitters?.processChatItemVotedMessage.fire({ + tabID: msg.tabID, + command: msg.command, + vote: msg.vote, + messageId: msg.messageId, + }) + } + + private chatItemFeedback(msg: any) { + this.docGenerationControllerEventsEmitters?.processChatItemFeedbackMessage.fire(msg) + } + + private processChatMessage(msg: any) { + this.docGenerationControllerEventsEmitters?.processHumanChatMessage.fire({ + message: msg.chatMessage, + tabID: msg.tabID, + }) + } + + private followUpClicked(msg: any) { + this.docGenerationControllerEventsEmitters?.followUpClicked.fire({ + followUp: msg.followUp, + tabID: msg.tabID, + }) + } + + private formActionClicked(msg: any) { + this.docGenerationControllerEventsEmitters?.formActionClicked.fire({ + ...msg, + }) + } + + private fileClicked(msg: any) { + this.docGenerationControllerEventsEmitters?.fileClicked.fire({ + tabID: msg.tabID, + filePath: msg.filePath, + actionName: msg.actionName, + messageId: msg.messageId, + }) + } + + private openDiff(msg: any) { + this.docGenerationControllerEventsEmitters?.openDiff.fire({ + tabID: msg.tabID, + filePath: msg.filePath, + deleted: msg.deleted, + messageId: msg.messageId, + }) + } + + private stopResponse(msg: any) { + this.docGenerationControllerEventsEmitters?.stopResponse.fire({ + tabID: msg.tabID, + }) + } + + private tabOpened(msg: any) { + this.docGenerationControllerEventsEmitters?.tabOpened.fire({ + tabID: msg.tabID, + }) + } + + private tabClosed(msg: any) { + this.docGenerationControllerEventsEmitters?.tabClosed.fire({ + tabID: msg.tabID, + }) + } + + private authClicked(msg: any) { + this.docGenerationControllerEventsEmitters?.authClicked.fire({ + tabID: msg.tabID, + authType: msg.authType, + }) + } + + private processResponseBodyLinkClick(msg: any) { + this.docGenerationControllerEventsEmitters?.processResponseBodyLinkClick.fire({ + command: msg.command, + messageId: msg.messageId, + tabID: msg.tabID, + link: msg.link, + }) + } + + private insertCodeAtPosition(msg: any) { + this.docGenerationControllerEventsEmitters?.insertCodeAtPositionClicked.fire({ + command: msg.command, + messageId: msg.messageId, + tabID: msg.tabID, + code: msg.code, + insertionTargetType: msg.insertionTargetType, + codeReference: msg.codeReference, + }) + } +} diff --git a/packages/core/src/amazonqFeatureDev/app.ts b/packages/core/src/amazonqFeatureDev/app.ts index 3aa6b06356f..99164f417be 100644 --- a/packages/core/src/amazonqFeatureDev/app.ts +++ b/packages/core/src/amazonqFeatureDev/app.ts @@ -12,13 +12,13 @@ import { MessageListener } from '../amazonq/messages/messageListener' import { fromQueryToParameters } from '../shared/utilities/uriUtils' import { getLogger } from '../shared/logger' import { TabIdNotFoundError } from './errors' -import { featureDevScheme } from './constants' -import { Messenger } from './controllers/chat/messenger/messenger' -import { AppToWebViewMessageDispatcher } from './views/connector/connector' +import { featureDevChat, featureDevScheme } from './constants' import globals from '../shared/extensionGlobals' -import { ChatSessionStorage } from './storages/chatSession' +import { FeatureDevChatSessionStorage } from './storages/chatSession' import { AuthUtil } from '../codewhisperer/util/authUtil' import { debounce } from 'lodash' +import { Messenger } from '../amazonq/commons/connector/baseMessenger' +import { AppToWebViewMessageDispatcher } from '../amazonq/commons/connector/connectorMessages' export function init(appContext: AmazonQAppInitContext) { const featureDevChatControllerEventEmitters: ChatControllerEventEmitters = { @@ -37,8 +37,11 @@ export function init(appContext: AmazonQAppInitContext) { storeCodeResultMessageId: new vscode.EventEmitter(), } - const messenger = new Messenger(new AppToWebViewMessageDispatcher(appContext.getAppsToWebViewMessagePublisher())) - const sessionStorage = new ChatSessionStorage(messenger) + const messenger = new Messenger( + new AppToWebViewMessageDispatcher(appContext.getAppsToWebViewMessagePublisher()), + featureDevChat + ) + const sessionStorage = new FeatureDevChatSessionStorage(messenger) new FeatureDevController( featureDevChatControllerEventEmitters, diff --git a/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json b/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json index 03abd3c16b1..6f628b220cb 100644 --- a/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json +++ b/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json @@ -21,13 +21,25 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "CreateUploadUrlRequest" }, - "output": { "shape": "CreateUploadUrlResponse" }, + "input": { + "shape": "CreateUploadUrlRequest" + }, + "output": { + "shape": "CreateUploadUrlResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ], "idempotent": true }, @@ -37,14 +49,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "CreateTaskAssistConversationRequest" }, - "output": { "shape": "CreateTaskAssistConversationResponse" }, + "input": { + "shape": "CreateTaskAssistConversationRequest" + }, + "output": { + "shape": "CreateTaskAssistConversationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ServiceQuotaExceededException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ServiceQuotaExceededException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "CreateUploadUrl": { @@ -53,16 +79,34 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "CreateUploadUrlRequest" }, - "output": { "shape": "CreateUploadUrlResponse" }, + "input": { + "shape": "CreateUploadUrlRequest" + }, + "output": { + "shape": "CreateUploadUrlResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ConflictException" }, - { "shape": "ServiceQuotaExceededException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ConflictException" + }, + { + "shape": "ServiceQuotaExceededException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ], "idempotent": true }, @@ -72,14 +116,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "DeleteTaskAssistConversationRequest" }, - "output": { "shape": "DeleteTaskAssistConversationResponse" }, + "input": { + "shape": "DeleteTaskAssistConversationRequest" + }, + "output": { + "shape": "DeleteTaskAssistConversationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "GenerateCompletions": { @@ -88,13 +146,25 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "GenerateCompletionsRequest" }, - "output": { "shape": "GenerateCompletionsResponse" }, + "input": { + "shape": "GenerateCompletionsRequest" + }, + "output": { + "shape": "GenerateCompletionsResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "GetCodeAnalysis": { @@ -103,14 +173,58 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "GetCodeAnalysisRequest" }, - "output": { "shape": "GetCodeAnalysisResponse" }, + "input": { + "shape": "GetCodeAnalysisRequest" + }, + "output": { + "shape": "GetCodeAnalysisResponse" + }, + "errors": [ + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } + ] + }, + "GetCodeFixJob": { + "name": "GetCodeFixJob", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetCodeFixJobRequest" + }, + "output": { + "shape": "GetCodeFixJobResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "GetTaskAssistCodeGeneration": { @@ -119,15 +233,58 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "GetTaskAssistCodeGenerationRequest" }, - "output": { "shape": "GetTaskAssistCodeGenerationResponse" }, + "input": { + "shape": "GetTaskAssistCodeGenerationRequest" + }, + "output": { + "shape": "GetTaskAssistCodeGenerationResponse" + }, + "errors": [ + { + "shape": "ThrottlingException" + }, + { + "shape": "ConflictException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } + ] + }, + "GetTestGeneration": { + "name": "GetTestGeneration", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetTestGenerationRequest" + }, + "output": { + "shape": "GetTestGenerationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ConflictException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "GetTransformation": { @@ -136,14 +293,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "GetTransformationRequest" }, - "output": { "shape": "GetTransformationResponse" }, + "input": { + "shape": "GetTransformationRequest" + }, + "output": { + "shape": "GetTransformationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "GetTransformationPlan": { @@ -152,14 +323,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "GetTransformationPlanRequest" }, - "output": { "shape": "GetTransformationPlanResponse" }, + "input": { + "shape": "GetTransformationPlanRequest" + }, + "output": { + "shape": "GetTransformationPlanResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "ListAvailableCustomizations": { @@ -168,13 +353,25 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "ListAvailableCustomizationsRequest" }, - "output": { "shape": "ListAvailableCustomizationsResponse" }, + "input": { + "shape": "ListAvailableCustomizationsRequest" + }, + "output": { + "shape": "ListAvailableCustomizationsResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "ListCodeAnalysisFindings": { @@ -183,14 +380,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "ListCodeAnalysisFindingsRequest" }, - "output": { "shape": "ListCodeAnalysisFindingsResponse" }, + "input": { + "shape": "ListCodeAnalysisFindingsRequest" + }, + "output": { + "shape": "ListCodeAnalysisFindingsResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "ListFeatureEvaluations": { @@ -199,13 +410,25 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "ListFeatureEvaluationsRequest" }, - "output": { "shape": "ListFeatureEvaluationsResponse" }, + "input": { + "shape": "ListFeatureEvaluationsRequest" + }, + "output": { + "shape": "ListFeatureEvaluationsResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "ResumeTransformation": { @@ -214,14 +437,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "ResumeTransformationRequest" }, - "output": { "shape": "ResumeTransformationResponse" }, + "input": { + "shape": "ResumeTransformationRequest" + }, + "output": { + "shape": "ResumeTransformationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "SendTelemetryEvent": { @@ -230,13 +467,25 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "SendTelemetryEventRequest" }, - "output": { "shape": "SendTelemetryEventResponse" }, + "input": { + "shape": "SendTelemetryEventRequest" + }, + "output": { + "shape": "SendTelemetryEventResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ], "idempotent": true }, @@ -246,50 +495,156 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "StartCodeAnalysisRequest" }, - "output": { "shape": "StartCodeAnalysisResponse" }, + "input": { + "shape": "StartCodeAnalysisRequest" + }, + "output": { + "shape": "StartCodeAnalysisResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ConflictException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ConflictException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ], "idempotent": true }, + "StartCodeFixJob": { + "name": "StartCodeFixJob", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "StartCodeFixJobRequest" + }, + "output": { + "shape": "StartCodeFixJobResponse" + }, + "errors": [ + { + "shape": "ThrottlingException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } + ] + }, "StartTaskAssistCodeGeneration": { "name": "StartTaskAssistCodeGeneration", "http": { "method": "POST", "requestUri": "/" }, - "input": { "shape": "StartTaskAssistCodeGenerationRequest" }, - "output": { "shape": "StartTaskAssistCodeGenerationResponse" }, + "input": { + "shape": "StartTaskAssistCodeGenerationRequest" + }, + "output": { + "shape": "StartTaskAssistCodeGenerationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ConflictException" }, - { "shape": "ServiceQuotaExceededException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ConflictException" + }, + { + "shape": "ServiceQuotaExceededException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, + "StartTestGeneration": { + "name": "StartTestGeneration", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "StartTestGenerationRequest" + }, + "output": { + "shape": "StartTestGenerationResponse" + }, + "errors": [ + { + "shape": "ThrottlingException" + }, + { + "shape": "ConflictException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } + ], + "idempotent": true + }, "StartTransformation": { "name": "StartTransformation", "http": { "method": "POST", "requestUri": "/" }, - "input": { "shape": "StartTransformationRequest" }, - "output": { "shape": "StartTransformationResponse" }, + "input": { + "shape": "StartTransformationRequest" + }, + "output": { + "shape": "StartTransformationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ConflictException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ConflictException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] }, "StopTransformation": { @@ -298,14 +653,28 @@ "method": "POST", "requestUri": "/" }, - "input": { "shape": "StopTransformationRequest" }, - "output": { "shape": "StopTransformationResponse" }, + "input": { + "shape": "StopTransformationRequest" + }, + "output": { + "shape": "StopTransformationResponse" + }, "errors": [ - { "shape": "ThrottlingException" }, - { "shape": "ResourceNotFoundException" }, - { "shape": "InternalServerException" }, - { "shape": "ValidationException" }, - { "shape": "AccessDeniedException" } + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } ] } }, @@ -314,8 +683,12 @@ "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" }, - "reason": { "shape": "AccessDeniedExceptionReason" } + "message": { + "shape": "String" + }, + "reason": { + "shape": "AccessDeniedExceptionReason" + } }, "exception": true }, @@ -327,10 +700,18 @@ "type": "structure", "required": ["namespace", "propertyName", "propertyContext"], "members": { - "namespace": { "shape": "AppStudioStateNamespaceString" }, - "propertyName": { "shape": "AppStudioStatePropertyNameString" }, - "propertyValue": { "shape": "AppStudioStatePropertyValueString" }, - "propertyContext": { "shape": "AppStudioStatePropertyContextString" } + "namespace": { + "shape": "AppStudioStateNamespaceString" + }, + "propertyName": { + "shape": "AppStudioStatePropertyNameString" + }, + "propertyValue": { + "shape": "AppStudioStatePropertyValueString" + }, + "propertyContext": { + "shape": "AppStudioStatePropertyContextString" + } } }, "AppStudioStateNamespaceString": { @@ -365,8 +746,12 @@ }, "ArtifactMap": { "type": "map", - "key": { "shape": "ArtifactType" }, - "value": { "shape": "UploadId" }, + "key": { + "shape": "ArtifactType" + }, + "value": { + "shape": "UploadId" + }, "max": 64, "min": 1 }, @@ -378,11 +763,21 @@ "type": "structure", "required": ["content"], "members": { - "messageId": { "shape": "MessageId" }, - "content": { "shape": "AssistantResponseMessageContentString" }, - "supplementaryWebLinks": { "shape": "SupplementaryWebLinks" }, - "references": { "shape": "References" }, - "followupPrompt": { "shape": "FollowupPrompt" } + "messageId": { + "shape": "MessageId" + }, + "content": { + "shape": "AssistantResponseMessageContentString" + }, + "supplementaryWebLinks": { + "shape": "SupplementaryWebLinks" + }, + "references": { + "shape": "References" + }, + "followupPrompt": { + "shape": "FollowupPrompt" + } } }, "AssistantResponseMessageContentString": { @@ -405,25 +800,55 @@ "type": "structure", "required": ["conversationId", "messageId"], "members": { - "conversationId": { "shape": "ConversationId" }, - "messageId": { "shape": "MessageId" }, - "customizationArn": { "shape": "CustomizationArn" }, - "userIntent": { "shape": "UserIntent" }, - "hasCodeSnippet": { "shape": "Boolean" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "activeEditorTotalCharacters": { "shape": "Integer" }, - "timeToFirstChunkMilliseconds": { "shape": "Double" }, - "timeBetweenChunks": { "shape": "timeBetweenChunks" }, - "fullResponselatency": { "shape": "Double" }, - "requestLength": { "shape": "Integer" }, - "responseLength": { "shape": "Integer" }, - "numberOfCodeBlocks": { "shape": "Integer" }, - "hasProjectLevelContext": { "shape": "Boolean" } + "conversationId": { + "shape": "ConversationId" + }, + "messageId": { + "shape": "MessageId" + }, + "customizationArn": { + "shape": "CustomizationArn" + }, + "userIntent": { + "shape": "UserIntent" + }, + "hasCodeSnippet": { + "shape": "Boolean" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "activeEditorTotalCharacters": { + "shape": "Integer" + }, + "timeToFirstChunkMilliseconds": { + "shape": "Double" + }, + "timeBetweenChunks": { + "shape": "timeBetweenChunks" + }, + "fullResponselatency": { + "shape": "Double" + }, + "requestLength": { + "shape": "Integer" + }, + "responseLength": { + "shape": "Integer" + }, + "numberOfCodeBlocks": { + "shape": "Integer" + }, + "hasProjectLevelContext": { + "shape": "Boolean" + } } }, "ChatHistory": { "type": "list", - "member": { "shape": "ChatMessage" }, + "member": { + "shape": "ChatMessage" + }, "max": 10, "min": 0 }, @@ -435,7 +860,9 @@ "messageId": { "shape": "MessageId" }, "customizationArn": { "shape": "CustomizationArn" }, "interactionType": { "shape": "ChatMessageInteractionType" }, - "interactionTarget": { "shape": "ChatInteractWithMessageEventInteractionTargetString" }, + "interactionTarget": { + "shape": "ChatInteractWithMessageEventInteractionTargetString" + }, "acceptedCharacterCount": { "shape": "Integer" }, "acceptedLineCount": { "shape": "Integer" }, "acceptedSnippetHasReference": { "shape": "Boolean" }, @@ -451,8 +878,12 @@ "ChatMessage": { "type": "structure", "members": { - "userInputMessage": { "shape": "UserInputMessage" }, - "assistantResponseMessage": { "shape": "AssistantResponseMessage" } + "userInputMessage": { + "shape": "UserInputMessage" + }, + "assistantResponseMessage": { + "shape": "AssistantResponseMessage" + } }, "union": true }, @@ -472,18 +903,30 @@ }, "ChatTriggerType": { "type": "string", - "enum": ["MANUAL", "DIAGNOSTIC"] + "enum": ["MANUAL", "DIAGNOSTIC", "INLINE_CHAT"] }, "ChatUserModificationEvent": { "type": "structure", "required": ["conversationId", "messageId", "modificationPercentage"], "members": { - "conversationId": { "shape": "ConversationId" }, - "customizationArn": { "shape": "CustomizationArn" }, - "messageId": { "shape": "MessageId" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "modificationPercentage": { "shape": "Double" }, - "hasProjectLevelContext": { "shape": "Boolean" } + "conversationId": { + "shape": "ConversationId" + }, + "customizationArn": { + "shape": "CustomizationArn" + }, + "messageId": { + "shape": "MessageId" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "modificationPercentage": { + "shape": "Double" + }, + "hasProjectLevelContext": { + "shape": "Boolean" + } } }, "CodeAnalysisFindingsSchema": { @@ -502,7 +945,9 @@ "type": "structure", "required": ["codeScanName"], "members": { - "codeScanName": { "shape": "CodeScanName" } + "codeScanName": { + "shape": "CodeScanName" + } } }, "CodeCoverageEvent": { @@ -514,7 +959,82 @@ "acceptedCharacterCount": { "shape": "PrimitiveInteger" }, "totalCharacterCount": { "shape": "PrimitiveInteger" }, "timestamp": { "shape": "Timestamp" }, - "unmodifiedAcceptedCharacterCount": { "shape": "PrimitiveInteger" } + "unmodifiedAcceptedCharacterCount": { "shape": "PrimitiveInteger" }, + "totalNewCodeCharacterCount": { "shape": "PrimitiveInteger" }, + "totalNewCodeLineCount": { "shape": "PrimitiveInteger" } + } + }, + "CodeFixAcceptanceEvent": { + "type": "structure", + "required": ["jobId"], + "members": { + "jobId": { + "shape": "String" + }, + "ruleId": { + "shape": "String" + }, + "detectorId": { + "shape": "String" + }, + "findingId": { + "shape": "String" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "linesOfCodeAccepted": { + "shape": "Integer" + }, + "charsOfCodeAccepted": { + "shape": "Integer" + } + } + }, + "CodeFixGenerationEvent": { + "type": "structure", + "required": ["jobId"], + "members": { + "jobId": { + "shape": "String" + }, + "ruleId": { + "shape": "String" + }, + "detectorId": { + "shape": "String" + }, + "findingId": { + "shape": "String" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "linesOfCodeGenerated": { + "shape": "Integer" + }, + "charsOfCodeGenerated": { + "shape": "Integer" + } + } + }, + "CodeFixJobStatus": { + "type": "string", + "enum": ["Succeeded", "InProgress", "Failed"] + }, + "CodeFixName": { + "type": "string", + "max": 128, + "min": 1, + "pattern": "[a-zA-Z0-9-_$:.]*" + }, + "CodeFixUploadContext": { + "type": "structure", + "required": ["codeFixName"], + "members": { + "codeFixName": { + "shape": "CodeFixName" + } } }, "CodeGenerationId": { @@ -526,8 +1046,12 @@ "type": "structure", "required": ["status", "currentStage"], "members": { - "status": { "shape": "CodeGenerationWorkflowStatus" }, - "currentStage": { "shape": "CodeGenerationWorkflowStage" } + "status": { + "shape": "CodeGenerationWorkflowStatus" + }, + "currentStage": { + "shape": "CodeGenerationWorkflowStage" + } } }, "CodeGenerationStatusDetail": { @@ -543,6 +1067,24 @@ "enum": ["InProgress", "Complete", "Failed"] }, "CodeScanEvent": { + "type": "structure", + "required": ["programmingLanguage", "codeScanJobId", "timestamp"], + "members": { + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "codeScanJobId": { + "shape": "CodeScanJobId" + }, + "timestamp": { + "shape": "Timestamp" + }, + "codeAnalysisScope": { + "shape": "CodeAnalysisScope" + } + } + }, + "CodeScanFailedEvent": { "type": "structure", "required": ["programmingLanguage", "codeScanJobId", "timestamp"], "members": { @@ -566,7 +1108,9 @@ "type": "structure", "members": { "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "CodeScanRemediationsEventType": { "shape": "CodeScanRemediationsEventType" }, + "CodeScanRemediationsEventType": { + "shape": "CodeScanRemediationsEventType" + }, "timestamp": { "shape": "Timestamp" }, "detectorId": { "shape": "String" }, "findingId": { "shape": "String" }, @@ -581,13 +1125,30 @@ "type": "string", "enum": ["CODESCAN_ISSUE_HOVER", "CODESCAN_ISSUE_APPLY_FIX", "CODESCAN_ISSUE_VIEW_DETAILS"] }, + "CodeScanSucceededEvent": { + "type": "structure", + "required": ["programmingLanguage", "codeScanJobId", "timestamp", "numberOfFindings"], + "members": { + "programmingLanguage": { "shape": "ProgrammingLanguage" }, + "codeScanJobId": { "shape": "CodeScanJobId" }, + "timestamp": { "shape": "Timestamp" }, + "numberOfFindings": { "shape": "PrimitiveInteger" }, + "codeAnalysisScope": { "shape": "CodeAnalysisScope" } + } + }, "Completion": { "type": "structure", "required": ["content"], "members": { - "content": { "shape": "CompletionContentString" }, - "references": { "shape": "References" }, - "mostRelevantMissingImports": { "shape": "Imports" } + "content": { + "shape": "CompletionContentString" + }, + "references": { + "shape": "References" + }, + "mostRelevantMissingImports": { + "shape": "Imports" + } } }, "CompletionContentString": { @@ -602,7 +1163,9 @@ }, "Completions": { "type": "list", - "member": { "shape": "Completion" }, + "member": { + "shape": "Completion" + }, "max": 10, "min": 0 }, @@ -610,19 +1173,40 @@ "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" } + "message": { + "shape": "String" + }, + "reason": { + "shape": "ConflictExceptionReason" + } }, "exception": true }, + "ConflictExceptionReason": { + "type": "string", + "enum": ["CUSTOMER_KMS_KEY_INVALID_KEY_POLICY", "CUSTOMER_KMS_KEY_DISABLED", "MISMATCHED_KMS_KEY"] + }, "ConsoleState": { "type": "structure", "members": { - "region": { "shape": "String" }, - "consoleUrl": { "shape": "SensitiveString" }, - "serviceId": { "shape": "String" }, - "serviceConsolePage": { "shape": "String" }, - "serviceSubconsolePage": { "shape": "String" }, - "taskName": { "shape": "SensitiveString" } + "region": { + "shape": "String" + }, + "consoleUrl": { + "shape": "SensitiveString" + }, + "serviceId": { + "shape": "String" + }, + "serviceConsolePage": { + "shape": "String" + }, + "serviceSubconsolePage": { + "shape": "String" + }, + "taskName": { + "shape": "SensitiveString" + } } }, "ContentChecksumType": { @@ -642,11 +1226,21 @@ "type": "structure", "required": ["currentMessage", "chatTriggerType"], "members": { - "conversationId": { "shape": "ConversationId" }, - "history": { "shape": "ChatHistory" }, - "currentMessage": { "shape": "ChatMessage" }, - "chatTriggerType": { "shape": "ChatTriggerType" }, - "customizationArn": { "shape": "ResourceArn" } + "conversationId": { + "shape": "ConversationId" + }, + "history": { + "shape": "ChatHistory" + }, + "currentMessage": { + "shape": "ChatMessage" + }, + "chatTriggerType": { + "shape": "ChatTriggerType" + }, + "customizationArn": { + "shape": "ResourceArn" + } } }, "CreateTaskAssistConversationRequest": { @@ -657,14 +1251,18 @@ "type": "structure", "required": ["conversationId"], "members": { - "conversationId": { "shape": "ConversationId" } + "conversationId": { + "shape": "ConversationId" + } } }, "CreateUploadUrlRequest": { "type": "structure", "members": { "contentMd5": { "shape": "CreateUploadUrlRequestContentMd5String" }, - "contentChecksum": { "shape": "CreateUploadUrlRequestContentChecksumString" }, + "contentChecksum": { + "shape": "CreateUploadUrlRequestContentChecksumString" + }, "contentChecksumType": { "shape": "ContentChecksumType" }, "contentLength": { "shape": "CreateUploadUrlRequestContentLengthLong" }, "artifactType": { "shape": "ArtifactType" }, @@ -694,17 +1292,29 @@ "type": "structure", "required": ["uploadId", "uploadUrl"], "members": { - "uploadId": { "shape": "UploadId" }, - "uploadUrl": { "shape": "PreSignedUrl" }, - "kmsKeyArn": { "shape": "ResourceArn" }, - "requestHeaders": { "shape": "RequestHeaders" } + "uploadId": { + "shape": "UploadId" + }, + "uploadUrl": { + "shape": "PreSignedUrl" + }, + "kmsKeyArn": { + "shape": "ResourceArn" + }, + "requestHeaders": { + "shape": "RequestHeaders" + } } }, "CursorState": { "type": "structure", "members": { - "position": { "shape": "Position" }, - "range": { "shape": "Range" } + "position": { + "shape": "Position" + }, + "range": { + "shape": "Range" + } }, "union": true }, @@ -712,9 +1322,15 @@ "type": "structure", "required": ["arn"], "members": { - "arn": { "shape": "CustomizationArn" }, - "name": { "shape": "CustomizationName" }, - "description": { "shape": "Description" } + "arn": { + "shape": "CustomizationArn" + }, + "name": { + "shape": "CustomizationName" + }, + "description": { + "shape": "Description" + } } }, "CustomizationArn": { @@ -731,20 +1347,26 @@ }, "Customizations": { "type": "list", - "member": { "shape": "Customization" } + "member": { + "shape": "Customization" + } }, "DeleteTaskAssistConversationRequest": { "type": "structure", "required": ["conversationId"], "members": { - "conversationId": { "shape": "ConversationId" } + "conversationId": { + "shape": "ConversationId" + } } }, "DeleteTaskAssistConversationResponse": { "type": "structure", "required": ["conversationId"], "members": { - "conversationId": { "shape": "ConversationId" } + "conversationId": { + "shape": "ConversationId" + } } }, "Description": { @@ -756,8 +1378,12 @@ "Diagnostic": { "type": "structure", "members": { - "textDocumentDiagnostic": { "shape": "TextDocumentDiagnostic" }, - "runtimeDiagnostic": { "shape": "RuntimeDiagnostic" } + "textDocumentDiagnostic": { + "shape": "TextDocumentDiagnostic" + }, + "runtimeDiagnostic": { + "shape": "RuntimeDiagnostic" + } }, "union": true }, @@ -768,13 +1394,19 @@ "Dimension": { "type": "structure", "members": { - "name": { "shape": "DimensionNameString" }, - "value": { "shape": "DimensionValueString" } + "name": { + "shape": "DimensionNameString" + }, + "value": { + "shape": "DimensionValueString" + } } }, "DimensionList": { "type": "list", - "member": { "shape": "Dimension" }, + "member": { + "shape": "Dimension" + }, "max": 30, "min": 0 }, @@ -790,13 +1422,46 @@ "min": 1, "pattern": "[-a-zA-Z0-9._]*" }, + "DocGenerationEvent": { + "type": "structure", + "required": ["conversationId"], + "members": { + "conversationId": { "shape": "ConversationId" }, + "numberOfAddChars": { "shape": "PrimitiveInteger" }, + "numberOfAddLines": { "shape": "PrimitiveInteger" }, + "numberOfAddFiles": { "shape": "PrimitiveInteger" }, + "userDecision": { "shape": "DocGenerationUserDecision" }, + "interactionType": { "shape": "DocGenerationInteractionType" }, + "userIdentity": { "shape": "String" }, + "numberOfNavigation": { "shape": "PrimitiveInteger" }, + "folderLevel": { "shape": "DocGenerationFolderLevel" } + } + }, + "DocGenerationFolderLevel": { + "type": "string", + "enum": ["SUB_FOLDER", "ENTIRE_WORKSPACE"] + }, + "DocGenerationInteractionType": { + "type": "string", + "enum": ["GENERATE_README", "UPDATE_README", "EDIT_README"] + }, + "DocGenerationUserDecision": { + "type": "string", + "enum": ["ACCEPT", "REJECT"] + }, "DocumentSymbol": { "type": "structure", "required": ["name", "type"], "members": { - "name": { "shape": "DocumentSymbolNameString" }, - "type": { "shape": "SymbolType" }, - "source": { "shape": "DocumentSymbolSourceString" } + "name": { + "shape": "DocumentSymbolNameString" + }, + "type": { + "shape": "SymbolType" + }, + "source": { + "shape": "DocumentSymbolSourceString" + } } }, "DocumentSymbolNameString": { @@ -811,10 +1476,34 @@ }, "DocumentSymbols": { "type": "list", - "member": { "shape": "DocumentSymbol" }, + "member": { + "shape": "DocumentSymbol" + }, "max": 1000, "min": 0 }, + "DocumentationIntentContext": { + "type": "structure", + "required": ["type"], + "members": { + "scope": { + "shape": "DocumentationIntentContextScopeString" + }, + "type": { + "shape": "DocumentationType" + } + } + }, + "DocumentationIntentContextScopeString": { + "type": "string", + "max": 4096, + "min": 1, + "sensitive": true + }, + "DocumentationType": { + "type": "string", + "enum": ["README"] + }, "Double": { "type": "double", "box": true @@ -822,18 +1511,29 @@ "EditorState": { "type": "structure", "members": { - "document": { "shape": "TextDocument" }, - "cursorState": { "shape": "CursorState" }, - "relevantDocuments": { "shape": "RelevantDocumentList" }, - "useRelevantDocuments": { "shape": "Boolean" } + "document": { + "shape": "TextDocument" + }, + "cursorState": { + "shape": "CursorState" + }, + "relevantDocuments": { + "shape": "RelevantDocumentList" + }, + "useRelevantDocuments": { + "shape": "Boolean" + } } }, "EnvState": { "type": "structure", "members": { "operatingSystem": { "shape": "EnvStateOperatingSystemString" }, - "currentWorkingDirectory": { "shape": "EnvStateCurrentWorkingDirectoryString" }, - "environmentVariables": { "shape": "EnvironmentVariables" } + "currentWorkingDirectory": { + "shape": "EnvStateCurrentWorkingDirectoryString" + }, + "environmentVariables": { "shape": "EnvironmentVariables" }, + "timezoneOffset": { "shape": "EnvStateTimezoneOffsetInteger" } } }, "EnvStateCurrentWorkingDirectoryString": { @@ -848,11 +1548,21 @@ "min": 1, "pattern": "(macos|linux|windows)" }, + "EnvStateTimezoneOffsetInteger": { + "type": "integer", + "box": true, + "max": 1440, + "min": -1440 + }, "EnvironmentVariable": { "type": "structure", "members": { - "key": { "shape": "EnvironmentVariableKeyString" }, - "value": { "shape": "EnvironmentVariableValueString" } + "key": { + "shape": "EnvironmentVariableKeyString" + }, + "value": { + "shape": "EnvironmentVariableValueString" + } } }, "EnvironmentVariableKeyString": { @@ -869,7 +1579,9 @@ }, "EnvironmentVariables": { "type": "list", - "member": { "shape": "EnvironmentVariable" }, + "member": { + "shape": "EnvironmentVariable" + }, "max": 100, "min": 0 }, @@ -878,8 +1590,12 @@ "required": ["conversationId", "linesOfCodeAccepted", "charactersOfCodeAccepted"], "members": { "conversationId": { "shape": "ConversationId" }, - "linesOfCodeAccepted": { "shape": "FeatureDevCodeAcceptanceEventLinesOfCodeAcceptedInteger" }, - "charactersOfCodeAccepted": { "shape": "FeatureDevCodeAcceptanceEventCharactersOfCodeAcceptedInteger" }, + "linesOfCodeAccepted": { + "shape": "FeatureDevCodeAcceptanceEventLinesOfCodeAcceptedInteger" + }, + "charactersOfCodeAccepted": { + "shape": "FeatureDevCodeAcceptanceEventCharactersOfCodeAcceptedInteger" + }, "programmingLanguage": { "shape": "ProgrammingLanguage" } } }, @@ -896,11 +1612,15 @@ "required": ["conversationId", "linesOfCodeGenerated", "charactersOfCodeGenerated"], "members": { "conversationId": { "shape": "ConversationId" }, - "linesOfCodeGenerated": { "shape": "FeatureDevCodeGenerationEventLinesOfCodeGeneratedInteger" }, + "linesOfCodeGenerated": { + "shape": "FeatureDevCodeGenerationEventLinesOfCodeGeneratedInteger" + }, "charactersOfCodeGenerated": { "shape": "FeatureDevCodeGenerationEventCharactersOfCodeGeneratedInteger" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" } + "programmingLanguage": { + "shape": "ProgrammingLanguage" + } } }, "FeatureDevCodeGenerationEventCharactersOfCodeGeneratedInteger": { @@ -915,21 +1635,31 @@ "type": "structure", "required": ["conversationId"], "members": { - "conversationId": { "shape": "ConversationId" } + "conversationId": { + "shape": "ConversationId" + } } }, "FeatureEvaluation": { "type": "structure", "required": ["feature", "variation", "value"], "members": { - "feature": { "shape": "FeatureName" }, - "variation": { "shape": "FeatureVariation" }, - "value": { "shape": "FeatureValue" } + "feature": { + "shape": "FeatureName" + }, + "variation": { + "shape": "FeatureVariation" + }, + "value": { + "shape": "FeatureValue" + } } }, "FeatureEvaluationsList": { "type": "list", - "member": { "shape": "FeatureEvaluation" }, + "member": { + "shape": "FeatureEvaluation" + }, "max": 50, "min": 0 }, @@ -942,10 +1672,18 @@ "FeatureValue": { "type": "structure", "members": { - "boolValue": { "shape": "Boolean" }, - "doubleValue": { "shape": "Double" }, - "longValue": { "shape": "Long" }, - "stringValue": { "shape": "FeatureValueStringType" } + "boolValue": { + "shape": "Boolean" + }, + "doubleValue": { + "shape": "Double" + }, + "longValue": { + "shape": "Long" + }, + "stringValue": { + "shape": "FeatureValueStringType" + } }, "union": true }, @@ -964,10 +1702,18 @@ "type": "structure", "required": ["leftFileContent", "rightFileContent", "filename", "programmingLanguage"], "members": { - "leftFileContent": { "shape": "FileContextLeftFileContentString" }, - "rightFileContent": { "shape": "FileContextRightFileContentString" }, - "filename": { "shape": "FileContextFilenameString" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" } + "leftFileContent": { + "shape": "FileContextLeftFileContentString" + }, + "rightFileContent": { + "shape": "FileContextRightFileContentString" + }, + "filename": { + "shape": "FileContextFilenameString" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + } } }, "FileContextFilenameString": { @@ -992,8 +1738,12 @@ "type": "structure", "required": ["content"], "members": { - "content": { "shape": "FollowupPromptContentString" }, - "userIntent": { "shape": "UserIntent" } + "content": { + "shape": "FollowupPromptContentString" + }, + "userIntent": { + "shape": "UserIntent" + } } }, "FollowupPromptContentString": { @@ -1007,9 +1757,13 @@ "required": ["fileContext"], "members": { "fileContext": { "shape": "FileContext" }, - "maxResults": { "shape": "GenerateCompletionsRequestMaxResultsInteger" }, + "maxResults": { + "shape": "GenerateCompletionsRequestMaxResultsInteger" + }, "nextToken": { "shape": "GenerateCompletionsRequestNextTokenString" }, - "referenceTrackerConfiguration": { "shape": "ReferenceTrackerConfiguration" }, + "referenceTrackerConfiguration": { + "shape": "ReferenceTrackerConfiguration" + }, "supplementalContexts": { "shape": "SupplementalContextList" }, "customizationArn": { "shape": "CustomizationArn" }, "optOutPreference": { "shape": "OptOutPreference" }, @@ -1033,15 +1787,21 @@ "GenerateCompletionsResponse": { "type": "structure", "members": { - "completions": { "shape": "Completions" }, - "nextToken": { "shape": "SensitiveString" } + "completions": { + "shape": "Completions" + }, + "nextToken": { + "shape": "SensitiveString" + } } }, "GetCodeAnalysisRequest": { "type": "structure", "required": ["jobId"], "members": { - "jobId": { "shape": "GetCodeAnalysisRequestJobIdString" } + "jobId": { + "shape": "GetCodeAnalysisRequestJobIdString" + } } }, "GetCodeAnalysisRequestJobIdString": { @@ -1053,61 +1813,133 @@ "type": "structure", "required": ["status"], "members": { - "status": { "shape": "CodeAnalysisStatus" }, - "errorMessage": { "shape": "SensitiveString" } + "status": { + "shape": "CodeAnalysisStatus" + }, + "errorMessage": { + "shape": "SensitiveString" + } + } + }, + "GetCodeFixJobRequest": { + "type": "structure", + "required": ["jobId"], + "members": { + "jobId": { "shape": "GetCodeFixJobRequestJobIdString" } + } + }, + "GetCodeFixJobRequestJobIdString": { + "type": "string", + "max": 256, + "min": 1, + "pattern": ".*[A-Za-z0-9-:]+.*" + }, + "GetCodeFixJobResponse": { + "type": "structure", + "members": { + "jobStatus": { + "shape": "CodeFixJobStatus" + }, + "suggestedFix": { + "shape": "SuggestedFix" + } } }, "GetTaskAssistCodeGenerationRequest": { "type": "structure", "required": ["conversationId", "codeGenerationId"], "members": { - "conversationId": { "shape": "ConversationId" }, - "codeGenerationId": { "shape": "CodeGenerationId" } + "conversationId": { + "shape": "ConversationId" + }, + "codeGenerationId": { + "shape": "CodeGenerationId" + } } }, "GetTaskAssistCodeGenerationResponse": { "type": "structure", "required": ["conversationId", "codeGenerationStatus"], "members": { - "conversationId": { "shape": "ConversationId" }, - "codeGenerationStatus": { "shape": "CodeGenerationStatus" }, - "codeGenerationStatusDetail": { "shape": "CodeGenerationStatusDetail" }, - "codeGenerationRemainingIterationCount": { "shape": "Integer" }, - "codeGenerationTotalIterationCount": { "shape": "Integer" } + "conversationId": { + "shape": "ConversationId" + }, + "codeGenerationStatus": { + "shape": "CodeGenerationStatus" + }, + "codeGenerationStatusDetail": { + "shape": "CodeGenerationStatusDetail" + }, + "codeGenerationRemainingIterationCount": { + "shape": "Integer" + }, + "codeGenerationTotalIterationCount": { + "shape": "Integer" + } + } + }, + "GetTestGenerationRequest": { + "type": "structure", + "required": ["testGenerationJobGroupName", "testGenerationJobId"], + "members": { + "testGenerationJobGroupName": { + "shape": "TestGenerationJobGroupName" + }, + "testGenerationJobId": { + "shape": "UUID" + } + } + }, + "GetTestGenerationResponse": { + "type": "structure", + "members": { + "testGenerationJob": { + "shape": "TestGenerationJob" + } } }, "GetTransformationPlanRequest": { "type": "structure", "required": ["transformationJobId"], "members": { - "transformationJobId": { "shape": "TransformationJobId" } + "transformationJobId": { + "shape": "TransformationJobId" + } } }, "GetTransformationPlanResponse": { "type": "structure", "required": ["transformationPlan"], "members": { - "transformationPlan": { "shape": "TransformationPlan" } + "transformationPlan": { + "shape": "TransformationPlan" + } } }, "GetTransformationRequest": { "type": "structure", "required": ["transformationJobId"], "members": { - "transformationJobId": { "shape": "TransformationJobId" } + "transformationJobId": { + "shape": "TransformationJobId" + } } }, "GetTransformationResponse": { "type": "structure", "required": ["transformationJob"], "members": { - "transformationJob": { "shape": "TransformationJob" } + "transformationJob": { + "shape": "TransformationJob" + } } }, "GitState": { "type": "structure", "members": { - "status": { "shape": "GitStateStatusString" } + "status": { + "shape": "GitStateStatusString" + } } }, "GitStateStatusString": { @@ -1118,7 +1950,7 @@ }, "IdeCategory": { "type": "string", - "enum": ["JETBRAINS", "VSCODE", "CLI", "JUPYTER_MD", "JUPYTER_SM"], + "enum": ["JETBRAINS", "VSCODE", "CLI", "JUPYTER_MD", "JUPYTER_SM", "ECLIPSE", "VISUAL_STUDIO"], "max": 64, "min": 1 }, @@ -1130,7 +1962,9 @@ "Import": { "type": "structure", "members": { - "statement": { "shape": "ImportStatementString" } + "statement": { + "shape": "ImportStatementString" + } } }, "ImportStatementString": { @@ -1141,19 +1975,55 @@ }, "Imports": { "type": "list", - "member": { "shape": "Import" }, + "member": { + "shape": "Import" + }, "max": 10, "min": 0 }, "InlineChatEvent": { "type": "structure", + "required": ["requestId", "timestamp"], "members": { - "inputLength": { "shape": "PrimitiveInteger" }, - "numSelectedLines": { "shape": "PrimitiveInteger" }, - "codeIntent": { "shape": "Boolean" }, - "userDecision": { "shape": "InlineChatUserDecision" }, - "responseStartLatency": { "shape": "Double" }, - "responseEndLatency": { "shape": "Double" } + "requestId": { + "shape": "UUID" + }, + "timestamp": { + "shape": "Timestamp" + }, + "inputLength": { + "shape": "PrimitiveInteger" + }, + "numSelectedLines": { + "shape": "PrimitiveInteger" + }, + "numSuggestionAddChars": { + "shape": "PrimitiveInteger" + }, + "numSuggestionAddLines": { + "shape": "PrimitiveInteger" + }, + "numSuggestionDelChars": { + "shape": "PrimitiveInteger" + }, + "numSuggestionDelLines": { + "shape": "PrimitiveInteger" + }, + "codeIntent": { + "shape": "Boolean" + }, + "userDecision": { + "shape": "InlineChatUserDecision" + }, + "responseStartLatency": { + "shape": "Double" + }, + "responseEndLatency": { + "shape": "Double" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + } } }, "InlineChatUserDecision": { @@ -1164,20 +2034,45 @@ "type": "integer", "box": true }, + "Intent": { + "type": "string", + "enum": ["DEV", "DOC"] + }, + "IntentContext": { + "type": "structure", + "members": { + "documentation": { + "shape": "DocumentationIntentContext" + } + }, + "union": true + }, "InternalServerException": { "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" } + "message": { + "shape": "String" + } }, "exception": true, "fault": true, - "retryable": { "throttling": false } + "retryable": { + "throttling": false + } + }, + "LineRangeList": { + "type": "list", + "member": { + "shape": "Range" + } }, "ListAvailableCustomizationsRequest": { "type": "structure", "members": { - "maxResults": { "shape": "ListAvailableCustomizationsRequestMaxResultsInteger" }, + "maxResults": { + "shape": "ListAvailableCustomizationsRequestMaxResultsInteger" + }, "nextToken": { "shape": "Base64EncodedPaginationToken" } } }, @@ -1191,17 +2086,27 @@ "type": "structure", "required": ["customizations"], "members": { - "customizations": { "shape": "Customizations" }, - "nextToken": { "shape": "Base64EncodedPaginationToken" } + "customizations": { + "shape": "Customizations" + }, + "nextToken": { + "shape": "Base64EncodedPaginationToken" + } } }, "ListCodeAnalysisFindingsRequest": { "type": "structure", "required": ["jobId", "codeAnalysisFindingsSchema"], "members": { - "jobId": { "shape": "ListCodeAnalysisFindingsRequestJobIdString" }, - "nextToken": { "shape": "PaginationToken" }, - "codeAnalysisFindingsSchema": { "shape": "CodeAnalysisFindingsSchema" } + "jobId": { + "shape": "ListCodeAnalysisFindingsRequestJobIdString" + }, + "nextToken": { + "shape": "PaginationToken" + }, + "codeAnalysisFindingsSchema": { + "shape": "CodeAnalysisFindingsSchema" + } } }, "ListCodeAnalysisFindingsRequestJobIdString": { @@ -1213,22 +2118,30 @@ "type": "structure", "required": ["codeAnalysisFindings"], "members": { - "nextToken": { "shape": "PaginationToken" }, - "codeAnalysisFindings": { "shape": "SensitiveString" } + "nextToken": { + "shape": "PaginationToken" + }, + "codeAnalysisFindings": { + "shape": "SensitiveString" + } } }, "ListFeatureEvaluationsRequest": { "type": "structure", "required": ["userContext"], "members": { - "userContext": { "shape": "UserContext" } + "userContext": { + "shape": "UserContext" + } } }, "ListFeatureEvaluationsResponse": { "type": "structure", "required": ["featureEvaluations"], "members": { - "featureEvaluations": { "shape": "FeatureEvaluationsList" } + "featureEvaluations": { + "shape": "FeatureEvaluationsList" + } } }, "Long": { @@ -1244,11 +2157,21 @@ "type": "structure", "required": ["metricName", "metricValue", "timestamp", "product"], "members": { - "metricName": { "shape": "MetricDataMetricNameString" }, - "metricValue": { "shape": "Double" }, - "timestamp": { "shape": "Timestamp" }, - "product": { "shape": "MetricDataProductString" }, - "dimensions": { "shape": "DimensionList" } + "metricName": { + "shape": "MetricDataMetricNameString" + }, + "metricValue": { + "shape": "Double" + }, + "timestamp": { + "shape": "Timestamp" + }, + "product": { + "shape": "MetricDataProductString" + }, + "dimensions": { + "shape": "DimensionList" + } } }, "MetricDataMetricNameString": { @@ -1283,8 +2206,12 @@ "type": "structure", "required": ["line", "character"], "members": { - "line": { "shape": "Integer" }, - "character": { "shape": "Integer" } + "line": { + "shape": "Integer" + }, + "character": { + "shape": "Integer" + } } }, "PreSignedUrl": { @@ -1293,7 +2220,9 @@ "min": 1, "sensitive": true }, - "PrimitiveInteger": { "type": "integer" }, + "PrimitiveInteger": { + "type": "integer" + }, "ProfileArn": { "type": "string", "max": 950, @@ -1304,25 +2233,33 @@ "type": "structure", "required": ["languageName"], "members": { - "languageName": { "shape": "ProgrammingLanguageLanguageNameString" } + "languageName": { + "shape": "ProgrammingLanguageLanguageNameString" + } } }, "ProgrammingLanguageLanguageNameString": { "type": "string", "max": 128, "min": 1, - "pattern": "(python|javascript|java|csharp|typescript|c|cpp|go|kotlin|php|ruby|rust|scala|shell|sql|json|yaml|vue|tf|tsx|jsx|plaintext)" + "pattern": "(python|javascript|java|csharp|typescript|c|cpp|go|kotlin|php|ruby|rust|scala|shell|sql|json|yaml|vue|tf|tsx|jsx|plaintext|systemverilog|dart|lua|swift|powershell|r)" }, "ProgressUpdates": { "type": "list", - "member": { "shape": "TransformationProgressUpdate" } + "member": { + "shape": "TransformationProgressUpdate" + } }, "Range": { "type": "structure", "required": ["start", "end"], "members": { - "start": { "shape": "Position" }, - "end": { "shape": "Position" } + "start": { + "shape": "Position" + }, + "end": { + "shape": "Position" + } } }, "RecommendationsWithReferencesPreference": { @@ -1332,10 +2269,18 @@ "Reference": { "type": "structure", "members": { - "licenseName": { "shape": "ReferenceLicenseNameString" }, - "repository": { "shape": "ReferenceRepositoryString" }, - "url": { "shape": "ReferenceUrlString" }, - "recommendationContentSpan": { "shape": "Span" } + "licenseName": { + "shape": "ReferenceLicenseNameString" + }, + "repository": { + "shape": "ReferenceRepositoryString" + }, + "url": { + "shape": "ReferenceUrlString" + }, + "recommendationContentSpan": { + "shape": "Span" + } } }, "ReferenceLicenseNameString": { @@ -1352,7 +2297,9 @@ "type": "structure", "required": ["recommendationsWithReferences"], "members": { - "recommendationsWithReferences": { "shape": "RecommendationsWithReferencesPreference" } + "recommendationsWithReferences": { + "shape": "RecommendationsWithReferencesPreference" + } } }, "ReferenceUrlString": { @@ -1362,13 +2309,17 @@ }, "References": { "type": "list", - "member": { "shape": "Reference" }, + "member": { + "shape": "Reference" + }, "max": 10, "min": 0 }, "RelevantDocumentList": { "type": "list", - "member": { "shape": "RelevantTextDocument" }, + "member": { + "shape": "RelevantTextDocument" + }, "max": 5, "min": 0 }, @@ -1376,7 +2327,9 @@ "type": "structure", "required": ["relativeFilePath"], "members": { - "relativeFilePath": { "shape": "RelevantTextDocumentRelativeFilePathString" }, + "relativeFilePath": { + "shape": "RelevantTextDocumentRelativeFilePathString" + }, "programmingLanguage": { "shape": "ProgrammingLanguage" }, "text": { "shape": "RelevantTextDocumentTextString" }, "documentSymbols": { "shape": "DocumentSymbols" } @@ -1406,8 +2359,12 @@ }, "RequestHeaders": { "type": "map", - "key": { "shape": "RequestHeaderKey" }, - "value": { "shape": "RequestHeaderValue" }, + "key": { + "shape": "RequestHeaderKey" + }, + "value": { + "shape": "RequestHeaderValue" + }, "max": 16, "min": 1, "sensitive": true @@ -1422,7 +2379,9 @@ "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" } + "message": { + "shape": "String" + } }, "exception": true }, @@ -1430,24 +2389,36 @@ "type": "structure", "required": ["transformationJobId"], "members": { - "transformationJobId": { "shape": "TransformationJobId" }, - "userActionStatus": { "shape": "TransformationUserActionStatus" } + "transformationJobId": { + "shape": "TransformationJobId" + }, + "userActionStatus": { + "shape": "TransformationUserActionStatus" + } } }, "ResumeTransformationResponse": { "type": "structure", "required": ["transformationStatus"], "members": { - "transformationStatus": { "shape": "TransformationStatus" } + "transformationStatus": { + "shape": "TransformationStatus" + } } }, "RuntimeDiagnostic": { "type": "structure", "required": ["source", "severity", "message"], "members": { - "source": { "shape": "RuntimeDiagnosticSourceString" }, - "severity": { "shape": "DiagnosticSeverity" }, - "message": { "shape": "RuntimeDiagnosticMessageString" } + "source": { + "shape": "RuntimeDiagnosticSourceString" + }, + "severity": { + "shape": "DiagnosticSeverity" + }, + "message": { + "shape": "RuntimeDiagnosticMessageString" + } } }, "RuntimeDiagnosticMessageString": { @@ -1470,10 +2441,18 @@ "shape": "IdempotencyToken", "idempotencyToken": true }, - "telemetryEvent": { "shape": "TelemetryEvent" }, - "optOutPreference": { "shape": "OptOutPreference" }, - "userContext": { "shape": "UserContext" }, - "profileArn": { "shape": "ProfileArn" } + "telemetryEvent": { + "shape": "TelemetryEvent" + }, + "optOutPreference": { + "shape": "OptOutPreference" + }, + "userContext": { + "shape": "UserContext" + }, + "profileArn": { + "shape": "ProfileArn" + } } }, "SendTelemetryEventResponse": { @@ -1488,13 +2467,17 @@ "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" } + "message": { + "shape": "String" + } }, "exception": true }, "ShellHistory": { "type": "list", - "member": { "shape": "ShellHistoryEntry" }, + "member": { + "shape": "ShellHistoryEntry" + }, "max": 20, "min": 0 }, @@ -1502,11 +2485,21 @@ "type": "structure", "required": ["command"], "members": { - "command": { "shape": "ShellHistoryEntryCommandString" }, - "directory": { "shape": "ShellHistoryEntryDirectoryString" }, - "exitCode": { "shape": "Integer" }, - "stdout": { "shape": "ShellHistoryEntryStdoutString" }, - "stderr": { "shape": "ShellHistoryEntryStderrString" } + "command": { + "shape": "ShellHistoryEntryCommandString" + }, + "directory": { + "shape": "ShellHistoryEntryDirectoryString" + }, + "exitCode": { + "shape": "Integer" + }, + "stdout": { + "shape": "ShellHistoryEntryStdoutString" + }, + "stderr": { + "shape": "ShellHistoryEntryStderrString" + } } }, "ShellHistoryEntryCommandString": { @@ -1537,8 +2530,12 @@ "type": "structure", "required": ["shellName"], "members": { - "shellName": { "shape": "ShellStateShellNameString" }, - "shellHistory": { "shape": "ShellHistory" } + "shellName": { + "shape": "ShellStateShellNameString" + }, + "shellHistory": { + "shape": "ShellHistory" + } } }, "ShellStateShellNameString": { @@ -1550,8 +2547,12 @@ "Span": { "type": "structure", "members": { - "start": { "shape": "SpanStartInteger" }, - "end": { "shape": "SpanEndInteger" } + "start": { + "shape": "SpanStartInteger" + }, + "end": { + "shape": "SpanEndInteger" + } } }, "SpanEndInteger": { @@ -1568,14 +2569,22 @@ "type": "structure", "required": ["artifacts", "programmingLanguage"], "members": { - "artifacts": { "shape": "ArtifactMap" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, + "artifacts": { + "shape": "ArtifactMap" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, "clientToken": { "shape": "StartCodeAnalysisRequestClientTokenString", "idempotencyToken": true }, - "scope": { "shape": "CodeAnalysisScope" }, - "codeScanName": { "shape": "CodeScanName" } + "scope": { + "shape": "CodeAnalysisScope" + }, + "codeScanName": { + "shape": "CodeScanName" + } } }, "StartCodeAnalysisRequestClientTokenString": { @@ -1587,9 +2596,15 @@ "type": "structure", "required": ["jobId", "status"], "members": { - "jobId": { "shape": "StartCodeAnalysisResponseJobIdString" }, - "status": { "shape": "CodeAnalysisStatus" }, - "errorMessage": { "shape": "SensitiveString" } + "jobId": { + "shape": "StartCodeAnalysisResponseJobIdString" + }, + "status": { + "shape": "CodeAnalysisStatus" + }, + "errorMessage": { + "shape": "SensitiveString" + } } }, "StartCodeAnalysisResponseJobIdString": { @@ -1597,38 +2612,155 @@ "max": 256, "min": 1 }, + "StartCodeFixJobRequest": { + "type": "structure", + "required": ["snippetRange", "uploadId"], + "members": { + "snippetRange": { + "shape": "Range" + }, + "uploadId": { + "shape": "UploadId" + }, + "description": { + "shape": "StartCodeFixJobRequestDescriptionString" + }, + "ruleId": { + "shape": "StartCodeFixJobRequestRuleIdString" + }, + "codeFixName": { + "shape": "CodeFixName" + } + } + }, + "StartCodeFixJobRequestDescriptionString": { + "type": "string", + "max": 2000, + "min": 1, + "sensitive": true + }, + "StartCodeFixJobRequestRuleIdString": { + "type": "string", + "max": 256, + "min": 1, + "pattern": ".*[A-Za-z0-9-]+.*" + }, + "StartCodeFixJobResponse": { + "type": "structure", + "members": { + "jobId": { + "shape": "StartCodeFixJobResponseJobIdString" + }, + "status": { + "shape": "CodeFixJobStatus" + } + } + }, + "StartCodeFixJobResponseJobIdString": { + "type": "string", + "max": 256, + "min": 1, + "pattern": ".*[A-Za-z0-9-:]+.*" + }, "StartTaskAssistCodeGenerationRequest": { "type": "structure", "required": ["conversationState", "workspaceState"], "members": { - "conversationState": { "shape": "ConversationState" }, - "workspaceState": { "shape": "WorkspaceState" }, - "taskAssistPlan": { "shape": "TaskAssistPlan" }, - "codeGenerationId": { "shape": "CodeGenerationId" }, - "currentCodeGenerationId": { "shape": "CodeGenerationId" } + "conversationState": { + "shape": "ConversationState" + }, + "workspaceState": { + "shape": "WorkspaceState" + }, + "taskAssistPlan": { + "shape": "TaskAssistPlan" + }, + "codeGenerationId": { + "shape": "CodeGenerationId" + }, + "currentCodeGenerationId": { + "shape": "CodeGenerationId" + }, + "intent": { + "shape": "Intent" + }, + "intentContext": { + "shape": "IntentContext" + } } }, "StartTaskAssistCodeGenerationResponse": { "type": "structure", "required": ["conversationId", "codeGenerationId"], "members": { - "conversationId": { "shape": "ConversationId" }, - "codeGenerationId": { "shape": "CodeGenerationId" } + "conversationId": { + "shape": "ConversationId" + }, + "codeGenerationId": { + "shape": "CodeGenerationId" + } + } + }, + "StartTestGenerationRequest": { + "type": "structure", + "required": ["uploadId", "targetCodeList", "userInput"], + "members": { + "uploadId": { + "shape": "UploadId" + }, + "targetCodeList": { + "shape": "TargetCodeList" + }, + "userInput": { + "shape": "StartTestGenerationRequestUserInputString" + }, + "testGenerationJobGroupName": { + "shape": "TestGenerationJobGroupName" + }, + "clientToken": { + "shape": "StartTestGenerationRequestClientTokenString", + "idempotencyToken": true + } + } + }, + "StartTestGenerationRequestClientTokenString": { + "type": "string", + "max": 256, + "min": 1 + }, + "StartTestGenerationRequestUserInputString": { + "type": "string", + "max": 4096, + "min": 0, + "sensitive": true + }, + "StartTestGenerationResponse": { + "type": "structure", + "members": { + "testGenerationJob": { + "shape": "TestGenerationJob" + } } }, "StartTransformationRequest": { "type": "structure", "required": ["workspaceState", "transformationSpec"], "members": { - "workspaceState": { "shape": "WorkspaceState" }, - "transformationSpec": { "shape": "TransformationSpec" } + "workspaceState": { + "shape": "WorkspaceState" + }, + "transformationSpec": { + "shape": "TransformationSpec" + } } }, "StartTransformationResponse": { "type": "structure", "required": ["transformationJobId"], "members": { - "transformationJobId": { "shape": "TransformationJobId" } + "transformationJobId": { + "shape": "TransformationJobId" + } } }, "StepId": { @@ -1640,27 +2772,63 @@ "type": "structure", "required": ["transformationJobId"], "members": { - "transformationJobId": { "shape": "TransformationJobId" } + "transformationJobId": { + "shape": "TransformationJobId" + } } }, "StopTransformationResponse": { "type": "structure", "required": ["transformationStatus"], "members": { - "transformationStatus": { "shape": "TransformationStatus" } + "transformationStatus": { + "shape": "TransformationStatus" + } + } + }, + "String": { + "type": "string" + }, + "SuggestedFix": { + "type": "structure", + "members": { + "codeDiff": { + "shape": "SuggestedFixCodeDiffString" + }, + "description": { + "shape": "SuggestedFixDescriptionString" + }, + "references": { + "shape": "References" + } } }, - "String": { "type": "string" }, + "SuggestedFixCodeDiffString": { + "type": "string", + "max": 200000, + "min": 0, + "sensitive": true + }, + "SuggestedFixDescriptionString": { + "type": "string", + "max": 2000, + "min": 1, + "sensitive": true + }, "SuggestionState": { "type": "string", - "enum": ["ACCEPT", "REJECT", "DISCARD", "EMPTY"] + "enum": ["ACCEPT", "REJECT", "DISCARD", "EMPTY", "MERGE"] }, "SupplementalContext": { "type": "structure", "required": ["filePath", "content"], "members": { - "filePath": { "shape": "SupplementalContextFilePathString" }, - "content": { "shape": "SupplementalContextContentString" } + "filePath": { + "shape": "SupplementalContextFilePathString" + }, + "content": { + "shape": "SupplementalContextContentString" + } } }, "SupplementalContextContentString": { @@ -1677,7 +2845,9 @@ }, "SupplementalContextList": { "type": "list", - "member": { "shape": "SupplementalContext" }, + "member": { + "shape": "SupplementalContext" + }, "max": 5, "min": 0 }, @@ -1685,9 +2855,15 @@ "type": "structure", "required": ["url", "title"], "members": { - "url": { "shape": "SupplementaryWebLinkUrlString" }, - "title": { "shape": "SupplementaryWebLinkTitleString" }, - "snippet": { "shape": "SupplementaryWebLinkSnippetString" } + "url": { + "shape": "SupplementaryWebLinkUrlString" + }, + "title": { + "shape": "SupplementaryWebLinkTitleString" + }, + "snippet": { + "shape": "SupplementaryWebLinkSnippetString" + } } }, "SupplementaryWebLinkSnippetString": { @@ -1710,7 +2886,9 @@ }, "SupplementaryWebLinks": { "type": "list", - "member": { "shape": "SupplementaryWebLink" }, + "member": { + "shape": "SupplementaryWebLink" + }, "max": 10, "min": 0 }, @@ -1718,20 +2896,57 @@ "type": "string", "enum": ["DECLARATION", "USAGE"] }, + "TargetCode": { + "type": "structure", + "required": ["relativeTargetPath"], + "members": { + "relativeTargetPath": { + "shape": "TargetCodeRelativeTargetPathString" + }, + "targetLineRangeList": { + "shape": "LineRangeList" + } + } + }, + "TargetCodeList": { + "type": "list", + "member": { + "shape": "TargetCode" + }, + "min": 1 + }, + "TargetCodeRelativeTargetPathString": { + "type": "string", + "max": 4096, + "min": 1, + "sensitive": true + }, "TaskAssistPlan": { "type": "list", - "member": { "shape": "TaskAssistPlanStep" }, + "member": { + "shape": "TaskAssistPlanStep" + }, "min": 0 }, "TaskAssistPlanStep": { "type": "structure", "required": ["filePath", "description"], "members": { - "filePath": { "shape": "TaskAssistPlanStepFilePathString" }, - "description": { "shape": "TaskAssistPlanStepDescriptionString" }, - "startLine": { "shape": "TaskAssistPlanStepStartLineInteger" }, - "endLine": { "shape": "TaskAssistPlanStepEndLineInteger" }, - "action": { "shape": "TaskAssistPlanStepAction" } + "filePath": { + "shape": "TaskAssistPlanStepFilePathString" + }, + "description": { + "shape": "TaskAssistPlanStepDescriptionString" + }, + "startLine": { + "shape": "TaskAssistPlanStepStartLineInteger" + }, + "endLine": { + "shape": "TaskAssistPlanStepEndLineInteger" + }, + "action": { + "shape": "TaskAssistPlanStepAction" + } } }, "TaskAssistPlanStepAction": { @@ -1762,7 +2977,9 @@ "type": "structure", "required": ["conversationId"], "members": { - "conversationId": { "shape": "ConversationId" } + "conversationId": { + "shape": "ConversationId" + } } }, "TelemetryEvent": { @@ -1772,23 +2989,40 @@ "codeCoverageEvent": { "shape": "CodeCoverageEvent" }, "userModificationEvent": { "shape": "UserModificationEvent" }, "codeScanEvent": { "shape": "CodeScanEvent" }, + "codeScanSucceededEvent": { "shape": "CodeScanSucceededEvent" }, + "codeScanFailedEvent": { "shape": "CodeScanFailedEvent" }, "codeScanRemediationsEvent": { "shape": "CodeScanRemediationsEvent" }, + "codeFixGenerationEvent": { "shape": "CodeFixGenerationEvent" }, + "codeFixAcceptanceEvent": { "shape": "CodeFixAcceptanceEvent" }, "metricData": { "shape": "MetricData" }, "chatAddMessageEvent": { "shape": "ChatAddMessageEvent" }, - "chatInteractWithMessageEvent": { "shape": "ChatInteractWithMessageEvent" }, + "chatInteractWithMessageEvent": { + "shape": "ChatInteractWithMessageEvent" + }, "chatUserModificationEvent": { "shape": "ChatUserModificationEvent" }, - "terminalUserInteractionEvent": { "shape": "TerminalUserInteractionEvent" }, + "terminalUserInteractionEvent": { + "shape": "TerminalUserInteractionEvent" + }, "featureDevEvent": { "shape": "FeatureDevEvent" }, - "featureDevCodeGenerationEvent": { "shape": "FeatureDevCodeGenerationEvent" }, - "featureDevCodeAcceptanceEvent": { "shape": "FeatureDevCodeAcceptanceEvent" }, - "inlineChatEvent": { "shape": "InlineChatEvent" } + "featureDevCodeGenerationEvent": { + "shape": "FeatureDevCodeGenerationEvent" + }, + "featureDevCodeAcceptanceEvent": { + "shape": "FeatureDevCodeAcceptanceEvent" + }, + "inlineChatEvent": { "shape": "InlineChatEvent" }, + "transformEvent": { "shape": "TransformEvent" }, + "docGenerationEvent": { "shape": "DocGenerationEvent" }, + "testGenerationEvent": { "shape": "TestGenerationEvent" } }, "union": true }, "TerminalUserInteractionEvent": { "type": "structure", "members": { - "terminalUserInteractionEventType": { "shape": "TerminalUserInteractionEventType" }, + "terminalUserInteractionEventType": { + "shape": "TerminalUserInteractionEventType" + }, "terminal": { "shape": "String" }, "terminalVersion": { "shape": "String" }, "shell": { "shape": "String" }, @@ -1803,25 +3037,122 @@ "type": "string", "enum": ["CODEWHISPERER_TERMINAL_TRANSLATION_ACTION", "CODEWHISPERER_TERMINAL_COMPLETION_INSERTED"] }, + "TestGenerationEvent": { + "type": "structure", + "required": ["jobId", "groupName"], + "members": { + "jobId": { + "shape": "UUID" + }, + "groupName": { + "shape": "TestGenerationJobGroupName" + }, + "timestamp": { + "shape": "Timestamp" + }, + "ideCategory": { + "shape": "IdeCategory" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "numberOfUnitTestCasesGenerated": { + "shape": "Integer" + }, + "numberOfUnitTestCasesAccepted": { + "shape": "Integer" + }, + "linesOfCodeGenerated": { + "shape": "Integer" + }, + "linesOfCodeAccepted": { + "shape": "Integer" + }, + "charsOfCodeGenerated": { + "shape": "Integer" + }, + "charsOfCodeAccepted": { + "shape": "Integer" + } + } + }, + "TestGenerationJob": { + "type": "structure", + "required": ["testGenerationJobId", "testGenerationJobGroupName", "status", "creationTime"], + "members": { + "testGenerationJobId": { + "shape": "UUID" + }, + "testGenerationJobGroupName": { + "shape": "TestGenerationJobGroupName" + }, + "status": { + "shape": "TestGenerationJobStatus" + }, + "shortAnswer": { + "shape": "SensitiveString" + }, + "creationTime": { + "shape": "Timestamp" + }, + "progressRate": { + "shape": "TestGenerationJobProgressRateInteger" + } + } + }, + "TestGenerationJobGroupName": { + "type": "string", + "max": 128, + "min": 1, + "pattern": "[a-zA-Z0-9-_]+" + }, + "TestGenerationJobProgressRateInteger": { + "type": "integer", + "box": true, + "max": 100, + "min": 0 + }, + "TestGenerationJobStatus": { + "type": "string", + "enum": ["IN_PROGRESS", "FAILED", "COMPLETED"] + }, "TextDocument": { "type": "structure", "required": ["relativeFilePath"], "members": { - "relativeFilePath": { "shape": "TextDocumentRelativeFilePathString" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "text": { "shape": "TextDocumentTextString" }, - "documentSymbols": { "shape": "DocumentSymbols" } + "relativeFilePath": { + "shape": "TextDocumentRelativeFilePathString" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "text": { + "shape": "TextDocumentTextString" + }, + "documentSymbols": { + "shape": "DocumentSymbols" + } } }, "TextDocumentDiagnostic": { "type": "structure", "required": ["document", "range", "source", "severity", "message"], "members": { - "document": { "shape": "TextDocument" }, - "range": { "shape": "Range" }, - "source": { "shape": "SensitiveString" }, - "severity": { "shape": "DiagnosticSeverity" }, - "message": { "shape": "TextDocumentDiagnosticMessageString" } + "document": { + "shape": "TextDocument" + }, + "range": { + "shape": "Range" + }, + "source": { + "shape": "SensitiveString" + }, + "severity": { + "shape": "DiagnosticSeverity" + }, + "message": { + "shape": "TextDocumentDiagnosticMessageString" + } } }, "TextDocumentDiagnosticMessageString": { @@ -1846,45 +3177,55 @@ "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" } + "message": { + "shape": "String" + } }, "exception": true, - "retryable": { "throttling": true } + "retryable": { + "throttling": true + } + }, + "Timestamp": { + "type": "timestamp" + }, + "TransformEvent": { + "type": "structure", + "required": ["jobId"], + "members": { + "jobId": { + "shape": "TransformationJobId" + }, + "timestamp": { + "shape": "Timestamp" + }, + "ideCategory": { + "shape": "IdeCategory" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "linesOfCodeChanged": { + "shape": "Integer" + }, + "charsOfCodeChanged": { + "shape": "Integer" + }, + "linesOfCodeSubmitted": { + "shape": "Integer" + } + } }, - "Timestamp": { "type": "timestamp" }, "TransformationDotNetRuntimeEnv": { "type": "string", - "enum": [ - "NET_FRAMEWORK_V_3_5", - "NET_FRAMEWORK_V_4_0", - "NET_FRAMEWORK_V_4_5", - "NET_FRAMEWORK_V_4_5_1", - "NET_FRAMEWORK_V_4_5_2", - "NET_FRAMEWORK_V_4_6", - "NET_FRAMEWORK_V_4_6_1", - "NET_FRAMEWORK_V_4_6_2", - "NET_FRAMEWORK_V_4_7", - "NET_FRAMEWORK_V_4_7_1", - "NET_FRAMEWORK_V_4_7_2", - "NET_FRAMEWORK_V_4_8", - "NET_FRAMEWORK_V_4_8_1", - "NET_CORE_APP_1_0", - "NET_CORE_APP_1_1", - "NET_CORE_APP_2_0", - "NET_CORE_APP_2_1", - "NET_CORE_APP_2_2", - "NET_CORE_APP_3_0", - "NET_CORE_APP_3_1", - "NET_5_0", - "NET_6_0", - "NET_7_0", - "NET_8_0" - ] + "enum": ["NET_5_0", "NET_6_0", "NET_7_0", "NET_8_0", "NET_9_0", "NET_STANDARD_2_0"] }, "TransformationDownloadArtifact": { "type": "structure", "members": { - "downloadArtifactType": { "shape": "TransformationDownloadArtifactType" }, + "downloadArtifactType": { + "shape": "TransformationDownloadArtifactType" + }, "downloadArtifactId": { "shape": "ArtifactId" } } }, @@ -1894,7 +3235,9 @@ }, "TransformationDownloadArtifacts": { "type": "list", - "member": { "shape": "TransformationDownloadArtifact" }, + "member": { + "shape": "TransformationDownloadArtifact" + }, "max": 10, "min": 0 }, @@ -1905,13 +3248,27 @@ "TransformationJob": { "type": "structure", "members": { - "jobId": { "shape": "TransformationJobId" }, - "transformationSpec": { "shape": "TransformationSpec" }, - "status": { "shape": "TransformationStatus" }, - "reason": { "shape": "String" }, - "creationTime": { "shape": "Timestamp" }, - "startExecutionTime": { "shape": "Timestamp" }, - "endExecutionTime": { "shape": "Timestamp" } + "jobId": { + "shape": "TransformationJobId" + }, + "transformationSpec": { + "shape": "TransformationSpec" + }, + "status": { + "shape": "TransformationStatus" + }, + "reason": { + "shape": "String" + }, + "creationTime": { + "shape": "Timestamp" + }, + "startExecutionTime": { + "shape": "Timestamp" + }, + "endExecutionTime": { + "shape": "Timestamp" + } } }, "TransformationJobId": { @@ -1925,7 +3282,9 @@ }, "TransformationLanguages": { "type": "list", - "member": { "shape": "TransformationLanguage" } + "member": { + "shape": "TransformationLanguage" + } }, "TransformationMainframeRuntimeEnv": { "type": "string", @@ -1939,35 +3298,53 @@ "type": "structure", "required": ["transformationSteps"], "members": { - "transformationSteps": { "shape": "TransformationSteps" } + "transformationSteps": { + "shape": "TransformationSteps" + } } }, "TransformationPlatformConfig": { "type": "structure", "members": { - "operatingSystemFamily": { "shape": "TransformationOperatingSystemFamily" } + "operatingSystemFamily": { + "shape": "TransformationOperatingSystemFamily" + } } }, "TransformationProgressUpdate": { "type": "structure", "required": ["name", "status"], "members": { - "name": { "shape": "String" }, - "status": { "shape": "TransformationProgressUpdateStatus" }, - "description": { "shape": "String" }, - "startTime": { "shape": "Timestamp" }, - "endTime": { "shape": "Timestamp" }, - "downloadArtifacts": { "shape": "TransformationDownloadArtifacts" } + "name": { + "shape": "String" + }, + "status": { + "shape": "TransformationProgressUpdateStatus" + }, + "description": { + "shape": "String" + }, + "startTime": { + "shape": "Timestamp" + }, + "endTime": { + "shape": "Timestamp" + }, + "downloadArtifacts": { + "shape": "TransformationDownloadArtifacts" + } } }, "TransformationProgressUpdateStatus": { "type": "string", - "enum": ["IN_PROGRESS", "COMPLETED", "FAILED", "PAUSED", "AWAITING_CLIENT_ACTION"] + "enum": ["IN_PROGRESS", "COMPLETED", "FAILED", "PAUSED", "AWAITING_CLIENT_ACTION", "SKIPPED"] }, "TransformationProjectArtifactDescriptor": { "type": "structure", "members": { - "sourceCodeArtifact": { "shape": "TransformationSourceCodeArtifactDescriptor" } + "sourceCodeArtifact": { + "shape": "TransformationSourceCodeArtifactDescriptor" + } }, "union": true }, @@ -1977,31 +3354,49 @@ "language": { "shape": "TransformationLanguage" }, "runtimeEnv": { "shape": "TransformationRuntimeEnv" }, "platformConfig": { "shape": "TransformationPlatformConfig" }, - "projectArtifact": { "shape": "TransformationProjectArtifactDescriptor" } + "projectArtifact": { + "shape": "TransformationProjectArtifactDescriptor" + } } }, "TransformationRuntimeEnv": { "type": "structure", "members": { - "java": { "shape": "TransformationJavaRuntimeEnv" }, - "dotNet": { "shape": "TransformationDotNetRuntimeEnv" }, - "mainframe": { "shape": "TransformationMainframeRuntimeEnv" } + "java": { + "shape": "TransformationJavaRuntimeEnv" + }, + "dotNet": { + "shape": "TransformationDotNetRuntimeEnv" + }, + "mainframe": { + "shape": "TransformationMainframeRuntimeEnv" + } }, "union": true }, "TransformationSourceCodeArtifactDescriptor": { "type": "structure", "members": { - "languages": { "shape": "TransformationLanguages" }, - "runtimeEnv": { "shape": "TransformationRuntimeEnv" } + "languages": { + "shape": "TransformationLanguages" + }, + "runtimeEnv": { + "shape": "TransformationRuntimeEnv" + } } }, "TransformationSpec": { "type": "structure", "members": { - "transformationType": { "shape": "TransformationType" }, - "source": { "shape": "TransformationProjectState" }, - "target": { "shape": "TransformationProjectState" } + "transformationType": { + "shape": "TransformationType" + }, + "source": { + "shape": "TransformationProjectState" + }, + "target": { + "shape": "TransformationProjectState" + } } }, "TransformationStatus": { @@ -2030,22 +3425,38 @@ "type": "structure", "required": ["id", "name", "description", "status"], "members": { - "id": { "shape": "StepId" }, - "name": { "shape": "String" }, - "description": { "shape": "String" }, - "status": { "shape": "TransformationStepStatus" }, - "progressUpdates": { "shape": "ProgressUpdates" }, - "startTime": { "shape": "Timestamp" }, - "endTime": { "shape": "Timestamp" } + "id": { + "shape": "StepId" + }, + "name": { + "shape": "String" + }, + "description": { + "shape": "String" + }, + "status": { + "shape": "TransformationStepStatus" + }, + "progressUpdates": { + "shape": "ProgressUpdates" + }, + "startTime": { + "shape": "Timestamp" + }, + "endTime": { + "shape": "Timestamp" + } } }, "TransformationStepStatus": { "type": "string", - "enum": ["CREATED", "COMPLETED", "PARTIALLY_COMPLETED", "STOPPED", "FAILED", "PAUSED"] + "enum": ["CREATED", "COMPLETED", "PARTIALLY_COMPLETED", "STOPPED", "FAILED", "PAUSED", "SKIPPED"] }, "TransformationSteps": { "type": "list", - "member": { "shape": "TransformationStep" } + "member": { + "shape": "TransformationStep" + } }, "TransformationType": { "type": "string", @@ -2059,8 +3470,12 @@ "type": "structure", "required": ["jobId", "uploadArtifactType"], "members": { - "jobId": { "shape": "TransformationJobId" }, - "uploadArtifactType": { "shape": "TransformationUploadArtifactType" } + "jobId": { + "shape": "TransformationJobId" + }, + "uploadArtifactType": { + "shape": "TransformationUploadArtifactType" + } } }, "TransformationUserActionStatus": { @@ -2075,9 +3490,14 @@ "UploadContext": { "type": "structure", "members": { - "taskAssistPlanningUploadContext": { "shape": "TaskAssistPlanningUploadContext" }, - "transformationUploadContext": { "shape": "TransformationUploadContext" }, - "codeAnalysisUploadContext": { "shape": "CodeAnalysisUploadContext" } + "taskAssistPlanningUploadContext": { + "shape": "TaskAssistPlanningUploadContext" + }, + "transformationUploadContext": { + "shape": "TransformationUploadContext" + }, + "codeAnalysisUploadContext": { "shape": "CodeAnalysisUploadContext" }, + "codeFixUploadContext": { "shape": "CodeFixUploadContext" } }, "union": true }, @@ -2092,18 +3512,30 @@ "TRANSFORMATION", "TASK_ASSIST_PLANNING", "AUTOMATIC_FILE_SECURITY_SCAN", - "FULL_PROJECT_SECURITY_SCAN" + "FULL_PROJECT_SECURITY_SCAN", + "UNIT_TESTS_GENERATION", + "CODE_FIX_GENERATION" ] }, "UserContext": { "type": "structure", "required": ["ideCategory", "operatingSystem", "product"], "members": { - "ideCategory": { "shape": "IdeCategory" }, - "operatingSystem": { "shape": "OperatingSystem" }, - "product": { "shape": "UserContextProductString" }, - "clientId": { "shape": "UUID" }, - "ideVersion": { "shape": "String" } + "ideCategory": { + "shape": "IdeCategory" + }, + "operatingSystem": { + "shape": "OperatingSystem" + }, + "product": { + "shape": "UserContextProductString" + }, + "clientId": { + "shape": "UUID" + }, + "ideVersion": { + "shape": "String" + } } }, "UserContextProductString": { @@ -2116,9 +3548,15 @@ "type": "structure", "required": ["content"], "members": { - "content": { "shape": "UserInputMessageContentString" }, - "userInputMessageContext": { "shape": "UserInputMessageContext" }, - "userIntent": { "shape": "UserIntent" } + "content": { + "shape": "UserInputMessageContentString" + }, + "userInputMessageContext": { + "shape": "UserInputMessageContext" + }, + "userIntent": { + "shape": "UserIntent" + } } }, "UserInputMessageContentString": { @@ -2130,14 +3568,30 @@ "UserInputMessageContext": { "type": "structure", "members": { - "editorState": { "shape": "EditorState" }, - "shellState": { "shape": "ShellState" }, - "gitState": { "shape": "GitState" }, - "envState": { "shape": "EnvState" }, - "appStudioContext": { "shape": "AppStudioState" }, - "diagnostic": { "shape": "Diagnostic" }, - "consoleState": { "shape": "ConsoleState" }, - "userSettings": { "shape": "UserSettings" } + "editorState": { + "shape": "EditorState" + }, + "shellState": { + "shape": "ShellState" + }, + "gitState": { + "shape": "GitState" + }, + "envState": { + "shape": "EnvState" + }, + "appStudioContext": { + "shape": "AppStudioState" + }, + "diagnostic": { + "shape": "Diagnostic" + }, + "consoleState": { + "shape": "ConsoleState" + }, + "userSettings": { + "shape": "UserSettings" + } } }, "UserIntent": { @@ -2151,25 +3605,54 @@ "EXPLAIN_LINE_BY_LINE", "EXPLAIN_CODE_SELECTION", "GENERATE_CLOUDFORMATION_TEMPLATE", - "GENERATE_UNIT_TESTS" + "GENERATE_UNIT_TESTS", + "CODE_GENERATION" ] }, "UserModificationEvent": { "type": "structure", - "required": ["sessionId", "requestId", "programmingLanguage", "modificationPercentage", "timestamp"], + "required": [ + "sessionId", + "requestId", + "programmingLanguage", + "modificationPercentage", + "timestamp", + "acceptedCharacterCount", + "unmodifiedAcceptedCharacterCount" + ], "members": { - "sessionId": { "shape": "UUID" }, - "requestId": { "shape": "UUID" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "modificationPercentage": { "shape": "Double" }, - "customizationArn": { "shape": "CustomizationArn" }, - "timestamp": { "shape": "Timestamp" } + "sessionId": { + "shape": "UUID" + }, + "requestId": { + "shape": "UUID" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "modificationPercentage": { + "shape": "Double" + }, + "customizationArn": { + "shape": "CustomizationArn" + }, + "timestamp": { + "shape": "Timestamp" + }, + "acceptedCharacterCount": { + "shape": "PrimitiveInteger" + }, + "unmodifiedAcceptedCharacterCount": { + "shape": "PrimitiveInteger" + } } }, "UserSettings": { "type": "structure", "members": { - "hasConsentedToCrossRegionCalls": { "shape": "Boolean" } + "hasConsentedToCrossRegionCalls": { + "shape": "Boolean" + } } }, "UserTriggerDecisionEvent": { @@ -2184,45 +3667,87 @@ "timestamp" ], "members": { - "sessionId": { "shape": "UUID" }, - "requestId": { "shape": "UUID" }, - "customizationArn": { "shape": "CustomizationArn" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "completionType": { "shape": "CompletionType" }, - "suggestionState": { "shape": "SuggestionState" }, - "recommendationLatencyMilliseconds": { "shape": "Double" }, - "timestamp": { "shape": "Timestamp" }, - "triggerToResponseLatencyMilliseconds": { "shape": "Double" }, - "suggestionReferenceCount": { "shape": "PrimitiveInteger" }, - "generatedLine": { "shape": "PrimitiveInteger" }, - "numberOfRecommendations": { "shape": "PrimitiveInteger" } + "sessionId": { + "shape": "UUID" + }, + "requestId": { + "shape": "UUID" + }, + "customizationArn": { + "shape": "CustomizationArn" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "completionType": { + "shape": "CompletionType" + }, + "suggestionState": { + "shape": "SuggestionState" + }, + "recommendationLatencyMilliseconds": { + "shape": "Double" + }, + "timestamp": { + "shape": "Timestamp" + }, + "triggerToResponseLatencyMilliseconds": { + "shape": "Double" + }, + "suggestionReferenceCount": { + "shape": "PrimitiveInteger" + }, + "generatedLine": { + "shape": "PrimitiveInteger" + }, + "numberOfRecommendations": { + "shape": "PrimitiveInteger" + }, + "perceivedLatencyMilliseconds": { + "shape": "Double" + }, + "acceptedCharacterCount": { + "shape": "PrimitiveInteger" + } } }, "ValidationException": { "type": "structure", "required": ["message"], "members": { - "message": { "shape": "String" }, - "reason": { "shape": "ValidationExceptionReason" } + "message": { + "shape": "String" + }, + "reason": { + "shape": "ValidationExceptionReason" + } }, "exception": true }, "ValidationExceptionReason": { "type": "string", - "enum": ["INVALID_CONVERSATION_ID", "CONTENT_LENGTH_EXCEEDS_THRESHOLD"] + "enum": ["INVALID_CONVERSATION_ID", "CONTENT_LENGTH_EXCEEDS_THRESHOLD", "INVALID_KMS_GRANT"] }, "WorkspaceState": { "type": "structure", "required": ["uploadId", "programmingLanguage"], "members": { - "uploadId": { "shape": "UploadId" }, - "programmingLanguage": { "shape": "ProgrammingLanguage" }, - "contextTruncationScheme": { "shape": "ContextTruncationScheme" } + "uploadId": { + "shape": "UploadId" + }, + "programmingLanguage": { + "shape": "ProgrammingLanguage" + }, + "contextTruncationScheme": { + "shape": "ContextTruncationScheme" + } } }, "timeBetweenChunks": { "type": "list", - "member": { "shape": "Double" }, + "member": { + "shape": "Double" + }, "max": 100, "min": 0 } diff --git a/packages/core/src/amazonqFeatureDev/client/featureDev.ts b/packages/core/src/amazonqFeatureDev/client/featureDev.ts index 909fa33627c..947949d48a9 100644 --- a/packages/core/src/amazonqFeatureDev/client/featureDev.ts +++ b/packages/core/src/amazonqFeatureDev/client/featureDev.ts @@ -139,8 +139,10 @@ export class FeatureDevClient { conversationId: string, uploadId: string, message: string, + intent: FeatureDevProxyClient.Intent, codeGenerationId: string, - currentCodeGenerationId?: string + currentCodeGenerationId?: string, + intentContext?: FeatureDevProxyClient.IntentContext ) { try { const client = await this.getClient(writeAPIRetryOptions) @@ -157,10 +159,14 @@ export class FeatureDevClient { uploadId, programmingLanguage: { languageName: 'javascript' }, }, + intent, } as FeatureDevProxyClient.Types.StartTaskAssistCodeGenerationRequest if (currentCodeGenerationId) { params.currentCodeGenerationId = currentCodeGenerationId } + if (intentContext) { + params.intentContext = intentContext + } getLogger().debug(`Executing startTaskAssistCodeGeneration with %O`, params) const response = await client.startTaskAssistCodeGeneration(params).promise() diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts index b4de877f569..6fcf89239a1 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts @@ -29,10 +29,8 @@ import { } from '../../errors' import { codeGenRetryLimit, defaultRetryLimit } from '../../limits' import { Session } from '../../session/session' -import { featureName } from '../../constants' -import { ChatSessionStorage } from '../../storages/chatSession' -import { DeletedFileInfo, DevPhase, FollowUpTypes, type NewFileInfo } from '../../types' -import { Messenger } from './messenger/messenger' +import { featureDevScheme, featureName } from '../../constants' +import { DeletedFileInfo, DevPhase, type NewFileInfo } from '../../types' import { AuthUtil } from '../../../codewhisperer/util/authUtil' import { AuthController } from '../../../amazonq/auth/controller' import { getLogger } from '../../../shared/logger' @@ -47,6 +45,9 @@ import { openDeletedDiff, openDiff } from '../../../amazonq/commons/diff' import { i18n } from '../../../shared/i18n-helper' import globals from '../../../shared/extensionGlobals' import { randomUUID } from '../../../shared' +import { FollowUpTypes } from '../../../amazonq/commons/types' +import { Messenger } from '../../../amazonq/commons/connector/baseMessenger' +import { BaseChatSessionStorage } from '../../../amazonq/commons/baseChatStorage' export const TotalSteps = 3 @@ -88,8 +89,9 @@ type StoreMessageIdMessage = { } export class FeatureDevController { + private readonly scheme: string = featureDevScheme private readonly messenger: Messenger - private readonly sessionStorage: ChatSessionStorage + private readonly sessionStorage: BaseChatSessionStorage private isAmazonQVisible: boolean private authController: AuthController private contentController: EditorContentController @@ -97,7 +99,7 @@ export class FeatureDevController { public constructor( private readonly chatControllerMessageListeners: ChatControllerEventEmitters, messenger: Messenger, - sessionStorage: ChatSessionStorage, + sessionStorage: BaseChatSessionStorage, onDidChangeAmazonQVisibility: vscode.Event ) { this.messenger = messenger @@ -818,21 +820,21 @@ export class FeatureDevController { if (message.deleted) { const name = path.basename(pathInfos.relativePath) - await openDeletedDiff(pathInfos.absolutePath, name, tabId) + await openDeletedDiff(pathInfos.absolutePath, name, tabId, this.scheme) } else { let uploadId = session.uploadId if (session?.state?.uploadHistory && session.state.uploadHistory[codeGenerationId]) { uploadId = session?.state?.uploadHistory[codeGenerationId].uploadId } const rightPath = path.join(uploadId, zipFilePath) - await openDiff(pathInfos.absolutePath, rightPath, tabId) + await openDiff(pathInfos.absolutePath, rightPath, tabId, this.scheme) } } private async openFile(filePath: NewFileInfo, tabId: string) { const leftPath = path.join(filePath.workspaceFolder.uri.fsPath, filePath.relativePath) const rightPath = filePath.virtualMemoryUri.path - await openDiff(leftPath, rightPath, tabId) + await openDiff(leftPath, rightPath, tabId, this.scheme) } private async stopResponse(message: any) { diff --git a/packages/core/src/amazonqFeatureDev/index.ts b/packages/core/src/amazonqFeatureDev/index.ts index d215ac3959a..22815f67143 100644 --- a/packages/core/src/amazonqFeatureDev/index.ts +++ b/packages/core/src/amazonqFeatureDev/index.ts @@ -10,10 +10,7 @@ export * from './session/sessionState' export * from './constants' export { Session } from './session/session' export { FeatureDevClient } from './client/featureDev' -export { Messenger } from './controllers/chat/messenger/messenger' -export { ChatSessionStorage } from './storages/chatSession' -export { AppToWebViewMessageDispatcher } from './views/connector/connector' +export { FeatureDevChatSessionStorage } from './storages/chatSession' export { TelemetryHelper } from './util/telemetryHelper' export { prepareRepoData } from './util/files' export { ChatControllerEventEmitters, FeatureDevController } from './controllers/chat/controller' -export { createSessionConfig } from './session/sessionConfigFactory' diff --git a/packages/core/src/amazonqFeatureDev/session/session.ts b/packages/core/src/amazonqFeatureDev/session/session.ts index a4f8f3c759b..d8db2d1b833 100644 --- a/packages/core/src/amazonqFeatureDev/session/session.ts +++ b/packages/core/src/amazonqFeatureDev/session/session.ts @@ -8,7 +8,6 @@ import * as path from 'path' import { ConversationNotStartedState, PrepareCodeGenState } from './sessionState' import { type DeletedFileInfo, - FollowUpTypes, type Interaction, type NewFileInfo, type SessionState, @@ -16,12 +15,10 @@ import { UpdateFilesPathsParams, } from '../types' import { ConversationIdNotFoundError } from '../errors' -import { referenceLogText } from '../constants' +import { featureDevChat, referenceLogText, featureDevScheme } from '../constants' import fs from '../../shared/fs/fs' -import { Messenger } from '../controllers/chat/messenger/messenger' import { FeatureDevClient } from '../client/featureDev' import { codeGenRetryLimit } from '../limits' -import { SessionConfig } from './sessionConfigFactory' import { telemetry } from '../../shared/telemetry/telemetry' import { TelemetryHelper } from '../util/telemetryHelper' import { ReferenceLogViewProvider } from '../../codewhisperer/service/referenceLogViewProvider' @@ -29,10 +26,13 @@ import { AuthUtil } from '../../codewhisperer/util/authUtil' import { getLogger } from '../../shared' import { logWithConversationId } from '../userFacingText' import { CodeReference } from '../../amazonq/webview/ui/connector' -import { UpdateAnswerMessage } from '../views/connector/connector' import { MynahIcons } from '@aws/mynah-ui' import { i18n } from '../../shared/i18n-helper' import { computeDiff } from '../../amazonq/commons/diff' +import { UpdateAnswerMessage } from '../../amazonq/commons/connector/connectorMessages' +import { FollowUpTypes } from '../../amazonq/commons/types' +import { SessionConfig } from '../../amazonq/commons/session/sessionConfigFactory' +import { Messenger } from '../../amazonq/commons/connector/baseMessenger' export class Session { private _state?: SessionState | Omit private task: string = '' @@ -184,7 +184,8 @@ export class Session { }, ], }, - tabID + tabID, + featureDevChat ) this.messenger.updateChatAnswer(answer) } @@ -280,7 +281,7 @@ export class Session { public async computeFilePathDiff(filePath: NewFileInfo) { const leftPath = `${filePath.workspaceFolder.uri.fsPath}/${filePath.relativePath}` const rightPath = filePath.virtualMemoryUri.path - const diff = await computeDiff(leftPath, rightPath, this.tabID) + const diff = await computeDiff(leftPath, rightPath, this.tabID, featureDevScheme) return { leftPath, rightPath, ...diff } } diff --git a/packages/core/src/amazonqFeatureDev/session/sessionState.ts b/packages/core/src/amazonqFeatureDev/session/sessionState.ts index e04c28e74d3..705232b0536 100644 --- a/packages/core/src/amazonqFeatureDev/session/sessionState.ts +++ b/packages/core/src/amazonqFeatureDev/session/sessionState.ts @@ -24,7 +24,7 @@ import { CurrentWsFolders, DeletedFileInfo, DevPhase, - FollowUpTypes, + Intent, NewFileInfo, NewFileZipContents, SessionState, @@ -42,9 +42,10 @@ import { AuthUtil } from '../../codewhisperer/util/authUtil' import { randomUUID } from '../../shared/crypto' import { collectFiles, getWorkspaceFoldersByPrefixes } from '../../shared/utilities/workspaceUtils' import { i18n } from '../../shared/i18n-helper' -import { Messenger } from '../controllers/chat/messenger/messenger' +import { Messenger } from '../../amazonq/commons/connector/baseMessenger' +import { FollowUpTypes } from '../../amazonq/commons/types' -const EmptyCodeGenID = 'EMPTY_CURRENT_CODE_GENERATION_ID' +export const EmptyCodeGenID = 'EMPTY_CURRENT_CODE_GENERATION_ID' export class ConversationNotStartedState implements Omit { public tokenSource: vscode.CancellationTokenSource @@ -64,7 +65,8 @@ export function registerNewFiles( newFileContents: NewFileZipContents[], uploadId: string, workspaceFolders: CurrentWsFolders, - conversationId: string + conversationId: string, + scheme: string ): NewFileInfo[] { const result: NewFileInfo[] = [] const workspaceFolderPrefixes = getWorkspaceFoldersByPrefixes(workspaceFolders) @@ -72,7 +74,7 @@ export function registerNewFiles( const encoder = new TextEncoder() const contents = encoder.encode(fileContent) const generationFilePath = path.join(uploadId, zipFilePath) - const uri = vscode.Uri.from({ scheme: featureDevScheme, path: generationFilePath }) + const uri = vscode.Uri.from({ scheme, path: generationFilePath }) fs.registerProvider(uri, new VirtualMemoryFile(contents)) const prefix = workspaceFolderPrefixes === undefined ? '' : zipFilePath.substring(0, zipFilePath.indexOf(path.sep)) @@ -109,7 +111,7 @@ export function registerNewFiles( return result } -function getDeletedFileInfos(deletedFiles: string[], workspaceFolders: CurrentWsFolders): DeletedFileInfo[] { +export function getDeletedFileInfos(deletedFiles: string[], workspaceFolders: CurrentWsFolders): DeletedFileInfo[] { const workspaceFolderPrefixes = getWorkspaceFoldersByPrefixes(workspaceFolders) return deletedFiles .map((deletedFilePath) => { @@ -193,7 +195,8 @@ abstract class CodeGenBase { newFileContents, this.uploadId, workspaceFolders, - this.conversationId + this.conversationId, + featureDevScheme ) telemetry.setNumberOfFilesGenerated(newFileInfo.length) @@ -309,6 +312,7 @@ export class CodeGenState extends CodeGenBase implements SessionState { this.config.conversationId, this.config.uploadId, action.msg, + Intent.DEV, codeGenerationId, this.currentCodeGenerationId ) @@ -420,7 +424,8 @@ export class MockCodeGenState implements SessionState { newFileContents, this.uploadId, this.config.workspaceFolders, - this.conversationId + this.conversationId, + featureDevScheme ) this.deletedFiles = [ { diff --git a/packages/core/src/amazonqFeatureDev/storages/chatSession.ts b/packages/core/src/amazonqFeatureDev/storages/chatSession.ts index cbfa9f5868f..f45576aa9df 100644 --- a/packages/core/src/amazonqFeatureDev/storages/chatSession.ts +++ b/packages/core/src/amazonqFeatureDev/storages/chatSession.ts @@ -3,47 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import AsyncLock from 'async-lock' -import { Messenger } from '../controllers/chat/messenger/messenger' +import { BaseChatSessionStorage } from '../../amazonq/commons/baseChatStorage' +import { Messenger } from '../../amazonq/commons/connector/baseMessenger' +import { createSessionConfig } from '../../amazonq/commons/session/sessionConfigFactory' +import { featureDevScheme } from '../constants' import { Session } from '../session/session' -import { createSessionConfig } from '../session/sessionConfigFactory' -export class ChatSessionStorage { - private lock = new AsyncLock() - - private sessions: Map = new Map() - - constructor(private readonly messenger: Messenger) {} +export class FeatureDevChatSessionStorage extends BaseChatSessionStorage { + constructor(protected readonly messenger: Messenger) { + super() + } - private async createSession(tabID: string): Promise { - const sessionConfig = await createSessionConfig() + override async createSession(tabID: string): Promise { + const sessionConfig = await createSessionConfig(featureDevScheme) const session = new Session(sessionConfig, this.messenger, tabID) this.sessions.set(tabID, session) return session } - - public async getSession(tabID: string): Promise { - /** - * The lock here is added in order to mitigate amazon Q's eventing fire & forget design when integrating with mynah-ui that creates a race condition here. - * The race condition happens when handleDevFeatureCommand in src/amazonq/webview/ui/quickActions/handler.ts is firing two events after each other to amazonqFeatureDev controller - * This eventually may make code generation fail as at the moment of that event it may get from the storage a session that has not been properly updated. - */ - return this.lock.acquire(tabID, async () => { - const sessionFromStorage = this.sessions.get(tabID) - if (sessionFromStorage === undefined) { - // If a session doesn't already exist just create it - return this.createSession(tabID) - } - return sessionFromStorage - }) - } - - // Find all sessions that are currently waiting to be authenticated - public getAuthenticatingSessions(): Session[] { - return Array.from(this.sessions.values()).filter((session) => session.isAuthenticating) - } - - public deleteSession(tabID: string) { - this.sessions.delete(tabID) - } } diff --git a/packages/core/src/amazonqFeatureDev/types.ts b/packages/core/src/amazonqFeatureDev/types.ts index 499ddd22c5d..9c1a86643a2 100644 --- a/packages/core/src/amazonqFeatureDev/types.ts +++ b/packages/core/src/amazonqFeatureDev/types.ts @@ -6,11 +6,11 @@ import * as vscode from 'vscode' import { VirtualFileSystem } from '../shared/virtualFilesystem' import type { CancellationTokenSource } from 'vscode' -import { Messenger } from './controllers/chat/messenger/messenger' import { FeatureDevClient } from './client/featureDev' import { TelemetryHelper } from './util/telemetryHelper' import { CodeReference, UploadHistory } from '../amazonq/webview/ui/connector' import { DiffTreeFileInfo } from '../amazonq/webview/ui/diffTree/types' +import { Messenger } from '../amazonq/commons/connector/baseMessenger' export type Interaction = { // content to be sent back to the chat UI @@ -24,6 +24,11 @@ export interface SessionStateInteraction { currentCodeGenerationId?: string } +export enum Intent { + DEV = 'DEV', + DOC = 'DOC', +} + export enum DevPhase { INIT = 'Init', APPROACH = 'Approach', @@ -39,18 +44,6 @@ export enum CodeGenerationStatus { FAILED = 'Failed', } -export enum FollowUpTypes { - GenerateCode = 'GenerateCode', - InsertCode = 'InsertCode', - ProvideFeedbackAndRegenerateCode = 'ProvideFeedbackAndRegenerateCode', - Retry = 'Retry', - ModifyDefaultSourceFolder = 'ModifyDefaultSourceFolder', - DevExamples = 'DevExamples', - NewTask = 'NewTask', - CloseSession = 'CloseSession', - SendFeedback = 'SendFeedback', -} - export type SessionStatePhase = DevPhase.INIT | DevPhase.CODEGEN export type CurrentWsFolders = [vscode.WorkspaceFolder, ...vscode.WorkspaceFolder[]] diff --git a/packages/core/src/amazonqFeatureDev/userFacingText.ts b/packages/core/src/amazonqFeatureDev/userFacingText.ts index 781cae997f5..9b8a781ef1a 100644 --- a/packages/core/src/amazonqFeatureDev/userFacingText.ts +++ b/packages/core/src/amazonqFeatureDev/userFacingText.ts @@ -17,7 +17,7 @@ You can use /dev to: To learn more, visit the _[Amazon Q Developer User Guide](${userGuideURL})_. ` -export const uploadCodeError = `I'm sorry, I couldn’t upload your workspace artifacts to Amazon S3 to help you with this task. You might need to allow access to the S3 bucket. For more information, see the [Amazon Q documentation](${manageAccessGuideURL}) or contact your network or organization administrator.` +export const uploadCodeError = `I'm sorry, I couldn't upload your workspace artifacts to Amazon S3 to help you with this task. You might need to allow access to the S3 bucket. For more information, see the [Amazon Q documentation](${manageAccessGuideURL}) or contact your network or organization administrator.` // Utils for logging and showing customer facing conversation id text export const messageWithConversationId = (conversationId?: string) => diff --git a/packages/core/src/amazonqScan/chat/session/session.ts b/packages/core/src/amazonqScan/chat/session/session.ts new file mode 100644 index 00000000000..c50f6291bde --- /dev/null +++ b/packages/core/src/amazonqScan/chat/session/session.ts @@ -0,0 +1,26 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum ConversationState { + IDLE, +} + +export class Session { + // Used to keep track of whether or not the current session is currently authenticating/needs authenticating + public isAuthenticating: boolean = false + + // A tab may or may not be currently open + public tabID: string | undefined + + public conversationState: ConversationState = ConversationState.IDLE + + public scanUuid: string | undefined + + constructor() {} + + public isTabOpen(): boolean { + return this.tabID !== undefined + } +} diff --git a/packages/core/src/amazonqScan/chat/storages/chatSession.ts b/packages/core/src/amazonqScan/chat/storages/chatSession.ts new file mode 100644 index 00000000000..b9742a5db95 --- /dev/null +++ b/packages/core/src/amazonqScan/chat/storages/chatSession.ts @@ -0,0 +1,51 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { getLogger } from '../../../shared' +import { Session } from '../session/session' + +export class SessionNotFoundError extends Error {} + +export class ChatSessionManager { + private static _instance: ChatSessionManager + private activeSession: Session | undefined + + constructor() {} + + public static get Instance() { + return this._instance || (this._instance = new this()) + } + + private createSession(): Session { + this.activeSession = new Session() + return this.activeSession + } + + public getSession(): Session { + if (this.activeSession === undefined) { + return this.createSession() + } + + return this.activeSession + } + + public setActiveTab(tabID: string): string { + getLogger().debug(`Setting active tab: ${tabID}, activeSession: ${this.activeSession}`) + if (this.activeSession !== undefined) { + this.activeSession.tabID = tabID + return tabID + } + throw new SessionNotFoundError() + } + + public removeActiveTab(): void { + getLogger().debug(`Removing active tab and deleting activeSession: ${this.activeSession}`) + if (this.activeSession !== undefined) { + this.activeSession.tabID = undefined + this.activeSession = undefined + } + } +} diff --git a/packages/core/src/amazonqScan/connector.ts b/packages/core/src/amazonqScan/connector.ts new file mode 100644 index 00000000000..1b2e05541ec --- /dev/null +++ b/packages/core/src/amazonqScan/connector.ts @@ -0,0 +1,21 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * TODO: + * This file/declaration needs to be moved to packages/amazonq/src/amazonqScan/chat/views/connector/connector + * Once the mapping from Q folder to core is configured. + */ + +export type ScanMessageType = + | 'authenticationUpdateMessage' + | 'authNeededException' + | 'chatMessage' + | 'chatInputEnabledMessage' + | 'sendCommandMessage' + | 'updatePlaceholderMessage' + | 'updatePromptProgress' + | 'chatPrompt' + | 'errorMessage' diff --git a/packages/core/src/amazonqScan/controller.ts b/packages/core/src/amazonqScan/controller.ts new file mode 100644 index 00000000000..7e928d6df69 --- /dev/null +++ b/packages/core/src/amazonqScan/controller.ts @@ -0,0 +1,33 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for responding to UI events by calling + * the Scan extension. + */ + +/** + * TODO: + * This file/declaration needs to be moved to packages/amazonq/src/amazonqScan/chat/controller + * Once the mapping from Q folder to core is configured. + */ + +import * as vscode from 'vscode' + +// These events can be interactions within the chat, +// or elsewhere in the IDE +export interface ScanChatControllerEventEmitters { + readonly tabOpened: vscode.EventEmitter + readonly tabClosed: vscode.EventEmitter + readonly authClicked: vscode.EventEmitter + readonly runScan: vscode.EventEmitter + readonly formActionClicked: vscode.EventEmitter + readonly errorThrown: vscode.EventEmitter + readonly showSecurityScan: vscode.EventEmitter + readonly scanStopped: vscode.EventEmitter + readonly followUpClicked: vscode.EventEmitter + readonly scanProgress: vscode.EventEmitter + readonly processResponseBodyLinkClick: vscode.EventEmitter + readonly fileClicked: vscode.EventEmitter + readonly scanCancelled: vscode.EventEmitter +} diff --git a/packages/core/src/amazonqScan/index.ts b/packages/core/src/amazonqScan/index.ts new file mode 100644 index 00000000000..7e29450c50c --- /dev/null +++ b/packages/core/src/amazonqScan/index.ts @@ -0,0 +1,9 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ScanChatControllerEventEmitters } from './controller' +export { ScanMessageType } from './connector' +export { ChatSessionManager } from './chat/storages/chatSession' +export { Session } from './chat/session/session' diff --git a/packages/core/src/amazonqTest/app.ts b/packages/core/src/amazonqTest/app.ts new file mode 100644 index 00000000000..3f857612520 --- /dev/null +++ b/packages/core/src/amazonqTest/app.ts @@ -0,0 +1,71 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode' +import { AmazonQAppInitContext } from '../amazonq/apps/initContext' +import { MessagePublisher } from '../amazonq/messages/messagePublisher' +import { MessageListener } from '../amazonq/messages/messageListener' +import { AuthUtil } from '../codewhisperer/util/authUtil' +import { ChatSessionManager } from './chat/storages/chatSession' +import { TestController, TestChatControllerEventEmitters } from './chat/controller/controller' +import { AppToWebViewMessageDispatcher } from './chat/views/connector/connector' +import { Messenger } from './chat/controller/messenger/messenger' +import { UIMessageListener } from './chat/views/actions/uiMessageListener' +import { debounce } from 'lodash' +import { testGenState } from '../codewhisperer/models/model' + +export function init(appContext: AmazonQAppInitContext) { + const testChatControllerEventEmitters: TestChatControllerEventEmitters = { + tabOpened: new vscode.EventEmitter(), + tabClosed: new vscode.EventEmitter(), + authClicked: new vscode.EventEmitter(), + startTestGen: new vscode.EventEmitter(), + processHumanChatMessage: new vscode.EventEmitter(), + updateShortAnswer: new vscode.EventEmitter(), + showCodeGenerationResults: new vscode.EventEmitter(), + openDiff: new vscode.EventEmitter(), + formActionClicked: new vscode.EventEmitter(), + followUpClicked: new vscode.EventEmitter(), + sendUpdatePromptProgress: new vscode.EventEmitter(), + errorThrown: new vscode.EventEmitter(), + insertCodeAtCursorPosition: new vscode.EventEmitter(), + processResponseBodyLinkClick: new vscode.EventEmitter(), + } + const dispatcher = new AppToWebViewMessageDispatcher(appContext.getAppsToWebViewMessagePublisher()) + const messenger = new Messenger(dispatcher) + + new TestController(testChatControllerEventEmitters, messenger, appContext.onDidChangeAmazonQVisibility.event) + + const testChatUIInputEventEmitter = new vscode.EventEmitter() + + new UIMessageListener({ + chatControllerEventEmitters: testChatControllerEventEmitters, + webViewMessageListener: new MessageListener(testChatUIInputEventEmitter), + }) + + appContext.registerWebViewToAppMessagePublisher(new MessagePublisher(testChatUIInputEventEmitter), 'testgen') + + const debouncedEvent = debounce(async () => { + const authenticated = (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' + let authenticatingSessionID = '' + + if (authenticated) { + const session = ChatSessionManager.Instance.getSession() + + if (session.isTabOpen() && session.isAuthenticating) { + authenticatingSessionID = session.tabID! + session.isAuthenticating = false + } + } + + messenger.sendAuthenticationUpdate(authenticated, [authenticatingSessionID]) + }, 500) + + AuthUtil.instance.secondaryAuth.onDidChangeActiveConnection(() => { + return debouncedEvent() + }) + testGenState.setChatControllers(testChatControllerEventEmitters) + // TODO: Add testGen provider for creating new files after test generation if they does not exist +} diff --git a/packages/core/src/amazonqTest/chat/controller/controller.ts b/packages/core/src/amazonqTest/chat/controller/controller.ts new file mode 100644 index 00000000000..610f9dca5cb --- /dev/null +++ b/packages/core/src/amazonqTest/chat/controller/controller.ts @@ -0,0 +1,1325 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class is responsible for responding to UI events by calling + * the Test extension. + */ +import * as vscode from 'vscode' +import path from 'path' +import { FollowUps, Messenger, TestNamedMessages } from './messenger/messenger' +import { AuthController } from '../../../amazonq/auth/controller' +import { ChatSessionManager } from '../storages/chatSession' +import { BuildStatus, ConversationState, Session } from '../session/session' +import { AuthUtil } from '../../../codewhisperer/util/authUtil' +import { + CodeWhispererConstants, + ReferenceLogViewProvider, + ShortAnswer, + ShortAnswerReference, + TelemetryHelper, + TestGenerationBuildStep, + testGenState, + unitTestGenerationCancelMessage, +} from '../../../codewhisperer' +import { + fs, + getLogger, + getTelemetryReasonDesc, + i18n, + openUrl, + randomUUID, + sleep, + tempDirPath, + testGenerationLogsDir, +} from '../../../shared' +import { + buildProgressField, + cancellingProgressField, + cancelTestGenButton, + errorProgressField, + testGenBuildProgressMessage, + testGenCompletedField, + testGenProgressField, + testGenSummaryMessage, +} from '../../models/constants' +import MessengerUtils, { ButtonActions } from './messenger/messengerUtils' +import { isAwsError } from '../../../shared/errors' +import { ChatItemType } from '../../../amazonq/commons/model' +import { ProgressField } from '@aws/mynah-ui' +import { FollowUpTypes } from '../../../amazonq/commons/types' +import { + cancelBuild, + runBuildCommand, + startTestGenerationProcess, +} from '../../../codewhisperer/commands/startTestGeneration' +import { UserIntent } from '@amzn/codewhisperer-streaming' +import { getSelectedCustomization } from '../../../codewhisperer/util/customizationUtil' +import { createCodeWhispererChatStreamingClient } from '../../../shared/clients/codewhispererChatClient' +import { ChatTriggerType } from '../../../codewhispererChat/controllers/chat/model' +import { triggerPayloadToChatRequest } from '../../../codewhispererChat/controllers/chat/chatRequest/converter' +import { EditorContentController } from '../../../amazonq/commons/controllers/contentController' +import { amazonQTabSuffix } from '../../../shared/constants' +import { applyChanges } from '../../../shared/utilities/textDocumentUtilities' +import { telemetry } from '../../../shared/telemetry/telemetry' +import { CodeReference } from '../../../amazonq' +import { CodeWhispererSettings } from '../../../codewhisperer/util/codewhispererSettings' + +export interface TestChatControllerEventEmitters { + readonly tabOpened: vscode.EventEmitter + readonly tabClosed: vscode.EventEmitter + readonly authClicked: vscode.EventEmitter + readonly startTestGen: vscode.EventEmitter + readonly processHumanChatMessage: vscode.EventEmitter + readonly updateShortAnswer: vscode.EventEmitter + readonly showCodeGenerationResults: vscode.EventEmitter + readonly openDiff: vscode.EventEmitter + readonly formActionClicked: vscode.EventEmitter + readonly followUpClicked: vscode.EventEmitter + readonly sendUpdatePromptProgress: vscode.EventEmitter + readonly errorThrown: vscode.EventEmitter + readonly insertCodeAtCursorPosition: vscode.EventEmitter + readonly processResponseBodyLinkClick: vscode.EventEmitter +} + +type OpenDiffMessage = { + tabID: string + messageId: string + filePath: string + codeGenerationId: string +} + +export class TestController { + private readonly messenger: Messenger + private readonly sessionStorage: ChatSessionManager + private authController: AuthController + private readonly editorContentController: EditorContentController + tempResultDirPath = path.join(tempDirPath, 'q-testgen') + + public constructor( + private readonly chatControllerMessageListeners: TestChatControllerEventEmitters, + messenger: Messenger, + onDidChangeAmazonQVisibility: vscode.Event + ) { + this.messenger = messenger + this.sessionStorage = ChatSessionManager.Instance + this.authController = new AuthController() + this.editorContentController = new EditorContentController() + + this.chatControllerMessageListeners.tabOpened.event((data) => { + return this.tabOpened(data) + }) + + this.chatControllerMessageListeners.tabClosed.event((data) => { + return this.tabClosed(data) + }) + + this.chatControllerMessageListeners.authClicked.event((data) => { + this.authClicked(data) + }) + + this.chatControllerMessageListeners.startTestGen.event(async (data) => { + await this.startTestGen(data, false) + }) + + this.chatControllerMessageListeners.processHumanChatMessage.event((data) => { + return this.processHumanChatMessage(data) + }) + + this.chatControllerMessageListeners.formActionClicked.event((data) => { + return this.handleFormActionClicked(data) + }) + + this.chatControllerMessageListeners.updateShortAnswer.event((data) => { + return this.updateShortAnswer(data) + }) + + this.chatControllerMessageListeners.showCodeGenerationResults.event((data) => { + return this.showCodeGenerationResults(data) + }) + + this.chatControllerMessageListeners.openDiff.event((data) => { + return this.openDiff(data) + }) + + this.chatControllerMessageListeners.sendUpdatePromptProgress.event((data) => { + return this.handleUpdatePromptProgress(data) + }) + + this.chatControllerMessageListeners.errorThrown.event((data) => { + return this.handleErrorMessage(data) + }) + + this.chatControllerMessageListeners.insertCodeAtCursorPosition.event((data) => { + return this.handleInsertCodeAtCursorPosition(data) + }) + + this.chatControllerMessageListeners.processResponseBodyLinkClick.event((data) => { + return this.processLink(data) + }) + + this.chatControllerMessageListeners.followUpClicked.event((data) => { + switch (data.followUp.type) { + case FollowUpTypes.ViewDiff: + return this.openDiff(data) + case FollowUpTypes.AcceptCode: + return this.acceptCode(data) + case FollowUpTypes.RejectCode: + return this.endSession(data, FollowUpTypes.RejectCode) + case FollowUpTypes.ContinueBuildAndExecute: + return this.handleBuildIteration(data) + case FollowUpTypes.BuildAndExecute: + return this.checkForInstallationDependencies(data) + case FollowUpTypes.ModifyCommands: + return this.modifyBuildCommand(data) + case FollowUpTypes.SkipBuildAndFinish: + return this.endSession(data, FollowUpTypes.SkipBuildAndFinish) + case FollowUpTypes.InstallDependenciesAndContinue: + return this.handleInstallDependencies(data) + case FollowUpTypes.ViewCodeDiffAfterIteration: + return this.openDiff(data) + } + }) + } + + /** + * Basic Functions + */ + private async tabOpened(message: any) { + const session: Session = this.sessionStorage.getSession() + const tabID = this.sessionStorage.setActiveTab(message.tabID) + const logger = getLogger() + logger.debug('Tab opened Processing message tabId: %s', message.tabID) + + // check if authentication has expired + try { + logger.debug(`Q - Test: Session created with id: ${session.tabID}`) + + const authState = await AuthUtil.instance.getChatAuthState() + if (authState.amazonQ !== 'connected') { + void this.messenger.sendAuthNeededExceptionMessage(authState, tabID) + session.isAuthenticating = true + return + } + } catch (err: any) { + logger.error('tabOpened failed: %O', err) + this.messenger.sendErrorMessage(err.message, message.tabID) + } + } + + private async tabClosed(data: any) { + getLogger().debug('Tab closed with data tab id: %s', data.tabID) + await this.sessionCleanUp() + getLogger().debug('Removing active tab') + this.sessionStorage.removeActiveTab() + } + + private authClicked(message: any) { + this.authController.handleAuth(message.authType) + + this.messenger.sendMessage('Follow instructions to re-authenticate ...', message.tabID, 'answer') + + // Explicitly ensure the user goes through the re-authenticate flow + this.messenger.sendChatInputEnabled(message.tabID, false) + } + + private processLink(message: any) { + void openUrl(vscode.Uri.parse(message.link)) + } + + private handleInsertCodeAtCursorPosition(message: any) { + this.editorContentController.insertTextAtCursorPosition(message.code, () => {}) + } + + private checkCodeDiffLengthAndBuildStatus(state: { codeDiffLength: number; buildStatus: BuildStatus }): boolean { + return state.codeDiffLength !== 0 && state.buildStatus !== BuildStatus.SUCCESS + } + + // Displaying error message to the user in the chat tab + private async handleErrorMessage(data: any) { + testGenState.setToNotStarted() + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(data.tabID, null) + const session = this.sessionStorage.getSession() + const isCancel = data.error.message === unitTestGenerationCancelMessage + telemetry.amazonq_utgGenerateTests.emit({ + cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', + jobId: session.listOfTestGenerationJobId[0], // For RIV, UTG does only one StartTestGeneration API call + jobGroup: session.testGenerationJobGroupName, + hasUserPromptSupplied: session.hasUserPromptSupplied, + isCodeBlockSelected: session.isCodeBlockSelected, + buildPayloadBytes: session.srcPayloadSize, + buildZipFileBytes: session.srcZipFileSize, + artifactsUploadDuration: session.artifactsUploadDuration, + perfClientLatency: performance.now() - session.testGenerationStartTime, + result: isCancel ? 'Cancelled' : 'Failed', + reasonDesc: getTelemetryReasonDesc(data.error), + isSupportedLanguage: true, + }) + if (session.stopIteration) { + // Error from Science + this.messenger.sendMessage(data.error.message.replaceAll('```', ''), data.tabID, 'answer') + } else { + isCancel + ? this.messenger.sendMessage(data.error.message, data.tabID, 'answer') + : this.sendErrorMessage(data) + } + await this.sessionCleanUp() + return + } + // Client side error messages + private sendErrorMessage(data: { tabID: string; error: { code: string; message: string } }) { + const { error, tabID } = data + + 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 + ) + } + } else { + // other unexpected errors (TODO enumerate all other failure cases) + getLogger().error('Other error message: %s', error.message) + this.messenger.sendErrorMessage( + 'Encountered an unexpected error when generating tests. Please try again', + tabID + ) + } + } + + // This function handles actions if user clicked on any Button one of these cases will be executed + private async handleFormActionClicked(data: any) { + const typedAction = MessengerUtils.stringToEnumValue(ButtonActions, data.action as any) + switch (typedAction) { + case ButtonActions.STOP_TEST_GEN: + testGenState.setToCancelling() + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_cancelTestGenerationProgress' }) + await this.sessionCleanUp() + break + case ButtonActions.STOP_BUILD: + cancelBuild() + void this.handleUpdatePromptProgress({ status: 'cancel', tabID: data.tabID }) + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_cancelBuildProgress' }) + this.messenger.sendChatInputEnabled(data.tabID, true) + await this.sessionCleanUp() + break + } + } + // This function handles actions if user gives any input from the chatInput box + private async processHumanChatMessage(data: { prompt: string; tabID: string }) { + const session = this.sessionStorage.getSession() + const conversationState = session.conversationState + + if (conversationState === ConversationState.WAITING_FOR_BUILD_COMMMAND_INPUT) { + this.messenger.sendChatInputEnabled(data.tabID, false) + this.sessionStorage.getSession().conversationState = ConversationState.IDLE + session.updatedBuildCommands = [data.prompt] + const updatedCommands = session.updatedBuildCommands.join('\n') + this.messenger.sendMessage(`Updated command to \`${updatedCommands}\``, data.tabID, 'prompt') + await this.checkForInstallationDependencies(data) + return + } else { + await this.startTestGen(data, false) + } + } + // This function takes filePath as input parameter and returns file language + private async getLanguageForFilePath(filePath: string): Promise { + try { + const document = await vscode.workspace.openTextDocument(filePath) + return document.languageId + } catch (error) { + return 'plaintext' + } + } + + /** + * Start Test Generation and show the code results + */ + + private async startTestGen(message: any, regenerateTests: boolean) { + const session: Session = this.sessionStorage.getSession() + const tabID = this.sessionStorage.setActiveTab(message.tabID) + getLogger().debug('startTestGen message: %O', message) + getLogger().debug('startTestGen tabId: %O', message.tabID) + let fileName = '' + let filePath = '' + let userMessage = '' + session.testGenerationStartTime = performance.now() + + try { + if (ChatSessionManager.Instance.getIsInProgress()) { + void vscode.window.showInformationMessage( + "There is already a test generation job in progress. Cancel current job or wait until it's finished to try again." + ) + return + } + if (testGenState.isCancelling()) { + void vscode.window.showInformationMessage( + 'There is a test generation job being cancelled. Please wait for cancellation to finish.' + ) + return + } + + // check that the session is authenticated + const authState = await AuthUtil.instance.getChatAuthState() + if (authState.amazonQ !== 'connected') { + void this.messenger.sendAuthNeededExceptionMessage(authState, tabID) + session.isAuthenticating = true + return + } + + // check that a project/workspace is open + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders === undefined || workspaceFolders.length === 0) { + this.messenger.sendUnrecoverableErrorResponse('no-project-found', tabID) + return + } + + // check if IDE has active file open. + const activeEditor = vscode.window.activeTextEditor + // also check all open editors and allow this to proceed if only one is open (even if not main focus) + const allVisibleEditors = vscode.window.visibleTextEditors + const openFileEditors = allVisibleEditors.filter((editor) => editor.document.uri.scheme === 'file') + const hasOnlyOneOpenFileSplitView = openFileEditors.length === 1 + getLogger().debug(`hasOnlyOneOpenSplitView: ${hasOnlyOneOpenFileSplitView}`) + // is not a file if the currently highlighted window is not a file, and there is either more than one or no file windows open + const isNotFile = activeEditor?.document.uri.scheme !== 'file' && !hasOnlyOneOpenFileSplitView + getLogger().debug(`activeEditor: ${activeEditor}, isNotFile: ${isNotFile}`) + if (!activeEditor || isNotFile) { + this.messenger.sendUnrecoverableErrorResponse( + isNotFile ? 'invalid-file-type' : 'no-open-file-found', + tabID + ) + this.messenger.sendUpdatePlaceholder( + tabID, + 'Please open and highlight a source code file in order to generate tests.' + ) + this.messenger.sendChatInputEnabled(tabID, true) + this.sessionStorage.getSession().conversationState = ConversationState.WAITING_FOR_INPUT + return + } + + const fileEditorToTest = hasOnlyOneOpenFileSplitView ? openFileEditors[0] : activeEditor + getLogger().debug(`File path: ${fileEditorToTest.document.uri.fsPath}`) + filePath = fileEditorToTest.document.uri.fsPath + fileName = path.basename(filePath) + userMessage = message.prompt + ? regenerateTests + ? `${message.prompt}` + : `/test ${message.prompt}` + : `/test Generate unit tests for \`${fileName}\`` + + session.hasUserPromptSupplied = message.prompt.length > 0 + + //displaying user message prompt in Test tab + this.messenger.sendMessage(userMessage, tabID, 'prompt') + this.messenger.sendChatInputEnabled(tabID, false) + this.sessionStorage.getSession().conversationState = ConversationState.IN_PROGRESS + this.messenger.sendUpdatePromptProgress(message.tabID, testGenProgressField) + + const language = await this.getLanguageForFilePath(filePath) + session.fileLanguage = language + + /* + For Re:Invent 2024 we are supporting only java and python for unit test generation, rest of the languages shows the similar experience as CWC + If user request test generation from input chat without opening a file, its difficult to get the language, so default will be plainText + */ + if (language !== 'java' && language !== 'python' && language !== 'plaintext') { + const unsupportedLanguage = language.charAt(0).toUpperCase() + language.slice(1) + let unsupportedMessage = `I'm sorry, but /test only supports Python and Java
While ${unsupportedLanguage} is not supported, I will generate a suggestion below. ` + // handle the case when language is undefined + if (!unsupportedLanguage) { + 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) + } else { + this.messenger.sendCapabilityCard({ tabID }) + this.messenger.sendMessage(testGenSummaryMessage(fileName), message.tabID, 'answer-part') + + // Grab the selection from the fileEditorToTest and get the vscode Range + const selection = fileEditorToTest.selection + let selectionRange = undefined + if ( + selection.start.line !== selection.end.line || + selection.start.character !== selection.end.character + ) { + selectionRange = new vscode.Range( + selection.start.line, + selection.start.character, + selection.end.line, + selection.end.character + ) + } + session.isCodeBlockSelected = selectionRange !== undefined + + /** + * Zip the project + * Create pre-signed URL and upload artifact to S3 + * send API request to startTestGeneration API + * Poll from getTestGeneration API + * Get Diff from exportResultArchive API + */ + ChatSessionManager.Instance.setIsInProgress(true) + await startTestGenerationProcess(fileName, filePath, message.prompt, tabID, true, selectionRange) + } + } catch (err: any) { + // TODO: refactor error handling to be more robust + ChatSessionManager.Instance.setIsInProgress(false) + getLogger().error('startTestGen failed: %O', err) + this.messenger.sendUpdatePromptProgress(message.tabID, cancellingProgressField) + this.sendErrorMessage({ tabID, error: err }) + this.messenger.sendChatInputEnabled(tabID, true) + this.sessionStorage.getSession().conversationState = ConversationState.WAITING_FOR_INPUT + await sleep(2000) + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(message.tabID, null) + } + } + + // Updating Progress bar + private async handleUpdatePromptProgress(data: any) { + const getProgressField = (status: string): ProgressField | null => { + switch (status) { + case 'Completed': + return testGenCompletedField + case 'Error': + return errorProgressField + case 'cancel': + return cancellingProgressField + case 'InProgress': + default: + return { + status: 'info', + text: 'Generating unit tests...', + value: data.progressRate, + valueText: data.progressRate.toString() + '%', + actions: [cancelTestGenButton], + } + } + } + this.messenger.sendUpdatePromptProgress(data.tabID, getProgressField(data.status)) + + await sleep(2000) + + // don't flash the bar when generation in progress + if (data.status !== 'InProgress') { + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(data.tabID, null) + } + } + + private async updateShortAnswer(message: { + tabID: string + status: string + shortAnswer?: ShortAnswer + testGenerationJobGroupName: string + testGenerationJobId: string + type: ChatItemType + fileName: string + }) { + this.messenger.sendShortSummary({ + type: 'answer', + tabID: message.tabID, + message: testGenSummaryMessage(message.fileName, message.shortAnswer?.planSummary?.replaceAll('```', '')), + canBeVoted: true, + filePath: message.shortAnswer?.testFilePath, + }) + } + + private async showCodeGenerationResults(data: { tabID: string; filePath: string; projectName: string }) { + const session = this.sessionStorage.getSession() + // return early if references are disabled and there are references + if (!CodeWhispererSettings.instance.isSuggestionsWithCodeReferencesEnabled() && session.references.length > 0) { + void vscode.window.showInformationMessage('Your settings do not allow code generation with references.') + await this.endSession(data, FollowUpTypes.SkipBuildAndFinish) + await this.sessionCleanUp() + return + } + const followUps: FollowUps = { + text: '', + options: [ + { + pillText: `View diff`, + type: FollowUpTypes.ViewDiff, + status: 'primary', + }, + ], + } + session.generatedFilePath = data.filePath + try { + const tempFilePath = path.join(this.tempResultDirPath, 'resultArtifacts', data.filePath) + const newContent = await fs.readFileText(tempFilePath) + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + let linesGenerated = newContent.split('\n').length + let charsGenerated = newContent.length + if (workspaceFolder) { + const projectPath = workspaceFolder.uri.fsPath + const absolutePath = path.join(projectPath, data.filePath) + const fileExists = await fs.existsFile(absolutePath) + if (fileExists) { + const originalContent = await fs.readFileText(absolutePath) + linesGenerated -= originalContent.split('\n').length + charsGenerated -= originalContent.length + } + } + session.linesOfCodeGenerated = linesGenerated > 0 ? linesGenerated : 0 + session.charsOfCodeGenerated = charsGenerated > 0 ? charsGenerated : 0 + } catch (e: any) { + getLogger().debug('failed to get chars and lines of code generated from test generation result: %O', e) + } + + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer', + codeGenerationId: '', + message: `Please see the unit tests generated below. Click “View diff” to review the changes in the code editor.`, + canBeVoted: true, + messageId: '', + followUps, + fileList: { + fileTreeTitle: 'READY FOR REVIEW', + rootFolderTitle: data.projectName, + filePaths: [data.filePath], + }, + codeReference: session.references.map( + (ref: ShortAnswerReference) => + ({ + ...ref, + information: `${ref.licenseName} - ${ref.repository}`, + }) as CodeReference + ), + }) + this.messenger.sendChatInputEnabled(data.tabID, false) + this.messenger.sendUpdatePlaceholder(data.tabID, `Select View diff to see the generated unit tests.`) + this.sessionStorage.getSession().conversationState = ConversationState.IDLE + } + + private async openDiff(message: OpenDiffMessage) { + const session = this.sessionStorage.getSession() + const filePath = session.generatedFilePath + const workspaceFolder = vscode.workspace.workspaceFolders?.[0] + if (!workspaceFolder) { + throw new Error('No workspace folder found') + } + const projectPath = workspaceFolder.uri.fsPath + const absolutePath = path.join(projectPath, filePath) + const fileExists = await fs.existsFile(absolutePath) + const leftUri = fileExists ? vscode.Uri.file(absolutePath) : vscode.Uri.from({ scheme: 'untitled' }) + const rightUri = vscode.Uri.file(path.join(this.tempResultDirPath, 'resultArtifacts', filePath)) + const fileName = path.basename(absolutePath) + await vscode.commands.executeCommand('vscode.diff', leftUri, rightUri, `${fileName} ${amazonQTabSuffix}`) + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_viewDiff' }) + session.latencyOfTestGeneration = performance.now() - session.testGenerationStartTime + this.messenger.sendUpdatePlaceholder(message.tabID, `Please select an action to proceed (Accept or Reject)`) + } + + private async acceptCode(message: any) { + const session = this.sessionStorage.getSession() + session.acceptedJobId = session.listOfTestGenerationJobId[session.listOfTestGenerationJobId.length - 1] + const filePath = session.generatedFilePath + session.fileLanguage = await this.getLanguageForFilePath(filePath) + const absolutePath = path.join(session.projectRootPath, filePath) + const fileExists = await fs.existsFile(absolutePath) + const buildCommand = session.updatedBuildCommands?.join(' ') + + const tempFilePath = path.join(this.tempResultDirPath, 'resultArtifacts', filePath) + const updatedContent = await fs.readFileText(tempFilePath) + let acceptedLines = updatedContent.split('\n').length + let acceptedChars = updatedContent.length + if (fileExists) { + const originalContent = await fs.readFileText(absolutePath) + acceptedLines -= originalContent.split('\n').length + acceptedLines = acceptedLines < 0 ? 0 : acceptedLines + acceptedChars -= originalContent.length + acceptedChars = acceptedChars < 0 ? 0 : acceptedChars + 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 + ) + } else { + await fs.writeFile(absolutePath, updatedContent) + } + session.charsOfCodeAccepted = acceptedChars + session.linesOfCodeAccepted = acceptedLines + + // add accepted references to reference log, if any + const fileName = path.basename(session.generatedFilePath) + const time = new Date().toLocaleString() + // TODO: this is duplicated in basicCommands.ts for scan (codewhisperer). Fix this later. + session.references.forEach((reference) => { + getLogger().debug('Processing reference: %O', reference) + // Log values for debugging + getLogger().debug('updatedContent: %s', updatedContent) + getLogger().debug( + 'start: %d, end: %d', + reference.recommendationContentSpan?.start, + reference.recommendationContentSpan?.end + ) + // given a start and end index, figure out which line number they belong to when splitting a string on /n characters + const getLineNumber = (content: string, index: number): number => { + const lines = content.slice(0, index).split('\n') + return lines.length + } + const startLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.start) + const endLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.end) + getLogger().debug('startLine: %d, endLine: %d', startLine, endLine) + + const code = updatedContent.slice( + reference.recommendationContentSpan?.start, + reference.recommendationContentSpan?.end + ) + getLogger().debug('Extracted code slice: %s', code) + const referenceLog = + `[${time}] Accepted recommendation ` + + CodeWhispererConstants.referenceLogText( + `
${code}
`, + reference.licenseName!, + reference.repository!, + fileName, + startLine === endLine ? `(line at ${startLine})` : `(lines from ${startLine} to ${endLine})` + ) + + '
' + getLogger().debug('Adding reference log: %s', referenceLog) + ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) + }) + + // TODO: see if there's a better way to check if active file is a diff + if (vscode.window.tabGroups.activeTabGroup.activeTab?.label.includes(amazonQTabSuffix)) { + await vscode.commands.executeCommand('workbench.action.closeActiveEditor') + } + const document = await vscode.workspace.openTextDocument(absolutePath) + await vscode.window.showTextDocument(document) + // TODO: send the message once again once build is enabled + //this.messenger.sendMessage('Accepted', message.tabID, 'prompt') + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_acceptDiff' }) + telemetry.amazonq_utgGenerateTests.emit({ + generatedCount: session.numberOfTestsGenerated, + acceptedCount: session.numberOfTestsGenerated, + generatedCharactersCount: session.charsOfCodeGenerated, + acceptedCharactersCount: session.charsOfCodeAccepted, + generatedLinesCount: session.linesOfCodeGenerated, + acceptedLinesCount: session.linesOfCodeAccepted, + cwsprChatProgrammingLanguage: session.fileLanguage, + jobId: session.listOfTestGenerationJobId[0], // For RIV, UTG does only one StartTestGeneration API call so jobId = session.listOfTestGenerationJobId[0] + jobGroup: session.testGenerationJobGroupName, + buildPayloadBytes: session.srcPayloadSize, + buildZipFileBytes: session.srcZipFileSize, + artifactsUploadDuration: session.artifactsUploadDuration, + hasUserPromptSupplied: session.hasUserPromptSupplied, + isCodeBlockSelected: session.isCodeBlockSelected, + perfClientLatency: session.latencyOfTestGeneration, + isSupportedLanguage: true, + result: 'Succeeded', + }) + + await this.endSession(message, FollowUpTypes.SkipBuildAndFinish) + await this.sessionCleanUp() + return + + if (session.listOfTestGenerationJobId.length === 1) { + this.startInitialBuild(message) + this.messenger.sendChatInputEnabled(message.tabID, false) + } else if (session.listOfTestGenerationJobId.length < 4) { + const remainingIterations = 4 - session.listOfTestGenerationJobId.length + + let userMessage = 'Would you like Amazon Q to build and execute again, and fix errors?' + if (buildCommand) { + userMessage += ` I will be running this build command: \`${buildCommand}\`` + } + userMessage += `\nYou have ${remainingIterations} iteration${remainingIterations > 1 ? 's' : ''} left.` + + const followUps: FollowUps = { + text: '', + options: [ + { + pillText: `Rebuild`, + type: FollowUpTypes.ContinueBuildAndExecute, + status: 'primary', + }, + { + pillText: `Skip and finish`, + type: FollowUpTypes.SkipBuildAndFinish, + status: 'primary', + }, + ], + } + this.messenger.sendBuildProgressMessage({ + tabID: message.tabID, + messageType: 'answer', + codeGenerationId: '', + message: userMessage, + canBeVoted: false, + messageId: '', + followUps: followUps, + }) + this.messenger.sendChatInputEnabled(message.tabID, false) + } else { + this.sessionStorage.getSession().listOfTestGenerationJobId = [] + this.messenger.sendMessage( + 'You have gone through both iterations and this unit test generation workflow is complete.', + message.tabID, + 'answer' + ) + await this.sessionCleanUp() + } + await fs.delete(this.tempResultDirPath, { recursive: true }) + } + + /** + * Handle a regular incoming message when a user is in the code generation phase + */ + private async onCodeGeneration( + session: Session, + message: string, + tabID: string, + fileName: string, + filePath: string + ) { + 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}`, + codeSelection: undefined, + trigger: ChatTriggerType.ChatMessage, + fileText: editorText, + fileLanguage: session.fileLanguage, + filePath: filePath, + message: `Generate unit tests for the following part of my code: ${message}`, + matchPolicy: undefined, + codeQuery: undefined, + userIntent: UserIntent.GENERATE_UNIT_TESTS, + customization: getSelectedCustomization(), + } + const chatRequest = triggerPayloadToChatRequest(triggerPayload) + const client = await createCodeWhispererChatStreamingClient() + const response = await client.generateAssistantResponse(chatRequest) + await this.messenger.sendAIResponse( + response, + session, + tabID, + randomUUID.toString(), + triggerPayload, + fileName + ) + } finally { + this.messenger.sendChatInputEnabled(tabID, true) + this.messenger.sendUpdatePlaceholder(tabID, `/test Generate unit tests...`) + this.sessionStorage.getSession().conversationState = ConversationState.WAITING_FOR_INPUT + } + } + + //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) { + const session = this.sessionStorage.getSession() + if (step === FollowUpTypes.RejectCode) { + telemetry.amazonq_utgGenerateTests.emit({ + generatedCount: session.numberOfTestsGenerated, + acceptedCount: 0, + generatedCharactersCount: session.charsOfCodeGenerated, + acceptedCharactersCount: 0, + generatedLinesCount: session.linesOfCodeGenerated, + acceptedLinesCount: 0, + cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', + jobId: session.listOfTestGenerationJobId[0], // For RIV, UTG does only one StartTestGeneration API call so jobId = session.listOfTestGenerationJobId[0] + jobGroup: session.testGenerationJobGroupName, + buildPayloadBytes: session.srcPayloadSize, + buildZipFileBytes: session.srcZipFileSize, + artifactsUploadDuration: session.artifactsUploadDuration, + hasUserPromptSupplied: session.hasUserPromptSupplied, + isCodeBlockSelected: session.isCodeBlockSelected, + perfClientLatency: session.latencyOfTestGeneration, + isSupportedLanguage: true, + result: 'Succeeded', + }) + 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.sendChatInputEnabled(data.tabID, true) + return + } + + /** + * BUILD LOOP IMPLEMENTATION + */ + + private startInitialBuild(data: any) { + //TODO: Remove the fallback build command after stable version of backend build command. + const userMessage = `Would you like me to help build and execute the test? I will need you to let me know what build command to run if you do.` + const followUps: FollowUps = { + text: '', + options: [ + { + pillText: `Specify command then build and execute`, + type: FollowUpTypes.ModifyCommands, + status: 'primary', + }, + { + pillText: `Skip and finish`, + type: FollowUpTypes.SkipBuildAndFinish, + status: 'primary', + }, + ], + } + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer', + codeGenerationId: '', + message: userMessage, + canBeVoted: false, + messageId: '', + followUps: followUps, + }) + this.messenger.sendChatInputEnabled(data.tabID, false) + } + + private async checkForInstallationDependencies(data: any) { + // const session: Session = this.sessionStorage.getSession() + // const listOfInstallationDependencies = session.testGenerationJob?.shortAnswer?.installationDependencies || [] + //MOCK: As there is no installation dependencies in shortAnswer + const listOfInstallationDependencies = [''] + const installationDependencies = listOfInstallationDependencies.join('\n') + + this.messenger.sendMessage('Build and execute', data.tabID, 'prompt') + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_buildAndExecute' }) + + if (installationDependencies.length > 0) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer', + codeGenerationId: '', + message: `Looks like you don’t have ${listOfInstallationDependencies.length > 1 ? `these` : `this`} ${listOfInstallationDependencies.length} required package${listOfInstallationDependencies.length > 1 ? `s` : ``} installed.\n\`\`\`sh\n${installationDependencies}\n`, + canBeVoted: false, + messageId: '', + followUps: { + text: '', + options: [ + { + pillText: `Install and continue`, + type: FollowUpTypes.InstallDependenciesAndContinue, + status: 'primary', + }, + { + pillText: `Skip and finish`, + type: FollowUpTypes.SkipBuildAndFinish, + status: 'primary', + }, + ], + }, + }) + } else { + await this.startLocalBuildExecution(data) + } + } + + private async handleInstallDependencies(data: any) { + this.messenger.sendMessage('Installation dependencies and continue', data.tabID, 'prompt') + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_installDependenciesAndContinue' }) + void this.startLocalBuildExecution(data) + } + + private async handleBuildIteration(data: any) { + this.messenger.sendMessage('Proceed with Iteration', data.tabID, 'prompt') + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_proceedWithIteration' }) + await this.startLocalBuildExecution(data) + } + + private async startLocalBuildExecution(data: any) { + const session: Session = this.sessionStorage.getSession() + // const installationDependencies = session.shortAnswer?.installationDependencies ?? [] + //MOCK: ignoring the installation case until backend send response + const installationDependencies: string[] = [] + const buildCommands = session.updatedBuildCommands + if (!buildCommands) { + throw new Error('Build command not found') + return + } + + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.START_STEP), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + + this.messenger.sendUpdatePromptProgress(data.tabID, buildProgressField) + + if (installationDependencies.length > 0 && session.listOfTestGenerationJobId.length < 2) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.INSTALL_DEPENDENCIES, 'current'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + + const status = await runBuildCommand(installationDependencies) + //TODO: Add separate status for installation dependencies + session.buildStatus = status + if (status === BuildStatus.FAILURE) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.INSTALL_DEPENDENCIES, 'error'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + } + if (status === BuildStatus.CANCELLED) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.INSTALL_DEPENDENCIES, 'error'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + this.messenger.sendMessage('Installation dependencies Cancelled', data.tabID, 'prompt') + this.messenger.sendMessage( + 'Unit test generation workflow is complete. You have 25 out of 30 Amazon Q Developer Agent invocations left this month.', + data.tabID, + 'answer' + ) + return + } + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.INSTALL_DEPENDENCIES, 'done'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + } + + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.RUN_BUILD, 'current'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + + const buildStatus = await runBuildCommand(buildCommands) + session.buildStatus = buildStatus + + if (buildStatus === BuildStatus.FAILURE) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.RUN_BUILD, 'error'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + } else if (buildStatus === BuildStatus.CANCELLED) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.RUN_BUILD, 'error'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + this.messenger.sendMessage('Build Cancelled', data.tabID, 'prompt') + this.messenger.sendMessage('Unit test generation workflow is complete.', data.tabID, 'answer') + return + } else { + // Build successful + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.RUN_BUILD, 'done'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + } + + // Running execution tests + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.RUN_EXECUTION_TESTS, 'current'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + // After running tests + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.RUN_EXECUTION_TESTS, 'done'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + if (session.buildStatus !== BuildStatus.SUCCESS) { + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.FIXING_TEST_CASES, 'current'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + await startTestGenerationProcess( + path.basename(session.sourceFilePath), + session.sourceFilePath, + '', + data.tabID, + false + ) + } + //TODO: Skip this if startTestGenerationProcess timeouts + if (session.generatedFilePath) { + await this.showTestCaseSummary(data) + } + } + + private async showTestCaseSummary(data: { tabID: string }) { + const session: Session = this.sessionStorage.getSession() + let codeDiffLength = 0 + if (session.buildStatus !== BuildStatus.SUCCESS) { + // Check the generated test file content, if fileContent length is 0, exit the unit test generation workflow. + const tempFilePath = path.join(this.tempResultDirPath, 'resultArtifacts', session.generatedFilePath) + const codeDiffFileContent = await fs.readFileText(tempFilePath) + codeDiffLength = codeDiffFileContent.length + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.FIXING_TEST_CASES + 1, 'done'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + } + + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.PROCESS_TEST_RESULTS, 'current'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.PROCESS_TEST_RESULTS, 'done'), + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + }) + + const followUps: FollowUps = { + text: '', + options: [ + { + pillText: `View diff`, + type: FollowUpTypes.ViewCodeDiffAfterIteration, + status: 'primary', + }, + ], + } + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer-part', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: testGenBuildProgressMessage(TestGenerationBuildStep.PROCESS_TEST_RESULTS + 1), + canBeVoted: true, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + followUps: undefined, + fileList: this.checkCodeDiffLengthAndBuildStatus({ codeDiffLength, buildStatus: session.buildStatus }) + ? { + fileTreeTitle: 'READY FOR REVIEW', + rootFolderTitle: 'tests', + filePaths: [session.generatedFilePath], + } + : undefined, + }) + this.messenger.sendBuildProgressMessage({ + tabID: data.tabID, + messageType: 'answer', + codeGenerationId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + message: undefined, + canBeVoted: false, + messageId: TestNamedMessages.TEST_GENERATION_BUILD_STATUS_MESSAGE, + followUps: this.checkCodeDiffLengthAndBuildStatus({ codeDiffLength, buildStatus: session.buildStatus }) + ? followUps + : undefined, + fileList: undefined, + }) + + this.messenger.sendUpdatePromptProgress(data.tabID, testGenCompletedField) + await sleep(2000) + // eslint-disable-next-line unicorn/no-null + this.messenger.sendUpdatePromptProgress(data.tabID, null) + this.messenger.sendChatInputEnabled(data.tabID, false) + + if (codeDiffLength === 0 || session.buildStatus === BuildStatus.SUCCESS) { + this.messenger.sendMessage('Unit test generation workflow is complete.', data.tabID, 'answer') + await this.sessionCleanUp() + } + } + + private modifyBuildCommand(data: any) { + this.sessionStorage.getSession().conversationState = ConversationState.WAITING_FOR_BUILD_COMMMAND_INPUT + this.messenger.sendMessage('Specify commands then build', data.tabID, 'prompt') + telemetry.ui_click.emit({ elementId: 'unitTestGeneration_modifyCommand' }) + this.messenger.sendMessage( + 'Sure, provide all command lines you’d like me to run to build.', + data.tabID, + 'answer' + ) + this.messenger.sendUpdatePlaceholder(data.tabID, 'Waiting on your Inputs') + this.messenger.sendChatInputEnabled(data.tabID, true) + } + + /** Perform Session CleanUp in below cases + * UTG success + * End Session with Reject or SkipAndFinish + * After finishing 3 build loop iterations + * Error while generating unit tests + * Closing a Q-Test tab + * Progress bar cancel + */ + private async sessionCleanUp() { + const session = this.sessionStorage.getSession() + const groupName = session.testGenerationJobGroupName + const filePath = session.generatedFilePath + getLogger().debug('Entering sessionCleanUp function with filePath: %s and groupName: %s', filePath, groupName) + + vscode.window.tabGroups.all.flatMap(({ tabs }) => + tabs.map((tab) => { + if (tab.label === `${path.basename(filePath)} ${amazonQTabSuffix}`) { + const tabClosed = vscode.window.tabGroups.close(tab) + if (!tabClosed) { + getLogger().error('ChatDiff: Unable to close the diff view tab for %s', tab.label) + } + } + }) + ) + + getLogger().debug( + 'listOfTestGenerationJobId length: %d, groupName: %s', + session.listOfTestGenerationJobId.length, + groupName + ) + if (session.listOfTestGenerationJobId.length && groupName) { + session.listOfTestGenerationJobId.forEach((id) => { + if (id === session.acceptedJobId) { + TelemetryHelper.instance.sendTestGenerationEvent( + groupName, + id, + session.fileLanguage, + session.numberOfTestsGenerated, + session.numberOfTestsGenerated, // this is number of accepted test cases, now they can only accept all + session.linesOfCodeGenerated, + session.linesOfCodeAccepted, + session.charsOfCodeGenerated, + session.charsOfCodeAccepted + ) + } else { + TelemetryHelper.instance.sendTestGenerationEvent( + groupName, + id, + session.fileLanguage, + session.numberOfTestsGenerated, + 0, + session.linesOfCodeGenerated, + 0, + session.charsOfCodeGenerated, + 0 + ) + } + }) + } + session.listOfTestGenerationJobId = [] + session.testGenerationJobGroupName = undefined + session.testGenerationJob = undefined + session.updatedBuildCommands = undefined + session.shortAnswer = undefined + session.testCoveragePercentage = 0 + session.conversationState = ConversationState.IDLE + session.sourceFilePath = '' + session.generatedFilePath = '' + session.projectRootPath = '' + session.stopIteration = false + session.fileLanguage = undefined + ChatSessionManager.Instance.setIsInProgress(false) + session.linesOfCodeGenerated = 0 + session.linesOfCodeAccepted = 0 + session.charsOfCodeGenerated = 0 + session.charsOfCodeAccepted = 0 + session.acceptedJobId = '' + session.numberOfTestsGenerated = 0 + if (session.tabID) { + getLogger().debug('Setting input state with tabID: %s', session.tabID) + this.messenger.sendChatInputEnabled(session.tabID, true) + this.messenger.sendUpdatePlaceholder(session.tabID, '/test Generate unit tests') //TODO: Change according to the UX + } + getLogger().debug( + '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 }) + } + + // TODO: return build command when product approves + // private getBuildCommands = (): string[] => { + // const session = this.sessionStorage.getSession() + // if (session.updatedBuildCommands?.length) { + // return [...session.updatedBuildCommands] + // } + + // // For Internal amazon users only + // if (Auth.instance.isInternalAmazonUser()) { + // return ['brazil-build release'] + // } + + // if (session.shortAnswer && Array.isArray(session.shortAnswer?.buildCommands)) { + // return [...session.shortAnswer.buildCommands] + // } + + // return ['source qdev-wbr/.venv/bin/activate && pytest --continue-on-collection-errors'] + // } +} diff --git a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts new file mode 100644 index 00000000000..8636740fa2a --- /dev/null +++ b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts @@ -0,0 +1,336 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * This class controls the presentation of the various chat bubbles presented by the + * Q Test. + * + * As much as possible, all strings used in the experience should originate here. + */ + +import { AuthFollowUpType, AuthMessageDataMap } from '../../../../amazonq/auth/model' +import { FeatureAuthState } from '../../../../codewhisperer/util/authUtil' +import { + AppToWebViewMessageDispatcher, + AuthNeededException, + AuthenticationUpdateMessage, + BuildProgressMessage, + CapabilityCardMessage, + ChatInputEnabledMessage, + ChatMessage, + ChatSummaryMessage, + ErrorMessage, + UpdatePlaceholderMessage, + UpdatePromptProgressMessage, +} from '../../views/connector/connector' +import { ChatItemType } from '../../../../amazonq/commons/model' +import { ChatItemAction, ProgressField } from '@aws/mynah-ui' +import * as CodeWhispererConstants from '../../../../codewhisperer/models/constants' +import { TriggerPayload } from '../../../../codewhispererChat/controllers/chat/model' +import { + CodeWhispererStreamingServiceException, + GenerateAssistantResponseCommandOutput, +} from '@amzn/codewhisperer-streaming' +import { Session } from '../../session/session' +import { CodeReference } from '../../../../amazonq/webview/ui/apps/amazonqCommonsConnector' +import { getHttpStatusCode, getRequestId, getTelemetryReasonDesc, ToolkitError } from '../../../../shared/errors' +import { sleep, waitUntil } from '../../../../shared/utilities/timeoutUtils' +import { keys } from '../../../../shared/utilities/tsUtils' +import { testGenState } from '../../../../codewhisperer' +import { cancellingProgressField, testGenCompletedField } from '../../../models/constants' +import { telemetry } from '../../../../shared/telemetry/telemetry' + +export type UnrecoverableErrorType = 'no-project-found' | 'no-open-file-found' | 'invalid-file-type' + +export enum TestNamedMessages { + TEST_GENERATION_BUILD_STATUS_MESSAGE = 'testGenerationBuildStatusMessage', +} + +export interface FollowUps { + text?: string + options?: ChatItemAction[] +} + +export interface FileList { + fileTreeTitle?: string + rootFolderTitle?: string + filePaths?: string[] +} + +export interface SendBuildProgressMessageParams { + tabID: string + messageType: ChatItemType + codeGenerationId: string + message?: string + canBeVoted: boolean + messageId?: string + followUps?: FollowUps + fileList?: FileList + codeReference?: CodeReference[] +} + +export class Messenger { + public constructor(private readonly dispatcher: AppToWebViewMessageDispatcher) {} + + public sendCapabilityCard(params: { tabID: string }) { + this.dispatcher.sendChatMessage(new CapabilityCardMessage(params.tabID)) + } + + public sendMessage(message: string, tabID: string, messageType: ChatItemType) { + this.dispatcher.sendChatMessage(new ChatMessage({ message, messageType }, tabID)) + } + + public sendShortSummary(params: { + message?: string + type: ChatItemType + tabID: string + messageID?: string + canBeVoted?: boolean + filePath?: string + }) { + this.dispatcher.sendChatSummaryMessage( + new ChatSummaryMessage( + { + message: params.message, + messageType: params.type, + messageId: params.messageID, + canBeVoted: params.canBeVoted, + filePath: params.filePath, + }, + params.tabID + ) + ) + } + + public sendChatInputEnabled(tabID: string, enabled: boolean) { + this.dispatcher.sendChatInputEnabled(new ChatInputEnabledMessage(tabID, enabled)) + } + + public sendUpdatePlaceholder(tabID: string, newPlaceholder: string) { + this.dispatcher.sendUpdatePlaceholder(new UpdatePlaceholderMessage(tabID, newPlaceholder)) + } + + public sendUpdatePromptProgress(tabID: string, progressField: ProgressField | null) { + this.dispatcher.sendUpdatePromptProgress(new UpdatePromptProgressMessage(tabID, progressField)) + } + + public async sendAuthNeededExceptionMessage(credentialState: FeatureAuthState, tabID: string) { + let authType: AuthFollowUpType = 'full-auth' + let message = AuthMessageDataMap[authType].message + + switch (credentialState.amazonQ) { + case 'disconnected': + authType = 'full-auth' + message = AuthMessageDataMap[authType].message + break + case 'unsupported': + authType = 'use-supported-auth' + message = AuthMessageDataMap[authType].message + break + case 'expired': + authType = 're-auth' + message = AuthMessageDataMap[authType].message + break + } + + this.dispatcher.sendAuthNeededExceptionMessage(new AuthNeededException(message, authType, tabID)) + } + + public sendAuthenticationUpdate(testEnabled: boolean, authenticatingTabIDs: string[]) { + this.dispatcher.sendAuthenticationUpdate(new AuthenticationUpdateMessage(testEnabled, authenticatingTabIDs)) + } + + /** + * This method renders an error message with a button at the end that will try the + * transformation again from the beginning. This message is meant for errors that are + * completely unrecoverable: the job cannot be completed in its current state, + * and the flow must be tried again. + */ + public sendUnrecoverableErrorResponse(type: UnrecoverableErrorType, tabID: string) { + let message = '...' + switch (type) { + case 'no-project-found': + message = CodeWhispererConstants.noOpenProjectsFoundChatTestGenMessage + break + case 'no-open-file-found': + message = CodeWhispererConstants.noOpenFileFoundChatMessage + break + case 'invalid-file-type': + message = CodeWhispererConstants.invalidFileTypeChatMessage + break + } + + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message, + messageType: 'answer-stream', + }, + tabID + ) + ) + } + + public sendErrorMessage(errorMessage: string, tabID: string) { + this.dispatcher.sendErrorMessage( + new ErrorMessage(CodeWhispererConstants.genericErrorMessage, errorMessage, tabID) + ) + } + + //To show the response of unsupported languages to the user in the Q-Test tab + public async sendAIResponse( + response: GenerateAssistantResponseCommandOutput, + session: Session, + tabID: string, + triggerID: string, + triggerPayload: TriggerPayload, + fileName: string + ) { + let message = '' + const messageId = response.$metadata.requestId ?? '' + let codeReference: CodeReference[] = [] + + if (response.generateAssistantResponseResponse === undefined) { + throw new ToolkitError( + `Empty response from Q Developer service. Request ID: ${response.$metadata.requestId}` + ) + } + + const eventCounts = new Map() + waitUntil( + async () => { + for await (const chatEvent of response.generateAssistantResponseResponse!) { + for (const key of keys(chatEvent)) { + if ((chatEvent[key] as any) !== undefined) { + eventCounts.set(key, (eventCounts.get(key) ?? 0) + 1) + } + } + + if ( + chatEvent.codeReferenceEvent?.references !== undefined && + chatEvent.codeReferenceEvent.references.length > 0 + ) { + codeReference = [ + ...codeReference, + ...chatEvent.codeReferenceEvent.references.map((reference) => ({ + ...reference, + recommendationContentSpan: { + start: reference.recommendationContentSpan?.start ?? 0, + end: reference.recommendationContentSpan?.end ?? 0, + }, + information: `Reference code under **${reference.licenseName}** license from repository \`${reference.repository}\``, + })), + ] + } + if (testGenState.isCancelling()) { + return true + } + if ( + chatEvent.assistantResponseEvent?.content !== undefined && + chatEvent.assistantResponseEvent.content.length > 0 + ) { + message += chatEvent.assistantResponseEvent.content + this.dispatcher.sendBuildProgressMessage( + new BuildProgressMessage({ + tabID, + messageType: 'answer-part', + codeGenerationId: '', + message, + canBeVoted: false, + messageId, + followUps: undefined, + fileList: undefined, + }) + ) + } + } + return true + }, + { timeout: 60000, truthy: true } + ) + .catch((error: any) => { + let errorMessage = 'Error reading chat stream.' + let statusCode = undefined + let requestID = undefined + if (error instanceof CodeWhispererStreamingServiceException) { + errorMessage = error.message + statusCode = getHttpStatusCode(error) ?? 0 + requestID = getRequestId(error) + } + let message = 'This error is reported to the team automatically. Please try sending your message again.' + if (errorMessage !== undefined) { + message += `\n\nDetails: ${errorMessage}` + } + + if (statusCode !== undefined) { + message += `\n\nStatus Code: ${statusCode}` + } + + if (requestID !== undefined) { + message += `\n\nRequest ID: ${requestID}` + } + this.sendMessage(message.trim(), tabID, 'answer') + }) + .finally(async () => { + if (testGenState.isCancelling()) { + this.sendMessage(CodeWhispererConstants.unitTestGenerationCancelMessage, tabID, 'answer') + telemetry.amazonq_utgGenerateTests.emit({ + cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', + hasUserPromptSupplied: session.hasUserPromptSupplied, + perfClientLatency: performance.now() - session.testGenerationStartTime, + result: 'Cancelled', + reasonDesc: getTelemetryReasonDesc(CodeWhispererConstants.unitTestGenerationCancelMessage), + isSupportedLanguage: false, + }) + + this.dispatcher.sendUpdatePromptProgress( + new UpdatePromptProgressMessage(tabID, cancellingProgressField) + ) + await sleep(500) + } else { + telemetry.amazonq_utgGenerateTests.emit({ + cwsprChatProgrammingLanguage: session.fileLanguage ?? 'plaintext', + hasUserPromptSupplied: session.hasUserPromptSupplied, + perfClientLatency: performance.now() - session.testGenerationStartTime, + result: 'Succeeded', + isSupportedLanguage: false, + }) + this.dispatcher.sendUpdatePromptProgress( + new UpdatePromptProgressMessage(tabID, testGenCompletedField) + ) + await sleep(500) + } + testGenState.setToNotStarted() + // eslint-disable-next-line unicorn/no-null + this.dispatcher.sendUpdatePromptProgress(new UpdatePromptProgressMessage(tabID, null)) + }) + } + + //To show the Build progress in the chat + public sendBuildProgressMessage(params: SendBuildProgressMessageParams) { + const { + tabID, + messageType, + codeGenerationId, + message, + canBeVoted, + messageId, + followUps, + fileList, + codeReference, + } = params + this.dispatcher.sendBuildProgressMessage( + new BuildProgressMessage({ + tabID, + messageType, + codeGenerationId, + message, + canBeVoted, + messageId, + followUps, + fileList, + codeReference, + }) + ) + } +} diff --git a/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts b/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts new file mode 100644 index 00000000000..647951daef8 --- /dev/null +++ b/packages/core/src/amazonqTest/chat/controller/messenger/messengerUtils.ts @@ -0,0 +1,30 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +// These enums map to string IDs +export enum ButtonActions { + ACCEPT = 'Accept', + MODIFY = 'Modify', + REJECT = 'Reject', + VIEW_DIFF = 'View-Diff', + STOP_TEST_GEN = 'Stop-Test-Generation', + STOP_BUILD = 'Stop-Build-Process', +} + +//TODO: Refactor the common functionality between Transform, FeatureDev, CWSPRChat, Scan and UTG to a new Folder. + +export default class MessengerUtils { + static stringToEnumValue = ( + enumObject: T, + value: `${T[K]}` + ): T[K] => { + if (Object.values(enumObject).includes(value)) { + return value as unknown as T[K] + } else { + throw new Error('Value provided was not found in Enum') + } + } +} diff --git a/packages/core/src/amazonqTest/chat/session/session.ts b/packages/core/src/amazonqTest/chat/session/session.ts new file mode 100644 index 00000000000..cd188c10c0f --- /dev/null +++ b/packages/core/src/amazonqTest/chat/session/session.ts @@ -0,0 +1,73 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ShortAnswer, ShortAnswerReference } from '../../../codewhisperer' +import { TestGenerationJob } from '../../../codewhisperer/client/codewhispereruserclient' + +export enum ConversationState { + IDLE, + JOB_SUBMITTED, + WAITING_FOR_INPUT, + WAITING_FOR_BUILD_COMMMAND_INPUT, + WAITING_FOR_REGENERATE_INPUT, + IN_PROGRESS, +} + +export enum BuildStatus { + SUCCESS, + FAILURE, + CANCELLED, +} + +export class Session { + // Used to keep track of whether or not the current session is currently authenticating/needs authenticating + public isAuthenticating: boolean = false + + // A tab may or may not be currently open + public tabID: string | undefined + + //This is unique per each test generation cycle + public testGenerationJobGroupName: string | undefined = undefined + public listOfTestGenerationJobId: string[] = [] + public testGenerationJob: TestGenerationJob | undefined + + // Start Test generation + public conversationState: ConversationState = ConversationState.IDLE + public shortAnswer: ShortAnswer | undefined + public sourceFilePath: string = '' + public generatedFilePath: string = '' + public projectRootPath: string = '' + public fileLanguage: string | undefined = 'plaintext' + public stopIteration: boolean = false + + // Telemetry + public testGenerationStartTime: number = 0 + public hasUserPromptSupplied: boolean = false + public isCodeBlockSelected: boolean = false + public srcPayloadSize: number = 0 + public srcZipFileSize: number = 0 + public artifactsUploadDuration: number = 0 + public numberOfTestsGenerated: number = 0 + public linesOfCodeGenerated: number = 0 + public linesOfCodeAccepted: number = 0 + public charsOfCodeGenerated: number = 0 + public charsOfCodeAccepted: number = 0 + public latencyOfTestGeneration: number = 0 + + //TODO: Take values from ShortAnswer or TestGenerationJob + //Build loop + public buildStatus: BuildStatus = BuildStatus.SUCCESS + public updatedBuildCommands: string[] | undefined = undefined + public testCoveragePercentage: number = 90 + public isInProgress: boolean = false + public acceptedJobId = '' + public references: ShortAnswerReference[] = [] + + constructor() {} + + public isTabOpen(): boolean { + return this.tabID !== undefined + } +} diff --git a/packages/core/src/amazonqTest/chat/storages/chatSession.ts b/packages/core/src/amazonqTest/chat/storages/chatSession.ts new file mode 100644 index 00000000000..99ef0a3b12e --- /dev/null +++ b/packages/core/src/amazonqTest/chat/storages/chatSession.ts @@ -0,0 +1,61 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import { Session } from '../session/session' +import { getLogger } from '../../../shared/logger' + +export class SessionNotFoundError extends Error {} + +export class ChatSessionManager { + private static _instance: ChatSessionManager + private activeSession: Session | undefined + private isInProgress: boolean = false + + constructor() {} + + public static get Instance() { + return this._instance || (this._instance = new this()) + } + + private createSession(): Session { + this.activeSession = new Session() + return this.activeSession + } + + public getSession(): Session { + if (this.activeSession === undefined) { + return this.createSession() + } + + return this.activeSession + } + + public getIsInProgress(): boolean { + return this.isInProgress + } + + public setIsInProgress(value: boolean): void { + this.isInProgress = value + } + + public setActiveTab(tabID: string): string { + getLogger().debug(`Setting active tab: ${tabID}, activeSession: ${this.activeSession}`) + if (this.activeSession !== undefined) { + this.activeSession.tabID = tabID + return tabID + } + + throw new SessionNotFoundError() + } + + public removeActiveTab(): void { + getLogger().debug(`Removing active tab and deleting activeSession: ${this.activeSession}`) + if (this.activeSession !== undefined) { + this.activeSession.tabID = undefined + this.activeSession = undefined + } + } +} diff --git a/packages/core/src/amazonqTest/chat/views/actions/uiMessageListener.ts b/packages/core/src/amazonqTest/chat/views/actions/uiMessageListener.ts new file mode 100644 index 00000000000..967672105be --- /dev/null +++ b/packages/core/src/amazonqTest/chat/views/actions/uiMessageListener.ts @@ -0,0 +1,143 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MessageListener } from '../../../../amazonq/messages/messageListener' +import { ExtensionMessage } from '../../../../amazonq/webview/ui/commands' +import { TestChatControllerEventEmitters } from '../../controller/controller' + +type UIMessage = ExtensionMessage & { + tabID?: string +} + +export interface UIMessageListenerProps { + readonly chatControllerEventEmitters: TestChatControllerEventEmitters + readonly webViewMessageListener: MessageListener +} + +export class UIMessageListener { + private testControllerEventsEmitters: TestChatControllerEventEmitters | undefined + private webViewMessageListener: MessageListener + + constructor(props: UIMessageListenerProps) { + this.testControllerEventsEmitters = props.chatControllerEventEmitters + this.webViewMessageListener = props.webViewMessageListener + + // Now we are listening to events that get sent from amazonq/webview/actions/actionListener (e.g. the tab) + this.webViewMessageListener.onMessage((msg) => { + this.handleMessage(msg) + }) + } + + private handleMessage(msg: ExtensionMessage) { + switch (msg.command) { + case 'new-tab-was-created': + this.tabOpened(msg) + break + case 'tab-was-removed': + this.tabClosed(msg) + break + case 'auth-follow-up-was-clicked': + this.authClicked(msg) + break + case 'start-test-gen': + this.startTestGen(msg) + break + case 'chat-prompt': + this.processChatPrompt(msg) + break + case 'form-action-click': + this.formActionClicked(msg) + break + case 'follow-up-was-clicked': + this.followUpClicked(msg) + break + case 'open-diff': + this.openDiff(msg) + break + case 'insert_code_at_cursor_position': + this.insertCodeAtCursorPosition(msg) + break + case 'response-body-link-click': + this.processResponseBodyLinkClick(msg) + break + } + } + + private tabOpened(msg: UIMessage) { + this.testControllerEventsEmitters?.tabOpened.fire({ + tabID: msg.tabID, + }) + } + + private tabClosed(msg: UIMessage) { + this.testControllerEventsEmitters?.tabClosed.fire({ + tabID: msg.tabID, + }) + } + + private authClicked(msg: UIMessage) { + this.testControllerEventsEmitters?.authClicked.fire({ + tabID: msg.tabID, + authType: msg.authType, + }) + } + + private startTestGen(msg: UIMessage) { + this.testControllerEventsEmitters?.startTestGen.fire({ + tabID: msg.tabID, + prompt: msg.prompt, + }) + } + + // Takes user input from chat input box. + private processChatPrompt(msg: UIMessage) { + this.testControllerEventsEmitters?.processHumanChatMessage.fire({ + prompt: msg.chatMessage, + tabID: msg.tabID, + }) + } + + private formActionClicked(msg: UIMessage) { + this.testControllerEventsEmitters?.formActionClicked.fire({ + ...msg, + }) + } + + private followUpClicked(msg: any) { + this.testControllerEventsEmitters?.followUpClicked.fire({ + followUp: msg.followUp, + tabID: msg.tabID, + }) + } + + private openDiff(msg: any) { + this.testControllerEventsEmitters?.openDiff.fire({ + tabID: msg.tabID, + filePath: msg.filePath, + deleted: msg.deleted, + messageId: msg.messageId, + }) + } + + private insertCodeAtCursorPosition(msg: any) { + this.testControllerEventsEmitters?.insertCodeAtCursorPosition.fire({ + command: msg.command, + messageId: msg.messageId, + tabID: msg.tabID, + code: msg.code, + insertionTargetType: msg.insertionTargetType, + codeReference: msg.codeReference, + }) + } + + private processResponseBodyLinkClick(msg: UIMessage) { + this.testControllerEventsEmitters?.processResponseBodyLinkClick.fire({ + command: msg.command, + messageId: msg.messageId, + tabID: msg.tabID, + link: msg.link, + }) + } +} diff --git a/packages/core/src/amazonqTest/chat/views/connector/connector.ts b/packages/core/src/amazonqTest/chat/views/connector/connector.ts new file mode 100644 index 00000000000..bc0c61c5390 --- /dev/null +++ b/packages/core/src/amazonqTest/chat/views/connector/connector.ts @@ -0,0 +1,254 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AuthFollowUpType } from '../../../../amazonq/auth/model' +import { MessagePublisher } from '../../../../amazonq/messages/messagePublisher' +import { ChatItemAction, ChatItemButton, ProgressField, ChatItemContent } from '@aws/mynah-ui/dist/static' +import { ChatItemType } from '../../../../amazonq/commons/model' +import { testChat } from '../../../models/constants' +import { MynahIcons } from '@aws/mynah-ui' +import { SendBuildProgressMessageParams } from '../../controller/messenger/messenger' +import { CodeReference } from '../../../../codewhispererChat/view/connector/connector' + +class UiMessage { + readonly time: number = Date.now() + readonly sender: string = testChat + readonly type: TestMessageType = 'chatMessage' + readonly status: string = 'info' + + public constructor(protected tabID: string) {} +} + +export type TestMessageType = + | 'authenticationUpdateMessage' + | 'authNeededException' + | 'chatMessage' + | 'chatInputEnabledMessage' + | 'updatePlaceholderMessage' + | 'errorMessage' + | 'updatePromptProgress' + | 'chatSummaryMessage' + | 'buildProgressMessage' + +export class AuthenticationUpdateMessage { + readonly time: number = Date.now() + readonly sender: string = testChat + readonly type: TestMessageType = 'authenticationUpdateMessage' + + constructor( + readonly testEnabled: boolean, + readonly authenticatingTabIDs: string[] + ) {} +} + +export class UpdatePromptProgressMessage extends UiMessage { + readonly progressField: ProgressField | null + override type: TestMessageType = 'updatePromptProgress' + constructor(tabID: string, progressField: ProgressField | null) { + super(tabID) + this.progressField = progressField + } +} + +export class AuthNeededException extends UiMessage { + override type: TestMessageType = 'authNeededException' + + constructor( + readonly message: string, + readonly authType: AuthFollowUpType, + tabID: string + ) { + super(tabID) + } +} + +export interface ChatMessageProps { + readonly message: string | undefined + readonly messageId?: string | undefined + readonly messageType: ChatItemType + readonly buttons?: ChatItemButton[] + readonly followUps?: ChatItemAction[] + readonly canBeVoted?: boolean + readonly filePath?: string + readonly informationCard?: ChatItemContent['informationCard'] +} + +export class ChatMessage extends UiMessage { + readonly message: string | undefined + readonly messageId?: string | undefined + readonly messageType: ChatItemType + readonly canBeVoted?: boolean + readonly informationCard: ChatItemContent['informationCard'] + override type: TestMessageType = 'chatMessage' + + constructor(props: ChatMessageProps, tabID: string) { + super(tabID) + this.message = props.message + this.messageType = props.messageType + this.messageId = props.messageId || undefined + this.canBeVoted = props.canBeVoted || undefined + this.informationCard = props.informationCard || undefined + } +} + +export class ChatSummaryMessage extends UiMessage { + readonly message: string | undefined + readonly messageId?: string | undefined + readonly messageType: ChatItemType + readonly buttons: ChatItemButton[] + readonly canBeVoted?: boolean + readonly filePath?: string + override type: TestMessageType = 'chatSummaryMessage' + + constructor(props: ChatMessageProps, tabID: string) { + super(tabID) + this.message = props.message + this.messageType = props.messageType + this.buttons = props.buttons || [] + this.messageId = props.messageId || undefined + this.canBeVoted = props.canBeVoted + this.filePath = props.filePath + } +} + +export class ChatInputEnabledMessage extends UiMessage { + override type: TestMessageType = 'chatInputEnabledMessage' + + constructor( + tabID: string, + readonly enabled: boolean + ) { + super(tabID) + } +} + +export class UpdatePlaceholderMessage extends UiMessage { + readonly newPlaceholder: string + override type: TestMessageType = 'updatePlaceholderMessage' + + constructor(tabID: string, newPlaceholder: string) { + super(tabID) + this.newPlaceholder = newPlaceholder + } +} + +export class CapabilityCardMessage extends ChatMessage { + constructor(tabID: string) { + super( + { + message: '', + messageType: 'answer', + informationCard: { + title: '/test', + description: 'Included in your Q Developer Agent subscription', + content: { + body: `I can generate unit tests for your active file. + +After you select the functions or methods I should focus on, I will: +1. Generate unit tests +2. Place them into relevant test file + +To learn more, check out our [User Guide](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/q-in-IDE.html).`, + }, + icon: 'check-list' as MynahIcons, + }, + }, + tabID + ) + } +} + +export class ErrorMessage extends UiMessage { + readonly title!: string + readonly message!: string + override type: TestMessageType = 'errorMessage' + + constructor(title: string, message: string, tabID: string) { + super(tabID) + this.title = title + this.message = message + } +} + +export class BuildProgressMessage extends UiMessage { + readonly message: string | undefined + readonly codeGenerationId!: string + readonly messageId?: string + readonly followUps?: { + text?: string + options?: ChatItemAction[] + } + readonly fileList?: { + fileTreeTitle?: string + rootFolderTitle?: string + filePaths?: string[] + } + readonly codeReference?: CodeReference[] + readonly canBeVoted: boolean + readonly messageType: ChatItemType + override type: TestMessageType = 'buildProgressMessage' + + constructor({ + tabID, + messageType, + codeGenerationId, + message, + canBeVoted, + messageId, + followUps, + fileList, + codeReference, + }: SendBuildProgressMessageParams) { + super(tabID) + this.messageType = messageType + this.codeGenerationId = codeGenerationId + this.message = message + this.canBeVoted = canBeVoted + this.messageId = messageId + this.followUps = followUps + this.fileList = fileList + this.codeReference = codeReference + } +} + +export class AppToWebViewMessageDispatcher { + constructor(private readonly appsToWebViewMessagePublisher: MessagePublisher) {} + + public sendChatMessage(message: ChatMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendChatSummaryMessage(message: ChatSummaryMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendUpdatePlaceholder(message: UpdatePlaceholderMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendAuthenticationUpdate(message: AuthenticationUpdateMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendAuthNeededExceptionMessage(message: AuthNeededException) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendChatInputEnabled(message: ChatInputEnabledMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendErrorMessage(message: ErrorMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendBuildProgressMessage(message: BuildProgressMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendUpdatePromptProgress(message: UpdatePromptProgressMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } +} diff --git a/packages/core/src/amazonqTest/index.ts b/packages/core/src/amazonqTest/index.ts new file mode 100644 index 00000000000..06f5ebb63f9 --- /dev/null +++ b/packages/core/src/amazonqTest/index.ts @@ -0,0 +1,6 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +export { default as MessengerUtils } from './chat/controller/messenger/messengerUtils' diff --git a/packages/core/src/amazonqTest/models/constants.ts b/packages/core/src/amazonqTest/models/constants.ts new file mode 100644 index 00000000000..fa0c00d59cc --- /dev/null +++ b/packages/core/src/amazonqTest/models/constants.ts @@ -0,0 +1,145 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { ProgressField, MynahIcons, ChatItemButton } from '@aws/mynah-ui' +import { ButtonActions } from '../chat/controller/messenger/messengerUtils' +import { TestGenerationBuildStep } from '../../codewhisperer' +import { ChatSessionManager } from '../chat/storages/chatSession' +import { BuildStatus } from '../chat/session/session' + +// For uniquely identifiying which chat messages should be routed to Test +export const testChat = 'testChat' + +export const cancelTestGenButton: ChatItemButton = { + id: ButtonActions.STOP_TEST_GEN, + text: 'Cancel', + icon: 'cancel' as MynahIcons, +} + +export const testGenProgressField: ProgressField = { + status: 'default', + value: -1, + text: 'Generating unit tests...', + actions: [cancelTestGenButton], +} + +export const testGenCompletedField: ProgressField = { + status: 'success', + value: 100, + text: 'Complete...', + actions: [], +} + +export const cancellingProgressField: ProgressField = { + status: 'warning', + text: 'Cancelling...', + value: -1, + actions: [], +} + +export const cancelBuildProgressButton: ChatItemButton = { + id: ButtonActions.STOP_BUILD, + text: 'Cancel', + icon: 'cancel' as MynahIcons, +} + +export const buildProgressField: ProgressField = { + status: 'default', + value: -1, + text: 'Executing...', + actions: [cancelBuildProgressButton], +} + +export const errorProgressField: ProgressField = { + status: 'error', + text: 'Error...Input needed', + value: -1, + actions: [cancelBuildProgressButton], +} + +export const testGenSummaryMessage = ( + fileName: string, + planSummary?: string +) => `Sure. This may take a few minutes. I'll share updates here as I work on this. + +**Generating unit tests for the following methods in \`${fileName}\`** +${planSummary ? `\n\n${planSummary}` : ''} +` + +const checkIcons = { + wait: '☐', + current: '☐', + done: '', + error: '❌', +} + +interface StepStatus { + step: TestGenerationBuildStep + status: 'wait' | 'current' | 'done' | 'error' +} + +const stepStatuses: StepStatus[] = [] + +export const testGenBuildProgressMessage = (currentStep: TestGenerationBuildStep, status?: string) => { + const session = ChatSessionManager.Instance.getSession() + const statusText = BuildStatus[session.buildStatus].toLowerCase() + const icon = session.buildStatus === BuildStatus.SUCCESS ? checkIcons['done'] : checkIcons['error'] + let message = `Sure. This may take a few minutes and I'll share updates on my progress here. +**Progress summary**\n\n` + + if (currentStep === TestGenerationBuildStep.START_STEP) { + return message.trim() + } + + updateStepStatuses(currentStep, status) + + if (currentStep >= TestGenerationBuildStep.RUN_BUILD) { + message += `${getIconForStep(TestGenerationBuildStep.RUN_BUILD)} Started build execution\n` + } + + if (currentStep >= TestGenerationBuildStep.RUN_EXECUTION_TESTS) { + message += `${getIconForStep(TestGenerationBuildStep.RUN_EXECUTION_TESTS)} Executing tests\n` + } + + if (currentStep >= TestGenerationBuildStep.FIXING_TEST_CASES && session.buildStatus === BuildStatus.FAILURE) { + message += `${getIconForStep(TestGenerationBuildStep.FIXING_TEST_CASES)} Fixing errors in tests\n\n` + } + + if (currentStep > TestGenerationBuildStep.PROCESS_TEST_RESULTS) { + message += `**Test case summary** +${session.shortAnswer?.testCoverage ? `- Unit test coverage ${session.shortAnswer?.testCoverage}%` : ``} +${icon} Build ${statusText} +${icon} Assertion ${statusText}` + //TODO: Update Assertion % + } + + return message.trim() +} +//TODO: Work on UX to show the build error in the progress message +const updateStepStatuses = (currentStep: TestGenerationBuildStep, status?: string) => { + for (let step = TestGenerationBuildStep.INSTALL_DEPENDENCIES; step <= currentStep; step++) { + const stepStatus: StepStatus = { + step: step, + status: 'wait', + } + + if (step === currentStep) { + stepStatus.status = status === 'failed' ? 'error' : 'current' + } else if (step < currentStep) { + stepStatus.status = 'done' + } + + const existingIndex = stepStatuses.findIndex((s) => s.step === step) + if (existingIndex !== -1) { + stepStatuses[existingIndex] = stepStatus + } else { + stepStatuses.push(stepStatus) + } + } +} + +const getIconForStep = (step: TestGenerationBuildStep) => { + const stepStatus = stepStatuses.find((s) => s.step === step) + return stepStatus ? checkIcons[stepStatus.status] : checkIcons.wait +} diff --git a/packages/core/src/codewhisperer/activation.ts b/packages/core/src/codewhisperer/activation.ts index e109d1c3de3..0ed50296da4 100644 --- a/packages/core/src/codewhisperer/activation.ts +++ b/packages/core/src/codewhisperer/activation.ts @@ -10,7 +10,15 @@ import { KeyStrokeHandler } from './service/keyStrokeHandler' import * as EditorContext from './util/editorContext' import * as CodeWhispererConstants from './models/constants' import { getCompletionItems } from './service/completionProvider' -import { vsCodeState, ConfigurationEntry, CodeSuggestionsState, CodeScansState } from './models/model' +import { + vsCodeState, + ConfigurationEntry, + CodeSuggestionsState, + CodeScansState, + SecurityTreeViewFilterState, + AggregatedCodeScanIssue, + CodeScanIssue, +} from './models/model' import { invokeRecommendation } from './commands/invokeRecommendation' import { acceptSuggestion } from './commands/onInlineAcceptance' import { resetIntelliSenseState } from './util/globalStateUtil' @@ -41,12 +49,27 @@ import { signoutCodeWhisperer, toggleCodeScans, registerToolkitApiCallback, + showFileScan, + clearFilters, + generateFix, + explainIssue, + ignoreIssue, + rejectFix, + showSecurityIssueFilters, + regenerateFix, + ignoreAllIssues, + focusIssue, + showExploreAgentsView, } from './commands/basicCommands' import { sleep } from '../shared/utilities/timeoutUtils' import { ReferenceLogViewProvider } from './service/referenceLogViewProvider' import { ReferenceHoverProvider } from './service/referenceHoverProvider' import { ReferenceInlineProvider } from './service/referenceInlineProvider' -import { disposeSecurityDiagnostic, securityScanRender } from './service/diagnosticsProvider' +import { + disposeSecurityDiagnostic, + securityScanRender, + updateSecurityDiagnosticCollection, +} from './service/diagnosticsProvider' import { SecurityPanelViewProvider, openEditorAtRange } from './views/securityPanelViewProvider' import { RecommendationHandler } from './service/recommendationHandler' import { Commands, registerCommandErrorHandler, registerDeclaredCommands } from '../shared/vscode/commands2' @@ -71,6 +94,11 @@ import { logAndShowError, logAndShowWebviewError } from '../shared/utilities/log import { openSettings } from '../shared/settings' import { telemetry } from '../shared/telemetry' import { FeatureConfigProvider } from '../shared/featureConfig' +import { SecurityIssueProvider } from './service/securityIssueProvider' +import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewProvider' +import { setContext } from '../shared/vscode/setContext' +import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview' +import { detectCommentAboveLine } from '../shared/utilities/commentUtils' let localize: nls.LocalizeFunc @@ -186,6 +214,11 @@ export async function activate(context: ExtContext): Promise { if (configurationChangeEvent.affectsConfiguration('http.proxy')) { updateUserProxyUrl() } + + if (configurationChangeEvent.affectsConfiguration('amazonQ.ignoredSecurityIssues')) { + const ignoredIssues = CodeWhispererSettings.instance.getIgnoredSecurityIssues() + toggleIssuesVisibility((issue) => !ignoredIssues.includes(issue.title)) + } }), /** * Open Configuration @@ -222,8 +255,10 @@ export async function activate(context: ExtContext): Promise { toggleCodeScans.register(CodeScansState.instance), // enable code suggestions enableCodeSuggestions.register(context), - // code scan + // project scan showSecurityScan.register(context, securityPanelViewProvider, client), + // on demand file scan + showFileScan.register(context, securityPanelViewProvider, client), // show security issue webview panel openSecurityIssuePanel.register(context), // sign in with sso or AWS ID @@ -238,10 +273,40 @@ export async function activate(context: ExtContext): Promise { updateReferenceLog.register(), // refresh codewhisperer status bar refreshStatusBar.register(), + // generate code fix + generateFix.register(client, context), + // regenerate code fix + regenerateFix.register(), // apply suggested fix applySecurityFix.register(), + // reject suggested fix + rejectFix.register(context.extensionContext), + // ignore issues by title + ignoreAllIssues.register(), + // ignore single issue + ignoreIssue.register(), + // explain issue + explainIssue.register(), // quick pick with codewhisperer options listCodeWhispererCommands.register(), + // quick pick with security issues tree filters + showSecurityIssueFilters.register(), + // reset security issue filters + clearFilters.register(), + // handle security issues tree item clicked + focusIssue.register(), + // refresh the treeview on every change + SecurityTreeViewFilterState.instance.onDidChangeState((e) => { + SecurityIssueTreeViewProvider.instance.refresh() + }), + // show a no match state + SecurityIssueTreeViewProvider.instance.onDidChangeTreeData((e) => { + const noMatches = + Array.isArray(e) && + e.length === 0 && + SecurityIssueProvider.instance.issues.some((group) => group.issues.some((issue) => issue.visible)) + void setContext('aws.amazonq.security.noMatches', noMatches) + }), // manual trigger Commands.register({ id: 'aws.amazonq.invokeInlineCompletion', autoconnect: true }, async () => { invokeRecommendation( @@ -278,6 +343,7 @@ export async function activate(context: ExtContext): Promise { ), vscode.window.registerWebviewViewProvider(ReferenceLogViewProvider.viewType, ReferenceLogViewProvider.instance), showReferenceLog.register(), + showExploreAgentsView.register(), vscode.languages.registerCodeLensProvider( [...CodeWhispererConstants.platformLanguageIds], ReferenceInlineProvider.instance @@ -328,6 +394,8 @@ export async function activate(context: ExtContext): Promise { */ setSubscriptionsForAutoScans() + setSubscriptionsForCodeIssues() + function shouldRunAutoScan(editor: vscode.TextEditor | undefined, isScansEnabled?: boolean) { return ( (isScansEnabled ?? CodeScansState.instance.isScansEnabled()) && @@ -349,7 +417,8 @@ export async function activate(context: ExtContext): Promise { editor, client, context.extensionContext, - CodeWhispererConstants.CodeAnalysisScope.FILE + CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO, + false ) } @@ -373,7 +442,8 @@ export async function activate(context: ExtContext): Promise { editor, client, context.extensionContext, - CodeWhispererConstants.CodeAnalysisScope.FILE + CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO, + false ) } }), @@ -391,7 +461,8 @@ export async function activate(context: ExtContext): Promise { editor, client, context.extensionContext, - CodeWhispererConstants.CodeAnalysisScope.FILE + CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO, + false ) } }) @@ -406,7 +477,8 @@ export async function activate(context: ExtContext): Promise { editor, client, context.extensionContext, - CodeWhispererConstants.CodeAnalysisScope.FILE + CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO, + false ) } }) @@ -482,14 +554,6 @@ export async function activate(context: ExtContext): Promise { return } - /** - * CodeWhisperer security panel dynamic handling - */ - disposeSecurityDiagnostic(e) - - SecurityIssueHoverProvider.instance.handleDocumentChange(e) - SecurityIssueCodeActionProvider.instance.handleDocumentChange(e) - CodeWhispererCodeCoverageTracker.getTracker(e.document.languageId)?.countTotalTokens(e) /** @@ -602,6 +666,34 @@ export async function activate(context: ExtContext): Promise { await Commands.tryExecute('aws.amazonq.refreshConnectionCallback') container.ready() + + function setSubscriptionsForCodeIssues() { + context.extensionContext.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(async (e) => { + // verify the document is something with a finding + for (const issue of SecurityIssueProvider.instance.issues) { + if (issue.filePath === e.document.uri.fsPath) { + disposeSecurityDiagnostic(e) + + SecurityIssueProvider.instance.handleDocumentChange(e) + SecurityIssueTreeViewProvider.instance.refresh() + await syncSecurityIssueWebview(context) + + toggleIssuesVisibility((issue, filePath) => + filePath !== e.document.uri.fsPath + ? issue.visible + : !detectCommentAboveLine( + e.document, + issue.startLine, + CodeWhispererConstants.amazonqIgnoreNextLine + ) + ) + break + } + } + }) + ) + } } export async function shutdown() { @@ -621,3 +713,14 @@ export async function enableDefaultConfigCloud9() { getLogger().error('amazonq: Failed to update user settings %O', error) } } + +function toggleIssuesVisibility(visibleCondition: (issue: CodeScanIssue, filePath: string) => boolean) { + const updatedIssues: AggregatedCodeScanIssue[] = SecurityIssueProvider.instance.issues.map((group) => ({ + ...group, + issues: group.issues.map((issue) => ({ ...issue, visible: visibleCondition(issue, group.filePath) })), + })) + securityScanRender.securityDiagnosticCollection?.clear() + updatedIssues.forEach((issue) => updateSecurityDiagnosticCollection(issue)) + SecurityIssueProvider.instance.issues = updatedIssues + SecurityIssueTreeViewProvider.instance.refresh() +} diff --git a/packages/core/src/codewhisperer/client/codewhisperer.ts b/packages/core/src/codewhisperer/client/codewhisperer.ts index 5104ef7ede0..c5a89b36e0c 100644 --- a/packages/core/src/codewhisperer/client/codewhisperer.ts +++ b/packages/core/src/codewhisperer/client/codewhisperer.ts @@ -90,6 +90,7 @@ export type CreateCodeScanResponse = | CodeWhispererUserClient.StartCodeAnalysisResponse export type Import = CodeWhispererUserClient.Import export type Imports = CodeWhispererUserClient.Imports + export class DefaultCodeWhispererClient { private async createSdkClient(): Promise { const isOptedOut = CodeWhispererSettings.instance.isOptoutEnabled() @@ -317,6 +318,30 @@ export class DefaultCodeWhispererClient { ): Promise> { return (await this.createUserSdkClient()).getTransformationPlan(request).promise() } + + public async startCodeFixJob( + request: CodeWhispererUserClient.StartCodeFixJobRequest + ): Promise> { + return (await this.createUserSdkClient()).startCodeFixJob(request).promise() + } + + public async getCodeFixJob( + request: CodeWhispererUserClient.GetCodeFixJobRequest + ): Promise> { + return (await this.createUserSdkClient()).getCodeFixJob(request).promise() + } + + public async startTestGeneration( + request: CodeWhispererUserClient.StartTestGenerationRequest + ): Promise> { + return (await this.createUserSdkClient()).startTestGeneration(request).promise() + } + + public async getTestGeneration( + request: CodeWhispererUserClient.GetTestGenerationRequest + ): Promise> { + return (await this.createUserSdkClient()).getTestGeneration(request).promise() + } } export const codeWhispererClient = new DefaultCodeWhispererClient() diff --git a/packages/core/src/codewhisperer/client/user-service-2.json b/packages/core/src/codewhisperer/client/user-service-2.json index bf20eeb6fa0..123160fb0b3 100644 --- a/packages/core/src/codewhisperer/client/user-service-2.json +++ b/packages/core/src/codewhisperer/client/user-service-2.json @@ -663,7 +663,8 @@ "type": "string", "documentation": "

Code fix name

", "max": 128, - "min": 1 + "min": 1, + "pattern": "[a-zA-Z0-9-_$:.]*" }, "CodeFixUploadContext": { "type": "structure", @@ -2092,7 +2093,8 @@ "StartCodeFixJobRequestDescriptionString": { "type": "string", "max": 2000, - "min": 1 + "min": 1, + "sensitive": true }, "StartCodeFixJobRequestRuleIdString": { "type": "string", @@ -2228,7 +2230,8 @@ "SuggestedFixDescriptionString": { "type": "string", "max": 2000, - "min": 1 + "min": 1, + "sensitive": true }, "SuggestionState": { "type": "string", @@ -2665,7 +2668,7 @@ }, "TransformationProgressUpdateStatus": { "type": "string", - "enum": ["IN_PROGRESS", "COMPLETED", "FAILED", "PAUSED", "AWAITING_CLIENT_ACTION"] + "enum": ["IN_PROGRESS", "COMPLETED", "FAILED", "PAUSED", "AWAITING_CLIENT_ACTION", "SKIPPED"] }, "TransformationProjectArtifactDescriptor": { "type": "structure", @@ -2744,7 +2747,7 @@ }, "TransformationStepStatus": { "type": "string", - "enum": ["CREATED", "COMPLETED", "PARTIALLY_COMPLETED", "STOPPED", "FAILED", "PAUSED"] + "enum": ["CREATED", "COMPLETED", "PARTIALLY_COMPLETED", "STOPPED", "FAILED", "PAUSED", "SKIPPED"] }, "TransformationSteps": { "type": "list", diff --git a/packages/core/src/codewhisperer/commands/basicCommands.ts b/packages/core/src/codewhisperer/commands/basicCommands.ts index 53e693b8e69..8f428419853 100644 --- a/packages/core/src/codewhisperer/commands/basicCommands.ts +++ b/packages/core/src/codewhisperer/commands/basicCommands.ts @@ -9,24 +9,39 @@ import { ExtContext, VSCODE_EXTENSION_ID } from '../../shared/extensions' import { Commands, VsCodeCommandArg, placeholder } from '../../shared/vscode/commands2' import * as CodeWhispererConstants from '../models/constants' import { DefaultCodeWhispererClient } from '../client/codewhisperer' -import { startSecurityScanWithProgress, confirmStopSecurityScan } from './startSecurityScan' +import { confirmStopSecurityScan, startSecurityScan } from './startSecurityScan' import { SecurityPanelViewProvider } from '../views/securityPanelViewProvider' -import { CodeScanIssue, CodeScansState, codeScanState, CodeSuggestionsState, vsCodeState } from '../models/model' +import { + codeFixState, + CodeScanIssue, + CodeScansState, + codeScanState, + CodeSuggestionsState, + onDemandFileScanState, + SecurityIssueFilters, + SecurityTreeViewFilterState, + severities, + vsCodeState, +} from '../models/model' import { connectToEnterpriseSso, getStartUrl } from '../util/getStartUrl' import { showCodeWhispererConnectionPrompt } from '../util/showSsoPrompt' import { ReferenceLogViewProvider } from '../service/referenceLogViewProvider' import { AuthUtil } from '../util/authUtil' import { isCloud9 } from '../../shared/extensionUtilities' import { getLogger } from '../../shared/logger' -import { isExtensionActive, isExtensionInstalled, openUrl } from '../../shared/utilities/vsCodeUtils' +import { isExtensionActive, isExtensionInstalled, localize, openUrl } from '../../shared/utilities/vsCodeUtils' import { getPersistedCustomizations, notifyNewCustomizations, selectCustomization, showCustomizationPrompt, } from '../util/customizationUtil' -import { applyPatch } from 'diff' -import { closeSecurityIssueWebview, showSecurityIssueWebview } from '../views/securityIssue/securityIssueWebview' +import { + closeSecurityIssueWebview, + isSecurityIssueWebviewOpen, + showSecurityIssueWebview, + updateSecurityIssueWebview, +} from '../views/securityIssue/securityIssueWebview' import { Mutable } from '../../shared/utilities/tsUtils' import { CodeWhispererSource } from './types' import { TelemetryHelper } from '../util/telemetryHelper' @@ -34,8 +49,6 @@ import { Auth, AwsConnection } from '../../auth' import { once } from '../../shared/utilities/functionUtils' import { focusAmazonQPanel } from '../../codewhispererChat/commands/registerCommands' import { removeDiagnostic } from '../service/diagnosticsProvider' -import { SecurityIssueHoverProvider } from '../service/securityIssueHoverProvider' -import { SecurityIssueCodeActionProvider } from '../service/securityIssueCodeActionProvider' import { SsoAccessTokenProvider } from '../../auth/sso/ssoAccessTokenProvider' import { ToolkitError, getTelemetryReason, getTelemetryReasonDesc } from '../../shared/errors' import { isRemoteWorkspace } from '../../shared/vscode/env' @@ -44,6 +57,15 @@ import globals from '../../shared/extensionGlobals' import { getVscodeCliPath } from '../../shared/utilities/pathFind' import { setContext } from '../../shared/vscode/setContext' import { tryRun } from '../../shared/utilities/pathFind' +import { IssueItem, SecurityIssueTreeViewProvider } from '../service/securityIssueTreeViewProvider' +import { SecurityIssueProvider } from '../service/securityIssueProvider' +import { CodeWhispererSettings } from '../util/codewhispererSettings' +import { closeDiff, getPatchedCode } from '../../shared/utilities/diffUtils' +import { insertCommentAboveLine } from '../../shared/utilities/commentUtils' +import { cancel, confirm } from '../../shared' +import { startCodeFixGeneration } from './startCodeFixGeneration' +import { DefaultAmazonQAppInitContext } from '../../amazonq/apps/initContext' +import path from 'path' const MessageTimeOut = 5_000 @@ -127,6 +149,20 @@ export const showReferenceLog = Commands.declare( } ) +export const showExploreAgentsView = Commands.declare( + { id: 'aws.amazonq.exploreAgents', compositeKey: { 1: 'source' } }, + () => async (_: VsCodeCommandArg, source: CodeWhispererSource) => { + if (_ !== placeholder) { + source = 'ellipsesMenu' + } + + DefaultAmazonQAppInitContext.instance.getAppsToWebViewMessagePublisher().publish({ + sender: 'amazonqCore', + command: 'showExploreAgentsView', + }) + } +) + export const showIntroduction = Commands.declare('aws.amazonq.introduction', () => async () => { void openUrl(vscode.Uri.parse(CodeWhispererConstants.learnMoreUriGeneral)) }) @@ -134,18 +170,67 @@ export const showIntroduction = Commands.declare('aws.amazonq.introduction', () export const showSecurityScan = Commands.declare( { id: 'aws.amazonq.security.scan', compositeKey: { 1: 'source' } }, (context: ExtContext, securityPanelViewProvider: SecurityPanelViewProvider, client: DefaultCodeWhispererClient) => - async (_: VsCodeCommandArg, source: CodeWhispererSource) => { + async (_: VsCodeCommandArg, source: CodeWhispererSource, initiatedByChat: boolean, scanUuid?: string) => { if (AuthUtil.instance.isConnectionExpired()) { await AuthUtil.instance.notifyReauthenticate() } if (codeScanState.isNotStarted()) { // User intends to start as "Start Security Scan" is shown in the explorer tree codeScanState.setToRunning() - void startSecurityScanWithProgress(securityPanelViewProvider, client, context.extensionContext) + void startSecurityScan( + securityPanelViewProvider, + undefined, + client, + context.extensionContext, + CodeWhispererConstants.CodeAnalysisScope.PROJECT, + initiatedByChat, + undefined, + scanUuid + ) } else if (codeScanState.isRunning()) { // User intends to stop as "Stop Security Scan" is shown in the explorer tree // Cancel only when the code scan state is "Running" - await confirmStopSecurityScan() + await confirmStopSecurityScan( + codeScanState, + initiatedByChat, + CodeWhispererConstants.CodeAnalysisScope.PROJECT, + undefined + ) + } + vsCodeState.isFreeTierLimitReached = false + } +) + +export const showFileScan = Commands.declare( + { id: 'aws.amazonq.security.filescan', compositeKey: { 1: 'source' } }, + (context: ExtContext, securityPanelViewProvider: SecurityPanelViewProvider, client: DefaultCodeWhispererClient) => + async (_: VsCodeCommandArg, source: CodeWhispererSource, scanUuid?: string) => { + if (AuthUtil.instance.isConnectionExpired()) { + await AuthUtil.instance.notifyReauthenticate() + } + const editor = vscode.window.activeTextEditor + if (onDemandFileScanState.isNotStarted()) { + onDemandFileScanState.setToRunning() + void startSecurityScan( + securityPanelViewProvider, + editor, + client, + context.extensionContext, + CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND, + true, + undefined, + scanUuid + ) + } else if (onDemandFileScanState.isRunning()) { + //TODO: Pending with progress bar implementation in the Q chat Panel + // User intends to stop the scan from Q chat panel. + // Cancel only when the file scan state is "Running" + await confirmStopSecurityScan( + onDemandFileScanState, + true, + CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND, + editor?.document.fileName + ) } vsCodeState.isFreeTierLimitReached = false } @@ -263,25 +348,27 @@ export const updateReferenceLog = Commands.declare( export const openSecurityIssuePanel = Commands.declare( 'aws.amazonq.openSecurityIssuePanel', - (context: ExtContext) => async (issue: CodeScanIssue, filePath: string) => { - await showSecurityIssueWebview(context.extensionContext, issue, filePath) + (context: ExtContext) => async (issue: CodeScanIssue | IssueItem, filePath: string) => { + const targetIssue: CodeScanIssue = issue instanceof IssueItem ? issue.issue : issue + const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath + await showSecurityIssueWebview(context.extensionContext, targetIssue, targetFilePath) telemetry.codewhisperer_codeScanIssueViewDetails.emit({ - findingId: issue.findingId, - detectorId: issue.detectorId, - ruleId: issue.ruleId, + findingId: targetIssue.findingId, + detectorId: targetIssue.detectorId, + ruleId: targetIssue.ruleId, credentialStartUrl: AuthUtil.instance.startUrl, }) TelemetryHelper.instance.sendCodeScanRemediationsEvent( undefined, 'CODESCAN_ISSUE_VIEW_DETAILS', - issue.detectorId, - issue.findingId, - issue.ruleId, + targetIssue.detectorId, + targetIssue.findingId, + targetIssue.ruleId, undefined, undefined, undefined, - !!issue.suggestedFixes.length + !!targetIssue.suggestedFixes.length ) } ) @@ -343,27 +430,29 @@ export const installAmazonQExtension = Commands.declare( export const applySecurityFix = Commands.declare( 'aws.amazonq.applySecurityFix', - () => async (issue: CodeScanIssue, filePath: string, source: Component) => { - const [suggestedFix] = issue.suggestedFixes - if (!suggestedFix || !filePath) { + () => async (issue: CodeScanIssue | IssueItem, filePath: string, source: Component) => { + const targetIssue: CodeScanIssue = issue instanceof IssueItem ? issue.issue : issue + const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath + const targetSource: Component = issue instanceof IssueItem ? 'tree' : source + const [suggestedFix] = targetIssue.suggestedFixes + if (!suggestedFix || !targetFilePath || !suggestedFix.code) { return } const applyFixTelemetryEntry: Mutable = { - detectorId: issue.detectorId, - findingId: issue.findingId, - ruleId: issue.ruleId, - component: source, + detectorId: targetIssue.detectorId, + findingId: targetIssue.findingId, + ruleId: targetIssue.ruleId, + component: targetSource, result: 'Succeeded', credentialStartUrl: AuthUtil.instance.startUrl, + codeFixAction: 'applyFix', } let languageId = undefined try { - const patch = suggestedFix.code - const document = await vscode.workspace.openTextDocument(filePath) - const fileContent = document.getText() + const document = await vscode.workspace.openTextDocument(targetFilePath) languageId = document.languageId - const updatedContent = applyPatch(fileContent, patch, { fuzzFactor: 4 }) + const updatedContent = await getPatchedCode(targetFilePath, suggestedFix.code) if (!updatedContent) { void vscode.window.showErrorMessage(CodeWhispererConstants.codeFixAppliedFailedMessage) throw Error('Failed to get updated content from applying diff patch') @@ -375,18 +464,77 @@ export const applySecurityFix = Commands.declare( new vscode.Range(document.lineAt(0).range.start, document.lineAt(document.lineCount - 1).range.end), updatedContent ) + SecurityIssueProvider.instance.disableEventHandler() const isApplied = await vscode.workspace.applyEdit(edit) - if (!isApplied) { + if (isApplied) { + void document.save().then((didSave) => { + if (!didSave) { + getLogger().error('Apply fix command failed to save the document.') + } + }) + } else { throw Error('Failed to apply edit to the workspace.') } + // add accepted references to reference log, if any + const fileName = path.basename(targetFilePath) + const time = new Date().toLocaleString() + // TODO: this is duplicated in controller.ts for test. Fix this later. + suggestedFix.references?.forEach((reference) => { + getLogger().debug('Processing reference: %O', reference) + // Log values for debugging + getLogger().debug('suggested fix code: %s', suggestedFix.code) + getLogger().debug('updated content: %s', updatedContent) + getLogger().debug( + 'start: %d, end: %d', + reference.recommendationContentSpan?.start, + reference.recommendationContentSpan?.end + ) + // given a start and end index, figure out which line number they belong to when splitting a string on /n characters + const getLineNumber = (content: string, index: number): number => { + const lines = content.slice(0, index).split('\n') + return lines.length + } + const startLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.start!) + const endLine = getLineNumber(updatedContent, reference.recommendationContentSpan!.end!) + getLogger().debug('startLine: %d, endLine: %d', startLine, endLine) + const code = updatedContent.slice( + reference.recommendationContentSpan?.start, + reference.recommendationContentSpan?.end + ) + getLogger().debug('Extracted code slice: %s', code) + const referenceLog = + `[${time}] Accepted recommendation ` + + CodeWhispererConstants.referenceLogText( + `
${code}
`, + reference.licenseName!, + reference.repository!, + fileName, + startLine === endLine ? `(line at ${startLine})` : `(lines from ${startLine} to ${endLine})` + ) + + '
' + getLogger().debug('Adding reference log: %s', referenceLog) + ReferenceLogViewProvider.instance.addReferenceLog(referenceLog) + }) - if (CodeScansState.instance.isScansEnabled()) { - removeDiagnostic(document.uri, issue) - SecurityIssueHoverProvider.instance.removeIssue(document.uri, issue) - SecurityIssueCodeActionProvider.instance.removeIssue(document.uri, issue) + removeDiagnostic(document.uri, targetIssue) + SecurityIssueProvider.instance.removeIssue(document.uri, targetIssue) + SecurityIssueTreeViewProvider.instance.refresh() + + await closeSecurityIssueWebview(targetIssue.findingId) + await closeDiff(targetFilePath) + await vscode.window.showTextDocument(document, { viewColumn: vscode.ViewColumn.One }) + const linesLength = suggestedFix.code.split('\n').length + const charsLength = suggestedFix.code.length + if (targetIssue.fixJobId) { + TelemetryHelper.instance.sendCodeFixAcceptanceEvent( + targetIssue.fixJobId, + languageId, + targetIssue.ruleId, + targetIssue.detectorId, + linesLength, + charsLength + ) } - - await closeSecurityIssueWebview(issue.findingId) } catch (err) { getLogger().error(`Apply fix command failed. ${err}`) applyFixTelemetryEntry.result = 'Failed' @@ -397,13 +545,13 @@ export const applySecurityFix = Commands.declare( TelemetryHelper.instance.sendCodeScanRemediationsEvent( languageId, 'CODESCAN_ISSUE_APPLY_FIX', - issue.detectorId, - issue.findingId, - issue.ruleId, + targetIssue.detectorId, + targetIssue.findingId, + targetIssue.ruleId, source, applyFixTelemetryEntry.reasonDesc, applyFixTelemetryEntry.result, - !!issue.suggestedFixes.length + !!targetIssue.suggestedFixes.length ) } } @@ -413,6 +561,7 @@ export const signoutCodeWhisperer = Commands.declare( { id: 'aws.amazonq.signout', compositeKey: { 1: 'source' } }, (auth: AuthUtil) => async (_: VsCodeCommandArg, source: CodeWhispererSource) => { await auth.secondaryAuth.deleteConnection() + SecurityIssueTreeViewProvider.instance.refresh() return focusAmazonQPanel.execute(placeholder, source) } ) @@ -455,7 +604,7 @@ export const registerToolkitApiCallback = Commands.declare( // we need to do it manually here because the Toolkit would have been unable to call // this API if the Q/CW extension started afterwards (and this code block is running). if (isExtensionInstalled(VSCODE_EXTENSION_ID.awstoolkit)) { - getLogger().info(`Trying to register toolkit callback. Toolkit is installed, + getLogger().info(`Trying to register toolkit callback. Toolkit is installed, toolkit activated = ${isExtensionActive(VSCODE_EXTENSION_ID.awstoolkit)}`) if (toolkitApi) { // when this command is executed by AWS Toolkit activation @@ -484,3 +633,258 @@ export const registerToolkitApiCallback = Commands.declare( } } ) + +export const clearFilters = Commands.declare( + { id: 'aws.amazonq.securityIssuesTreeFilter.clearFilters' }, + () => async () => { + await SecurityTreeViewFilterState.instance.resetFilters() + } +) + +export const generateFix = Commands.declare( + { id: 'aws.amazonq.security.generateFix' }, + (client: DefaultCodeWhispererClient, context: ExtContext) => + async ( + issue: CodeScanIssue | IssueItem | undefined, + filePath: string, + source: Component, + refresh: boolean = false + ) => { + const targetIssue: CodeScanIssue | undefined = issue instanceof IssueItem ? issue.issue : issue + const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath + const targetSource: Component = issue instanceof IssueItem ? 'tree' : source + if (!targetIssue) { + return + } + await telemetry.codewhisperer_codeScanIssueGenerateFix.run(async () => { + try { + await vscode.commands + .executeCommand('aws.amazonq.openSecurityIssuePanel', targetIssue, targetFilePath) + .then(undefined, (e) => { + getLogger().error('Failed to open security issue panel: %s', e.message) + }) + await updateSecurityIssueWebview({ + isGenerateFixLoading: true, + isGenerateFixError: false, + context: context.extensionContext, + filePath: targetFilePath, + shouldRefreshView: false, + }) + + codeFixState.setToRunning() + let hasSuggestedFix = false + const { suggestedFix, jobId } = await startCodeFixGeneration( + client, + targetIssue, + targetFilePath, + targetIssue.findingId + ) + // redact the fix if the user disabled references and there is a reference + if ( + // TODO: enable references later for scans + // !CodeWhispererSettings.instance.isSuggestionsWithCodeReferencesEnabled() && + suggestedFix?.references && + suggestedFix?.references?.length > 0 + ) { + getLogger().debug( + `Received fix with reference and user settings disallow references. Job ID: ${jobId}` + ) + // TODO: re-enable notifications once references published + // void vscode.window.showInformationMessage( + // 'Your settings do not allow code generation with references.' + // ) + hasSuggestedFix = false + } else { + hasSuggestedFix = suggestedFix !== undefined + } + const updatedIssue: CodeScanIssue = { + ...targetIssue, + fixJobId: jobId, + suggestedFixes: + hasSuggestedFix && suggestedFix + ? [ + { + code: suggestedFix.codeDiff, + description: suggestedFix.description ?? '', + references: suggestedFix.references, + }, + ] + : [], + } + await updateSecurityIssueWebview({ + issue: updatedIssue, + isGenerateFixLoading: false, + filePath: targetFilePath, + context: context.extensionContext, + shouldRefreshView: true, + }) + + SecurityIssueProvider.instance.updateIssue(updatedIssue, targetFilePath) + SecurityIssueTreeViewProvider.instance.refresh() + } catch (err) { + await updateSecurityIssueWebview({ + issue: targetIssue, + isGenerateFixLoading: false, + isGenerateFixError: true, + filePath: targetFilePath, + context: context.extensionContext, + shouldRefreshView: true, + }) + SecurityIssueProvider.instance.updateIssue(targetIssue, targetFilePath) + SecurityIssueTreeViewProvider.instance.refresh() + throw err + } + telemetry.record({ + component: targetSource, + detectorId: targetIssue.detectorId, + findingId: targetIssue.findingId, + ruleId: targetIssue.ruleId, + variant: refresh ? 'refresh' : undefined, + }) + }) + } +) + +export const rejectFix = Commands.declare( + { id: 'aws.amazonq.security.rejectFix' }, + (context: vscode.ExtensionContext) => async (issue: CodeScanIssue | IssueItem | undefined, filePath: string) => { + const targetIssue: CodeScanIssue | undefined = issue instanceof IssueItem ? issue.issue : issue + const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath + if (!targetIssue) { + return + } + const updatedIssue: CodeScanIssue = { ...targetIssue, suggestedFixes: [] } + await updateSecurityIssueWebview({ + issue: updatedIssue, + context, + filePath: targetFilePath, + shouldRefreshView: false, + }) + + SecurityIssueProvider.instance.updateIssue(updatedIssue, targetFilePath) + SecurityIssueTreeViewProvider.instance.refresh() + await closeDiff(targetFilePath) + + return updatedIssue + } +) + +export const regenerateFix = Commands.declare( + { id: 'aws.amazonq.security.regenerateFix' }, + () => async (issue: CodeScanIssue | IssueItem | undefined, filePath: string, source: Component) => { + const targetIssue: CodeScanIssue | undefined = issue instanceof IssueItem ? issue.issue : issue + const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath + const targetSource: Component = issue instanceof IssueItem ? 'tree' : source + const updatedIssue = await rejectFix.execute(targetIssue, targetFilePath) + await generateFix.execute(updatedIssue, targetFilePath, targetSource, true) + } +) + +export const explainIssue = Commands.declare( + { id: 'aws.amazonq.security.explain' }, + () => async (issueItem: IssueItem) => { + await vscode.commands.executeCommand('aws.amazonq.explainIssue', issueItem.issue) + } +) + +export const ignoreAllIssues = Commands.declare( + { id: 'aws.amazonq.security.ignoreAll' }, + () => async (issue: CodeScanIssue | IssueItem, source: Component) => { + const targetIssue: CodeScanIssue = issue instanceof IssueItem ? issue.issue : issue + const targetSource: Component = issue instanceof IssueItem ? 'tree' : source + const resp = await vscode.window.showWarningMessage( + CodeWhispererConstants.ignoreAllIssuesMessage(targetIssue.title), + confirm, + cancel + ) + if (resp === confirm) { + await telemetry.codewhisperer_codeScanIssueIgnore.run(async () => { + const ignoredIssues = CodeWhispererSettings.instance.getIgnoredSecurityIssues() + if (!ignoredIssues.includes(targetIssue.title)) { + await CodeWhispererSettings.instance.addToIgnoredSecurityIssuesList(targetIssue.title) + } + await closeSecurityIssueWebview(targetIssue.findingId) + + telemetry.record({ + component: targetSource, + credentialStartUrl: AuthUtil.instance.startUrl, + detectorId: targetIssue.detectorId, + findingId: targetIssue.findingId, + ruleId: targetIssue.ruleId, + variant: 'all', + }) + }) + } + } +) + +export const ignoreIssue = Commands.declare( + { id: 'aws.amazonq.security.ignore' }, + () => async (issue: CodeScanIssue | IssueItem, filePath: string, source: Component) => { + await telemetry.codewhisperer_codeScanIssueIgnore.run(async () => { + const targetIssue: CodeScanIssue = issue instanceof IssueItem ? issue.issue : issue + const targetFilePath: string = issue instanceof IssueItem ? issue.filePath : filePath + const targetSource: Component = issue instanceof IssueItem ? 'tree' : source + const document = await vscode.workspace.openTextDocument(targetFilePath) + + const documentIsVisible = vscode.window.visibleTextEditors.some((editor) => editor.document === document) + if (!documentIsVisible) { + await vscode.window.showTextDocument(document, { + selection: new vscode.Range(targetIssue.startLine, 0, targetIssue.endLine, 0), + preserveFocus: true, + preview: true, + viewColumn: vscode.ViewColumn.One, + }) + } + insertCommentAboveLine(document, targetIssue.startLine, CodeWhispererConstants.amazonqIgnoreNextLine) + await closeSecurityIssueWebview(targetIssue.findingId) + + telemetry.record({ + component: targetSource, + credentialStartUrl: AuthUtil.instance.startUrl, + detectorId: targetIssue.detectorId, + findingId: targetIssue.findingId, + ruleId: targetIssue.ruleId, + }) + }) + } +) + +export const showSecurityIssueFilters = Commands.declare({ id: 'aws.amazonq.security.showFilters' }, () => async () => { + const filterState = SecurityTreeViewFilterState.instance.getState() + const quickPickItems: vscode.QuickPickItem[] = severities.map((severity) => ({ + label: severity, + picked: filterState.severity[severity], + })) + const result = await vscode.window.showQuickPick(quickPickItems, { + title: localize('aws.commands.amazonq.filterIssues', 'Filter Issues'), + placeHolder: localize('aws.amazonq.security.showFilters.placeholder', 'Select code issues to show'), + canPickMany: true, + }) + if (result) { + await SecurityTreeViewFilterState.instance.setState({ + ...filterState, + severity: severities.reduce( + (p, c) => ({ ...p, [c]: result.map(({ label }) => label).includes(c) }), + {} + ) as SecurityIssueFilters['severity'], + }) + } +}) + +export const focusIssue = Commands.declare( + { id: 'aws.amazonq.security.focusIssue' }, + () => async (issue: CodeScanIssue, filePath: string) => { + const document = await vscode.workspace.openTextDocument(filePath) + void vscode.window.showTextDocument(document, { + selection: new vscode.Range(issue.startLine, 0, issue.endLine, 0), + preserveFocus: true, + preview: true, + viewColumn: vscode.ViewColumn.One, + }) + + if (isSecurityIssueWebviewOpen()) { + void vscode.commands.executeCommand('aws.amazonq.openSecurityIssuePanel', issue, filePath) + } + } +) diff --git a/packages/core/src/codewhisperer/commands/startCodeFixGeneration.ts b/packages/core/src/codewhisperer/commands/startCodeFixGeneration.ts new file mode 100644 index 00000000000..78291bea6e8 --- /dev/null +++ b/packages/core/src/codewhisperer/commands/startCodeFixGeneration.ts @@ -0,0 +1,116 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { fs, getLogger, tempDirPath } from '../../shared' +import { + createCodeFixJob, + getCodeFixJob, + getPresignedUrlAndUpload, + pollCodeFixJobStatus, + throwIfCancelled, +} from '../service/codeFixHandler' +import { ArtifactMap, DefaultCodeWhispererClient } from '../client/codewhisperer' +import { codeFixState, CodeScanIssue } from '../models/model' +import { CreateCodeFixError } from '../models/errors' +import AdmZip from 'adm-zip' +import path from 'path' +import { TelemetryHelper } from '../util/telemetryHelper' + +export async function startCodeFixGeneration( + client: DefaultCodeWhispererClient, + issue: CodeScanIssue, + filePath: string, + codeFixName: string +) { + /** + * Step 0: Initial code fix telemetry + */ + // TODO: Telemetry + let jobId + let linesOfFixGenerated + let charsOfFixGenerated + try { + getLogger().verbose( + `Starting code fix generation for lines ${issue.startLine + 1} through ${issue.endLine} of file ${filePath}` + ) + + /** + * Step 1: Generate zip + */ + throwIfCancelled() + const admZip = new AdmZip() + admZip.addLocalFile(filePath) + + const zipFilePath = path.join(tempDirPath, 'codefix.zip') + admZip.writeZip(zipFilePath) + + /** + * Step 2: Get presigned Url, upload and clean up + */ + let artifactMap: ArtifactMap = {} + try { + artifactMap = await getPresignedUrlAndUpload(client, zipFilePath, codeFixName) + } finally { + await fs.delete(zipFilePath) + } + + /** + * Step 3: Create code fix job + */ + throwIfCancelled() + const codeFixJob = await createCodeFixJob( + client, + artifactMap.SourceCode, + { + start: { line: issue.startLine + 1, character: 0 }, + end: { line: issue.endLine, character: 0 }, + }, + issue.description.text, + codeFixName, + issue.ruleId + ) + if (codeFixJob.status === 'Failed') { + throw new CreateCodeFixError() + } + jobId = codeFixJob.jobId + issue.fixJobId = codeFixJob.jobId + getLogger().verbose(`Created code fix job.`) + + /** + * Step 4: Polling mechanism on code fix job status + */ + throwIfCancelled() + const jobStatus = await pollCodeFixJobStatus(client, String(codeFixJob.jobId)) + if (jobStatus === 'Failed') { + getLogger().verbose(`Code fix generation failed.`) + throw new CreateCodeFixError() + } + + /** + * Step 5: Process and render code fix results + */ + throwIfCancelled() + getLogger().verbose(`Code fix job succeeded and start processing result.`) + + const { suggestedFix } = await getCodeFixJob(client, String(codeFixJob.jobId)) + // eslint-disable-next-line aws-toolkits/no-json-stringify-in-log + getLogger().verbose(`Suggested fix: ${JSON.stringify(suggestedFix)}`) + return { suggestedFix, jobId } + } catch (err) { + getLogger().error('Code fix generation failed: %s', err) + throw err + } finally { + codeFixState.setToNotStarted() + if (jobId) { + TelemetryHelper.instance.sendCodeFixGenerationEvent( + jobId, + issue.language, + issue.ruleId, + issue.detectorId, + linesOfFixGenerated, + charsOfFixGenerated + ) + } + } +} diff --git a/packages/core/src/codewhisperer/commands/startSecurityScan.ts b/packages/core/src/codewhisperer/commands/startSecurityScan.ts index 5bd0b1552cf..698b9792187 100644 --- a/packages/core/src/codewhisperer/commands/startSecurityScan.ts +++ b/packages/core/src/codewhisperer/commands/startSecurityScan.ts @@ -24,22 +24,33 @@ import { runtimeLanguageContext } from '../util/runtimeLanguageContext' import { AggregatedCodeScanIssue, CodeScansState, + CodeScanState, codeScanState, CodeScanStoppedError, CodeScanTelemetryEntry, + onDemandFileScanState, + OnDemandFileScanState, } from '../models/model' import { cancel, ok } from '../../shared/localizedText' import { telemetry } from '../../shared/telemetry/telemetry' import { ToolkitError, getTelemetryReasonDesc, isAwsError } from '../../shared/errors' -import { openUrl } from '../../shared/utilities/vsCodeUtils' import { AuthUtil } from '../util/authUtil' import path from 'path' import { ZipMetadata, ZipUtil } from '../util/zipUtil' import { debounce } from 'lodash' import { once } from '../../shared/utilities/functionUtils' import { randomUUID } from '../../shared/crypto' -import { CodeAnalysisScope, ProjectSizeExceededErrorMessage } from '../models/constants' -import { CodeScanJobFailedError, CreateCodeScanFailedError, SecurityScanError } from '../models/errors' +import { CodeAnalysisScope, ProjectSizeExceededErrorMessage, SecurityScanStep } from '../models/constants' +import { + CodeScanJobFailedError, + CreateCodeScanFailedError, + MaximumFileScanReachedError, + MaximumProjectScanReachedError, + SecurityScanError, +} from '../models/errors' +import { SecurityIssuesTree } from '../service/securityIssueTreeViewProvider' +import { ChatSessionManager } from '../../amazonqScan/chat/storages/chatSession' +import { TelemetryHelper } from '../util/telemetryHelper' const localize = nls.loadMessageBundle() export const stopScanButton = localize('aws.codewhisperer.stopscan', 'Stop Scan') @@ -62,23 +73,23 @@ const getLogOutputChan = once(() => { export function startSecurityScanWithProgress( securityPanelViewProvider: SecurityPanelViewProvider, + editor: vscode.TextEditor | undefined, client: DefaultCodeWhispererClient, - context: vscode.ExtensionContext + context: vscode.ExtensionContext, + scope: CodeWhispererConstants.CodeAnalysisScope, + initiatedByChat: boolean ) { return vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, - title: CodeWhispererConstants.runningSecurityScan, + title: + scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT + ? CodeWhispererConstants.runningSecurityScan + : CodeWhispererConstants.runningFileScan, cancellable: false, }, async () => { - await startSecurityScan( - securityPanelViewProvider, - undefined, - client, - context, - CodeWhispererConstants.CodeAnalysisScope.PROJECT - ) + await startSecurityScan(securityPanelViewProvider, editor, client, context, scope, initiatedByChat) } ) } @@ -94,14 +105,16 @@ export async function startSecurityScan( client: DefaultCodeWhispererClient, context: vscode.ExtensionContext, scope: CodeWhispererConstants.CodeAnalysisScope, - zipUtil: ZipUtil = new ZipUtil() + initiatedByChat: boolean, + zipUtil: ZipUtil = new ZipUtil(), + scanUuid?: string ) { const logger = getLoggerForScope(scope) /** * Step 0: Initial Code Scan telemetry */ const codeScanStartTime = performance.now() - if (scope === CodeAnalysisScope.FILE) { + if (scope === CodeAnalysisScope.FILE_AUTO) { CodeScansState.instance.setLatestScanTime(codeScanStartTime) } let serviceInvocationStartTime = 0 @@ -124,13 +137,25 @@ export async function startSecurityScan( codewhispererCodeScanIssuesWithFixes: 0, credentialStartUrl: AuthUtil.instance.startUrl, codewhispererCodeScanScope: scope, + source: initiatedByChat ? 'chat' : 'menu', } + const fileName = editor?.document.fileName + const scanState = scope === CodeAnalysisScope.PROJECT ? codeScanState : onDemandFileScanState try { logger.verbose(`Starting security scan `) /** * Step 1: Generate zip */ throwIfCancelled(scope, codeScanStartTime) + if (initiatedByChat) { + scanState.getChatControllers()?.scanProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + step: SecurityScanStep.GENERATE_ZIP, + scope, + fileName, + scanUuid, + }) + } const zipMetadata = await zipUtil.generateZip(editor?.document.uri, scope) const projectPaths = zipUtil.getProjectPaths() @@ -148,7 +173,17 @@ export async function startSecurityScan( /** * Step 2: Get presigned Url, upload and clean up */ + throwIfCancelled(scope, codeScanStartTime) + if (initiatedByChat) { + scanState.getChatControllers()?.scanProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + step: SecurityScanStep.UPLOAD_TO_S3, + scope, + fileName, + scanUuid, + }) + } let artifactMap: ArtifactMap = {} const uploadStartTime = performance.now() const scanName = randomUUID() @@ -163,6 +198,15 @@ export async function startSecurityScan( * Step 3: Create scan job */ throwIfCancelled(scope, codeScanStartTime) + if (initiatedByChat) { + scanState.getChatControllers()?.scanProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + step: SecurityScanStep.CREATE_SCAN_JOB, + scope, + fileName, + scanUuid, + }) + } serviceInvocationStartTime = performance.now() const scanJob = await createScanJob( client, @@ -183,6 +227,15 @@ export async function startSecurityScan( * Step 4: Polling mechanism on scan job status */ throwIfCancelled(scope, codeScanStartTime) + if (initiatedByChat) { + scanState.getChatControllers()?.scanProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + step: SecurityScanStep.POLL_SCAN_STATUS, + scope, + fileName, + scanUuid, + }) + } const jobStatus = await pollScanJobStatus(client, scanJob.jobId, scope, codeScanStartTime) if (jobStatus === 'Failed') { logger.verbose(`Security scan failed.`) @@ -193,6 +246,15 @@ export async function startSecurityScan( * Step 5: Process and render scan results */ throwIfCancelled(scope, codeScanStartTime) + if (initiatedByChat) { + scanState.getChatControllers()?.scanProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + step: SecurityScanStep.PROCESS_SCAN_RESULTS, + scope, + fileName, + scanUuid, + }) + } logger.verbose(`Security scan job succeeded and start processing result.`) const securityRecommendationCollection = await listScanResults( client, @@ -213,53 +275,114 @@ export async function startSecurityScan( codeScanTelemetryEntry.codewhispererCodeScanIssuesWithFixes = withFixes throwIfCancelled(scope, codeScanStartTime) logger.verbose(`Security scan totally found ${total} issues. ${withFixes} of them have fixes.`) - showSecurityScanResults( - securityPanelViewProvider, - securityRecommendationCollection, - editor, - context, - scope, - zipMetadata, - total + /** + * initiatedByChat is true for PROJECT and FILE_ON_DEMAND scopes, + * initiatedByChat is false for PROJECT and FILE_AUTO scopes + */ + if (initiatedByChat) { + showScanResultsInChat( + securityPanelViewProvider, + securityRecommendationCollection, + editor, + context, + scope, + zipMetadata, + total, + scanUuid + ) + } else { + showSecurityScanResults( + securityPanelViewProvider, + securityRecommendationCollection, + editor, + context, + scope, + zipMetadata, + total, + scanUuid + ) + } + TelemetryHelper.instance.sendCodeScanSucceededEvent( + codeScanTelemetryEntry.codewhispererLanguage, + scanJob.jobId, + total, + scope ) logger.verbose(`Security scan completed.`) } catch (error) { getLogger().error('Security scan failed. %O', error) if (error instanceof CodeScanStoppedError) { + codeScanState.getChatControllers()?.scanCancelled.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + scanUuid, + }) codeScanTelemetryEntry.result = 'Cancelled' - } else { - errorPromptHelper(error as SecurityScanError, scope) + } else if (isAwsError(error) && error.code === 'ThrottlingException') { codeScanTelemetryEntry.result = 'Failed' - } - - if (isAwsError(error) && error.code === 'ThrottlingException') { if ( scope === CodeAnalysisScope.PROJECT && - error.message.includes(CodeWhispererConstants.projectScansThrottlingMessage) + error.message.includes(CodeWhispererConstants.scansLimitReachedErrorMessage) ) { - getLogger().error(CodeWhispererConstants.projectScansLimitReached) - void vscode.window.showErrorMessage(CodeWhispererConstants.projectScansLimitReached) + const maximumProjectScanReachedError = new MaximumProjectScanReachedError() + getLogger().error(maximumProjectScanReachedError.customerFacingMessage) + errorPromptHelper(maximumProjectScanReachedError, scope, initiatedByChat, fileName, scanUuid) // TODO: Should we set a graphical state? // We shouldn't set vsCodeState.isFreeTierLimitReached here because it will hide CW and Q chat options. - } else if ( - scope === CodeAnalysisScope.FILE && - error.message.includes(CodeWhispererConstants.fileScansThrottlingMessage) - ) { - getLogger().error(CodeWhispererConstants.fileScansLimitReached) + } else if (scope === CodeAnalysisScope.PROJECT) { + getLogger().error(error.message) + errorPromptHelper( + new SecurityScanError( + error.code, + (error as any).statusCode?.toString() ?? '', + 'Too many requests, please wait before trying again.' + ), + scope, + initiatedByChat, + fileName, + scanUuid + ) + } else { + const maximumFileScanReachedError = new MaximumFileScanReachedError() + getLogger().error(maximumFileScanReachedError.customerFacingMessage) + errorPromptHelper(maximumFileScanReachedError, scope, initiatedByChat, fileName, scanUuid) CodeScansState.instance.setMonthlyQuotaExceeded() } + } else { + codeScanTelemetryEntry.result = 'Failed' + errorPromptHelper( + new SecurityScanError( + (error as any).code ?? 'unknown error', + (error as any).statusCode?.toString() ?? '', + 'Encountered an unexpected error when processing the request, please try again' + ), + scope, + initiatedByChat, + fileName + ) } codeScanTelemetryEntry.reasonDesc = (error as ToolkitError)?.code === 'ContentLengthError' ? 'Payload size limit reached' : getTelemetryReasonDesc(error) codeScanTelemetryEntry.reason = (error as ToolkitError)?.code ?? 'DefaultError' + if (codeScanTelemetryEntry.codewhispererCodeScanJobId) { + TelemetryHelper.instance.sendCodeScanFailedEvent( + codeScanTelemetryEntry.codewhispererLanguage, + codeScanTelemetryEntry.codewhispererCodeScanJobId, + scope + ) + } } finally { - codeScanState.setToNotStarted() + const scanState = scope === CodeAnalysisScope.PROJECT ? codeScanState : onDemandFileScanState + scanState.setToNotStarted() + scanState.getChatControllers()?.scanStopped.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + scanUuid, + }) codeScanTelemetryEntry.duration = performance.now() - codeScanStartTime codeScanTelemetryEntry.codeScanServiceInvocationsDuration = performance.now() - serviceInvocationStartTime - await emitCodeScanTelemetry(codeScanTelemetryEntry, scope) + await emitCodeScanTelemetry(codeScanTelemetryEntry) } } @@ -270,39 +393,114 @@ export function showSecurityScanResults( context: vscode.ExtensionContext, scope: CodeWhispererConstants.CodeAnalysisScope, zipMetadata: ZipMetadata, - totalIssues: number + totalIssues: number, + scanUuid: string | undefined ) { if (isCloud9()) { securityPanelViewProvider.addLines(securityRecommendationCollection, editor) void vscode.commands.executeCommand('workbench.view.extension.aws-codewhisperer-security-panel') } else { initSecurityScanRender(securityRecommendationCollection, context, editor, scope) - if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) { - void vscode.commands.executeCommand('workbench.action.problems.focus') + if ( + totalIssues > 0 && + (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) + ) { + SecurityIssuesTree.instance.focus() } } if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) { populateCodeScanLogStream(zipMetadata.scannedFiles) - showScanCompletedNotification(totalIssues, zipMetadata.scannedFiles) } } -export async function emitCodeScanTelemetry( - codeScanTelemetryEntry: CodeScanTelemetryEntry, - scope: CodeWhispererConstants.CodeAnalysisScope +export function showScanResultsInChat( + securityPanelViewProvider: SecurityPanelViewProvider, + securityRecommendationCollection: AggregatedCodeScanIssue[], + editor: vscode.TextEditor | undefined, + context: vscode.ExtensionContext, + scope: CodeWhispererConstants.CodeAnalysisScope, + zipMetadata: ZipMetadata, + totalIssues: number, + scanUuid: string | undefined ) { + if (isCloud9()) { + securityPanelViewProvider.addLines(securityRecommendationCollection, editor) + void vscode.commands.executeCommand('workbench.view.extension.aws-codewhisperer-security-panel') + } else { + const tabID = ChatSessionManager.Instance.getSession().tabID + const eventData = { + message: 'Show Findings in the Chat panel', + totalIssues, + securityRecommendationCollection, + fileName: scope === CodeAnalysisScope.FILE_ON_DEMAND ? [...zipMetadata.scannedFiles][0] : undefined, + tabID, + scope, + scanUuid, + } + switch (scope) { + case CodeAnalysisScope.PROJECT: + codeScanState.getChatControllers()?.showSecurityScan.fire(eventData) + break + case CodeAnalysisScope.FILE_ON_DEMAND: + onDemandFileScanState.getChatControllers()?.showSecurityScan.fire(eventData) + break + default: + break + } + initSecurityScanRender(securityRecommendationCollection, context, editor, scope) + if (totalIssues > 0) { + SecurityIssuesTree.instance.focus() + } + } + populateCodeScanLogStream(zipMetadata.scannedFiles) + if (scope === CodeAnalysisScope.PROJECT) { + showScanCompletedNotification(totalIssues, zipMetadata.scannedFiles) + } +} + +export async function emitCodeScanTelemetry(codeScanTelemetryEntry: CodeScanTelemetryEntry) { codeScanTelemetryEntry.codewhispererCodeScanProjectBytes = 0 telemetry.codewhisperer_securityScan.emit({ ...codeScanTelemetryEntry, - passive: codeScanTelemetryEntry.codewhispererCodeScanScope === CodeAnalysisScope.FILE, + passive: codeScanTelemetryEntry.codewhispererCodeScanScope === CodeAnalysisScope.FILE_AUTO, }) } -export function errorPromptHelper(error: SecurityScanError, scope: CodeAnalysisScope) { - if (scope === CodeAnalysisScope.PROJECT) { - const message = - error.code === 'ContentLengthError' ? ProjectSizeExceededErrorMessage : error.customerFacingMessage - void vscode.window.showWarningMessage(message, ok) +export function errorPromptHelper( + error: SecurityScanError, + scope: CodeAnalysisScope, + initiatedByChat: boolean, + fileName?: string, + scanUuid?: string +) { + if (scope === CodeAnalysisScope.FILE_AUTO) { + return + } + if (initiatedByChat) { + const state = scope === CodeAnalysisScope.PROJECT ? codeScanState : onDemandFileScanState + state.getChatControllers()?.errorThrown.fire({ + error, + tabID: ChatSessionManager.Instance.getSession().tabID, + scope, + fileName, + scanUuid, + }) + } + if (error.code !== 'NoSourceFilesError') { + void vscode.window.showWarningMessage(getErrorMessage(error), ok) + } +} + +function getErrorMessage(error: any): string { + switch (error.code) { + case 'ContentLengthError': + return ProjectSizeExceededErrorMessage + case 'MaximumProjectScanReachedError': + case 'MaximumFileScanReachedError': + return CodeWhispererConstants.monthlyLimitReachedNotification + default: + return error.customerFacingMessage } } @@ -323,28 +521,36 @@ function populateCodeScanLogStream(scannedFiles: Set) { } } -export async function confirmStopSecurityScan() { +export async function confirmStopSecurityScan( + state: CodeScanState | OnDemandFileScanState, + initiatedByChat: boolean, + scope: CodeWhispererConstants.CodeAnalysisScope, + fileName: string | undefined, + scanUuid?: string +) { // Confirm if user wants to stop security scan const resp = await vscode.window.showWarningMessage(CodeWhispererConstants.stopScanMessage, stopScanButton, cancel) - if (resp === stopScanButton && codeScanState.isRunning()) { + if (resp === stopScanButton && state.isRunning()) { getLogger().verbose('User requested to stop security scan. Stopping security scan.') - codeScanState.setToCancelling() + state.setToCancelling() + if (initiatedByChat) { + const scanState = scope === CodeAnalysisScope.PROJECT ? codeScanState : onDemandFileScanState + const scopeText = scope === CodeAnalysisScope.PROJECT ? 'Project' : 'File' + scanState.getChatControllers()?.errorThrown.fire({ + error: scopeText + CodeWhispererConstants.stopScanMessageInChat, + tabID: ChatSessionManager.Instance.getSession().tabID, + scope, + fileName, + }) + } } } function showScanCompletedNotification(total: number, scannedFiles: Set) { - const totalFiles = `${scannedFiles.size} ${scannedFiles.size === 1 ? 'file' : 'files'}` - const totalIssues = `${total} ${total === 1 ? 'issue was' : 'issues were'}` - const learnMore = 'Learn More' const items = [CodeWhispererConstants.showScannedFilesMessage] - void vscode.window - .showInformationMessage(`Security scan completed for ${totalFiles}. ${totalIssues} found.`, ...items) - .then((value) => { - if (value === CodeWhispererConstants.showScannedFilesMessage) { - const [, codeScanOutpuChan] = getLogOutputChan() - codeScanOutpuChan.show() - } else if (value === learnMore) { - void openUrl(vscode.Uri.parse(CodeWhispererConstants.securityScanLearnMoreUri)) - } - }) + void vscode.window.showInformationMessage(`Code Review Completed`, ...items).then((value) => { + if (total > 0 && value === CodeWhispererConstants.showScannedFilesMessage) { + SecurityIssuesTree.instance.focus() + } + }) } diff --git a/packages/core/src/codewhisperer/commands/startTestGeneration.ts b/packages/core/src/codewhisperer/commands/startTestGeneration.ts new file mode 100644 index 00000000000..c4f6d06b939 --- /dev/null +++ b/packages/core/src/codewhisperer/commands/startTestGeneration.ts @@ -0,0 +1,260 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getLogger } from '../../shared/logger' +import { ZipUtil } from '../util/zipUtil' +import { ArtifactMap } from '../client/codewhisperer' +import { testGenerationLogsDir } from '../../shared/filesystemUtilities' +import { + createTestJob, + exportResultsArchive, + getPresignedUrlAndUploadTestGen, + pollTestJobStatus, + throwIfCancelled, +} from '../service/testGenHandler' +import path from 'path' +import { testGenState } from '..' +import { ChatSessionManager } from '../../amazonqTest/chat/storages/chatSession' +import { ChildProcess, spawn } from 'child_process' +import { BuildStatus } from '../../amazonqTest/chat/session/session' +import { fs } from '../../shared/fs/fs' +import { TestGenerationJobStatus } from '../models/constants' +import { TestGenFailedError } from '../models/errors' +import { Range } from '../client/codewhispereruserclient' + +// eslint-disable-next-line unicorn/no-null +let spawnResult: ChildProcess | null = null +let isCancelled = false +export async function startTestGenerationProcess( + fileName: string, + filePath: string, + userInputPrompt: string, + tabID: string, + initialExecution: boolean, + selectionRange?: Range +) { + const logger = getLogger() + const session = ChatSessionManager.Instance.getSession() + //TODO: Step 0: Initial Test Gen telemetry + try { + logger.verbose(`Starting Test Generation `) + logger.verbose(`Tab ID: ${tabID} !== ${session.tabID}`) + if (tabID !== session.tabID) { + logger.verbose(`Tab ID mismatch: ${tabID} !== ${session.tabID}`) + return + } + /** + * Zip the project + */ + + const zipUtil = new ZipUtil() + if (initialExecution) { + const projectPath = zipUtil.getProjectPath(filePath) ?? '' + const relativeTargetPath = path.relative(projectPath, filePath) + session.listOfTestGenerationJobId = [] + session.shortAnswer = undefined + session.sourceFilePath = relativeTargetPath + session.projectRootPath = projectPath + session.listOfTestGenerationJobId = [] + } + const zipMetadata = await zipUtil.generateZipTestGen(session.projectRootPath, initialExecution) + session.srcPayloadSize = zipMetadata.buildPayloadSizeInBytes + session.srcZipFileSize = zipMetadata.zipFileSizeInBytes + + /** + * Step 2: Get presigned Url, upload and clean up + */ + throwIfCancelled() + if (!shouldContinueRunning(tabID)) { + return + } + let artifactMap: ArtifactMap = {} + const uploadStartTime = performance.now() + try { + artifactMap = await getPresignedUrlAndUploadTestGen(zipMetadata) + } finally { + if (await fs.existsFile(path.join(testGenerationLogsDir, 'output.log'))) { + await fs.delete(path.join(testGenerationLogsDir, 'output.log')) + } + await zipUtil.removeTmpFiles(zipMetadata) + session.artifactsUploadDuration = performance.now() - uploadStartTime + } + + /** + * Step 3: Create scan job with startTestGeneration + */ + throwIfCancelled() + if (!shouldContinueRunning(tabID)) { + return + } + const sessionFilePath = session.sourceFilePath + const testJob = await createTestJob( + artifactMap, + [ + { + relativeTargetPath: sessionFilePath, + targetLineRangeList: selectionRange ? [selectionRange] : [], + }, + ], + userInputPrompt + ) + if (!testJob.testGenerationJob) { + throw Error('Test job not found') + } + session.testGenerationJob = testJob.testGenerationJob + + /** + * Step 4: Polling mechanism on test job status with getTestGenStatus + */ + throwIfCancelled() + if (!shouldContinueRunning(tabID)) { + return + } + const jobStatus = await pollTestJobStatus( + testJob.testGenerationJob.testGenerationJobId, + testJob.testGenerationJob.testGenerationJobGroupName, + fileName, + initialExecution + ) + //TODO: Send status to test summary + if (jobStatus === TestGenerationJobStatus.FAILED) { + logger.verbose(`Test generation failed.`) + throw new TestGenFailedError() + } + throwIfCancelled() + if (!shouldContinueRunning(tabID)) { + return + } + /** + * Step 5: Process and show the view diff by getting the results from exportResultsArchive + */ + //https://github.com/aws/aws-toolkit-vscode/blob/0164d4145e58ae036ddf3815455ea12a159d491d/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts#L314-L405 + await exportResultsArchive( + artifactMap.SourceCode, + testJob.testGenerationJob.testGenerationJobGroupName, + testJob.testGenerationJob.testGenerationJobId, + path.basename(session.projectRootPath), + session.projectRootPath, + initialExecution + ) + } catch (error) { + logger.error(`startTestGenerationProcess failed: %O`, error) + //TODO: Send error message to Chat + testGenState.getChatControllers()?.errorThrown.fire({ + tabID: session.tabID, + error: error, + }) + } finally { + testGenState.setToNotStarted() + } +} + +export function shouldContinueRunning(tabID: string): boolean { + if (tabID !== ChatSessionManager.Instance.getSession().tabID) { + getLogger().verbose(`Tab ID mismatch: ${tabID} !== ${ChatSessionManager.Instance.getSession().tabID}`) + return false + } + return true +} + +/** + * Run client side build with given build commands + */ +export async function runBuildCommand(listofBuildCommand: string[]): Promise { + for (const buildCommand of listofBuildCommand) { + try { + await fs.mkdir(testGenerationLogsDir) + const tmpFile = path.join(testGenerationLogsDir, 'output.log') + const result = await runLocalBuild(buildCommand, tmpFile) + if (result.isCancelled) { + return BuildStatus.CANCELLED + } + if (result.code !== 0) { + return BuildStatus.FAILURE + } + } catch (error) { + getLogger().error(`Build process error`) + return BuildStatus.FAILURE + } + } + return BuildStatus.SUCCESS +} + +function runLocalBuild( + buildCommand: string, + tmpFile: string +): Promise<{ code: number | null; isCancelled: boolean; message: string }> { + return new Promise(async (resolve, reject) => { + const environment = process.env + const repositoryPath = ChatSessionManager.Instance.getSession().projectRootPath + const [command, ...args] = buildCommand.split(' ') + getLogger().info(`Build process started for command: ${buildCommand}, for path: ${repositoryPath}`) + + let buildLogs = '' + + spawnResult = spawn(command, args, { + cwd: repositoryPath, + shell: true, + env: environment, + }) + + if (spawnResult.stdout) { + spawnResult.stdout.on('data', async (data) => { + const output = data.toString().trim() + getLogger().info(`BUILD OUTPUT: ${output}`) + buildLogs += output + }) + } + + if (spawnResult.stderr) { + spawnResult.stderr.on('data', async (data) => { + const output = data.toString().trim() + getLogger().warn(`BUILD ERROR: ${output}`) + buildLogs += output + }) + } + + spawnResult.on('close', async (code) => { + let message = '' + if (isCancelled) { + message = 'Build cancelled' + getLogger().info('BUILD CANCELLED') + } else if (code === 0) { + message = 'Build successful' + getLogger().info('BUILD SUCCESSFUL') + } else { + message = `Build failed with exit code ${code}` + getLogger().info(`BUILD FAILED with exit code ${code}`) + } + + try { + await fs.writeFile(tmpFile, buildLogs) + getLogger().info(`Build logs written to ${tmpFile}`) + } catch (error) { + getLogger().error(`Failed to write build logs to ${tmpFile}: ${error}`) + } + + resolve({ code, isCancelled, message }) + + // eslint-disable-next-line unicorn/no-null + spawnResult = null + isCancelled = false + }) + + spawnResult.on('error', (error) => { + reject(new Error(`Failed to start build process: ${error.message}`)) + }) + }) +} + +export function cancelBuild() { + if (spawnResult) { + isCancelled = true + spawnResult.kill() + getLogger().info('Build cancellation requested') + } else { + getLogger().info('No active build to cancel') + } +} diff --git a/packages/core/src/codewhisperer/index.ts b/packages/core/src/codewhisperer/index.ts index 1c6423f7c86..54a1c508322 100644 --- a/packages/core/src/codewhisperer/index.ts +++ b/packages/core/src/codewhisperer/index.ts @@ -45,6 +45,14 @@ export { listCodeWhispererCommands, listCodeWhispererCommandsId } from './ui/sta export { refreshStatusBar, CodeWhispererStatusBar, InlineCompletionService } from './service/inlineCompletionService' export { SecurityIssueHoverProvider } from './service/securityIssueHoverProvider' export { SecurityIssueCodeActionProvider } from './service/securityIssueCodeActionProvider' +export { + SecurityIssueTreeViewProvider, + SecurityViewTreeItem, + SecurityIssuesTree, + FileItem, + IssueItem, + SeverityItem, +} from './service/securityIssueTreeViewProvider' export { invokeRecommendation } from './commands/invokeRecommendation' export { onAcceptance } from './commands/onAcceptance' export { CodeWhispererTracker } from './tracker/codewhispererTracker' @@ -88,5 +96,8 @@ export * as supplementalContextUtil from './util/supplementalContext/supplementa export * from './service/diagnosticsProvider' export * as diagnosticsProvider from './service/diagnosticsProvider' export * from './ui/codeWhispererNodes' +export { SecurityScanError } from '../codewhisperer/models/errors' +export * as CodeWhispererConstants from '../codewhisperer/models/constants' export { getSelectedCustomization } from './util/customizationUtil' export { Container } from './service/serviceContainer' +export * from './util/gitUtil' diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index b86444d59f0..7020de28c31 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -144,11 +144,13 @@ export type PlatformLanguageId = (typeof platformLanguageIds)[number] */ export const pendingResponse = 'Waiting for Amazon Q...' -export const runningSecurityScan = 'Scanning project for security issues...' +export const runningSecurityScan = 'Reviewing project for code issues...' + +export const runningFileScan = 'Reviewing current file for code issues...' export const noSuggestions = 'No suggestions from Amazon Q' -export const licenseFilter = 'Amazon Q suggestions were filtered due to reference setting' +export const licenseFilter = 'Amazon Q suggestions were filtered due to reference settings' /** * Key bindings JSON file path @@ -249,22 +251,40 @@ export const projectScanUploadIntent = 'FULL_PROJECT_SECURITY_SCAN' export const codeScanTruncDirPrefix = 'codewhisperer_scan' +export const TestGenerationTruncDirPrefix = 'Q_TestGeneration' + export const codeScanZipExt = '.zip' export const contextTruncationTimeoutSeconds = 10 export const codeScanJobTimeoutSeconds = 60 * 10 //10 minutes -export const codeFileScanJobTimeoutSeconds = 60 //1 minute +export const codeFileScanJobTimeoutSeconds = 60 * 10 //10 minutes + +export const codeFixJobTimeoutMs = 60_000 export const projectSizeCalculateTimeoutSeconds = 10 export const codeScanJobPollingIntervalSeconds = 1 +export const codeFixJobPollingIntervalMs = 1000 + export const fileScanPollingDelaySeconds = 10 export const projectScanPollingDelaySeconds = 30 +export const codeFixJobPollingDelayMs = 5_000 + +export const testGenPollingDelaySeconds = 10 + +export const testGenJobPollingIntervalMilliseconds = 1000 + +export const testGenJobTimeoutMilliseconds = 60 * 10 * 1000 // 10 minutes + +export const testGenUploadIntent = 'UNIT_TESTS_GENERATION' + +export const codeFixUploadIntent = 'CODE_FIX_GENERATION' + export const artifactTypeSource = 'SourceCode' export const codeScanFindingsSchema = 'codescan/findings/1.0' @@ -328,30 +348,41 @@ export const settingsLearnMore = 'Learn More about Amazon Q Settings' export const freeTierLimitReached = 'You have reached the monthly fair use limit of code recommendations.' -export const freeTierLimitReachedCodeScan = 'You have reached the monthly quota of code scans.' +export const freeTierLimitReachedCodeScan = 'You have reached the monthly quota of code reviews.' -export const fileScansLimitReached = 'Amazon Q: You have reached the monthly limit for auto-scans.' +export const scansLimitReachedErrorMessage = + 'Maximum com.amazon.aws.codewhisperer.StartCodeAnalysis reached for this month.' -export const projectScansLimitReached = 'Amazon Q: You have reached the monthly limit for project scans.' +export const utgLimitReached = + 'Maximum com.amazon.aws.codewhisperer.runtime.StartTestGeneration reached for this month.' export const DefaultCodeScanErrorMessage = - 'Amazon Q encountered an error while scanning for security issues. Try again later.' + 'Amazon Q encountered an error while reviewing for code issues. Try again later.' + +export const defaultTestGenErrorMessage = 'Amazon Q encountered an error while generating tests. Try again later.' + +export const defaultCodeFixErrorMessage = 'Amazon Q encountered an error while generating code fixes. Try again later.' + +export const FileSizeExceededErrorMessage = `Amazon Q: The selected file exceeds the input artifact limit. Try again with a smaller file. For more information about review limits, see the [Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security-scans.html#quotas).` + +export const ProjectSizeExceededErrorMessage = `Amazon Q: The selected workspace exceeds the input artifact limit. Try again with a smaller workspace. For more information about review limits, see the [Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security-scans.html#quotas).` -export const FileSizeExceededErrorMessage = `Amazon Q: The selected file exceeds the input artifact limit. Try again with a smaller file. For more information about scan limits, see the [Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security-scans.html#quotas).` +export const monthlyLimitReachedNotification = + "You've reached the monthly quota for Amazon Q Developer's agent capabilities. You can try again next month. For more information on usage limits, see the Amazon Q Developer pricing page." -export const ProjectSizeExceededErrorMessage = `Amazon Q: The selected project exceeds the input artifact limit. Try again with a smaller project. For more information about scan limits, see the [Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security-scans.html#quotas).` +export const noSourceFilesErrorMessage = 'Amazon Q: workspace does not contain valid files to review' -export const noSourceFilesErrorMessage = 'Amazon Q: Project does not contain valid files to scan' +export const noActiveFileErrorMessage = 'Amazon Q: Open valid file to run a file review' -export const UploadArtifactToS3ErrorMessage = `Amazon Q is unable to upload your workspace artifacts to Amazon S3 for security scans. For more information, see the [Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security_iam_manage-access-with-policies.html#data-perimeters).` +export const UploadArtifactToS3ErrorMessage = `Amazon Q is unable to upload your workspace artifacts to Amazon S3 for security reviews. For more information, see the [Amazon Q documentation](https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/security_iam_manage-access-with-policies.html#data-perimeters).` export const throttlingLearnMore = `Learn More` export const throttlingMessage = `Maximum recommendation count reached for this month` -export const fileScansThrottlingMessage = `Maximum auto-scans count reached for this month` +export const fileScansThrottlingMessage = `Maximum file reviews count reached for this month` -export const projectScansThrottlingMessage = `Maximum project scan count reached for this month` +export const projectScansThrottlingMessage = `Maximum workspace review count reached for this month` export const connectionChangeMessage = `Keep using Amazon Q with ` @@ -363,9 +394,16 @@ export const failedToConnectAwsBuilderId = `Failed to connect to AWS Builder ID` export const failedToConnectIamIdentityCenter = `Failed to connect to IAM Identity Center` export const stopScanMessage = - 'Stop security scan? This scan will be counted as one complete scan towards your monthly security scan limits.' + 'Stop security review? This review will be counted as one complete review towards your monthly security review limits.' -export const showScannedFilesMessage = 'Show Scanned Files' +//TODO: Change the Text according to the UX +export const stopScanMessageInChat = 'Review is stopped. Retry reviews by selecting below options' + +export const showScannedFilesMessage = 'View Code Issues' + +export const ignoreAllIssuesMessage = (issueTitle: string) => { + return `Are you sure you want to ignore all "${issueTitle}" issues? Amazon Q will not show these issues for future reviews. You can manage a list of your ignored issues in the Amazon Q extension settings.` +} export const updateInlineLockKey = 'CODEWHISPERER_INLINE_UPDATE_LOCK_KEY' @@ -506,7 +544,7 @@ export const buildingCodeMessage = 'Amazon Q is building your code using Java JAVA_VERSION_HERE in a secure build environment.' export const scanningProjectMessage = - 'Amazon Q is scanning the project files and getting ready to start the job. To start the job, Amazon Q needs to upload the project artifacts. Once that is done, Amazon Q can start the transformation job. The estimated time for this operation ranges from a few seconds to several minutes.' + 'Amazon Q is reviewing the project files and getting ready to start the job. To start the job, Amazon Q needs to upload the project artifacts. Once that is done, Amazon Q can start the transformation job. The estimated time for this operation ranges from a few seconds to several minutes.' export const failedStepMessage = 'The step failed, fetching additional details...' @@ -645,7 +683,7 @@ export const noJavaHomeFoundChatMessage = `Sorry, I couldn\'t locate your Java i export const dependencyVersionsErrorMessage = 'I could not find any other versions of this dependency in your local Maven repository. Try transforming the dependency to make it compatible with Java 17, and then try transforming this module again.' -export const errorUploadingWithExpiredUrl = `The upload error may have been caused by the expiration of the S3 pre-signed URL that was used to upload code artifacts to Q Code Transformation. The S3 pre-signed URL expires in 30 minutes. This could be caused by any delays introduced by intermediate services in your network infrastructure. Please investigate your network configuration and consider allowlisting 'amazonq-code-transformation-us-east-1-c6160f047e0.s3.amazonaws.com' to skip any scanning that might delay the upload. For more information, see the [Amazon Q documentation](${codeTransformTroubleshootAllowS3Access}).` +export const errorUploadingWithExpiredUrl = `The upload error may have been caused by the expiration of the S3 pre-signed URL that was used to upload code artifacts to Q Code Transformation. The S3 pre-signed URL expires in 30 minutes. This could be caused by any delays introduced by intermediate services in your network infrastructure. Please investigate your network configuration and consider allowlisting 'amazonq-code-transformation-us-east-1-c6160f047e0.s3.amazonaws.com' to skip any reviewing that might delay the upload. For more information, see the [Amazon Q documentation](${codeTransformTroubleshootAllowS3Access}).` export const socketConnectionFailed = 'Please check your network connectivity or firewall configuration, and then try again.' @@ -695,6 +733,14 @@ export const changesAppliedNotificationMultipleDiffs = (currentPatchIndex: numbe export const noOpenProjectsFoundChatMessage = `I couldn\'t find a project that I can upgrade. Currently, I support Java 8, Java 11, and Java 17 projects built on Maven. Make sure your project is open in the IDE. For more information, see the [Amazon Q documentation](${codeTransformPrereqDoc}).` +export const noOpenFileFoundChatMessage = `Sorry, there isn't a source file open right now that I can generate a test for. Make sure you open a source file so I can generate tests.` + +export const invalidFileTypeChatMessage = `Sorry, your current active window is not a source code file. Make sure you select a source file as your primary context.` + +export const noOpenProjectsFoundChatTestGenMessage = `Sorry, I couldn\'t find a project to generate tests` + +export const unitTestGenerationCancelMessage = 'Unit test generation cancelled.' + 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' @@ -802,7 +848,15 @@ export const supplemetalContextFetchingTimeoutMsg = 'Amazon Q supplemental conte export const codeFixAppliedFailedMessage = 'Failed to apply suggested code fix.' -export const runSecurityScanButtonTitle = 'Run security scan' +export const runSecurityScanButtonTitle = 'Run security review' + +export const startProjectScan = 'Review Project' + +export const startFileScan = 'Review Current File in Focus' + +export const noOpenProjectsFound = `Sorry, I couldn\'t find a project in the workspace. Open a project in your IDE and retry the review.` + +export const noOpenFileFound = `Sorry, I couldn\'t find an active file in the editor. Open a file in your IDE and retry the review.` export const crossFileContextConfig = { numberOfChunkToFetch: 60, @@ -816,6 +870,39 @@ export const utgConfig = { } export enum CodeAnalysisScope { - FILE = 'FILE', + FILE_AUTO = 'FILE_AUTO', + FILE_ON_DEMAND = 'FILE_ON_DEMAND', PROJECT = 'PROJECT', } + +export enum TestGenerationJobStatus { + IN_PROGRESS = 'IN_PROGRESS', + FAILED = 'FAILED', + COMPLETED = 'COMPLETED', +} + +export enum ZipUseCase { + TEST_GENERATION = 'TEST_GENERATION', + CODE_SCAN = 'CODE_SCAN', +} + +export const amazonqIgnoreNextLine = 'amazonq-ignore-next-line' + +export enum TestGenerationBuildStep { + START_STEP, + INSTALL_DEPENDENCIES, + RUN_BUILD, + RUN_EXECUTION_TESTS, + FIXING_TEST_CASES, + PROCESS_TEST_RESULTS, +} + +export enum SecurityScanStep { + GENERATE_ZIP, + UPLOAD_TO_S3, + CREATE_SCAN_JOB, + POLL_SCAN_STATUS, + PROCESS_SCAN_RESULTS, +} + +export const amazonqCodeIssueDetailsTabTitle = 'Code Issue Details' diff --git a/packages/core/src/codewhisperer/models/errors.ts b/packages/core/src/codewhisperer/models/errors.ts index 9deb401cd6c..3fe22f22af0 100644 --- a/packages/core/src/codewhisperer/models/errors.ts +++ b/packages/core/src/codewhisperer/models/errors.ts @@ -2,12 +2,16 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '../../shared' import { ToolkitError } from '../../shared/errors' import { DefaultCodeScanErrorMessage, FileSizeExceededErrorMessage, ProjectSizeExceededErrorMessage, UploadArtifactToS3ErrorMessage, + defaultCodeFixErrorMessage, + defaultTestGenErrorMessage, + noActiveFileErrorMessage, noSourceFilesErrorMessage, } from './constants' @@ -51,6 +55,12 @@ export class NoSourceFilesError extends SecurityScanError { } } +export class NoActiveFileError extends SecurityScanError { + constructor() { + super('Open valid file to run a file scan', 'NoActiveFileError', noActiveFileErrorMessage) + } +} + export class CreateUploadUrlError extends SecurityScanError { constructor(error: string) { super(error, 'CreateUploadUrlError', DefaultCodeScanErrorMessage) @@ -86,3 +96,79 @@ export class CodeScanJobFailedError extends SecurityScanError { super('Security scan failed.', 'CodeScanJobFailedError', DefaultCodeScanErrorMessage) } } + +export class MaximumFileScanReachedError extends SecurityScanError { + constructor() { + super( + 'Maximum file review count reached for this month.', + 'MaximumFileScanReachedError', + i18n('AWS.amazonq.featureDev.error.monthlyLimitReached') + ) + } +} + +export class MaximumProjectScanReachedError extends SecurityScanError { + constructor() { + super( + 'Maximum project review count reached for this month', + 'MaximumProjectScanReachedError', + i18n('AWS.amazonq.featureDev.error.monthlyLimitReached') + ) + } +} + +export class TestGenError extends ToolkitError { + constructor( + error: string, + code: string, + public customerFacingMessage: string + ) { + super(error, { code }) + } +} + +export class TestGenTimedOutError extends TestGenError { + constructor() { + super('Test generation failed. Amazon Q timed out.', 'TestGenTimedOutError', defaultTestGenErrorMessage) + } +} + +export class TestGenStoppedError extends TestGenError { + constructor() { + super('Test generation stopped by user.', 'TestGenCancelled', defaultTestGenErrorMessage) + } +} + +export class TestGenFailedError extends TestGenError { + constructor(error?: string) { + super(error ?? 'Test generation failed', 'TestGenFailedError', defaultTestGenErrorMessage) + } +} + +export class CodeFixError extends ToolkitError { + constructor( + error: string, + code: string, + public customerFacingMessage: string + ) { + super(error, { code }) + } +} + +export class CreateCodeFixError extends CodeFixError { + constructor() { + super('Code fix generation failed', 'CreateCodeFixFailed', defaultCodeFixErrorMessage) + } +} + +export class CodeFixJobTimedOutError extends CodeFixError { + constructor() { + super('Code fix generation failed. Amazon Q timed out.', 'CodeFixTimedOutError', defaultCodeFixErrorMessage) + } +} + +export class CodeFixJobStoppedError extends CodeFixError { + constructor() { + super('Code fix generation stopped by user.', 'CodeFixCancelled', defaultCodeFixErrorMessage) + } +} diff --git a/packages/core/src/codewhisperer/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index 82b2457b74b..ade478ad875 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' import { ToolkitError } from '../../shared/errors' -import { getIcon } from '../../shared/icons' +import { getIcon, Icon } from '../../shared/icons' import { CodewhispererCodeScanScope, CodewhispererCompletionType, @@ -18,6 +18,8 @@ import globals from '../../shared/extensionGlobals' import { ChatControllerEventEmitters } from '../../amazonqGumby/chat/controller/controller' 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' // unavoidable global variables interface VsCodeState { @@ -120,6 +122,136 @@ export class CodeSuggestionsState { } } +export interface AcceptedSuggestionEntry { + readonly time: Date + readonly fileUrl: vscode.Uri + readonly originalString: string + readonly startPosition: vscode.Position + readonly endPosition: vscode.Position + readonly requestId: string + readonly sessionId: string + readonly index: number + readonly triggerType: CodewhispererTriggerType + readonly completionType: CodewhispererCompletionType + readonly language: CodewhispererLanguage +} + +export interface OnRecommendationAcceptanceEntry { + readonly editor: vscode.TextEditor | undefined + readonly range: vscode.Range + readonly effectiveRange: vscode.Range + readonly acceptIndex: number + readonly recommendation: string + readonly requestId: string + readonly sessionId: string + readonly triggerType: CodewhispererTriggerType + readonly completionType: CodewhispererCompletionType + readonly language: CodewhispererLanguage + readonly references: References | undefined +} + +export interface ConfigurationEntry { + readonly isShowMethodsEnabled: boolean + readonly isManualTriggerEnabled: boolean + readonly isAutomatedTriggerEnabled: boolean + readonly isSuggestionsWithCodeReferencesEnabled: boolean +} + +export interface InlineCompletionItem { + content: string + index: number +} + +/** + * Q Security Scans + */ + +enum ScanStatus { + NotStarted, + Running, + Cancelling, +} + +type IconPath = { light: vscode.Uri; dark: vscode.Uri; toString: () => string } | Icon + +abstract class BaseScanState { + protected scanState: ScanStatus = ScanStatus.NotStarted + + protected chatControllers: ScanChatControllerEventEmitters | undefined = undefined + + public isNotStarted(): boolean { + return this.scanState === ScanStatus.NotStarted + } + + public isRunning(): boolean { + return this.scanState === ScanStatus.Running + } + + public isCancelling(): boolean { + return this.scanState === ScanStatus.Cancelling + } + + public setToNotStarted(): void { + this.scanState = ScanStatus.NotStarted + } + + public setToCancelling(): void { + this.scanState = ScanStatus.Cancelling + } + + public setToRunning(): void { + this.scanState = ScanStatus.Running + } + + public getPrefixTextForButton(): string { + switch (this.scanState) { + case ScanStatus.NotStarted: + return 'Run' + case ScanStatus.Running: + return 'Stop' + case ScanStatus.Cancelling: + return 'Stopping' + } + } + + public setChatControllers(controllers: ScanChatControllerEventEmitters) { + this.chatControllers = controllers + } + public getChatControllers() { + return this.chatControllers + } + + public abstract getIconForButton(): IconPath +} + +export class CodeScanState extends BaseScanState { + public getIconForButton(): IconPath { + switch (this.scanState) { + case ScanStatus.NotStarted: + return getIcon('vscode-debug-all') + case ScanStatus.Running: + return getIcon('vscode-stop-circle') + case ScanStatus.Cancelling: + return getIcon('vscode-loading~spin') + } + } +} + +export class OnDemandFileScanState extends BaseScanState { + public getIconForButton(): IconPath { + switch (this.scanState) { + case ScanStatus.NotStarted: + return getIcon('vscode-debug-all') + case ScanStatus.Running: + return getIcon('vscode-stop-circle') + case ScanStatus.Cancelling: + return getIcon('vscode-icons:loading~spin') + } + } +} +export const codeScanState: CodeScanState = new CodeScanState() +export const onDemandFileScanState: OnDemandFileScanState = new OnDemandFileScanState() + export class CodeScansState { /** The initial state if scan state was not defined */ #fallback: boolean @@ -175,113 +307,266 @@ export class CodeScansState { } } -export interface AcceptedSuggestionEntry { - readonly time: Date - readonly fileUrl: vscode.Uri - readonly originalString: string - readonly startPosition: vscode.Position - readonly endPosition: vscode.Position - readonly requestId: string - readonly sessionId: string - readonly index: number - readonly triggerType: CodewhispererTriggerType - readonly completionType: CodewhispererCompletionType - readonly language: CodewhispererLanguage +export class CodeScanStoppedError extends ToolkitError { + constructor() { + super('Security scan stopped by user.', { cancelled: true }) + } } -export interface OnRecommendationAcceptanceEntry { - readonly editor: vscode.TextEditor | undefined - readonly range: vscode.Range - readonly effectiveRange: vscode.Range - readonly acceptIndex: number - readonly recommendation: string - readonly requestId: string - readonly sessionId: string - readonly triggerType: CodewhispererTriggerType - readonly completionType: CodewhispererCompletionType - readonly language: CodewhispererLanguage - readonly references: References | undefined +export interface CodeScanTelemetryEntry extends MetricBase { + codewhispererCodeScanJobId?: string + codewhispererLanguage: CodewhispererLanguage + codewhispererCodeScanProjectBytes?: number + codewhispererCodeScanSrcPayloadBytes: number + codewhispererCodeScanBuildPayloadBytes?: number + codewhispererCodeScanSrcZipFileBytes: number + codewhispererCodeScanBuildZipFileBytes?: number + codewhispererCodeScanLines: number + duration: number + contextTruncationDuration: number + artifactsUploadDuration: number + codeScanServiceInvocationsDuration: number + result: Result + reason?: string + reasonDesc?: string + codewhispererCodeScanTotalIssues: number + codewhispererCodeScanIssuesWithFixes: number + credentialStartUrl: string | undefined + codewhispererCodeScanScope: CodewhispererCodeScanScope + source?: string } -export interface ConfigurationEntry { - readonly isShowMethodsEnabled: boolean - readonly isManualTriggerEnabled: boolean - readonly isAutomatedTriggerEnabled: boolean - readonly isSuggestionsWithCodeReferencesEnabled: boolean +export interface RecommendationDescription { + text: string + markdown: string } -export interface InlineCompletionItem { +export interface Recommendation { + text: string + url: string +} + +export interface SuggestedFix { + description: string + code?: string + references?: References +} + +export interface Remediation { + recommendation: Recommendation + suggestedFixes: SuggestedFix[] +} + +export interface CodeLine { content: string - index: number + number: number } /** - * Security Scan Interfaces + * Unit Test Generation */ -enum CodeScanStatus { +enum TestGenStatus { NotStarted, Running, Cancelling, } - -export class CodeScanState { +//TODO: Refactor model of /scan and /test +export class TestGenState { // Define a constructor for this class - private codeScanState: CodeScanStatus = CodeScanStatus.NotStarted + private testGenState: TestGenStatus = TestGenStatus.NotStarted + + protected chatControllers: TestChatControllerEventEmitters | undefined = undefined public isNotStarted() { - return this.codeScanState === CodeScanStatus.NotStarted + return this.testGenState === TestGenStatus.NotStarted } public isRunning() { - return this.codeScanState === CodeScanStatus.Running + return this.testGenState === TestGenStatus.Running } public isCancelling() { - return this.codeScanState === CodeScanStatus.Cancelling + return this.testGenState === TestGenStatus.Cancelling } public setToNotStarted() { - this.codeScanState = CodeScanStatus.NotStarted + this.testGenState = TestGenStatus.NotStarted } public setToCancelling() { - this.codeScanState = CodeScanStatus.Cancelling + this.testGenState = TestGenStatus.Cancelling } public setToRunning() { - this.codeScanState = CodeScanStatus.Running + this.testGenState = TestGenStatus.Running } - public getPrefixTextForButton() { - switch (this.codeScanState) { - case CodeScanStatus.NotStarted: - return 'Run' - case CodeScanStatus.Running: - return 'Stop' - case CodeScanStatus.Cancelling: - return 'Stopping' - } + public setChatControllers(controllers: TestChatControllerEventEmitters) { + this.chatControllers = controllers + } + public getChatControllers() { + return this.chatControllers } +} - public getIconForButton() { - switch (this.codeScanState) { - case CodeScanStatus.NotStarted: - return getIcon('vscode-debug-all') - case CodeScanStatus.Running: - return getIcon('vscode-stop-circle') - case CodeScanStatus.Cancelling: - return getIcon('vscode-loading~spin') - } +export const testGenState: TestGenState = new TestGenState() + +enum CodeFixStatus { + NotStarted, + Running, + Cancelling, +} + +export class CodeFixState { + // Define a constructor for this class + private codeFixState: CodeFixStatus = CodeFixStatus.NotStarted + + public isNotStarted() { + return this.codeFixState === CodeFixStatus.NotStarted + } + + public isRunning() { + return this.codeFixState === CodeFixStatus.Running + } + + public isCancelling() { + return this.codeFixState === CodeFixStatus.Cancelling + } + + public setToNotStarted() { + this.codeFixState = CodeFixStatus.NotStarted + } + + public setToCancelling() { + this.codeFixState = CodeFixStatus.Cancelling + } + + public setToRunning() { + this.codeFixState = CodeFixStatus.Running } } -export const codeScanState: CodeScanState = new CodeScanState() +export const codeFixState: CodeFixState = new CodeFixState() -export class CodeScanStoppedError extends ToolkitError { - constructor() { - super('Security scan stopped by user.', { cancelled: true }) +/** + * Security Scan Interfaces + */ + +export interface RawCodeScanIssue { + filePath: string + startLine: number + endLine: number + title: string + description: RecommendationDescription + detectorId: string + detectorName: string + findingId: string + ruleId?: string + relatedVulnerabilities: string[] + severity: string + remediation: Remediation + codeSnippet: CodeLine[] +} + +export interface CodeScanIssue { + startLine: number + endLine: number + comment: string + title: string + description: RecommendationDescription + detectorId: string + detectorName: string + findingId: string + ruleId?: string + relatedVulnerabilities: string[] + severity: string + recommendation: Recommendation + suggestedFixes: SuggestedFix[] + visible: boolean + scanJobId: string + language: string + fixJobId?: string +} + +export interface AggregatedCodeScanIssue { + filePath: string + issues: CodeScanIssue[] +} + +export interface SecurityPanelItem { + path: string + range: vscode.Range + severity: vscode.DiagnosticSeverity + message: string + issue: CodeScanIssue + decoration: vscode.DecorationOptions +} + +export interface SecurityPanelSet { + path: string + uri: vscode.Uri + items: SecurityPanelItem[] +} + +export const severities = ['Critical', 'High', 'Medium', 'Low', 'Info'] as const +export type Severity = (typeof severities)[number] + +export interface SecurityIssueFilters { + severity: { + Critical: boolean + High: boolean + Medium: boolean + Low: boolean + Info: boolean } } +const defaultVisibilityState: SecurityIssueFilters = { + severity: { + Critical: true, + High: true, + Medium: true, + Low: true, + Info: true, + }, +} + +export class SecurityTreeViewFilterState { + #fallback: SecurityIssueFilters + #onDidChangeState = new vscode.EventEmitter() + onDidChangeState = this.#onDidChangeState.event + + static #instance: SecurityTreeViewFilterState + static get instance() { + return (this.#instance ??= new this()) + } + + protected constructor(fallback: SecurityIssueFilters = defaultVisibilityState) { + this.#fallback = fallback + } + + public getState(): SecurityIssueFilters { + return globals.globalState.tryGet('aws.amazonq.securityIssueFilters', Object) ?? this.#fallback + } + + public async setState(state: SecurityIssueFilters) { + await globals.globalState.update('aws.amazonq.securityIssueFilters', state) + this.#onDidChangeState.fire(state) + } + + public getHiddenSeverities() { + return Object.entries(this.getState().severity) + .filter(([_, value]) => !value) + .map(([key]) => key) + } + + public resetFilters() { + return this.setState(defaultVisibilityState) + } +} + +/** + * Q - Transform + */ // for internal use; store status of job export enum TransformByQStatus { @@ -820,105 +1105,6 @@ export class TransformByQStoppedError extends ToolkitError { } } -export interface CodeScanTelemetryEntry extends MetricBase { - codewhispererCodeScanJobId?: string - codewhispererLanguage: CodewhispererLanguage - codewhispererCodeScanProjectBytes?: number - codewhispererCodeScanSrcPayloadBytes: number - codewhispererCodeScanBuildPayloadBytes?: number - codewhispererCodeScanSrcZipFileBytes: number - codewhispererCodeScanBuildZipFileBytes?: number - codewhispererCodeScanLines: number - duration: number - contextTruncationDuration: number - artifactsUploadDuration: number - codeScanServiceInvocationsDuration: number - result: Result - reason?: string - reasonDesc?: string - codewhispererCodeScanTotalIssues: number - codewhispererCodeScanIssuesWithFixes: number - credentialStartUrl: string | undefined - codewhispererCodeScanScope: CodewhispererCodeScanScope -} - -export interface RecommendationDescription { - text: string - markdown: string -} - -export interface Recommendation { - text: string - url: string -} - -export interface SuggestedFix { - description: string - code: string -} - -export interface Remediation { - recommendation: Recommendation - suggestedFixes: SuggestedFix[] -} - -export interface CodeLine { - content: string - number: number -} - -export interface RawCodeScanIssue { - filePath: string - startLine: number - endLine: number - title: string - description: RecommendationDescription - detectorId: string - detectorName: string - findingId: string - ruleId?: string - relatedVulnerabilities: string[] - severity: string - remediation: Remediation - codeSnippet: CodeLine[] -} - -export interface CodeScanIssue { - startLine: number - endLine: number - comment: string - title: string - description: RecommendationDescription - detectorId: string - detectorName: string - findingId: string - ruleId?: string - relatedVulnerabilities: string[] - severity: string - recommendation: Recommendation - suggestedFixes: SuggestedFix[] -} - -export interface AggregatedCodeScanIssue { - filePath: string - issues: CodeScanIssue[] -} - -export interface SecurityPanelItem { - path: string - range: vscode.Range - severity: vscode.DiagnosticSeverity - message: string - issue: CodeScanIssue - decoration: vscode.DecorationOptions -} - -export interface SecurityPanelSet { - path: string - uri: vscode.Uri - items: SecurityPanelItem[] -} - export enum Cloud9AccessState { NoAccess, RequestedAccess, @@ -935,3 +1121,27 @@ export interface FolderInfo { path: string name: string } + +export interface ShortAnswerReference { + licenseName?: string + repository?: string + url?: string + recommendationContentSpan?: { + start: number + end: number + } +} + +export interface ShortAnswer { + testFilePath: string + buildCommands: string[] + planSummary: string + sourceFilePath?: string + testFramework?: string + executionCommands?: string[] + testCoverage?: number + stopIteration?: string + errorMessage?: string + codeReferences?: ShortAnswerReference[] + numberOfTestMethods?: number +} diff --git a/packages/core/src/codewhisperer/service/codeFixHandler.ts b/packages/core/src/codewhisperer/service/codeFixHandler.ts new file mode 100644 index 00000000000..0358d8d3ed9 --- /dev/null +++ b/packages/core/src/codewhisperer/service/codeFixHandler.ts @@ -0,0 +1,113 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CodeWhispererUserClient } from '../indexNode' +import * as CodeWhispererConstants from '../models/constants' +import { codeFixState } from '../models/model' +import { getLogger, sleep } from '../../shared' +import { ArtifactMap, CreateUploadUrlRequest, DefaultCodeWhispererClient } from '../client/codewhisperer' +import { + CodeFixJobStoppedError, + CodeFixJobTimedOutError, + CreateCodeFixError, + CreateUploadUrlError, +} from '../models/errors' +import { uploadArtifactToS3 } from './securityScanHandler' + +export async function getPresignedUrlAndUpload( + client: DefaultCodeWhispererClient, + zipFilePath: string, + codeFixName: string +) { + const srcReq: CreateUploadUrlRequest = { + artifactType: 'SourceCode', + uploadIntent: CodeWhispererConstants.codeFixUploadIntent, + uploadContext: { codeFixUploadContext: { codeFixName } }, + } + getLogger().verbose(`Prepare for uploading src context...`) + const srcResp = await client.createUploadUrl(srcReq).catch((err) => { + getLogger().error(`Failed getting presigned url for uploading src context. Request id: ${err.requestId}`) + throw new CreateUploadUrlError(err) + }) + 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) + getLogger().verbose(`Complete uploading src context.`) + const artifactMap: ArtifactMap = { + SourceCode: srcResp.uploadId, + } + return artifactMap +} + +export async function createCodeFixJob( + client: DefaultCodeWhispererClient, + uploadId: string, + snippetRange: CodeWhispererUserClient.Range, + description: string, + codeFixName?: string, + ruleId?: string +) { + getLogger().verbose(`Creating code fix job...`) + const req: CodeWhispererUserClient.StartCodeFixJobRequest = { + uploadId, + snippetRange, + codeFixName, + ruleId, + description, + } + + const resp = await client.startCodeFixJob(req).catch((err) => { + getLogger().error(`Failed creating code fix job. Request id: ${err.requestId}`) + throw new CreateCodeFixError() + }) + getLogger().info(`AmazonQ generate fix Request id: ${resp.$response.requestId}`) + return resp +} + +export async function pollCodeFixJobStatus(client: DefaultCodeWhispererClient, jobId: string) { + const pollingStartTime = performance.now() + await sleep(CodeWhispererConstants.codeFixJobPollingDelayMs) + + getLogger().verbose(`Polling code fix job status...`) + let status: string | undefined = 'InProgress' + while (true) { + throwIfCancelled() + const req: CodeWhispererUserClient.GetCodeFixJobRequest = { + jobId, + } + const resp = await client.getCodeFixJob(req) + getLogger().verbose(`GetCodeFixJobRequest requestId: ${resp.$response.requestId}`) + if (resp.jobStatus !== 'InProgress') { + status = resp.jobStatus + getLogger().verbose(`Code fix job status: ${status}`) + getLogger().verbose(`Complete polling code fix job status.`) + break + } + throwIfCancelled() + await sleep(CodeWhispererConstants.codeFixJobPollingIntervalMs) + const elapsedTime = performance.now() - pollingStartTime + if (elapsedTime > CodeWhispererConstants.codeFixJobTimeoutMs) { + getLogger().verbose(`Code fix job status: ${status}`) + getLogger().verbose(`Code fix job failed. Amazon Q timed out.`) + throw new CodeFixJobTimedOutError() + } + } + return status +} + +export async function getCodeFixJob(client: DefaultCodeWhispererClient, jobId: string) { + const req: CodeWhispererUserClient.GetCodeFixJobRequest = { + jobId, + } + const resp = await client.getCodeFixJob(req) + return resp +} + +export function throwIfCancelled() { + if (codeFixState.isCancelling()) { + throw new CodeFixJobStoppedError() + } +} diff --git a/packages/core/src/codewhisperer/service/diagnosticsProvider.ts b/packages/core/src/codewhisperer/service/diagnosticsProvider.ts index 97f44e0665f..fb8e6a37d9a 100644 --- a/packages/core/src/codewhisperer/service/diagnosticsProvider.ts +++ b/packages/core/src/codewhisperer/service/diagnosticsProvider.ts @@ -5,9 +5,9 @@ import * as vscode from 'vscode' import { CodeScanIssue, AggregatedCodeScanIssue, CodeScansState } from '../models/model' -import { SecurityIssueHoverProvider } from './securityIssueHoverProvider' -import { SecurityIssueCodeActionProvider } from './securityIssueCodeActionProvider' import { CodeAnalysisScope, codewhispererDiagnosticSourceLabel } from '../models/constants' +import { SecurityIssueTreeViewProvider } from './securityIssueTreeViewProvider' +import { SecurityIssueProvider } from './securityIssueProvider' export interface SecurityDiagnostic extends vscode.Diagnostic { findingId?: string @@ -30,27 +30,25 @@ export function initSecurityScanRender( scope: CodeAnalysisScope ) { securityScanRender.initialized = false - if (scope === CodeAnalysisScope.FILE && editor) { + if ((scope === CodeAnalysisScope.FILE_AUTO || scope === CodeAnalysisScope.FILE_ON_DEMAND) && editor) { securityScanRender.securityDiagnosticCollection?.delete(editor.document.uri) } else if (scope === CodeAnalysisScope.PROJECT) { securityScanRender.securityDiagnosticCollection?.clear() } securityRecommendationList.forEach((securityRecommendation) => { updateSecurityDiagnosticCollection(securityRecommendation) - updateSecurityIssueHoverAndCodeActions(securityRecommendation) + updateSecurityIssuesForProviders(securityRecommendation) }) securityScanRender.initialized = true } -function updateSecurityIssueHoverAndCodeActions(securityRecommendation: AggregatedCodeScanIssue) { +function updateSecurityIssuesForProviders(securityRecommendation: AggregatedCodeScanIssue) { const updatedSecurityRecommendationList = [ - ...SecurityIssueHoverProvider.instance.issues.filter( - (group) => group.filePath !== securityRecommendation.filePath - ), + ...SecurityIssueProvider.instance.issues.filter((group) => group.filePath !== securityRecommendation.filePath), securityRecommendation, ] - SecurityIssueHoverProvider.instance.issues = updatedSecurityRecommendationList - SecurityIssueCodeActionProvider.instance.issues = updatedSecurityRecommendationList + SecurityIssueProvider.instance.issues = updatedSecurityRecommendationList + SecurityIssueTreeViewProvider.instance.refresh() } export function updateSecurityDiagnosticCollection(securityRecommendation: AggregatedCodeScanIssue) { @@ -60,9 +58,11 @@ export function updateSecurityDiagnosticCollection(securityRecommendation: Aggre const securityDiagnostics: vscode.Diagnostic[] = vscode.languages .getDiagnostics(uri) .filter((diagnostic) => diagnostic.source === codewhispererDiagnosticSourceLabel) - securityRecommendation.issues.forEach((securityIssue) => { - securityDiagnostics.push(createSecurityDiagnostic(securityIssue)) - }) + securityRecommendation.issues + .filter((securityIssue) => securityIssue.visible) + .forEach((securityIssue) => { + securityDiagnostics.push(createSecurityDiagnostic(securityIssue)) + }) securityDiagnosticCollection.set(uri, securityDiagnostics) } @@ -74,13 +74,14 @@ export function createSecurityDiagnostic(securityIssue: CodeScanIssue) { vscode.DiagnosticSeverity.Warning ) securityDiagnostic.source = codewhispererDiagnosticSourceLabel - const detectorUrl = securityIssue.recommendation.url - securityDiagnostic.code = detectorUrl - ? { - value: securityIssue.detectorId, - target: vscode.Uri.parse(detectorUrl), - } - : securityIssue.detectorId + // const detectorUrl = securityIssue.recommendation.url + securityDiagnostic.code = securityIssue.findingId + // securityDiagnostic.code = detectorUrl + // ? { + // value: securityIssue.detectorId, + // target: vscode.Uri.parse(detectorUrl), + // } + // : securityIssue.detectorId securityDiagnostic.findingId = securityIssue.findingId return securityDiagnostic } diff --git a/packages/core/src/codewhisperer/service/securityIssueCodeActionProvider.ts b/packages/core/src/codewhisperer/service/securityIssueCodeActionProvider.ts index d093fd3fba1..f1d01494d54 100644 --- a/packages/core/src/codewhisperer/service/securityIssueCodeActionProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueCodeActionProvider.ts @@ -7,9 +7,11 @@ import * as vscode from 'vscode' import { SecurityIssueProvider } from './securityIssueProvider' import { CodeScanIssue } from '../models/model' import { Component } from '../../shared/telemetry/telemetry' +import { amazonqCodeIssueDetailsTabTitle } from '../models/constants' -export class SecurityIssueCodeActionProvider extends SecurityIssueProvider implements vscode.CodeActionProvider { +export class SecurityIssueCodeActionProvider implements vscode.CodeActionProvider { static #instance: SecurityIssueCodeActionProvider + private issueProvider = SecurityIssueProvider.instance public static get instance() { return (this.#instance ??= new this()) @@ -23,12 +25,15 @@ export class SecurityIssueCodeActionProvider extends SecurityIssueProvider imple ): vscode.CodeAction[] { const codeActions: vscode.CodeAction[] = [] - for (const group of this.issues) { + for (const group of this.issueProvider.issues) { if (document.fileName !== group.filePath) { continue } for (const issue of group.issues) { + if (!issue.visible) { + continue + } const issueRange = new vscode.Range(issue.startLine, 0, issue.endLine, 0) if (issueRange.contains(range)) { const [suggestedFix] = issue.suggestedFixes @@ -51,7 +56,7 @@ export class SecurityIssueCodeActionProvider extends SecurityIssueProvider imple ) const args: [CodeScanIssue, string] = [issue, group.filePath] openIssue.command = { - title: 'Open "Amazon Q Security Issue"', + title: `Open "${amazonqCodeIssueDetailsTabTitle}"`, command: 'aws.amazonq.openSecurityIssuePanel', arguments: args, } @@ -68,6 +73,30 @@ export class SecurityIssueCodeActionProvider extends SecurityIssueProvider imple arguments: explainWithQArgs, } codeActions.push(explainWithQ) + + const ignoreIssue = new vscode.CodeAction( + `Amazon Q: Ignore this "${issue.title}" issue`, + vscode.CodeActionKind.QuickFix + ) + const ignoreIssueArgs = [issue, group.filePath, 'quickfix'] + ignoreIssue.command = { + title: 'Ignore this issue', + command: 'aws.amazonq.security.ignore', + arguments: ignoreIssueArgs, + } + codeActions.push(ignoreIssue) + + const ignoreAllIssues = new vscode.CodeAction( + `Amazon Q: Ignore all "${issue.title}" issues`, + vscode.CodeActionKind.QuickFix + ) + const ignoreAllIssuesArgs = [issue, 'quickfix'] + ignoreAllIssues.command = { + title: 'Ignore similar issues', + command: 'aws.amazonq.security.ignoreAll', + arguments: ignoreAllIssuesArgs, + } + codeActions.push(ignoreAllIssues) } } } diff --git a/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts b/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts index 7624ec2554b..d2ed5c210df 100644 --- a/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueHoverProvider.ts @@ -5,14 +5,16 @@ import * as vscode from 'vscode' import { CodeScanIssue } from '../models/model' import globals from '../../shared/extensionGlobals' -import { SecurityIssueProvider } from './securityIssueProvider' import { telemetry } from '../../shared/telemetry/telemetry' import path from 'path' import { AuthUtil } from '../util/authUtil' import { TelemetryHelper } from '../util/telemetryHelper' +import { SecurityIssueProvider } from './securityIssueProvider' +import { amazonqCodeIssueDetailsTabTitle } from '../models/constants' -export class SecurityIssueHoverProvider extends SecurityIssueProvider implements vscode.HoverProvider { +export class SecurityIssueHoverProvider implements vscode.HoverProvider { static #instance: SecurityIssueHoverProvider + private issueProvider: SecurityIssueProvider = SecurityIssueProvider.instance public static get instance() { return (this.#instance ??= new this()) @@ -25,12 +27,15 @@ export class SecurityIssueHoverProvider extends SecurityIssueProvider implements ): vscode.Hover { const contents: vscode.MarkdownString[] = [] - for (const group of this.issues) { + for (const group of this.issueProvider.issues) { if (document.fileName !== group.filePath) { continue } for (const issue of group.issues) { + if (!issue.visible) { + continue + } const range = new vscode.Range(issue.startLine, 0, issue.endLine, 0) if (range.contains(position)) { contents.push(this._getContent(group.filePath, issue)) @@ -69,14 +74,16 @@ export class SecurityIssueHoverProvider extends SecurityIssueProvider implements const [suggestedFix] = issue.suggestedFixes markdownString.appendMarkdown(`## ${issue.title} ${this._makeSeverityBadge(issue.severity)}\n`) - markdownString.appendMarkdown(`${suggestedFix ? suggestedFix.description : issue.recommendation.text}\n\n`) + markdownString.appendMarkdown( + `${suggestedFix?.code && suggestedFix.description !== '' ? suggestedFix.description : issue.recommendation.text}\n\n` + ) const viewDetailsCommand = this._getCommandMarkdown( 'aws.amazonq.openSecurityIssuePanel', [issue, filePath], 'eye', 'View Details', - 'Open "Amazon Q Security Issue"' + `Open "${amazonqCodeIssueDetailsTabTitle}"` ) markdownString.appendMarkdown(viewDetailsCommand) @@ -89,7 +96,25 @@ export class SecurityIssueHoverProvider extends SecurityIssueProvider implements ) markdownString.appendMarkdown(' | ' + explainWithQCommand) - if (suggestedFix) { + const ignoreIssueCommand = this._getCommandMarkdown( + 'aws.amazonq.security.ignore', + [issue, filePath, 'hover'], + 'error', + 'Ignore', + 'Ignore Issue' + ) + markdownString.appendMarkdown(' | ' + ignoreIssueCommand) + + const ignoreSimilarIssuesCommand = this._getCommandMarkdown( + 'aws.amazonq.security.ignoreAll', + [issue, 'hover'], + 'error', + 'Ignore All', + 'Ignore Similar Issues' + ) + markdownString.appendMarkdown(' | ' + ignoreSimilarIssuesCommand) + + if (suggestedFix && suggestedFix.code) { const applyFixCommand = this._getCommandMarkdown( 'aws.amazonq.applySecurityFix', [issue, filePath, 'hover'], diff --git a/packages/core/src/codewhisperer/service/securityIssueProvider.ts b/packages/core/src/codewhisperer/service/securityIssueProvider.ts index e666d5335f5..fa60c7c215a 100644 --- a/packages/core/src/codewhisperer/service/securityIssueProvider.ts +++ b/packages/core/src/codewhisperer/service/securityIssueProvider.ts @@ -4,9 +4,15 @@ */ import * as vscode from 'vscode' -import { AggregatedCodeScanIssue, CodeScanIssue, CodeScansState, SuggestedFix } from '../models/model' -export abstract class SecurityIssueProvider { +import { AggregatedCodeScanIssue, CodeScanIssue, SuggestedFix } from '../models/model' +export class SecurityIssueProvider { + static #instance: SecurityIssueProvider + public static get instance() { + return (this.#instance ??= new this()) + } + private _issues: AggregatedCodeScanIssue[] = [] + private _disableEventHandler: boolean = false public get issues() { return this._issues } @@ -15,20 +21,26 @@ export abstract class SecurityIssueProvider { this._issues = issues } + public disableEventHandler() { + this._disableEventHandler = true + } + public handleDocumentChange(event: vscode.TextDocumentChangeEvent) { // handleDocumentChange function may be triggered while testing by our own code generation. if (!event.contentChanges || event.contentChanges.length === 0) { return } - const { changedRange, changedText, lineOffset } = event.contentChanges.reduce( + if (this._disableEventHandler) { + this._disableEventHandler = false + return + } + const { changedRange, lineOffset } = event.contentChanges.reduce( (acc, change) => ({ changedRange: acc.changedRange.union(change.range), - changedText: acc.changedText + change.text, lineOffset: acc.lineOffset + this._getLineOffset(change.range, change.text), }), { changedRange: event.contentChanges[0].range, - changedText: '', lineOffset: 0, } ) @@ -40,20 +52,18 @@ export abstract class SecurityIssueProvider { return { ...group, issues: group.issues - .filter((issue) => { - const range = new vscode.Range( - issue.startLine, - event.document.lineAt(issue.startLine)?.range.start.character ?? 0, - issue.endLine, - event.document.lineAt(issue.endLine - 1)?.range.end.character ?? 0 - ) - const intersection = changedRange.intersection(range) - return !( - intersection && - (/\S/.test(changedText) || changedText === '') && - !CodeScansState.instance.isScansEnabled() - ) - }) + .filter( + (issue) => + // Filter out any modified issues + !changedRange.intersection( + new vscode.Range( + issue.startLine, + event.document.lineAt(issue.startLine)?.range.start.character ?? 0, + issue.endLine, + event.document.lineAt(issue.endLine)?.range.end.character ?? 0 + ) + ) + ) .map((issue) => { if (issue.startLine < changedRange.end.line) { return issue @@ -80,7 +90,7 @@ export abstract class SecurityIssueProvider { private _offsetSuggestedFix(suggestedFix: SuggestedFix, lines: number): SuggestedFix { return { ...suggestedFix, - code: suggestedFix.code.replace( + code: suggestedFix.code?.replace( /^(@@ -)(\d+)(,\d+ \+)(\d+)(,\d+ @@)/, function (_fullMatch, ...groups: string[]) { return ( @@ -92,6 +102,15 @@ export abstract class SecurityIssueProvider { ) } ), + references: + suggestedFix.references?.map((ref) => ({ + ...ref, + recommendationContentSpan: { + ...ref.recommendationContentSpan, + start: Number(ref.recommendationContentSpan?.start) + lines, + end: Number(ref.recommendationContentSpan?.end) + lines, + }, + })) ?? [], } } @@ -106,4 +125,16 @@ export abstract class SecurityIssueProvider { } }) } + + public updateIssue(issue: CodeScanIssue, filePath?: string) { + this._issues = this._issues.map((group) => { + if (filePath && group.filePath !== filePath) { + return group + } + return { + ...group, + issues: group.issues.map((i) => (i.findingId === issue.findingId ? issue : i)), + } + }) + } } diff --git a/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts b/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts new file mode 100644 index 00000000000..e76a201be87 --- /dev/null +++ b/packages/core/src/codewhisperer/service/securityIssueTreeViewProvider.ts @@ -0,0 +1,170 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import path from 'path' +import { CodeScanIssue, SecurityTreeViewFilterState, severities, Severity } from '../models/model' +import globals from '../../shared/extensionGlobals' +import { getLogger } from '../../shared/logger' +import { SecurityIssueProvider } from './securityIssueProvider' + +export type SecurityViewTreeItem = FileItem | IssueItem | SeverityItem +type CodeScanIssueWithFilePath = CodeScanIssue & { filePath: string } + +export class SecurityIssueTreeViewProvider implements vscode.TreeDataProvider { + public static readonly viewType = 'aws.amazonq.SecurityIssuesTree' + + private _onDidChangeTreeData: vscode.EventEmitter< + SecurityViewTreeItem | SecurityViewTreeItem[] | undefined | null | void + > = new vscode.EventEmitter() + readonly onDidChangeTreeData: vscode.Event< + SecurityViewTreeItem | SecurityViewTreeItem[] | undefined | null | void + > = this._onDidChangeTreeData.event + + static #instance: SecurityIssueTreeViewProvider + private issueProvider = SecurityIssueProvider.instance + + public static get instance() { + return (this.#instance ??= new this()) + } + + public getTreeItem(element: SecurityViewTreeItem): vscode.TreeItem | Thenable { + return element + } + + public getChildren(element?: SecurityViewTreeItem | undefined): vscode.ProviderResult { + const filterHiddenSeverities = (severity: Severity) => + !SecurityTreeViewFilterState.instance.getHiddenSeverities().includes(severity) + + if (element instanceof SeverityItem) { + return element.issues + .filter((issue) => issue.visible) + .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine) + .map((issue) => new IssueItem(issue.filePath, issue)) + } + const result = severities.filter(filterHiddenSeverities).map( + (severity) => + new SeverityItem( + severity, + this.issueProvider.issues.reduce( + (accumulator, current) => + accumulator.concat( + current.issues + .filter((issue) => issue.severity === severity) + .filter((issue) => issue.visible) + .map((issue) => ({ ...issue, filePath: current.filePath })) + ), + [] as CodeScanIssueWithFilePath[] + ) + ) + ) + + this._onDidChangeTreeData.fire(result) + return result + } + + public refresh(): void { + this._onDidChangeTreeData.fire() + } +} + +enum ContextValue { + FILE = 'file', + ISSUE_WITH_FIX = 'issueWithFix', + ISSUE_WITHOUT_FIX = 'issueWithoutFix', + SEVERITY = 'severity', +} + +export class SeverityItem extends vscode.TreeItem { + constructor( + public readonly severity: string, + public readonly issues: CodeScanIssueWithFilePath[] + ) { + super(severity) + this.description = `${this.issues.length} ${this.issues.length === 1 ? 'issue' : 'issues'}` + this.iconPath = this.getSeverityIcon() + this.contextValue = ContextValue.SEVERITY + this.collapsibleState = this.getCollapsibleState() + } + + private getSeverityIcon() { + return globals.context.asAbsolutePath(`resources/icons/aws/amazonq/severity-${this.severity.toLowerCase()}.svg`) + } + + private getCollapsibleState() { + return this.severity === 'Critical' || this.severity === 'High' + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed + } +} + +export class FileItem extends vscode.TreeItem { + constructor( + public readonly filePath: string, + public readonly issues: CodeScanIssue[] + ) { + super(path.basename(filePath), vscode.TreeItemCollapsibleState.Expanded) + this.resourceUri = vscode.Uri.file(this.filePath) + this.description = vscode.workspace.asRelativePath(path.dirname(this.filePath)) + this.iconPath = new vscode.ThemeIcon('file') + this.contextValue = ContextValue.FILE + } +} + +export class IssueItem extends vscode.TreeItem { + constructor( + public readonly filePath: string, + public readonly issue: CodeScanIssue + ) { + super(issue.title, vscode.TreeItemCollapsibleState.None) + this.description = `${path.basename(this.filePath)} [Ln ${this.issue.startLine + 1}, Col 1]` + this.tooltip = this.getTooltipMarkdown() + this.command = { + title: 'Focus Issue', + command: 'aws.amazonq.security.focusIssue', + arguments: [this.issue, this.filePath], + } + this.contextValue = this.getContextValue() + } + + private getSeverityImage() { + return globals.context.asAbsolutePath(`resources/images/severity-${this.issue.severity.toLowerCase()}.svg`) + } + + private getContextValue() { + return this.issue.suggestedFixes.length === 0 || !this.issue.suggestedFixes[0].code + ? ContextValue.ISSUE_WITHOUT_FIX + : ContextValue.ISSUE_WITH_FIX + } + + private getTooltipMarkdown() { + const markdown = new vscode.MarkdownString() + markdown.isTrusted = true + markdown.supportHtml = true + markdown.supportThemeIcons = true + markdown.appendMarkdown(`## ${this.issue.title} ![${this.issue.severity}](${this.getSeverityImage()})\n`) + markdown.appendMarkdown(this.issue.recommendation.text) + + return markdown + } +} + +export class SecurityIssuesTree { + static #instance: SecurityIssuesTree + public static get instance() { + return (this.#instance ??= new this()) + } + + constructor() { + vscode.window.createTreeView(SecurityIssueTreeViewProvider.viewType, { + treeDataProvider: SecurityIssueTreeViewProvider.instance, + }) + } + + public focus() { + void vscode.commands.executeCommand('aws.amazonq.SecurityIssuesTree.focus').then(undefined, (e) => { + getLogger().error('SecurityIssuesTree focus failed: %s', e.message) + }) + } +} diff --git a/packages/core/src/codewhisperer/service/securityScanHandler.ts b/packages/core/src/codewhisperer/service/securityScanHandler.ts index 86480f0766e..537638b52c9 100644 --- a/packages/core/src/codewhisperer/service/securityScanHandler.ts +++ b/packages/core/src/codewhisperer/service/securityScanHandler.ts @@ -12,6 +12,7 @@ import { CodeScansState, codeScanState, CodeScanStoppedError, + onDemandFileScanState, } from '../models/model' import { sleep } from '../../shared/utilities/timeoutUtils' import * as codewhispererClient from '../client/codewhisperer' @@ -39,6 +40,9 @@ import { UploadArtifactToS3Error, } from '../models/errors' import { getTelemetryReasonDesc } from '../../shared/errors' +import { CodeWhispererSettings } from '../util/codewhispererSettings' +import { detectCommentAboveLine } from '../../shared/utilities/commentUtils' +import { runtimeLanguageContext } from '../util/runtimeLanguageContext' export async function listScanResults( client: DefaultCodeWhispererClient, @@ -52,11 +56,12 @@ export async function listScanResults( const codeScanIssueMap: Map = new Map() const aggregatedCodeScanIssueList: AggregatedCodeScanIssue[] = [] const requester = (request: codewhispererClient.ListCodeScanFindingsRequest) => client.listCodeScanFindings(request) - const collection = pageableToCollection(requester, { jobId, codeScanFindingsSchema }, 'nextToken') + const request: codewhispererClient.ListCodeScanFindingsRequest = { jobId, codeScanFindingsSchema } + const collection = pageableToCollection(requester, request, 'nextToken') const issues = await collection .flatten() .map((resp) => { - logger.verbose(`Request id: ${resp.$response.requestId}`) + logger.verbose(`ListCodeScanFindingsRequest requestId: ${resp.$response.requestId}`) if ('codeScanFindings' in resp) { return resp.codeScanFindings } @@ -76,7 +81,7 @@ export async function listScanResults( if (existsSync(filePath) && statSync(filePath).isFile()) { const aggregatedCodeScanIssue: AggregatedCodeScanIssue = { filePath: filePath, - issues: issues.map(mapRawToCodeScanIssue), + issues: issues.map((issue) => mapRawToCodeScanIssue(issue, editor, jobId)), } aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue) } @@ -85,7 +90,7 @@ export async function listScanResults( if (existsSync(maybeAbsolutePath) && statSync(maybeAbsolutePath).isFile()) { const aggregatedCodeScanIssue: AggregatedCodeScanIssue = { filePath: maybeAbsolutePath, - issues: issues.map(mapRawToCodeScanIssue), + issues: issues.map((issue) => mapRawToCodeScanIssue(issue, editor, jobId)), } aggregatedCodeScanIssueList.push(aggregatedCodeScanIssue) } @@ -93,7 +98,19 @@ export async function listScanResults( return aggregatedCodeScanIssueList } -function mapRawToCodeScanIssue(issue: RawCodeScanIssue): CodeScanIssue { +function mapRawToCodeScanIssue( + issue: RawCodeScanIssue, + editor: vscode.TextEditor | undefined, + jobId: string +): CodeScanIssue { + const isIssueTitleIgnored = CodeWhispererSettings.instance.getIgnoredSecurityIssues().includes(issue.title) + const isSingleIssueIgnored = + editor && + detectCommentAboveLine(editor.document, issue.startLine - 1, CodeWhispererConstants.amazonqIgnoreNextLine) + const language = editor + ? runtimeLanguageContext.getLanguageContext(editor.document.languageId, path.extname(editor.document.fileName)) + .language + : 'plaintext' return { startLine: issue.startLine - 1 >= 0 ? issue.startLine - 1 : 0, endLine: issue.endLine, @@ -108,6 +125,9 @@ function mapRawToCodeScanIssue(issue: RawCodeScanIssue): CodeScanIssue { severity: issue.severity, recommendation: issue.remediation.recommendation, suggestedFixes: issue.remediation.suggestedFixes, + visible: !isIssueTitleIgnored && !isSingleIssueIgnored, + scanJobId: jobId, + language, } } @@ -119,7 +139,11 @@ export function mapToAggregatedList( ) { const codeScanIssues: RawCodeScanIssue[] = JSON.parse(json) const filteredIssues = codeScanIssues.filter((issue) => { - if (scope === CodeWhispererConstants.CodeAnalysisScope.FILE && editor) { + if ( + (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) && + editor + ) { for (let lineNumber = issue.startLine; lineNumber <= issue.endLine; lineNumber++) { const line = editor.document.lineAt(lineNumber - 1)?.text const codeContent = issue.codeSnippet.find((codeIssue) => codeIssue.number === lineNumber)?.content @@ -137,13 +161,30 @@ export function mapToAggregatedList( filteredIssues.forEach((issue) => { const filePath = issue.filePath if (codeScanIssueMap.has(filePath)) { - codeScanIssueMap.get(filePath)?.push(issue) + if (!isExistingIssue(issue, codeScanIssueMap)) { + codeScanIssueMap.get(filePath)?.push(issue) + } else { + getLogger().warn('Found duplicate issue %O, ignoring...', issue) + } } else { codeScanIssueMap.set(filePath, [issue]) } }) } +function isDuplicateIssue(issueA: RawCodeScanIssue, issueB: RawCodeScanIssue) { + return ( + issueA.filePath === issueB.filePath && + issueA.title === issueB.title && + issueA.startLine === issueB.startLine && + issueA.endLine === issueB.endLine + ) +} + +function isExistingIssue(issue: RawCodeScanIssue, codeScanIssueMap: Map) { + return codeScanIssueMap.get(issue.filePath)?.some((existingIssue) => isDuplicateIssue(issue, existingIssue)) +} + export async function pollScanJobStatus( client: DefaultCodeWhispererClient, jobId: string, @@ -163,7 +204,7 @@ export async function pollScanJobStatus( jobId: jobId, } const resp = await client.getCodeScan(req) - logger.verbose(`Request id: ${resp.$response.requestId}`) + logger.verbose(`GetCodeScanRequest requestId: ${resp.$response.requestId}`) if (resp.status !== 'Pending') { status = resp.status logger.verbose(`Scan job status: ${status}`) @@ -191,19 +232,28 @@ export async function createScanJob( ) { const logger = getLoggerForScope(scope) logger.verbose(`Creating scan job...`) + const codeAnalysisScope = scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO ? 'FILE' : 'PROJECT' const req: codewhispererClient.CreateCodeScanRequest = { artifacts: artifactMap, programmingLanguage: { languageName: languageId, }, - scope: scope, + scope: codeAnalysisScope, codeScanName: scanName, } const resp = await client.createCodeScan(req).catch((err) => { getLogger().error(`Failed creating scan job. Request id: ${err.requestId}`) + if ( + err.message === CodeWhispererConstants.scansLimitReachedErrorMessage && + err.code === 'ThrottlingException' + ) { + throw err + } throw new CreateCodeScanError(err) }) - logger.verbose(`Request id: ${resp.$response.requestId}`) + getLogger().info( + `Amazon Q Code Review requestId: ${resp.$response.requestId} and Amazon Q Code Review jobId: ${resp.jobId}` + ) TelemetryHelper.instance.sendCodeScanEvent(languageId, resp.$response.requestId) return resp } @@ -234,7 +284,7 @@ export async function getPresignedUrlAndUpload( getLogger().error(`Failed getting presigned url for uploading src context. Request id: ${err.requestId}`) throw new CreateUploadUrlError(err) }) - logger.verbose(`Request id: ${srcResp.$response.requestId}`) + 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) @@ -246,12 +296,17 @@ export async function getPresignedUrlAndUpload( } function getUploadIntent(scope: CodeWhispererConstants.CodeAnalysisScope): UploadIntent { - return scope === CodeWhispererConstants.CodeAnalysisScope.FILE - ? CodeWhispererConstants.fileScanUploadIntent - : CodeWhispererConstants.projectScanUploadIntent + if ( + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND + ) { + return CodeWhispererConstants.fileScanUploadIntent + } else { + return CodeWhispererConstants.projectScanUploadIntent + } } -function getMd5(fileName: string) { +export function getMd5(fileName: string) { const hasher = crypto.createHash('md5') hasher.update(readFileSync(fileName)) return hasher.digest('base64') @@ -264,7 +319,12 @@ export function throwIfCancelled(scope: CodeWhispererConstants.CodeAnalysisScope throw new CodeScanStoppedError() } break - case CodeWhispererConstants.CodeAnalysisScope.FILE: { + case CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND: + if (onDemandFileScanState.isCancelling()) { + throw new CodeScanStoppedError() + } + break + case CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO: { const latestCodeScanStartTime = CodeScansState.instance.getLatestScanTime() if ( !CodeScansState.instance.isScansEnabled() || @@ -279,11 +339,11 @@ export function throwIfCancelled(scope: CodeWhispererConstants.CodeAnalysisScope break } } - +//TODO: Refactor this export async function uploadArtifactToS3( fileName: string, resp: CreateUploadUrlResponse, - scope: CodeWhispererConstants.CodeAnalysisScope + scope?: CodeWhispererConstants.CodeAnalysisScope ) { const logger = getLoggerForScope(scope) const encryptionContext = `{"uploadId":"${resp.uploadId}"}` @@ -316,13 +376,15 @@ export async function uploadArtifactToS3( } } -export function getLoggerForScope(scope: CodeWhispererConstants.CodeAnalysisScope) { - return scope === CodeWhispererConstants.CodeAnalysisScope.FILE ? getNullLogger() : getLogger() +//TODO: Refactor this +export function getLoggerForScope(scope?: CodeWhispererConstants.CodeAnalysisScope) { + return scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO ? getNullLogger() : getLogger() } function getPollingDelayMsForScope(scope: CodeWhispererConstants.CodeAnalysisScope) { return ( - (scope === CodeWhispererConstants.CodeAnalysisScope.FILE + (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND ? CodeWhispererConstants.fileScanPollingDelaySeconds : CodeWhispererConstants.projectScanPollingDelaySeconds) * 1000 ) @@ -330,7 +392,8 @@ function getPollingDelayMsForScope(scope: CodeWhispererConstants.CodeAnalysisSco function getPollingTimeoutMsForScope(scope: CodeWhispererConstants.CodeAnalysisScope) { return ( - (scope === CodeWhispererConstants.CodeAnalysisScope.FILE + (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND ? CodeWhispererConstants.codeFileScanJobTimeoutSeconds : CodeWhispererConstants.codeScanJobTimeoutSeconds) * 1000 ) diff --git a/packages/core/src/codewhisperer/service/testGenHandler.ts b/packages/core/src/codewhisperer/service/testGenHandler.ts new file mode 100644 index 00000000000..218864ce256 --- /dev/null +++ b/packages/core/src/codewhisperer/service/testGenHandler.ts @@ -0,0 +1,294 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ZipMetadata } from '../util/zipUtil' +import { getLogger } from '../../shared/logger' +import * as CodeWhispererConstants from '../models/constants' +import * as codewhispererClient from '../client/codewhisperer' +import * as codeWhisperer from '../client/codewhisperer' +import CodeWhispererUserClient, { + ArtifactMap, + CreateUploadUrlRequest, + TargetCode, +} from '../client/codewhispereruserclient' +import { CreateUploadUrlError, InvalidSourceZipError, TestGenFailedError, TestGenTimedOutError } from '../models/errors' +import { getMd5, uploadArtifactToS3 } from './securityScanHandler' +import { fs, randomUUID, sleep, tempDirPath } from '../../shared' +import { ShortAnswer, TestGenerationJobStatus, testGenState } from '..' +import { ChatSessionManager } from '../../amazonqTest/chat/storages/chatSession' +import { createCodeWhispererChatStreamingClient } from '../../shared/clients/codewhispererChatClient' +import { downloadExportResultArchive } from '../../shared/utilities/download' +import AdmZip from 'adm-zip' +import path from 'path' +import { ExportIntent } from '@amzn/codewhisperer-streaming' +import { glob } from 'glob' + +//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) + } +} + +export async function getPresignedUrlAndUploadTestGen(zipMetadata: ZipMetadata) { + const logger = getLogger() + if (zipMetadata.zipFilePath === '') { + getLogger().error('Failed to create valid source zip') + throw new InvalidSourceZipError() + } + const srcReq: CreateUploadUrlRequest = { + contentMd5: getMd5(zipMetadata.zipFilePath), + artifactType: 'SourceCode', + uploadIntent: CodeWhispererConstants.testGenUploadIntent, + } + 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) + }) + 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) + logger.verbose(`Complete uploading src context.`) + const artifactMap: ArtifactMap = { + SourceCode: srcResp.uploadId, + } + return artifactMap +} + +export async function createTestJob( + artifactMap: codewhispererClient.ArtifactMap, + relativeTargetPath: TargetCode[], + userInputPrompt: string, + clientToken?: string +) { + const logger = getLogger() + logger.verbose(`Creating test job and starting startTestGeneration...`) + + // JS will minify this input object - fix that + const targetCodeList = relativeTargetPath.map((targetCode) => ({ + relativeTargetPath: targetCode.relativeTargetPath, + targetLineRangeList: targetCode.targetLineRangeList?.map((range) => ({ + start: { line: range.start.line, character: range.start.character }, + end: { line: range.end.line, character: range.end.character }, + })), + })) + logger.debug('updated target code list: %O', targetCodeList) + const req: CodeWhispererUserClient.StartTestGenerationRequest = { + uploadId: artifactMap.SourceCode, + targetCodeList, + userInput: userInputPrompt, + testGenerationJobGroupName: ChatSessionManager.Instance.getSession().testGenerationJobGroupName ?? randomUUID(), // TODO: remove fallback + clientToken, + } + logger.debug('Unit test generation request body: %O', req) + logger.debug('target code list: %O', req.targetCodeList[0]) + const firstTargetCodeList = req.targetCodeList?.[0] + const firstTargetLineRangeList = firstTargetCodeList?.targetLineRangeList?.[0] + logger.debug('target line range list: %O', firstTargetLineRangeList) + logger.debug('target line range start: %O', firstTargetLineRangeList?.start) + logger.debug('target line range end: %O', firstTargetLineRangeList?.end) + + const resp = await codewhispererClient.codeWhispererClient.startTestGeneration(req).catch((err) => { + logger.error(`Failed creating test job. Request id: ${err.requestId}`) + throw err + }) + logger.info('Unit test generation request id: %s', resp.$response.requestId) + logger.debug('Unit test generation data: %O', resp.$response.data) + if (resp.$response.error) { + logger.error('Unit test generation error: %O', resp.$response.error) + } + if (resp.testGenerationJob) { + ChatSessionManager.Instance.getSession().listOfTestGenerationJobId.push( + resp.testGenerationJob?.testGenerationJobId + ) + ChatSessionManager.Instance.getSession().testGenerationJobGroupName = + resp.testGenerationJob?.testGenerationJobGroupName + } + return resp +} + +export async function pollTestJobStatus( + jobId: string, + jobGroupName: string, + fileName: string, + initialExecution: boolean +) { + const session = ChatSessionManager.Instance.getSession() + const pollingStartTime = performance.now() + // We don't expect to get results immediately, so sleep for some time initially to not make unnecessary calls + await sleep(CodeWhispererConstants.testGenPollingDelaySeconds) + + const logger = getLogger() + logger.verbose(`Polling testgen job status...`) + let status = CodeWhispererConstants.TestGenerationJobStatus.IN_PROGRESS + while (true) { + throwIfCancelled() + const req: CodeWhispererUserClient.GetTestGenerationRequest = { + testGenerationJobId: jobId, + testGenerationJobGroupName: jobGroupName, + } + const resp = await codewhispererClient.codeWhispererClient.getTestGeneration(req) + logger.verbose('pollTestJobStatus request id: %s', resp.$response.requestId) + logger.debug('pollTestJobStatus testGenerationJob %O', resp.testGenerationJob) + ChatSessionManager.Instance.getSession().testGenerationJob = resp.testGenerationJob + const progressRate = resp.testGenerationJob?.progressRate ?? 0 + testGenState.getChatControllers()?.sendUpdatePromptProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + status: 'InProgress', + progressRate, + }) + const shortAnswerString = resp.testGenerationJob?.shortAnswer + if (shortAnswerString) { + const parsedShortAnswer = JSON.parse(shortAnswerString) + const shortAnswer: ShortAnswer = JSON.parse(parsedShortAnswer) + //Stop the Unit test generation workflow if IDE receive stopIteration = true + if (shortAnswer.stopIteration === 'true') { + session.stopIteration = true + throw new TestGenFailedError(shortAnswer.planSummary) + } + if (shortAnswer.numberOfTestMethods) { + session.numberOfTestsGenerated = Number(shortAnswer.numberOfTestMethods) + } + if (shortAnswer.codeReferences) { + session.references = shortAnswer.codeReferences + } + if (initialExecution) { + session.generatedFilePath = shortAnswer?.testFilePath ?? '' + const currentPlanSummary = session.shortAnswer?.planSummary + const newPlanSummary = shortAnswer?.planSummary + const status = shortAnswer.stopIteration + + if (currentPlanSummary !== newPlanSummary && newPlanSummary) { + const chatControllers = testGenState.getChatControllers() + if (chatControllers) { + const currentSession = ChatSessionManager.Instance.getSession() + chatControllers.updateShortAnswer.fire({ + tabID: currentSession.tabID, + status, + shortAnswer, + testGenerationJobGroupName: resp.testGenerationJob?.testGenerationJobGroupName, + testGenerationJobId: resp.testGenerationJob?.testGenerationJobId, + fileName, + }) + } + } + } + ChatSessionManager.Instance.getSession().shortAnswer = shortAnswer + } + if (resp.testGenerationJob?.status !== TestGenerationJobStatus.IN_PROGRESS) { + //This can be FAILED or COMPLETED + status = resp.testGenerationJob?.status as TestGenerationJobStatus + logger.verbose(`testgen job status: ${status}`) + logger.verbose(`Complete polling test job status.`) + break + } + throwIfCancelled() + await sleep(CodeWhispererConstants.testGenJobPollingIntervalMilliseconds) + const elapsedTime = performance.now() - pollingStartTime + if (elapsedTime > CodeWhispererConstants.testGenJobTimeoutMilliseconds) { + logger.verbose(`testgen job status: ${status}`) + logger.verbose(`testgen job failed. Amazon Q timed out.`) + throw new TestGenTimedOutError() + } + } + return status +} + +/** + * Download the zip from exportResultsArchieve API and store in temp zip + */ +export async function exportResultsArchive( + uploadId: string, + groupName: string, + jobId: string, + projectName: string, + projectPath: string, + initialExecution: boolean +) { + //TODO: Make a common Temp folder + const pathToArchiveDir = path.join(tempDirPath, 'q-testgen') + + const archivePathExists = await fs.existsDir(pathToArchiveDir) + if (archivePathExists) { + await fs.delete(pathToArchiveDir, { recursive: true }) + } + await fs.mkdir(pathToArchiveDir) + + let downloadErrorMessage = undefined + try { + const pathToArchive = path.join(pathToArchiveDir, 'QTestGeneration.zip') + // Download and deserialize the zip + await downloadResultArchive(uploadId, groupName, jobId, pathToArchive) + const zip = new AdmZip(pathToArchive) + zip.extractAllTo(pathToArchiveDir, true) + + const session = ChatSessionManager.Instance.getSession() + const testFilePathFromResponse = session?.shortAnswer?.testFilePath + const testFilePath = testFilePathFromResponse + ? testFilePathFromResponse.split('/').slice(1).join('/') // remove the project name + : await getTestFilePathFromZip(pathToArchiveDir) + if (initialExecution) { + testGenState.getChatControllers()?.showCodeGenerationResults.fire({ + tabID: session.tabID, + filePath: testFilePath, + projectName, + }) + + //If User accepts the diff + testGenState.getChatControllers()?.sendUpdatePromptProgress.fire({ + tabID: ChatSessionManager.Instance.getSession().tabID, + status: 'Completed', + }) + } + } catch (e) { + downloadErrorMessage = (e as Error).message + getLogger().error(`Unit Test Generation: ExportResultArchive error = ${downloadErrorMessage}`) + throw new Error('Error downloading test generation result artifacts: ' + downloadErrorMessage) + } +} + +async function getTestFilePathFromZip(pathToArchiveDir: string) { + const resultArtifactsDir = path.join(pathToArchiveDir, 'resultArtifacts') + const paths = await glob([resultArtifactsDir + '/**/*', '!**/.DS_Store'], { nodir: true }) + const absolutePath = paths[0] + const result = path.relative(resultArtifactsDir, absolutePath) + return result +} + +export async function downloadResultArchive( + uploadId: string, + testGenerationJobGroupName: string, + testGenerationJobId: string, + pathToArchive: string +) { + let downloadErrorMessage = undefined + const cwStreamingClient = await createCodeWhispererChatStreamingClient() + + try { + await downloadExportResultArchive( + cwStreamingClient, + { + exportId: uploadId, + exportIntent: ExportIntent.UNIT_TESTS, + exportContext: { + unitTestGenerationExportContext: { + testGenerationJobGroupName, + testGenerationJobId, + }, + }, + }, + pathToArchive + ) + } catch (e: any) { + downloadErrorMessage = (e as Error).message + getLogger().error(`Unit Test Generation: ExportResultArchive error = ${downloadErrorMessage}`) + throw e + } finally { + cwStreamingClient.destroy() + } +} diff --git a/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts b/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts index e256195cbef..5d8382cbec9 100644 --- a/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts +++ b/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts @@ -11,7 +11,6 @@ import { Commands, placeholder } from '../../shared/vscode/commands2' import { toggleCodeSuggestions, showReferenceLog, - showSecurityScan, showLearnMore, showFreeTierLimit, reconnect, @@ -44,9 +43,9 @@ export function createAutoSuggestions(running: boolean): DataQuickPickItem<'auto } export function createAutoScans(running: boolean): DataQuickPickItem<'autoScans'> { - const labelResume = localize('AWS.codewhisperer.resumeCodeWhispererNode.label', 'Resume Auto-Scans') + const labelResume = localize('AWS.codewhisperer.resumeCodeWhispererNode.label', 'Resume Auto-Reviews') const iconResume = getIcon('vscode-debug-alt') - const labelPause = localize('AWS.codewhisperer.pauseCodeWhispererNode.label', 'Pause Auto-Scans') + const labelPause = localize('AWS.codewhisperer.pauseCodeWhispererNode.label', 'Pause Auto-Reviews') const iconPause = getIcon('vscode-debug-pause') const monthlyQuotaExceeded = CodeScansState.instance.isMonthlyQuotaExceeded() @@ -70,14 +69,21 @@ export function createOpenReferenceLog(): DataQuickPickItem<'openReferenceLog'> } export function createSecurityScan(): DataQuickPickItem<'securityScan'> { - const prefix = codeScanState.getPrefixTextForButton() - const label = `${prefix} Project Scan` + const label = `Full project scan is now /review!` const icon = codeScanState.getIconForButton() + const description = 'Open in Chat Panel' return { data: 'securityScan', label: codicon`${icon} ${label}`, - onClick: () => showSecurityScan.execute(placeholder, cwQuickPickSource), + description: description, + onClick: () => + vscode.commands.executeCommand( + 'aws.amazonq.security.scan-statusbar', + placeholder, + 'cwQuickPickSource', + true + ), } as DataQuickPickItem<'securityScan'> } diff --git a/packages/core/src/codewhisperer/ui/statusBarMenu.ts b/packages/core/src/codewhisperer/ui/statusBarMenu.ts index 7a44a6361e1..8988f0eb339 100644 --- a/packages/core/src/codewhisperer/ui/statusBarMenu.ts +++ b/packages/core/src/codewhisperer/ui/statusBarMenu.ts @@ -6,7 +6,6 @@ import { createAutoSuggestions, createOpenReferenceLog, - createSecurityScan, createLearnMore, createFreeTierLimitMet, createSelectCustomization, @@ -21,6 +20,7 @@ import { createAutoScans, createSignIn, switchToAmazonQNode, + createSecurityScan, } from './codeWhispererNodes' import { hasVendedIamCredentials } from '../../auth/auth' import { AuthUtil } from '../util/authUtil' @@ -66,7 +66,7 @@ function getAmazonQCodeWhispererNodes() { createGettingStarted(), // "Learn" node : opens Learn CodeWhisperer page // Security scans - createSeparator('Security Scans'), + createSeparator('Code Reviews'), ...(AuthUtil.instance.isBuilderIdInUse() ? [] : [createAutoScans(autoScansEnabled)]), createSecurityScan(), diff --git a/packages/core/src/codewhisperer/util/codewhispererSettings.ts b/packages/core/src/codewhisperer/util/codewhispererSettings.ts index 09c7e2657bd..32ae9cd0307 100644 --- a/packages/core/src/codewhisperer/util/codewhispererSettings.ts +++ b/packages/core/src/codewhisperer/util/codewhispererSettings.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import { fromExtensionManifest, migrateSetting } from '../../shared/settings' +import { ArrayConstructor } from '../../shared/utilities/typeConstructors' const description = { showInlineCodeSuggestionsWithCodeReferences: Boolean, // eslint-disable-line id-length @@ -12,6 +13,7 @@ const description = { workspaceIndexWorkerThreads: Number, workspaceIndexUseGPU: Boolean, workspaceIndexMaxSize: Number, + ignoredSecurityIssues: ArrayConstructor(String), } export class CodeWhispererSettings extends fromExtensionManifest('amazonQ', description) { @@ -64,6 +66,14 @@ export class CodeWhispererSettings extends fromExtensionManifest('amazonQ', desc return Math.max(this.get('workspaceIndexMaxSize', 250), 1) } + public getIgnoredSecurityIssues(): string[] { + return this.get('ignoredSecurityIssues', []) + } + + public async addToIgnoredSecurityIssuesList(issueTitle: string) { + await this.update('ignoredSecurityIssues', [...this.getIgnoredSecurityIssues(), issueTitle]) + } + static #instance: CodeWhispererSettings public static get instance() { diff --git a/packages/core/src/codewhisperer/util/gitUtil.ts b/packages/core/src/codewhisperer/util/gitUtil.ts new file mode 100644 index 00000000000..f4a48dbd0cb --- /dev/null +++ b/packages/core/src/codewhisperer/util/gitUtil.ts @@ -0,0 +1,37 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { showOutputMessage } from '../../shared/utilities/messages' +import { getLogger, globals, removeAnsi } from '../../shared' +import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' +import { Uri } from 'vscode' + +export async function isGitRepo(folder: Uri): Promise { + const childProcess = new ChildProcess('git', ['rev-parse', '--is-inside-work-tree']) + + let output = '' + const runOptions: ChildProcessOptions = { + rejectOnError: true, + rejectOnErrorCode: true, + onStdout: (text) => { + output += text + showOutputMessage(removeAnsi(text), globals.outputChannel) + }, + onStderr: (text) => { + showOutputMessage(removeAnsi(text), globals.outputChannel) + }, + spawnOptions: { + cwd: folder.fsPath, + }, + } + + try { + await childProcess.run(runOptions) + return output.trim() === 'true' + } catch (err) { + getLogger().warn(`Failed to run command \`${childProcess.toString()}\`: ${err}`) + return false + } +} diff --git a/packages/core/src/codewhisperer/util/telemetryHelper.ts b/packages/core/src/codewhisperer/util/telemetryHelper.ts index 0efca563f93..5176ffee2be 100644 --- a/packages/core/src/codewhisperer/util/telemetryHelper.ts +++ b/packages/core/src/codewhisperer/util/telemetryHelper.ts @@ -26,6 +26,7 @@ import { session } from './codeWhispererSession' import { CodeWhispererSupplementalContext } from '../models/model' import { FeatureConfigProvider } from '../../shared/featureConfig' import { CodeScanRemediationsEventType } from '../client/codewhispereruserclient' +import { CodeAnalysisScope as CodeAnalysisScopeClientSide } from '../models/constants' export class TelemetryHelper { // Some variables for client component latency @@ -626,6 +627,188 @@ export class TelemetryHelper { }) } + public sendCodeScanSucceededEvent( + language: string, + jobId: string, + numberOfFindings: number, + scope: CodeAnalysisScopeClientSide + ) { + client + .sendTelemetryEvent({ + telemetryEvent: { + codeScanSucceededEvent: { + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language as CodewhispererLanguage), + }, + codeScanJobId: jobId, + numberOfFindings: numberOfFindings, + timestamp: new Date(Date.now()), + codeAnalysisScope: scope === CodeAnalysisScopeClientSide.FILE_AUTO ? 'FILE' : 'PROJECT', + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + + getLogger().debug( + `Failed to sendTelemetryEvent for code scan success, requestId: ${requestId ?? ''}, message: ${ + error.message + }` + ) + }) + } + + public sendCodeScanFailedEvent(language: string, jobId: string, scope: CodeAnalysisScopeClientSide) { + client + .sendTelemetryEvent({ + telemetryEvent: { + codeScanFailedEvent: { + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language as CodewhispererLanguage), + }, + codeScanJobId: jobId, + codeAnalysisScope: scope === CodeAnalysisScopeClientSide.FILE_AUTO ? 'FILE' : 'PROJECT', + timestamp: new Date(Date.now()), + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + getLogger().debug( + `Failed to sendTelemetryEvent for code scan failure, requestId: ${requestId ?? ''}, message: ${ + error.message + }` + ) + }) + } + + public sendCodeFixGenerationEvent( + jobId: string, + language?: string, + ruleId?: string, + detectorId?: string, + linesOfCodeGenerated?: number, + charsOfCodeGenerated?: number + ) { + client + .sendTelemetryEvent({ + telemetryEvent: { + codeFixGenerationEvent: { + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language as CodewhispererLanguage), + }, + jobId, + ruleId, + detectorId, + linesOfCodeGenerated, + charsOfCodeGenerated, + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + getLogger().debug( + `Failed to sendTelemetryEvent for code fix generation, requestId: ${requestId ?? ''}, message: ${ + error.message + }` + ) + }) + } + + public sendCodeFixAcceptanceEvent( + jobId: string, + language?: string, + ruleId?: string, + detectorId?: string, + linesOfCodeAccepted?: number, + charsOfCodeAccepted?: number + ) { + client + .sendTelemetryEvent({ + telemetryEvent: { + codeFixAcceptanceEvent: { + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language as CodewhispererLanguage), + }, + jobId, + ruleId, + detectorId, + linesOfCodeAccepted, + charsOfCodeAccepted, + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + getLogger().debug( + `Failed to sendTelemetryEvent for code fix acceptance, requestId: ${requestId ?? ''}, message: ${ + error.message + }` + ) + }) + } + + public sendTestGenerationEvent( + groupName: string, + jobId: string, + language?: string, + numberOfUnitTestCasesGenerated?: number, + numberOfUnitTestCasesAccepted?: number, + linesOfCodeGenerated?: number, + linesOfCodeAccepted?: number, + charsOfCodeGenerated?: number, + charsOfCodeAccepted?: number + ) { + client + .sendTelemetryEvent({ + telemetryEvent: { + testGenerationEvent: { + programmingLanguage: { + languageName: runtimeLanguageContext.toRuntimeLanguage(language as CodewhispererLanguage), + }, + jobId, + groupName, + ideCategory: 'VSCODE', + numberOfUnitTestCasesGenerated, + numberOfUnitTestCasesAccepted, + linesOfCodeGenerated, + linesOfCodeAccepted, + charsOfCodeGenerated, + charsOfCodeAccepted, + timestamp: new Date(Date.now()), + }, + }, + }) + .then() + .catch((error) => { + let requestId: string | undefined + if (isAwsError(error)) { + requestId = error.requestId + } + getLogger().debug( + `Failed to sendTelemetryEvent for test generation, requestId: ${requestId ?? ''}, message: ${ + error.message + }` + ) + }) + } + public sendCodeScanRemediationsEvent( languageId?: string, codeScanRemediationEventType?: CodeScanRemediationsEventType, diff --git a/packages/core/src/codewhisperer/util/zipUtil.ts b/packages/core/src/codewhisperer/util/zipUtil.ts index 2d41da94f98..7678f9dcb12 100644 --- a/packages/core/src/codewhisperer/util/zipUtil.ts +++ b/packages/core/src/codewhisperer/util/zipUtil.ts @@ -5,7 +5,7 @@ import admZip from 'adm-zip' import * as vscode from 'vscode' import path from 'path' -import { tempDirPath } from '../../shared/filesystemUtilities' +import { tempDirPath, testGenerationLogsDir } from '../../shared/filesystemUtilities' import { getLogger } from '../../shared/logger' import * as CodeWhispererConstants from '../models/constants' import { ToolkitError } from '../../shared/errors' @@ -14,7 +14,16 @@ import { getLoggerForScope } from '../service/securityScanHandler' import { runtimeLanguageContext } from './runtimeLanguageContext' import { CodewhispererLanguage } from '../../shared/telemetry/telemetry.gen' import { CurrentWsFolders, collectFiles } from '../../shared/utilities/workspaceUtils' -import { FileSizeExceededError, NoSourceFilesError, ProjectSizeExceededError } from '../models/errors' +import { + FileSizeExceededError, + NoActiveFileError, + NoSourceFilesError, + ProjectSizeExceededError, +} from '../models/errors' +import { ZipUseCase } from '../models/constants' +import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' +import { showOutputMessage } from '../../shared/utilities/messages' +import { globals, removeAnsi } from '../../shared' export interface ZipMetadata { rootDir: string @@ -31,6 +40,14 @@ export const ZipConstants = { newlineRegex: /\r?\n/, gitignoreFilename: '.gitignore', knownBinaryFileExts: ['.class'], + codeDiffFilePath: 'codeDiff/code.diff', +} + +interface GitDiffOptions { + projectPath: string + projectName: string + filePath?: string + scope?: CodeWhispererConstants.CodeAnalysisScope } export class ZipUtil { @@ -43,7 +60,7 @@ export class ZipUtil { protected _totalLines: number = 0 protected _fetchedDirs: Set = new Set() protected _language: CodewhispererLanguage | undefined - + protected _timestamp: string = Date.now().toString() constructor() {} getFileScanPayloadSizeLimitInBytes(): number { @@ -59,6 +76,12 @@ export class ZipUtil { return workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [] } + public getProjectPath(filePath: string) { + const fileUri = vscode.Uri.file(filePath) + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri) + return workspaceFolder?.uri.fsPath + } + protected async getTextContent(uri: vscode.Uri) { const document = await vscode.workspace.openTextDocument(uri) const content = document.getText() @@ -66,7 +89,10 @@ export class ZipUtil { } public reachSizeLimit(size: number, scope: CodeWhispererConstants.CodeAnalysisScope): boolean { - if (scope === CodeWhispererConstants.CodeAnalysisScope.FILE) { + if ( + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND + ) { return size > this.getFileScanPayloadSizeLimitInBytes() } else { return size > this.getProjectScanPayloadSizeLimitInBytes() @@ -78,9 +104,9 @@ export class ZipUtil { return willReachLimit } - protected async zipFile(uri: vscode.Uri | undefined) { + protected async zipFile(uri: vscode.Uri | undefined, scope: CodeWhispererConstants.CodeAnalysisScope) { if (!uri) { - throw Error('Uri is undefined') + throw new NoActiveFileError() } const zip = new admZip() @@ -92,6 +118,15 @@ export class ZipUtil { const relativePath = vscode.workspace.asRelativePath(uri) const zipEntryPath = this.getZipEntryPath(projectName, relativePath) zip.addFile(zipEntryPath, Buffer.from(content, 'utf-8')) + + if (scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND) { + await this.processCombinedGitDiff( + zip, + [workspaceFolder.uri.fsPath], + uri.fsPath, + CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND + ) + } } else { zip.addFile(uri.fsPath, Buffer.from(content, 'utf-8')) } @@ -100,44 +135,284 @@ export class ZipUtil { this._totalSize += (await fs.stat(uri.fsPath)).size this._totalLines += content.split(ZipConstants.newlineRegex).length - if (this.reachSizeLimit(this._totalSize, CodeWhispererConstants.CodeAnalysisScope.FILE)) { + if (this.reachSizeLimit(this._totalSize, scope)) { throw new FileSizeExceededError() } - - const zipFilePath = this.getZipDirPath() + CodeWhispererConstants.codeScanZipExt + const zipFilePath = this.getZipDirPath(ZipUseCase.CODE_SCAN) + CodeWhispererConstants.codeScanZipExt zip.writeZip(zipFilePath) return zipFilePath } - protected getZipEntryPath(projectName: string, relativePath: string) { + protected getZipEntryPath(projectName: string, relativePath: string, useCase?: ZipUseCase) { // Workspaces with multiple folders have the folder names as the root folder, // but workspaces with only a single folder don't. So prepend the workspace folder name // if it is not present. + if (useCase === ZipUseCase.TEST_GENERATION) { + return path.join(projectName, relativePath) + } return relativePath.split('/').shift() === projectName ? relativePath : path.join(projectName, relativePath) } - protected async zipProject() { - const zip = new admZip() + /** + * Processes a directory and adds its contents to a zip archive while preserving the directory structure. + * + * @param zip - The AdmZip instance to add files and directories to + * @param metadataDir - The absolute path to the directory to process + * + * @remarks + * This function: + * - Creates empty directory entries in the zip for each directory + * - Recursively processes all subdirectories + * - Adds all files to the zip while maintaining relative paths + * - Handles errors for individual file operations without stopping the overall process + * + * The files in the zip will be stored under a root directory named after the input directory's basename. + * + * @throws May throw errors from filesystem operations or zip creation + * + * @example + * ```typescript + * const zip = new AdmZip(); + * await processMetadataDir(zip, '/path/to/directory'); + * ``` + */ + protected async processMetadataDir(zip: admZip, metadataDir: string) { + const metadataDirName = path.basename(metadataDir) + // Helper function to add empty directory to zip + const addEmptyDirectory = (dirPath: string) => { + const relativePath = path.relative(metadataDir, dirPath) + const pathWithMetadata = path.join(metadataDirName, relativePath, '/') + zip.addFile(pathWithMetadata, Buffer.from('')) + } + + // Recursive function to process directories + const processDirectory = async (dirPath: string) => { + const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(dirPath)) + addEmptyDirectory(dirPath) + + for (const [fileName, fileType] of entries) { + const filePath = path.join(dirPath, fileName) + + if (fileType === vscode.FileType.File) { + try { + const fileContent = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)) + const buffer = Buffer.from(fileContent) + const relativePath = path.relative(metadataDir, filePath) + const pathWithMetadata = path.join(metadataDirName, relativePath) + zip.addFile(pathWithMetadata, buffer) + } catch (error) { + getLogger().error(`Failed to add file ${filePath} to zip: ${error}`) + } + } else if (fileType === vscode.FileType.Directory) { + // Recursively process subdirectory + await processDirectory(filePath) + } + } + } + await processDirectory(metadataDir) + } - const projectPaths = this.getProjectPaths() + protected async zipProject(useCase: ZipUseCase, projectPath?: string, metadataDir?: string) { + const zip = new admZip() + let projectPaths = [] + if (useCase === ZipUseCase.TEST_GENERATION && projectPath) { + projectPaths.push(projectPath) + } else { + projectPaths = this.getProjectPaths() + } + if (useCase === ZipUseCase.CODE_SCAN) { + await this.processCombinedGitDiff(zip, projectPaths, '', CodeWhispererConstants.CodeAnalysisScope.PROJECT) + } const languageCount = new Map() - await this.processSourceFiles(zip, languageCount, projectPaths) - this.processOtherFiles(zip, languageCount) + await this.processSourceFiles(zip, languageCount, projectPaths, useCase) + if (metadataDir) { + await this.processMetadataDir(zip, metadataDir) + } + if (useCase !== ZipUseCase.TEST_GENERATION) { + this.processOtherFiles(zip, languageCount) + } if (languageCount.size === 0) { throw new NoSourceFilesError() } this._language = [...languageCount.entries()].reduce((a, b) => (b[1] > a[1] ? b : a))[0] - const zipFilePath = this.getZipDirPath() + CodeWhispererConstants.codeScanZipExt + const zipFilePath = this.getZipDirPath(useCase) + CodeWhispererConstants.codeScanZipExt zip.writeZip(zipFilePath) return zipFilePath } + protected async processCombinedGitDiff( + zip: admZip, + projectPaths: string[], + filePath?: string, + scope?: CodeWhispererConstants.CodeAnalysisScope + ) { + let gitDiffContent = '' + for (const projectPath of projectPaths) { + const projectName = path.basename(projectPath) + // Get diff content + gitDiffContent += await this.executeGitDiff({ + projectPath, + projectName, + filePath, + scope, + }) + } + if (gitDiffContent) { + zip.addFile(ZipConstants.codeDiffFilePath, Buffer.from(gitDiffContent, 'utf-8')) + } + } + + private async getGitUntrackedFiles(projectPath: string): Promise { + const checkNewFileArgs = ['ls-files', '--others', '--exclude-standard'] + const checkProcess = new ChildProcess('git', checkNewFileArgs) + + try { + let output = '' + await checkProcess.run({ + rejectOnError: true, + rejectOnErrorCode: true, + onStdout: (text) => { + output += text + }, + spawnOptions: { + cwd: projectPath, + }, + }) + return output + } catch (err) { + getLogger().warn(`Failed to check if file is new: ${err}`) + return undefined + } + } + + private async generateNewFileDiff(projectPath: string, projectName: string, relativePath: string): Promise { + let diffContent = '' + + const gitArgs = [ + 'diff', + '--no-index', + `--src-prefix=a/${projectName}/`, + `--dst-prefix=b/${projectName}/`, + '/dev/null', // Use /dev/null as the old file + relativePath, + ] + + const childProcess = new ChildProcess('git', gitArgs) + const runOptions: ChildProcessOptions = { + rejectOnError: false, + rejectOnErrorCode: false, + onStdout: (text) => { + diffContent += text + showOutputMessage(removeAnsi(text), globals.outputChannel) + }, + onStderr: (text) => { + showOutputMessage(removeAnsi(text), globals.outputChannel) + }, + spawnOptions: { + cwd: projectPath, + }, + } + + try { + await childProcess.run(runOptions) + return diffContent + } catch (err) { + getLogger().warn(`Failed to run diff command: ${err}`) + return '' + } + } + + private async generateHeadDiff(projectPath: string, projectName: string, relativePath?: string): Promise { + let diffContent = '' + + const gitArgs = [ + 'diff', + 'HEAD', + `--src-prefix=a/${projectName}/`, + `--dst-prefix=b/${projectName}/`, + ...(relativePath ? [relativePath] : []), + ] + + const childProcess = new ChildProcess('git', gitArgs) + + const runOptions: ChildProcessOptions = { + rejectOnError: true, + rejectOnErrorCode: true, + onStdout: (text) => { + diffContent += text + showOutputMessage(removeAnsi(text), globals.outputChannel) + }, + onStderr: (text) => { + showOutputMessage(removeAnsi(text), globals.outputChannel) + }, + spawnOptions: { + cwd: projectPath, + }, + } + + try { + await childProcess.run(runOptions) + return diffContent + } catch (err) { + getLogger().warn(`Failed to run command \`${childProcess.toString()}\`: ${err}`) + return '' + } + } + + private async executeGitDiff(options: GitDiffOptions): Promise { + const { projectPath, projectName, filePath, scope } = options + const isProjectScope = scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT + + const untrackedFilesString = await this.getGitUntrackedFiles(projectPath) + const untrackedFilesArray = untrackedFilesString?.trim()?.split('\n')?.filter(Boolean) + + if (isProjectScope && untrackedFilesArray && !untrackedFilesArray.length) { + return await this.generateHeadDiff(projectPath, projectName) + } + + let diffContent = '' + + if (isProjectScope) { + diffContent = await this.generateHeadDiff(projectPath, projectName) + + if (untrackedFilesArray) { + const untrackedDiffs = await Promise.all( + untrackedFilesArray.map((file) => this.generateNewFileDiff(projectPath, projectName, file)) + ) + diffContent += untrackedDiffs.join('') + } + } else if (!isProjectScope && filePath) { + const relativeFilePath = path.relative(projectPath, filePath) + + const newFileDiff = await this.generateNewFileDiff(projectPath, projectName, relativeFilePath) + diffContent = this.rewriteDiff(newFileDiff) + } + return diffContent + } + + private rewriteDiff(inputStr: string): string { + const lines = inputStr.split('\n') + const rewrittenLines = lines.slice(0, 5).map((line) => { + line = line.replace(/\\\\/g, '/') + line = line.replace(/("a\/[^"]*)/g, (match, p1) => p1) + line = line.replace(/("b\/[^"]*)/g, (match, p1) => p1) + line = line.replace(/"/g, '') + + return line + }) + const outputLines = [...rewrittenLines, ...lines.slice(5)] + const outputStr = outputLines.join('\n') + + return outputStr + } + protected async processSourceFiles( zip: admZip, languageCount: Map, - projectPaths: string[] | undefined + projectPaths: string[] | undefined, + useCase: ZipUseCase ) { if (!projectPaths || projectPaths.length === 0) { return @@ -150,9 +425,12 @@ export class ZipUtil { this.getProjectScanPayloadSizeLimitInBytes() ) for (const file of sourceFiles) { - const zipEntryPath = this.getZipEntryPath(file.workspaceFolder.name, file.zipFilePath) + const zipEntryPath = this.getZipEntryPath(file.workspaceFolder.name, file.relativeFilePath, useCase) if (ZipConstants.knownBinaryFileExts.includes(path.extname(file.fileUri.fsPath))) { + if (useCase === ZipUseCase.TEST_GENERATION) { + continue + } await this.processBinaryFile(zip, file.fileUri, zipEntryPath) } else { const isFileOpenAndDirty = this.isFileOpenAndDirty(file.fileUri) @@ -171,6 +449,27 @@ export class ZipUtil { ) } + protected async processTestCoverageFiles(targetPath: string) { + //TODO: will be removed post release + const coverageFilePatterns = ['**/coverage.xml', '**/coverage.json', '**/coverage.txt'] + let files: vscode.Uri[] = [] + + for (const pattern of coverageFilePatterns) { + files = await vscode.workspace.findFiles(pattern) + if (files.length > 0) { + break + } + } + + await Promise.all( + files.map(async (file) => { + const fileName = path.basename(file.path) + const targetFilePath = path.join(targetPath, fileName) + await fs.copy(file.path, targetFilePath) + }) + ) + } + protected processTextFile( zip: admZip, uri: vscode.Uri, @@ -221,12 +520,14 @@ export class ZipUtil { return vscode.workspace.textDocuments.some((document) => document.uri.fsPath === uri.fsPath && document.isDirty) } - protected getZipDirPath(): string { + public getZipDirPath(useCase: ZipUseCase): string { if (this._zipDir === '') { - this._zipDir = path.join( - this._tmpDir, - CodeWhispererConstants.codeScanTruncDirPrefix + '_' + Date.now().toString() - ) + const prefix = + useCase === ZipUseCase.TEST_GENERATION + ? CodeWhispererConstants.TestGenerationTruncDirPrefix + : CodeWhispererConstants.codeScanTruncDirPrefix + + this._zipDir = path.join(this._tmpDir, `${prefix}_${this._timestamp}`) } return this._zipDir } @@ -236,12 +537,15 @@ export class ZipUtil { scope: CodeWhispererConstants.CodeAnalysisScope ): Promise { try { - const zipDirPath = this.getZipDirPath() + const zipDirPath = this.getZipDirPath(ZipUseCase.CODE_SCAN) let zipFilePath: string - if (scope === CodeWhispererConstants.CodeAnalysisScope.FILE) { - zipFilePath = await this.zipFile(uri) + if ( + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_AUTO || + scope === CodeWhispererConstants.CodeAnalysisScope.FILE_ON_DEMAND + ) { + zipFilePath = await this.zipFile(uri, scope) } else if (scope === CodeWhispererConstants.CodeAnalysisScope.PROJECT) { - zipFilePath = await this.zipProject() + zipFilePath = await this.zipProject(ZipUseCase.CODE_SCAN) } else { throw new ToolkitError(`Unknown code analysis scope: ${scope}`) } @@ -264,7 +568,56 @@ export class ZipUtil { } } - public async removeTmpFiles(zipMetadata: ZipMetadata, scope: CodeWhispererConstants.CodeAnalysisScope) { + public async generateZipTestGen(projectPath: string, initialExecution: boolean): Promise { + try { + // const repoMapFile = await LspClient.instance.getRepoMapJSON() + const zipDirPath = this.getZipDirPath(ZipUseCase.TEST_GENERATION) + + const metadataDir = path.join(zipDirPath, 'utgRequiredArtifactsDir') + + // Create directories + const dirs = { + metadata: metadataDir, + buildAndExecuteLogDir: path.join(metadataDir, 'buildAndExecuteLogDir'), + repoMapDir: path.join(metadataDir, 'repoMapData'), + testCoverageDir: path.join(metadataDir, 'testCoverageDir'), + } + await Promise.all(Object.values(dirs).map((dir) => fs.mkdir(dir))) + + // if (await fs.exists(repoMapFile)) { + // await fs.copy(repoMapFile, path.join(dirs.repoMapDir, 'repoMapData.json')) + // await fs.delete(repoMapFile) + // } + + if (!initialExecution) { + await this.processTestCoverageFiles(dirs.testCoverageDir) + + const sourcePath = path.join(testGenerationLogsDir, 'output.log') + const targetPath = path.join(dirs.buildAndExecuteLogDir, 'output.log') + if (await fs.exists(sourcePath)) { + await fs.copy(sourcePath, targetPath) + } + } + + const zipFilePath: string = await this.zipProject(ZipUseCase.TEST_GENERATION, projectPath, metadataDir) + const zipFileSize = (await fs.stat(zipFilePath)).size + return { + rootDir: zipDirPath, + zipFilePath: zipFilePath, + srcPayloadSizeInBytes: this._totalSize, + scannedFiles: new Set(this._pickedSourceFiles), + zipFileSizeInBytes: zipFileSize, + buildPayloadSizeInBytes: this._totalBuildSize, + lines: this._totalLines, + language: this._language, + } + } catch (error) { + getLogger().error('Zip error caused by: %s', error) + throw error + } + } + //TODO: Refactor this + public async removeTmpFiles(zipMetadata: ZipMetadata, scope?: CodeWhispererConstants.CodeAnalysisScope) { const logger = getLoggerForScope(scope) logger.verbose(`Cleaning up temporary files...`) await fs.delete(zipMetadata.zipFilePath, { force: true }) diff --git a/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts b/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts index a61508a09b2..0a70d9e8319 100644 --- a/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts +++ b/packages/core/src/codewhisperer/views/securityIssue/securityIssueWebview.ts @@ -6,7 +6,20 @@ import * as vscode from 'vscode' import { VueWebview } from '../../../webviews/main' import { CodeScanIssue } from '../../models/model' -import { Component } from '../../../shared/telemetry/telemetry' +import { + CodeFixAction, + CodewhispererCodeScanIssueApplyFix, + Component, + telemetry, +} from '../../../shared/telemetry/telemetry' +import { copyToClipboard } from '../../../shared/utilities/messages' +import { EditorContentController } from '../../../amazonq/commons/controllers/contentController' +import { SecurityIssueProvider } from '../../service/securityIssueProvider' +import { getPatchedCode, previewDiff } from '../../../shared/utilities/diffUtils' +import { amazonqCodeIssueDetailsTabTitle } from '../../models/constants' +import { AuthUtil } from '../../util/authUtil' +import { Mutable } from '../../../shared/utilities/tsUtils' +import { ExtContext } from '../../../shared/extensions' export class SecurityIssueWebview extends VueWebview { public static readonly sourcePath: string = 'src/codewhisperer/views/securityIssue/vue/index.js' @@ -14,6 +27,8 @@ export class SecurityIssueWebview extends VueWebview { private issue: CodeScanIssue | undefined private filePath: string | undefined + private isGenerateFixLoading: boolean = false + private isGenerateFixError: boolean = false public constructor() { super(SecurityIssueWebview.sourcePath) @@ -50,12 +65,12 @@ export class SecurityIssueWebview extends VueWebview { return '' } - public navigateToFile() { + public navigateToFile(showRange = true) { if (this.issue && this.filePath) { const range = new vscode.Range(this.issue.startLine, 0, this.issue.endLine, 0) return vscode.workspace.openTextDocument(this.filePath).then((doc) => { void vscode.window.showTextDocument(doc, { - selection: range, + selection: showRange ? range : undefined, viewColumn: vscode.ViewColumn.One, preview: true, }) @@ -68,30 +83,199 @@ export class SecurityIssueWebview extends VueWebview { this.dispose() } } + + public getIsGenerateFixLoading() { + return this.isGenerateFixLoading + } + + public setIsGenerateFixLoading(isGenerateFixLoading: boolean) { + this.isGenerateFixLoading = isGenerateFixLoading + } + + public getIsGenerateFixError() { + return this.isGenerateFixError + } + + public setIsGenerateFixError(isGenerateFixError: boolean) { + this.isGenerateFixError = isGenerateFixError + } + + public generateFix() { + void vscode.commands.executeCommand('aws.amazonq.security.generateFix', this.issue, this.filePath, 'webview') + } + + public regenerateFix() { + void vscode.commands.executeCommand('aws.amazonq.security.regenerateFix', this.issue, this.filePath, 'webview') + } + + public rejectFix() { + void vscode.commands.executeCommand('aws.amazonq.security.rejectFix', this.issue, this.filePath) + } + + public ignoreIssue() { + void vscode.commands.executeCommand('aws.amazonq.security.ignore', this.issue, this.filePath, 'webview') + } + + public ignoreAllIssues() { + void vscode.commands.executeCommand('aws.amazonq.security.ignoreAll', this.issue, 'webview') + } + + createApplyFixTelemetryEntry(fixAction: CodeFixAction): Mutable { + return { + detectorId: this.issue!.detectorId, + findingId: this.issue!.findingId, + ruleId: this.issue!.ruleId, + component: 'webview', + result: 'Succeeded', + credentialStartUrl: AuthUtil.instance.startUrl, + codeFixAction: fixAction, + } + } + + public async copyFixedCode() { + telemetry.ui_click.emit({ elementId: 'codeReviewGeneratedFix_copyCodeFix' }) + const fixedCode = await this.getFixedCode() + if (!fixedCode || fixedCode.length === 0) { + return + } + void copyToClipboard(fixedCode, 'suggested code fix') + const copyFixedCodeTelemetryEntry = this.createApplyFixTelemetryEntry('copyDiff') + telemetry.codewhisperer_codeScanIssueApplyFix.emit(copyFixedCodeTelemetryEntry) + } + + public async insertAtCursor() { + telemetry.ui_click.emit({ elementId: 'codeReviewGeneratedFix_insertCodeFixAtCursor' }) + const fixedCode = await this.getFixedCode() + if (!fixedCode || fixedCode.length === 0) { + return + } + const controller = new EditorContentController() + await this.navigateToFile(false) + controller.insertTextAtCursorPosition(fixedCode, () => {}) + const copyFixedCodeTelemetryEntry = this.createApplyFixTelemetryEntry('insertAtCursor') + telemetry.codewhisperer_codeScanIssueApplyFix.emit(copyFixedCodeTelemetryEntry) + } + + public async openDiff() { + telemetry.ui_click.emit({ elementId: 'codeReviewGeneratedFix_openCodeFixDiff' }) + const [suggestedFix] = this.issue?.suggestedFixes ?? [] + if (!this.filePath || !suggestedFix || !suggestedFix.code) { + return + } + await previewDiff(this.filePath, suggestedFix.code) + const copyFixedCodeTelemetryEntry = this.createApplyFixTelemetryEntry('openDiff') + telemetry.codewhisperer_codeScanIssueApplyFix.emit(copyFixedCodeTelemetryEntry) + } + + public async getLanguageId() { + if (!this.filePath) { + return + } + const document = await vscode.workspace.openTextDocument(this.filePath) + return document.languageId + } + + public async getFixedCode(snippetMode = true) { + const [suggestedFix] = this.issue?.suggestedFixes ?? [] + if (!this.filePath || !suggestedFix || !suggestedFix.code || !this.issue) { + return '' + } + const patchedCode = await getPatchedCode(this.filePath, suggestedFix.code, snippetMode) + return patchedCode + } } const Panel = VueWebview.compilePanel(SecurityIssueWebview) let activePanel: InstanceType | undefined export async function showSecurityIssueWebview(ctx: vscode.ExtensionContext, issue: CodeScanIssue, filePath: string) { - // always create a new panel per finding + const previousPanel = activePanel + const previousId = previousPanel?.server?.getIssue()?.findingId + if (previousPanel && previousId) { + previousPanel.server.closeWebview(previousId) + } activePanel = new Panel(ctx) activePanel.server.setIssue(issue) activePanel.server.setFilePath(filePath) + activePanel.server.setIsGenerateFixLoading(false) + activePanel.server.setIsGenerateFixError(false) const webviewPanel = await activePanel.show({ - title: 'Amazon Q Security Issue', + title: amazonqCodeIssueDetailsTabTitle, viewColumn: vscode.ViewColumn.Beside, cssFiles: ['securityIssue.css'], }) webviewPanel.iconPath = { - light: vscode.Uri.joinPath(ctx.extensionUri, 'resources/icons/vscode/light/shield.svg'), - dark: vscode.Uri.joinPath(ctx.extensionUri, 'resources/icons/vscode/dark/shield.svg'), + light: vscode.Uri.joinPath(ctx.extensionUri, 'resources/icons/aws/amazonq/q-squid-ink.svg'), + dark: vscode.Uri.joinPath(ctx.extensionUri, 'resources/icons/aws/amazonq/q-white.svg'), } webviewPanel.onDidDispose(() => (activePanel = undefined)) } +export function isSecurityIssueWebviewOpen() { + return activePanel !== undefined +} + export async function closeSecurityIssueWebview(findingId: string) { activePanel?.server.closeWebview(findingId) } + +export async function syncSecurityIssueWebview(context: ExtContext) { + const activeIssueId = activePanel?.server.getIssue()?.findingId + if (!activeIssueId) { + return + } + const updatedIssue = SecurityIssueProvider.instance.issues + .flatMap(({ issues }) => issues) + .find((issue) => issue.findingId === activeIssueId) + await updateSecurityIssueWebview({ + issue: updatedIssue, + context: context.extensionContext, + shouldRefreshView: false, + }) +} + +export async function getWebviewActiveIssueId() { + return activePanel?.server.getIssue()?.findingId +} + +type WebviewParams = { + issue?: CodeScanIssue + filePath?: string + isGenerateFixLoading?: boolean + isGenerateFixError?: boolean + shouldRefreshView: boolean + context: vscode.ExtensionContext +} +export async function updateSecurityIssueWebview({ + issue, + filePath, + isGenerateFixLoading, + isGenerateFixError, + shouldRefreshView, + context, +}: WebviewParams): Promise { + if (!activePanel) { + return + } + if (issue) { + activePanel.server.setIssue(issue) + } + if (filePath) { + activePanel.server.setFilePath(filePath) + } + if (isGenerateFixLoading !== undefined) { + activePanel.server.setIsGenerateFixLoading(isGenerateFixLoading) + } + if (isGenerateFixError !== undefined) { + activePanel.server.setIsGenerateFixError(isGenerateFixError) + } + if (shouldRefreshView && filePath && issue) { + await showSecurityIssueWebview(context, issue, filePath) + } +} + +export function getIsGenerateFixLoading() { + return activePanel?.server.getIsGenerateFixLoading() +} diff --git a/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue b/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue index 6e8e9889ea9..a6f01cdbc2a 100644 --- a/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue +++ b/packages/core/src/codewhisperer/views/securityIssue/vue/root.vue @@ -4,7 +4,6 @@

{{ title }}

-
@@ -15,7 +14,10 @@
- Common Weakness Enumeration (CWE) + Common Weakness
+ Enumeration (CWE)

@@ -86,6 +102,8 @@ import highSeverity from '../../../../../resources/images/severity-high.svg' import criticalSeverity from '../../../../../resources/images/severity-critical.svg' import markdownIt from 'markdown-it' import hljs from 'highlight.js' +import { parsePatch } from 'diff' +import { CodeScanIssue } from '../../../models/model' const client = WebviewClientFactory.create() const severityImages: Record = { @@ -97,10 +115,37 @@ const severityImages: Record = { } const md = markdownIt({ - highlight: function (str, lang) { - if (lang && hljs.getLanguage(lang)) { + highlight: function (str, lang, attrRaw): string { + const attrs = attrRaw.split(/\s+/g) + const showLineNumbers = attrs.includes('showLineNumbers') + const startFrom = parseInt(attrRaw.match(/startFrom=(\d+)/)?.[1] ?? '1') + const highlightStart = parseInt(attrRaw.match(/highlightStart=(\d+)/)?.[1] ?? '0') + const highlightEnd = parseInt(attrRaw.match(/highlightEnd=(\d+)/)?.[1] ?? '0') + if (lang) { try { - return hljs.highlight(str, { language: lang }).value + const highlighted = hljs.highlight(str, { + language: hljs.getLanguage(lang) ? lang : 'plaintext', + ignoreIllegals: true, + }).value + let result = highlighted + .trimEnd() + .split('\n') + .map((line) => { + if (line.startsWith('+')) { + return `${line}` + } else if (line.startsWith('-')) { + return `${line}` + } + return line + }) + .join('\n') + if (showLineNumbers) { + result = applyLineNumbers(result, startFrom - 1) + } + if (highlightStart && highlightEnd) { + result = applyHighlight(result, startFrom - 1, highlightStart, highlightEnd) + } + return result } catch (__) {} } @@ -108,6 +153,27 @@ const md = markdownIt({ }, }) +const applyLineNumbers = (code: string, lineNumberOffset = 0) => { + const lines = code.split('\n') + const rows = lines.map((line, idx) => { + const lineNumber = idx + 1 + lineNumberOffset + return `

${lineNumber}
${line}` + }) + return rows.join('\n') +} + +const applyHighlight = (code: string, lineNumberOffset = 0, highlightStart: number, highlightEnd: number) => { + const lines = code.split('\n') + const rows = lines.map((line, idx) => { + const lineNumber = idx + 1 + lineNumberOffset + if (lineNumber >= highlightStart && lineNumber < highlightEnd) { + return `
${line}
` + } + return line + }) + return rows.join('\n') +} + export default defineComponent({ data() { return { @@ -123,39 +189,89 @@ export default defineComponent({ isFixDescriptionAvailable: false, relatedVulnerabilities: [] as string[], startLine: 0, + endLine: 0, relativePath: '', + isGenerateFixLoading: false, + isGenerateFixError: false, + languageId: 'plaintext', + fixedCode: '', + referenceText: '', + referenceSpan: [0, 0], } }, created() { this.getData() }, + beforeMount() { + this.getData() + }, methods: { async getData() { const issue = await client.getIssue() - const relativePath = await client.getRelativePath() if (issue) { - const [suggestedFix] = issue.suggestedFixes - - this.title = issue.title - this.detectorId = issue.detectorId - this.detectorName = issue.detectorName - this.detectorUrl = issue.recommendation.url - this.relatedVulnerabilities = issue.relatedVulnerabilities - this.severity = issue.severity - this.recommendationText = issue.recommendation.text - this.startLine = issue.startLine - this.relativePath = relativePath - this.isFixAvailable = false - if (suggestedFix) { - this.isFixAvailable = true - this.suggestedFix = suggestedFix.code - if ( - suggestedFix.description.trim() !== '' && - suggestedFix.description.trim() !== 'Suggested remediation:' - ) { - this.isFixDescriptionAvailable = true - } - this.suggestedFixDescription = suggestedFix.description + this.updateFromIssue(issue) + } + const relativePath = await client.getRelativePath() + this.updateRelativePath(relativePath) + const isGenerateFixLoading = await client.getIsGenerateFixLoading() + const isGenerateFixError = await client.getIsGenerateFixError() + this.updateGenerateFixState(isGenerateFixLoading, isGenerateFixError) + const languageId = await client.getLanguageId() + if (languageId) { + this.updateLanguageId(languageId) + } + const fixedCode = await client.getFixedCode() + this.updateFixedCode(fixedCode) + }, + updateRelativePath(relativePath: string) { + this.relativePath = relativePath + }, + updateGenerateFixState(isGenerateFixLoading: boolean, isGenerateFixError: boolean) { + this.isGenerateFixLoading = isGenerateFixLoading + this.isGenerateFixError = isGenerateFixError + }, + updateLanguageId(languageId: string) { + this.languageId = languageId + }, + updateFixedCode(fixedCode: string) { + this.fixedCode = fixedCode.replaceAll('\n\\ No newline at end of file', '') + }, + updateFromIssue(issue: CodeScanIssue) { + const [suggestedFix] = issue.suggestedFixes + + this.title = issue.title + this.detectorId = issue.detectorId + this.detectorName = issue.detectorName + this.detectorUrl = issue.recommendation.url + this.relatedVulnerabilities = issue.relatedVulnerabilities + this.severity = issue.severity + this.recommendationText = issue.recommendation.text + this.startLine = issue.startLine + this.endLine = issue.endLine + this.isFixAvailable = false + this.isFixDescriptionAvailable = false + if (suggestedFix) { + this.isFixAvailable = !!suggestedFix.code && suggestedFix.code?.trim() !== '' + this.suggestedFix = suggestedFix.code ?? '' + if ( + suggestedFix.description?.trim() !== '' && + suggestedFix.description?.trim() !== 'Suggested remediation:' + ) { + this.isFixDescriptionAvailable = true + } + this.suggestedFixDescription = md.render(suggestedFix.description) + + const [reference] = suggestedFix.references ?? [] + if ( + reference && + reference.recommendationContentSpan?.start && + reference.recommendationContentSpan.end + ) { + this.referenceText = `Reference code under ${reference.licenseName} license from repository ${reference.repository}` + this.referenceSpan = [ + reference.recommendationContentSpan.start, + reference.recommendationContentSpan.end, + ] } } }, @@ -172,6 +288,57 @@ export default defineComponent({ navigateToFile() { client.navigateToFile() }, + generateFix() { + client.generateFix() + }, + regenerateFix() { + client.regenerateFix() + }, + rejectFix() { + client.rejectFix() + }, + ignoreIssue() { + client.ignoreIssue() + }, + ignoreAllIssues() { + client.ignoreAllIssues() + }, + copyFixedCode() { + client.copyFixedCode() + }, + insertAtCursor() { + client.insertAtCursor() + }, + openDiff() { + client.openDiff() + }, + computeSuggestedFixHtml() { + if (!this.isFixAvailable) { + return + } + const [parsedDiff] = parsePatch(this.suggestedFix) + const { oldStart } = parsedDiff.hunks[0] + const [referenceStart, referenceEnd] = this.referenceSpan + const htmlString = md.render(` +\`\`\`${this.languageId} showLineNumbers startFrom=${oldStart} ${ + referenceStart && referenceEnd + ? `highlightStart=${referenceStart + 1} highlightEnd=${referenceEnd + 1}` + : '' + } +${this.fixedCode} +\`\`\` + `) + const parser = new DOMParser() + const doc = parser.parseFromString(htmlString, 'text/html') + const referenceTracker = doc.querySelector('.reference-tracker') + if (referenceTracker) { + const tooltip = doc.createElement('div') + tooltip.classList.add('tooltip') + tooltip.innerHTML = this.referenceText + referenceTracker.appendChild(tooltip) + } + return doc.body.innerHTML + }, }, computed: { severityImage() { @@ -181,11 +348,7 @@ export default defineComponent({ return md.render(this.recommendationText) }, suggestedFixHtml() { - return md.render(` -\`\`\`diff -${this.suggestedFix.replaceAll('\n\\ No newline at end of file', '')} -\`\`\` - `) + return this.computeSuggestedFixHtml() }, }, }) diff --git a/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts b/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts index e7fc67a6a67..d02eea155eb 100644 --- a/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts +++ b/packages/core/src/codewhisperer/views/securityPanelViewProvider.ts @@ -214,7 +214,7 @@ export class SecurityPanelViewProvider implements vscode.WebviewViewProvider { private getHtmlContent(): string { if (this.persistLog.length === 0) { - return 'No security issues have been detected in the workspace.' + return 'No code issues have been detected in the workspace.' } return this.persistLog.join('') + this.dynamicLog.join('') } diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 346419e3e2f..6a1d388c05d 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -53,6 +53,7 @@ import { globals, waitUntil } from '../../../shared' import { telemetry } from '../../../shared/telemetry' import { isSsoConnection } from '../../../auth/connection' import { inspect } from '../../../shared/utilities/collectionUtils' +import { DefaultAmazonQAppInitContext } from '../../../amazonq/apps/initContext' export interface ChatControllerMessagePublishers { readonly processPromptChatMessage: MessagePublisher @@ -377,11 +378,20 @@ export class ChatController { this.editorContextExtractor .extractContextForTrigger('ContextMenu') - .then((context) => { + .then(async (context) => { const triggerID = randomUUID() + if (command.type === 'aws.amazonq.generateUnitTests') { + DefaultAmazonQAppInitContext.instance.getAppsToWebViewMessagePublisher().publish({ + sender: 'testChat', + command: 'test', + type: 'chatMessage', + }) + // For non-supported languages, we'll just open the standard chat. + return + } if (context?.focusAreaContext?.codeBlock === undefined) { - throw 'Sorry, we cannot help with the selected language code snippet' + throw 'Sorry, I cannot help with the selected language code snippet' } const prompt = this.promptGenerator.generateForContextMenuCommand(command) diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 3c377119747..af6a3a2a3ce 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -28,13 +28,13 @@ import { getHttpStatusCode, getRequestId, ToolkitError } from '../../../../share import { keys } from '../../../../shared/utilities/tsUtils' import { getLogger } from '../../../../shared/logger/logger' import { FeatureAuthState } from '../../../../codewhisperer/util/authUtil' -import { userGuideURL } from '../../../../amazonq/webview/ui/texts/constants' import { CodeScanIssue } from '../../../../codewhisperer/models/model' import { marked } from 'marked' import { JSDOM } from 'jsdom' import { LspController } from '../../../../amazonq/lsp/lspController' import { extractCodeBlockLanguage } from '../../../../shared/markdown' import { extractAuthFollowUp } from '../../../../amazonq/util/authUtils' +import { helpMessage } from '../../../../amazonq/webview/ui/texts/constants' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -358,34 +358,7 @@ export class Messenger { let followUpsHeader switch (type) { case 'quick-action-help': - message = `I'm Amazon Q, a generative AI assistant. Learn more about me below. Your feedback will help me improve. - \n\n### What I can do: - \n\n- Answer questions about AWS - \n\n- Answer questions about general programming concepts - \n\n- Explain what a line of code or code function does - \n\n- Write unit tests and code - \n\n- Debug and fix code - \n\n- Refactor code - \n\n### What I don't do right now: - \n\n- Answer questions in languages other than English - \n\n- Remember conversations from your previous sessions - \n\n- Have information about your AWS account or your specific AWS resources - \n\n### Examples of questions I can answer: - \n\n- When should I use ElastiCache? - \n\n- How do I create an Application Load Balancer? - \n\n- Explain the and ask clarifying questions about it. - \n\n- What is the syntax of declaring a variable in TypeScript? - \n\n### Special Commands - \n\n- /clear - Clear the conversation. - \n\n- /dev - Get code suggestions across files in your current project. Provide a brief prompt, such as "Implement a GET API." - \n\n- /transform - Transform your code. Use to upgrade Java code versions. - \n\n- /help - View chat topics and commands. - \n\n### Things to note: - \n\n- I may not always provide completely accurate or current information. - \n\n- Provide feedback by choosing the like or dislike buttons that appear below answers. - \n\n- When you use Amazon Q, AWS may, for service improvement purposes, store data about your usage and content. You can opt-out of sharing this data by following the steps in AI services opt-out policies. See here - \n\n- Do not enter any confidential, sensitive, or personal information. - \n\n*For additional help, visit the [Amazon Q User Guide](${userGuideURL}).*` + message = helpMessage break case 'onboarding-help': message = `### What I can do: diff --git a/packages/core/src/codewhispererChat/controllers/chat/prompts/promptsGenerator.ts b/packages/core/src/codewhispererChat/controllers/chat/prompts/promptsGenerator.ts index d738eddcb67..6abecda2f8c 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/prompts/promptsGenerator.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/prompts/promptsGenerator.ts @@ -14,7 +14,6 @@ export class PromptsGenerator { ['aws.amazonq.fixCode', 'Fix'], ['aws.amazonq.optimizeCode', 'Optimize'], ['aws.amazonq.sendToPrompt', 'Send to prompt'], - ['aws.amazonq.generateUnitTests', 'Generate unit tests for'], ]) public generateForContextMenuCommand(command: EditorContextCommand): string { diff --git a/packages/core/src/shared/filesystemUtilities.ts b/packages/core/src/shared/filesystemUtilities.ts index 578cc677a04..1c8cf40f11a 100644 --- a/packages/core/src/shared/filesystemUtilities.ts +++ b/packages/core/src/shared/filesystemUtilities.ts @@ -20,6 +20,8 @@ export const tempDirPath = path.join( 'aws-toolkit-vscode' ) +export const testGenerationLogsDir = path.join(tempDirPath, 'testGenerationLogs') + export async function getDirSize(dirPath: string, startTime: number, duration: number): Promise { if (performance.now() - startTime > duration) { getLogger().warn('getDirSize: exceeds time limit') diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index 7b1ae5d19c6..bbdb03b7cd5 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -31,7 +31,9 @@ export type globalKey = | 'aws.amazonq.codewhisperer.newCustomizations' | 'aws.amazonq.hasShownWalkthrough' | 'aws.amazonq.showTryChatCodeLens' + | 'aws.amazonq.securityIssueFilters' | 'aws.amazonq.notifications' + | 'aws.amazonq.welcomeChatShowCount' | 'aws.notifications' | 'aws.notifications.dev' // keys to store notifications for testing | 'aws.downloadPath' diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index c1fb3a259b0..a4f35d525c6 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -20,7 +20,7 @@ export { RegionProvider } from './regions/regionProvider' export { Commands } from './vscode/commands2' export { getMachineId } from './vscode/env' export { getLogger } from './logger/logger' -export { activateExtension } from './utilities/vsCodeUtils' +export { activateExtension, openUrl } from './utilities/vsCodeUtils' export { waitUntil, sleep, Timeout } from './utilities/timeoutUtils' export { Prompter } from './ui/prompter' export { VirtualFileSystem } from './virtualFilesystem' @@ -55,5 +55,7 @@ export * from './handleUninstall' export { CrashMonitoring } from './crashMonitoring' export { amazonQDiffScheme } from './constants' export * from './featureConfig' +export { i18n } from './i18n-helper' export * from './icons' export * as textDocumentUtil from './utilities/textDocumentUtilities' +export { TabTypeDataMap } from '../amazonq/webview/ui/tabs/constants' diff --git a/packages/core/src/shared/logger/activation.ts b/packages/core/src/shared/logger/activation.ts index ff3a8ad6076..ac740d1b0f3 100644 --- a/packages/core/src/shared/logger/activation.ts +++ b/packages/core/src/shared/logger/activation.ts @@ -58,7 +58,12 @@ export async function activate( 'debugConsole' ) - getLogger().info('Log level: %s%s', chanLogLevel, logUri ? `, file (always "debug" level): ${logUri.fsPath}` : '') + getLogger().info( + 'Log level: %s, beta=%s%s', + chanLogLevel, + isBeta(), + logUri ? `, file (always "debug" level): ${logUri.fsPath}` : '' + ) getLogger().debug('User agent: %s', getUserAgent({ includePlatform: true, includeClientId: true })) if (devLogfile && typeof devLogfile !== 'string') { getLogger().error('invalid aws.dev.logfile setting') diff --git a/packages/core/src/shared/settings-amazonq.gen.ts b/packages/core/src/shared/settings-amazonq.gen.ts index 4fbe8ab0005..c3e92d45035 100644 --- a/packages/core/src/shared/settings-amazonq.gen.ts +++ b/packages/core/src/shared/settings-amazonq.gen.ts @@ -26,7 +26,8 @@ export const amazonqSettings = { "amazonQ.workspaceIndex": {}, "amazonQ.workspaceIndexWorkerThreads": {}, "amazonQ.workspaceIndexUseGPU": {}, - "amazonQ.workspaceIndexMaxSize": {} + "amazonQ.workspaceIndexMaxSize": {}, + "amazonQ.ignoredSecurityIssues": {} } export default amazonqSettings diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index bfdf4dc96c2..48d5ab88f4c 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -1,5 +1,20 @@ { "types": [ + { + "name": "acceptedCharactersCount", + "type": "int", + "description": "The number of accepted characters" + }, + { + "name": "acceptedCount", + "type": "int", + "description": "The number of accepted cases" + }, + { + "name": "acceptedLinesCount", + "type": "int", + "description": "The number of accepted lines of code" + }, { "name": "amazonGenerateApproachLatency", "type": "double", @@ -281,6 +296,16 @@ "type": "string", "description": "An AWS region." }, + { + "name": "buildPayloadBytes", + "type": "int", + "description": "The uncompressed payload size in bytes of the source files in customer project context" + }, + { + "name": "buildZipFileBytes", + "type": "int", + "description": "The compressed payload size of source files in bytes of customer project context sent" + }, { "name": "connectionState", "type": "string", @@ -350,6 +375,51 @@ "name": "amazonqMessageDisplayedMs", "type": "int", "description": "Duration between the partner teams code receiving the message and when the message was finally displayed in ms" + }, + { + "name": "executedCount", + "type": "int", + "description": "The number of executed operations" + }, + { + "name": "generatedCharactersCount", + "type": "int", + "description": "Number of characters of code generated" + }, + { + "name": "generatedCount", + "type": "int", + "description": "The number of generated cases" + }, + { + "name": "generatedLinesCount", + "type": "int", + "description": "The number of generated lines of code" + }, + { + "name": "hasUserPromptSupplied", + "type": "boolean", + "description": "True if user supplied prompt message as input else false" + }, + { + "name": "isCodeBlockSelected", + "type": "boolean", + "description": "True if user selected code snippet as input else false" + }, + { + "name": "isSupportedLanguage", + "type": "boolean", + "description": "Indicate if the language is supported" + }, + { + "name": "jobGroup", + "type": "string", + "description": "Job group name used in the operation" + }, + { + "name": "jobId", + "type": "string", + "description": "Job id used in the operation" } ], "metrics": [ @@ -1068,6 +1138,77 @@ } ] }, + { + "name": "amazonq_utgGenerateTests", + "description": "Client side invocation of the AmazonQ Unit Test Generation", + "metadata": [ + { + "type": "acceptedCharactersCount", + "required": false + }, + { + "type": "acceptedCount", + "required": false + }, + { + "type": "acceptedLinesCount", + "required": false + }, + { + "type": "artifactsUploadDuration", + "required": false + }, + { + "type": "buildPayloadBytes", + "required": false + }, + { + "type": "buildZipFileBytes", + "required": false + }, + { + "type": "credentialStartUrl", + "required": false + }, + { + "type": "cwsprChatProgrammingLanguage" + }, + { + "type": "generatedCharactersCount", + "required": false + }, + { + "type": "generatedCount", + "required": false + }, + { + "type": "generatedLinesCount", + "required": false + }, + { + "type": "hasUserPromptSupplied" + }, + { + "type": "isCodeBlockSelected", + "required": false + }, + { + "type": "isSupportedLanguage" + }, + { + "type": "jobGroup", + "required": false + }, + { + "type": "jobId", + "required": false + }, + { + "type": "perfClientLatency", + "required": false + } + ] + }, { "name": "ide_editCodeFile", "description": "User opened a code file with the given file extension. Client should DEDUPLICATE this metric (ideally hourly/daily). AWS-specific files should (also) emit `file_editAwsFile`.", diff --git a/packages/core/src/shared/utilities/commentUtils.ts b/packages/core/src/shared/utilities/commentUtils.ts new file mode 100644 index 00000000000..42854ec313c --- /dev/null +++ b/packages/core/src/shared/utilities/commentUtils.ts @@ -0,0 +1,102 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import vscode from 'vscode' +import { CodeWhispererConstants } from '../../codewhisperer' + +interface CommentConfig { + lineComment?: string + blockComment?: [string, string] +} + +const defaultCommentConfig: CommentConfig = { lineComment: '//', blockComment: ['/*', '*/'] } + +const languageCommentConfig: Record = { + java: defaultCommentConfig, + python: { lineComment: '#', blockComment: ["'''", "'''"] }, + javascript: defaultCommentConfig, + javascriptreact: defaultCommentConfig, + typescript: defaultCommentConfig, + typescriptreact: defaultCommentConfig, + csharp: defaultCommentConfig, + c: defaultCommentConfig, + cpp: defaultCommentConfig, + go: defaultCommentConfig, + php: defaultCommentConfig, + ruby: { lineComment: '#', blockComment: ['=begin', '=end'] }, + golang: defaultCommentConfig, + json: undefined, + yaml: { lineComment: '#' }, + tf: { lineComment: '#', blockComment: defaultCommentConfig.blockComment }, + hcl: { lineComment: '#', blockComment: defaultCommentConfig.blockComment }, + terraform: { lineComment: '#', blockComment: defaultCommentConfig.blockComment }, + terragrunt: { lineComment: '#', blockComment: defaultCommentConfig.blockComment }, + packer: { lineComment: '#', blockComment: defaultCommentConfig.blockComment }, + plaintext: undefined, + jsonc: { lineComment: '//' }, + xml: { blockComment: [''] }, + toml: { lineComment: '#' }, + 'pip-requirements': { lineComment: '#' }, + 'java-properties': { lineComment: '#' }, + 'go.mod': { lineComment: '//' }, + 'go.sum': undefined, + kotlin: defaultCommentConfig, + scala: defaultCommentConfig, + sh: { lineComment: '#', blockComment: [": '", "'"] }, + shell: { lineComment: '#', blockComment: [": '", "'"] }, + shellscript: { lineComment: '#', blockComment: [": '", "'"] }, +} + +export function getLanguageCommentConfig(languageId: string): CommentConfig { + return languageCommentConfig[languageId as CodeWhispererConstants.SecurityScanLanguageId] ?? {} +} + +export function detectCommentAboveLine(document: vscode.TextDocument, line: number, comment: string): boolean { + const languageId = document.languageId + + const { lineComment, blockComment } = getLanguageCommentConfig(languageId) + + for (let i = line - 1; i >= 0; i--) { + const lineText = document.lineAt(i).text.trim() + if (lineText === '') { + continue + } + if (lineComment && lineComment.length && lineText.startsWith(lineComment) && lineText.includes(comment)) { + return true + } + if (blockComment && blockComment.length === 2) { + const [blockCommentStart, blockCommentEnd] = blockComment + if ( + lineText.startsWith(blockCommentStart) && + lineText.includes(comment) && + lineText.endsWith(blockCommentEnd) + ) { + return true + } + } + return false + } + + return false +} + +export function insertCommentAboveLine(document: vscode.TextDocument, line: number, comment: string): void { + const languageId = document.languageId + const { lineComment, blockComment } = getLanguageCommentConfig(languageId) + if (!lineComment && !blockComment) { + return + } + + const edit = new vscode.WorkspaceEdit() + const position = new vscode.Position(line, 0) + const indent = ' '.repeat(Math.max(0, document.lineAt(line).firstNonWhitespaceCharacterIndex)) + const commentText = lineComment + ? `${indent}${lineComment} ${comment}\n` + : blockComment?.[0] && blockComment[1] + ? `${indent}${blockComment[0]} ${comment} ${blockComment[1]}\n` + : `${indent}${defaultCommentConfig.lineComment} ${comment}\n` + edit.insert(document.uri, position, commentText) + void vscode.workspace.applyEdit(edit) +} diff --git a/packages/core/src/shared/utilities/diffUtils.ts b/packages/core/src/shared/utilities/diffUtils.ts new file mode 100644 index 00000000000..46292b07ef1 --- /dev/null +++ b/packages/core/src/shared/utilities/diffUtils.ts @@ -0,0 +1,150 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import vscode from 'vscode' +import path from 'path' +import { applyPatch, parsePatch, type LinesOptions, diffLines, Change } from 'diff' +import { tempDirPath } from '../filesystemUtilities' +import fs from '../fs/fs' +import { amazonQDiffScheme } from '../constants' +import { ContentProvider } from '../../amazonq/commons/controllers/contentController' +import { disposeOnEditorClose } from './editorUtilities' +import { getLogger } from '../logger' + +/** + * Get the patched code from a file and a patch. + * If snippetMode is true, it will return the code snippet that was changed. + * Otherwise, it will return the entire file with the changes applied. + * + * @param filePath The file being patched + * @param patch The patch to apply + * @param snippetMode Whether to return a snippet or the entire code + * @returns The patched code + */ +export async function getPatchedCode(filePath: string, patch: string, snippetMode = false) { + const document = await vscode.workspace.openTextDocument(filePath) + const fileContent = document.getText() + // Usage with the existing getPatchedCode function: + + let updatedPatch = patch + let updatedContent = applyPatch(fileContent, updatedPatch, { fuzzFactor: 4 }) + if (!updatedContent) { + updatedPatch = updatePatchLineNumbers(patch, 1) + updatedContent = applyPatch(fileContent, updatedPatch, { fuzzFactor: 4 }) + if (!updatedContent) { + return '' + } + } + + if (!snippetMode) { + return updatedContent + } + + const [parsedDiff] = parsePatch(updatedPatch) + const { lines, oldStart } = parsedDiff.hunks[0] + const deletionLines = lines.filter((line) => line.startsWith('-')) + const startLine = oldStart - 1 + const endLine = startLine + lines.length - deletionLines.length + return updatedContent.split('\n').slice(startLine, endLine).join('\n') +} + +function updatePatchLineNumbers(patch: string, offset: number): string { + // Regular expression to match the @@ line with capturing groups for the numbers + const lineNumberRegex = /@@ -(\d+),(\d+) \+(\d+),(\d+) @@/g + return patch.replace( + lineNumberRegex, + (match: string, oldStart: string, oldCount: string, newStart: string, newCount: string) => { + // Convert to numbers and adjust by offset + const adjustedOldStart = Math.max(1, parseInt(oldStart) + offset) + const adjustedNewStart = Math.max(1, parseInt(newStart) + offset) + + // Create new @@ line with adjusted numbers + return `@@ -${adjustedOldStart},${oldCount} +${adjustedNewStart},${newCount} @@` + } + ) +} + +/** + * Preview the diff of a file with a patch in vscode native diff view. + * Creates a temporary file with the patched code to do the comparison. + * + * @param filePath The file being patched + * @param patch The patch to apply + * @returns + */ +export async function previewDiff(filePath: string, patch: string) { + const patchedCode = await getPatchedCode(filePath, patch) + const file = path.parse(filePath) + const tmpFilePath = path.join(tempDirPath, `${file.name}_proposed-${Date.now()}${file.ext}`) + const tmpFileUri = vscode.Uri.parse(`${amazonQDiffScheme}:${tmpFilePath}`) + + await fs.writeFile(tmpFilePath, patchedCode) + const contentProvider = new ContentProvider(tmpFileUri) + const disposable = vscode.workspace.registerTextDocumentContentProvider(amazonQDiffScheme, contentProvider) + + await vscode.commands.executeCommand( + 'vscode.diff', + vscode.Uri.file(filePath), + tmpFileUri, + getDiffTitle(file.base), + { preview: true, viewColumn: vscode.ViewColumn.One } + ) + + disposeOnEditorClose(tmpFileUri, disposable) + await fs.delete(tmpFilePath) +} + +export async function closeDiff(filePath: string) { + vscode.window.tabGroups.all.flatMap(({ tabs }) => + tabs.map((tab) => { + if (tab.label === getDiffTitle(path.basename(filePath))) { + const tabClosed = vscode.window.tabGroups.close(tab) + if (!tabClosed) { + getLogger().error('Unable to close the diff view tab for %s', tab.label) + } + } + }) + ) +} + +function getDiffTitle(fileName: string) { + return `${fileName}: Original ↔ ${fileName}` +} + +/** + * Calculates the number of added characters and lines between existing content and LLM response + * + * @param existingContent The original text content before changes + * @param llmResponse The new text content from the LLM + * @returns An object containing: + * - addedChars: Total number of new characters added + * - addedLines: Total number of new lines added + * + */ +export function getDiffCharsAndLines( + existingContent: string, + llmResponse: string +): { + addedChars: number + addedLines: number +} { + let addedChars = 0 + let addedLines = 0 + const diffs = diffLines(existingContent, llmResponse, { + stripTrailingCr: true, + ignoreNewlineAtEof: true, + } as LinesOptions) + + diffs.forEach((part: Change) => { + if (part.added) { + addedChars += part.value.length + addedLines += part.count ?? part.value.split('\n').length + } + }) + + return { + addedChars, + addedLines, + } +} diff --git a/packages/core/src/shared/utilities/workspaceUtils.ts b/packages/core/src/shared/utilities/workspaceUtils.ts index f9d56f5f4ea..3894c3b56ff 100644 --- a/packages/core/src/shared/utilities/workspaceUtils.ts +++ b/packages/core/src/shared/utilities/workspaceUtils.ts @@ -74,6 +74,11 @@ export function hasWorkspace() { return wsFolders !== undefined && wsFolders.length > 0 } +export function isMultiRootWorkspace() { + const wsFolders = vscode.workspace.workspaceFolders + return wsFolders !== undefined && wsFolders.length > 1 +} + /** * Resolves `relPath` against parent `workspaceFolder`, or returns `relPath` if * already absolute or the operation fails. diff --git a/packages/core/src/shared/vscode/setContext.ts b/packages/core/src/shared/vscode/setContext.ts index 2e8d1a6e750..b1dcdfb2067 100644 --- a/packages/core/src/shared/vscode/setContext.ts +++ b/packages/core/src/shared/vscode/setContext.ts @@ -16,6 +16,7 @@ export type contextKey = | 'aws.isWebExtHost' | 'aws.isInternalUser' | 'aws.amazonq.showLoginView' + | 'aws.amazonq.security.noMatches' | 'aws.amazonq.notifications.show' | 'aws.codecatalyst.connected' | 'aws.codewhisperer.connected' diff --git a/packages/core/src/test/amazonq/common/diff.test.ts b/packages/core/src/test/amazonq/common/diff.test.ts index 64002d32575..0fc81403a59 100644 --- a/packages/core/src/test/amazonq/common/diff.test.ts +++ b/packages/core/src/test/amazonq/common/diff.test.ts @@ -12,6 +12,8 @@ import assert from 'assert' import * as path from 'path' import * as vscode from 'vscode' import sinon from 'sinon' +import { FileSystem } from '../../../shared/fs/fs' +import { featureDevScheme } from '../../../amazonqFeatureDev' import { createAmazonQUri, getFileDiffUris, @@ -20,7 +22,6 @@ import { openDiff, computeDiff, } from '../../../amazonq' -import { FileSystem } from '../../../shared/fs/fs' import { TextDocument } from 'vscode' describe('diff', () => { @@ -44,19 +45,19 @@ describe('diff', () => { describe('openDiff', () => { it('file exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(true) - await openDiff(filePath, rightPath, tabId) + await openDiff(filePath, rightPath, tabId, featureDevScheme) const leftExpected = vscode.Uri.file(filePath) - const rightExpected = createAmazonQUri(rightPath, tabId) + const rightExpected = createAmazonQUri(rightPath, tabId, featureDevScheme) assert.ok(executeCommandSpy.calledWith('vscode.diff', leftExpected, rightExpected)) }) it('file does not exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(false) - await openDiff(filePath, rightPath, tabId) + await openDiff(filePath, rightPath, tabId, featureDevScheme) - const leftExpected = await getOriginalFileUri(filePath, tabId) - const rightExpected = createAmazonQUri(rightPath, tabId) + const leftExpected = await getOriginalFileUri(filePath, tabId, featureDevScheme) + const rightExpected = createAmazonQUri(rightPath, tabId, featureDevScheme) assert.ok(executeCommandSpy.calledWith('vscode.diff', leftExpected, rightExpected)) }) }) @@ -66,19 +67,19 @@ describe('diff', () => { it('file exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(true) - await openDeletedDiff(filePath, name, tabId) + await openDeletedDiff(filePath, name, tabId, featureDevScheme) const leftExpected = vscode.Uri.file(filePath) - const rightExpected = createAmazonQUri('empty', tabId) + const rightExpected = createAmazonQUri('empty', tabId, featureDevScheme) assert.ok(executeCommandSpy.calledWith('vscode.diff', leftExpected, rightExpected, `${name} (Deleted)`)) }) it('file does not exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(false) - await openDeletedDiff(filePath, name, tabId) + await openDeletedDiff(filePath, name, tabId, featureDevScheme) - const leftExpected = createAmazonQUri('empty', tabId) - const rightExpected = createAmazonQUri('empty', tabId) + const leftExpected = createAmazonQUri('empty', tabId, featureDevScheme) + const rightExpected = createAmazonQUri('empty', tabId, featureDevScheme) assert.ok(executeCommandSpy.calledWith('vscode.diff', leftExpected, rightExpected, `${name} (Deleted)`)) }) }) @@ -86,13 +87,13 @@ describe('diff', () => { describe('getOriginalFileUri', () => { it('file exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(true) - assert.deepStrictEqual((await getOriginalFileUri(filePath, tabId)).fsPath, filePath) + assert.deepStrictEqual((await getOriginalFileUri(filePath, tabId, featureDevScheme)).fsPath, filePath) }) it('file does not exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(false) - const expected = createAmazonQUri('empty', tabId) - assert.deepStrictEqual(await getOriginalFileUri(filePath, tabId), expected) + const expected = createAmazonQUri('empty', tabId, featureDevScheme) + assert.deepStrictEqual(await getOriginalFileUri(filePath, tabId, featureDevScheme), expected) }) }) @@ -100,23 +101,23 @@ describe('diff', () => { it('file exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(true) - const { left, right } = await getFileDiffUris(filePath, rightPath, tabId) + const { left, right } = await getFileDiffUris(filePath, rightPath, tabId, featureDevScheme) const leftExpected = vscode.Uri.file(filePath) assert.deepStrictEqual(left, leftExpected) - const rightExpected = createAmazonQUri(rightPath, tabId) + const rightExpected = createAmazonQUri(rightPath, tabId, featureDevScheme) assert.deepStrictEqual(right, rightExpected) }) it('file does not exists locally', async () => { sandbox.stub(FileSystem.prototype, 'exists').resolves(false) - const { left, right } = await getFileDiffUris(filePath, rightPath, tabId) + const { left, right } = await getFileDiffUris(filePath, rightPath, tabId, featureDevScheme) - const leftExpected = await getOriginalFileUri(filePath, tabId) + const leftExpected = await getOriginalFileUri(filePath, tabId, featureDevScheme) assert.deepStrictEqual(left, leftExpected) - const rightExpected = createAmazonQUri(rightPath, tabId) + const rightExpected = createAmazonQUri(rightPath, tabId, featureDevScheme) assert.deepStrictEqual(right, rightExpected) }) }) @@ -135,7 +136,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, filePath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -169,7 +171,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -213,7 +216,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -263,7 +267,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -313,7 +318,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -357,7 +363,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -407,7 +414,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ @@ -451,7 +459,8 @@ describe('diff', () => { const { changes, charsAdded, linesAdded, charsRemoved, linesRemoved } = await computeDiff( filePath, rightPath, - tabId + tabId, + featureDevScheme ) const expectedChanges = [ diff --git a/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts b/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts index 2e200aaa3b3..efc5b51bd39 100644 --- a/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts +++ b/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts @@ -9,7 +9,7 @@ import * as path from 'path' import sinon from 'sinon' import { waitUntil } from '../../../../shared/utilities/timeoutUtils' import { ControllerSetup, createController, createSession, generateVirtualMemoryUri } from '../../utils' -import { CurrentWsFolders, DeletedFileInfo, FollowUpTypes, NewFileInfo } from '../../../../amazonqFeatureDev/types' +import { CurrentWsFolders, DeletedFileInfo, NewFileInfo } from '../../../../amazonqFeatureDev/types' import { Session } from '../../../../amazonqFeatureDev/session/session' import { Prompter } from '../../../../shared/ui/prompter' import { assertTelemetry, toFile } from '../../../testUtil' @@ -33,8 +33,9 @@ import { CodeGenState, PrepareCodeGenState } from '../../../../amazonqFeatureDev import { FeatureDevClient } from '../../../../amazonqFeatureDev/client/featureDev' import { createAmazonQUri } from '../../../../amazonq/commons/diff' import { AuthUtil } from '../../../../codewhisperer' -import { featureName, messageWithConversationId } from '../../../../amazonqFeatureDev' +import { featureDevScheme, featureName, messageWithConversationId } from '../../../../amazonqFeatureDev' import { i18n } from '../../../../shared/i18n-helper' +import { FollowUpTypes } from '../../../../amazonq/commons/types' let mockGetCodeGeneration: sinon.SinonStub describe('Controller', () => { @@ -51,7 +52,7 @@ describe('Controller', () => { relativePath: 'myfile1.js', fileContent: '', rejected: false, - virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'myfile1.js'), + virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'myfile1.js', featureDevScheme), workspaceFolder: controllerSetup.workspaceFolder, changeApplied: false, }, @@ -60,7 +61,7 @@ describe('Controller', () => { relativePath: 'myfile2.js', fileContent: '', rejected: true, - virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'myfile2.js'), + virtualMemoryUri: generateVirtualMemoryUri(uploadID, 'myfile2.js', featureDevScheme), workspaceFolder: controllerSetup.workspaceFolder, changeApplied: false, }, @@ -89,7 +90,13 @@ describe('Controller', () => { beforeEach(async () => { controllerSetup = await createController() - session = await createSession({ messenger: controllerSetup.messenger, conversationID, tabID, uploadID }) + session = await createSession({ + messenger: controllerSetup.messenger, + conversationID, + tabID, + uploadID, + scheme: featureDevScheme, + }) sinon.stub(AuthUtil.instance, 'getChatAuthState').resolves({ codewhispererCore: 'connected', @@ -121,8 +128,8 @@ describe('Controller', () => { assert.strictEqual( executedDiff.calledWith( 'vscode.diff', - createAmazonQUri('empty', tabID), - createAmazonQUri(path.join(uploadID, 'src', 'mynewfile.js'), tabID) + createAmazonQUri('empty', tabID, featureDevScheme), + createAmazonQUri(path.join(uploadID, 'src', 'mynewfile.js'), tabID, featureDevScheme) ), true ) @@ -139,7 +146,7 @@ describe('Controller', () => { executedDiff.calledWith( 'vscode.diff', vscode.Uri.file(newFileLocation), - createAmazonQUri(path.join(uploadID, 'mynewfile.js'), tabID) + createAmazonQUri(path.join(uploadID, 'mynewfile.js'), tabID, featureDevScheme) ), true ) @@ -156,7 +163,7 @@ describe('Controller', () => { executedDiff.calledWith( 'vscode.diff', vscode.Uri.file(newFileLocation), - createAmazonQUri(path.join(uploadID, 'src', 'mynewfile.js'), tabID) + createAmazonQUri(path.join(uploadID, 'src', 'mynewfile.js'), tabID, featureDevScheme) ), true ) @@ -175,7 +182,7 @@ describe('Controller', () => { executedDiff.calledWith( 'vscode.diff', vscode.Uri.file(newFileLocation), - createAmazonQUri(path.join(uploadID, 'foo', 'fi', 'mynewfile.js'), tabID) + createAmazonQUri(path.join(uploadID, 'foo', 'fi', 'mynewfile.js'), tabID, featureDevScheme) ), true ) @@ -287,6 +294,7 @@ describe('Controller', () => { conversationID, tabID, uploadID, + scheme: featureDevScheme, }) return newSession } @@ -357,6 +365,7 @@ describe('Controller', () => { conversationID, tabID, uploadID, + scheme: featureDevScheme, }) const getSessionStub = sinon.stub(controllerSetup.sessionStorage, 'getSession').resolves(newSession) diff --git a/packages/core/src/test/amazonqFeatureDev/session/sessionState.test.ts b/packages/core/src/test/amazonqFeatureDev/session/sessionState.test.ts index ec9cf8ea396..5ce34a079d3 100644 --- a/packages/core/src/test/amazonqFeatureDev/session/sessionState.test.ts +++ b/packages/core/src/test/amazonqFeatureDev/session/sessionState.test.ts @@ -9,14 +9,15 @@ import sinon from 'sinon' import { MockCodeGenState, CodeGenState, PrepareCodeGenState } from '../../../amazonqFeatureDev/session/sessionState' import { VirtualFileSystem } from '../../../shared/virtualFilesystem' import { SessionStateConfig, SessionStateAction } from '../../../amazonqFeatureDev/types' -import { Messenger } from '../../../amazonqFeatureDev/controllers/chat/messenger/messenger' -import { AppToWebViewMessageDispatcher } from '../../../amazonqFeatureDev/views/connector/connector' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' import { FeatureDevClient } from '../../../amazonqFeatureDev/client/featureDev' import { ToolkitError } from '../../../shared/errors' import * as crypto from '../../../shared/crypto' import { TelemetryHelper } from '../../../amazonqFeatureDev/util/telemetryHelper' import { createTestWorkspaceFolder } from '../../testUtil' +import { Messenger } from '../../../amazonq/commons/connector/baseMessenger' +import { AppToWebViewMessageDispatcher } from '../../../amazonq/commons/connector/connectorMessages' +import { featureDevChat } from '../../../amazonqFeatureDev' const mockSessionStateAction = (msg?: string): SessionStateAction => { return { @@ -24,7 +25,8 @@ const mockSessionStateAction = (msg?: string): SessionStateAction => { msg: msg ?? 'test-msg', fs: new VirtualFileSystem(), messenger: new Messenger( - new AppToWebViewMessageDispatcher(new MessagePublisher(new vscode.EventEmitter())) + new AppToWebViewMessageDispatcher(new MessagePublisher(new vscode.EventEmitter())), + featureDevChat ), telemetry: new TelemetryHelper(), uploadHistory: {}, diff --git a/packages/core/src/test/amazonqFeatureDev/utils.ts b/packages/core/src/test/amazonqFeatureDev/utils.ts index 57e9eb7ed1c..72a7b139ee8 100644 --- a/packages/core/src/test/amazonqFeatureDev/utils.ts +++ b/packages/core/src/test/amazonqFeatureDev/utils.ts @@ -6,22 +6,23 @@ import * as vscode from 'vscode' import * as sinon from 'sinon' import { MessagePublisher } from '../../amazonq/messages/messagePublisher' -import { Messenger } from '../../amazonqFeatureDev/controllers/chat/messenger/messenger' -import { AppToWebViewMessageDispatcher } from '../../amazonqFeatureDev/views/connector/connector' import { ChatControllerEventEmitters, FeatureDevController } from '../../amazonqFeatureDev/controllers/chat/controller' -import { ChatSessionStorage } from '../../amazonqFeatureDev/storages/chatSession' +import { FeatureDevChatSessionStorage } from '../../amazonqFeatureDev/storages/chatSession' import { createTestWorkspaceFolder } from '../testUtil' -import { createSessionConfig } from '../../amazonqFeatureDev/session/sessionConfigFactory' import { Session } from '../../amazonqFeatureDev/session/session' import { SessionState } from '../../amazonqFeatureDev/types' import { FeatureDevClient } from '../../amazonqFeatureDev/client/featureDev' import { VirtualMemoryFile } from '../../shared/virtualMemoryFile' import path from 'path' -import { featureDevScheme } from '../../amazonqFeatureDev/constants' +import { featureDevChat } from '../../amazonqFeatureDev/constants' +import { Messenger } from '../../amazonq/commons/connector/baseMessenger' +import { AppToWebViewMessageDispatcher } from '../../amazonq/commons/connector/connectorMessages' +import { createSessionConfig } from '../../amazonq/commons/session/sessionConfigFactory' export function createMessenger(): Messenger { return new Messenger( - new AppToWebViewMessageDispatcher(new MessagePublisher(sinon.createStubInstance(vscode.EventEmitter))) + new AppToWebViewMessageDispatcher(new MessagePublisher(sinon.createStubInstance(vscode.EventEmitter))), + featureDevChat ) } @@ -47,23 +48,25 @@ export interface ControllerSetup { emitters: ChatControllerEventEmitters workspaceFolder: vscode.WorkspaceFolder messenger: Messenger - sessionStorage: ChatSessionStorage + sessionStorage: FeatureDevChatSessionStorage } export async function createSession({ messenger, sessionState, + scheme, conversationID = '0', tabID = '0', uploadID = '0', }: { messenger: Messenger + scheme: string sessionState?: Omit conversationID?: string tabID?: string uploadID?: string }) { - const sessionConfig = await createSessionConfig() + const sessionConfig = await createSessionConfig(scheme) const client = sinon.createStubInstance(FeatureDevClient) client.createConversation.resolves(conversationID) @@ -79,9 +82,9 @@ export async function sessionRegisterProvider(session: Session, uri: vscode.Uri, session.config.fs.registerProvider(uri, new VirtualMemoryFile(fileContents)) } -export function generateVirtualMemoryUri(uploadID: string, filePath: string) { +export function generateVirtualMemoryUri(uploadID: string, filePath: string, scheme: string) { const generationFilePath = path.join(uploadID, filePath) - const uri = vscode.Uri.from({ scheme: featureDevScheme, path: generationFilePath }) + const uri = vscode.Uri.from({ scheme, path: generationFilePath }) return uri } @@ -99,7 +102,7 @@ export async function createController(): Promise { const testWorkspaceFolder = await createTestWorkspaceFolder() sinon.stub(vscode.workspace, 'workspaceFolders').value([testWorkspaceFolder]) - const sessionStorage = new ChatSessionStorage(messenger) + const sessionStorage = new FeatureDevChatSessionStorage(messenger) const mockChatControllerEventEmitters = createMockChatEmitters() diff --git a/packages/core/src/test/codewhisperer/commands/basicCommands.test.ts b/packages/core/src/test/codewhisperer/commands/basicCommands.test.ts index 43c8b668581..cafb2d29860 100644 --- a/packages/core/src/test/codewhisperer/commands/basicCommands.test.ts +++ b/packages/core/src/test/codewhisperer/commands/basicCommands.test.ts @@ -12,12 +12,18 @@ import { assertTelemetry, assertTelemetryCurried, tryRegister } from '../../test import { toggleCodeSuggestions, showSecurityScan, + showFileScan, applySecurityFix, showReferenceLog, selectCustomizationPrompt, reconnect, signoutCodeWhisperer, toggleCodeScans, + generateFix, + rejectFix, + ignoreIssue, + regenerateFix, + ignoreAllIssues, } from '../../../codewhisperer/commands/basicCommands' import { FakeExtensionContext } from '../../fakeExtensionContext' import { testCommand } from '../../shared/vscode/testUtils' @@ -53,10 +59,14 @@ import { cwQuickPickSource } from '../../../codewhisperer/commands/types' import { refreshStatusBar } from '../../../codewhisperer/service/inlineCompletionService' import { focusAmazonQPanel } from '../../../codewhispererChat/commands/registerCommands' import * as diagnosticsProvider from '../../../codewhisperer/service/diagnosticsProvider' -import { SecurityIssueHoverProvider } from '../../../codewhisperer/service/securityIssueHoverProvider' -import { SecurityIssueCodeActionProvider } from '../../../codewhisperer/service/securityIssueCodeActionProvider' import { randomUUID } from '../../../shared/crypto' import { assertLogsContain } from '../../globalSetup.test' +import * as securityIssueWebview from '../../../codewhisperer/views/securityIssue/securityIssueWebview' +import { IssueItem, SecurityIssueTreeViewProvider } from '../../../codewhisperer/service/securityIssueTreeViewProvider' +import { SecurityIssueProvider } from '../../../codewhisperer/service/securityIssueProvider' +import { CodeWhispererSettings } from '../../../codewhisperer/util/codewhispererSettings' +import { confirm } from '../../../shared' +import * as commentUtils from '../../../shared/utilities/commentUtils' describe('CodeWhisperer-basicCommands', function () { let targetCommand: Command & vscode.Disposable @@ -290,6 +300,43 @@ describe('CodeWhisperer-basicCommands', function () { }) }) + describe('showFileScan', function () { + let mockExtensionContext: vscode.ExtensionContext + let mockSecurityPanelViewProvider: SecurityPanelViewProvider + let mockClient: DefaultCodeWhispererClient + let mockExtContext: ExtContext + + beforeEach(async function () { + await resetCodeWhispererGlobalVariables() + mockExtensionContext = await FakeExtensionContext.create() + mockSecurityPanelViewProvider = new SecurityPanelViewProvider(mockExtensionContext) + mockClient = stub(DefaultCodeWhispererClient) + mockExtContext = await FakeExtensionContext.getFakeExtContext() + }) + + afterEach(function () { + targetCommand?.dispose() + sinon.restore() + codeScanState.setToNotStarted() + }) + + it('prompts user to reauthenticate if connection is expired', async function () { + targetCommand = testCommand(showFileScan, mockExtContext, mockSecurityPanelViewProvider, mockClient) + + sinon.stub(AuthUtil.instance, 'isConnectionExpired').returns(true) + const spy = sinon.stub(AuthUtil.instance, 'showReauthenticatePrompt') + + await targetCommand.execute(placeholder, cwQuickPickSource) + assert.ok(spy.called) + }) + + it('includes the "source" in the command execution metric', async function () { + targetCommand = testCommand(showFileScan, mockExtContext, mockSecurityPanelViewProvider, mockClient) + await targetCommand.execute(placeholder, cwQuickPickSource) + assertTelemetry('vscode_executeCommand', { source: cwQuickPickSource, command: targetCommand.id }) + }) + }) + describe('showReferenceLog', function () { beforeEach(async function () { await resetCodeWhispererGlobalVariables() @@ -407,7 +454,6 @@ describe('CodeWhisperer-basicCommands', function () { createOpenReferenceLog(), createGettingStarted(), createAutoScans(false), - createSecurityScan(), switchToAmazonQNode(), ...genericItems(), createSettingsNode(), @@ -431,7 +477,6 @@ describe('CodeWhisperer-basicCommands', function () { createOpenReferenceLog(), createGettingStarted(), createAutoScans(false), - createSecurityScan(), createSelectCustomization(), switchToAmazonQNode(), ...genericItems(), @@ -454,7 +499,7 @@ describe('CodeWhisperer-basicCommands', function () { createAutoSuggestions(true), createOpenReferenceLog(), createGettingStarted(), - createSeparator('Security Scans'), + createSeparator('Code Reviews'), createSecurityScan(), createSeparator('Other Features'), switchToAmazonQNode(), @@ -478,6 +523,7 @@ describe('CodeWhisperer-basicCommands', function () { let removeDiagnosticMock: sinon.SinonStub let removeIssueMock: sinon.SinonStub let codeScanIssue: CodeScanIssue + let showTextDocumentMock: sinon.SinonStub beforeEach(function () { sandbox = sinon.createSandbox() @@ -489,6 +535,7 @@ describe('CodeWhisperer-basicCommands', function () { codeScanIssue = createCodeScanIssue({ findingId: randomUUID(), }) + showTextDocumentMock = sinon.stub() }) afterEach(function () { @@ -506,8 +553,8 @@ describe('CodeWhisperer-basicCommands', function () { applyEditMock.resolves(true) sandbox.stub(vscode.workspace, 'applyEdit').value(applyEditMock) sandbox.stub(diagnosticsProvider, 'removeDiagnostic').value(removeDiagnosticMock) - sandbox.stub(SecurityIssueHoverProvider.instance, 'removeIssue').value(removeIssueMock) - sandbox.stub(SecurityIssueCodeActionProvider.instance, 'removeIssue').value(removeIssueMock) + sandbox.stub(SecurityIssueProvider.instance, 'removeIssue').value(removeIssueMock) + sandbox.stub(vscode.window, 'showTextDocument').value(showTextDocumentMock) targetCommand = testCommand(applySecurityFix) codeScanIssue.suggestedFixes = [ @@ -526,49 +573,7 @@ describe('CodeWhisperer-basicCommands', function () { ) assert.ok(applyEditMock.calledOnce) assert.ok(removeDiagnosticMock.calledOnceWith(textDocumentMock.uri, codeScanIssue)) - assert.ok(removeIssueMock.calledTwice) - - assertTelemetry('codewhisperer_codeScanIssueApplyFix', { - detectorId: codeScanIssue.detectorId, - findingId: codeScanIssue.findingId, - component: 'hover', - result: 'Succeeded', - }) - }) - - it('should call applySecurityFix command successfully but not remove issues if auto-scans is disabled', async function () { - const fileName = 'sample.py' - const textDocumentMock = createMockDocument('first line\n second line\n fourth line', fileName) - - openTextDocumentMock.resolves(textDocumentMock) - sandbox.stub(vscode.workspace, 'openTextDocument').value(openTextDocumentMock) - - sandbox.stub(vscode.WorkspaceEdit.prototype, 'replace').value(replaceMock) - applyEditMock.resolves(true) - sandbox.stub(vscode.workspace, 'applyEdit').value(applyEditMock) - sandbox.stub(diagnosticsProvider, 'removeDiagnostic').value(removeDiagnosticMock) - sandbox.stub(SecurityIssueHoverProvider.instance, 'removeIssue').value(removeIssueMock) - sandbox.stub(SecurityIssueCodeActionProvider.instance, 'removeIssue').value(removeIssueMock) - await CodeScansState.instance.setScansEnabled(false) - - targetCommand = testCommand(applySecurityFix) - codeScanIssue.suggestedFixes = [ - { - description: 'fix', - code: '@@ -1,3 +1,3 @@\n first line\n- second line\n+ third line\n fourth line', - }, - ] - await targetCommand.execute(codeScanIssue, fileName, 'hover') - assert.ok( - replaceMock.calledOnceWith( - textDocumentMock.uri, - new vscode.Range(0, 0, 2, 12), - 'first line\n third line\n fourth line' - ) - ) - assert.ok(applyEditMock.calledOnce) - assert.ok(removeDiagnosticMock.notCalled) - assert.ok(removeIssueMock.notCalled) + assert.ok(removeIssueMock.calledOnce) assertTelemetry('codewhisperer_codeScanIssueApplyFix', { detectorId: codeScanIssue.detectorId, @@ -613,8 +618,8 @@ describe('CodeWhisperer-basicCommands', function () { applyEditMock.resolves(true) sandbox.stub(vscode.workspace, 'applyEdit').value(applyEditMock) sandbox.stub(diagnosticsProvider, 'removeDiagnostic').value(removeDiagnosticMock) - sandbox.stub(SecurityIssueHoverProvider.instance, 'removeIssue').value(removeIssueMock) - sandbox.stub(SecurityIssueCodeActionProvider.instance, 'removeIssue').value(removeIssueMock) + sandbox.stub(SecurityIssueProvider.instance, 'removeIssue').value(removeIssueMock) + sandbox.stub(vscode.window, 'showTextDocument').value(showTextDocumentMock) targetCommand = testCommand(applySecurityFix) codeScanIssue.suggestedFixes = [ @@ -662,4 +667,395 @@ describe('CodeWhisperer-basicCommands', function () { }) }) }) + + // describe('generateFix', function () { + // let sandbox: sinon.SinonSandbox + // let mockClient: Stub + // let filePath: string + // let codeScanIssue: CodeScanIssue + // let issueItem: IssueItem + // let updateSecurityIssueWebviewMock: sinon.SinonStub + // let updateIssueMock: sinon.SinonStub + // let refreshTreeViewMock: sinon.SinonStub + // let mockDocument: vscode.TextDocument + + // beforeEach(function () { + // sandbox = sinon.createSandbox() + // mockClient = stub(DefaultCodeWhispererClient) + // mockClient.generateCodeFix.resolves({ + // // TODO: Clean this up + // $response: {} as PromiseResult['$response'], + // suggestedRemediationDiff: 'diff', + // suggestedRemediationDescription: 'description', + // references: [], + // }) + // filePath = 'dummy/file.py' + // codeScanIssue = createCodeScanIssue({ + // findingId: randomUUID(), + // ruleId: 'dummy-rule-id', + // }) + // issueItem = new IssueItem(filePath, codeScanIssue) + // updateSecurityIssueWebviewMock = sinon.stub() + // updateIssueMock = sinon.stub() + // refreshTreeViewMock = sinon.stub() + // mockDocument = createMockDocument('dummy input') + // }) + + // afterEach(function () { + // sandbox.restore() + // }) + + // it('should call generateFix command successfully', async function () { + // sinon.stub(securityIssueWebview, 'updateSecurityIssueWebview').value(updateSecurityIssueWebviewMock) + // sinon.stub(SecurityIssueProvider.instance, 'updateIssue').value(updateIssueMock) + // sinon.stub(SecurityIssueTreeViewProvider.instance, 'refresh').value(refreshTreeViewMock) + // sinon.stub(vscode.workspace, 'openTextDocument').resolves(mockDocument) + // targetCommand = testCommand(generateFix, mockClient) + // await targetCommand.execute(codeScanIssue, filePath, 'webview') + // assert.ok(updateSecurityIssueWebviewMock.calledWith({ isGenerateFixLoading: true })) + // assert.ok( + // mockClient.generateCodeFix.calledWith({ + // sourceCode: 'dummy input', + // ruleId: codeScanIssue.ruleId, + // startLine: codeScanIssue.startLine, + // endLine: codeScanIssue.endLine, + // findingDescription: codeScanIssue.description.text, + // }) + // ) + + // const expectedUpdatedIssue = { + // ...codeScanIssue, + // suggestedFixes: [{ code: 'diff', description: 'description', references: [] }], + // } + // assert.ok( + // updateSecurityIssueWebviewMock.calledWith({ issue: expectedUpdatedIssue, isGenerateFixLoading: false }) + // ) + // assert.ok(updateIssueMock.calledWith(expectedUpdatedIssue, filePath)) + // assert.ok(refreshTreeViewMock.calledOnce) + + // assertTelemetry('codewhisperer_codeScanIssueGenerateFix', { + // detectorId: codeScanIssue.detectorId, + // findingId: codeScanIssue.findingId, + // ruleId: codeScanIssue.ruleId, + // component: 'webview', + // result: 'Succeeded', + // }) + // }) + + // it('should call generateFix from tree view item', async function () { + // sinon.stub(securityIssueWebview, 'updateSecurityIssueWebview').value(updateSecurityIssueWebviewMock) + // sinon.stub(SecurityIssueProvider.instance, 'updateIssue').value(updateIssueMock) + // sinon.stub(SecurityIssueTreeViewProvider.instance, 'refresh').value(refreshTreeViewMock) + // sinon.stub(vscode.workspace, 'openTextDocument').resolves(mockDocument) + // const filePath = 'dummy/file.py' + // targetCommand = testCommand(generateFix, mockClient) + // await targetCommand.execute(issueItem, filePath, 'tree') + // assert.ok(updateSecurityIssueWebviewMock.calledWith({ isGenerateFixLoading: true })) + // assert.ok( + // mockClient.generateCodeFix.calledWith({ + // sourceCode: 'dummy input', + // ruleId: codeScanIssue.ruleId, + // startLine: codeScanIssue.startLine, + // endLine: codeScanIssue.endLine, + // findingDescription: codeScanIssue.description.text, + // }) + // ) + + // const expectedUpdatedIssue = { + // ...codeScanIssue, + // suggestedFixes: [{ code: 'diff', description: 'description', references: [] }], + // } + // assert.ok( + // updateSecurityIssueWebviewMock.calledWith({ issue: expectedUpdatedIssue, isGenerateFixLoading: false }) + // ) + // assert.ok(updateIssueMock.calledWith(expectedUpdatedIssue, filePath)) + // assert.ok(refreshTreeViewMock.calledOnce) + + // assertTelemetry('codewhisperer_codeScanIssueGenerateFix', { + // detectorId: codeScanIssue.detectorId, + // findingId: codeScanIssue.findingId, + // ruleId: codeScanIssue.ruleId, + // component: 'tree', + // result: 'Succeeded', + // }) + // }) + + // it('should call generateFix with refresh=true to indicate fix regenerated', async function () { + // sinon.stub(securityIssueWebview, 'updateSecurityIssueWebview').value(updateSecurityIssueWebviewMock) + // sinon.stub(SecurityIssueProvider.instance, 'updateIssue').value(updateIssueMock) + // sinon.stub(SecurityIssueTreeViewProvider.instance, 'refresh').value(refreshTreeViewMock) + // sinon.stub(vscode.workspace, 'openTextDocument').resolves(mockDocument) + // targetCommand = testCommand(generateFix, mockClient) + // await targetCommand.execute(codeScanIssue, filePath, 'webview', true) + // assert.ok(updateSecurityIssueWebviewMock.calledWith({ isGenerateFixLoading: true })) + // assert.ok( + // mockClient.generateCodeFix.calledWith({ + // sourceCode: 'dummy input', + // ruleId: codeScanIssue.ruleId, + // startLine: codeScanIssue.startLine, + // endLine: codeScanIssue.endLine, + // findingDescription: codeScanIssue.description.text, + // }) + // ) + + // const expectedUpdatedIssue = { + // ...codeScanIssue, + // suggestedFixes: [{ code: 'diff', description: 'description', references: [] }], + // } + // assert.ok( + // updateSecurityIssueWebviewMock.calledWith({ issue: expectedUpdatedIssue, isGenerateFixLoading: false }) + // ) + // assert.ok(updateIssueMock.calledWith(expectedUpdatedIssue, filePath)) + // assert.ok(refreshTreeViewMock.calledOnce) + + // assertTelemetry('codewhisperer_codeScanIssueGenerateFix', { + // detectorId: codeScanIssue.detectorId, + // findingId: codeScanIssue.findingId, + // ruleId: codeScanIssue.ruleId, + // component: 'webview', + // variant: 'refresh', + // result: 'Succeeded', + // }) + // }) + // }) + + describe('rejectFix', function () { + let mockExtensionContext: vscode.ExtensionContext + let sandbox: sinon.SinonSandbox + let filePath: string + let codeScanIssue: CodeScanIssue + let issueItem: IssueItem + let updateSecurityIssueWebviewMock: sinon.SinonStub + let updateIssueMock: sinon.SinonStub + let refreshTreeViewMock: sinon.SinonStub + + beforeEach(async function () { + sandbox = sinon.createSandbox() + filePath = 'dummy/file.py' + codeScanIssue = createCodeScanIssue({ + findingId: randomUUID(), + suggestedFixes: [{ code: 'diff', description: 'description' }], + }) + issueItem = new IssueItem(filePath, codeScanIssue) + updateSecurityIssueWebviewMock = sinon.stub() + updateIssueMock = sinon.stub() + refreshTreeViewMock = sinon.stub() + mockExtensionContext = await FakeExtensionContext.create() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should call rejectFix command successfully', async function () { + sinon.stub(securityIssueWebview, 'updateSecurityIssueWebview').value(updateSecurityIssueWebviewMock) + sinon.stub(SecurityIssueProvider.instance, 'updateIssue').value(updateIssueMock) + sinon.stub(SecurityIssueTreeViewProvider.instance, 'refresh').value(refreshTreeViewMock) + targetCommand = testCommand(rejectFix, mockExtensionContext) + await targetCommand.execute(codeScanIssue, filePath) + + const expectedUpdatedIssue = { ...codeScanIssue, suggestedFixes: [] } + assert.ok(updateIssueMock.calledWith(expectedUpdatedIssue, filePath)) + assert.ok(refreshTreeViewMock.calledOnce) + }) + + it('should call rejectFix from tree view item', async function () { + sinon.stub(securityIssueWebview, 'updateSecurityIssueWebview').value(updateSecurityIssueWebviewMock) + sinon.stub(SecurityIssueProvider.instance, 'updateIssue').value(updateIssueMock) + sinon.stub(SecurityIssueTreeViewProvider.instance, 'refresh').value(refreshTreeViewMock) + targetCommand = testCommand(rejectFix, mockExtensionContext) + await targetCommand.execute(issueItem, filePath) + + const expectedUpdatedIssue = { ...codeScanIssue, suggestedFixes: [] } + assert.ok(updateIssueMock.calledWith(expectedUpdatedIssue, filePath)) + assert.ok(refreshTreeViewMock.calledOnce) + }) + }) + + describe('ignoreAllIssues', function () { + let sandbox: sinon.SinonSandbox + let codeScanIssue: CodeScanIssue + let issueItem: IssueItem + let addToIgnoredSecurityIssuesListMock: sinon.SinonStub + let closeSecurityIssueWebviewMock: sinon.SinonStub + + beforeEach(function () { + sandbox = sinon.createSandbox() + codeScanIssue = createCodeScanIssue() + issueItem = new IssueItem('dummy/file.py', codeScanIssue) + addToIgnoredSecurityIssuesListMock = sinon.stub() + closeSecurityIssueWebviewMock = sinon.stub() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should call ignoreAllIssues command successfully', async function () { + sinon + .stub(CodeWhispererSettings.instance, 'addToIgnoredSecurityIssuesList') + .value(addToIgnoredSecurityIssuesListMock) + sinon.stub(securityIssueWebview, 'closeSecurityIssueWebview').value(closeSecurityIssueWebviewMock) + targetCommand = testCommand(ignoreAllIssues) + getTestWindow().onDidShowMessage((m) => { + if (m.message === CodeWhispererConstants.ignoreAllIssuesMessage(codeScanIssue.title)) { + m.selectItem(confirm) + } + }) + await targetCommand.execute(codeScanIssue, 'webview') + + assert.ok(addToIgnoredSecurityIssuesListMock.calledWith(codeScanIssue.title)) + assert.ok(closeSecurityIssueWebviewMock.calledOnce) + + assertTelemetry('codewhisperer_codeScanIssueIgnore', { + component: 'webview', + detectorId: codeScanIssue.detectorId, + findingId: codeScanIssue.findingId, + ruleId: codeScanIssue.ruleId, + variant: 'all', + result: 'Succeeded', + }) + }) + + it('should call ignoreAllIssues from tree view item', async function () { + sinon + .stub(CodeWhispererSettings.instance, 'addToIgnoredSecurityIssuesList') + .value(addToIgnoredSecurityIssuesListMock) + targetCommand = testCommand(ignoreAllIssues) + getTestWindow().onDidShowMessage((m) => { + if (m.message === CodeWhispererConstants.ignoreAllIssuesMessage(codeScanIssue.title)) { + m.selectItem(confirm) + } + }) + await targetCommand.execute(issueItem) + + assert.ok(addToIgnoredSecurityIssuesListMock.calledWith(codeScanIssue.title)) + + assertTelemetry('codewhisperer_codeScanIssueIgnore', { + component: 'tree', + detectorId: codeScanIssue.detectorId, + findingId: codeScanIssue.findingId, + ruleId: codeScanIssue.ruleId, + variant: 'all', + result: 'Succeeded', + }) + }) + }) + + describe('ignoreIssue', function () { + let sandbox: sinon.SinonSandbox + let codeScanIssue: CodeScanIssue + let issueItem: IssueItem + let mockDocument: vscode.TextDocument + let insertCommentMock: sinon.SinonStub + let showTextDocumentMock: sinon.SinonStub + + beforeEach(function () { + sandbox = sinon.createSandbox() + codeScanIssue = createCodeScanIssue() + issueItem = new IssueItem('dummy/file.py', codeScanIssue) + mockDocument = createMockDocument() + insertCommentMock = sinon.stub() + showTextDocumentMock = sinon.stub() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should call ignoreIssue command successfully', async function () { + sinon.stub(vscode.workspace, 'openTextDocument').resolves(mockDocument) + sinon.stub(commentUtils, 'insertCommentAboveLine').value(insertCommentMock) + sinon.stub(vscode.window, 'showTextDocument').value(showTextDocumentMock) + targetCommand = testCommand(ignoreIssue) + await targetCommand.execute(codeScanIssue, 'filepath', 'webview') + + assert.ok( + insertCommentMock.calledOnceWith( + mockDocument, + codeScanIssue.startLine, + CodeWhispererConstants.amazonqIgnoreNextLine + ) + ) + + assertTelemetry('codewhisperer_codeScanIssueIgnore', { + component: 'webview', + detectorId: codeScanIssue.detectorId, + findingId: codeScanIssue.findingId, + ruleId: codeScanIssue.ruleId, + result: 'Succeeded', + }) + }) + + it('should call ignoreIssue from tree view item', async function () { + sinon.stub(vscode.workspace, 'openTextDocument').resolves(mockDocument) + sinon.stub(commentUtils, 'insertCommentAboveLine').value(insertCommentMock) + sinon.stub(vscode.window, 'showTextDocument').value(showTextDocumentMock) + targetCommand = testCommand(ignoreIssue) + await targetCommand.execute(issueItem) + + assert.ok( + insertCommentMock.calledOnceWith( + mockDocument, + codeScanIssue.startLine, + CodeWhispererConstants.amazonqIgnoreNextLine + ) + ) + + assertTelemetry('codewhisperer_codeScanIssueIgnore', { + component: 'tree', + detectorId: codeScanIssue.detectorId, + findingId: codeScanIssue.findingId, + ruleId: codeScanIssue.ruleId, + result: 'Succeeded', + }) + }) + }) + + describe('regenerateFix', function () { + let sandbox: sinon.SinonSandbox + let filePath: string + let codeScanIssue: CodeScanIssue + let issueItem: IssueItem + let rejectFixMock: sinon.SinonStub + let generateFixMock: sinon.SinonStub + + beforeEach(function () { + sandbox = sinon.createSandbox() + filePath = 'dummy/file.py' + codeScanIssue = createCodeScanIssue({ + findingId: randomUUID(), + suggestedFixes: [{ code: 'diff', description: 'description' }], + }) + issueItem = new IssueItem(filePath, codeScanIssue) + rejectFixMock = sinon.stub() + generateFixMock = sinon.stub() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('should call regenerateFix command successfully', async function () { + const updatedIssue = createCodeScanIssue({ findingId: 'updatedIssue' }) + sinon.stub(rejectFix, 'execute').value(rejectFixMock.resolves(updatedIssue)) + sinon.stub(generateFix, 'execute').value(generateFixMock) + targetCommand = testCommand(regenerateFix) + await targetCommand.execute(codeScanIssue, filePath) + + assert.ok(rejectFixMock.calledWith(codeScanIssue, filePath)) + assert.ok(generateFixMock.calledWith(updatedIssue, filePath)) + }) + + it('should call regenerateFix from tree view item', async function () { + const updatedIssue = createCodeScanIssue({ findingId: 'updatedIssue' }) + sinon.stub(rejectFix, 'execute').value(rejectFixMock.resolves(updatedIssue)) + sinon.stub(generateFix, 'execute').value(generateFixMock) + targetCommand = testCommand(regenerateFix) + await targetCommand.execute(issueItem, filePath) + + assert.ok(rejectFixMock.calledWith(codeScanIssue, filePath)) + assert.ok(generateFixMock.calledWith(updatedIssue, filePath)) + }) + }) }) diff --git a/packages/core/src/test/codewhisperer/startSecurityScan.test.ts b/packages/core/src/test/codewhisperer/startSecurityScan.test.ts index fadd1063aa8..59a517e997c 100644 --- a/packages/core/src/test/codewhisperer/startSecurityScan.test.ts +++ b/packages/core/src/test/codewhisperer/startSecurityScan.test.ts @@ -22,12 +22,14 @@ import { showScannedFilesMessage, stopScanMessage, CodeAnalysisScope, - projectScansLimitReached, + monthlyLimitReachedNotification, + scansLimitReachedErrorMessage, } from '../../codewhisperer/models/constants' import * as model from '../../codewhisperer/models/model' import { CodewhispererSecurityScan } from '../../shared/telemetry/telemetry.gen' import * as errors from '../../shared/errors' import * as timeoutUtils from '../../shared/utilities/timeoutUtils' +import { SecurityIssuesTree } from '../../codewhisperer' import { createClient, mockGetCodeScanResponse } from './testUtil' let extensionContext: FakeExtensionContext @@ -35,6 +37,7 @@ let mockSecurityPanelViewProvider: SecurityPanelViewProvider let appRoot: string let appCodePath: string let editor: vscode.TextEditor +let focusStub: sinon.SinonStub describe('startSecurityScan', function () { const workspaceFolder = getTestWorkspaceFolder() @@ -46,6 +49,7 @@ describe('startSecurityScan', function () { editor = await openTestFile(appCodePath) await model.CodeScansState.instance.setScansEnabled(false) sinon.stub(timeoutUtils, 'sleep') + focusStub = sinon.stub(SecurityIssuesTree.instance, 'focus') }) afterEach(function () { sinon.restore() @@ -62,6 +66,24 @@ describe('startSecurityScan', function () { } it('Should render security scan result', async function () { + getFetchStubWithResponse({ status: 200, statusText: 'testing stub' }) + const securityScanRenderSpy = sinon.spy(diagnosticsProvider, 'initSecurityScanRender') + + await startSecurityScan.startSecurityScan( + mockSecurityPanelViewProvider, + editor, + createClient(), + extensionContext, + CodeAnalysisScope.PROJECT, + false + ) + assert.ok(focusStub.calledOnce) + assert.ok(securityScanRenderSpy.calledOnce) + const warnings = getTestWindow().shownMessages.filter((m) => m.severity === SeverityLevel.Warning) + assert.strictEqual(warnings.length, 0) + }) + + it('Should render security scan result for on-demand file scan', async function () { getFetchStubWithResponse({ status: 200, statusText: 'testing stub' }) const commandSpy = sinon.spy(vscode.commands, 'executeCommand') const securityScanRenderSpy = sinon.spy(diagnosticsProvider, 'initSecurityScanRender') @@ -71,9 +93,10 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.FILE_ON_DEMAND, + true ) - assert.ok(commandSpy.calledWith('workbench.action.problems.focus')) + assert.ok(commandSpy.neverCalledWith('workbench.action.problems.focus')) assert.ok(securityScanRenderSpy.calledOnce) const warnings = getTestWindow().shownMessages.filter((m) => m.severity === SeverityLevel.Warning) assert.strictEqual(warnings.length, 0) @@ -81,7 +104,6 @@ describe('startSecurityScan', function () { it('Should not focus problems panel for file scans', async function () { getFetchStubWithResponse({ status: 200, statusText: 'testing stub' }) - const commandSpy = sinon.spy(vscode.commands, 'executeCommand') const securityScanRenderSpy = sinon.spy(diagnosticsProvider, 'initSecurityScanRender') await model.CodeScansState.instance.setScansEnabled(true) @@ -90,14 +112,15 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.FILE + CodeAnalysisScope.FILE_AUTO, + false ) - assert.ok(commandSpy.neverCalledWith('workbench.action.problems.focus')) + assert.ok(focusStub.notCalled) assert.ok(securityScanRenderSpy.calledOnce) const warnings = getTestWindow().shownMessages.filter((m) => m.severity === SeverityLevel.Warning) assert.strictEqual(warnings.length, 0) assertTelemetry('codewhisperer_securityScan', { - codewhispererCodeScanScope: 'FILE', + codewhispererCodeScanScope: 'FILE_AUTO', passive: true, }) }) @@ -118,9 +141,15 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.PROJECT, + false + ) + await startSecurityScan.confirmStopSecurityScan( + model.codeScanState, + false, + CodeAnalysisScope.PROJECT, + undefined ) - await startSecurityScan.confirmStopSecurityScan() await scanPromise assert.ok(securityScanRenderSpy.notCalled) assert.ok(securityScanStoppedErrorSpy.calledOnce) @@ -144,9 +173,15 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.PROJECT, + false + ) + await startSecurityScan.confirmStopSecurityScan( + model.codeScanState, + false, + CodeAnalysisScope.PROJECT, + undefined ) - await startSecurityScan.confirmStopSecurityScan() await scanPromise assert.ok(securityScanRenderSpy.calledOnce) assert.ok(securityScanStoppedErrorSpy.notCalled) @@ -154,7 +189,7 @@ describe('startSecurityScan', function () { assert.ok(warnings.map((m) => m.message).includes(stopScanMessage)) }) - it('Should stop security scan for file scans if setting is disabled', async function () { + it('Should stop security scan for auto file scans if setting is disabled', async function () { getFetchStubWithResponse({ status: 200, statusText: 'testing stub' }) const securityScanRenderSpy = sinon.spy(diagnosticsProvider, 'initSecurityScanRender') const securityScanStoppedErrorSpy = sinon.spy(model, 'CodeScanStoppedError') @@ -164,7 +199,8 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.FILE + CodeAnalysisScope.FILE_AUTO, + false ) await model.CodeScansState.instance.setScansEnabled(false) await scanPromise @@ -188,7 +224,8 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.PROJECT, + false ) assertTelemetry('codewhisperer_securityScan', { codewhispererCodeScanTotalIssues: 1, @@ -207,14 +244,16 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.FILE + CodeAnalysisScope.FILE_AUTO, + false ) await startSecurityScan.startSecurityScan( mockSecurityPanelViewProvider, editor, createClient(), extensionContext, - CodeAnalysisScope.FILE + CodeAnalysisScope.FILE_AUTO, + false ) await scanPromise assertTelemetry('codewhisperer_securityScan', [ @@ -238,20 +277,22 @@ describe('startSecurityScan', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.PROJECT, + false ) await startSecurityScan.startSecurityScan( mockSecurityPanelViewProvider, editor, createClient(), extensionContext, - CodeAnalysisScope.FILE + CodeAnalysisScope.FILE_AUTO, + false ) await scanPromise assertTelemetry('codewhisperer_securityScan', [ { result: 'Succeeded', - codewhispererCodeScanScope: 'FILE', + codewhispererCodeScanScope: 'FILE_AUTO', }, { result: 'Succeeded', @@ -273,7 +314,8 @@ describe('startSecurityScan', function () { editor, mockClient, extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.PROJECT, + false ) assertTelemetry('codewhisperer_securityScan', { codewhispererCodeScanScope: 'PROJECT', @@ -291,7 +333,7 @@ describe('startSecurityScan', function () { code: 'ThrottlingException', time: new Date(), name: 'error name', - message: 'Maximum project scan count reached for this month.', + message: scansLimitReachedErrorMessage, } satisfies AWSError) sinon.stub(errors, 'isAwsError').returns(true) const testWindow = getTestWindow() @@ -300,19 +342,21 @@ describe('startSecurityScan', function () { editor, mockClient, extensionContext, - CodeAnalysisScope.PROJECT + CodeAnalysisScope.PROJECT, + false ) - assert.ok(testWindow.shownMessages.map((m) => m.message).includes(projectScansLimitReached)) + + assert.ok(testWindow.shownMessages.map((m) => m.message).includes(monthlyLimitReachedNotification)) assertTelemetry('codewhisperer_securityScan', { codewhispererCodeScanScope: 'PROJECT', result: 'Failed', reason: 'ThrottlingException', - reasonDesc: 'ThrottlingException: Maximum project scan count reached for this month.', + reasonDesc: `ThrottlingException: Maximum com.amazon.aws.codewhisperer.StartCodeAnalysis reached for this month.`, passive: false, } as unknown as CodewhispererSecurityScan) }) - it('Should set monthly quota exceeded when throttled for file scans', async function () { + it('Should set monthly quota exceeded when throttled for auto file scans', async function () { getFetchStubWithResponse({ status: 200, statusText: 'testing stub' }) await model.CodeScansState.instance.setScansEnabled(true) const mockClient = createClient() @@ -320,7 +364,7 @@ describe('startSecurityScan', function () { code: 'ThrottlingException', time: new Date(), name: 'error name', - message: 'Maximum auto-scans count reached for this month.', + message: 'Maximum file scans count reached for this month', } satisfies AWSError) sinon.stub(errors, 'isAwsError').returns(true) assert.equal(model.CodeScansState.instance.isMonthlyQuotaExceeded(), false) @@ -329,16 +373,17 @@ describe('startSecurityScan', function () { editor, mockClient, extensionContext, - CodeAnalysisScope.FILE + CodeAnalysisScope.FILE_AUTO, + false ) assert.equal(model.CodeScansState.instance.isMonthlyQuotaExceeded(), true) const warnings = getTestWindow().shownMessages.filter((m) => m.severity === SeverityLevel.Warning) assert.strictEqual(warnings.length, 0) assertTelemetry('codewhisperer_securityScan', { - codewhispererCodeScanScope: 'FILE', + codewhispererCodeScanScope: 'FILE_AUTO', result: 'Failed', reason: 'ThrottlingException', - reasonDesc: 'ThrottlingException: Maximum auto-scans count reached for this month.', + reasonDesc: 'ThrottlingException: Maximum file scans count reached for this month', passive: true, } as unknown as CodewhispererSecurityScan) }) diff --git a/packages/core/src/test/codewhisperer/testUtil.ts b/packages/core/src/test/codewhisperer/testUtil.ts index 4abac73dc39..2bda1b08bc5 100644 --- a/packages/core/src/test/codewhisperer/testUtil.ts +++ b/packages/core/src/test/codewhisperer/testUtil.ts @@ -44,7 +44,12 @@ export function createMockDocument( filename = 'test.py', language = 'python' ): MockDocument { - return new MockDocument(doc, filename, sinon.spy(), language) + return new MockDocument( + doc, + filename, + sinon.spy(async (_doc) => true), + language + ) } export function createMockTextEditor( @@ -191,6 +196,9 @@ export function createCodeScanIssue(overrides?: Partial): CodeSca suggestedFixes: [ { description: 'fix', code: '@@ -1,1 +1,1 @@\nfirst line\n-second line\n+third line\nfourth line' }, ], + visible: true, + language: 'python', + scanJobId: 'scanJob', ...overrides, } } diff --git a/packages/core/src/test/fake/fakeDocument.ts b/packages/core/src/test/fake/fakeDocument.ts index de1f28bb315..de93e86707c 100644 --- a/packages/core/src/test/fake/fakeDocument.ts +++ b/packages/core/src/test/fake/fakeDocument.ts @@ -46,7 +46,7 @@ class MockLine implements TextLine { public get firstNonWhitespaceCharacterIndex(): number { if (this._firstNonWhitespaceIndex === undefined) { - this._firstNonWhitespaceIndex = this._contents.trimStart().length - this._contents.length + this._firstNonWhitespaceIndex = this._contents.length - this._contents.trimStart().length } return this._firstNonWhitespaceIndex } diff --git a/packages/core/src/test/shared/utilities/commentUtils.test.ts b/packages/core/src/test/shared/utilities/commentUtils.test.ts new file mode 100644 index 00000000000..35932bdc6ea --- /dev/null +++ b/packages/core/src/test/shared/utilities/commentUtils.test.ts @@ -0,0 +1,101 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import sinon from 'sinon' +import assert from 'assert' +import vscode from 'vscode' +import { + detectCommentAboveLine, + getLanguageCommentConfig, + insertCommentAboveLine, +} from '../../../shared/utilities/commentUtils' +import { createMockDocument } from '../../codewhisperer/testUtil' + +describe('CommentUtils', function () { + let sandbox: sinon.SinonSandbox + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('getLanguageCommentConfig', function () { + it('should get comment config for a given languageId', function () { + assert.equal(getLanguageCommentConfig('java').lineComment, '//') + assert.equal(getLanguageCommentConfig('python').lineComment, '#') + assert.equal(getLanguageCommentConfig('javascript').lineComment, '//') + assert.deepEqual(getLanguageCommentConfig('xml').blockComment, ['']) + }) + + it('should handle invalid languageIds', function () { + assert.deepEqual(getLanguageCommentConfig('invalid'), {}) + }) + }) + + describe('detectCommentAboveLine', function () { + it('should return true if the comment exists above the line in the given document', function () { + const document = createMockDocument('# some-comment\nfoo = 1', 'foo.py', 'python') + assert.equal(detectCommentAboveLine(document, 1, 'some-comment'), true) + }) + + it('should fallback to block comment if line comment is not found', function () { + const document = createMockDocument("''' some-comment '''\nfoo = 1", 'foo.py', 'python') + assert.equal(detectCommentAboveLine(document, 1, 'some-comment'), true) + }) + + it('should allow empty lines in between the line and the comment', function () { + const document = createMockDocument('# some-comment\n\n\nfoo = 1', 'foo.py', 'python') + assert.equal(detectCommentAboveLine(document, 3, 'some-comment'), true) + }) + + it('should return false if the comment is not found', function () { + const document = createMockDocument('foo = 1\nbar = 2', 'foo.py', 'python') + assert.equal(detectCommentAboveLine(document, 2, 'some-comment'), false) + }) + + it('should return false for invalid inputs', function () { + const document = createMockDocument('# some-comment\nfoo = 1', 'foo.py', 'python') + assert.equal(detectCommentAboveLine(document, -1, 'some-comment'), false) + }) + }) + + describe('insertCommentAboveLine', function () { + let insertMock: sinon.SinonStub + let applyEditMock: sinon.SinonStub + + beforeEach(function () { + insertMock = sandbox.stub() + applyEditMock = sandbox.stub() + }) + + it('should insert the comment above the line in the given document', function () { + sandbox.stub(vscode.WorkspaceEdit.prototype, 'insert').value(insertMock) + sandbox.stub(vscode.workspace, 'applyEdit').value(applyEditMock) + + const document = createMockDocument('foo = 1\nbar = 2', 'foo.py', 'python') + insertCommentAboveLine(document, 1, 'some-comment') + assert.ok(insertMock.calledOnceWith(document.uri, new vscode.Position(1, 0), '# some-comment\n')) + }) + + it('should indent the comment by the same amount as the current line', function () { + sandbox.stub(vscode.WorkspaceEdit.prototype, 'insert').value(insertMock) + sandbox.stub(vscode.workspace, 'applyEdit').value(applyEditMock) + + const document = createMockDocument(' foo = 1\n bar = 2', 'foo.py', 'python') + insertCommentAboveLine(document, 1, 'some-comment') + assert.ok(insertMock.calledOnceWith(document.uri, new vscode.Position(1, 0), ' # some-comment\n')) + }) + + it('should fallback to block comment if line comment is undefined', function () { + sandbox.stub(vscode.WorkspaceEdit.prototype, 'insert').value(insertMock) + sandbox.stub(vscode.workspace, 'applyEdit').value(applyEditMock) + + const document = createMockDocument('\n \n', 'foo.xml', 'xml') + insertCommentAboveLine(document, 1, 'some-comment') + assert.ok(insertMock.calledOnceWith(document.uri, new vscode.Position(1, 0), ' \n')) + }) + }) +}) diff --git a/packages/core/src/testInteg/perf/buildIndex.test.ts b/packages/core/src/testInteg/perf/buildIndex.test.ts index 879224c3c4c..e8c469db4bc 100644 --- a/packages/core/src/testInteg/perf/buildIndex.test.ts +++ b/packages/core/src/testInteg/perf/buildIndex.test.ts @@ -10,7 +10,7 @@ import assert from 'assert' import { LspClient, LspController } from '../../amazonq' import { LanguageClient, ServerOptions } from 'vscode-languageclient' import { createTestWorkspace } from '../../test/testUtil' -import { BuildIndexRequestType, GetUsageRequestType } from '../../amazonq/lsp/types' +import { BuildIndexRequestType, GetRepomapIndexJSONRequestType, GetUsageRequestType } from '../../amazonq/lsp/types' import { fs, getRandomString } from '../../shared' import { FileSystem } from '../../shared/fs/fs' import { getFsCallsUpperBound } from './utilities' @@ -22,9 +22,11 @@ interface SetupResult { } async function verifyResult(setup: SetupResult) { - assert.ok(setup.clientReqStub.calledTwice) - assert.ok(setup.clientReqStub.firstCall.calledWith(BuildIndexRequestType)) - assert.ok(setup.clientReqStub.secondCall.calledWith(GetUsageRequestType)) + // A correct run makes 3 requests, but don't want to make it exact to avoid over-sensitivity to implementation. If we make 10+ something is likely wrong. + assert.ok(setup.clientReqStub.callCount >= 3 && setup.clientReqStub.callCount <= 10) + assert.ok(setup.clientReqStub.calledWith(BuildIndexRequestType)) + assert.ok(setup.clientReqStub.calledWith(GetUsageRequestType)) + assert.ok(setup.clientReqStub.calledWith(GetRepomapIndexJSONRequestType)) assert.strictEqual(getFsCallsUpperBound(setup.fsSpy), 0, 'should not make any fs calls') assert.ok(setup.findFilesSpy.callCount <= 2, 'findFiles should not be called more than twice') diff --git a/packages/core/src/testInteg/perf/registerNewFiles.test.ts b/packages/core/src/testInteg/perf/registerNewFiles.test.ts index c718bf46d26..15d1d56889d 100644 --- a/packages/core/src/testInteg/perf/registerNewFiles.test.ts +++ b/packages/core/src/testInteg/perf/registerNewFiles.test.ts @@ -5,7 +5,7 @@ import assert from 'assert' import sinon from 'sinon' import * as vscode from 'vscode' -import { NewFileInfo, NewFileZipContents, registerNewFiles } from '../../amazonqFeatureDev' +import { featureDevScheme, NewFileInfo, NewFileZipContents, registerNewFiles } from '../../amazonqFeatureDev' import { getEqualOSTestOptions, performanceTest } from '../../shared/performance/performance' import { getTestWorkspaceFolder } from '../integrationTestsUtilities' import { VirtualFileSystem } from '../../shared' @@ -60,7 +60,8 @@ function performanceTestWrapper(label: string, numFiles: number, fileSize: numbe setup.fileContents, 'test-upload-id', [setup.workspace], - conversationId + conversationId, + featureDevScheme ) }, verify: async (setup: SetupResult, result: NewFileInfo[]) => { diff --git a/packages/core/src/testInteg/perf/startSecurityScan.test.ts b/packages/core/src/testInteg/perf/startSecurityScan.test.ts index ff881bf7f61..6883d765c83 100644 --- a/packages/core/src/testInteg/perf/startSecurityScan.test.ts +++ b/packages/core/src/testInteg/perf/startSecurityScan.test.ts @@ -93,7 +93,8 @@ describe('startSecurityScanPerformanceTest', function () { editor, createClient(), extensionContext, - CodeAnalysisScope.FILE, + CodeAnalysisScope.FILE_AUTO, + false, setup.zipUtil ) }, @@ -108,9 +109,12 @@ describe('startSecurityScanPerformanceTest', function () { 'should make less than a small constant number of file system calls' ) const warnings = getTestWindow().shownMessages.filter((m) => m.severity === SeverityLevel.Warning) - assert.strictEqual(warnings.length, 0) + // If we see a warning, make sure its about the toolkit and unrelated to security scan. + if (warnings.length > 0) { + assert.ok(!warnings.some((s) => !s.message.includes('AWS Toolkit PREVIEW'))) + } assertTelemetry('codewhisperer_securityScan', { - codewhispererCodeScanScope: 'FILE', + codewhispererCodeScanScope: 'FILE_AUTO', passive: true, }) }, diff --git a/src.gen/@amzn/codewhisperer-streaming/src/models/models_0.ts b/src.gen/@amzn/codewhisperer-streaming/src/models/models_0.ts index 7d2622c985e..2b069eb9a73 100644 --- a/src.gen/@amzn/codewhisperer-streaming/src/models/models_0.ts +++ b/src.gen/@amzn/codewhisperer-streaming/src/models/models_0.ts @@ -1928,10 +1928,24 @@ export interface TransformationExportContext { /** * @public + * Unit test generation export context + */ +export interface UnitTestGenerationExportContext { + /** + * @public + * Test generation job group name + */ + testGenerationJobGroupName: string | undefined; + + testGenerationJobId?: string; +} + +/** * Export Context */ export type ExportContext = | ExportContext.TransformationExportContextMember + | ExportContext.UnitTestGenerationExportContextMember | ExportContext.$UnknownMember /** @@ -1945,6 +1959,17 @@ export namespace ExportContext { */ export interface TransformationExportContextMember { transformationExportContext: TransformationExportContext; + unitTestGenerationExportContext?: never; + $unknown?: never; + } + + /** + * @public + * Unit test generation export context + */ + export interface UnitTestGenerationExportContextMember { + transformationExportContext?: never; + unitTestGenerationExportContext: UnitTestGenerationExportContext; $unknown?: never; } @@ -1953,11 +1978,13 @@ export namespace ExportContext { */ export interface $UnknownMember { transformationExportContext?: never; + unitTestGenerationExportContext?: never; $unknown: [string, any]; } export interface Visitor { transformationExportContext: (value: TransformationExportContext) => T; + unitTestGenerationExportContext: (value: UnitTestGenerationExportContext) => T; _: (name: string, value: any) => T; } @@ -1966,6 +1993,7 @@ export namespace ExportContext { visitor: Visitor ): T => { if (value.transformationExportContext !== undefined) return visitor.transformationExportContext(value.transformationExportContext); + if (value.unitTestGenerationExportContext !== undefined) return visitor.unitTestGenerationExportContext(value.unitTestGenerationExportContext); return visitor._(value.$unknown[0], value.$unknown[1]); } @@ -1984,6 +2012,10 @@ export const ExportIntent = { * Code Transformation */ TRANSFORMATION: "TRANSFORMATION", + /** + * Unit Test + */ + UNIT_TESTS: "UNIT_TESTS", } as const /** * @public