diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index 4f4e0079250..a3497c557f5 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -958,6 +958,10 @@ "command": "codeQLQueryHistory.copyRepoList", "title": "Copy Repository List" }, + { + "command": "codeQLQueryHistory.viewAutofixes", + "title": "View Autofixes" + }, { "command": "codeQLQueryResults.down", "title": "CodeQL: Navigate Down in Local Result Viewer" @@ -1296,6 +1300,11 @@ "group": "1_queryHistory@1", "when": "viewItem == remoteResultsItem" }, + { + "command": "codeQLQueryHistory.viewAutofixes", + "group": "1_queryHistory@2", + "when": "viewItem == remoteResultsItem && config.codeQL.canary" + }, { "command": "codeQLQueries.runLocalQueryFromQueriesPanel", "group": "inline", @@ -1706,6 +1715,10 @@ "command": "codeQLQueryHistory.copyRepoList", "when": "false" }, + { + "command": "codeQLQueryHistory.viewAutofixes", + "when": "false" + }, { "command": "codeQLQueryHistory.showQueryText", "when": "false" diff --git a/extensions/ql-vscode/src/common/commands.ts b/extensions/ql-vscode/src/common/commands.ts index fcaf2eb72d8..cfcfabb3f85 100644 --- a/extensions/ql-vscode/src/common/commands.ts +++ b/extensions/ql-vscode/src/common/commands.ts @@ -197,6 +197,7 @@ export type QueryHistoryCommands = { "codeQLQueryHistory.itemClicked": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.openOnGithub": TreeViewContextMultiSelectionCommandFunction; "codeQLQueryHistory.copyRepoList": TreeViewContextMultiSelectionCommandFunction; + "codeQLQueryHistory.viewAutofixes": TreeViewContextMultiSelectionCommandFunction; // Commands in the command palette "codeQL.exportSelectedVariantAnalysisResults": () => Promise; diff --git a/extensions/ql-vscode/src/common/interface-types.ts b/extensions/ql-vscode/src/common/interface-types.ts index 2a0fb24c811..eca0779966a 100644 --- a/extensions/ql-vscode/src/common/interface-types.ts +++ b/extensions/ql-vscode/src/common/interface-types.ts @@ -163,6 +163,25 @@ interface SetUserSettingsMsg { userSettings: UserSettings; } +export interface VariantAnalysisUserSettings { + /** Whether to display the "View Autofixes" button. */ + shouldShowViewAutofixesButton: boolean; +} + +export const DEFAULT_VARIANT_ANALYSIS_USER_SETTINGS: VariantAnalysisUserSettings = + { + shouldShowViewAutofixesButton: false, + }; + +/** + * Message indicating that the user's variant analysis configuration + * settings have changed. + */ +interface SetVariantAnalysisUserSettingsMsg { + t: "setVariantAnalysisUserSettings"; + variantAnalysisUserSettings: VariantAnalysisUserSettings; +} + /** * Message indicating that the results view should display interpreted * results. @@ -527,6 +546,11 @@ interface OpenQueryTextMessage { t: "openQueryText"; } +interface ViewAutofixesMessage { + t: "viewAutofixes"; + filterSort?: RepositoriesFilterSortStateWithIds; +} + interface CopyRepositoryListMessage { t: "copyRepositoryList"; filterSort?: RepositoriesFilterSortStateWithIds; @@ -554,13 +578,15 @@ export type ToVariantAnalysisMessage = | SetVariantAnalysisMessage | SetFilterSortStateMessage | SetRepoResultsMessage - | SetRepoStatesMessage; + | SetRepoStatesMessage + | SetVariantAnalysisUserSettingsMsg; export type FromVariantAnalysisMessage = | CommonFromViewMessages | RequestRepositoryResultsMessage | OpenQueryFileMessage | OpenQueryTextMessage + | ViewAutofixesMessage | CopyRepositoryListMessage | ExportResultsMessage | OpenLogsMessage diff --git a/extensions/ql-vscode/src/config.ts b/extensions/ql-vscode/src/config.ts index 989e4931bcf..58e4d6ff929 100644 --- a/extensions/ql-vscode/src/config.ts +++ b/extensions/ql-vscode/src/config.ts @@ -954,3 +954,17 @@ export class GitHubDatabaseConfigListener await GITHUB_DATABASE_UPDATE.updateValue(value, target); } } + +const AUTOFIX_SETTING = new Setting("autofix", ROOT_SETTING); + +const AUTOFIX_PATH = new Setting("path", AUTOFIX_SETTING); + +export function getAutofixPath(): string | undefined { + return AUTOFIX_PATH.getValue() || undefined; +} + +const AUTOFIX_MODEL = new Setting("model", AUTOFIX_SETTING); + +export function getAutofixModel(): string | undefined { + return AUTOFIX_MODEL.getValue() || undefined; +} diff --git a/extensions/ql-vscode/src/query-history/query-history-manager.ts b/extensions/ql-vscode/src/query-history/query-history-manager.ts index bf6823abac1..dd21a9d17f8 100644 --- a/extensions/ql-vscode/src/query-history/query-history-manager.ts +++ b/extensions/ql-vscode/src/query-history/query-history-manager.ts @@ -338,6 +338,11 @@ export class QueryHistoryManager extends DisposableObject { this.handleOpenOnGithub.bind(this), "query", ), + "codeQLQueryHistory.viewAutofixes": createSingleSelectionCommand( + this.app.logger, + this.handleViewAutofixes.bind(this), + "query", + ), "codeQLQueryHistory.copyRepoList": createSingleSelectionCommand( this.app.logger, this.handleCopyRepoList.bind(this), @@ -1052,6 +1057,14 @@ export class QueryHistoryManager extends DisposableObject { ); } + async handleViewAutofixes(item: QueryHistoryInfo) { + if (item.t !== "variant-analysis") { + return; + } + + await this.variantAnalysisManager.viewAutofixes(item.variantAnalysis.id); + } + async handleCopyRepoList(item: QueryHistoryInfo) { if (item.t !== "variant-analysis") { return; diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts index 4e56c3cea7f..179105a2b14 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-manager.ts @@ -73,6 +73,7 @@ import type { VariantAnalysisCommands, } from "../common/commands"; import { exportVariantAnalysisResults } from "./export-results"; +import { viewAutofixesForVariantAnalysisResults } from "./view-autofixes"; import { readRepoStates, REPO_STATES_FILENAME, @@ -967,6 +968,21 @@ export class VariantAnalysisManager ); } + public async viewAutofixes( + variantAnalysisId: number, + filterSort: RepositoriesFilterSortStateWithIds = defaultFilterSortState, + ) { + await viewAutofixesForVariantAnalysisResults( + this, + this.variantAnalysisResultsManager, + variantAnalysisId, + filterSort, + this.app.credentials, + this.app, + this.cliServer, + ); + } + public async copyRepoListToClipboard( variantAnalysisId: number, filterSort: RepositoriesFilterSortStateWithIds = defaultFilterSortState, diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-results-manager.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-results-manager.ts index 0f24e950325..01e27410ff0 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-results-manager.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-results-manager.ts @@ -44,6 +44,7 @@ export type LoadResultsOptions = { export class VariantAnalysisResultsManager extends DisposableObject { private static readonly RESULTS_DIRECTORY = "results"; + private static readonly RESULTS_SARIF_FILENAME = "results.sarif"; private readonly cachedResults: Map< CacheKey, @@ -212,7 +213,10 @@ export class VariantAnalysisResultsManager extends DisposableObject { storageDirectory, VariantAnalysisResultsManager.RESULTS_DIRECTORY, ); - const sarifPath = join(resultsDirectory, "results.sarif"); + const sarifPath = join( + resultsDirectory, + VariantAnalysisResultsManager.RESULTS_SARIF_FILENAME, + ); const bqrsPath = join(resultsDirectory, "results.bqrs"); let interpretedResults: AnalysisAlert[] | undefined; @@ -294,6 +298,17 @@ export class VariantAnalysisResultsManager extends DisposableObject { return join(variantAnalysisStoragePath, fullName); } + public getRepoResultsSarifStoragePath( + variantAnalysisStoragePath: string, + fullName: string, + ): string { + return join( + this.getRepoStorageDirectory(variantAnalysisStoragePath, fullName), + VariantAnalysisResultsManager.RESULTS_DIRECTORY, + VariantAnalysisResultsManager.RESULTS_SARIF_FILENAME, + ); + } + private createGitHubFileLinkPrefix(fullName: string, sha: string): string { return new URL( `/${fullName}/blob/${sha}`, diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-view-manager.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-view-manager.ts index cc531c558e7..db591ac3d95 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-view-manager.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-view-manager.ts @@ -34,4 +34,8 @@ export interface VariantAnalysisViewManager< variantAnalysisId: number, filterSort?: RepositoriesFilterSortStateWithIds, ): Promise; + viewAutofixes( + variantAnalysisId: number, + filterSort?: RepositoriesFilterSortStateWithIds, + ): Promise; } diff --git a/extensions/ql-vscode/src/variant-analysis/variant-analysis-view.ts b/extensions/ql-vscode/src/variant-analysis/variant-analysis-view.ts index f8a87e47b87..555a3686e68 100644 --- a/extensions/ql-vscode/src/variant-analysis/variant-analysis-view.ts +++ b/extensions/ql-vscode/src/variant-analysis/variant-analysis-view.ts @@ -27,6 +27,7 @@ import type { App } from "../common/app"; import { getVariantAnalysisDefaultResultsFilter, getVariantAnalysisDefaultResultsSort, + isCanary, } from "../config"; export class VariantAnalysisView @@ -53,6 +54,13 @@ export class VariantAnalysisView panel.reveal(undefined, true); await this.waitForPanelLoaded(); + + await this.postMessage({ + t: "setVariantAnalysisUserSettings", + variantAnalysisUserSettings: { + shouldShowViewAutofixesButton: isCanary(), + }, + }); } public async updateView(variantAnalysis: VariantAnalysis): Promise { @@ -135,6 +143,12 @@ export class VariantAnalysisView case "openQueryText": await this.manager.openQueryText(this.variantAnalysisId); break; + case "viewAutofixes": + await this.manager.viewAutofixes( + this.variantAnalysisId, + msg.filterSort, + ); + break; case "copyRepositoryList": await this.manager.copyRepoListToClipboard( this.variantAnalysisId, diff --git a/extensions/ql-vscode/src/variant-analysis/view-autofixes.ts b/extensions/ql-vscode/src/variant-analysis/view-autofixes.ts new file mode 100644 index 00000000000..2254eed6fa3 --- /dev/null +++ b/extensions/ql-vscode/src/variant-analysis/view-autofixes.ts @@ -0,0 +1,793 @@ +import type { RepositoriesFilterSortStateWithIds } from "./shared/variant-analysis-filter-sort"; +import { + defaultFilterSortState, + filterAndSortRepositoriesWithResults, +} from "./shared/variant-analysis-filter-sort"; +import type { + VariantAnalysis, + VariantAnalysisRepositoryTask, +} from "./shared/variant-analysis"; +import type { Credentials } from "../common/authentication"; +import { extLogger } from "../common/logging/vscode"; +import type { App } from "../common/app"; +import type { CodeQLCliServer } from "../codeql-cli/cli"; +import { + pathExists, + ensureDir, + readdir, + move, + remove, + unlink, + mkdtemp, + readFile, + writeFile, +} from "fs-extra"; +import { withProgress, progressUpdate } from "../common/vscode/progress"; +import type { ProgressCallback } from "../common/vscode/progress"; +import { join, dirname, parse } from "path"; +import { tryGetQueryMetadata } from "../codeql-cli/query-metadata"; +import { pluralize } from "../common/word"; +import { readRepoTask } from "./repo-tasks-store"; +import { tmpdir } from "os"; +import { spawn } from "child_process"; +import type { execFileSync } from "child_process"; +import { tryOpenExternalFile } from "../common/vscode/external-files"; +import type { VariantAnalysisManager } from "./variant-analysis-manager"; +import type { VariantAnalysisResultsManager } from "./variant-analysis-results-manager"; +import { getAutofixPath, getAutofixModel } from "../config"; +import { getErrorMessage } from "../common/helpers-pure"; + +// Limit to three repos when generating autofixes so not sending +// too many requests to autofix. Since we only need to validate +// a handle of autofixes for each query, this should be sufficient. +// Consider increasing this in the future if needed. +const MAX_NUM_REPOS: number = 3; +// Similarly, limit to three fixes per repo. +const MAX_NUM_FIXES: number = 3; + +/** + * Generates autofixes for the results of a variant analysis. + */ +export async function viewAutofixesForVariantAnalysisResults( + variantAnalysisManager: VariantAnalysisManager, + variantAnalysisResultsManager: VariantAnalysisResultsManager, + variantAnalysisId: number, + filterSort: RepositoriesFilterSortStateWithIds = defaultFilterSortState, + credentials: Credentials, + app: App, + cliServer: CodeQLCliServer, +): Promise { + await withProgress( + async (progress: ProgressCallback) => { + // Get the path to the local autofix installation. + const localAutofixPath = await findLocalAutofix(); + + // Get the variant analysis with the given id. + const variantAnalysis = + variantAnalysisManager.tryGetVariantAnalysis(variantAnalysisId); + if (!variantAnalysis) { + throw new Error(`No variant analysis with id: ${variantAnalysisId}`); + } + + // Generate the query help and output it to the override directory. + await overrideQueryHelp(variantAnalysis, cliServer, localAutofixPath); + + // Get the full names (nwos) of the selected repositories. + const selectedRepoNames = getSelectedRepositoryNames( + variantAnalysis, + filterSort, + ); + + // Get storage paths for the autofix results. + const { + variantAnalysisIdStoragePath, + sourceRootsStoragePath, + autofixOutputStoragePath, + } = await getStoragePaths(variantAnalysisManager, variantAnalysisId); + + // Process the selected repositories: + // Get sarif + // Download source root + // Run autofix and output results + progress( + progressUpdate( + 1, + 2, + `Processing ${pluralize(selectedRepoNames.length, "repository", "repositories")}`, + ), + ); + const outputTextFiles = await processSelectedRepositories( + variantAnalysisResultsManager, + selectedRepoNames, + variantAnalysisIdStoragePath, + sourceRootsStoragePath, + autofixOutputStoragePath, + localAutofixPath, + credentials, + ); + + // Output results from all repos to a combined markdown file. + progress(progressUpdate(2, 2, `Finalizing autofix results`)); + const combinedOutputMarkdownFile = join( + autofixOutputStoragePath, + "autofix-output.md", + ); + await mergeFiles(outputTextFiles, combinedOutputMarkdownFile, false); + + // Open the combined markdown file. + await tryOpenExternalFile(app.commands, combinedOutputMarkdownFile); + }, + { + title: "Generating Autofixes", + cancellable: false, // not cancellable for now + }, + ); +} + +/** + * Finds the local autofix installation path from the AUTOFIX_PATH environment variable. + * Throws an error if the path is not set or does not exist. + * @returns An object containing the local autofix path. + * @throws Error if the AUTOFIX_PATH environment variable is not set or the path does not exist. + */ +async function findLocalAutofix(): Promise { + const localAutofixPath = getAutofixPath(); + if (!localAutofixPath) { + throw new Error( + "Path to local autofix installation not found. Internal GitHub access required.", + ); + } + if (!(await pathExists(localAutofixPath))) { + throw new Error(`Local autofix path ${localAutofixPath} does not exist.`); + } + return localAutofixPath; +} + +/** + * Overrides the query help from a given variant analysis + * at a location within the `localAutofixPath` directory . + */ +async function overrideQueryHelp( + variantAnalysis: VariantAnalysis, + cliServer: CodeQLCliServer, + localAutofixPath: string, +): Promise { + // Get path to the query used by the variant analysis. + const queryFilePath = variantAnalysis.query.filePath; + if (!(await pathExists(queryFilePath))) { + throw new Error(`Query file used by variant analysis not found.`); + } + const parsedQueryFilePath = parse(queryFilePath); + const queryFilePathNoExt = join( + parsedQueryFilePath.dir, + parsedQueryFilePath.name, + ); + + // Get the path to the query help, which may be either a `.qhelp` or a `.md` file. + // Note: we assume that the name of the query file is the same as the name of the query help file. + const queryHelpFilePathQhelp = `${queryFilePathNoExt}.qhelp`; + const queryHelpFilePathMarkdown = `${queryFilePathNoExt}.md`; + + // Set `queryHelpFilePath` to the existing extension type. + let queryHelpFilePath: string; + if (await pathExists(queryHelpFilePathQhelp)) { + queryHelpFilePath = queryHelpFilePathQhelp; + } else if (await pathExists(queryHelpFilePathMarkdown)) { + queryHelpFilePath = queryHelpFilePathMarkdown; + } else { + throw new Error( + `Could not find query help file at either ${queryHelpFilePathQhelp} or ${queryHelpFilePathMarkdown}. Check that the query help file exists and is named correctly.`, + ); + } + + // Get the query metadata. + const metadata = await tryGetQueryMetadata(cliServer, queryFilePath); + if (!metadata) { + throw new Error(`Could not get query metadata for ${queryFilePath}.`); + } + // Get the query ID (used for the overridden query help's filename). + const queryId = metadata.id; + if (!queryId) { + throw new Error(`Query metadata for ${queryFilePath} is missing an ID.`); + } + // Replace `/` with `-` for use with the overridden query help's filename. + // Use `replaceAll` since some query IDs have multiple slashes. + const queryIdWithDash = queryId.replaceAll("/", "-"); + + // Get the path to the output directory for overriding the query help. + // Note: the path to this directory may change in the future. + const queryHelpOverrideDirectory = join( + localAutofixPath, + "prompt-templates", + "qhelps", + `${queryIdWithDash}.md`, + ); + + await cliServer.generateQueryHelp( + queryHelpFilePath, + queryHelpOverrideDirectory, + ); +} + +/** + * Gets the full names (owner/repo) of the selected + * repositories from the given variant analysis while + * limiting the number of repositories to `MAX_NUM_REPOS`. + */ +function getSelectedRepositoryNames( + variantAnalysis: VariantAnalysis, + filterSort: RepositoriesFilterSortStateWithIds, +): string[] { + // Get the repositories that were selected by the user. + const filteredRepositories = filterAndSortRepositoriesWithResults( + variantAnalysis.scannedRepos, + filterSort, + ); + + // Get the full names (owner/repo = nwo) of the selected repos. + let fullNames = filteredRepositories + ?.filter((a) => a.resultCount && a.resultCount > 0) + .map((a) => a.repository.fullName); + if (!fullNames || fullNames.length === 0) { + throw new Error("No repositories with results found."); + } + + // Limit to MAX_NUM_REPOS by slicing the array. + if (fullNames.length > MAX_NUM_REPOS) { + fullNames = fullNames.slice(0, MAX_NUM_REPOS); + void extLogger.log( + `Only the first ${MAX_NUM_REPOS} repos (${fullNames.join(", ")}) will be included in the Autofix results.`, + ); + } + + return fullNames; +} + +/** + * Gets the storage paths needed for the autofix results. + */ +async function getStoragePaths( + variantAnalysisManager: VariantAnalysisManager, + variantAnalysisId: number, +): Promise<{ + variantAnalysisIdStoragePath: string; + sourceRootsStoragePath: string; + autofixOutputStoragePath: string; +}> { + // Confirm storage path for the variant analysis ID exists. + const variantAnalysisIdStoragePath = + variantAnalysisManager.getVariantAnalysisStorageLocation(variantAnalysisId); + if (!(await pathExists(variantAnalysisIdStoragePath))) { + throw new Error( + `Variant analysis storage location does not exist: ${variantAnalysisIdStoragePath}`, + ); + } + + // Storage path for all autofix info. + const autofixStoragePath = join(variantAnalysisIdStoragePath, "autofix"); + + // Storage path for the source roots used with autofix. + const sourceRootsStoragePath = join(autofixStoragePath, "source-roots"); + await ensureDir(sourceRootsStoragePath); + + // Storage path for the autofix output. + let autofixOutputStoragePath = join(autofixStoragePath, "output"); + // If the path already exists, assume that it's a previous run + // and append "-n" to the end of the path where n is the next available number. + if (await pathExists(autofixOutputStoragePath)) { + let i = 1; + while (await pathExists(autofixOutputStoragePath + i.toString())) { + i++; + } + autofixOutputStoragePath = autofixOutputStoragePath += i.toString(); + } + await ensureDir(autofixOutputStoragePath); + + return { + variantAnalysisIdStoragePath, + sourceRootsStoragePath, + autofixOutputStoragePath, + }; +} + +/** + * Processes the selected repositories for autofix generation. + */ +async function processSelectedRepositories( + variantAnalysisResultsManager: VariantAnalysisResultsManager, + selectedRepoNames: string[], + variantAnalysisIdStoragePath: string, + sourceRootsStoragePath: string, + autofixOutputStoragePath: string, + localAutofixPath: string, + credentials: Credentials, +): Promise { + const outputTextFiles: string[] = []; + await Promise.all( + selectedRepoNames.map(async (nwo) => + withProgress( + async (progressForRepo: ProgressCallback) => { + // Get the sarif file. + progressForRepo(progressUpdate(1, 3, `Getting sarif`)); + const sarifFile = await getRepoSarifFile( + variantAnalysisResultsManager, + variantAnalysisIdStoragePath, + nwo, + ); + + // Read the contents of the variant analysis' `repo_task.json` file, + // and confirm that the `databaseCommitSha` and `resultCount` exist. + const repoTask: VariantAnalysisRepositoryTask = await readRepoTask( + variantAnalysisResultsManager.getRepoStorageDirectory( + variantAnalysisIdStoragePath, + nwo, + ), + ); + if (!repoTask.databaseCommitSha) { + throw new Error(`Missing database commit SHA for ${nwo}`); + } + if (!repoTask.resultCount) { + throw new Error(`Missing variant analysis result count for ${nwo}`); + } + + // Download the source root. + // Using `0` as the progress step to force a dynamic vs static progress bar. + // Consider using `reportStreamProgress` as a future enhancement. + progressForRepo(progressUpdate(0, 3, `Downloading source root`)); + const srcRootPath = await downloadPublicCommitSource( + nwo, + repoTask.databaseCommitSha, + sourceRootsStoragePath, + credentials, + ); + + // Run autofix. + progressForRepo(progressUpdate(2, 3, `Running autofix`)); + await runAutofixForRepository( + nwo, + sarifFile, + srcRootPath, + localAutofixPath, + autofixOutputStoragePath, + repoTask.resultCount, + outputTextFiles, + ); + }, + { + title: `Processing ${nwo}`, + cancellable: false, + }, + ), + ), + ); + + return outputTextFiles; +} + +/** + * Gets the path to a SARIF file for a given `nwo`. + */ +async function getRepoSarifFile( + variantAnalysisResultsManager: VariantAnalysisResultsManager, + variantAnalysisIdStoragePath: string, + nwo: string, +): Promise { + if ( + !(await variantAnalysisResultsManager.isVariantAnalysisRepoDownloaded( + variantAnalysisIdStoragePath, + nwo, + )) + ) { + throw new Error(`Variant analysis results not downloaded for ${nwo}`); + } + const sarifFile = + variantAnalysisResultsManager.getRepoResultsSarifStoragePath( + variantAnalysisIdStoragePath, + nwo, + ); + if (!(await pathExists(sarifFile))) { + throw new Error(`SARIF file not found for ${nwo}`); + } + return sarifFile; +} + +/** + * Downloads the source code of a public commit from a GitHub repository. + */ +async function downloadPublicCommitSource( + nwo: string, + sha: string, + outputPath: string, + credentials: Credentials, +): Promise { + const [owner, repo] = nwo.split("/"); + if (!owner || !repo) { + throw new Error(`Invalid repository name: ${nwo}`); + } + + // Create output directory if it doesn't exist + await ensureDir(outputPath); + + // Define the final checkout directory + const checkoutDir = join( + outputPath, + `${owner}-${repo}-${sha.substring(0, 7)}`, + ); + + // Check if directory already exists to avoid re-downloading + if (await pathExists(checkoutDir)) { + void extLogger.log( + `Source for ${nwo} at ${sha} already exists at ${checkoutDir}.`, + ); + return checkoutDir; + } + + void extLogger.log(`Fetching source of repository ${nwo} at ${sha}...`); + + try { + // Create a temporary directory for downloading + const downloadDir = await mkdtemp(join(tmpdir(), "download-source-")); + const tarballPath = join(downloadDir, "source.tar.gz"); + + const octokit = await credentials.getOctokit(); + + // Get the tarball URL + const { url } = await octokit.rest.repos.downloadTarballArchive({ + owner, + repo, + ref: sha, + }); + + // Download the tarball using spawn + await new Promise((resolve, reject) => { + const curlArgs = [ + "-H", + "Accept: application/octet-stream", + "--user-agent", + "GitHub-CodeQL-Extension", + "-L", // Follow redirects + "-o", + tarballPath, + url, + ]; + + const process = spawn("curl", curlArgs, { cwd: downloadDir }); + + process.on("error", reject); + process.on("exit", (code) => + code === 0 + ? resolve() + : reject(new Error(`curl exited with code ${code}`)), + ); + }); + + void extLogger.log(`Download complete, extracting source...`); + + // Extract the tarball + await new Promise((resolve, reject) => { + const process = spawn("tar", ["-xzf", tarballPath], { cwd: downloadDir }); + + process.on("error", reject); + process.on("exit", (code) => + code === 0 + ? resolve() + : reject(new Error(`tar extraction failed with code ${code}`)), + ); + }); + + // Remove the tarball to save space + await unlink(tarballPath); + + // Find the extracted directory (GitHub tarballs extract to a single directory) + const extractedFiles = await readdir(downloadDir); + const sourceDir = extractedFiles.filter((f) => f !== "source.tar.gz")[0]; + + if (!sourceDir) { + throw new Error("Failed to find extracted source directory"); + } + + const extractedSourcePath = join(downloadDir, sourceDir); + + // Ensure the destination directory's parent exists + await ensureDir(dirname(checkoutDir)); + + // Move the extracted source to the final location + await move(extractedSourcePath, checkoutDir); + + // Clean up the temporary directory + await remove(downloadDir); + + return checkoutDir; + } catch (error) { + throw new Error( + `Failed to download ${nwo} at ${sha}:. Reason: ${getErrorMessage(error)}`, + ); + } +} + +/** + * Runs autofix for a given repository (nwo). + */ +async function runAutofixForRepository( + nwo: string, + sarifFile: string, + srcRootPath: string, + localAutofixPath: string, + autofixOutputStoragePath: string, + resultCount: number, + outputTextFiles: string[], +): Promise { + // Get storage paths for the autofix results for this repository. + const { + repoAutofixOutputStoragePath, + outputTextFilePath, + transcriptFilePath, + fixDescriptionFilePath, + } = await getRepoStoragePaths(autofixOutputStoragePath, nwo); + + // Get autofix binary. + // Switch to Go binary in the future and have user pass full path + // in an environment variable instead of hardcoding part here. + const cocofixBin = join(process.cwd(), localAutofixPath, "bin", "cocofix.js"); + + // Limit number of fixes generated. + const limitFixesBoolean: boolean = resultCount > MAX_NUM_FIXES; + if (limitFixesBoolean) { + void extLogger.log( + `Only generating autofixes for the first ${MAX_NUM_FIXES} alerts for ${nwo}.`, + ); + + // Run autofix in a loop for the first MAX_NUM_FIXES alerts. + // Not an ideal solution, but avoids modifying the input SARIF file. + const tempOutputTextFiles: string[] = []; + const fixDescriptionFiles: string[] = []; + const transcriptFiles: string[] = []; + for (let i = 0; i < MAX_NUM_FIXES; i++) { + const tempOutputTextFilePath = appendSuffixToFilePath( + outputTextFilePath, + i.toString(), + ); + const tempFixDescriptionFilePath = appendSuffixToFilePath( + fixDescriptionFilePath, + i.toString(), + ); + const tempTranscriptFilePath = appendSuffixToFilePath( + transcriptFilePath, + i.toString(), + ); + + tempOutputTextFiles.push(tempOutputTextFilePath); + fixDescriptionFiles.push(tempFixDescriptionFilePath); + transcriptFiles.push(tempTranscriptFilePath); + + await runAutofixOnResults( + cocofixBin, + sarifFile, + srcRootPath, + tempOutputTextFilePath, + tempFixDescriptionFilePath, + tempTranscriptFilePath, + repoAutofixOutputStoragePath, + i, + ); + } + + // Merge the output files together. + // Caveat that autofix will call each alert "alert 0", which will look a bit odd in the merged output file. + await mergeFiles(tempOutputTextFiles, outputTextFilePath); + await mergeFiles(fixDescriptionFiles, fixDescriptionFilePath); + await mergeFiles(transcriptFiles, transcriptFilePath); + } else { + // Run autofix once for all alerts. + await runAutofixOnResults( + cocofixBin, + sarifFile, + srcRootPath, + outputTextFilePath, + fixDescriptionFilePath, + transcriptFilePath, + repoAutofixOutputStoragePath, + ); + } + + // Format the output text file with markdown. + await formatWithMarkdown(outputTextFilePath, `${nwo}`); + + // Save output text files from each repo to later merge + // into a single markdown file containing all results. + outputTextFiles.push(outputTextFilePath); +} + +/** + * Gets the storage paths for the autofix results for a given repository. + */ +async function getRepoStoragePaths( + autofixOutputStoragePath: string, + nwo: string, +) { + // Create output directories for repo's autofix results. + const repoAutofixOutputStoragePath = join( + autofixOutputStoragePath, + nwo.replaceAll("/", "-"), + ); + await ensureDir(repoAutofixOutputStoragePath); + return { + repoAutofixOutputStoragePath, + outputTextFilePath: join(repoAutofixOutputStoragePath, "output.txt"), + transcriptFilePath: join(repoAutofixOutputStoragePath, "transcript.md"), + fixDescriptionFilePath: join( + repoAutofixOutputStoragePath, + "fix-description.md", + ), + }; +} + +/** + * Runs autofix on the results in the given SARIF file. + */ +async function runAutofixOnResults( + cocofixBin: string, + sarifFile: string, + srcRootPath: string, + outputTextFilePath: string, + fixDescriptionFilePath: string, + transcriptFilePath: string, + workDir: string, + alertNumber?: number, // Optional parameter for specific alert +): Promise { + // Get autofix model from user settings. + const autofixModel = getAutofixModel(); + if (!autofixModel) { + throw new Error("Autofix model not found."); + } + // Set up args for autofix command. + const autofixArgs = [ + "--sarif", + sarifFile, + "--source-root", + srcRootPath, + "--model", + autofixModel, + "--dev", + "--no-cache", + "--format", + "text", + "--diff-style", + "diff", // could do "text" instead if want line of "=" between fixes + "--output", + outputTextFilePath, + "--fix-description", + fixDescriptionFilePath, + "--transcript", + transcriptFilePath, + ]; + + // Add alert number argument if provided + if (alertNumber !== undefined) { + autofixArgs.push("--only-alert-number", alertNumber.toString()); + } + + await execAutofix( + cocofixBin, + autofixArgs, + { + cwd: workDir, + env: { + CAPI_DEV_KEY: process.env.CAPI_DEV_KEY, + PATH: process.env.PATH, + }, + }, + true, + ); +} + +/** + * Executes the autofix command. + */ +function execAutofix( + bin: string, + args: string[], + options: Parameters[2], + showCommand?: boolean, +): Promise { + return new Promise((resolve, reject) => { + try { + const cwd = options?.cwd || process.cwd(); + if (showCommand) { + void extLogger.log(`Spawning '${bin} ${args.join(" ")}' in ${cwd}`); + } + const p = spawn(bin, args, { stdio: [0, 1, 2], ...options }); + p.on("error", reject); + p.on("exit", (code) => (code === 0 ? resolve() : reject(code))); + } catch (e) { + reject(e); + } + }); +} + +/** + * Creates a new file path by appending the given suffix. + * @param filePath The original file path. + * @param suffix The suffix to append to the file name (before the extension). + * @returns The new file path with the suffix appended. + */ +function appendSuffixToFilePath(filePath: string, suffix: string): string { + const { dir, name, ext } = parse(filePath); + return join(dir, `${name}-${suffix}${ext}`); +} + +/** + * Merges the given `inputFiles` into a single `outputFile`. + * @param inputFiles - The list of input files to merge. + * @param outputFile - The output file path. + * @param deleteOriginalFiles - Whether to delete the original input files after merging. + */ +async function mergeFiles( + inputFiles: string[], + outputFile: string, + deleteOriginalFiles: boolean = true, +): Promise { + try { + // Check if any input files do not exist and return if so. + const pathChecks = await Promise.all( + inputFiles.map(async (path) => ({ + exists: await pathExists(path), + })), + ); + const anyPathMissing = pathChecks.some((check) => !check.exists); + if (inputFiles.length === 0 || anyPathMissing) { + return; + } + + // Merge the files + const contents = await Promise.all( + inputFiles.map((file) => readFile(file, "utf8")), + ); + + // Write merged content + await writeFile(outputFile, contents.join("\n")); + + // Delete original files + if (deleteOriginalFiles) { + await Promise.all(inputFiles.map((file) => unlink(file))); + } + } catch (error) { + throw new Error(`Error merging files. Reason: ${getErrorMessage(error)}`); + } +} + +/** + * Formats the given input file with the specified header. + * @param inputFile The path to the input file to format. + * @param header The header to include in the formatted output. + */ +async function formatWithMarkdown( + inputFile: string, + header: string, +): Promise { + try { + // Check if the input file exists + const exists = await pathExists(inputFile); + if (!exists) { + return; + } + + // Read the input file content + const content = await readFile(inputFile, "utf8"); + + const frontFormatting: string = + "
Fix suggestion details\n\n```diff\n"; + + const backFormatting: string = + "```\n\n
\n\n ### Notes\n - notes placeholder\n\n"; + + // Format the content with Markdown + // Replace ``` in the content with \``` to avoid breaking the Markdown code block + const formattedContent = `## ${header}\n\n${frontFormatting}${content.replaceAll("```", "\\```")}${backFormatting}`; + + // Write the formatted content back to the file + await writeFile(inputFile, formattedContent); + } catch (error) { + throw new Error(`Error formatting file. Reason: ${getErrorMessage(error)}`); + } +} diff --git a/extensions/ql-vscode/src/view/results/__tests__/AlertTablePathRow.spec.tsx b/extensions/ql-vscode/src/view/results/__tests__/AlertTablePathRow.spec.tsx index ada710bee85..001fa322a34 100644 --- a/extensions/ql-vscode/src/view/results/__tests__/AlertTablePathRow.spec.tsx +++ b/extensions/ql-vscode/src/view/results/__tests__/AlertTablePathRow.spec.tsx @@ -22,7 +22,9 @@ describe(AlertTablePathRow.name, () => { currentPathExpanded={true} databaseUri={"dbUri"} sourceLocationPrefix="src" - userSettings={{ shouldShowProvenance: false }} + userSettings={{ + shouldShowProvenance: false, + }} updateSelectionCallback={jest.fn()} toggleExpanded={jest.fn()} {...props} diff --git a/extensions/ql-vscode/src/view/results/__tests__/AlertTableResultRow.spec.tsx b/extensions/ql-vscode/src/view/results/__tests__/AlertTableResultRow.spec.tsx index 0aa7279ae72..9082384e9d2 100644 --- a/extensions/ql-vscode/src/view/results/__tests__/AlertTableResultRow.spec.tsx +++ b/extensions/ql-vscode/src/view/results/__tests__/AlertTableResultRow.spec.tsx @@ -17,7 +17,9 @@ describe(AlertTableResultRow.name, () => { selectedItemRef={mockRef} databaseUri={"dbUri"} sourceLocationPrefix="src" - userSettings={{ shouldShowProvenance: false }} + userSettings={{ + shouldShowProvenance: false, + }} updateSelectionCallback={jest.fn()} toggleExpanded={jest.fn()} {...props} diff --git a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx index 5b68d114d6f..3ac0599ae6c 100644 --- a/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx +++ b/extensions/ql-vscode/src/view/variant-analysis/VariantAnalysis.tsx @@ -9,11 +9,15 @@ import { VariantAnalysisStatus } from "../../variant-analysis/shared/variant-ana import { VariantAnalysisHeader } from "./VariantAnalysisHeader"; import { VariantAnalysisOutcomePanels } from "./VariantAnalysisOutcomePanels"; import { VariantAnalysisLoading } from "./VariantAnalysisLoading"; -import type { ToVariantAnalysisMessage } from "../../common/interface-types"; +import type { + ToVariantAnalysisMessage, + VariantAnalysisUserSettings, +} from "../../common/interface-types"; import { vscode } from "../vscode-api"; import { defaultFilterSortState } from "../../variant-analysis/shared/variant-analysis-filter-sort"; import { sendTelemetry, useTelemetryOnChange } from "../common/telemetry"; import { useMessageFromExtension } from "../common/useMessageFromExtension"; +import { DEFAULT_VARIANT_ANALYSIS_USER_SETTINGS } from "../../common/interface-types"; export type VariantAnalysisProps = { variantAnalysis?: VariantAnalysisDomainModel; @@ -77,6 +81,10 @@ export function VariantAnalysis({ useTelemetryOnChange(filterSortState, "variant-analysis-filter-sort-state", { debounceTimeoutMillis: 1000, }); + const [variantAnalysisUserSettings, setVariantAnalysisUserSettings] = + useState( + DEFAULT_VARIANT_ANALYSIS_USER_SETTINGS, + ); useMessageFromExtension((msg) => { if (msg.t === "setVariantAnalysis") { @@ -102,9 +110,22 @@ export function VariantAnalysis({ ...msg.repoStates, ]; }); + } else if (msg.t === "setVariantAnalysisUserSettings") { + setVariantAnalysisUserSettings(msg.variantAnalysisUserSettings); } }, []); + const viewAutofixes = useCallback(() => { + vscode.postMessage({ + t: "viewAutofixes", + filterSort: { + ...filterSortState, + repositoryIds: selectedRepositoryIds, + }, + }); + sendTelemetry("variant-analysis-view-autofixes"); + }, [filterSortState, selectedRepositoryIds]); + const copyRepositoryList = useCallback(() => { vscode.postMessage({ t: "copyRepositoryList", @@ -148,9 +169,11 @@ export function VariantAnalysis({ onOpenQueryFileClick={openQueryFile} onViewQueryTextClick={openQueryText} onStopQueryClick={stopQuery} + onViewAutofixesClick={viewAutofixes} onCopyRepositoryListClick={copyRepositoryList} onExportResultsClick={exportResults} onViewLogsClick={onViewLogsClick} + variantAnalysisUserSettings={variantAnalysisUserSettings} /> void; onCopyRepositoryListClick: () => void; onExportResultsClick: () => void; + viewAutofixesDisabled?: boolean; copyRepositoryListDisabled?: boolean; exportResultsDisabled?: boolean; hasSelectedRepositories?: boolean; hasFilteredRepositories?: boolean; + + showViewAutofixesButton: boolean; }; const Container = styled.div` @@ -55,17 +59,35 @@ export const VariantAnalysisActions = ({ onStopQueryClick, stopQueryDisabled, showResultActions, + onViewAutofixesClick, onCopyRepositoryListClick, onExportResultsClick, + viewAutofixesDisabled, copyRepositoryListDisabled, exportResultsDisabled, hasSelectedRepositories, hasFilteredRepositories, + showViewAutofixesButton, }: VariantAnalysisActionsProps) => { return ( {showResultActions && ( <> + {showViewAutofixesButton && ( + + )}