Skip to content

Commit

Permalink
feat(feature dev): Add setting to allow Q to run code and test commands
Browse files Browse the repository at this point in the history
  • Loading branch information
willyyhuang committed Nov 6, 2024
1 parent 1f9b8a4 commit 07d2d3f
Show file tree
Hide file tree
Showing 11 changed files with 180 additions and 31 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "Add setting to allow Q /dev to run code and test commands"
}
5 changes: 5 additions & 0 deletions packages/amazonq/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@
"markdownDescription": "%AWS.configuration.description.amazonq%",
"default": true
},
"amazonQ.allowDevCommands": {
"markdownDescription": "%AWS.configuration.description.allowDevCommands%",
"type": "object",
"default": {}
},
"amazonQ.importRecommendationForInlineCodeSuggestions": {
"type": "boolean",
"description": "%AWS.configuration.description.amazonq.importRecommendation%",
Expand Down
7 changes: 7 additions & 0 deletions packages/core/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.allowDevCommands": "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",
Expand Down Expand Up @@ -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 <a href=\"https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/software-dev.html\" target=\"_blank\">Amazon Q Developer documentation</a>.",
"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",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/amazonqFeatureDev/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
99 changes: 75 additions & 24 deletions packages/core/src/amazonqFeatureDev/controllers/chat/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand Down Expand Up @@ -147,6 +148,12 @@ 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:
return this.newTask(data, true)
}
})
this.chatControllerMessageListeners.openDiff.event((data) => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.`,
})
}

Expand All @@ -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),
Expand Down Expand Up @@ -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)).config.workspaceRoots[0]
)

if (isStoppedGeneration) {
this.messenger.sendAnswer({
message:
Expand All @@ -509,21 +520,37 @@ export class FeatureDevController {
}

if ((remainingIterations <= 0 && isStoppedGeneration) || !isStoppedGeneration) {
const followUps: Array<ChatItemAction> = [
{
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'))
Expand All @@ -537,6 +564,20 @@ export class FeatureDevController {
i18n('AWS.amazonq.featureDev.placeholder.additionalImprovements')
)
}

private async processAutoBuildSetting(setting: boolean, msg: any) {
const root = (await this.sessionStorage.getSession(msg.tabID)).config.workspaceRoots[0]
await CodeWhispererSettings.instance.updateToAutoBuildFeatureProjects(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
Expand All @@ -563,7 +604,7 @@ export class FeatureDevController {
canBeVoted: true,
})

this.workOnNewTask(
await this.workOnNewTask(
message,
session.state.codeGenerationRemainingIterationCount,
session.state.codeGenerationTotalIterationCount
Expand Down Expand Up @@ -854,7 +895,7 @@ export class FeatureDevController {
this.sessionStorage.deleteSession(message.tabID)
}

private async newTask(message: any) {
private async newTask(message: any, generateDevFile?: boolean) {
// 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({
Expand All @@ -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 (generateDevFile) {
this.messenger.sendAnswer({
type: 'system-prompt',
tabID: message.tabID,
message: i18n('AWS.amazonq.featureDev.pillText.generateDevFile'),
})

await this.processUserChatMessage({ ...message, message: generateDevFilePrompt })
} else {
this.messenger.sendChatInputEnabled(message.tabID, true)
this.messenger.sendUpdatePlaceholder(message.tabID, i18n('AWS.amazonq.featureDev.placeholder.describe'))
}
}

private async closeSession(message: any) {
Expand Down
52 changes: 47 additions & 5 deletions packages/core/src/amazonqFeatureDev/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path'

import { ConversationNotStartedState, PrepareCodeGenState } from './sessionState'
import {
FollowUpTypes,
type DeletedFileInfo,
type Interaction,
type NewFileInfo,
Expand All @@ -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<SessionState, 'uploadId'>
private task: string = ''
Expand Down Expand Up @@ -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.getAutoBuildFeatureProjects()
const hasDevfile = await checkForDevFile(root)
const isPromptedForAutoBuildFeature = Object.keys(autoBuildProjectSetting).includes(root)

if (hasDevfile && !isPromptedForAutoBuildFeature) {
await this.promptUserConsent(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 promptUserConsent(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
*
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/amazonqFeatureDev/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/amazonqFeatureDev/util/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -34,15 +41,19 @@ export async function prepareRepoData(
) {
try {
const files = await collectFiles(repoRootPaths, workspaceFolders, true, maxRepoSizeBytes)
const allowDevCommands = CodeWhispererSettings.instance.getAutoBuildFeatureProjects()
const useAutoBuildFeature = allowDevCommands[repoRootPaths[0]] ?? false

let totalBytes = 0
const ignoredExtensionMap = new Map<string, number>()

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)
Expand Down
Loading

0 comments on commit 07d2d3f

Please sign in to comment.