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 f1b30dc28fd..31b516ec37d 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -127,6 +127,11 @@ "markdownDescription": "%AWS.configuration.description.amazonq%", "default": true }, + "amazonQ.allowFeatureDevelopmentToRunCodeAndTests": { + "markdownDescription": "%AWS.configuration.description.featureDevelopment.allowRunningCodeAndTests%", + "type": "object", + "default": {} + }, "amazonQ.importRecommendationForInlineCodeSuggestions": { "type": "boolean", "description": "%AWS.configuration.description.amazonq.importRecommendation%", diff --git a/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts b/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts index a7a5d831f67..680caf4aff0 100644 --- a/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts +++ b/packages/amazonq/test/unit/amazonqFeatureDev/session/session.test.ts @@ -38,8 +38,9 @@ describe('session', () => { describe('preloader', () => { it('emits start chat telemetry', async () => { const session = await createSession({ messenger, conversationID, scheme: featureDevScheme }) + session.latestMessage = 'implement twosum in typescript' - await session.preloader('implement twosum in typescript') + await session.preloader() assertTelemetry('amazonq_startConversationInvoke', { amazonqConversationId: conversationID, diff --git a/packages/amazonq/test/unit/amazonqFeatureDev/util/files.test.ts b/packages/amazonq/test/unit/amazonqFeatureDev/util/files.test.ts index e8ecefc171e..0bf7df006b2 100644 --- a/packages/amazonq/test/unit/amazonqFeatureDev/util/files.test.ts +++ b/packages/amazonq/test/unit/amazonqFeatureDev/util/files.test.ts @@ -10,55 +10,113 @@ import { ContentLengthError, maxRepoSizeBytes, } from 'aws-core-vscode/amazonqFeatureDev' -import { assertTelemetry, createTestWorkspace } from 'aws-core-vscode/test' +import { assertTelemetry, getWorkspaceFolder, TestFolder } from 'aws-core-vscode/test' import { fs, AmazonqCreateUpload } from 'aws-core-vscode/shared' -import { Span } from 'aws-core-vscode/telemetry' +import { MetricName, Span } from 'aws-core-vscode/telemetry' import sinon from 'sinon' +import { CodeWhispererSettings } from 'aws-core-vscode/codewhisperer' + +import AdmZip from 'adm-zip' + +const testDevfilePrepareRepo = async (devfileEnabled: boolean) => { + const files: Record = { + 'file.md': 'test content', + // only include when execution is enabled + 'devfile.yaml': 'test', + // .git folder is always dropped (because of vscode global exclude rules) + '.git/ref': '####', + // .gitignore should always be included + '.gitignore': 'node_models/*', + // non code files only when dev execution is enabled + 'abc.jar': 'jar-content', + 'data/logo.ico': 'binary-content', + } + const folder = await TestFolder.create() + + for (const [fileName, content] of Object.entries(files)) { + await folder.write(fileName, content) + } + + const expectedFiles = !devfileEnabled + ? ['./file.md', './.gitignore'] + : ['./devfile.yaml', './file.md', './.gitignore', './abc.jar', 'data/logo.ico'] + + const workspace = getWorkspaceFolder(folder.path) + sinon + .stub(CodeWhispererSettings.instance, 'getAutoBuildSetting') + .returns(devfileEnabled ? { [workspace.uri.fsPath]: true } : {}) + + await testPrepareRepoData(workspace, expectedFiles) +} + +const testPrepareRepoData = async ( + workspace: vscode.WorkspaceFolder, + expectedFiles: string[], + expectedTelemetryMetrics?: Array<{ metricName: MetricName; value: any }> +) => { + expectedFiles.sort((a, b) => a.localeCompare(b)) + const telemetry = new TelemetryHelper() + const result = await prepareRepoData([workspace.uri.fsPath], [workspace], telemetry, { + record: () => {}, + } as unknown as Span) + + assert.strictEqual(Buffer.isBuffer(result.zipFileBuffer), true) + // checksum is not the same across different test executions because some unique random folder names are generated + assert.strictEqual(result.zipFileChecksum.length, 44) + + if (expectedTelemetryMetrics) { + for (const metric of expectedTelemetryMetrics) { + assertTelemetry(metric.metricName, metric.value) + } + } + + // Unzip the buffer and compare the entry names + const zip = new AdmZip(result.zipFileBuffer) + const actualZipEntries = zip.getEntries().map((entry) => entry.entryName) + actualZipEntries.sort((a, b) => a.localeCompare(b)) + assert.deepStrictEqual(actualZipEntries, expectedFiles) +} describe('file utils', () => { describe('prepareRepoData', function () { - it('returns files in the workspace as a zip', async function () { - // these variables are a manual selection of settings for the test in order to test the collectFiles function - const fileAmount = 2 - const fileNamePrefix = 'file' - const fileNameSuffix = '.md' - const fileContent = 'test content' + afterEach(() => { + sinon.restore() + }) - const workspace = await createTestWorkspace(fileAmount, { fileNamePrefix, fileContent, fileNameSuffix }) + it('returns files in the workspace as a zip', async function () { + const folder = await TestFolder.create() + await folder.write('file1.md', 'test content') + await folder.write('file2.md', 'test content') + const workspace = getWorkspaceFolder(folder.path) - const telemetry = new TelemetryHelper() - const result = await prepareRepoData([workspace.uri.fsPath], [workspace], telemetry, { - record: () => {}, - } as unknown as Span) - assert.strictEqual(Buffer.isBuffer(result.zipFileBuffer), true) - // checksum is not the same across different test executions because some unique random folder names are generated - assert.strictEqual(result.zipFileChecksum.length, 44) - assert.strictEqual(telemetry.repositorySize, 24) + await testPrepareRepoData(workspace, ['./file1.md', './file2.md']) }) it('prepareRepoData ignores denied file extensions', async function () { - // these variables are a manual selection of settings for the test in order to test the collectFiles function - const fileAmount = 1 - const fileNamePrefix = 'file' - const fileNameSuffix = '.mp4' - const fileContent = 'test content' + const folder = await TestFolder.create() + await folder.write('file.mp4', 'test content') + const workspace = getWorkspaceFolder(folder.path) - const workspace = await createTestWorkspace(fileAmount, { fileNamePrefix, fileContent, fileNameSuffix }) - const telemetry = new TelemetryHelper() - const result = await prepareRepoData([workspace.uri.fsPath], [workspace], telemetry, { - record: () => {}, - } as unknown as Span) - - assert.strictEqual(Buffer.isBuffer(result.zipFileBuffer), true) - // checksum is not the same across different test executions because some unique random folder names are generated - assert.strictEqual(result.zipFileChecksum.length, 44) - assert.strictEqual(telemetry.repositorySize, 0) - assertTelemetry('amazonq_bundleExtensionIgnored', { filenameExt: 'mp4', count: 1 }) + await testPrepareRepoData( + workspace, + [], + [{ metricName: 'amazonq_bundleExtensionIgnored', value: { filenameExt: 'mp4', count: 1 } }] + ) + }) + + it('should ignore devfile.yaml when setting is disabled', async function () { + await testDevfilePrepareRepo(false) + }) + + it('should include devfile.yaml when setting is enabled', async function () { + await testDevfilePrepareRepo(true) }) // Test the logic that allows the customer to modify root source folder it('prepareRepoData throws a ContentLengthError code when repo is too big', async function () { - const workspace = await createTestWorkspace(1, {}) + const folder = await TestFolder.create() + await folder.write('file.md', 'test content') + const workspace = getWorkspaceFolder(folder.path) const telemetry = new TelemetryHelper() sinon.stub(fs, 'stat').resolves({ size: 2 * maxRepoSizeBytes } as vscode.FileStat) diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 8225ff3eb9f..5d96e40e71f 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -20,6 +20,7 @@ "AWS.configuration.description.suppressPrompts": "Prompts which ask for confirmation. Checking an item suppresses the prompt.", "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.featureDevelopment.allowRunningCodeAndTests": "Allow /dev to run code and test commands", "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.", "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).", @@ -362,12 +363,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.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.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/amazonq/commons/types.ts b/packages/core/src/amazonq/commons/types.ts index f5724a13872..da6503b262f 100644 --- a/packages/core/src/amazonq/commons/types.ts +++ b/packages/core/src/amazonq/commons/types.ts @@ -24,6 +24,9 @@ export enum FollowUpTypes { NewTask = 'NewTask', CloseSession = 'CloseSession', SendFeedback = 'SendFeedback', + AcceptAutoBuild = 'AcceptAutoBuild', + DenyAutoBuild = 'DenyAutoBuild', + GenerateDevFile = 'GenerateDevFile', // Doc CreateDocumentation = 'CreateDocumentation', ChooseFolder = 'ChooseFolder', diff --git a/packages/core/src/amazonqFeatureDev/constants.ts b/packages/core/src/amazonqFeatureDev/constants.ts index 72d490e7ec4..5c00e8d7bfa 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 commands are install, build and test (are all optional). so you may have to bundle some commands together using '&&'. 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 (but don't assume it's always a node project. look at the existing repository structure before generating the devfile): schemaVersion: 2.0.0 components: - name: dev container: image: public.ecr.aws/aws-mde/universal-image:latest commands: - id: install exec: component: dev commandLine: ”npm install” - id: build exec: component: dev commandLine: ”npm run build” - id: test exec: component: dev commandLine: ”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 aad70595366..6ce82a1a0ab 100644 --- a/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts +++ b/packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { MynahIcons } from '@aws/mynah-ui' +import { ChatItemAction, MynahIcons } from '@aws/mynah-ui' import * as path from 'path' import * as vscode from 'vscode' import { EventEmitter } from 'vscode' @@ -29,7 +29,7 @@ import { } from '../../errors' import { codeGenRetryLimit, defaultRetryLimit } from '../../limits' import { Session } from '../../session/session' -import { featureDevScheme, featureName } from '../../constants' +import { featureDevScheme, featureName, generateDevFilePrompt } from '../../constants' import { DeletedFileInfo, DevPhase, MetricDataOperationName, MetricDataResult, type NewFileInfo } from '../../types' import { AuthUtil } from '../../../codewhisperer/util/authUtil' import { AuthController } from '../../../amazonq/auth/controller' @@ -38,12 +38,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' import { randomUUID } from '../../../shared' import { FollowUpTypes } from '../../../amazonq/commons/types' import { Messenger } from '../../../amazonq/commons/connector/baseMessenger' @@ -155,6 +156,17 @@ export class FeatureDevController { case FollowUpTypes.SendFeedback: this.sendFeedback() break + case FollowUpTypes.AcceptAutoBuild: + return this.processAutoBuildSetting(true, data) + case FollowUpTypes.DenyAutoBuild: + return this.processAutoBuildSetting(false, data) + case FollowUpTypes.GenerateDevFile: + this.messenger.sendAnswer({ + type: 'system-prompt', + tabID: data?.tabID, + message: i18n('AWS.amazonq.featureDev.pillText.generateDevFile'), + }) + return this.newTask(data, generateDevFilePrompt) } }) this.chatControllerMessageListeners.openDiff.event((data) => { @@ -369,6 +381,9 @@ export class FeatureDevController { getLogger().debug(`${featureName}: Processing message: ${message.message}`) session = await this.sessionStorage.getSession(message.tabID) + // set latestMessage in session as retry would lose context if function returns early + session.latestMessage = message.message + await session.disableFileList() const authState = await AuthUtil.instance.getChatAuthState() if (authState.amazonQ !== 'connected') { @@ -377,7 +392,17 @@ export class FeatureDevController { return } - await session.preloader(message.message) + const root = session.getWorkspaceRoot() + const autoBuildProjectSetting = CodeWhispererSettings.instance.getAutoBuildSetting() + const hasDevfile = await checkForDevFile(root) + const isPromptedForAutoBuildFeature = Object.keys(autoBuildProjectSetting).includes(root) + + if (hasDevfile && !isPromptedForAutoBuildFeature) { + await this.promptAllowQCommandsConsent(message.tabID) + return + } + + await session.preloader() if (session.state.phase === DevPhase.CODEGEN) { await this.onCodeGeneration(session, message.message, message.tabID) @@ -390,6 +415,32 @@ export class FeatureDevController { } } + 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, + }) + } + /** * Handle a regular incoming message when a user is in the code generation phase */ @@ -519,7 +570,7 @@ export class FeatureDevController { // Finish processing the event if (session?.state?.tokenSource?.token.isCancellationRequested) { - this.workOnNewTask( + await this.workOnNewTask( session.tabID, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount, @@ -557,12 +608,14 @@ export class FeatureDevController { }) } - private workOnNewTask( + private async workOnNewTask( tabID: string, remainingIterations: number = 0, totalIterations?: number, isStoppedGeneration: boolean = false ) { + const hasDevFile = await checkForDevFile((await this.sessionStorage.getSession(tabID)).getWorkspaceRoot()) + if (isStoppedGeneration) { this.messenger.sendAnswer({ message: ((remainingIterations) => { @@ -581,21 +634,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: i18n('AWS.amazonq.featureDev.answer.devFileSuggestion'), + }) + } + this.messenger.sendAnswer({ type: 'system-prompt', 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(tabID, false) this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.pillText.selectOption')) @@ -606,6 +675,20 @@ export class FeatureDevController { this.messenger.sendChatInputEnabled(tabID, true) this.messenger.sendUpdatePlaceholder(tabID, i18n('AWS.amazonq.featureDev.placeholder.additionalImprovements')) } + + private async processAutoBuildSetting(setting: boolean, msg: any) { + const root = (await this.sessionStorage.getSession(msg.tabID)).getWorkspaceRoot() + await CodeWhispererSettings.instance.updateAutoBuildSetting(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 @@ -619,9 +702,10 @@ export class FeatureDevController { this.sendAcceptCodeTelemetry(session, filesAccepted) await session.insertChanges() + if (session.acceptCodeMessageId) { this.sendUpdateCodeMessage(message.tabID) - this.workOnNewTask( + await this.workOnNewTask( message.tabID, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount @@ -820,7 +904,7 @@ export class FeatureDevController { ) if (allFilePathsAccepted && allDeletedFilePathsAccepted) { this.sendUpdateCodeMessage(tabId) - this.workOnNewTask( + await this.workOnNewTask( tabId, session.state.codeGenerationRemainingIterationCount, session.state.codeGenerationTotalIterationCount @@ -939,7 +1023,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) await session.disableFileList() @@ -953,8 +1037,12 @@ 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) { + 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 0f6d13bc0e9..a1de23d1861 100644 --- a/packages/core/src/amazonqFeatureDev/session/session.ts +++ b/packages/core/src/amazonqFeatureDev/session/session.ts @@ -70,9 +70,9 @@ export class Session { /** * Preload any events that have to run before a chat message can be sent */ - async preloader(msg: string) { + async preloader() { if (!this.preloaderFinished) { - await this.setupConversation(msg) + await this.setupConversation() this.preloaderFinished = true this.messenger.sendAsyncEventProgress(this.tabID, true, undefined) await this.proxyClient.sendFeatureDevTelemetryEvent(this.conversationId) // send the event only once per conversation. @@ -84,10 +84,7 @@ export class Session { * * 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 - + private async setupConversation() { await telemetry.amazonq_startConversationInvoke.run(async (span) => { this._conversationId = await this.proxyClient.createConversation() getLogger().info(logWithConversationId(this.conversationId)) @@ -115,6 +112,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, @@ -382,6 +383,10 @@ export class Session { return this._latestMessage } + set latestMessage(msg: string) { + this._latestMessage = msg + } + get telemetry() { return this._telemetry } diff --git a/packages/core/src/amazonqFeatureDev/util/files.ts b/packages/core/src/amazonqFeatureDev/util/files.ts index 8258d7fe179..7e93cf0f9ce 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. */ @@ -33,7 +40,10 @@ export async function prepareRepoData( zip: AdmZip = new AdmZip() ) { try { - const files = await collectFiles(repoRootPaths, workspaceFolders, true, maxRepoSizeBytes) + const autoBuildSetting = CodeWhispererSettings.instance.getAutoBuildSetting() + const useAutoBuildFeature = autoBuildSetting[repoRootPaths[0]] ?? false + // We only respect gitignore file rules if useAutoBuildFeature is on, this is to avoid dropping necessary files for building the code (e.g. png files imported in js code) + const files = await collectFiles(repoRootPaths, workspaceFolders, true, maxRepoSizeBytes, !useAutoBuildFeature) let totalBytes = 0 const ignoredExtensionMap = new Map() @@ -50,8 +60,10 @@ export async function prepareRepoData( throw error } const isCodeFile_ = isCodeFile(file.relativeFilePath) - - if (fileSize >= maxFileSizeBytes || !isCodeFile_) { + const isDevFile = file.relativeFilePath === 'devfile.yaml' + // When useAutoBuildFeature is on, only respect the gitignore rules filtered earlier and apply the size limit, otherwise, exclude all non code files and gitignore files + const isNonCodeFileAndIgnored = useAutoBuildFeature ? false : !isCodeFile_ || isDevFile + if (fileSize >= maxFileSizeBytes || isNonCodeFileAndIgnored) { 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 2bf2f657867..8a52627aacb 100644 --- a/packages/core/src/codewhisperer/util/codewhispererSettings.ts +++ b/packages/core/src/codewhisperer/util/codewhispererSettings.ts @@ -13,6 +13,7 @@ const description = { workspaceIndexWorkerThreads: Number, workspaceIndexUseGPU: Boolean, workspaceIndexMaxSize: Number, + allowFeatureDevelopmentToRunCodeAndTests: Object, ignoredSecurityIssues: ArrayConstructor(String), } @@ -50,6 +51,18 @@ export class CodeWhispererSettings extends fromExtensionManifest('amazonQ', desc return Math.max(this.get('workspaceIndexMaxSize', 250), 1) } + public getAutoBuildSetting(): { [key: string]: boolean } { + return this.get('allowFeatureDevelopmentToRunCodeAndTests', {}) + } + + public async updateAutoBuildSetting(projectName: string, setting: boolean) { + const projects = this.getAutoBuildSetting() + + projects[projectName] = setting + + await this.update('allowFeatureDevelopmentToRunCodeAndTests', projects) + } + public getIgnoredSecurityIssues(): string[] { return this.get('ignoredSecurityIssues', []) } diff --git a/packages/core/src/shared/clients/s3Client.ts b/packages/core/src/shared/clients/s3Client.ts index f4375cc6755..950360caec5 100644 --- a/packages/core/src/shared/clients/s3Client.ts +++ b/packages/core/src/shared/clients/s3Client.ts @@ -463,7 +463,7 @@ export class DefaultS3Client { MaxKeys: request.maxResults ?? DEFAULT_MAX_KEYS, /** * Set '' as the default prefix to ensure that the bucket's content will be displayed - * when the user has at least list access to the root of the bucket. + * when the user has at least list access to the root of the bucket * https://github.com/aws/aws-toolkit-vscode/issues/4643 * @default '' */ diff --git a/packages/core/src/shared/filetypes.ts b/packages/core/src/shared/filetypes.ts index 212bb58ebe2..843397e6a17 100644 --- a/packages/core/src/shared/filetypes.ts +++ b/packages/core/src/shared/filetypes.ts @@ -166,6 +166,7 @@ export const codefileExtensions = new Set([ '.cbl', '.cc', '.cfc', + '.cfg', '.cfm', '.cjs', '.clj', @@ -177,6 +178,7 @@ export const codefileExtensions = new Set([ '.cob', '.cobra', '.coffee', + '.config', '.cpp', '.cpy', '.cr', @@ -192,6 +194,7 @@ export const codefileExtensions = new Set([ '.e', '.el', '.elm', + '.env', '.erl', '.ex', '.exs', @@ -207,6 +210,7 @@ export const codefileExtensions = new Set([ '.fsi', '.fsx', '.gd', + '.gitignore', '.go', '.gql', '.gradle', @@ -227,8 +231,8 @@ export const codefileExtensions = new Set([ '.html', '.hy', '.idl', + '.ini', '.io', - '.jar', '.java', '.jl', '.js', @@ -240,6 +244,7 @@ export const codefileExtensions = new Set([ '.lgt', '.lhs', '.lisp', + '.lock', '.logtalk', '.lsp', '.lua', @@ -326,14 +331,17 @@ export const codefileExtensions = new Set([ '.ss', '.st', '.sv', + '.svg', '.swift', '.t', '.tcl', '.tf', + '.toml', '.trigger', '.ts', '.tsx', '.tu', + '.txt', '.v', '.vala', '.vapi', @@ -353,7 +361,7 @@ export const codefileExtensions = new Set([ ]) // Code file names without an extension -export const codefileNames = new Set(['Dockerfile', 'Dockerfile.build', 'gradlew', 'mvnw']) +export const codefileNames = new Set(['Dockerfile', 'Dockerfile.build', 'gradlew', 'mvnw', '.gitignore']) /** Returns true if `filename` is a code file. */ export function isCodeFile(filename: string): boolean { diff --git a/packages/core/src/shared/settings-amazonq.gen.ts b/packages/core/src/shared/settings-amazonq.gen.ts index c3e92d45035..38d575f6617 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.allowFeatureDevelopmentToRunCodeAndTests": {}, "amazonQ.importRecommendationForInlineCodeSuggestions": {}, "amazonQ.shareContentWithAWS": {}, "amazonQ.workspaceIndex": {}, diff --git a/packages/core/src/shared/utilities/workspaceUtils.ts b/packages/core/src/shared/utilities/workspaceUtils.ts index 3894c3b56ff..c650b886016 100644 --- a/packages/core/src/shared/utilities/workspaceUtils.ts +++ b/packages/core/src/shared/utilities/workspaceUtils.ts @@ -268,7 +268,7 @@ export function checkUnsavedChanges(): boolean { return vscode.workspace.textDocuments.some((doc) => doc.isDirty) } -export function getExcludePattern(additionalPatterns: string[] = []) { +export function getExcludePattern(defaultExcludePatterns: boolean = true) { const globAlwaysExcludedDirs = getGlobDirExcludedPatterns().map((pattern) => `**/${pattern}/*`) const extraPatterns = [ '**/package-lock.json', @@ -290,19 +290,24 @@ export function getExcludePattern(additionalPatterns: string[] = []) { '**/License.md', '**/LICENSE.md', ] - const allPatterns = [...globAlwaysExcludedDirs, ...extraPatterns, ...additionalPatterns] + const allPatterns = [...globAlwaysExcludedDirs, ...(defaultExcludePatterns ? extraPatterns : [])] return `{${allPatterns.join(',')}}` } /** * @param rootPath root folder to look for .gitignore files + * @param addExtraPatterns whether to add extra exclude patterns even if not in gitignore * @returns list of glob patterns extracted from .gitignore * These patterns are compatible with vscode exclude patterns */ -async function filterOutGitignoredFiles(rootPath: string, files: vscode.Uri[]): Promise { +async function filterOutGitignoredFiles( + rootPath: string, + files: vscode.Uri[], + defaultExcludePatterns: boolean = true +): Promise { const gitIgnoreFiles = await vscode.workspace.findFiles( new vscode.RelativePattern(rootPath, '**/.gitignore'), - getExcludePattern() + getExcludePattern(defaultExcludePatterns) ) const gitIgnoreFilter = await GitIgnoreFilter.build(gitIgnoreFiles) return gitIgnoreFilter.filterFiles(files) @@ -313,13 +318,15 @@ async function filterOutGitignoredFiles(rootPath: string, files: vscode.Uri[]): * @param sourcePaths the paths where collection starts * @param workspaceFolders the current workspace folders opened * @param respectGitIgnore whether to respect gitignore file + * @param addExtraIgnorePatterns whether to add extra exclude patterns even if not in gitignore * @returns all matched files */ export async function collectFiles( sourcePaths: string[], workspaceFolders: CurrentWsFolders, respectGitIgnore: boolean = true, - maxSize = 200 * 1024 * 1024 // 200 MB + maxSize = 200 * 1024 * 1024, // 200 MB + defaultExcludePatterns: boolean = true ): Promise< { workspaceFolder: vscode.WorkspaceFolder @@ -356,10 +363,12 @@ export async function collectFiles( for (const rootPath of sourcePaths) { const allFiles = await vscode.workspace.findFiles( new vscode.RelativePattern(rootPath, '**'), - getExcludePattern() + getExcludePattern(defaultExcludePatterns) ) - const files = respectGitIgnore ? await filterOutGitignoredFiles(rootPath, allFiles) : allFiles + const files = respectGitIgnore + ? await filterOutGitignoredFiles(rootPath, allFiles, defaultExcludePatterns) + : allFiles for (const file of files) { const relativePath = getWorkspaceRelativePath(file.fsPath, { workspaceFolders }) 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 a6d36942840..139ac2c112c 100644 --- a/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts +++ b/packages/core/src/test/amazonqFeatureDev/controllers/chat/controller.test.ts @@ -452,9 +452,14 @@ describe('Controller', () => { message: 'test message', }) - // Wait until the controller has time to process the event + /** + * Wait until the controller has time to process the event + * Sessions should be called twice: + * 1. When the session getWorkspaceRoot is called + * 2. When the controller processes preloader + */ await waitUntil(() => { - return Promise.resolve(getSessionStub.callCount > 0) + return Promise.resolve(getSessionStub.callCount > 1) }, {}) }