Merge branch 'koesie10/filter-export-copy' into koesie10/selected-copy

This commit is contained in:
Koen Vlaswinkel
2022-11-15 16:13:29 +01:00
30 changed files with 921 additions and 399 deletions

View File

@@ -325,7 +325,7 @@
"title": "CodeQL: Run Variant Analysis" "title": "CodeQL: Run Variant Analysis"
}, },
{ {
"command": "codeQL.exportVariantAnalysisResults", "command": "codeQL.exportSelectedVariantAnalysisResults",
"title": "CodeQL: Export Variant Analysis Results" "title": "CodeQL: Export Variant Analysis Results"
}, },
{ {
@@ -954,7 +954,7 @@
"when": "config.codeQL.canary && config.codeQL.variantAnalysis.liveResults" "when": "config.codeQL.canary && config.codeQL.variantAnalysis.liveResults"
}, },
{ {
"command": "codeQL.exportVariantAnalysisResults", "command": "codeQL.exportSelectedVariantAnalysisResults",
"when": "config.codeQL.canary" "when": "config.codeQL.canary"
}, },
{ {

View File

@@ -1,8 +1,17 @@
import { Disposable } from '../pure/disposable-object';
import { AppEventEmitter } from './events'; import { AppEventEmitter } from './events';
export interface App { export interface App {
createEventEmitter<T>(): AppEventEmitter<T>; createEventEmitter<T>(): AppEventEmitter<T>;
mode: AppMode;
subscriptions: Disposable[];
extensionPath: string; extensionPath: string;
globalStoragePath: string; globalStoragePath: string;
workspaceStoragePath?: string; workspaceStoragePath?: string;
} }
export enum AppMode {
Production = 1,
Development = 2,
Test = 3,
}

View File

@@ -1,5 +1,6 @@
import * as vscode from 'vscode'; import * as vscode from 'vscode';
import { App } from '../app'; import { Disposable } from '../../pure/disposable-object';
import { App, AppMode } from '../app';
import { AppEventEmitter } from '../events'; import { AppEventEmitter } from '../events';
import { VSCodeAppEventEmitter } from './events'; import { VSCodeAppEventEmitter } from './events';
@@ -21,6 +22,21 @@ export class ExtensionApp implements App {
return this.extensionContext.storageUri?.fsPath; return this.extensionContext.storageUri?.fsPath;
} }
public get subscriptions(): Disposable[] {
return this.extensionContext.subscriptions;
}
public get mode(): AppMode {
switch (this.extensionContext.extensionMode) {
case vscode.ExtensionMode.Development:
return AppMode.Development;
case vscode.ExtensionMode.Test:
return AppMode.Test;
default:
return AppMode.Production;
}
}
public createEventEmitter<T>(): AppEventEmitter<T> { public createEventEmitter<T>(): AppEventEmitter<T> {
return new VSCodeAppEventEmitter<T>(); return new VSCodeAppEventEmitter<T>();
} }

View File

@@ -6,8 +6,12 @@ import { DisposableObject } from '../pure/disposable-object';
import { DbConfigValidator } from './db-config-validator'; import { DbConfigValidator } from './db-config-validator';
import { ValueResult } from '../common/value-result'; import { ValueResult } from '../common/value-result';
import { App } from '../common/app'; import { App } from '../common/app';
import { AppEvent, AppEventEmitter } from '../common/events';
export class DbConfigStore extends DisposableObject { export class DbConfigStore extends DisposableObject {
public readonly onDidChangeConfig: AppEvent<void>;
private readonly onDidChangeConfigEventEmitter: AppEventEmitter<void>;
private readonly configPath: string; private readonly configPath: string;
private readonly configValidator: DbConfigValidator; private readonly configValidator: DbConfigValidator;
@@ -25,6 +29,8 @@ export class DbConfigStore extends DisposableObject {
this.configErrors = []; this.configErrors = [];
this.configWatcher = undefined; this.configWatcher = undefined;
this.configValidator = new DbConfigValidator(app.extensionPath); this.configValidator = new DbConfigValidator(app.extensionPath);
this.onDidChangeConfigEventEmitter = app.createEventEmitter<void>();
this.onDidChangeConfig = this.onDidChangeConfigEventEmitter.event;
} }
public async initialize(): Promise<void> { public async initialize(): Promise<void> {
@@ -85,6 +91,8 @@ export class DbConfigStore extends DisposableObject {
} }
this.config = this.configErrors.length === 0 ? newConfig : undefined; this.config = this.configErrors.length === 0 ? newConfig : undefined;
this.onDidChangeConfigEventEmitter.fire();
} }
private watchConfig(): void { private watchConfig(): void {
@@ -95,15 +103,17 @@ export class DbConfigStore extends DisposableObject {
private createEmptyConfig(): DbConfig { private createEmptyConfig(): DbConfig {
return { return {
remote: { databases: {
repositoryLists: [], remote: {
owners: [], repositoryLists: [],
repositories: [], owners: [],
}, repositories: [],
local: { },
lists: [], local: {
databases: [], lists: [],
}, databases: [],
},
}
}; };
} }
} }

View File

@@ -1,10 +1,25 @@
// Contains models for the data we want to store in the database config // Contains models for the data we want to store in the database config
export interface DbConfig { export interface DbConfig {
databases: DbConfigDatabases;
selected?: SelectedDbItem;
}
export interface DbConfigDatabases {
remote: RemoteDbConfig; remote: RemoteDbConfig;
local: LocalDbConfig; local: LocalDbConfig;
} }
export interface SelectedDbItem {
kind: SelectedDbItemKind;
value: string;
}
export enum SelectedDbItemKind {
ConfigDefined = 'configDefined',
RemoteSystemDefinedList = 'remoteSystemDefinedList',
}
export interface RemoteDbConfig { export interface RemoteDbConfig {
repositoryLists: RemoteRepositoryList[]; repositoryLists: RemoteRepositoryList[];
owners: string[]; owners: string[];
@@ -35,20 +50,26 @@ export interface LocalDatabase {
export function cloneDbConfig(config: DbConfig): DbConfig { export function cloneDbConfig(config: DbConfig): DbConfig {
return { return {
remote: { databases: {
repositoryLists: config.remote.repositoryLists.map((list) => ({ remote: {
name: list.name, repositoryLists: config.databases.remote.repositoryLists.map((list) => ({
repositories: [...list.repositories], name: list.name,
})), repositories: [...list.repositories],
owners: [...config.remote.owners], })),
repositories: [...config.remote.repositories], owners: [...config.databases.remote.owners],
}, repositories: [...config.databases.remote.repositories],
local: { },
lists: config.local.lists.map((list) => ({ local: {
name: list.name, lists: config.databases.local.lists.map((list) => ({
databases: list.databases.map((db) => ({ ...db })), name: list.name,
})), databases: list.databases.map((db) => ({ ...db })),
databases: config.local.databases.map((db) => ({ ...db })), })),
databases: config.databases.local.databases.map((db) => ({ ...db })),
},
}, },
selected: config.selected ? {
kind: config.selected.kind,
value: config.selected.value,
} : undefined
}; };
} }

View File

@@ -1,12 +1,24 @@
import { App } from '../common/app';
import { AppEvent, AppEventEmitter } from '../common/events';
import { ValueResult } from '../common/value-result'; import { ValueResult } from '../common/value-result';
import { DbConfigStore } from './db-config-store'; import { DbConfigStore } from './db-config-store';
import { DbItem } from './db-item'; import { DbItem } from './db-item';
import { createLocalTree, createRemoteTree } from './db-tree-creator'; import { createLocalTree, createRemoteTree } from './db-tree-creator';
export class DbManager { export class DbManager {
public readonly onDbItemsChanged: AppEvent<void>;
private readonly onDbItemsChangesEventEmitter: AppEventEmitter<void>;
constructor( constructor(
app: App,
private readonly dbConfigStore: DbConfigStore private readonly dbConfigStore: DbConfigStore
) { ) {
this.onDbItemsChangesEventEmitter = app.createEventEmitter<void>();
this.onDbItemsChanged = this.onDbItemsChangesEventEmitter.event;
this.dbConfigStore.onDidChangeConfig(() => {
this.onDbItemsChangesEventEmitter.fire();
});
} }
public getDbItems(): ValueResult<DbItem[]> { public getDbItems(): ValueResult<DbItem[]> {

View File

@@ -1,5 +1,4 @@
import * as vscode from 'vscode'; import { App, AppMode } from '../common/app';
import { ExtensionApp } from '../common/vscode/vscode-app';
import { isCanary, isNewQueryRunExperienceEnabled } from '../config'; import { isCanary, isNewQueryRunExperienceEnabled } from '../config';
import { logger } from '../logging'; import { logger } from '../logging';
import { DisposableObject } from '../pure/disposable-object'; import { DisposableObject } from '../pure/disposable-object';
@@ -8,10 +7,8 @@ import { DbManager } from './db-manager';
import { DbPanel } from './ui/db-panel'; import { DbPanel } from './ui/db-panel';
export class DbModule extends DisposableObject { export class DbModule extends DisposableObject {
public async initialize( public async initialize(app: App): Promise<void> {
extensionContext: vscode.ExtensionContext if (app.mode !== AppMode.Development ||
): Promise<void> {
if (extensionContext.extensionMode !== vscode.ExtensionMode.Development ||
!isCanary() || !isCanary() ||
!isNewQueryRunExperienceEnabled()) { !isNewQueryRunExperienceEnabled()) {
// Currently, we only want to expose the new database panel when we // Currently, we only want to expose the new database panel when we
@@ -22,25 +19,20 @@ export class DbModule extends DisposableObject {
void logger.log('Initializing database module'); void logger.log('Initializing database module');
const app = new ExtensionApp(extensionContext);
const dbConfigStore = new DbConfigStore(app); const dbConfigStore = new DbConfigStore(app);
await dbConfigStore.initialize(); await dbConfigStore.initialize();
const dbManager = new DbManager(dbConfigStore); const dbManager = new DbManager(app, dbConfigStore);
const dbPanel = new DbPanel(dbManager); const dbPanel = new DbPanel(dbManager);
await dbPanel.initialize(); await dbPanel.initialize();
extensionContext.subscriptions.push(dbPanel);
this.push(dbPanel); this.push(dbPanel);
this.push(dbConfigStore); this.push(dbConfigStore);
} }
} }
export async function initializeDbModule( export async function initializeDbModule(app: App): Promise<DbModule> {
extensionContext: vscode.ExtensionContext
): Promise<DbModule> {
const dbModule = new DbModule(); const dbModule = new DbModule();
await dbModule.initialize(extensionContext); await dbModule.initialize(app);
return dbModule; return dbModule;
} }

View File

@@ -16,9 +16,9 @@ export function createRemoteTree(dbConfig: DbConfig): RootRemoteDbItem {
createSystemDefinedList(1000) createSystemDefinedList(1000)
]; ];
const userDefinedRepoLists = dbConfig.remote.repositoryLists.map(createUserDefinedList); const userDefinedRepoLists = dbConfig.databases.remote.repositoryLists.map(createUserDefinedList);
const owners = dbConfig.remote.owners.map(createOwnerItem); const owners = dbConfig.databases.remote.owners.map(createOwnerItem);
const repos = dbConfig.remote.repositories.map(createRepoItem); const repos = dbConfig.databases.remote.repositories.map(createRepoItem);
return { return {
kind: DbItemKind.RootRemote, kind: DbItemKind.RootRemote,

View File

@@ -1,15 +1,29 @@
import { ProviderResult, TreeDataProvider, TreeItem } from 'vscode'; import { Event, EventEmitter, ProviderResult, TreeDataProvider, TreeItem } from 'vscode';
import { createDbTreeViewItemError, DbTreeViewItem } from './db-tree-view-item'; import { createDbTreeViewItemError, DbTreeViewItem } from './db-tree-view-item';
import { DbManager } from '../db-manager'; import { DbManager } from '../db-manager';
import { mapDbItemToTreeViewItem } from './db-item-mapper'; import { mapDbItemToTreeViewItem } from './db-item-mapper';
import { DisposableObject } from '../../pure/disposable-object';
export class DbTreeDataProvider implements TreeDataProvider<DbTreeViewItem> { export class DbTreeDataProvider extends DisposableObject implements TreeDataProvider<DbTreeViewItem> {
// This is an event to signal that there's been a change in the tree which
// will case the view to refresh. It is part of the TreeDataProvider interface.
public readonly onDidChangeTreeData: Event<DbTreeViewItem | undefined>;
private _onDidChangeTreeData = this.push(new EventEmitter<DbTreeViewItem | undefined>());
private dbTreeItems: DbTreeViewItem[]; private dbTreeItems: DbTreeViewItem[];
public constructor( public constructor(
private readonly dbManager: DbManager private readonly dbManager: DbManager
) { ) {
super();
this.dbTreeItems = this.createTree(); this.dbTreeItems = this.createTree();
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
dbManager.onDbItemsChanged(() => {
this.dbTreeItems = this.createTree();
this._onDidChangeTreeData.fire(undefined);
});
} }
/** /**

View File

@@ -98,7 +98,11 @@ import { RemoteQueryResult } from './remote-queries/remote-query-result';
import { URLSearchParams } from 'url'; import { URLSearchParams } from 'url';
import { handleDownloadPacks, handleInstallPackDependencies } from './packaging'; import { handleDownloadPacks, handleInstallPackDependencies } from './packaging';
import { HistoryItemLabelProvider } from './history-item-label-provider'; import { HistoryItemLabelProvider } from './history-item-label-provider';
import { exportRemoteQueryResults } from './remote-queries/export-results'; import {
exportRemoteQueryResults,
exportSelectedRemoteQueryResults,
exportVariantAnalysisResults
} from './remote-queries/export-results';
import { RemoteQuery } from './remote-queries/remote-query'; import { RemoteQuery } from './remote-queries/remote-query';
import { EvalLogViewer } from './eval-log-viewer'; import { EvalLogViewer } from './eval-log-viewer';
import { SummaryLanguageSupport } from './log-insights/summary-language-support'; import { SummaryLanguageSupport } from './log-insights/summary-language-support';
@@ -116,6 +120,7 @@ import { createVariantAnalysisContentProvider } from './remote-queries/variant-a
import { VSCodeMockGitHubApiServer } from './mocks/vscode-mock-gh-api-server'; import { VSCodeMockGitHubApiServer } from './mocks/vscode-mock-gh-api-server';
import { VariantAnalysisResultsManager } from './remote-queries/variant-analysis-results-manager'; import { VariantAnalysisResultsManager } from './remote-queries/variant-analysis-results-manager';
import { initializeDbModule } from './databases/db-module'; import { initializeDbModule } from './databases/db-module';
import { ExtensionApp } from './common/vscode/vscode-app';
import { RepositoriesFilterSortStateWithIds } from './pure/variant-analysis-filter-sort'; import { RepositoriesFilterSortStateWithIds } from './pure/variant-analysis-filter-sort';
/** /**
@@ -992,11 +997,23 @@ async function activateWithInstalledDistribution(
})); }));
ctx.subscriptions.push( ctx.subscriptions.push(
commandRunner('codeQL.exportVariantAnalysisResults', async (queryId?: string) => { commandRunner('codeQL.exportSelectedVariantAnalysisResults', async () => {
await exportSelectedRemoteQueryResults(qhm);
})
);
ctx.subscriptions.push(
commandRunner('codeQL.exportRemoteQueryResults', async (queryId: string) => {
await exportRemoteQueryResults(qhm, rqm, ctx, queryId); await exportRemoteQueryResults(qhm, rqm, ctx, queryId);
}) })
); );
ctx.subscriptions.push(
commandRunner('codeQL.exportVariantAnalysisResults', async (variantAnalysisId: number) => {
await exportVariantAnalysisResults(ctx, variantAnalysisManager, variantAnalysisId);
})
);
ctx.subscriptions.push( ctx.subscriptions.push(
commandRunner('codeQL.loadVariantAnalysisRepoResults', async (variantAnalysisId: number, repositoryFullName: string) => { commandRunner('codeQL.loadVariantAnalysisRepoResults', async (variantAnalysisId: number, repositoryFullName: string) => {
await variantAnalysisManager.loadResults(variantAnalysisId, repositoryFullName); await variantAnalysisManager.loadResults(variantAnalysisId, repositoryFullName);
@@ -1251,7 +1268,8 @@ async function activateWithInstalledDistribution(
void logger.log('Reading query history'); void logger.log('Reading query history');
await qhm.readQueryHistory(); await qhm.readQueryHistory();
const dbModule = await initializeDbModule(ctx); const app = new ExtensionApp(ctx);
const dbModule = await initializeDbModule(app);
ctx.subscriptions.push(dbModule); ctx.subscriptions.push(dbModule);
void logger.log('Successfully finished extension initialization.'); void logger.log('Successfully finished extension initialization.');

View File

@@ -30,7 +30,7 @@ export class MockGitHubApiServer extends DisposableObject {
return; return;
} }
this.server.listen(); this.server.listen({ onUnhandledRequest: 'bypass' });
this._isListening = true; this._isListening = true;
} }

View File

@@ -478,6 +478,10 @@ export interface CopyRepositoryListMessage {
filterSort?: RepositoriesFilterSortStateWithIds; filterSort?: RepositoriesFilterSortStateWithIds;
} }
export interface ExportResultsMessage {
t: 'exportResults';
}
export interface OpenLogsMessage { export interface OpenLogsMessage {
t: 'openLogs'; t: 'openLogs';
} }
@@ -497,5 +501,6 @@ export type FromVariantAnalysisMessage =
| OpenQueryFileMessage | OpenQueryFileMessage
| OpenQueryTextMessage | OpenQueryTextMessage
| CopyRepositoryListMessage | CopyRepositoryListMessage
| ExportResultsMessage
| OpenLogsMessage | OpenLogsMessage
| CancelVariantAnalysisMessage; | CancelVariantAnalysisMessage;

View File

@@ -1267,8 +1267,22 @@ export class QueryHistoryManager extends DisposableObject {
} }
} }
async handleExportResults(): Promise<void> { async handleExportResults(
await commands.executeCommand('codeQL.exportVariantAnalysisResults'); singleItem: QueryHistoryInfo,
multiSelect: QueryHistoryInfo[],
): Promise<void> {
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) {
return;
}
// 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);
}
} }
addQuery(item: QueryHistoryInfo) { addQuery(item: QueryHistoryInfo) {

View File

@@ -1,7 +1,7 @@
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs-extra'; 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 { Credentials } from '../authentication';
import { UserCancellationException } from '../commandRunner'; import { UserCancellationException } from '../commandRunner';
import { showInformationMessageWithAction } from '../helpers'; import { showInformationMessageWithAction } from '../helpers';
@@ -9,36 +9,54 @@ import { logger } from '../logging';
import { QueryHistoryManager } from '../query-history'; import { QueryHistoryManager } from '../query-history';
import { createGist } from './gh-api/gh-api-client'; import { createGist } from './gh-api/gh-api-client';
import { RemoteQueriesManager } from './remote-queries-manager'; 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 { RemoteQuery } from './remote-query';
import { AnalysisResults, sumAnalysesResults } from './shared/analysis-result'; import { AnalysisResults, sumAnalysesResults } from './shared/analysis-result';
import { RemoteQueryHistoryItem } from './remote-query-history-item';
import { pluralize } from '../pure/word'; 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 given or 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();
if (!queryHistoryItem || queryHistoryItem.t === 'local') {
throw new Error('No variant analysis results currently open. To open results, click an item in the query history view.');
}
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 {
assertNever(queryHistoryItem);
}
}
/**
* Exports the results of the given remote query.
* The user is prompted to select the export format. * The user is prompted to select the export format.
*/ */
export async function exportRemoteQueryResults( export async function exportRemoteQueryResults(
queryHistoryManager: QueryHistoryManager, queryHistoryManager: QueryHistoryManager,
remoteQueriesManager: RemoteQueriesManager, remoteQueriesManager: RemoteQueriesManager,
ctx: ExtensionContext, ctx: ExtensionContext,
queryId?: string, queryId: string,
): Promise<void> { ): Promise<void> {
let queryHistoryItem: RemoteQueryHistoryItem; const queryHistoryItem = queryHistoryManager.getRemoteQueryById(queryId);
if (queryId) { if (!queryHistoryItem) {
const query = queryHistoryManager.getRemoteQueryById(queryId); void logger.log(`Could not find query with id ${queryId}`);
if (!query) { throw new Error('There was an error when trying to retrieve variant analysis information');
void logger.log(`Could not find query with id ${queryId}`);
throw new Error('There was an error when trying to retrieve variant analysis information');
}
queryHistoryItem = query;
} else {
const query = queryHistoryManager.getCurrentQueryHistoryItem();
if (!query || query.t !== 'remote') {
throw new Error('No variant analysis results currently open. To open results, click an item in the query history view.');
}
queryHistoryItem = query;
} }
if (!queryHistoryItem.completed) { if (!queryHistoryItem.completed) {
@@ -49,32 +67,107 @@ export async function exportRemoteQueryResults(
const query = queryHistoryItem.remoteQuery; const query = queryHistoryItem.remoteQuery;
const analysesResults = remoteQueriesManager.getAnalysesResults(queryHistoryItem.queryId); 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;
}
const result = await variantAnalysisManager.loadResults(variantAnalysis.id, repo.repository.fullName, {
skipCacheStore: true,
});
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 = { const gistOption = {
label: '$(ports-open-browser-icon) Create Gist (GitHub)', label: '$(ports-open-browser-icon) Create Gist (GitHub)',
}; };
const localMarkdownOption = { const localMarkdownOption = {
label: '$(markdown) Save as markdown', 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( const exportFormat = await window.showQuickPick(
options, [
gistOption,
localMarkdownOption,
],
{ {
placeHolder: 'Select export format', placeHolder: 'Select export format',
canPickMany: false, canPickMany: false,
@@ -84,20 +177,38 @@ async function determineExportFormat(
if (!exportFormat || !exportFormat.label) { if (!exportFormat || !exportFormat.label) {
throw new UserCancellationException('No export format selected', true); throw new UserCancellationException('No export format selected', true);
} }
return exportFormat;
if (exportFormat === gistOption) {
return 'gist';
}
if (exportFormat === localMarkdownOption) {
return 'local';
}
return undefined;
} }
/** export async function exportResults(
* Converts the results of a remote query to markdown and uploads the files as a secret gist.
*/
export async function exportResultsToGist(
ctx: ExtensionContext, ctx: ExtensionContext,
query: RemoteQuery, exportDirectory: string,
analysesResults: AnalysisResults[] description: string,
): Promise<void> { 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 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 // Convert markdownFiles to the appropriate format for uploading to gist
const gistFiles = markdownFiles.reduce((acc, cur) => { const gistFiles = markdownFiles.reduce((acc, cur) => {
acc[`${cur.fileName}.md`] = { content: cur.content.join('\n') }; acc[`${cur.fileName}.md`] = { content: cur.content.join('\n') };
@@ -128,16 +239,25 @@ const buildGistDescription = (query: RemoteQuery, analysesResults: AnalysisResul
}; };
/** /**
* Converts the results of a remote query to markdown and saves the files locally * Builds Gist description
* in the query directory (where query results and metadata are also saved). * Ex: Empty Block (Go) x results (y repositories)
*/ */
async function exportResultsToLocalMarkdown( const buildVariantAnalysisGistDescription = (variantAnalysis: VariantAnalysis) => {
queryDirectoryPath: string, const resultCount = variantAnalysis.scannedRepos?.reduce((acc, item) => acc + (item.resultCount ?? 0), 0) ?? 0;
query: RemoteQuery, const resultLabel = pluralize(resultCount, 'result', 'results');
analysesResults: AnalysisResults[]
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(exportDirectory, 'exported-results');
const exportedResultsPath = path.join(queryDirectoryPath, 'exported-results');
await fs.ensureDir(exportedResultsPath); await fs.ensureDir(exportedResultsPath);
for (const markdownFile of markdownFiles) { for (const markdownFile of markdownFiles) {
const filePath = path.join(exportedResultsPath, `${markdownFile.fileName}.md`); 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 { convertNonPrintableChars } from '../text-utils';
import { RemoteQuery } from './remote-query'; import { RemoteQuery } from './remote-query';
import { AnalysisAlert, AnalysisRawResults, AnalysisResults, CodeSnippet, FileLink, getAnalysisResultCount, HighlightedRegion } from './shared/analysis-result'; 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'; export type MarkdownLinkType = 'local' | 'gist';
@@ -57,6 +62,51 @@ export function generateMarkdown(
return [summaryFile, ...resultsFiles]; 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 { export function generateMarkdownSummary(query: RemoteQuery): MarkdownFile {
const lines: string[] = []; const lines: string[] = [];
// Title // 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[] { function generateMarkdownForInterpretedResult(interpretedResult: AnalysisAlert, language: string): string[] {
const lines: string[] = []; const lines: string[] = [];
lines.push(createMarkdownRemoteFileRef( lines.push(createMarkdownRemoteFileRef(
@@ -296,11 +384,11 @@ export function createMarkdownRemoteFileRef(
/** /**
* Builds an expandable markdown section of the form: * Builds an expandable markdown section of the form:
* <details> * <details>
* <summary>title</summary> * <summary>title</summary>
* *
* contents * contents
* *
* </details> * </details>
*/ */
function buildExpandableMarkdownSection(title: string, contents: string[]): string[] { function buildExpandableMarkdownSection(title: string, contents: string[]): string[] {

View File

@@ -146,7 +146,7 @@ export class RemoteQueriesView extends AbstractWebview<ToRemoteQueriesMessage, F
await this.downloadAllAnalysesResults(msg); await this.downloadAllAnalysesResults(msg);
break; break;
case 'remoteQueryExportResults': case 'remoteQueryExportResults':
await commands.executeCommand('codeQL.exportVariantAnalysisResults', msg.queryId); await commands.executeCommand('codeQL.exportRemoteQueryResults', msg.queryId);
break; break;
default: default:
assertNever(msg); assertNever(msg);

View File

@@ -18,7 +18,7 @@ import {
import { getErrorMessage } from '../pure/helpers-pure'; import { getErrorMessage } from '../pure/helpers-pure';
import { VariantAnalysisView } from './variant-analysis-view'; import { VariantAnalysisView } from './variant-analysis-view';
import { VariantAnalysisViewManager } from './variant-analysis-view-manager'; import { VariantAnalysisViewManager } from './variant-analysis-view-manager';
import { VariantAnalysisResultsManager } from './variant-analysis-results-manager'; import { LoadResultsOptions, VariantAnalysisResultsManager } from './variant-analysis-results-manager';
import { getControllerRepo, getQueryName, prepareRemoteQueryRun } from './run-remote-query'; import { getControllerRepo, getQueryName, prepareRemoteQueryRun } from './run-remote-query';
import { import {
processUpdatedVariantAnalysis, processUpdatedVariantAnalysis,
@@ -214,13 +214,13 @@ export class VariantAnalysisManager extends DisposableObject implements VariantA
return this.variantAnalyses.size; return this.variantAnalyses.size;
} }
public async loadResults(variantAnalysisId: number, repositoryFullName: string): Promise<void> { public async loadResults(variantAnalysisId: number, repositoryFullName: string, options?: LoadResultsOptions): Promise<VariantAnalysisScannedRepositoryResult> {
const variantAnalysis = this.variantAnalyses.get(variantAnalysisId); const variantAnalysis = this.variantAnalyses.get(variantAnalysisId);
if (!variantAnalysis) { if (!variantAnalysis) {
throw new Error(`No variant analysis with id: ${variantAnalysisId}`); 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, options);
} }
private async variantAnalysisRecordExists(variantAnalysisId: number): Promise<boolean> { private async variantAnalysisRecordExists(variantAnalysisId: number): Promise<boolean> {

View File

@@ -28,6 +28,12 @@ export type ResultDownloadedEvent = {
repoTask: VariantAnalysisRepositoryTask; repoTask: VariantAnalysisRepositoryTask;
} }
export type LoadResultsOptions = {
// If true, when results are loaded from storage, they will not be stored in the cache. This reduces memory usage if
// results are only needed temporarily (e.g. for exporting results to a different format).
skipCacheStore?: boolean;
}
export class VariantAnalysisResultsManager extends DisposableObject { export class VariantAnalysisResultsManager extends DisposableObject {
private static readonly REPO_TASK_FILENAME = 'repo_task.json'; private static readonly REPO_TASK_FILENAME = 'repo_task.json';
private static readonly RESULTS_DIRECTORY = 'results'; private static readonly RESULTS_DIRECTORY = 'results';
@@ -86,11 +92,19 @@ export class VariantAnalysisResultsManager extends DisposableObject {
public async loadResults( public async loadResults(
variantAnalysisId: number, variantAnalysisId: number,
variantAnalysisStoragePath: string, variantAnalysisStoragePath: string,
repositoryFullName: string repositoryFullName: string,
options?: LoadResultsOptions,
): Promise<VariantAnalysisScannedRepositoryResult> { ): Promise<VariantAnalysisScannedRepositoryResult> {
const result = this.cachedResults.get(createCacheKey(variantAnalysisId, repositoryFullName)); const result = this.cachedResults.get(createCacheKey(variantAnalysisId, repositoryFullName));
if (result) {
return result;
}
return result ?? await this.loadResultsIntoMemory(variantAnalysisId, variantAnalysisStoragePath, repositoryFullName); if (options?.skipCacheStore) {
return this.loadResultsFromStorage(variantAnalysisId, variantAnalysisStoragePath, repositoryFullName);
}
return this.loadResultsIntoMemory(variantAnalysisId, variantAnalysisStoragePath, repositoryFullName);
} }
private async loadResultsIntoMemory( private async loadResultsIntoMemory(

View File

@@ -106,6 +106,9 @@ export class VariantAnalysisView extends AbstractWebview<ToVariantAnalysisMessag
case 'copyRepositoryList': case 'copyRepositoryList':
void commands.executeCommand('codeQL.copyVariantAnalysisRepoList', this.variantAnalysisId, msg.filterSort); void commands.executeCommand('codeQL.copyVariantAnalysisRepoList', this.variantAnalysisId, msg.filterSort);
break; break;
case 'exportResults':
void commands.executeCommand('codeQL.exportVariantAnalysisResults', this.variantAnalysisId);
break;
case 'openLogs': case 'openLogs':
await this.openLogs(); await this.openLogs();
break; break;

View File

@@ -37,6 +37,12 @@ const stopQuery = () => {
}); });
}; };
const exportResults = () => {
vscode.postMessage({
t: 'exportResults',
});
};
const openLogs = () => { const openLogs = () => {
vscode.postMessage({ vscode.postMessage({
t: 'openLogs', t: 'openLogs',
@@ -110,7 +116,7 @@ export function VariantAnalysis({
onViewQueryTextClick={openQueryText} onViewQueryTextClick={openQueryText}
onStopQueryClick={stopQuery} onStopQueryClick={stopQuery}
onCopyRepositoryListClick={copyRepositoryList} onCopyRepositoryListClick={copyRepositoryList}
onExportResultsClick={() => console.log('Export results')} onExportResultsClick={exportResults}
onViewLogsClick={openLogs} onViewLogsClick={openLogs}
/> />
<VariantAnalysisOutcomePanels <VariantAnalysisOutcomePanels

View File

@@ -31,10 +31,12 @@ describe('db panel', async () => {
globalStoragePath, globalStoragePath,
workspaceStoragePath workspaceStoragePath
}); });
await fs.ensureDir(workspaceStoragePath);
const app = new ExtensionApp(extensionContext); const app = new ExtensionApp(extensionContext);
dbConfigStore = new DbConfigStore(app); dbConfigStore = new DbConfigStore(app);
dbManager = new DbManager(dbConfigStore); dbManager = new DbManager(app, dbConfigStore);
// Create a modified version of the DbPanel module that allows // Create a modified version of the DbPanel module that allows
// us to override the creation of the DbTreeDataProvider // us to override the creation of the DbTreeDataProvider
@@ -63,14 +65,16 @@ describe('db panel', async () => {
it('should render default local and remote nodes when the config is empty', async () => { it('should render default local and remote nodes when the config is empty', async () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [], remote: {
owners: [], repositoryLists: [],
repositories: [] owners: [],
}, repositories: []
local: { },
lists: [], local: {
databases: [] lists: [],
databases: []
},
}, },
}; };
@@ -109,30 +113,32 @@ describe('db panel', async () => {
it('should render remote repository list nodes', async () => { it('should render remote repository list nodes', async () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [ remote: {
{ repositoryLists: [
name: 'my-list-1', {
repositories: [ name: 'my-list-1',
'owner1/repo1', repositories: [
'owner1/repo2' 'owner1/repo1',
] 'owner1/repo2'
}, ]
{ },
name: 'my-list-2', {
repositories: [ name: 'my-list-2',
'owner1/repo1', repositories: [
'owner2/repo1', 'owner1/repo1',
'owner2/repo2' 'owner2/repo1',
] 'owner2/repo2'
}, ]
], },
owners: [], ],
repositories: [] owners: [],
}, repositories: []
local: { },
lists: [], local: {
databases: [] lists: [],
databases: []
},
}, },
}; };
@@ -161,14 +167,16 @@ describe('db panel', async () => {
it('should render owner list nodes', async () => { it('should render owner list nodes', async () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [], remote: {
owners: ['owner1', 'owner2'], repositoryLists: [],
repositories: [] owners: ['owner1', 'owner2'],
}, repositories: []
local: { },
lists: [], local: {
databases: [] lists: [],
databases: []
},
}, },
}; };
@@ -194,14 +202,16 @@ describe('db panel', async () => {
it('should render repository nodes', async () => { it('should render repository nodes', async () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [], remote: {
owners: [], repositoryLists: [],
repositories: ['owner1/repo1', 'owner1/repo2'] owners: [],
}, repositories: ['owner1/repo1', 'owner1/repo2']
local: { },
lists: [], local: {
databases: [] lists: [],
databases: []
},
}, },
}; };
@@ -228,8 +238,10 @@ describe('db panel', async () => {
async function saveDbConfig(dbConfig: DbConfig): Promise<void> { async function saveDbConfig(dbConfig: DbConfig): Promise<void> {
await fs.writeJson(dbConfigFilePath, dbConfig); await fs.writeJson(dbConfigFilePath, dbConfig);
// Once we have watching of the db config, this can happen // Ideally we would just initialise the db config store at the start
// at the start of the test. // of each test and then rely on the file watcher to update the config.
// However, this requires adding sleep to the tests to allow for the
// file watcher to catch up, so we instead initialise the config store here.
await dbConfigStore.initialize(); await dbConfigStore.initialize();
dbTreeDataProvider = new DbTreeDataProvider(dbManager); dbTreeDataProvider = new DbTreeDataProvider(dbManager);
} }

View File

@@ -495,6 +495,96 @@ describe('query-history', () => {
}); });
}); });
describe('handleCopyRepoList', () => {
it('should not call a command for a local query', async () => {
queryHistoryManager = await createMockQueryHistory(localQueryHistory);
const item = localQueryHistory[4];
await queryHistoryManager.handleCopyRepoList(item, [item]);
expect(executeCommandSpy).to.not.have.been.called;
});
it('should copy repo list for a single remote query', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item = remoteQueryHistory[1];
await queryHistoryManager.handleCopyRepoList(item, [item]);
expect(executeCommandSpy).to.have.been.calledWith('codeQL.copyRepoList', item.queryId);
});
it('should not copy repo list for multiple remote queries', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item1 = remoteQueryHistory[1];
const item2 = remoteQueryHistory[3];
await queryHistoryManager.handleCopyRepoList(item1, [item1, item2]);
expect(executeCommandSpy).not.to.have.been.called;
});
it('should copy repo list for a single variant analysis', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item = variantAnalysisHistory[1];
await queryHistoryManager.handleCopyRepoList(item, [item]);
expect(executeCommandSpy).to.have.been.calledWith('codeQL.copyVariantAnalysisRepoList', item.variantAnalysis.id);
});
it('should not copy repo list for multiple variant analyses', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item1 = variantAnalysisHistory[1];
const item2 = variantAnalysisHistory[3];
await queryHistoryManager.handleCopyRepoList(item1, [item1, item2]);
expect(executeCommandSpy).not.to.have.been.called;
});
});
describe('handleExportResults', () => {
it('should not call a command for a local query', async () => {
queryHistoryManager = await createMockQueryHistory(localQueryHistory);
const item = localQueryHistory[4];
await queryHistoryManager.handleExportResults(item, [item]);
expect(executeCommandSpy).to.not.have.been.called;
});
it('should export results for a single remote query', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item = remoteQueryHistory[1];
await queryHistoryManager.handleExportResults(item, [item]);
expect(executeCommandSpy).to.have.been.calledWith('codeQL.exportRemoteQueryResults', item.queryId);
});
it('should not export results for multiple remote queries', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item1 = remoteQueryHistory[1];
const item2 = remoteQueryHistory[3];
await queryHistoryManager.handleExportResults(item1, [item1, item2]);
expect(executeCommandSpy).not.to.have.been.called;
});
it('should export results for a single variant analysis', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item = variantAnalysisHistory[1];
await queryHistoryManager.handleExportResults(item, [item]);
expect(executeCommandSpy).to.have.been.calledWith('codeQL.exportVariantAnalysisResults', item.variantAnalysis.id);
});
it('should not export results for multiple variant analyses', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
const item1 = variantAnalysisHistory[1];
const item2 = variantAnalysisHistory[3];
await queryHistoryManager.handleExportResults(item1, [item1, item2]);
expect(executeCommandSpy).not.to.have.been.called;
});
});
describe('determineSelection', () => { describe('determineSelection', () => {
const singleItem = 'a'; const singleItem = 'a';
const multipleItems = ['b', 'c', 'd']; const multipleItems = ['b', 'c', 'd'];

View File

@@ -8,12 +8,12 @@ import { createMockExtensionContext } from '../index';
import { Credentials } from '../../../authentication'; import { Credentials } from '../../../authentication';
import { MarkdownFile } from '../../../remote-queries/remote-queries-markdown-generation'; import { MarkdownFile } from '../../../remote-queries/remote-queries-markdown-generation';
import * as ghApiClient from '../../../remote-queries/gh-api/gh-api-client'; 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(); const proxyquire = pq.noPreserveCache();
describe('export results', async function() { describe('export results', async function() {
describe('exportResultsToGist', async function() { describe('exportRemoteQueryAnalysisResults', async function() {
let sandbox: sinon.SinonSandbox; let sandbox: sinon.SinonSandbox;
let mockCredentials: Credentials; let mockCredentials: Credentials;
let mockResponse: sinon.SinonStub<any, Promise<{ status: number }>>; 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 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')); 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.calledOnce).to.be.true;
expect(mockCreateGist.firstCall.args[1]).to.equal('Shell command built from environment values (javascript) 3 results (10 repositories)'); expect(mockCreateGist.firstCall.args[1]).to.equal('Shell command built from environment values (javascript) 3 results (10 repositories)');

View File

@@ -1,4 +1,4 @@
import { App } from '../../src/common/app'; import { App, AppMode } from '../../src/common/app';
import { AppEvent, AppEventEmitter } from '../../src/common/events'; import { AppEvent, AppEventEmitter } from '../../src/common/events';
import { Disposable } from '../../src/pure/disposable-object'; import { Disposable } from '../../src/pure/disposable-object';
@@ -6,7 +6,7 @@ export function createMockApp({
extensionPath = '/mock/extension/path', extensionPath = '/mock/extension/path',
workspaceStoragePath = '/mock/workspace/storage/path', workspaceStoragePath = '/mock/workspace/storage/path',
globalStoragePath = '/mock/global/storage/path', globalStoragePath = '/mock/global/storage/path',
createEventEmitter = <T>() => new MockAppEventEmitter<T>() createEventEmitter = <T>() => new MockAppEventEmitter<T>(),
}: { }: {
extensionPath?: string, extensionPath?: string,
workspaceStoragePath?: string, workspaceStoragePath?: string,
@@ -14,10 +14,12 @@ export function createMockApp({
createEventEmitter?: <T>() => AppEventEmitter<T> createEventEmitter?: <T>() => AppEventEmitter<T>
}): App { }): App {
return { return {
createEventEmitter, mode: AppMode.Test,
subscriptions: [],
extensionPath, extensionPath,
workspaceStoragePath, workspaceStoragePath,
globalStoragePath globalStoragePath,
createEventEmitter,
}; };
} }

View File

@@ -0,0 +1,13 @@
{
"databases": {
"remote": {
"repositoryLists": [],
"owners": [],
"repositories": []
},
"local": {
"lists": [],
"databases": []
}
}
}

View File

@@ -1,46 +1,52 @@
{ {
"remote": { "databases": {
"repositoryLists": [ "remote": {
{ "repositoryLists": [
"name": "repoList1", {
"repositories": ["foo/bar", "foo/baz"] "name": "repoList1",
} "repositories": ["foo/bar", "foo/baz"]
], }
"owners": [], ],
"repositories": ["owner/repo1", "owner/repo2", "owner/repo3"] "owners": [],
"repositories": ["owner/repo1", "owner/repo2", "owner/repo3"]
},
"local": {
"lists": [
{
"name": "localList1",
"databases": [
{
"name": "foo/bar",
"dateAdded": 1668096745193,
"language": "go",
"storagePath": "/path/to/database/"
}
]
},
{
"name": "localList2",
"databases": [
{
"name": "foo/baz",
"dateAdded": 1668096760848,
"language": "javascript",
"storagePath": "/path/to/database/"
}
]
}
],
"databases": [
{
"name": "example-db",
"dateAdded": 1668096927267,
"language": "ruby",
"storagePath": "/path/to/database/"
}
]
}
}, },
"local": { "selected": {
"lists": [ "kind": "configDefined",
{ "value": "path.to.database"
"name": "localList1",
"databases": [
{
"name": "foo/bar",
"dateAdded": 1668096745193,
"language": "go",
"storagePath": "/path/to/database/"
}
]
},
{
"name": "localList2",
"databases": [
{
"name": "foo/baz",
"dateAdded": 1668096760848,
"language": "javascript",
"storagePath": "/path/to/database/"
}
]
}
],
"databases": [
{
"name": "example-db",
"dateAdded": 1668096927267,
"language": "ruby",
"storagePath": "/path/to/database/"
}
]
} }
} }

View File

@@ -29,12 +29,14 @@ describe('db config store', async () => {
await configStore.initialize(); await configStore.initialize();
expect(await fs.pathExists(configPath)).to.be.true; expect(await fs.pathExists(configPath)).to.be.true;
const config = configStore.getConfig().value; const config = configStore.getConfig().value;
expect(config.remote.repositoryLists).to.be.empty; expect(config.databases.remote.repositoryLists).to.be.empty;
expect(config.remote.owners).to.be.empty; expect(config.databases.remote.owners).to.be.empty;
expect(config.remote.repositories).to.be.empty; expect(config.databases.remote.repositories).to.be.empty;
expect(config.local.lists).to.be.empty; expect(config.databases.local.lists).to.be.empty;
expect(config.local.databases).to.be.empty; expect(config.databases.local.databases).to.be.empty;
expect(config.selected).to.be.undefined;
}); });
it('should load an existing config', async () => { it('should load an existing config', async () => {
@@ -46,20 +48,20 @@ describe('db config store', async () => {
await configStore.initialize(); await configStore.initialize();
const config = configStore.getConfig().value; const config = configStore.getConfig().value;
expect(config.remote.repositoryLists).to.have.length(1); expect(config.databases.remote.repositoryLists).to.have.length(1);
expect(config.remote.repositoryLists[0]).to.deep.equal({ expect(config.databases.remote.repositoryLists[0]).to.deep.equal({
name: 'repoList1', name: 'repoList1',
repositories: ['foo/bar', 'foo/baz'] repositories: ['foo/bar', 'foo/baz']
}); });
expect(config.remote.owners).to.be.empty; expect(config.databases.remote.owners).to.be.empty;
expect(config.remote.repositories).to.have.length(3); expect(config.databases.remote.repositories).to.have.length(3);
expect(config.remote.repositories).to.deep.equal([ expect(config.databases.remote.repositories).to.deep.equal([
'owner/repo1', 'owner/repo1',
'owner/repo2', 'owner/repo2',
'owner/repo3', 'owner/repo3',
]); ]);
expect(config.local.lists).to.have.length(2); expect(config.databases.local.lists).to.have.length(2);
expect(config.local.lists[0]).to.deep.equal({ expect(config.databases.local.lists[0]).to.deep.equal({
name: 'localList1', name: 'localList1',
databases: [ databases: [
{ {
@@ -70,13 +72,32 @@ describe('db config store', async () => {
}, },
], ],
}); });
expect(config.local.databases).to.have.length(1); expect(config.databases.local.databases).to.have.length(1);
expect(config.local.databases[0]).to.deep.equal({ expect(config.databases.local.databases[0]).to.deep.equal({
name: 'example-db', name: 'example-db',
dateAdded: 1668096927267, dateAdded: 1668096927267,
language: 'ruby', language: 'ruby',
storagePath: '/path/to/database/', storagePath: '/path/to/database/',
}); });
expect(config.selected).to.deep.equal({
kind: 'configDefined',
value: 'path.to.database',
});
});
it('should load an existing config without selected db', async () => {
const testDataStoragePathWithout = path.join(__dirname, 'data', 'without-selected');
const app = createMockApp({
extensionPath,
workspaceStoragePath: testDataStoragePathWithout
});
const configStore = new DbConfigStore(app);
await configStore.initialize();
const config = configStore.getConfig().value;
expect(config.selected).to.be.undefined;
}); });
it('should not allow modification of the config', async () => { it('should not allow modification of the config', async () => {
@@ -88,9 +109,9 @@ describe('db config store', async () => {
await configStore.initialize(); await configStore.initialize();
const config = configStore.getConfig().value; const config = configStore.getConfig().value;
config.remote.repositoryLists = []; config.databases.remote.repositoryLists = [];
const reRetrievedConfig = configStore.getConfig().value; const reRetrievedConfig = configStore.getConfig().value;
expect(reRetrievedConfig.remote.repositoryLists).to.have.length(1); expect(reRetrievedConfig.databases.remote.repositoryLists).to.have.length(1);
}); });
}); });

View File

@@ -11,15 +11,17 @@ describe('db config validation', async () => {
// We're intentionally bypassing the type check because we'd // We're intentionally bypassing the type check because we'd
// like to make sure validation errors are highlighted. // like to make sure validation errors are highlighted.
const dbConfig = { const dbConfig = {
'remote': { 'databases': {
'repositoryLists': [ 'remote': {
{ 'repositoryLists': [
'name': 'repoList1', {
'repositories': ['foo/bar', 'foo/baz'] 'name': 'repoList1',
} 'repositories': ['foo/bar', 'foo/baz']
], }
'repositories': ['owner/repo1', 'owner/repo2', 'owner/repo3'], ],
'somethingElse': 'bar' 'repositories': ['owner/repo1', 'owner/repo2', 'owner/repo3'],
'somethingElse': 'bar'
}
} }
} as any as DbConfig; } as any as DbConfig;
@@ -27,8 +29,8 @@ describe('db config validation', async () => {
expect(validationOutput).to.have.length(3); expect(validationOutput).to.have.length(3);
expect(validationOutput[0]).to.deep.equal(' must have required property \'local\''); expect(validationOutput[0]).to.deep.equal('/databases must have required property \'local\'');
expect(validationOutput[1]).to.deep.equal('/remote must have required property \'owners\''); expect(validationOutput[1]).to.deep.equal('/databases/remote must have required property \'owners\'');
expect(validationOutput[2]).to.deep.equal('/remote must NOT have additional properties'); expect(validationOutput[2]).to.deep.equal('/databases/remote must NOT have additional properties');
}); });
}); });

View File

@@ -8,15 +8,17 @@ describe('db tree creator', () => {
describe('createRemoteTree', () => { describe('createRemoteTree', () => {
it('should build root node and system defined lists', () => { it('should build root node and system defined lists', () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [], remote: {
owners: [], repositoryLists: [],
repositories: [] owners: [],
repositories: []
},
local: {
lists: [],
databases: []
}
}, },
local: {
lists: [],
databases: []
}
}; };
const dbTreeRoot = createRemoteTree(dbConfig); const dbTreeRoot = createRemoteTree(dbConfig);
@@ -46,32 +48,34 @@ describe('db tree creator', () => {
it('should create remote user defined list nodes', () => { it('should create remote user defined list nodes', () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [ remote: {
{ repositoryLists: [
name: 'my-list-1', {
repositories: [ name: 'my-list-1',
'owner1/repo1', repositories: [
'owner1/repo2', 'owner1/repo1',
'owner2/repo1' 'owner1/repo2',
] 'owner2/repo1'
}, ]
{ },
name: 'my-list-2', {
repositories: [ name: 'my-list-2',
'owner3/repo1', repositories: [
'owner3/repo2', 'owner3/repo1',
'owner4/repo1' 'owner3/repo2',
] 'owner4/repo1'
} ]
], }
owners: [], ],
repositories: [] owners: [],
repositories: []
},
local: {
lists: [],
databases: []
},
}, },
local: {
lists: [],
databases: []
}
}; };
const dbTreeRoot = createRemoteTree(dbConfig); const dbTreeRoot = createRemoteTree(dbConfig);
@@ -85,16 +89,16 @@ describe('db tree creator', () => {
expect(repositoryListNodes.length).to.equal(2); expect(repositoryListNodes.length).to.equal(2);
expect(repositoryListNodes[0]).to.deep.equal({ expect(repositoryListNodes[0]).to.deep.equal({
kind: DbItemKind.RemoteUserDefinedList, kind: DbItemKind.RemoteUserDefinedList,
listName: dbConfig.remote.repositoryLists[0].name, listName: dbConfig.databases.remote.repositoryLists[0].name,
repos: dbConfig.remote.repositoryLists[0].repositories.map((repo) => ({ repos: dbConfig.databases.remote.repositoryLists[0].repositories.map((repo) => ({
kind: DbItemKind.RemoteRepo, kind: DbItemKind.RemoteRepo,
repoFullName: repo repoFullName: repo
})) }))
}); });
expect(repositoryListNodes[1]).to.deep.equal({ expect(repositoryListNodes[1]).to.deep.equal({
kind: DbItemKind.RemoteUserDefinedList, kind: DbItemKind.RemoteUserDefinedList,
listName: dbConfig.remote.repositoryLists[1].name, listName: dbConfig.databases.remote.repositoryLists[1].name,
repos: dbConfig.remote.repositoryLists[1].repositories.map((repo) => ({ repos: dbConfig.databases.remote.repositoryLists[1].repositories.map((repo) => ({
kind: DbItemKind.RemoteRepo, kind: DbItemKind.RemoteRepo,
repoFullName: repo repoFullName: repo
})) }))
@@ -103,17 +107,19 @@ describe('db tree creator', () => {
it('should create remote owner nodes', () => { it('should create remote owner nodes', () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [], remote: {
owners: [ repositoryLists: [],
'owner1', owners: [
'owner2' 'owner1',
], 'owner2'
repositories: [] ],
}, repositories: []
local: { },
lists: [], local: {
databases: [] lists: [],
databases: []
}
} }
}; };
@@ -128,28 +134,30 @@ describe('db tree creator', () => {
expect(ownerNodes.length).to.equal(2); expect(ownerNodes.length).to.equal(2);
expect(ownerNodes[0]).to.deep.equal({ expect(ownerNodes[0]).to.deep.equal({
kind: DbItemKind.RemoteOwner, kind: DbItemKind.RemoteOwner,
ownerName: dbConfig.remote.owners[0] ownerName: dbConfig.databases.remote.owners[0]
}); });
expect(ownerNodes[1]).to.deep.equal({ expect(ownerNodes[1]).to.deep.equal({
kind: DbItemKind.RemoteOwner, kind: DbItemKind.RemoteOwner,
ownerName: dbConfig.remote.owners[1] ownerName: dbConfig.databases.remote.owners[1]
}); });
}); });
it('should create remote repo nodes', () => { it('should create remote repo nodes', () => {
const dbConfig: DbConfig = { const dbConfig: DbConfig = {
remote: { databases: {
repositoryLists: [], remote: {
owners: [], repositoryLists: [],
repositories: [ owners: [],
'owner1/repo1', repositories: [
'owner1/repo2', 'owner1/repo1',
'owner2/repo1' 'owner1/repo2',
] 'owner2/repo1'
}, ]
local: { },
lists: [], local: {
databases: [] lists: [],
databases: []
},
} }
}; };
@@ -164,15 +172,15 @@ describe('db tree creator', () => {
expect(repoNodes.length).to.equal(3); expect(repoNodes.length).to.equal(3);
expect(repoNodes[0]).to.deep.equal({ expect(repoNodes[0]).to.deep.equal({
kind: DbItemKind.RemoteRepo, kind: DbItemKind.RemoteRepo,
repoFullName: dbConfig.remote.repositories[0] repoFullName: dbConfig.databases.remote.repositories[0]
}); });
expect(repoNodes[1]).to.deep.equal({ expect(repoNodes[1]).to.deep.equal({
kind: DbItemKind.RemoteRepo, kind: DbItemKind.RemoteRepo,
repoFullName: dbConfig.remote.repositories[1] repoFullName: dbConfig.databases.remote.repositories[1]
}); });
expect(repoNodes[2]).to.deep.equal({ expect(repoNodes[2]).to.deep.equal({
kind: DbItemKind.RemoteRepo, kind: DbItemKind.RemoteRepo,
repoFullName: dbConfig.remote.repositories[2] repoFullName: dbConfig.databases.remote.repositories[2]
}); });
}); });
}); });

View File

@@ -4,112 +4,138 @@
"$schema": { "$schema": {
"type": "string" "type": "string"
}, },
"remote": { "databases": {
"type": "object", "type": "object",
"properties": { "properties": {
"repositoryLists": { "remote": {
"type": "array", "type": "object",
"items": { "properties": {
"type": "object", "repositoryLists": {
"properties": { "type": "array",
"name": { "items": {
"type": "string" "type": "object",
}, "properties": {
"repositories": { "name": {
"type": "array", "type": "string"
"items": { },
"type": "string", "repositories": {
"pattern": "^[a-zA-Z0-9-_\\.]+/[a-zA-Z0-9-_\\.]+$" "type": "array",
} "items": {
"type": "string",
"pattern": "^[a-zA-Z0-9-_\\.]+/[a-zA-Z0-9-_\\.]+$"
}
}
},
"required": ["name", "repositories"],
"additionalProperties": false
} }
}, },
"required": ["name", "repositories"], "owners": {
"additionalProperties": false "type": "array",
} "items": {
"type": "string",
"pattern": "^[a-zA-Z0-9-_\\.]+$"
}
},
"repositories": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z0-9-_\\.]+/[a-zA-Z0-9-_\\.]+$"
}
}
},
"required": ["repositoryLists", "owners", "repositories"],
"additionalProperties": false
}, },
"owners": { "local": {
"type": "array", "type": "object",
"items": { "properties": {
"type": "string", "lists": {
"pattern": "^[a-zA-Z0-9-_\\.]+$" "type": "array",
} "items": {
}, "type": "object",
"repositories": { "properties": {
"type": "array", "name": {
"items": { "type": "string"
"type": "string", },
"pattern": "^[a-zA-Z0-9-_\\.]+/[a-zA-Z0-9-_\\.]+$" "databases": {
} "type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"dateAdded": {
"type": "number"
},
"language": {
"type": "string"
},
"storagePath": {
"type": "string"
}
},
"required": [
"name",
"dateAdded",
"language",
"storagePath"
],
"additionalProperties": false
}
}
},
"required": ["name", "databases"],
"additionalProperties": false
}
},
"databases": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"dateAdded": {
"type": "number"
},
"language": {
"type": "string"
},
"storagePath": {
"type": "string"
}
},
"required": ["name", "dateAdded", "language", "storagePath"],
"additionalProperties": false
}
}
},
"required": ["lists", "databases"],
"additionalProperties": false
} }
}, },
"required": ["repositoryLists", "owners", "repositories"], "required": ["remote", "local"],
"additionalProperties": false "additionalProperties": false
}, },
"local": { "selected": {
"type": "object", "type": "object",
"properties": { "properties": {
"lists": { "kind": {
"type": "array", "type": "string",
"items": { "enum": ["configDefined", "remoteSystemDefinedList"]
"type": "object",
"properties": {
"name": {
"type": "string"
},
"databases": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"dateAdded": {
"type": "number"
},
"language": {
"type": "string"
},
"storagePath": {
"type": "string"
}
},
"required": ["name", "dateAdded", "language", "storagePath"],
"additionalProperties": false
}
}
},
"required": ["name", "databases"],
"additionalProperties": false
}
}, },
"databases": { "value": {
"type": "array", "type": "string"
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"dateAdded": {
"type": "number"
},
"language": {
"type": "string"
},
"storagePath": {
"type": "string"
}
},
"required": ["name", "dateAdded", "language", "storagePath"],
"additionalProperties": false
}
} }
}, },
"required": ["lists", "databases"], "required": ["kind", "value"],
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": ["remote", "local"], "required": ["databases"],
"additionalProperties": false "additionalProperties": false
} }