From f1cf31397a47322ef0c9d69609267f89c0727416 Mon Sep 17 00:00:00 2001 From: Wilson Huang Date: Wed, 6 Nov 2024 14:05:50 -0500 Subject: [PATCH] feat(feature dev): Add setting to allow Q to run code and test commands --- ...-10418729-4b46-4126-b1af-623a08f0223c.json | 4 + packages/amazonq/package.json | 5 + packages/core/package.nls.json | 7 ++ .../core/src/amazonqFeatureDev/constants.ts | 3 + .../controllers/chat/controller.ts | 99 ++++++++++++++----- .../src/amazonqFeatureDev/session/session.ts | 56 ++++++++++- packages/core/src/amazonqFeatureDev/types.ts | 3 + .../core/src/amazonqFeatureDev/util/files.ts | 13 ++- .../util/codewhispererSettings.ts | 13 +++ packages/core/src/shared/filetypes.ts | 11 ++- .../core/src/shared/settings-amazonq.gen.ts | 1 + 11 files changed, 184 insertions(+), 31 deletions(-) create mode 100644 packages/amazonq/.changes/next-release/Feature-10418729-4b46-4126-b1af-623a08f0223c.json diff --git a/packages/amazonq/.changes/next-release/Feature-10418729-4b46-4126-b1af-623a08f0223c.json b/packages/amazonq/.changes/next-release/Feature-10418729-4b46-4126-b1af-623a08f0223c.json new file mode 100644 index 00000000000..c957071b238 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-10418729-4b46-4126-b1af-623a08f0223c.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Add setting to allow Q /dev to run code and test commands" +} diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 3f82d6c06af..60ce546627a 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -127,6 +127,11 @@ "markdownDescription": "%AWS.configuration.description.amazonq%", "default": true }, + "amazonQ.devCommandWorkspaceConfigurations": { + "markdownDescription": "%AWS.configuration.description.devCommandWorkspaceConfigurations%", + "type": "object", + "default": {} + }, "amazonQ.importRecommendationForInlineCodeSuggestions": { "type": "boolean", "description": "%AWS.configuration.description.amazonq.importRecommendation%", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index bc3d2182452..0f5549424b1 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -21,6 +21,7 @@ "AWS.configuration.enableCodeLenses": "Enable SAM hints in source code and template.yaml files", "AWS.configuration.description.resources.enabledResources": "AWS resources to display in the 'Resources' portion of the explorer.", "AWS.configuration.description.experiments": "Try experimental features and give feedback. Note that experimental features may be removed at any time.\n * `jsonResourceModification` - Enables basic create, update, and delete support for cloud resources via the JSON Resources explorer component.\n * `samSyncCode` - Adds an additional code-only option when synchronizing SAM applications. Code-only synchronizations are faster but can cause drift in the CloudFormation stack. Does nothing when using the legacy SAM deploy feature.\n * `iamPolicyChecks` - Enables IAM Policy Checks feature, allowing users to validate IAM policies against IAM policy grammar, AWS best practices, and specified security standards.", + "AWS.configuration.description.devCommandWorkspaceConfigurations": "Amazon Q: Allow Q /dev to run code and test commands", "AWS.stepFunctions.asl.format.enable.desc": "Enables the default formatter used with Amazon States Language files", "AWS.stepFunctions.asl.maxItemsComputed.desc": "The maximum number of outline symbols and folding regions computed (limited for performance reasons).", "AWS.configuration.description.awssam.debug.api": "API Gateway configuration", @@ -322,12 +323,18 @@ "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.pillText.generateDevFile": "Generate devfile to build code", + "AWS.amazonq.featureDev.pillText.acceptForProject": "Yes, use my devfile for this project", + "AWS.amazonq.featureDev.pillText.declineForProject": "No, thanks", "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?", "AWS.amazonq.featureDev.answer.sessionClosed": "Okay, I've ended this chat session. You can open a new tab to chat or start another workflow.", "AWS.amazonq.featureDev.answer.newTaskChanges": "What new task would you like to work on?", + "AWS.amazonq.featureDev.answer.devFileSuggestion": "For future tasks in this project, I can create a devfile to build and test code as I generate it. This can improve the quality of generated code. To allow me to create a devfile, choose **Generate devfile to build code**.", + "AWS.amazonq.featureDev.answer.settingUpdated": "I've updated your settings so I can run code and test commands based on your devfile for this project. You can update this setting under **Amazon Q: Allow Q /dev to run code and test commands**.", + "AWS.amazonq.featureDev.answer.devFileInRepository": "I noticed that your repository has a `devfile.yaml`. Would you like me to use the devfile to build and test your project as I generate code? \n\nFor more information on using devfiles to improve code generation, see the Amazon Q Developer documentation.", "AWS.amazonq.featureDev.placeholder.chatInputDisabled": "Chat input is disabled", "AWS.amazonq.featureDev.placeholder.additionalImprovements": "Describe your task or issue in detail", "AWS.amazonq.featureDev.placeholder.feedback": "Provide feedback or comments", diff --git a/packages/core/src/amazonqFeatureDev/constants.ts b/packages/core/src/amazonqFeatureDev/constants.ts index 72d490e7ec4..f106ebb5db3 100644 --- a/packages/core/src/amazonqFeatureDev/constants.ts +++ b/packages/core/src/amazonqFeatureDev/constants.ts @@ -14,6 +14,9 @@ export const featureDevChat = 'featureDevChat' export const featureName = 'Amazon Q Developer Agent for software development' +export const generateDevFilePrompt = + 'generate a devfile in my repository. Note that you should only use devfile version 2.0.0 and the only supported command is test, so you should bundle all install, build and test commands in "test". also you can use "public.ecr.aws/aws-mde/universal-image:latest" as universal image if you aren\'t sure which image to use. here is an example for a node repository: schemaVersion: 2.0.0 components: - name: dev container: image: public.ecr.aws/aws-mde/universal-image:latest commands: - id: test exec: component: dev commandLine: "npm install && npm run build && npm run test"' + // Max allowed size for file collection export const maxRepoSizeBytes = 200 * 1024 * 1024 diff --git a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts index aa300b9ddba..62999cf9017 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts @@ -29,7 +29,7 @@ import { } from '../../errors' import { codeGenRetryLimit, defaultRetryLimit } from '../../limits' import { Session } from '../../session/session' -import { featureName } from '../../constants' +import { featureName, generateDevFilePrompt } from '../../constants' import { ChatSessionStorage } from '../../storages/chatSession' import { DevPhase, FollowUpTypes, SessionStatePhase } from '../../types' import { Messenger } from './messenger/messenger' @@ -40,12 +40,13 @@ import { submitFeedback } from '../../../feedback/vue/submitFeedback' import { placeholder } from '../../../shared/vscode/commands2' import { EditorContentController } from '../../../amazonq/commons/controllers/contentController' import { openUrl } from '../../../shared/utilities/vsCodeUtils' -import { getPathsFromZipFilePath } from '../../util/files' +import { checkForDevFile, getPathsFromZipFilePath } from '../../util/files' import { examples, messageWithConversationId } from '../../userFacingText' import { getWorkspaceFoldersByPrefixes } from '../../../shared/utilities/workspaceUtils' import { openDeletedDiff, openDiff } from '../../../amazonq/commons/diff' import { i18n } from '../../../shared/i18n-helper' import globals from '../../../shared/extensionGlobals' +import { CodeWhispererSettings } from '../../../codewhisperer' export const TotalSteps = 3 @@ -147,6 +148,12 @@ export class FeatureDevController { case FollowUpTypes.SendFeedback: this.sendFeedback() break + case FollowUpTypes.AcceptAutoBuild: + return this.processDevCommandWorkspaceSetting(true, data) + case FollowUpTypes.DenyAutoBuild: + return this.processDevCommandWorkspaceSetting(false, data) + case FollowUpTypes.GenerateDevFile: + return this.newTask(data, generateDevFilePrompt) } }) this.chatControllerMessageListeners.openDiff.event((data) => { @@ -361,9 +368,9 @@ export class FeatureDevController { return } - await session.preloader(message.message) + const isPreloaderFinished = await session.preloader(message.message) - if (session.state.phase === DevPhase.CODEGEN) { + if (isPreloaderFinished && session.state.phase === DevPhase.CODEGEN) { await this.onCodeGeneration(session, message.message, message.tabID) } } catch (err: any) { @@ -447,8 +454,8 @@ export class FeatureDevController { tabID: tabID, message: remainingIterations === 0 - ? 'Would you like me to add this code to your project?' - : `Would you like me to add this code to your project, or provide feedback for new code? You have ${remainingIterations} out of ${totalIterations} code generations left.`, + ? 'Would you like to add this code to your project?' + : `Would you like to add this code to your project, or provide feedback for new code? You have ${remainingIterations} out of ${totalIterations} code generations left.`, }) } @@ -463,7 +470,7 @@ export class FeatureDevController { // Finish processing the event if (session?.state?.tokenSource?.token.isCancellationRequested) { - this.workOnNewTask( + await this.workOnNewTask( session, session.state.codeGenerationRemainingIterationCount || TotalSteps - (session.state?.currentIteration || 0), @@ -491,12 +498,16 @@ export class FeatureDevController { } } } - private workOnNewTask( + private async workOnNewTask( message: any, remainingIterations: number = 0, totalIterations?: number, isStoppedGeneration: boolean = false ) { + const hasDevFile = await checkForDevFile( + (await this.sessionStorage.getSession(message.tabID)).getWorkspaceRoot() + ) + if (isStoppedGeneration) { this.messenger.sendAnswer({ message: @@ -509,21 +520,37 @@ export class FeatureDevController { } if ((remainingIterations <= 0 && isStoppedGeneration) || !isStoppedGeneration) { + const followUps: Array = [ + { + pillText: i18n('AWS.amazonq.featureDev.pillText.newTask'), + type: FollowUpTypes.NewTask, + status: 'info', + }, + { + pillText: i18n('AWS.amazonq.featureDev.pillText.closeSession'), + type: FollowUpTypes.CloseSession, + status: 'info', + }, + ] + + if (!hasDevFile) { + followUps.push({ + pillText: i18n('AWS.amazonq.featureDev.pillText.generateDevFile'), + type: FollowUpTypes.GenerateDevFile, + status: 'info', + }) + + this.messenger.sendAnswer({ + type: 'answer', + tabID: message.tabID, + message: i18n('AWS.amazonq.featureDev.answer.devFileSuggestion'), + }) + } + this.messenger.sendAnswer({ type: 'system-prompt', tabID: message.tabID, - followUps: [ - { - pillText: i18n('AWS.amazonq.featureDev.pillText.newTask'), - type: FollowUpTypes.NewTask, - status: 'info', - }, - { - pillText: i18n('AWS.amazonq.featureDev.pillText.closeSession'), - type: FollowUpTypes.CloseSession, - status: 'info', - }, - ], + followUps, }) this.messenger.sendChatInputEnabled(message.tabID, false) this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) @@ -537,6 +564,20 @@ export class FeatureDevController { i18n('AWS.amazonq.featureDev.placeholder.additionalImprovements') ) } + + private async processDevCommandWorkspaceSetting(setting: boolean, msg: any) { + const root = (await this.sessionStorage.getSession(msg.tabID)).getWorkspaceRoot() + await CodeWhispererSettings.instance.updateDevCommandWorkspaceConfigurations(root, setting) + + this.messenger.sendAnswer({ + message: i18n('AWS.amazonq.featureDev.answer.settingUpdated'), + tabID: msg.tabID, + type: 'answer', + }) + + await this.retryRequest(msg) + } + // TODO add type private async insertCode(message: any) { let session @@ -563,7 +604,7 @@ export class FeatureDevController { canBeVoted: true, }) - this.workOnNewTask( + await this.workOnNewTask( message, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount @@ -854,7 +895,7 @@ export class FeatureDevController { this.sessionStorage.deleteSession(message.tabID) } - private async newTask(message: any) { + private async newTask(message: any, prefilledPrompt?: string) { // Old session for the tab is ending, delete it so we can create a new one for the message id const session = await this.sessionStorage.getSession(message.tabID) telemetry.amazonq_endChat.emit({ @@ -867,8 +908,18 @@ export class FeatureDevController { // Re-run the opening flow, where we check auth + create a session await this.tabOpened(message) - this.messenger.sendChatInputEnabled(message.tabID, true) - this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.describe')) + if (prefilledPrompt) { + this.messenger.sendAnswer({ + type: 'system-prompt', + tabID: message.tabID, + message: i18n('AWS.amazonq.featureDev.pillText.generateDevFile'), + }) + + await this.processUserChatMessage({ ...message, message: prefilledPrompt }) + } else { + this.messenger.sendChatInputEnabled(message.tabID, true) + this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.describe')) + } } private async closeSession(message: any) { diff --git a/packages/core/src/amazonqFeatureDev/session/session.ts b/packages/core/src/amazonqFeatureDev/session/session.ts index 204e974eee0..f04b0ab2519 100644 --- a/packages/core/src/amazonqFeatureDev/session/session.ts +++ b/packages/core/src/amazonqFeatureDev/session/session.ts @@ -7,6 +7,7 @@ import * as path from 'path' import { ConversationNotStartedState, PrepareCodeGenState } from './sessionState' import { + FollowUpTypes, type DeletedFileInfo, type Interaction, type NewFileInfo, @@ -26,6 +27,10 @@ import { ReferenceLogViewProvider } from '../../codewhisperer/service/referenceL import { AuthUtil } from '../../codewhisperer/util/authUtil' import { getLogger } from '../../shared' import { logWithConversationId } from '../userFacingText' +import { checkForDevFile } from '../util/files' +import { CodeWhispererSettings } from '../../codewhisperer' +import { i18n } from '../../shared/i18n-helper' + export class Session { private _state?: SessionState | Omit private task: string = '' @@ -59,14 +64,51 @@ export class Session { * 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 - this.messenger.sendAsyncEventProgress(this.tabID, true, undefined) - await this.proxyClient.sendFeatureDevTelemetryEvent(this.conversationId) // send the event only once per conversation. + const root = this.config.workspaceRoots[0] + const autoBuildProjectSetting = CodeWhispererSettings.instance.getDevCommandWorkspaceConfigurations() + const hasDevfile = await checkForDevFile(root) + const isPromptedForAutoBuildFeature = Object.keys(autoBuildProjectSetting).includes(root) + + if (hasDevfile && !isPromptedForAutoBuildFeature) { + await this.promptAllowQCommandsConsent(this.tabID) + return false + } else { + if (!this.preloaderFinished) { + await this.setupConversation(msg) + this.preloaderFinished = true + this.messenger.sendAsyncEventProgress(this.tabID, true, undefined) + await this.proxyClient.sendFeatureDevTelemetryEvent(this.conversationId) // send the event only once per conversation. + } + return this.preloaderFinished } } + private async promptAllowQCommandsConsent(tabID: string) { + this.messenger.sendAnswer({ + tabID: tabID, + message: i18n('AWS.amazonq.featureDev.answer.devFileInRepository'), + type: 'answer', + }) + + this.messenger.sendAnswer({ + message: undefined, + type: 'system-prompt', + followUps: [ + { + pillText: i18n('AWS.amazonq.featureDev.pillText.acceptForProject'), + type: FollowUpTypes.AcceptAutoBuild, + status: 'success', + }, + { + pillText: i18n('AWS.amazonq.featureDev.pillText.declineForProject'), + type: FollowUpTypes.DenyAutoBuild, + status: 'error', + }, + ], + tabID: tabID, + }) + } + /** * setupConversation * @@ -103,6 +145,10 @@ export class Session { this._state && this._state.updateWorkspaceRoot && this._state.updateWorkspaceRoot(workspaceRootFolder) } + getWorkspaceRoot(): string { + return this.config.workspaceRoots[0] + } + private getSessionStateConfig(): Omit { return { workspaceRoots: this.config.workspaceRoots, diff --git a/packages/core/src/amazonqFeatureDev/types.ts b/packages/core/src/amazonqFeatureDev/types.ts index cf046743425..71c69923f4d 100644 --- a/packages/core/src/amazonqFeatureDev/types.ts +++ b/packages/core/src/amazonqFeatureDev/types.ts @@ -49,6 +49,9 @@ export enum FollowUpTypes { NewTask = 'NewTask', CloseSession = 'CloseSession', SendFeedback = 'SendFeedback', + AcceptAutoBuild = 'AcceptAutoBuild', + DenyAutoBuild = 'DenyAutoBuild', + GenerateDevFile = 'GenerateDevFile', } export type SessionStatePhase = DevPhase.INIT | DevPhase.CODEGEN diff --git a/packages/core/src/amazonqFeatureDev/util/files.ts b/packages/core/src/amazonqFeatureDev/util/files.ts index 1b83bdbe2b5..a14cd0e859d 100644 --- a/packages/core/src/amazonqFeatureDev/util/files.ts +++ b/packages/core/src/amazonqFeatureDev/util/files.ts @@ -19,9 +19,16 @@ import { TelemetryHelper } from './telemetryHelper' import { maxRepoSizeBytes } from '../constants' import { isCodeFile } from '../../shared/filetypes' import { fs } from '../../shared' +import { CodeWhispererSettings } from '../../codewhisperer' const getSha256 = (file: Buffer) => createHash('sha256').update(file).digest('base64') +export async function checkForDevFile(root: string) { + const devFilePath = root + '/devfile.yaml' + const hasDevFile = await fs.existsFile(devFilePath) + return hasDevFile +} + /** * given the root path of the repo it zips its files in memory and generates a checksum for it. */ @@ -34,6 +41,8 @@ export async function prepareRepoData( ) { try { const files = await collectFiles(repoRootPaths, workspaceFolders, true, maxRepoSizeBytes) + const devCommandWorkspaceConfigurations = CodeWhispererSettings.instance.getDevCommandWorkspaceConfigurations() + const useAutoBuildFeature = devCommandWorkspaceConfigurations[repoRootPaths[0]] ?? false let totalBytes = 0 const ignoredExtensionMap = new Map() @@ -41,8 +50,10 @@ export async function prepareRepoData( for (const file of files) { const fileSize = (await fs.stat(file.fileUri)).size const isCodeFile_ = isCodeFile(file.relativeFilePath) + // exclude user's devfile if `useAutoBuildFeature` is set to false + const excludeDevFile = useAutoBuildFeature ? false : file.relativeFilePath === 'devfile.yaml' - if (fileSize >= maxFileSizeBytes || !isCodeFile_) { + if (fileSize >= maxFileSizeBytes || !isCodeFile_ || excludeDevFile) { if (!isCodeFile_) { const re = /(?:\.([^.]+))?$/ const extensionArray = re.exec(file.relativeFilePath) diff --git a/packages/core/src/codewhisperer/util/codewhispererSettings.ts b/packages/core/src/codewhisperer/util/codewhispererSettings.ts index 09c7e2657bd..56cfad2a0b2 100644 --- a/packages/core/src/codewhisperer/util/codewhispererSettings.ts +++ b/packages/core/src/codewhisperer/util/codewhispererSettings.ts @@ -12,6 +12,7 @@ const description = { workspaceIndexWorkerThreads: Number, workspaceIndexUseGPU: Boolean, workspaceIndexMaxSize: Number, + devCommandWorkspaceConfigurations: Object, } export class CodeWhispererSettings extends fromExtensionManifest('amazonQ', description) { @@ -64,6 +65,18 @@ export class CodeWhispererSettings extends fromExtensionManifest('amazonQ', desc return Math.max(this.get('workspaceIndexMaxSize', 250), 1) } + public getDevCommandWorkspaceConfigurations(): { [key: string]: boolean } { + return this.get('devCommandWorkspaceConfigurations', {}) + } + + public async updateDevCommandWorkspaceConfigurations(projectName: string, setting: boolean) { + const projects = this.getDevCommandWorkspaceConfigurations() + + projects[projectName] = setting + + await this.update('devCommandWorkspaceConfigurations', projects) + } + static #instance: CodeWhispererSettings public static get instance() { diff --git a/packages/core/src/shared/filetypes.ts b/packages/core/src/shared/filetypes.ts index 446cabe0a2a..863de8c9adc 100644 --- a/packages/core/src/shared/filetypes.ts +++ b/packages/core/src/shared/filetypes.ts @@ -165,6 +165,7 @@ export const codefileExtensions = new Set([ '.cbl', '.cc', '.cfc', + '.cfg', '.cfm', '.cjs', '.clj', @@ -175,6 +176,7 @@ export const codefileExtensions = new Set([ '.cob', '.cobra', '.coffee', + '.config', '.cpp', '.cpy', '.cr', @@ -189,6 +191,7 @@ export const codefileExtensions = new Set([ '.e', '.el', '.elm', + '.env', '.erl', '.ex', '.exs', @@ -204,6 +207,7 @@ export const codefileExtensions = new Set([ '.fsi', '.fsx', '.gd', + '.gitignore', '.go', '.gql', '.graphql', @@ -223,6 +227,7 @@ export const codefileExtensions = new Set([ '.html', '.hy', '.idl', + '.ini', '.io', '.jar', '.java', @@ -236,6 +241,7 @@ export const codefileExtensions = new Set([ '.lgt', '.lhs', '.lisp', + '.lock', '.logtalk', '.lsp', '.lua', @@ -324,10 +330,12 @@ export const codefileExtensions = new Set([ '.t', '.tcl', '.tf', + '.toml', '.trigger', '.ts', '.tsx', '.tu', + '.txt', '.v', '.vala', '.vapi', @@ -349,5 +357,6 @@ export const codefileExtensions = new Set([ /** Returns true if `filename` is a code file. */ export function isCodeFile(filename: string): boolean { const ext = path.extname(filename).toLowerCase() - return codefileExtensions.has(ext) + const result = codefileExtensions.has(ext) || codefileExtensions.has(filename) + return result } diff --git a/packages/core/src/shared/settings-amazonq.gen.ts b/packages/core/src/shared/settings-amazonq.gen.ts index 4fbe8ab0005..e9dd5016c93 100644 --- a/packages/core/src/shared/settings-amazonq.gen.ts +++ b/packages/core/src/shared/settings-amazonq.gen.ts @@ -21,6 +21,7 @@ export const amazonqSettings = { "ssoCacheError": {} }, "amazonQ.showInlineCodeSuggestionsWithCodeReferences": {}, + "amazonQ.devCommandWorkspaceConfigurations": {}, "amazonQ.importRecommendationForInlineCodeSuggestions": {}, "amazonQ.shareContentWithAWS": {}, "amazonQ.workspaceIndex": {},