Add exporting of variant analysis results

This adds the export of variant analysis results. This is unfortunately
a larger change than I would have liked because there are many
differences in the types and I think further unification of the code
might make it less clear and would actually make this code harder to
read when the remote queries code is removed.

In general, the idea for the export of a variant analysis follows the
same process as the export of remote queries, with the difference being
that variant analysis results are loaded on-the-fly from dis, rather
than only loading from memory. This means it should use less memory, but
it also means that the export is slower.
This commit is contained in:
Koen Vlaswinkel
2022-11-11 13:54:23 +01:00
parent 62453d12c6
commit 42c642df25
7 changed files with 299 additions and 56 deletions

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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<void> {
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<void> {
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<QuickPickItem> {
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<void> {
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`);

View File

@@ -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<MarkdownFile[]> {
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(
'<br />',
'',
);
// 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:
* <details>
* <details>
* <summary>title</summary>
*
*
* contents
*
*
* </details>
*/
function buildExpandableMarkdownSection(title: string, contents: string[]): string[] {

View File

@@ -142,13 +142,34 @@ export class VariantAnalysisManager extends DisposableObject implements VariantA
return this.variantAnalyses.size;
}
public async loadResults(variantAnalysisId: number, repositoryFullName: string): Promise<void> {
public async loadResults(variantAnalysisId: number, repositoryFullName: string): Promise<VariantAnalysisScannedRepositoryResult> {
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<VariantAnalysisScannedRepositoryResult> {
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<boolean> {

View File

@@ -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,

View File

@@ -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<any, Promise<{ status: number }>>;
@@ -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)');