diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 62274bbbd..41db796d5 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -97,7 +97,11 @@ import { RemoteQueryResult } from './remote-queries/remote-query-result'; import { URLSearchParams } from 'url'; import { handleDownloadPacks, handleInstallPackDependencies } from './packaging'; import { HistoryItemLabelProvider } from './history-item-label-provider'; -import { exportRemoteQueryResults, exportSelectedRemoteQueryResults } from './remote-queries/export-results'; +import { + exportRemoteQueryResults, + exportSelectedRemoteQueryResults, + exportVariantAnalysisResults +} from './remote-queries/export-results'; import { RemoteQuery } from './remote-queries/remote-query'; import { EvalLogViewer } from './eval-log-viewer'; import { SummaryLanguageSupport } from './log-insights/summary-language-support'; @@ -986,6 +990,12 @@ async function activateWithInstalledDistribution( }) ); + ctx.subscriptions.push( + commandRunner('codeQL.exportVariantAnalysisResults', async (variantAnalysisId: number) => { + await exportVariantAnalysisResults(ctx, variantAnalysisManager, variantAnalysisId); + }) + ); + ctx.subscriptions.push( commandRunner('codeQL.loadVariantAnalysisRepoResults', async (variantAnalysisId: number, repositoryFullName: string) => { await variantAnalysisManager.loadResults(variantAnalysisId, repositoryFullName); diff --git a/extensions/ql-vscode/src/query-history.ts b/extensions/ql-vscode/src/query-history.ts index 2a446fb69..fd1645868 100644 --- a/extensions/ql-vscode/src/query-history.ts +++ b/extensions/ql-vscode/src/query-history.ts @@ -1273,9 +1273,11 @@ export class QueryHistoryManager extends DisposableObject { return; } - // Remote queries only + // Remote queries and variant analysis only if (finalSingleItem.t === 'remote') { await commands.executeCommand('codeQL.exportRemoteQueryResults', finalSingleItem.queryId); + } else if (finalSingleItem.t === 'variant-analysis') { + await commands.executeCommand('codeQL.exportVariantAnalysisResults', finalSingleItem.variantAnalysis.id); } } diff --git a/extensions/ql-vscode/src/remote-queries/export-results.ts b/extensions/ql-vscode/src/remote-queries/export-results.ts index d203f9394..abad9cf05 100644 --- a/extensions/ql-vscode/src/remote-queries/export-results.ts +++ b/extensions/ql-vscode/src/remote-queries/export-results.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as fs from 'fs-extra'; -import { window, commands, Uri, ExtensionContext, QuickPickItem, workspace, ViewColumn } from 'vscode'; +import { window, commands, Uri, ExtensionContext, workspace, ViewColumn } from 'vscode'; import { Credentials } from '../authentication'; import { UserCancellationException } from '../commandRunner'; import { showInformationMessageWithAction } from '../helpers'; @@ -9,13 +9,24 @@ import { logger } from '../logging'; import { QueryHistoryManager } from '../query-history'; import { createGist } from './gh-api/gh-api-client'; import { RemoteQueriesManager } from './remote-queries-manager'; -import { generateMarkdown } from './remote-queries-markdown-generation'; +import { + generateMarkdown, + generateVariantAnalysisMarkdown, + MarkdownFile, +} from './remote-queries-markdown-generation'; import { RemoteQuery } from './remote-query'; import { AnalysisResults, sumAnalysesResults } from './shared/analysis-result'; import { pluralize } from '../pure/word'; +import { VariantAnalysisManager } from './variant-analysis-manager'; +import { assertNever } from '../pure/helpers-pure'; +import { + VariantAnalysis, + VariantAnalysisScannedRepository, + VariantAnalysisScannedRepositoryResult +} from './shared/variant-analysis'; /** - * Exports the results of the currently-selected remote query. + * Exports the results of the currently-selected remote query or variant analysis. */ export async function exportSelectedRemoteQueryResults(queryHistoryManager: QueryHistoryManager): Promise { const queryHistoryItem = queryHistoryManager.getCurrentQueryHistoryItem(); @@ -23,19 +34,17 @@ export async function exportSelectedRemoteQueryResults(queryHistoryManager: Quer throw new Error('No variant analysis results currently open. To open results, click an item in the query history view.'); } - if (!queryHistoryItem.completed) { - throw new Error('Variant analysis results are not yet available.'); - } - if (queryHistoryItem.t === 'remote') { return commands.executeCommand('codeQL.exportRemoteQueryResults', queryHistoryItem.queryId); + } else if (queryHistoryItem.t === 'variant-analysis') { + return commands.executeCommand('codeQL.exportVariantAnalysisResults', queryHistoryItem.variantAnalysis.id); } else { - throw new Error('No variant analysis results currently open. To open results, click an item in the query history view.'); + assertNever(queryHistoryItem); } } /** - * Exports the results of the given or currently-selected remote query. + * Exports the results of the given remote query. * The user is prompted to select the export format. */ export async function exportRemoteQueryResults( @@ -58,32 +67,111 @@ export async function exportRemoteQueryResults( const query = queryHistoryItem.remoteQuery; const analysesResults = remoteQueriesManager.getAnalysesResults(queryHistoryItem.queryId); + const exportFormat = await determineExportFormat(); + if (!exportFormat) { + return; + } + + const exportDirectory = await queryHistoryManager.getQueryHistoryItemDirectory(queryHistoryItem); + + await exportRemoteQueryAnalysisResults(ctx, exportDirectory, query, analysesResults, exportFormat); +} + +export async function exportRemoteQueryAnalysisResults( + ctx: ExtensionContext, + exportDirectory: string, + query: RemoteQuery, + analysesResults: AnalysisResults[], + exportFormat: 'gist' | 'local', +) { + const description = buildGistDescription(query, analysesResults); + const markdownFiles = generateMarkdown(query, analysesResults, exportFormat); + + await exportResults(ctx, exportDirectory, description, markdownFiles, exportFormat); +} + +/** + * Exports the results of the given or currently-selected remote query. + * The user is prompted to select the export format. + */ +export async function exportVariantAnalysisResults( + ctx: ExtensionContext, + variantAnalysisManager: VariantAnalysisManager, + variantAnalysisId: number, +): Promise { + const variantAnalysis = await variantAnalysisManager.getVariantAnalysis(variantAnalysisId); + if (!variantAnalysis) { + void logger.log(`Could not find variant analysis with id ${variantAnalysisId}`); + throw new Error('There was an error when trying to retrieve variant analysis information'); + } + + void logger.log(`Exporting variant analysis results for variant analysis with id ${variantAnalysis.id}`); + + const exportFormat = await determineExportFormat(); + if (!exportFormat) { + return; + } + + async function* getAnalysesResults(): AsyncGenerator<[VariantAnalysisScannedRepository, VariantAnalysisScannedRepositoryResult]> { + if (!variantAnalysis?.scannedRepos) { + return; + } + + for (const repo of variantAnalysis.scannedRepos) { + if (repo.resultCount == 0) { + yield [repo, { + variantAnalysisId: variantAnalysis.id, + repositoryId: repo.repository.id, + }]; + continue; + } + + let result: VariantAnalysisScannedRepositoryResult; + + if (!variantAnalysisManager.areResultsLoaded(variantAnalysis.id, repo.repository.fullName)) { + result = await variantAnalysisManager.loadResultsFromStorage(variantAnalysis.id, repo.repository.fullName); + } else { + result = await variantAnalysisManager.loadResults(variantAnalysis.id, repo.repository.fullName); + } + + yield [repo, result]; + } + } + + const exportDirectory = variantAnalysisManager.getVariantAnalysisStorageLocation(variantAnalysis.id); + + await exportVariantAnalysisAnalysisResults(ctx, exportDirectory, variantAnalysis, getAnalysesResults(), exportFormat); +} + +export async function exportVariantAnalysisAnalysisResults( + ctx: ExtensionContext, + exportDirectory: string, + variantAnalysis: VariantAnalysis, + analysesResults: AsyncIterable<[VariantAnalysisScannedRepository, VariantAnalysisScannedRepositoryResult]>, + exportFormat: 'gist' | 'local', +) { + const description = buildVariantAnalysisGistDescription(variantAnalysis); + const markdownFiles = await generateVariantAnalysisMarkdown(variantAnalysis, analysesResults, 'gist'); + + await exportResults(ctx, exportDirectory, description, markdownFiles, exportFormat); +} + +/** + * Determines the format in which to export the results, from the given export options. + */ +async function determineExportFormat(): Promise<'gist' | 'local' | undefined> { const gistOption = { label: '$(ports-open-browser-icon) Create Gist (GitHub)', }; const localMarkdownOption = { label: '$(markdown) Save as markdown', }; - const exportFormat = await determineExportFormat(gistOption, localMarkdownOption); - if (exportFormat === gistOption) { - await exportResultsToGist(ctx, query, analysesResults); - } else if (exportFormat === localMarkdownOption) { - const queryDirectoryPath = await queryHistoryManager.getQueryHistoryItemDirectory( - queryHistoryItem - ); - await exportResultsToLocalMarkdown(queryDirectoryPath, query, analysesResults); - } -} - -/** - * Determines the format in which to export the results, from the given export options. - */ -async function determineExportFormat( - ...options: { label: string }[] -): Promise { const exportFormat = await window.showQuickPick( - options, + [ + gistOption, + localMarkdownOption, + ], { placeHolder: 'Select export format', canPickMany: false, @@ -93,20 +181,38 @@ async function determineExportFormat( if (!exportFormat || !exportFormat.label) { throw new UserCancellationException('No export format selected', true); } - return exportFormat; + + if (exportFormat === gistOption) { + return 'gist'; + } + if (exportFormat === localMarkdownOption) { + return 'local'; + } + + return undefined; } -/** - * Converts the results of a remote query to markdown and uploads the files as a secret gist. - */ -export async function exportResultsToGist( +export async function exportResults( ctx: ExtensionContext, - query: RemoteQuery, - analysesResults: AnalysisResults[] -): Promise { + exportDirectory: string, + description: string, + markdownFiles: MarkdownFile[], + exportFormat: 'gist' | 'local', +) { + if (exportFormat === 'gist') { + await exportToGist(ctx, description, markdownFiles); + } else if (exportFormat === 'local') { + await exportToLocalMarkdown(exportDirectory, markdownFiles); + } +} + +export async function exportToGist( + ctx: ExtensionContext, + description: string, + markdownFiles: MarkdownFile[] +) { const credentials = await Credentials.initialize(ctx); - const description = buildGistDescription(query, analysesResults); - const markdownFiles = generateMarkdown(query, analysesResults, 'gist'); + // Convert markdownFiles to the appropriate format for uploading to gist const gistFiles = markdownFiles.reduce((acc, cur) => { acc[`${cur.fileName}.md`] = { content: cur.content.join('\n') }; @@ -137,16 +243,25 @@ const buildGistDescription = (query: RemoteQuery, analysesResults: AnalysisResul }; /** - * Converts the results of a remote query to markdown and saves the files locally - * in the query directory (where query results and metadata are also saved). + * Builds Gist description + * Ex: Empty Block (Go) x results (y repositories) */ -async function exportResultsToLocalMarkdown( - queryDirectoryPath: string, - query: RemoteQuery, - analysesResults: AnalysisResults[] +const buildVariantAnalysisGistDescription = (variantAnalysis: VariantAnalysis) => { + const resultCount = variantAnalysis.scannedRepos?.reduce((acc, item) => acc + (item.resultCount ?? 0), 0) ?? 0; + const resultLabel = pluralize(resultCount, 'result', 'results'); + + const repositoryLabel = variantAnalysis.scannedRepos?.length ? `(${pluralize(variantAnalysis.scannedRepos.length, 'repository', 'repositories')})` : ''; + return `${variantAnalysis.query.name} (${variantAnalysis.query.language}) ${resultLabel} ${repositoryLabel}`; +}; + +/** + * Saves the results of an exported query to local markdown files. + */ +async function exportToLocalMarkdown( + exportDirectory: string, + markdownFiles: MarkdownFile[], ) { - const markdownFiles = generateMarkdown(query, analysesResults, 'local'); - const exportedResultsPath = path.join(queryDirectoryPath, 'exported-results'); + const exportedResultsPath = path.join(exportDirectory, 'exported-results'); await fs.ensureDir(exportedResultsPath); for (const markdownFile of markdownFiles) { const filePath = path.join(exportedResultsPath, `${markdownFile.fileName}.md`); diff --git a/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts b/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts index b6a71b8d4..6792d79fa 100644 --- a/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts +++ b/extensions/ql-vscode/src/remote-queries/remote-queries-markdown-generation.ts @@ -5,6 +5,11 @@ import { parseHighlightedLine, shouldHighlightLine } from '../pure/sarif-utils'; import { convertNonPrintableChars } from '../text-utils'; import { RemoteQuery } from './remote-query'; import { AnalysisAlert, AnalysisRawResults, AnalysisResults, CodeSnippet, FileLink, getAnalysisResultCount, HighlightedRegion } from './shared/analysis-result'; +import { + VariantAnalysis, + VariantAnalysisScannedRepository, + VariantAnalysisScannedRepositoryResult +} from './shared/variant-analysis'; export type MarkdownLinkType = 'local' | 'gist'; @@ -57,6 +62,51 @@ export function generateMarkdown( return [summaryFile, ...resultsFiles]; } +/** + * Generates markdown files with variant analysis results. + */ +export async function generateVariantAnalysisMarkdown( + variantAnalysis: VariantAnalysis, + results: AsyncIterable<[VariantAnalysisScannedRepository, VariantAnalysisScannedRepositoryResult]>, + linkType: MarkdownLinkType +): Promise { + const resultsFiles: MarkdownFile[] = []; + // Generate summary file with links to individual files + const summaryFile: MarkdownFile = generateVariantAnalysisMarkdownSummary(variantAnalysis); + for await (const [scannedRepo, result] of results) { + if (scannedRepo.resultCount === 0) { + continue; + } + + // Append nwo and results count to the summary table + const fullName = scannedRepo.repository.fullName; + const fileName = createFileName(fullName); + const link = createRelativeLink(fileName, linkType); + summaryFile.content.push(`| ${fullName} | [${scannedRepo.resultCount} result(s)](${link}) |`); + + // Generate individual markdown file for each repository + const resultsFileContent = [ + `### ${scannedRepo.repository.fullName}`, + '' + ]; + if (result.interpretedResults) { + for (const interpretedResult of result.interpretedResults) { + const individualResult = generateMarkdownForInterpretedResult(interpretedResult, variantAnalysis.query.language); + resultsFileContent.push(...individualResult); + } + } + if (result.rawResults) { + const rawResultTable = generateMarkdownForRawResults(result.rawResults); + resultsFileContent.push(...rawResultTable); + } + resultsFiles.push({ + fileName: fileName, + content: resultsFileContent, + }); + } + return [summaryFile, ...resultsFiles]; +} + export function generateMarkdownSummary(query: RemoteQuery): MarkdownFile { const lines: string[] = []; // Title @@ -95,6 +145,44 @@ export function generateMarkdownSummary(query: RemoteQuery): MarkdownFile { }; } +export function generateVariantAnalysisMarkdownSummary(variantAnalysis: VariantAnalysis): MarkdownFile { + const lines: string[] = []; + // Title + lines.push( + `### Results for "${variantAnalysis.query.name}"`, + '' + ); + + // Expandable section containing query text + const queryCodeBlock = [ + '```ql', + ...variantAnalysis.query.text.split('\n'), + '```', + ]; + lines.push( + ...buildExpandableMarkdownSection('Query', queryCodeBlock) + ); + + // Padding between sections + lines.push( + '
', + '', + ); + + // Summary table + lines.push( + '### Summary', + '', + '| Repository | Results |', + '| --- | --- |', + ); + // nwo and result count will be appended to this table + return { + fileName: '_summary', + content: lines + }; +} + function generateMarkdownForInterpretedResult(interpretedResult: AnalysisAlert, language: string): string[] { const lines: string[] = []; lines.push(createMarkdownRemoteFileRef( @@ -296,11 +384,11 @@ export function createMarkdownRemoteFileRef( /** * Builds an expandable markdown section of the form: - *
+ *
* title - * + * * contents - * + * *
*/ function buildExpandableMarkdownSection(title: string, contents: string[]): string[] { diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts index ad295ecfe..a0b315a13 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts @@ -142,13 +142,34 @@ export class VariantAnalysisManager extends DisposableObject implements VariantA return this.variantAnalyses.size; } - public async loadResults(variantAnalysisId: number, repositoryFullName: string): Promise { + public async loadResults(variantAnalysisId: number, repositoryFullName: string): Promise { const variantAnalysis = this.variantAnalyses.get(variantAnalysisId); if (!variantAnalysis) { throw new Error(`No variant analysis with id: ${variantAnalysisId}`); } - await this.variantAnalysisResultsManager.loadResults(variantAnalysisId, this.getVariantAnalysisStorageLocation(variantAnalysisId), repositoryFullName); + return this.variantAnalysisResultsManager.loadResults(variantAnalysisId, this.getVariantAnalysisStorageLocation(variantAnalysisId), repositoryFullName); + } + + public areResultsLoaded(variantAnalysisId: number, repositoryFullName: string): boolean { + if (!this.variantAnalyses.has(variantAnalysisId)) { + return false; + } + + return this.variantAnalysisResultsManager.areResultsLoaded(variantAnalysisId, repositoryFullName); + } + + /** + * This method should only be used to temporarily get the results for a variant analysis. In general, loadResults should + * be preferred. + */ + public async loadResultsFromStorage(variantAnalysisId: number, repositoryFullName: string): Promise { + const variantAnalysis = this.variantAnalyses.get(variantAnalysisId); + if (!variantAnalysis) { + throw new Error(`No variant analysis with id: ${variantAnalysisId}`); + } + + return this.variantAnalysisResultsManager.loadResultsFromStorage(variantAnalysisId, this.getVariantAnalysisStorageLocation(variantAnalysisId), repositoryFullName); } private async variantAnalysisRecordExists(variantAnalysisId: number): Promise { diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-results-manager.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-results-manager.ts index fa57bf724..9c16f908c 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-results-manager.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-results-manager.ts @@ -93,6 +93,13 @@ export class VariantAnalysisResultsManager extends DisposableObject { return result ?? await this.loadResultsIntoMemory(variantAnalysisId, variantAnalysisStoragePath, repositoryFullName); } + public areResultsLoaded( + variantAnalysisId: number, + repositoryFullName: string + ): boolean { + return this.cachedResults.has(createCacheKey(variantAnalysisId, repositoryFullName)); + } + private async loadResultsIntoMemory( variantAnalysisId: number, variantAnalysisStoragePath: string, @@ -104,7 +111,7 @@ export class VariantAnalysisResultsManager extends DisposableObject { return result; } - private async loadResultsFromStorage( + public async loadResultsFromStorage( variantAnalysisId: number, variantAnalysisStoragePath: string, repositoryFullName: string, diff --git a/extensions/ql-vscode/src/vscode-tests/no-workspace/remote-queries/export-results.test.ts b/extensions/ql-vscode/src/vscode-tests/no-workspace/remote-queries/export-results.test.ts index c6133eee6..4894995ab 100644 --- a/extensions/ql-vscode/src/vscode-tests/no-workspace/remote-queries/export-results.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/no-workspace/remote-queries/export-results.test.ts @@ -8,12 +8,12 @@ import { createMockExtensionContext } from '../index'; import { Credentials } from '../../../authentication'; import { MarkdownFile } from '../../../remote-queries/remote-queries-markdown-generation'; import * as ghApiClient from '../../../remote-queries/gh-api/gh-api-client'; -import { exportResultsToGist } from '../../../remote-queries/export-results'; +import { exportRemoteQueryAnalysisResults } from '../../../remote-queries/export-results'; const proxyquire = pq.noPreserveCache(); describe('export results', async function() { - describe('exportResultsToGist', async function() { + describe('exportRemoteQueryAnalysisResults', async function() { let sandbox: sinon.SinonSandbox; let mockCredentials: Credentials; let mockResponse: sinon.SinonStub>; @@ -47,7 +47,7 @@ describe('export results', async function() { const query = JSON.parse(await fs.readFile(path.join(__dirname, '../data/remote-queries/query-with-results/query.json'), 'utf8')); const analysesResults = JSON.parse(await fs.readFile(path.join(__dirname, '../data/remote-queries/query-with-results/analyses-results.json'), 'utf8')); - await exportResultsToGist(ctx, query, analysesResults); + await exportRemoteQueryAnalysisResults(ctx, '', query, analysesResults, 'gist'); expect(mockCreateGist.calledOnce).to.be.true; expect(mockCreateGist.firstCall.args[1]).to.equal('Shell command built from environment values (javascript) 3 results (10 repositories)');