diff --git a/CHANGELOG.md b/CHANGELOG.md index e61b5fea..be7c57f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +# [0.103.0](https://github.com/getappmap/vscode-appland/compare/v0.102.0...v0.103.0) (2023-10-31) + + +### Features + +* Update @appland/components to 3.9.0 ([fa2cc6e](https://github.com/getappmap/vscode-appland/commit/fa2cc6e56f96b13225d1a865a3972a4495eecf28)) + +# [0.102.0](https://github.com/getappmap/vscode-appland/compare/v0.101.2...v0.102.0) (2023-10-11) + + +### Bug Fixes + +* AppMaps displayed chronologically for Java requests ([#813](https://github.com/getappmap/vscode-appland/issues/813)) ([4f8e730](https://github.com/getappmap/vscode-appland/commit/4f8e730be089000d8c2eb3e28b1226f39efaf0e4)) +* Instructions page should be reactive to the Java agent file ([3bfbcb6](https://github.com/getappmap/vscode-appland/commit/3bfbcb602e53e19285e902a56e427c7f07fe2459)) + + +### Features + +* Accurately determine if Runtime Analysis step is complete ([#829](https://github.com/getappmap/vscode-appland/issues/829)) ([0dd1fa2](https://github.com/getappmap/vscode-appland/commit/0dd1fa2d61e5b7923c67bf0d1e051b2074df3120)) +* The Code Objects view also lists External Service Calls ([d9b9c32](https://github.com/getappmap/vscode-appland/commit/d9b9c325edb40791d94353ccd3541baab1d241fa)) +* Update [@appland](https://github.com/appland) dependencies ([d696a53](https://github.com/getappmap/vscode-appland/commit/d696a5388d158f827c97caf5db4242340d8b44eb)) +* update @appland/components to 3.8.0 ([8b66898](https://github.com/getappmap/vscode-appland/commit/8b668983fe30602f5960836fe1d1120d6b28b31f)) +* **deleteAppMaps.ts:** add closeEditorByUri function after deleting appmaps ([2a54b8c](https://github.com/getappmap/vscode-appland/commit/2a54b8cde5c5b0b5894185cfe63f78c0cd539e4e)) + ## [0.101.2](https://github.com/getappmap/vscode-appland/compare/v0.101.1...v0.101.2) (2023-09-21) diff --git a/extensions.json b/extensions.json new file mode 100644 index 00000000..d54a2f2e --- /dev/null +++ b/extensions.json @@ -0,0 +1 @@ +[{"identifier":{"id":"undefined_publisher.undefined"},"location":{"$mid":1,"fsPath":"/Users/kgilpin/source/appland/vscode-appland/extern","path":"/Users/kgilpin/source/appland/vscode-appland/extern","scheme":"file"},"relativeLocation":"extern"},{"identifier":{"id":"appland.appmap","uuid":"41d86b02-68d3-4049-9422-95da6d11cc2e"},"version":"0.90.1","location":{"$mid":1,"fsPath":"/Users/kgilpin/source/appland/vscode-appland/out","path":"/Users/kgilpin/source/appland/vscode-appland/out","scheme":"file"},"relativeLocation":"out","metadata":{"id":"41d86b02-68d3-4049-9422-95da6d11cc2e","publisherDisplayName":"AppLand","publisherId":"f7f1004e-6038-49cd-a096-4e618fe53f77","isPreReleaseVersion":false}}] \ No newline at end of file diff --git a/package.json b/package.json index 2088242f..94026c71 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "appmap", "displayName": "AppMap", "description": "Interactive maps of runtime code behavior", - "version": "0.101.2", + "version": "0.103.0", "repository": { "type": "git", "url": "https://github.com/getappmap/vscode-appland" @@ -131,6 +131,22 @@ "command": "appmap.openCodeObjectInAppMap", "title": "AppMap: Open Code Object in AppMap" }, + { + "command": "appmap.fixFinding", + "title": "AppMap: Suggest a Fix for an Analysis Finding" + }, + { + "command": "appmap.fixTest", + "title": "AppMap: Suggest a Fix for a Failing Test" + }, + { + "command": "appmap.ask", + "title": "AppMap: Ask a Question About the Current Code Selection" + }, + { + "command": "appmap.generate", + "title": "AppMap: Generate Code Based on the Current Code Selection" + }, { "command": "appmap.touchOutOfDateTestFiles", "title": "AppMap: Touch Out-of-Date Test Files" @@ -145,14 +161,24 @@ }, { "command": "appmap.context.openAsJson", - "title": "AppMap View: Open as JSON", + "title": "Open as JSON", "icon": "$(bracket)" }, { "command": "appmap.context.deleteAppMap", - "title": "AppMap View: Delete AppMap", + "title": "Delete", "icon": "$(trash)" }, + { + "command": "appmap.context.fixTest", + "title": "Fix", + "icon": "$(wrench)" + }, + { + "command": "appmap.context.fixFinding", + "title": "Fix", + "icon": "$(wrench)" + }, { "command": "appmap.context.rename", "title": "AppMap View: Rename AppMap" @@ -376,6 +402,16 @@ "when": "view == appmap.views.appmaps && viewItem == appmap.views.appmaps.appMap", "group": "inline" }, + { + "command": "appmap.context.fixTest", + "when": "view == appmap.views.findings && viewItem == appmap.views.analysis.failedTest", + "group": "inline" + }, + { + "command": "appmap.context.fixFinding", + "when": "view == appmap.views.findings && viewItem == appmap.views.analysis.finding", + "group": "inline" + }, { "command": "appmap.context.openInFileExplorer", "when": "view == appmap.views.appmaps && viewItem == appmap.views.appmaps.appMap" @@ -498,6 +534,7 @@ "diff": "^5.1.0", "jquery": "^3.5.1", "js-yaml": "^4.1.0", + "openai": "^3.3.0", "popper.js": "^1.16.1", "proper-lockfile": "^4.1.2", "semver": "^7.3.5", diff --git a/src/commands/ask.ts b/src/commands/ask.ts new file mode 100644 index 00000000..9493e231 --- /dev/null +++ b/src/commands/ask.ts @@ -0,0 +1,220 @@ +import * as vscode from 'vscode'; +import LineInfoIndex, { LineInfo } from '../services/lineInfoIndex'; +import assert from 'assert'; +import AppMapCollection from '../services/appmapCollection'; +import { promptForAppMap } from '../lib/promptForAppMap'; +import { debug } from 'console'; +import { readFile, writeFile } from 'fs/promises'; +import { AppMap, AppMapFilter, CodeObject, buildAppMap } from '@appland/models'; +import buildOpenAIApi from '../lib/buildOpenAIApi'; +import { join } from 'path'; +import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; +import { randomUUID } from 'crypto'; +import { Completion, Question } from '../lib/ask'; +import selectedCode from '../lib/ask/selectedCode'; +import contextAppMap from '../lib/ask/contextAppMap'; + +class LineInfoQuickPickItem implements vscode.QuickPickItem { + constructor(public lineInfo: LineInfo) {} + + get label(): string { + return [ + `line ${this.lineInfo.line}`, + this.lineInfo.codeObjects?.map((co) => co.fqid).join(', '), + ].join(': '); + } +} + +export default function register( + context: vscode.ExtensionContext, + lineInfoIndex: LineInfoIndex, + appmapCollection: AppMapCollection +): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.ask', async () => { + const { activeTextEditor } = vscode.window; + if (!activeTextEditor) return; + + const selection = selectedCode(activeTextEditor); + if (!selection) return; + + const documentUri = activeTextEditor.document.uri; + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) return; + + const lineInfo = (await lineInfoIndex.lineInfo(documentUri)).filter( + (line) => line.codeObjects + ); + if (lineInfo.length === 0) { + vscode.window.showErrorMessage( + `I couldn't find any AppMaps related to ${documentUri.path}` + ); + return; + } + + const lineCodePrompt = + selection.text.split('\n').length === 0 + ? selection.text + : [selection.text.split('\n')[0], '...'].join(''); + const question = await vscode.window.showInputBox({ + placeHolder: `Ask a question about: ${lineCodePrompt}`, + value: 'How does this code work?', + }); + if (!question) return; + + const distanceFromSelection = (item: LineInfoQuickPickItem): number => + Math.min( + Math.abs(item.lineInfo.line - selection.startLine), + Math.abs(item.lineInfo.line - selection.endLine) + ); + + const lineInfoItems = lineInfo.map((info) => new LineInfoQuickPickItem(info)); + const lineDistance = lineInfoItems.map(distanceFromSelection); + const preferredDistance = lineDistance.sort()[0]; + + const choiceItems: vscode.QuickPickItem[] = [ + ...lineInfoItems.filter( + (lineInfo) => distanceFromSelection(lineInfo) === preferredDistance + ), + { label: '---' }, + ...lineInfoItems.filter( + (lineInfo) => distanceFromSelection(lineInfo) !== preferredDistance + ), + ]; + + const lineAbout = await vscode.window.showQuickPick(choiceItems, { + placeHolder: `Verify the function that you want to ask about`, + }); + if (!lineAbout) return; + if (!(lineAbout instanceof LineInfoQuickPickItem)) return; + + const codeObjectEntry = ((lineAbout as LineInfoQuickPickItem).lineInfo.codeObjects || [])[0]; + assert(codeObjectEntry); + + if (codeObjectEntry.appMapFiles.length === 0) { + console.warn(`No AppMaps for ${codeObjectEntry.fqid}`); + return; + } + + let appMapFileName: string; + if (codeObjectEntry.appMapFiles.length === 1) { + appMapFileName = codeObjectEntry.appMapFiles[0]; + } else { + const appmapFiles = new Set(Object.keys(await codeObjectEntry.appMapMetadata())); + const appmaps = appmapCollection + .allAppMaps() + .filter((appmap) => appmapFiles.has(appmap.descriptor.resourceUri.fsPath)); + + const selectedAppMap = await promptForAppMap(appmaps); + if (!selectedAppMap) return; + + appMapFileName = selectedAppMap.fsPath; + } + + const q = new Question(selection.text, question); + + const appmapUri = vscode.Uri.file(appMapFileName); + { + const data = await readFile(appmapUri.fsPath, 'utf-8'); + const appmap = buildAppMap().source(data).build(); + + let codeObject: CodeObject | undefined; + appmap.classMap.visit((co) => { + if (co.fqid === codeObjectEntry.fqid) codeObject = co; + }); + if (!codeObject) { + vscode.window.showInformationMessage( + `Could not find code object ${codeObjectEntry.fqid} in the AppMap. Maybe it's out of date?` + ); + return; + } + q.codeObject = codeObject; + + const { events } = appmap; + let filterAppMap: AppMap; + { + const codeObjectEvents = events.filter( + (event) => event.codeObject.fqid === codeObjectEntry.fqid + ); + q.returnValues = [ + ...new Set( + codeObjectEvents + .map((e) => { + return e.returnValue + ? [e.returnValue.class, e.returnValue.value].join(': ') + : undefined; + }) + .filter(Boolean) + ), + ] as string[]; + filterAppMap = contextAppMap(appmap, codeObjectEvents); + + q.scopeCodeObjects = [...new Set(appmap.rootEvents().map((e) => e.codeObject))]; + } + + { + const filterAppMapData = JSON.stringify(filterAppMap, null, 2); + let appmapName = ['ask_appmap', randomUUID()].join('-'); // question.replace(/[^a-zA-Z0-9]/g, '-'); KEG: Can result in a too-long file name. + if (appmapName.endsWith('-')) appmapName = appmapName.slice(0, -1); + const filePath = join(workspaceFolder?.uri.fsPath, `${appmapName}.appmap.json`); + await writeFile(filePath, filterAppMapData); + const uri = vscode.Uri.file(filePath); + await vscode.commands.executeCommand('vscode.openWith', uri, 'appmap.views.appMapFile'); + } + + const specification = Specification.build(filterAppMap, { loops: true }); + assert(appmapUri); + let sequenceDiagramAppMap: AppMap; + { + const sequenceDiagramFilter = new AppMapFilter(); + if (appmap.metadata.language?.name !== 'java') + sequenceDiagramFilter.declutter.hideExternalPaths.on = true; + sequenceDiagramAppMap = sequenceDiagramFilter.filter(filterAppMap, []); + } + const diagram = buildDiagram(appmapUri.fsPath, sequenceDiagramAppMap, specification); + q.sequenceDiagram = format(FormatType.PlantUML, diagram, appmapUri.fsPath).diagram; + } + + const openAI = await buildOpenAIApi(context); + if (!openAI) return; + + let completion: Completion | undefined; + await vscode.window.withProgress( + { + title: `Thinking about your question...`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + try { + completion = await q.complete(openAI); + } catch (e) { + debug(e); + vscode.window.showErrorMessage(`Unable to process your question: ${e}`); + } + } + ); + if (!completion) return; + + const fence = '```'; + const responseText = [ + `## ${lineCodePrompt}`, + `### You asked`, + `${fence}${question}${fence}`, + `### AI response:`, + completion.response, + `## Prompt +
+Click to expand + +${fence} +${completion.prompt} +${fence} +
`, + ].join('\n\n'); + const newDocument = await vscode.workspace.openTextDocument({ + content: responseText, + }); + await vscode.window.showTextDocument(newDocument); + }) + ); +} diff --git a/src/commands/fixFinding.ts b/src/commands/fixFinding.ts new file mode 100644 index 00000000..d1da3a49 --- /dev/null +++ b/src/commands/fixFinding.ts @@ -0,0 +1,163 @@ +import * as vscode from 'vscode'; +import { ResolvedFinding } from '../services/resolvedFinding'; +import AnalysisManager from '../services/analysisManager'; +import { + ChatCompletionRequestMessage, + ChatCompletionRequestMessageRoleEnum, + Configuration, + OpenAIApi, +} from 'openai'; +import assert from 'assert'; +import { Event, buildAppMap } from '@appland/models'; +import { readFile } from 'fs/promises'; +import loadSnippet, { DEFAULT_SPAN } from '../lib/snippet'; +import { suggestFix } from '../lib/suggestFix'; +import { debug } from 'console'; + +export class FindingPickItem implements vscode.QuickPickItem { + constructor(public finding: ResolvedFinding) {} + + get label(): string { + return [this.finding.finding.ruleTitle, `(${this.finding.finding.hash_v2.slice(0, 8)})`].join( + ' ' + ); + } +} + +export async function fixFinding(finding: ResolvedFinding, openAI: OpenAIApi) { + const appmap = buildAppMap() + .source(await readFile(finding.finding.appMapFile, 'utf-8')) + .build(); + const language = appmap.metadata?.language?.name; + + const scopeEvent = appmap.events[finding.finding.scope.id - 1]; + const event = appmap.events[finding.finding.event.id - 1]; + const stackFunctions: string[] = []; + { + let parent: Event | undefined = event; + while (parent) { + stackFunctions.push(parent.codeObject.fqid); + parent = parent.parent; + } + } + + let participatingEvents: Event[] = Object.values(finding.finding.participatingEvents || {}); + if (participatingEvents.length === 0) participatingEvents = [finding.finding.event]; + + const locations = [ + ...participatingEvents.map((event) => [event.path, event.lineno].filter(Boolean).join(':')), + ...finding.finding.stack, + ]; + + const snippetLocations = new Set(); + const codeSnippets = new Array(); + for (const location of locations) { + if (snippetLocations.has(location)) continue; + + // TODO: Determine the function span more accurately + const snippet = await loadSnippet(finding.folder, location, 0, DEFAULT_SPAN * 2); + if (!snippet) continue; + + if (snippet) snippetLocations.add(location); + codeSnippets.push(['', `Source file: ${snippet.path}`, snippet.lines.join('\n')].join('\n')); + } + + const systemMessages: ChatCompletionRequestMessage[] = [ + { + content: `You are a software developer fixing problems in a codebase`, + role: 'system', + }, + ]; + if (language) + systemMessages.push({ content: `Programming language: ${language}`, role: 'system' }); + + let relatedReferences: string[] = []; + if (finding.rule.references && Object.keys(finding.rule.references).length > 0) + relatedReferences = Object.entries(finding.rule.references || {}) + .map(([key, value]) => [key, value].join(' ')) + .map((reference) => `Related reference: ${reference}`); + + const userMessages: ChatCompletionRequestMessage[] = [ + `The code contains a ${finding.rule.impactDomain} problem: ${finding.rule.title}`, + `Specifically: ${finding.finding.message}`, + ...relatedReferences, + `The problem occurs within ${scopeEvent.codeObject.fqid}. It's related to the following code:`, + ...codeSnippets, + ].map((message) => ({ + content: message, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + })); + userMessages.push({ + content: `Describe the problem in the style of a code review comment, then suggest a fixed version of the code by generating one or more complete ${ + language ? language : '' + } functions`, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + }); + + await suggestFix(openAI, finding.rule.title, systemMessages, userMessages); +} + +export default function register(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.fixFinding', async (findingHash?: string) => { + const findingsIndex = AnalysisManager.findingsIndex; + if (!findingsIndex) return; + + if (!findingHash) { + const uniqueFindingHashes = new Set(); + const findings = ( + findingsIndex + .findings() + .map((finding) => { + if (uniqueFindingHashes.has(finding.finding.hash_v2)) return; + + uniqueFindingHashes.add(finding.finding.hash_v2); + return finding; + }) + + .filter(Boolean) as ResolvedFinding[] + ).sort((a, b) => a.rule.title.localeCompare(b.rule.title)); + + const choices = findings.map((finding) => new FindingPickItem(finding)); + + const selection = await vscode.window.showQuickPick(choices); + if (!selection) return; + + findingHash = selection.finding.finding.hash_v2; + } + if (!findingHash) return; + + const findings = findingsIndex.findingsByHash(findingHash); + if (!findings || findings.length === 0) return; + + const finding = findings[0]; + assert(finding); + + let gptKey = await context.secrets.get('openai.gptKey'); + if (!gptKey) { + gptKey = await vscode.window.showInputBox({ title: `Enter your OpenAI API key` }); + if (!gptKey) return; + + await context.secrets.store('openai.gptKey', gptKey); + } + + const openAI = new OpenAIApi(new Configuration({ apiKey: gptKey })); + + await vscode.window.withProgress( + { + title: `Analyzing: ${finding.rule.title}`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + assert(findingHash); + try { + await fixFinding(finding, openAI); + } catch (e) { + debug(e); + vscode.window.showErrorMessage(`Failed to analyze finding: ${e}`); + } + } + ); + }) + ); +} diff --git a/src/commands/fixTest.ts b/src/commands/fixTest.ts new file mode 100644 index 00000000..08d94a3b --- /dev/null +++ b/src/commands/fixTest.ts @@ -0,0 +1,197 @@ +import * as vscode from 'vscode'; +import AppMapCollection from '../services/appmapCollection'; +import AppMapLoader from '../services/appmapLoader'; +import buildOpenAIApi from '../lib/buildOpenAIApi'; +import assert from 'assert'; +import { + ChatCompletionRequestMessage, + ChatCompletionRequestMessageRoleEnum, + OpenAIApi, +} from 'openai'; +import loadSnippet, { DEFAULT_SPAN } from '../lib/snippet'; +import filesUnknownToGit from '../lib/filesUnknownToGit'; +import filesModifiedInGit from '../lib/filesModifiedInGit'; +import { suggestFix } from '../lib/suggestFix'; +import { debug } from 'console'; +import { isAbsolute } from 'path'; + +export class AppMapPickItem implements vscode.QuickPickItem { + constructor(public appmap: AppMapLoader) {} + + get label(): string { + return this.appmap.descriptor.metadata?.name || ''; + } +} + +async function fixFailedTest(appmapLoader: AppMapLoader, openAI: OpenAIApi) { + const appmap = await appmapLoader.loadAppMap(); + const language = appmap.metadata?.language?.name; + + const folder = vscode.workspace.workspaceFolders?.find((folder) => + appmapLoader.descriptor.resourceUri.fsPath.startsWith(folder.uri.fsPath) + ); + if (!folder) return; + + const systemMessages: ChatCompletionRequestMessage[] = [ + { + content: `You are a software developer fixing problems in a codebase`, + role: 'system', + }, + ]; + if (language) + systemMessages.push({ content: `Programming language: ${language}`, role: 'system' }); + + if (appmap.metadata.source_location) { + const snippet = await loadSnippet(folder, appmap.metadata.source_location, 0, DEFAULT_SPAN * 2); + if (snippet) { + const content = [ + `Test case${snippet.lineno ? ' begins at line ' + snippet.lineno : ''}: ${snippet.path}`, + '', + snippet.lines.join('\n'), + ].join('\n'); + systemMessages.push({ content, role: 'system' }); + } + } + + const userMessages: ChatCompletionRequestMessage[] = []; + if (appmap.metadata.test_failure?.message) { + userMessages.push({ + content: `Test failed: ${appmap.metadata.test_failure.message}`, + role: 'user', + }); + } + + if (appmap.metadata.test_failure?.location) { + const snippet = await loadSnippet(folder, appmap.metadata.test_failure.location); + if (snippet) { + const content = [ + '', + `Test failure${snippet.lineno ? ' occurred at line ' + snippet.lineno : ''}: ${ + snippet.path + }`, + snippet.lines.join('\n'), + ].join('\n'); + userMessages.push({ content, role: 'user' }); + } + } + + const uniqueExceptions = new Set(); + for (const exceptionEvent of appmap.events.filter( + (event) => event.exceptions && event.exceptions.length + )) { + for (const exception of exceptionEvent.exceptions) { + const location = [exception.path, exception.lineno].filter(Boolean).join(':'); + const exceptionId = [exception.class, location].filter(Boolean).join(':'); + if (uniqueExceptions.has(exceptionId)) continue; + + uniqueExceptions.add(exceptionId); + + const snippet = await loadSnippet(folder, location); + if (!snippet) continue; + + const content = [ + '', + `Exception ${snippet.lineno ? ' occurred at line ' + snippet.lineno : ''}: ${snippet.path}`, + snippet.lines.join('\n'), + ].join('\n'); + userMessages.push({ content, role: 'user' }); + } + } + + const outOfDateFiles = new Set([ + ...(await filesUnknownToGit(folder.uri.fsPath)), + ...(await filesModifiedInGit(folder.uri.fsPath)), + ]); + const snippetLocations = new Set(); + const snippetMessages: string[] = []; + for (const event of appmap.events) { + if (!(event.path && event.lineno)) continue; + + let { path } = event; + if (isAbsolute(path) && path.startsWith(folder.uri.fsPath)) + path = path.slice(folder.uri.fsPath.length + 1); + + if (!outOfDateFiles.has(event.path)) continue; + + const location = [path, event.lineno].join(':'); + if (snippetLocations.has(location)) continue; + snippetLocations.add(location); + + const snippet = await loadSnippet(folder, location, 0, DEFAULT_SPAN); + if (!snippet) continue; + + const content = [ + '', + `Code executed ${snippet.lineno ? ' at line ' + snippet.lineno : ''}: ${snippet.path}`, + snippet.lines.join('\n'), + ].join('\n'); + snippetMessages.push(content); + } + + userMessages.push( + ...snippetMessages + .slice(0, 4) // TODO: Limiting to 4 of these for now, to stay under the token limit + .map((content) => ({ content, role: 'user' as ChatCompletionRequestMessageRoleEnum })) + ); + + userMessages.push({ + content: `Analyze the problem, then provide a fixed version of the code, by generating one or more complete ${ + language ? language : '' + } functions`, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + }); + + const title = appmap.metadata.test_failure?.message || appmap.metadata.name || 'Test failure'; + await suggestFix(openAI, title, systemMessages, userMessages); +} + +export default function register( + context: vscode.ExtensionContext, + appmapCollection: AppMapCollection +): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.fixTest', async (appmapUri?: vscode.Uri) => { + let appmap: AppMapLoader | undefined; + if (appmapUri) { + appmap = appmapCollection + .allAppMaps() + .find((appmap) => appmap.descriptor.resourceUri === appmapUri); + } else { + const choices = ( + appmapCollection + .allAppMaps() + .filter( + (appmap) => appmap.descriptor.metadata?.test_status === 'failed' + ) as AppMapLoader[] + ) + .map((appmap) => new AppMapPickItem(appmap)) + .sort((a, b) => a.label.localeCompare(b.label)); + + const selection = await vscode.window.showQuickPick(choices); + if (!selection) return; + + appmap = selection.appmap; + } + if (!appmap) return; + + const openAI = await buildOpenAIApi(context); + if (!openAI) return; + + await vscode.window.withProgress( + { + title: `Analyzing failed test`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + assert(appmap); + try { + await fixFailedTest(appmap, openAI); + } catch (e) { + debug(e); + vscode.window.showErrorMessage(`Failed to analyze failed test: ${e}`); + } + } + ); + }) + ); +} diff --git a/src/commands/generate.ts b/src/commands/generate.ts new file mode 100644 index 00000000..6645635a --- /dev/null +++ b/src/commands/generate.ts @@ -0,0 +1,129 @@ +import * as vscode from 'vscode'; +import AppMapCollection from '../services/appmapCollection'; +import selectedCode from '../lib/ask/selectedCode'; +import { promptForAppMap } from '../lib/promptForAppMap'; +import { CodeGen, Completion } from '../lib/ask'; +import assert from 'assert'; +import buildOpenAIApi from '../lib/buildOpenAIApi'; +import { FormatType, Specification, buildDiagram, format } from '@appland/sequence-diagram'; +import { AppMap, AppMapFilter, buildAppMap } from '@appland/models'; +import contextAppMap from '../lib/ask/contextAppMap'; +import { readFile, writeFile } from 'fs/promises'; +import { randomUUID } from 'crypto'; +import { join } from 'path'; +import { debug } from 'console'; + +export default function register( + context: vscode.ExtensionContext, + appmapCollection: AppMapCollection +): void { + context.subscriptions.push( + vscode.commands.registerCommand('appmap.generate', async () => { + const documentUri = vscode.window.activeTextEditor?.document.uri; + if (!documentUri) return; + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(documentUri); + if (!workspaceFolder) return; + + const selection = selectedCode(vscode.window.activeTextEditor); + if (!selection) return; + + const lineCodePrompt = + selection.text.split('\n').length === 0 + ? selection.text + : [selection.text.split('\n')[0], '...'].join(''); + const question = await vscode.window.showInputBox({ + placeHolder: `Generated code related to: ${lineCodePrompt}`, + }); + if (!question) return; + + const appmaps = appmapCollection.allAppMaps(); + const appmapUri = await promptForAppMap(appmaps); + if (!appmapUri) return; + + const g = new CodeGen(selection.text, question); + + { + const appmapEntry = appmaps.find((appmap) => appmap.descriptor.resourceUri === appmapUri); + assert(appmapEntry); + + if (appmapEntry.descriptor.metadata?.language?.name) + g.language = appmapEntry.descriptor.metadata.language.name; + } + + let filterAppMap: AppMap; + { + const data = await readFile(appmapUri.fsPath, 'utf-8'); + const appmap = buildAppMap().source(data).build(); + + filterAppMap = contextAppMap(appmap, appmap.rootEvents()); + + g.scopeCodeObjects = [...new Set(appmap.rootEvents().map((e) => e.codeObject))]; + } + + { + const filterAppMapData = JSON.stringify(filterAppMap, null, 2); + let appmapName = ['ask_appmap', randomUUID()].join('-'); // question.replace(/[^a-zA-Z0-9]/g, '-'); KEG: Can result in a too-long file name. + if (appmapName.endsWith('-')) appmapName = appmapName.slice(0, -1); + const filePath = join(workspaceFolder.uri.fsPath, `${appmapName}.appmap.json`); + await writeFile(filePath, filterAppMapData); + const uri = vscode.Uri.file(filePath); + await vscode.commands.executeCommand('vscode.openWith', uri, 'appmap.views.appMapFile'); + } + + const specification = Specification.build(filterAppMap, { loops: true }); + assert(appmapUri); + let sequenceDiagramAppMap: AppMap; + { + const sequenceDiagramFilter = new AppMapFilter(); + if (filterAppMap.metadata.language?.name !== 'java') + sequenceDiagramFilter.declutter.hideExternalPaths.on = true; + sequenceDiagramAppMap = sequenceDiagramFilter.filter(filterAppMap, []); + } + const diagram = buildDiagram(appmapUri.fsPath, sequenceDiagramAppMap, specification); + g.sequenceDiagram = format(FormatType.PlantUML, diagram, appmapUri.fsPath).diagram; + + const openAI = await buildOpenAIApi(context); + if (!openAI) return; + + let completion: Completion | undefined; + await vscode.window.withProgress( + { + title: `Generating code...`, + location: vscode.ProgressLocation.Notification, + }, + async () => { + try { + completion = await g.complete(openAI); + } catch (e) { + if (e instanceof Error) debug(e.toString()); + else debug(`${e}`); + vscode.window.showErrorMessage(`Unable to process your question: ${e}`); + } + } + ); + if (!completion) return; + + const fence = '```'; + const responseText = [ + `## ${lineCodePrompt}`, + `### You asked`, + `${fence}${question}${fence}`, + `### AI response:`, + completion.response, + `## Prompt +
+Click to expand + +${fence} +${completion.prompt} +${fence} +
`, + ].join('\n\n'); + const newDocument = await vscode.workspace.openTextDocument({ + content: responseText, + }); + await vscode.window.showTextDocument(newDocument); + }) + ); +} diff --git a/src/extension.ts b/src/extension.ts index 24bda976..ff0ee764 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -58,6 +58,10 @@ import getAppmapDir from './commands/getAppmapDir'; import JavaAssets from './services/javaAssets'; import checkAndTriggerFirstAppMapNotification from './lib/firstAppMapNotification'; import Watcher from './services/watcher'; +import { default as fixFinding } from './commands/fixFinding'; +import { default as fixTest } from './commands/fixTest'; +import { default as ask } from './commands/ask'; +import { default as generate } from './commands/generate'; export async function activate(context: vscode.ExtensionContext): Promise { Telemetry.register(context); @@ -192,6 +196,11 @@ export async function activate(context: vscode.ExtensionContext): Promise { + const propertyBasedUserMessages: string[] = [`A code snippet is: ${this.selectedCode}`]; + if (this.codeObject) + propertyBasedUserMessages.push( + `The code snippet belongs to a function called: ${this.codeObject.fqid}` + ); + if (this.returnValues && this.returnValues.length > 0) + propertyBasedUserMessages.push(`The code snippet returns ${this.returnValues.join(' or ')}`); + if (this.scopeCodeObjects && this.scopeCodeObjects.length > 0) + propertyBasedUserMessages.push( + `The code snippet is used by higher-level functions such as ${this.scopeCodeObjects + .map((co) => co.fqid) + .join(', ')}` + ); + if (this.sequenceDiagram) + propertyBasedUserMessages.push( + `A PlantUML sequence diagram about this code flow is: ${this.sequenceDiagram}` + ); + + const systemMessages: ChatCompletionRequestMessage[] = this.systemMessages.map((message) => ({ + content: message, + role: 'system' as ChatCompletionRequestMessageRoleEnum, + })); + + const userMessages: ChatCompletionRequestMessage[] = [ + ...propertyBasedUserMessages, + ...this.userMessages, + ].map((message) => ({ + content: message, + role: 'user' as ChatCompletionRequestMessageRoleEnum, + })); + + const messages = [...systemMessages, ...userMessages]; + try { + const result = await ai.createChatCompletion({ + model: this.model, + messages, + n: 1, + max_tokens: + // 16384 is the maximum number of tokens allowed by gpt-3.5-turbo-16k + 16384 - + // This is a conservative estimate of the number of tokens in the prompt, since + // a token can be more than one character. + (messages.map((msg) => msg.content?.length).filter(Boolean) as number[]).reduce( + (a, b) => a + b, + 0 + ), + }); + const response = result.data.choices + .map((choice) => (assert(choice.message), choice.message.content)) + .filter(Boolean) + .join('\n'); + return { + prompt: messages.map((msg) => [msg.role, msg.content].join(': ')).join('\n'), + response, + }; + } catch (e) { + warn(e); + return; + } + } + + protected abstract get systemMessages(): string[]; + + protected abstract get userMessages(): string[]; +} + +export class Question extends Ask { + public constructor(public readonly selectedCode: string, public readonly question: string) { + super(selectedCode, question); + } + + protected get userMessages(): string[] { + return [`Answer the following question about the code: "${this.question}"`]; + } + + protected get systemMessages(): string[] { + return [ + `You are a software developer explaining how code works.`, + `Be as assertive as possible. Make your best guess based on the information you have available.`, + `Try to include URL routes, SQL queries, function names, and code snippets.`, + `Use a list format for the answer.`, + `Do not discuss software development in general terms.`, + `Do not make statements like "However, without seeing the complete codebase...".`, + ]; + } +} + +export class CodeGen extends Ask { + public constructor(public readonly selectedCode: string, public readonly question: string) { + super(selectedCode, question); + } + + protected get userMessages(): string[] { + return [`Generate code to: "${this.question}"`]; + } + + protected get systemMessages(): string[] { + const messages = [ + `You are a software developer writing code.`, + `Be as assertive as possible. Make your best guess based on the information you have available.`, + `Respond with code that is syntactically correct.`, + ]; + if (this.language) messages.push(`Respond with code in ${this.language}.`); + + return messages; + } +} diff --git a/src/lib/ask/contextAppMap.ts b/src/lib/ask/contextAppMap.ts new file mode 100644 index 00000000..79b6a07e --- /dev/null +++ b/src/lib/ask/contextAppMap.ts @@ -0,0 +1,47 @@ +import { AppMap, Event, EventNavigator, buildAppMap } from '@appland/models'; + +export default function contextAppMap(appmap: AppMap, scopeEvents: Event[]): AppMap { + const { events } = appmap; + + const roots: Event[] = []; + const filterEvents: Event[] = [...scopeEvents]; + for (const event of scopeEvents) { + for (const ancestor of new EventNavigator(event).ancestors()) { + filterEvents.push(ancestor.event); + if (ancestor.event.httpServerRequest) { + roots.push(ancestor.event); + break; + } + if (!ancestor.event.parent) roots.push(ancestor.event); + } + for (const child of event.children) { + filterEvents.push(child); + for (const child2 of child.children) { + filterEvents.push(child2); + for (const child3 of child2.children) { + filterEvents.push(child3); + for (const child4 of child3.children) filterEvents.push(child4); + } + } + } + } + for (const root of roots) { + for (const child of root.children) { + filterEvents.push(child); + for (const child2 of child.children) { + filterEvents.push(child2); + for (const child3 of child2.children) { + filterEvents.push(child3); + for (const child4 of child3.children) filterEvents.push(child4); + } + } + } + } + + const eventIds = new Set(filterEvents.map((e) => e.id)); + return buildAppMap({ + events: events.filter((e) => eventIds.has(e.callEvent.id)), + classMap: appmap.classMap.roots.map((c) => ({ ...c.data })), + metadata: appmap.metadata, + }).build(); +} diff --git a/src/lib/ask/selectedCode.ts b/src/lib/ask/selectedCode.ts new file mode 100644 index 00000000..d874029c --- /dev/null +++ b/src/lib/ask/selectedCode.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; + +export type Selection = { + startLine: number; + endLine: number; + text: string; +}; + +export default function selectedCode(textEditor?: vscode.TextEditor): Selection | undefined { + if (!textEditor) return; + + const { selection } = textEditor; + if (!selection) return; + + const startLine = selection.anchor.line; + const endLine = selection.active.line; + let selectedCode = textEditor.document.getText(selection).trim(); + if (selectedCode === '') selectedCode = textEditor.document.lineAt(startLine).text.trim(); + return { startLine, endLine, text: selectedCode }; +} diff --git a/src/lib/buildOpenAIApi.ts b/src/lib/buildOpenAIApi.ts new file mode 100644 index 00000000..5ce2ceaa --- /dev/null +++ b/src/lib/buildOpenAIApi.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode'; + +import { Configuration, OpenAIApi } from 'openai'; + +export default async function buildOpenAIApi( + context: vscode.ExtensionContext +): Promise { + let gptKey = await context.secrets.get('openai.gptKey'); + if (!gptKey) { + gptKey = await vscode.window.showInputBox({ title: `Enter your OpenAI API key` }); + if (!gptKey) return; + + await context.secrets.store('openai.gptKey', gptKey); + } + + return new OpenAIApi(new Configuration({ apiKey: gptKey })); +} diff --git a/src/lib/filesModifiedInGit.ts b/src/lib/filesModifiedInGit.ts new file mode 100644 index 00000000..d49283a4 --- /dev/null +++ b/src/lib/filesModifiedInGit.ts @@ -0,0 +1,11 @@ +import { exec } from 'child_process'; + +export default async function filesModifiedInGit(cwd: string): Promise { + return new Promise((resolve, reject) => { + exec(`git ls-files -m`, { cwd }, (err, stdout) => { + if (err && err.code && err.code > 0) reject(err.code); + + resolve(stdout.trim().split('\n')); + }); + }); +} diff --git a/src/lib/filesUnknownToGit.ts b/src/lib/filesUnknownToGit.ts new file mode 100644 index 00000000..20543b7e --- /dev/null +++ b/src/lib/filesUnknownToGit.ts @@ -0,0 +1,11 @@ +import { exec } from 'child_process'; + +export default async function filesUnknownToGit(cwd: string): Promise { + return new Promise((resolve, reject) => { + exec(`git ls-files -o --exclude-standard`, { cwd }, (err, stdout) => { + if (err && err.code && err.code > 0) reject(err.code); + + resolve(stdout.trim().split('\n')); + }); + }); +} diff --git a/src/lib/snippet.ts b/src/lib/snippet.ts new file mode 100644 index 00000000..b9da05a1 --- /dev/null +++ b/src/lib/snippet.ts @@ -0,0 +1,60 @@ +import * as vscode from 'vscode'; + +import { isAbsolute } from 'path'; + +export const DEFAULT_SPAN = 10; + +export type Snippet = { + path: string; + lineno?: number; + lines: string[]; +}; + +export default async function loadSnippet( + folder: vscode.WorkspaceFolder, + location: string, + behindSpan = DEFAULT_SPAN, + aheadSpan = DEFAULT_SPAN +): Promise { + const [path, lineStr] = location.split(':'); + let lineno: number | undefined; + if (lineStr) lineno = parseInt(lineStr, 10); + + let filePath = path; + if (isAbsolute(filePath) && filePath.startsWith(folder.uri.fsPath)) + filePath = filePath.slice(folder.uri.fsPath.length + 1); + + if (isAbsolute(filePath) || filePath.startsWith('node_modules') || filePath.startsWith('vendor')) + return; + + const fileUri = vscode.Uri.joinPath(folder.uri, filePath); + let fileStat: vscode.FileStat; + let exists = false; + try { + fileStat = await vscode.workspace.fs.stat(fileUri); + exists = fileStat.type === vscode.FileType.File; + } catch (e) { + // File doesn't exist + } + + if (!exists) return; + + const fileContents = await vscode.workspace.fs.readFile(fileUri); + const lines = fileContents.toString().split('\n'); + let snippet: string[]; + if (lineno) { + const startLine = Math.max(lineno - behindSpan - 1, 0); + const endLine = Math.min(lineno + aheadSpan - 1, lines.length); + snippet = lines + .slice(startLine, endLine) + .map((line, index) => `${index + startLine + 1}: ${line}`); + } else { + snippet = lines; + } + + return { + path: filePath, + lineno, + lines: snippet, + }; +} diff --git a/src/lib/suggestFix.ts b/src/lib/suggestFix.ts new file mode 100644 index 00000000..1d5273b4 --- /dev/null +++ b/src/lib/suggestFix.ts @@ -0,0 +1,58 @@ +import * as vscode from 'vscode'; +import assert from 'assert'; +import { ChatCompletionRequestMessage, CreateChatCompletionResponse, OpenAIApi } from 'openai'; + +const MAX_TITLE = 30; + +export async function suggestFix( + openAI: OpenAIApi, + title: string, + systemMessages: ChatCompletionRequestMessage[], + userMessages: ChatCompletionRequestMessage[] +) { + const messages = [...systemMessages, ...userMessages]; + + let response: CreateChatCompletionResponse; + try { + const result = await openAI.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages, + n: 1, + max_tokens: + // 4096 is the maximum number of tokens allowed by gpt-3.5-turbo + 4096 - + // This is a conservative estimate of the number of tokens in the prompt, since + // a token can be more than one character. + (messages.map((msg) => msg.content?.length).filter(Boolean) as number[]).reduce( + (a, b) => a + b, + 0 + ), + }); + response = result.data; + } catch (e) { + if (e instanceof Error) vscode.window.showErrorMessage(e.message); + else vscode.window.showErrorMessage(`Failed to analyze failed test: ${e}`); + return; + } + + const promptContent = [ + '```', + ...messages.map((msg) => [msg.role, msg.content].join(': ')), + '```', + ].join('\n'); + const responseContent = response.choices + .filter((choice) => choice.message) + .map((choice) => (assert(choice.message), choice.message.content)) + .filter(Boolean) + .join('\n'); + + title = title.replaceAll('\n', ' '); + if (title.length > MAX_TITLE) title = title.slice(0, MAX_TITLE) + '...'; + + const newDocument = await vscode.workspace.openTextDocument({ + content: [`## Analysis of '${title}'`, responseContent, `## Prompt`, promptContent].join( + '\n\n' + ), + }); + vscode.window.showTextDocument(newDocument); +} diff --git a/src/services/findingLocation.ts b/src/services/findingLocation.ts index 4a3d48b5..520afcff 100644 --- a/src/services/findingLocation.ts +++ b/src/services/findingLocation.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { Finding } from '@appland/scanner/built/cli'; +import { Finding } from '@appland/scanner'; export type FindingLocation = { finding: Finding; diff --git a/src/services/findingsIndex.ts b/src/services/findingsIndex.ts index ff8ffb2f..5578cb8b 100644 --- a/src/services/findingsIndex.ts +++ b/src/services/findingsIndex.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { Finding } from '@appland/scanner/built/cli'; +import { Finding } from '@appland/scanner'; import { ResolvedFinding } from './resolvedFinding'; import { debuglog, promisify } from 'util'; import { readFile } from 'fs'; diff --git a/src/services/lineInfoIndex.ts b/src/services/lineInfoIndex.ts index 6fc78105..ca2fdac7 100644 --- a/src/services/lineInfoIndex.ts +++ b/src/services/lineInfoIndex.ts @@ -3,7 +3,7 @@ import * as vscode from 'vscode'; import ClassMapIndex from './classMapIndex'; import { CodeObjectEntry } from '../lib/CodeObjectEntry'; -class LineInfo { +export class LineInfo { public codeObjects?: CodeObjectEntry[]; constructor(public line: number) {} diff --git a/src/tree/findingsTreeDataProvider.ts b/src/tree/findingsTreeDataProvider.ts index c5d52740..c6f1a6e3 100644 --- a/src/tree/findingsTreeDataProvider.ts +++ b/src/tree/findingsTreeDataProvider.ts @@ -153,6 +153,23 @@ export class FindingsTreeDataProvider ); appmaps.onUpdated(() => this._onDidChangeTreeData.fire(undefined)); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'appmap.context.fixTest', + async (item: FailedTestTreeItem) => { + vscode.commands.executeCommand('appmap.fixTest', item.appmap.descriptor.resourceUri); + } + ) + ); + context.subscriptions.push( + vscode.commands.registerCommand( + 'appmap.context.fixFinding', + async (item: FindingTreeItem) => { + vscode.commands.executeCommand('appmap.fixFinding', item.finding.finding.hash_v2); + } + ) + ); } public setFindingsIndex(findingsIndex?: FindingsIndex): void { diff --git a/test/integration/lib/indexJanitor.test.ts b/test/integration/lib/indexJanitor.test.ts index 9eadfadd..5eda19fc 100644 --- a/test/integration/lib/indexJanitor.test.ts +++ b/test/integration/lib/indexJanitor.test.ts @@ -20,6 +20,7 @@ describe('AppMapIndex', () => { beforeEach(async () => (extension = await waitForExtension())); afterEach(initializeWorkspace); + // TODO: Restore this test once the IndexJanitor has been fixed or this test has been fixed. xit('cleans up index directories', async () => { const appmapFiles = await findFiles(`tmp/appmap/**/*.appmap.json`); const indexDirs = appmapFiles.map(({ fsPath }) => fsPath.replace(/\.appmap\.json$/, '')); diff --git a/yarn.lock b/yarn.lock index ce7b5bcf..25ecb464 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2829,6 +2829,7 @@ __metadata: mocha-suppress-logs: ^0.3.1 mockery: ^2.1.0 node-libs-browser: ^2.2.1 + openai: ^3.3.0 openapi-types: ^11.0.1 popper.js: ^1.16.1 prettier: ^2.8.4 @@ -3125,6 +3126,15 @@ __metadata: languageName: node linkType: hard +"axios@npm:^0.26.0": + version: 0.26.1 + resolution: "axios@npm:0.26.1" + dependencies: + follow-redirects: ^1.14.8 + checksum: d9eb58ff4bc0b36a04783fc9ff760e9245c829a5a1052ee7ca6013410d427036b1d10d04e7380c02f3508c5eaf3485b1ae67bd2adbfec3683704745c8d7a6e1a + languageName: node + linkType: hard + "axios@npm:^0.27.2": version: 0.27.2 resolution: "axios@npm:0.27.2" @@ -6332,7 +6342,7 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.14.9": +"follow-redirects@npm:^1.14.4, follow-redirects@npm:^1.14.8, follow-redirects@npm:^1.14.9": version: 1.15.2 resolution: "follow-redirects@npm:1.15.2" peerDependenciesMeta: @@ -9853,6 +9863,16 @@ __metadata: languageName: node linkType: hard +"openai@npm:^3.3.0": + version: 3.3.0 + resolution: "openai@npm:3.3.0" + dependencies: + axios: ^0.26.0 + form-data: ^4.0.0 + checksum: 28ccff8c09b6f47828c9583bb3bafc38a8459c76ea10eb9e08ca880f65523c5a9cc6c5f3c7669dded6f4c93e7cf49dd5c4dbfd12732a0f958c923117740d677b + languageName: node + linkType: hard + "openapi-diff@npm:^0.23.5, openapi-diff@npm:^0.23.6": version: 0.23.6 resolution: "openapi-diff@npm:0.23.6"