Merge remote-tracking branch 'origin/main' into koesie10/fix-duplicate-downloads

This commit is contained in:
Koen Vlaswinkel
2022-11-07 09:32:37 +01:00
40 changed files with 1377 additions and 154 deletions

View File

@@ -15,7 +15,8 @@ const packageFiles = [
'snippets.json',
'media',
'node_modules',
'out'
'out',
'workspace-databases-schema.json'
];
async function copyPackage(sourcePath: string, destPath: string): Promise<void> {

View File

@@ -84,6 +84,12 @@
"editor.wordBasedSuggestions": false
}
},
"jsonValidation": [
{
"fileMatch": "workspace-databases.json",
"url": "./workspace-databases-schema.json"
}
],
"languages": [
{
"id": "ql",

View File

@@ -12,6 +12,7 @@ export class DbConfigStore extends DisposableObject {
public constructor(workspaceStoragePath: string) {
super();
this.configPath = path.join(workspaceStoragePath, 'workspace-databases.json');
this.config = this.createEmptyConfig();

View File

@@ -1,6 +1,6 @@
import { logger } from '../../logging';
import { ProviderResult, TreeDataProvider, TreeItem } from 'vscode';
import { DbTreeViewItem } from './db-tree-view-item';
import { createDbTreeViewItemWarning, DbTreeViewItem } from './db-tree-view-item';
import { DbManager } from '../db-manager';
export class DbTreeDataProvider implements TreeDataProvider<DbTreeViewItem> {
@@ -41,6 +41,12 @@ export class DbTreeDataProvider implements TreeDataProvider<DbTreeViewItem> {
// This will be fleshed out in a future change.
void logger.log(`Creating database tree with ${dbItems.length} items`);
return [];
// Add a sample warning as a proof of concept.
const warningTreeViewItem = createDbTreeViewItemWarning(
'There was an error',
'Fix it'
);
return [warningTreeViewItem];
}
}

View File

@@ -2,11 +2,13 @@ import * as vscode from 'vscode';
import { DbItem } from '../db-item';
/**
* Represents an item in the database tree view.
* Represents an item in the database tree view. This item could be
* representing an actual database item or a warning.
*/
export class DbTreeViewItem extends vscode.TreeItem {
constructor(
public readonly dbItem: DbItem,
public readonly dbItem: DbItem | undefined,
public readonly iconPath: vscode.ThemeIcon,
public readonly label: string,
public readonly tooltip: string,
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
@@ -15,3 +17,14 @@ export class DbTreeViewItem extends vscode.TreeItem {
super(label, collapsibleState);
}
}
export function createDbTreeViewItemWarning(label: string, tooltip: string): DbTreeViewItem {
return new DbTreeViewItem(
undefined,
new vscode.ThemeIcon('warning', new vscode.ThemeColor('problemsWarningIcon.foreground')),
label,
tooltip,
vscode.TreeItemCollapsibleState.None,
[]
);
}

View File

@@ -109,11 +109,7 @@ import { NewQueryRunner } from './query-server/query-runner';
import { QueryRunner } from './queryRunner';
import { VariantAnalysisView } from './remote-queries/variant-analysis-view';
import { VariantAnalysisViewSerializer } from './remote-queries/variant-analysis-view-serializer';
import { VariantAnalysis } from './remote-queries/shared/variant-analysis';
import {
VariantAnalysis as VariantAnalysisApiResponse,
VariantAnalysisScannedRepository as ApiVariantAnalysisScannedRepository
} from './remote-queries/gh-api/variant-analysis';
import { VariantAnalysis, VariantAnalysisScannedRepository } from './remote-queries/shared/variant-analysis';
import { VariantAnalysisManager } from './remote-queries/variant-analysis-manager';
import { createVariantAnalysisContentProvider } from './remote-queries/variant-analysis-content-provider';
import { VSCodeMockGitHubApiServer } from './mocks/vscode-mock-gh-api-server';
@@ -949,8 +945,8 @@ async function activateWithInstalledDistribution(
ctx.subscriptions.push(
commandRunner('codeQL.autoDownloadVariantAnalysisResult', async (
scannedRepo: ApiVariantAnalysisScannedRepository,
variantAnalysisSummary: VariantAnalysisApiResponse,
scannedRepo: VariantAnalysisScannedRepository,
variantAnalysisSummary: VariantAnalysis,
token: CancellationToken
) => {
await variantAnalysisManager.enqueueDownload(scannedRepo, variantAnalysisSummary, token);

View File

@@ -40,7 +40,7 @@ import * as fs from 'fs-extra';
import { CliVersionConstraint } from './cli';
import { HistoryItemLabelProvider } from './history-item-label-provider';
import { Credentials } from './authentication';
import { cancelRemoteQuery } from './remote-queries/gh-api/gh-actions-api-client';
import { cancelRemoteQuery, cancelVariantAnalysis } from './remote-queries/gh-api/gh-actions-api-client';
import { RemoteQueriesManager } from './remote-queries/remote-queries-manager';
import { RemoteQueryHistoryItem } from './remote-queries/remote-query-history-item';
import { ResultsView } from './interface';
@@ -1109,6 +1109,7 @@ export class QueryHistoryManager extends DisposableObject {
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
const selected = finalMultiSelect || [finalSingleItem];
const results = selected.map(async item => {
if (item.status === QueryStatus.InProgress) {
if (item.t === 'local') {
@@ -1117,6 +1118,10 @@ export class QueryHistoryManager extends DisposableObject {
void showAndLogInformationMessage('Cancelling variant analysis. This may take a while.');
const credentials = await this.getCredentials();
await cancelRemoteQuery(credentials, item.remoteQuery);
} else if (item.t === 'variant-analysis') {
void showAndLogInformationMessage('Cancelling variant analysis. This may take a while.');
const credentials = await this.getCredentials();
await cancelVariantAnalysis(credentials, item.variantAnalysis);
}
}
});

View File

@@ -9,6 +9,7 @@ import { RemoteQuery } from '../remote-query';
import { RemoteQueryFailureIndexItem, RemoteQueryResultIndex, RemoteQuerySuccessIndexItem } from '../remote-query-result-index';
import { getErrorMessage } from '../../pure/helpers-pure';
import { unzipFile } from '../../pure/zip';
import { VariantAnalysis } from '../shared/variant-analysis';
export const RESULT_INDEX_ARTIFACT_NAME = 'result-index';
@@ -94,6 +95,18 @@ export async function cancelRemoteQuery(
}
}
export async function cancelVariantAnalysis(
credentials: Credentials,
variantAnalysis: VariantAnalysis
): Promise<void> {
const octokit = await credentials.getOctokit();
const { actionsWorkflowRunId, controllerRepo: { fullName } } = variantAnalysis;
const response = await octokit.request(`POST /repos/${fullName}/actions/runs/${actionsWorkflowRunId}/cancel`);
if (response.status >= 300) {
throw new Error(`Error cancelling variant analysis: ${response.status} ${response?.data?.message || ''}`);
}
}
export async function downloadArtifactFromLink(
credentials: Credentials,
storagePath: string,

View File

@@ -76,6 +76,17 @@ export interface VariantAnalysisScannedRepository {
failureMessage?: string
}
export interface VariantAnalysisRepositoryTask {
repository: Repository,
analysisStatus: VariantAnalysisRepoStatus,
resultCount?: number,
artifactSizeInBytes?: number,
failureMessage?: string,
databaseCommitSha?: string,
sourceLocationPrefix?: string,
artifactUrl?: string,
}
export interface VariantAnalysisSkippedRepositories {
accessMismatchRepos?: VariantAnalysisSkippedRepositoryGroup,
notFoundRepos?: VariantAnalysisSkippedRepositoryGroup,

View File

@@ -5,14 +5,11 @@ import { CancellationToken, commands, EventEmitter, ExtensionContext, window } f
import { DisposableObject } from '../pure/disposable-object';
import { Credentials } from '../authentication';
import { VariantAnalysisMonitor } from './variant-analysis-monitor';
import {
VariantAnalysis as VariantAnalysisApiResponse,
VariantAnalysisRepoTask,
VariantAnalysisScannedRepository as ApiVariantAnalysisScannedRepository
} from './gh-api/variant-analysis';
import {
isVariantAnalysisComplete,
VariantAnalysis, VariantAnalysisQueryLanguage,
VariantAnalysis,
VariantAnalysisQueryLanguage,
VariantAnalysisRepositoryTask,
VariantAnalysisScannedRepository,
VariantAnalysisScannedRepositoryDownloadStatus,
VariantAnalysisScannedRepositoryResult,
@@ -23,7 +20,7 @@ import { VariantAnalysisView } from './variant-analysis-view';
import { VariantAnalysisViewManager } from './variant-analysis-view-manager';
import { VariantAnalysisResultsManager } from './variant-analysis-results-manager';
import { getControllerRepo } from './run-remote-query';
import { processUpdatedVariantAnalysis } from './variant-analysis-processor';
import { processUpdatedVariantAnalysis, processVariantAnalysisRepositoryTask } from './variant-analysis-processor';
import PQueue from 'p-queue';
import { createTimestampFile, showAndLogErrorMessage } from '../helpers';
import * as fs from 'fs-extra';
@@ -206,11 +203,11 @@ export class VariantAnalysisManager extends DisposableObject implements VariantA
}
public async autoDownloadVariantAnalysisResult(
scannedRepo: ApiVariantAnalysisScannedRepository,
variantAnalysisSummary: VariantAnalysisApiResponse,
scannedRepo: VariantAnalysisScannedRepository,
variantAnalysis: VariantAnalysis,
cancellationToken: CancellationToken
): Promise<void> {
if (this.repoStates.get(variantAnalysisSummary.id)?.[scannedRepo.repository.id]?.downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.Succeeded) {
if (this.repoStates.get(variantAnalysis.id)?.[scannedRepo.repository.id]?.downloadStatus === VariantAnalysisScannedRepositoryDownloadStatus.Succeeded) {
return;
}
@@ -219,56 +216,58 @@ export class VariantAnalysisManager extends DisposableObject implements VariantA
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Pending,
};
await this.onRepoStateUpdated(variantAnalysisSummary.id, repoState);
await this.onRepoStateUpdated(variantAnalysis.id, repoState);
const credentials = await Credentials.initialize(this.ctx);
if (!credentials) { throw Error('Error authenticating with GitHub'); }
if (cancellationToken && cancellationToken.isCancellationRequested) {
repoState.downloadStatus = VariantAnalysisScannedRepositoryDownloadStatus.Failed;
await this.onRepoStateUpdated(variantAnalysisSummary.id, repoState);
await this.onRepoStateUpdated(variantAnalysis.id, repoState);
return;
}
let repoTask: VariantAnalysisRepoTask;
let repoTask: VariantAnalysisRepositoryTask;
try {
repoTask = await ghApiClient.getVariantAnalysisRepo(
const repoTaskResponse = await ghApiClient.getVariantAnalysisRepo(
credentials,
variantAnalysisSummary.controller_repo.id,
variantAnalysisSummary.id,
variantAnalysis.controllerRepo.id,
variantAnalysis.id,
scannedRepo.repository.id
);
repoTask = processVariantAnalysisRepositoryTask(repoTaskResponse);
} catch (e) {
repoState.downloadStatus = VariantAnalysisScannedRepositoryDownloadStatus.Failed;
await this.onRepoStateUpdated(variantAnalysisSummary.id, repoState);
throw new Error(`Could not download the results for variant analysis with id: ${variantAnalysisSummary.id}. Error: ${getErrorMessage(e)}`);
await this.onRepoStateUpdated(variantAnalysis.id, repoState);
throw new Error(`Could not download the results for variant analysis with id: ${variantAnalysis.id}. Error: ${getErrorMessage(e)}`);
}
if (repoTask.artifact_url) {
if (repoTask.artifactUrl) {
repoState.downloadStatus = VariantAnalysisScannedRepositoryDownloadStatus.InProgress;
await this.onRepoStateUpdated(variantAnalysisSummary.id, repoState);
await this.onRepoStateUpdated(variantAnalysis.id, repoState);
try {
await this.variantAnalysisResultsManager.download(credentials, variantAnalysisSummary.id, repoTask, this.getVariantAnalysisStorageLocation(variantAnalysisSummary.id));
await this.variantAnalysisResultsManager.download(credentials, variantAnalysis.id, repoTask, this.getVariantAnalysisStorageLocation(variantAnalysis.id));
} catch (e) {
repoState.downloadStatus = VariantAnalysisScannedRepositoryDownloadStatus.Failed;
await this.onRepoStateUpdated(variantAnalysisSummary.id, repoState);
throw new Error(`Could not download the results for variant analysis with id: ${variantAnalysisSummary.id}. Error: ${getErrorMessage(e)}`);
await this.onRepoStateUpdated(variantAnalysis.id, repoState);
throw new Error(`Could not download the results for variant analysis with id: ${variantAnalysis.id}. Error: ${getErrorMessage(e)}`);
}
}
repoState.downloadStatus = VariantAnalysisScannedRepositoryDownloadStatus.Succeeded;
await this.onRepoStateUpdated(variantAnalysisSummary.id, repoState);
await this.onRepoStateUpdated(variantAnalysis.id, repoState);
await fs.outputJson(this.getRepoStatesStoragePath(variantAnalysisSummary.id), this.repoStates.get(variantAnalysisSummary.id));
await fs.outputJson(this.getRepoStatesStoragePath(variantAnalysis.id), this.repoStates.get(variantAnalysis.id));
}
public async enqueueDownload(
scannedRepo: ApiVariantAnalysisScannedRepository,
variantAnalysisSummary: VariantAnalysisApiResponse,
scannedRepo: VariantAnalysisScannedRepository,
variantAnalysis: VariantAnalysis,
token: CancellationToken
): Promise<void> {
await this.queue.add(() => this.autoDownloadVariantAnalysisResult(scannedRepo, variantAnalysisSummary, token));
await this.queue.add(() => this.autoDownloadVariantAnalysisResult(scannedRepo, variantAnalysis, token));
}
public downloadsQueueSize(): number {

View File

@@ -1,12 +1,13 @@
import { ExtensionContext, CancellationToken, commands, EventEmitter } from 'vscode';
import { CancellationToken, commands, EventEmitter, ExtensionContext } from 'vscode';
import { Credentials } from '../authentication';
import * as ghApiClient from './gh-api/gh-api-client';
import { isFinalVariantAnalysisStatus, VariantAnalysis } from './shared/variant-analysis';
import {
VariantAnalysis as VariantAnalysisApiResponse,
isFinalVariantAnalysisStatus,
VariantAnalysis,
VariantAnalysisRepoStatus,
VariantAnalysisScannedRepository
} from './gh-api/variant-analysis';
} from './shared/variant-analysis';
import { VariantAnalysisMonitorResult } from './shared/variant-analysis-monitor-result';
import { processUpdatedVariantAnalysis } from './variant-analysis-processor';
import { DisposableObject } from '../pure/disposable-object';
@@ -57,7 +58,7 @@ export class VariantAnalysisMonitor extends DisposableObject {
this._onVariantAnalysisChange.fire(variantAnalysis);
const downloadedRepos = this.downloadVariantAnalysisResults(variantAnalysisSummary, scannedReposDownloaded);
const downloadedRepos = this.downloadVariantAnalysisResults(variantAnalysis, scannedReposDownloaded);
scannedReposDownloaded.push(...downloadedRepos);
if (isFinalVariantAnalysisStatus(variantAnalysis.status) || variantAnalysis.failureReason) {
@@ -72,7 +73,7 @@ export class VariantAnalysisMonitor extends DisposableObject {
private scheduleForDownload(
scannedRepo: VariantAnalysisScannedRepository,
variantAnalysisSummary: VariantAnalysisApiResponse
variantAnalysisSummary: VariantAnalysis
) {
void commands.executeCommand('codeQL.autoDownloadVariantAnalysisResult', scannedRepo, variantAnalysisSummary);
}
@@ -81,22 +82,22 @@ export class VariantAnalysisMonitor extends DisposableObject {
scannedRepo: VariantAnalysisScannedRepository,
alreadyDownloaded: number[]
): boolean {
return !alreadyDownloaded.includes(scannedRepo.repository.id) && scannedRepo.analysis_status === 'succeeded';
return !alreadyDownloaded.includes(scannedRepo.repository.id) && scannedRepo.analysisStatus === VariantAnalysisRepoStatus.Succeeded;
}
private getReposToDownload(
variantAnalysisSummary: VariantAnalysisApiResponse,
variantAnalysisSummary: VariantAnalysis,
alreadyDownloaded: number[]
): VariantAnalysisScannedRepository[] {
if (variantAnalysisSummary.scanned_repositories) {
return variantAnalysisSummary.scanned_repositories.filter(scannedRepo => this.shouldDownload(scannedRepo, alreadyDownloaded));
if (variantAnalysisSummary.scannedRepos) {
return variantAnalysisSummary.scannedRepos.filter(scannedRepo => this.shouldDownload(scannedRepo, alreadyDownloaded));
} else {
return [];
}
}
private downloadVariantAnalysisResults(
variantAnalysisSummary: VariantAnalysisApiResponse,
variantAnalysisSummary: VariantAnalysis,
scannedReposDownloaded: number[]
): number[] {
const repoResultsToDownload = this.getReposToDownload(variantAnalysisSummary, scannedReposDownloaded);

View File

@@ -6,7 +6,8 @@ import {
VariantAnalysisFailureReason as ApiVariantAnalysisFailureReason,
VariantAnalysisStatus as ApiVariantAnalysisStatus,
VariantAnalysisSkippedRepositoryGroup as ApiVariantAnalysisSkippedRepositoryGroup,
VariantAnalysisNotFoundRepositoryGroup as ApiVariantAnalysisNotFoundRepositoryGroup
VariantAnalysisNotFoundRepositoryGroup as ApiVariantAnalysisNotFoundRepositoryGroup,
VariantAnalysisRepoTask as ApiVariantAnalysisRepoTask,
} from './gh-api/variant-analysis';
import {
VariantAnalysis,
@@ -16,7 +17,8 @@ import {
VariantAnalysisStatus,
VariantAnalysisRepoStatus,
VariantAnalysisSubmission,
VariantAnalysisSkippedRepositoryGroup
VariantAnalysisSkippedRepositoryGroup,
VariantAnalysisRepositoryTask
} from './shared/variant-analysis';
export function processVariantAnalysis(
@@ -76,10 +78,28 @@ export function processUpdatedVariantAnalysis(
return variantAnalysis;
}
function processScannedRepositories(
scannedRepos: ApiVariantAnalysisScannedRepository[]
): VariantAnalysisScannedRepository[] {
return scannedRepos.map(scannedRepo => {
export function processVariantAnalysisRepositoryTask(
response: ApiVariantAnalysisRepoTask
): VariantAnalysisRepositoryTask {
return {
repository: {
id: response.repository.id,
fullName: response.repository.full_name,
private: response.repository.private,
},
analysisStatus: processApiRepoStatus(response.analysis_status),
resultCount: response.result_count,
artifactSizeInBytes: response.artifact_size_in_bytes,
failureMessage: response.failure_message,
databaseCommitSha: response.database_commit_sha,
sourceLocationPrefix: response.source_location_prefix,
artifactUrl: response.artifact_url,
};
}
export function processScannedRepository(
scannedRepo: ApiVariantAnalysisScannedRepository
): VariantAnalysisScannedRepository {
return {
repository: {
id: scannedRepo.repository.id,
@@ -93,7 +113,12 @@ function processScannedRepositories(
artifactSizeInBytes: scannedRepo.artifact_size_in_bytes,
failureMessage: scannedRepo.failure_message
};
});
}
function processScannedRepositories(
scannedRepos: ApiVariantAnalysisScannedRepository[]
): VariantAnalysisScannedRepository[] {
return scannedRepos.map(scannedRepo => processScannedRepository(scannedRepo));
}
function processSkippedRepositories(

View File

@@ -9,9 +9,12 @@ import { sarifParser } from '../sarif-parser';
import { extractAnalysisAlerts } from './sarif-processing';
import { CodeQLCliServer } from '../cli';
import { extractRawResults } from './bqrs-processing';
import { VariantAnalysis, VariantAnalysisScannedRepositoryResult } from './shared/variant-analysis';
import {
VariantAnalysis,
VariantAnalysisRepositoryTask,
VariantAnalysisScannedRepositoryResult
} from './shared/variant-analysis';
import { DisposableObject, DisposeHandler } from '../pure/disposable-object';
import { VariantAnalysisRepoTask } from './gh-api/variant-analysis';
import * as ghApiClient from './gh-api/gh-api-client';
import { EventEmitter } from 'vscode';
import { unzipFile } from '../pure/zip';
@@ -22,7 +25,7 @@ const createCacheKey = (variantAnalysisId: number, repositoryFullName: string):
export type ResultDownloadedEvent = {
variantAnalysisId: number;
repoTask: VariantAnalysisRepoTask;
repoTask: VariantAnalysisRepositoryTask;
}
export class VariantAnalysisResultsManager extends DisposableObject {
@@ -48,18 +51,18 @@ export class VariantAnalysisResultsManager extends DisposableObject {
public async download(
credentials: Credentials,
variantAnalysisId: number,
repoTask: VariantAnalysisRepoTask,
repoTask: VariantAnalysisRepositoryTask,
variantAnalysisStoragePath: string,
): Promise<void> {
if (!repoTask.artifact_url) {
if (!repoTask.artifactUrl) {
throw new Error('Missing artifact URL');
}
const resultDirectory = this.getRepoStorageDirectory(variantAnalysisStoragePath, repoTask.repository.full_name);
const resultDirectory = this.getRepoStorageDirectory(variantAnalysisStoragePath, repoTask.repository.fullName);
const result = await ghApiClient.getVariantAnalysisRepoResult(
credentials,
repoTask.artifact_url
repoTask.artifactUrl
);
if (!(await fs.pathExists(resultDirectory))) {
@@ -112,13 +115,13 @@ export class VariantAnalysisResultsManager extends DisposableObject {
const storageDirectory = this.getRepoStorageDirectory(variantAnalysisStoragePath, repositoryFullName);
const repoTask: VariantAnalysisRepoTask = await fs.readJson(path.join(storageDirectory, VariantAnalysisResultsManager.REPO_TASK_FILENAME));
const repoTask: VariantAnalysisRepositoryTask = await fs.readJson(path.join(storageDirectory, VariantAnalysisResultsManager.REPO_TASK_FILENAME));
if (!repoTask.database_commit_sha || !repoTask.source_location_prefix) {
if (!repoTask.databaseCommitSha || !repoTask.sourceLocationPrefix) {
throw new Error('Missing database commit SHA');
}
const fileLinkPrefix = this.createGitHubDotcomFileLinkPrefix(repoTask.repository.full_name, repoTask.database_commit_sha);
const fileLinkPrefix = this.createGitHubDotcomFileLinkPrefix(repoTask.repository.fullName, repoTask.databaseCommitSha);
const resultsDirectory = path.join(storageDirectory, VariantAnalysisResultsManager.RESULTS_DIRECTORY);
const sarifPath = path.join(resultsDirectory, 'results.sarif');
@@ -134,7 +137,7 @@ export class VariantAnalysisResultsManager extends DisposableObject {
}
if (await fs.pathExists(bqrsPath)) {
const rawResults = await this.readBqrsResults(bqrsPath, fileLinkPrefix, repoTask.source_location_prefix);
const rawResults = await this.readBqrsResults(bqrsPath, fileLinkPrefix, repoTask.sourceLocationPrefix);
return {
variantAnalysisId,

View File

@@ -5,7 +5,7 @@ import { ComponentMeta } from '@storybook/react';
import RepositoriesSearchComponent from '../../view/remote-queries/RepositoriesSearch';
export default {
title: 'Repositories Search',
title: 'MRVA/Repositories Search',
component: RepositoriesSearchComponent,
argTypes: {
filterValue: {

View File

@@ -0,0 +1,25 @@
import React, { useState } from 'react';
import { ComponentMeta } from '@storybook/react';
import { RepositoriesSearch as RepositoriesSearchComponent } from '../../view/variant-analysis/RepositoriesSearch';
export default {
title: 'Variant Analysis/Repositories Search',
component: RepositoriesSearchComponent,
argTypes: {
value: {
control: {
disable: true,
},
},
}
} as ComponentMeta<typeof RepositoriesSearchComponent>;
export const RepositoriesSearch = () => {
const [value, setValue] = useState('');
return (
<RepositoriesSearchComponent value={value} onChange={setValue} />
);
};

View File

@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { ComponentMeta } from '@storybook/react';
import { RepositoriesSearchSortRow as RepositoriesSearchSortRowComponent } from '../../view/variant-analysis/RepositoriesSearchSortRow';
import { defaultFilterSortState } from '../../view/variant-analysis/filterSort';
export default {
title: 'Variant Analysis/Repositories Search and Sort Row',
component: RepositoriesSearchSortRowComponent,
argTypes: {
value: {
control: {
disable: true,
},
},
}
} as ComponentMeta<typeof RepositoriesSearchSortRowComponent>;
export const RepositoriesSearchSortRow = () => {
const [value, setValue] = useState(defaultFilterSortState);
return (
<RepositoriesSearchSortRowComponent value={value} onChange={setValue} />
);
};

View File

@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { ComponentMeta } from '@storybook/react';
import { RepositoriesSort as RepositoriesSortComponent } from '../../view/variant-analysis/RepositoriesSort';
import { SortKey } from '../../view/variant-analysis/filterSort';
export default {
title: 'Variant Analysis/Repositories Sort',
component: RepositoriesSortComponent,
argTypes: {
value: {
control: {
disable: true,
},
},
}
} as ComponentMeta<typeof RepositoriesSortComponent>;
export const RepositoriesSort = () => {
const [value, setValue] = useState(SortKey.Name);
return (
<RepositoriesSortComponent value={value} onChange={setValue} />
);
};

View File

@@ -246,3 +246,13 @@ FullExample.args = {
repoStates,
repoResults,
};
export const FullExampleWithoutSkipped = Template.bind({});
FullExampleWithoutSkipped.args = {
variantAnalysis: {
...variantAnalysis,
skippedRepos: {},
},
repoStates,
repoResults,
};

View File

@@ -2,6 +2,8 @@ import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { faker } from '@faker-js/faker';
import { VariantAnalysisContainer } from '../../view/variant-analysis/VariantAnalysisContainer';
import { VariantAnalysisAnalyzedRepos } from '../../view/variant-analysis/VariantAnalysisAnalyzedRepos';
import {
@@ -11,6 +13,7 @@ import {
import { AnalysisAlert } from '../../remote-queries/shared/analysis-result';
import { createMockVariantAnalysis } from '../../vscode-tests/factories/remote-queries/shared/variant-analysis';
import { createMockRepositoryWithMetadata } from '../../vscode-tests/factories/remote-queries/shared/repository';
import { createMockScannedRepo } from '../../vscode-tests/factories/remote-queries/shared/scanned-repositories';
import analysesResults from '../remote-queries/data/analysesResultsMessage.json';
@@ -114,5 +117,40 @@ Example.args = {
interpretedResults: interpretedResultsForRepo('expressjs/express'),
}
]
}
;
};
faker.seed(42);
const uniqueStore = {};
const manyScannedRepos = Array.from({ length: 1000 }, (_, i) => {
const mockedScannedRepo = createMockScannedRepo();
return {
...mockedScannedRepo,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
resultCount: faker.datatype.number({ min: 0, max: 1000 }),
repository: {
...mockedScannedRepo.repository,
// We need to ensure the ID is unique for React keys
id: faker.helpers.unique(faker.datatype.number, [], {
store: uniqueStore,
}),
fullName: `octodemo/${faker.helpers.unique(faker.random.word, [], {
store: uniqueStore,
})}`,
}
};
});
export const PerformanceExample = Template.bind({});
PerformanceExample.args = {
variantAnalysis: {
...createMockVariantAnalysis(VariantAnalysisStatus.Succeeded, manyScannedRepos),
id: 1,
},
repositoryResults: manyScannedRepos.map(repoTask => ({
variantAnalysisId: 1,
repositoryId: repoTask.repository.id,
interpretedResults: interpretedResultsForRepo('facebook/create-react-app'),
}))
};

View File

@@ -6,6 +6,7 @@ type Props = {
name: string;
label: string;
className?: string;
slot?: string;
};
const CodiconIcon = styled.span`
@@ -15,5 +16,6 @@ const CodiconIcon = styled.span`
export const Codicon = ({
name,
label,
className
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} />;
className,
slot,
}: Props) => <CodiconIcon role="img" aria-label={label} className={classNames('codicon', `codicon-${name}`, className)} slot={slot} />;

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
import { useCallback } from 'react';
import styled from 'styled-components';
import { VSCodeTextField } from '@vscode/webview-ui-toolkit/react';
import { Codicon } from '../common';
const TextField = styled(VSCodeTextField)`
width: 100%;
`;
type Props = {
value: string;
onChange: (value: string) => void;
className?: string;
}
export const RepositoriesSearch = ({ value, onChange, className }: Props) => {
const handleInput = useCallback((e: InputEvent) => {
const target = e.target as HTMLInputElement;
onChange(target.value);
}, [onChange]);
return (
<TextField
placeholder='Filter by repository owner/name'
value={value}
onInput={handleInput}
className={className}
>
<Codicon name="search" label="Search..." slot="start" />
</TextField>
);
};

View File

@@ -0,0 +1,50 @@
import * as React from 'react';
import { Dispatch, SetStateAction, useCallback } from 'react';
import styled from 'styled-components';
import { RepositoriesFilterSortState, SortKey } from './filterSort';
import { RepositoriesSearch } from './RepositoriesSearch';
import { RepositoriesSort } from './RepositoriesSort';
type Props = {
value: RepositoriesFilterSortState;
onChange: Dispatch<SetStateAction<RepositoriesFilterSortState>>;
}
const Container = styled.div`
display: flex;
gap: 1em;
width: 100%;
margin-bottom: 1em;
`;
const RepositoriesSearchColumn = styled(RepositoriesSearch)`
flex: 3;
`;
const RepositoriesSortColumn = styled(RepositoriesSort)`
flex: 1;
`;
export const RepositoriesSearchSortRow = ({ value, onChange }: Props) => {
const handleSearchValueChange = useCallback((searchValue: string) => {
onChange(oldValue => ({
...oldValue,
searchValue,
}));
}, [onChange]);
const handleSortKeyChange = useCallback((sortKey: SortKey) => {
onChange(oldValue => ({
...oldValue,
sortKey,
}));
}, [onChange]);
return (
<Container>
<RepositoriesSearchColumn value={value.searchValue} onChange={handleSearchValueChange} />
<RepositoriesSortColumn value={value.sortKey} onChange={handleSortKeyChange} />
</Container>
);
};

View File

@@ -0,0 +1,39 @@
import * as React from 'react';
import { useCallback } from 'react';
import styled from 'styled-components';
import { VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react';
import { SortKey } from './filterSort';
import { Codicon } from '../common';
const Dropdown = styled(VSCodeDropdown)`
width: 100%;
`;
type Props = {
value: SortKey;
onChange: (value: SortKey) => void;
className?: string;
}
export const RepositoriesSort = ({ value, onChange, className }: Props) => {
const handleInput = useCallback((e: InputEvent) => {
const target = e.target as HTMLSelectElement;
onChange(target.value as SortKey);
}, [onChange]);
return (
<Dropdown
value={value}
onInput={handleInput}
className={className}
>
<Codicon name="sort-precedence" label="Sort..." slot="indicator" />
<VSCodeOption value={SortKey.Name}>Name</VSCodeOption>
<VSCodeOption value={SortKey.ResultsCount}>Results</VSCodeOption>
<VSCodeOption value={SortKey.Stars}>Stars</VSCodeOption>
<VSCodeOption value={SortKey.LastUpdated}>Last commit</VSCodeOption>
</Dropdown>
);
};

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components';
import { RepoRow } from './RepoRow';
import {
@@ -6,7 +7,7 @@ import {
VariantAnalysisScannedRepositoryResult,
VariantAnalysisScannedRepositoryState
} from '../../remote-queries/shared/variant-analysis';
import { useMemo } from 'react';
import { compareWithResults, matchesFilter, RepositoriesFilterSortState } from './filterSort';
const Container = styled.div`
display: flex;
@@ -19,12 +20,15 @@ export type VariantAnalysisAnalyzedReposProps = {
variantAnalysis: VariantAnalysis;
repositoryStates?: VariantAnalysisScannedRepositoryState[];
repositoryResults?: VariantAnalysisScannedRepositoryResult[];
filterSortState?: RepositoriesFilterSortState;
}
export const VariantAnalysisAnalyzedRepos = ({
variantAnalysis,
repositoryStates,
repositoryResults,
filterSortState,
}: VariantAnalysisAnalyzedReposProps) => {
const repositoryStateById = useMemo(() => {
const map = new Map<number, VariantAnalysisScannedRepositoryState>();
@@ -42,9 +46,15 @@ export const VariantAnalysisAnalyzedRepos = ({
return map;
}, [repositoryResults]);
const repositories = useMemo(() => {
return variantAnalysis.scannedRepos?.filter((repoTask) => {
return matchesFilter(repoTask.repository, filterSortState);
})?.sort(compareWithResults(filterSortState));
}, [filterSortState, variantAnalysis.scannedRepos]);
return (
<Container>
{variantAnalysis.scannedRepos?.map(repository => {
{repositories?.map(repository => {
const state = repositoryStateById.get(repository.repository.id);
const results = repositoryResultsById.get(repository.repository.id);

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { useState } from 'react';
import styled from 'styled-components';
import { VSCodeBadge, VSCodePanels, VSCodePanelTab, VSCodePanelView } from '@vscode/webview-ui-toolkit/react';
import { formatDecimal } from '../../pure/number';
@@ -10,6 +11,8 @@ import {
import { VariantAnalysisAnalyzedRepos } from './VariantAnalysisAnalyzedRepos';
import { Alert } from '../common';
import { VariantAnalysisSkippedRepositoriesTab } from './VariantAnalysisSkippedRepositoriesTab';
import { defaultFilterSortState, RepositoriesFilterSortState } from './filterSort';
import { RepositoriesSearchSortRow } from './RepositoriesSearchSortRow';
export type VariantAnalysisOutcomePanelProps = {
variantAnalysis: VariantAnalysis;
@@ -42,6 +45,8 @@ export const VariantAnalysisOutcomePanels = ({
repositoryStates,
repositoryResults,
}: VariantAnalysisOutcomePanelProps) => {
const [filterSortState, setFilterSortState] = useState<RepositoriesFilterSortState>(defaultFilterSortState);
const noCodeqlDbRepos = variantAnalysis.skippedRepos?.noCodeqlDbRepos;
const notFoundRepos = variantAnalysis.skippedRepos?.notFoundRepos;
const overLimitRepositoryCount = variantAnalysis.skippedRepos?.overLimitRepos?.repositoryCount ?? 0;
@@ -70,10 +75,12 @@ export const VariantAnalysisOutcomePanels = ({
return (
<>
{warnings}
<RepositoriesSearchSortRow value={filterSortState} onChange={setFilterSortState} />
<VariantAnalysisAnalyzedRepos
variantAnalysis={variantAnalysis}
repositoryStates={repositoryStates}
repositoryResults={repositoryResults}
filterSortState={filterSortState}
/>
</>
);
@@ -82,6 +89,7 @@ export const VariantAnalysisOutcomePanels = ({
return (
<>
{warnings}
<RepositoriesSearchSortRow value={filterSortState} onChange={setFilterSortState} />
<VSCodePanels>
<Tab>
Analyzed
@@ -104,6 +112,7 @@ export const VariantAnalysisOutcomePanels = ({
variantAnalysis={variantAnalysis}
repositoryStates={repositoryStates}
repositoryResults={repositoryResults}
filterSortState={filterSortState}
/>
</VSCodePanelView>
{notFoundRepos?.repositoryCount &&
@@ -111,14 +120,18 @@ export const VariantAnalysisOutcomePanels = ({
<VariantAnalysisSkippedRepositoriesTab
alertTitle='No access'
alertMessage='The following repositories could not be scanned because you do not have read access.'
skippedRepositoryGroup={notFoundRepos} />
skippedRepositoryGroup={notFoundRepos}
filterSortState={filterSortState}
/>
</VSCodePanelView>}
{noCodeqlDbRepos?.repositoryCount &&
<VSCodePanelView>
<VariantAnalysisSkippedRepositoriesTab
alertTitle='No database'
alertMessage='The following repositories could not be scanned because they do not have an available CodeQL database.'
skippedRepositoryGroup={noCodeqlDbRepos} />
skippedRepositoryGroup={noCodeqlDbRepos}
filterSortState={filterSortState}
/>
</VSCodePanelView>}
</VSCodePanels>
</>

View File

@@ -1,13 +1,17 @@
import * as React from 'react';
import { useMemo } from 'react';
import styled from 'styled-components';
import { VariantAnalysisSkippedRepositoryGroup } from '../../remote-queries/shared/variant-analysis';
import { Alert } from '../common';
import { RepoRow } from './RepoRow';
import { compareRepository, matchesFilter, RepositoriesFilterSortState } from './filterSort';
export type VariantAnalysisSkippedRepositoriesTabProps = {
alertTitle: string,
alertMessage: string,
skippedRepositoryGroup: VariantAnalysisSkippedRepositoryGroup,
filterSortState?: RepositoriesFilterSortState,
};
function getSkipReasonAlert(
@@ -39,11 +43,18 @@ export const VariantAnalysisSkippedRepositoriesTab = ({
alertTitle,
alertMessage,
skippedRepositoryGroup,
filterSortState,
}: VariantAnalysisSkippedRepositoriesTabProps) => {
const repositories = useMemo(() => {
return skippedRepositoryGroup.repositories?.filter((repo) => {
return matchesFilter(repo, filterSortState);
})?.sort(compareRepository(filterSortState));
}, [filterSortState, skippedRepositoryGroup.repositories]);
return (
<Container>
{getSkipReasonAlert(alertTitle, alertMessage, skippedRepositoryGroup)}
{skippedRepositoryGroup.repositories.map((repo) =>
{repositories.map((repo) =>
<RepoRow key={`repo/${repo.fullName}`} repository={repo} />
)}
</Container>

View File

@@ -10,6 +10,7 @@ import { VariantAnalysisAnalyzedRepos, VariantAnalysisAnalyzedReposProps } from
import { createMockVariantAnalysis } from '../../../vscode-tests/factories/remote-queries/shared/variant-analysis';
import { createMockRepositoryWithMetadata } from '../../../vscode-tests/factories/remote-queries/shared/repository';
import { createMockScannedRepo } from '../../../vscode-tests/factories/remote-queries/shared/scanned-repositories';
import { defaultFilterSortState, SortKey } from '../filterSort';
describe(VariantAnalysisAnalyzedRepos.name, () => {
const defaultVariantAnalysis = createMockVariantAnalysis({
@@ -22,7 +23,9 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 1,
fullName: 'octodemo/hello-world-1',
private: false,
stargazersCount: 5_000,
},
resultCount: undefined,
analysisStatus: VariantAnalysisRepoStatus.Pending,
},
{
@@ -32,7 +35,9 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 2,
fullName: 'octodemo/hello-world-2',
private: false,
stargazersCount: 20_000,
},
resultCount: 200,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
},
{
@@ -42,7 +47,9 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 3,
fullName: 'octodemo/hello-world-3',
private: true,
stargazersCount: 20,
},
resultCount: undefined,
analysisStatus: VariantAnalysisRepoStatus.Failed,
},
{
@@ -52,9 +59,35 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
id: 4,
fullName: 'octodemo/hello-world-4',
private: false,
stargazersCount: 8_000,
},
resultCount: undefined,
analysisStatus: VariantAnalysisRepoStatus.InProgress,
},
{
...createMockScannedRepo(),
repository: {
...createMockRepositoryWithMetadata(),
id: 5,
fullName: 'octodemo/hello-world-5',
private: false,
stargazersCount: 50_000,
},
resultCount: 55_323,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
},
{
...createMockScannedRepo(),
repository: {
...createMockRepositoryWithMetadata(),
id: 6,
fullName: 'octodemo/hello-world-6',
private: false,
stargazersCount: 1,
},
resultCount: 10_000,
analysisStatus: VariantAnalysisRepoStatus.Succeeded,
},
]
});
@@ -117,4 +150,56 @@ describe(VariantAnalysisAnalyzedRepos.name, () => {
}));
expect(screen.getByText('This is an empty block.')).toBeInTheDocument();
});
it('uses the search value', () => {
render({
filterSortState: {
...defaultFilterSortState,
searchValue: 'world-2',
}
});
expect(screen.queryByText('octodemo/hello-world-1')).not.toBeInTheDocument();
expect(screen.getByText('octodemo/hello-world-2')).toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-world-3')).not.toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-world-4')).not.toBeInTheDocument();
});
it('uses the sort key', async () => {
render({
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.Stars,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(6);
expect(rows[0]).toHaveTextContent('octodemo/hello-world-5');
expect(rows[1]).toHaveTextContent('octodemo/hello-world-2');
expect(rows[2]).toHaveTextContent('octodemo/hello-world-4');
expect(rows[3]).toHaveTextContent('octodemo/hello-world-1');
expect(rows[4]).toHaveTextContent('octodemo/hello-world-3');
expect(rows[5]).toHaveTextContent('octodemo/hello-world-6');
});
it('uses the results count sort key', async () => {
render({
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(6);
expect(rows[0]).toHaveTextContent('octodemo/hello-world-5');
expect(rows[1]).toHaveTextContent('octodemo/hello-world-6');
expect(rows[2]).toHaveTextContent('octodemo/hello-world-2');
expect(rows[3]).toHaveTextContent('octodemo/hello-world-1');
expect(rows[4]).toHaveTextContent('octodemo/hello-world-3');
expect(rows[5]).toHaveTextContent('octodemo/hello-world-4');
});
});

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import { render as reactRender, screen } from '@testing-library/react';
import { VariantAnalysisSkippedRepositoriesTab, VariantAnalysisSkippedRepositoriesTabProps } from '../VariantAnalysisSkippedRepositoriesTab';
import { defaultFilterSortState, SortKey } from '../filterSort';
describe(VariantAnalysisSkippedRepositoriesTab.name, () => {
const render = (props: VariantAnalysisSkippedRepositoriesTabProps) =>
@@ -97,4 +98,100 @@ describe(VariantAnalysisSkippedRepositoriesTab.name, () => {
expect(screen.getByText('octodemo/hello-galaxy')).toBeInTheDocument();
expect(screen.getByText('octodemo/hello-universe')).toBeInTheDocument();
});
it('uses the search value', async () => {
render({
alertTitle: 'No database',
alertMessage: 'The following repositories could not be scanned because they do not have an available CodeQL database.',
skippedRepositoryGroup: {
repositoryCount: 1,
repositories: [
{
fullName: 'octodemo/hello-world',
},
{
fullName: 'octodemo/hello-galaxy',
},
{
fullName: 'octodemo/hello-universe',
},
],
},
filterSortState: {
...defaultFilterSortState,
searchValue: 'world',
}
});
expect(screen.getByText('octodemo/hello-world')).toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-galaxy')).not.toBeInTheDocument();
expect(screen.queryByText('octodemo/hello-universe')).not.toBeInTheDocument();
});
it('uses the sort key', async () => {
render({
alertTitle: 'No database',
alertMessage: 'The following repositories could not be scanned because they do not have an available CodeQL database.',
skippedRepositoryGroup: {
repositoryCount: 1,
repositories: [
{
fullName: 'octodemo/hello-world',
stargazersCount: 300,
},
{
fullName: 'octodemo/hello-galaxy',
stargazersCount: 50,
},
{
fullName: 'octodemo/hello-universe',
stargazersCount: 500,
},
],
},
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.Stars,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent('octodemo/hello-universe');
expect(rows[1]).toHaveTextContent('octodemo/hello-world');
expect(rows[2]).toHaveTextContent('octodemo/hello-galaxy');
});
it('does not use the result count sort key', async () => {
render({
alertTitle: 'No database',
alertMessage: 'The following repositories could not be scanned because they do not have an available CodeQL database.',
skippedRepositoryGroup: {
repositoryCount: 1,
repositories: [
{
fullName: 'octodemo/hello-world',
},
{
fullName: 'octodemo/hello-galaxy',
},
{
fullName: 'octodemo/hello-universe',
},
],
},
filterSortState: {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
}
});
const rows = screen.queryAllByRole('button');
expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent('octodemo/hello-galaxy');
expect(rows[1]).toHaveTextContent('octodemo/hello-universe');
expect(rows[2]).toHaveTextContent('octodemo/hello-world');
});
});

View File

@@ -0,0 +1,279 @@
import { compareRepository, compareWithResults, defaultFilterSortState, matchesFilter, SortKey } from '../filterSort';
describe(matchesFilter.name, () => {
const repository = {
fullName: 'github/codeql'
};
const testCases = [
{ searchValue: '', matches: true },
{ searchValue: 'github/codeql', matches: true },
{ searchValue: 'github', matches: true },
{ searchValue: 'git', matches: true },
{ searchValue: 'codeql', matches: true },
{ searchValue: 'code', matches: true },
{ searchValue: 'ql', matches: true },
{ searchValue: '/', matches: true },
{ searchValue: 'gothub/codeql', matches: false },
{ searchValue: 'hello', matches: false },
{ searchValue: 'cod*ql', matches: false },
{ searchValue: 'cod?ql', matches: false },
];
test.each(testCases)('returns $matches if searching for $searchValue', ({ searchValue, matches }) => {
expect(matchesFilter(repository, {
...defaultFilterSortState,
searchValue,
})).toBe(matches);
});
});
describe(compareRepository.name, () => {
describe('when sort key is undefined', () => {
const sorter = compareRepository(undefined);
const left = {
fullName: 'github/galaxy'
};
const right = {
fullName: 'github/world'
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeLessThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeGreaterThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
});
describe('when sort key is name', () => {
const sorter = compareRepository({
...defaultFilterSortState,
sortKey: SortKey.Name,
});
const left = {
fullName: 'github/galaxy'
};
const right = {
fullName: 'github/world'
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeLessThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeGreaterThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
});
describe('when sort key is stars', () => {
const sorter = compareRepository({
...defaultFilterSortState,
sortKey: SortKey.Stars,
});
const left = {
fullName: 'github/galaxy',
stargazersCount: 1,
};
const right = {
fullName: 'github/world',
stargazersCount: 10,
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeLessThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
it('compares equal single values correctly', () => {
expect(sorter(left, {
...right,
stargazersCount: left.stargazersCount,
})).toBeLessThan(0);
});
it('compares missing single values correctly', () => {
expect(sorter(left, {
...right,
stargazersCount: undefined,
})).toBeLessThan(0);
});
});
describe('when sort key is last updated', () => {
const sorter = compareRepository({
...defaultFilterSortState,
sortKey: SortKey.LastUpdated,
});
const left = {
fullName: 'github/galaxy',
updatedAt: '2020-01-01T00:00:00Z',
};
const right = {
fullName: 'github/world',
updatedAt: '2021-01-01T00:00:00Z',
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeLessThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
it('compares equal single values correctly', () => {
expect(sorter(left, {
...right,
updatedAt: left.updatedAt,
})).toBeLessThan(0);
});
it('compares missing single values correctly', () => {
expect(sorter({
...left,
updatedAt: undefined,
}, right)).toBeGreaterThan(0);
});
});
});
describe(compareWithResults.name, () => {
describe('when sort key is undefined', () => {
const sorter = compareWithResults(undefined);
const left = {
repository: {
fullName: 'github/galaxy',
},
};
const right = {
repository: {
fullName: 'github/world',
},
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeLessThan(0);
});
});
describe('when sort key is stars', () => {
const sorter = compareWithResults({
...defaultFilterSortState,
sortKey: SortKey.Stars,
});
const left = {
repository: {
fullName: 'github/galaxy',
stargazersCount: 1,
},
};
const right = {
repository: {
fullName: 'github/world',
stargazersCount: 10,
},
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
});
describe('when sort key is last updated', () => {
const sorter = compareWithResults({
...defaultFilterSortState,
sortKey: SortKey.LastUpdated,
});
const left = {
repository: {
fullName: 'github/galaxy',
updatedAt: '2020-01-01T00:00:00Z',
},
};
const right = {
repository: {
fullName: 'github/world',
updatedAt: '2021-01-01T00:00:00Z',
},
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
});
describe('when sort key is results count', () => {
const sorter = compareWithResults({
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
});
const left = {
repository: {
fullName: 'github/galaxy',
},
resultCount: 10,
};
const right = {
repository: {
fullName: 'github/world',
},
resultCount: 100,
};
it('compares correctly', () => {
expect(sorter(left, right)).toBeGreaterThan(0);
});
it('compares the inverse correctly', () => {
expect(sorter(right, left)).toBeLessThan(0);
});
it('compares equal values correctly', () => {
expect(sorter(left, left)).toBe(0);
});
it('compares equal single values correctly', () => {
expect(sorter(left, {
...right,
resultCount: left.resultCount,
})).toBeLessThan(0);
});
it('compares missing single values correctly', () => {
expect(sorter({
...left,
resultCount: undefined,
}, right)).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,73 @@
import { Repository, RepositoryWithMetadata } from '../../remote-queries/shared/repository';
import { parseDate } from '../../pure/date';
export enum SortKey {
Name = 'name',
Stars = 'stars',
LastUpdated = 'lastUpdated',
ResultsCount = 'resultsCount',
}
export type RepositoriesFilterSortState = {
searchValue: string;
sortKey: SortKey;
}
export const defaultFilterSortState: RepositoriesFilterSortState = {
searchValue: '',
sortKey: SortKey.Name,
};
export function matchesFilter(repo: Pick<Repository, 'fullName'>, filterSortState: RepositoriesFilterSortState | undefined): boolean {
if (!filterSortState) {
return true;
}
return repo.fullName.toLowerCase().includes(filterSortState.searchValue.toLowerCase());
}
type SortableRepository = Pick<Repository, 'fullName'> & Partial<Pick<RepositoryWithMetadata, 'stargazersCount' | 'updatedAt'>>;
export function compareRepository(filterSortState: RepositoriesFilterSortState | undefined): (left: SortableRepository, right: SortableRepository) => number {
return (left: SortableRepository, right: SortableRepository) => {
// Highest to lowest
if (filterSortState?.sortKey === SortKey.Stars) {
const stargazersCount = (right.stargazersCount ?? 0) - (left.stargazersCount ?? 0);
if (stargazersCount !== 0) {
return stargazersCount;
}
}
// Newest to oldest
if (filterSortState?.sortKey === SortKey.LastUpdated) {
const lastUpdated = (parseDate(right.updatedAt)?.getTime() ?? 0) - (parseDate(left.updatedAt)?.getTime() ?? 0);
if (lastUpdated !== 0) {
return lastUpdated;
}
}
// Fall back on name compare
return left.fullName.localeCompare(right.fullName, undefined, { sensitivity: 'base' });
};
}
type SortableResult = {
repository: SortableRepository;
resultCount?: number;
}
export function compareWithResults(filterSortState: RepositoriesFilterSortState | undefined): (left: SortableResult, right: SortableResult) => number {
const fallbackSort = compareRepository(filterSortState);
return (left: SortableResult, right: SortableResult) => {
// Highest to lowest
if (filterSortState?.sortKey === SortKey.ResultsCount) {
const resultCount = (right.resultCount ?? 0) - (left.resultCount ?? 0);
if (resultCount !== 0) {
return resultCount;
}
}
return fallbackSort(left.repository, right.repository);
};
}

View File

@@ -10,24 +10,21 @@ import * as fs from 'fs-extra';
import * as path from 'path';
import { VariantAnalysisManager } from '../../../remote-queries/variant-analysis-manager';
import {
VariantAnalysis as VariantAnalysisApiResponse,
VariantAnalysisRepoTask,
VariantAnalysisScannedRepository as ApiVariantAnalysisScannedRepository
} from '../../../remote-queries/gh-api/variant-analysis';
import { createMockApiResponse } from '../../factories/remote-queries/gh-api/variant-analysis-api-response';
import { createMockScannedRepos } from '../../factories/remote-queries/gh-api/scanned-repositories';
import { createMockVariantAnalysisRepoTask } from '../../factories/remote-queries/gh-api/variant-analysis-repo-task';
import { CodeQLCliServer } from '../../../cli';
import { storagePath } from '../global.helper';
import { VariantAnalysisResultsManager } from '../../../remote-queries/variant-analysis-results-manager';
import {
VariantAnalysis,
VariantAnalysisScannedRepositoryDownloadStatus
} from '../../../remote-queries/shared/variant-analysis';
import { createMockVariantAnalysis } from '../../factories/remote-queries/shared/variant-analysis';
import * as VariantAnalysisModule from '../../../remote-queries/shared/variant-analysis';
import { createMockScannedRepos } from '../../factories/remote-queries/shared/scanned-repositories';
import {
VariantAnalysis,
VariantAnalysisScannedRepository,
VariantAnalysisScannedRepositoryDownloadStatus,
VariantAnalysisStatus,
} from '../../../remote-queries/shared/variant-analysis';
import { createTimestampFile } from '../../../helpers';
import { createMockVariantAnalysisRepoTask } from '../../factories/remote-queries/gh-api/variant-analysis-repo-task';
import { VariantAnalysisRepoTask } from '../../../remote-queries/gh-api/variant-analysis';
describe('Variant Analysis Manager', async function() {
let sandbox: sinon.SinonSandbox;
@@ -37,8 +34,8 @@ describe('Variant Analysis Manager', async function() {
let cli: CodeQLCliServer;
let cancellationTokenSource: CancellationTokenSource;
let variantAnalysisManager: VariantAnalysisManager;
let variantAnalysisApiResponse: VariantAnalysisApiResponse;
let scannedRepos: ApiVariantAnalysisScannedRepository[];
let variantAnalysis: VariantAnalysis;
let scannedRepos: VariantAnalysisScannedRepository[];
let getVariantAnalysisRepoStub: sinon.SinonStub;
let getVariantAnalysisRepoResultStub: sinon.SinonStub;
let variantAnalysisResultsManager: VariantAnalysisResultsManager;
@@ -56,7 +53,10 @@ describe('Variant Analysis Manager', async function() {
cancellationTokenSource = new CancellationTokenSource();
scannedRepos = createMockScannedRepos();
variantAnalysisApiResponse = createMockApiResponse('in_progress', scannedRepos);
variantAnalysis = createMockVariantAnalysis({
status: VariantAnalysisStatus.InProgress,
scannedRepos,
});
try {
const extension = await extensions.getExtension<CodeQLExtensionInterface | Record<string, never>>('GitHub.vscode-codeql')!.activate();
@@ -147,7 +147,7 @@ describe('Variant Analysis Manager', async function() {
try {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
} catch (error: any) {
@@ -184,7 +184,7 @@ describe('Variant Analysis Manager', async function() {
it('should not try to download the result', async () => {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
@@ -208,7 +208,7 @@ describe('Variant Analysis Manager', async function() {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
@@ -218,7 +218,7 @@ describe('Variant Analysis Manager', async function() {
it('should fetch a repo task', async () => {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
@@ -228,7 +228,7 @@ describe('Variant Analysis Manager', async function() {
it('should fetch a repo result', async () => {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
@@ -239,7 +239,7 @@ describe('Variant Analysis Manager', async function() {
// First, do a download so it is downloaded. This avoids having to mock the repo states.
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
@@ -247,7 +247,7 @@ describe('Variant Analysis Manager', async function() {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
@@ -257,11 +257,11 @@ describe('Variant Analysis Manager', async function() {
it('should write the repo state when the download is successful', async () => {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysisApiResponse.id.toString(), 'repo_states.json'), {
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysis.id.toString(), 'repo_states.json'), {
[scannedRepos[0].repository.id]: {
repositoryId: scannedRepos[0].repository.id,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Succeeded,
@@ -275,7 +275,7 @@ describe('Variant Analysis Manager', async function() {
try {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
fail('Expected an error to be thrown');
@@ -292,7 +292,7 @@ describe('Variant Analysis Manager', async function() {
try {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
fail('Expected an error to be thrown');
@@ -304,11 +304,11 @@ describe('Variant Analysis Manager', async function() {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[1],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysisApiResponse.id.toString(), 'repo_states.json'), {
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysis.id.toString(), 'repo_states.json'), {
[scannedRepos[0].repository.id]: {
repositoryId: scannedRepos[0].repository.id,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Failed,
@@ -326,7 +326,7 @@ describe('Variant Analysis Manager', async function() {
try {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
fail('Expected an error to be thrown');
@@ -338,11 +338,11 @@ describe('Variant Analysis Manager', async function() {
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[1],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysisApiResponse.id.toString(), 'repo_states.json'), {
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysis.id.toString(), 'repo_states.json'), {
[scannedRepos[0].repository.id]: {
repositoryId: scannedRepos[0].repository.id,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Failed,
@@ -359,9 +359,9 @@ describe('Variant Analysis Manager', async function() {
// The actual tests for these are in rehydrateVariantAnalysis, so we can just mock them here and test that
// the methods are called.
pathExistsStub.withArgs(path.join(storagePath, variantAnalysisApiResponse.id.toString())).resolves(true);
pathExistsStub.withArgs(path.join(storagePath, variantAnalysis.id.toString())).resolves(true);
// This will read in the correct repo states
readJsonStub.withArgs(path.join(storagePath, variantAnalysisApiResponse.id.toString(), 'repo_states.json')).resolves({
readJsonStub.withArgs(path.join(storagePath, variantAnalysis.id.toString(), 'repo_states.json')).resolves({
[scannedRepos[1].repository.id]: {
repositoryId: scannedRepos[1].repository.id,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Succeeded,
@@ -372,19 +372,16 @@ describe('Variant Analysis Manager', async function() {
},
});
await variantAnalysisManager.rehydrateVariantAnalysis({
...createMockVariantAnalysis({}),
id: variantAnalysisApiResponse.id,
});
sinon.assert.calledWith(readJsonStub, path.join(storagePath, variantAnalysisApiResponse.id.toString(), 'repo_states.json'));
await variantAnalysisManager.rehydrateVariantAnalysis(variantAnalysis);
sinon.assert.calledWith(readJsonStub, path.join(storagePath, variantAnalysis.id.toString(), 'repo_states.json'));
await variantAnalysisManager.autoDownloadVariantAnalysisResult(
scannedRepos[0],
variantAnalysisApiResponse,
variantAnalysis,
cancellationTokenSource.token
);
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysisApiResponse.id.toString(), 'repo_states.json'), {
sinon.assert.calledWith(outputJsonStub, path.join(storagePath, variantAnalysis.id.toString(), 'repo_states.json'), {
[scannedRepos[1].repository.id]: {
repositoryId: scannedRepos[1].repository.id,
downloadStatus: VariantAnalysisScannedRepositoryDownloadStatus.Succeeded,
@@ -405,9 +402,9 @@ describe('Variant Analysis Manager', async function() {
it('should pop download tasks off the queue', async () => {
const getResultsSpy = sandbox.spy(variantAnalysisManager, 'autoDownloadVariantAnalysisResult');
await variantAnalysisManager.enqueueDownload(scannedRepos[0], variantAnalysisApiResponse, cancellationTokenSource.token);
await variantAnalysisManager.enqueueDownload(scannedRepos[1], variantAnalysisApiResponse, cancellationTokenSource.token);
await variantAnalysisManager.enqueueDownload(scannedRepos[2], variantAnalysisApiResponse, cancellationTokenSource.token);
await variantAnalysisManager.enqueueDownload(scannedRepos[0], variantAnalysis, cancellationTokenSource.token);
await variantAnalysisManager.enqueueDownload(scannedRepos[1], variantAnalysis, cancellationTokenSource.token);
await variantAnalysisManager.enqueueDownload(scannedRepos[2], variantAnalysis, cancellationTokenSource.token);
expect(variantAnalysisManager.downloadsQueueSize()).to.equal(0);
expect(getResultsSpy).to.have.been.calledThrice;

View File

@@ -14,7 +14,11 @@ import {
import { createFailedMockApiResponse, createMockApiResponse } from '../../factories/remote-queries/gh-api/variant-analysis-api-response';
import { VariantAnalysis, VariantAnalysisStatus } from '../../../remote-queries/shared/variant-analysis';
import { createMockScannedRepos } from '../../factories/remote-queries/gh-api/scanned-repositories';
import { processFailureReason } from '../../../remote-queries/variant-analysis-processor';
import {
processFailureReason,
processScannedRepository,
processUpdatedVariantAnalysis,
} from '../../../remote-queries/variant-analysis-processor';
import { Credentials } from '../../../authentication';
import { createMockVariantAnalysis } from '../../factories/remote-queries/shared/variant-analysis';
import { VariantAnalysisManager } from '../../../remote-queries/variant-analysis-manager';
@@ -143,8 +147,8 @@ describe('Variant Analysis Monitor', async function() {
succeededRepos.forEach((succeededRepo, index) => {
expect(commandSpy.getCall(index).args[0]).to.eq('codeQL.autoDownloadVariantAnalysisResult');
expect(commandSpy.getCall(index).args[1]).to.eq(succeededRepo);
expect(commandSpy.getCall(index).args[2]).to.eq(mockApiResponse);
expect(commandSpy.getCall(index).args[1]).to.deep.eq(processScannedRepository(succeededRepo));
expect(commandSpy.getCall(index).args[2]).to.deep.eq(processUpdatedVariantAnalysis(variantAnalysis, mockApiResponse));
});
});
@@ -154,8 +158,8 @@ describe('Variant Analysis Monitor', async function() {
expect(mockGetDownloadResult).to.have.callCount(succeededRepos.length);
succeededRepos.forEach((succeededRepo, index) => {
expect(mockGetDownloadResult.getCall(index).args[0]).to.eq(succeededRepo);
expect(mockGetDownloadResult.getCall(index).args[1]).to.eq(mockApiResponse);
expect(mockGetDownloadResult.getCall(index).args[0]).to.deep.eq(processScannedRepository(succeededRepo));
expect(mockGetDownloadResult.getCall(index).args[1]).to.deep.eq(processUpdatedVariantAnalysis(variantAnalysis, mockApiResponse));
});
});
});

View File

@@ -8,11 +8,14 @@ import * as fs from 'fs-extra';
import * as path from 'path';
import { VariantAnalysisResultsManager } from '../../../remote-queries/variant-analysis-results-manager';
import { createMockVariantAnalysisRepoTask } from '../../factories/remote-queries/gh-api/variant-analysis-repo-task';
import { CodeQLCliServer } from '../../../cli';
import { storagePath } from '../global.helper';
import { faker } from '@faker-js/faker';
import * as ghApiClient from '../../../remote-queries/gh-api/gh-api-client';
import {
createMockVariantAnalysisRepositoryTask
} from '../../factories/remote-queries/shared/variant-analysis-repo-tasks';
import { VariantAnalysisRepositoryTask } from '../../../remote-queries/shared/variant-analysis';
describe(VariantAnalysisResultsManager.name, function() {
this.timeout(10000);
@@ -51,15 +54,15 @@ describe(VariantAnalysisResultsManager.name, function() {
request: getOctokitStub
})
} as unknown as Credentials;
let dummyRepoTask = createMockVariantAnalysisRepoTask();
let dummyRepoTask: VariantAnalysisRepositoryTask;
let variantAnalysisStoragePath: string;
let repoTaskStorageDirectory: string;
beforeEach(async () => {
dummyRepoTask = createMockVariantAnalysisRepoTask();
dummyRepoTask = createMockVariantAnalysisRepositoryTask();
variantAnalysisStoragePath = path.join(storagePath, variantAnalysisId.toString());
repoTaskStorageDirectory = variantAnalysisResultsManager.getRepoStorageDirectory(variantAnalysisStoragePath, dummyRepoTask.repository.full_name);
repoTaskStorageDirectory = variantAnalysisResultsManager.getRepoStorageDirectory(variantAnalysisStoragePath, dummyRepoTask.repository.fullName);
});
afterEach(async () => {
@@ -70,14 +73,14 @@ describe(VariantAnalysisResultsManager.name, function() {
describe('isVariantAnalysisRepoDownloaded', () => {
it('should return false when no results are downloaded', async () => {
expect(await variantAnalysisResultsManager.isVariantAnalysisRepoDownloaded(variantAnalysisStoragePath, dummyRepoTask.repository.full_name)).to.equal(false);
expect(await variantAnalysisResultsManager.isVariantAnalysisRepoDownloaded(variantAnalysisStoragePath, dummyRepoTask.repository.fullName)).to.equal(false);
});
});
describe('when the artifact_url is missing', async () => {
it('should not try to download the result', async () => {
const dummyRepoTask = createMockVariantAnalysisRepoTask();
delete dummyRepoTask.artifact_url;
const dummyRepoTask = createMockVariantAnalysisRepositoryTask();
delete dummyRepoTask.artifactUrl;
try {
await variantAnalysisResultsManager.download(
@@ -103,7 +106,7 @@ describe(VariantAnalysisResultsManager.name, function() {
getVariantAnalysisRepoResultStub = sandbox
.stub(ghApiClient, 'getVariantAnalysisRepoResult')
.withArgs(mockCredentials, dummyRepoTask.artifact_url as string)
.withArgs(mockCredentials, dummyRepoTask.artifactUrl as string)
.resolves(arrayBuffer);
});
@@ -149,7 +152,7 @@ describe(VariantAnalysisResultsManager.name, function() {
variantAnalysisStoragePath
);
expect(await variantAnalysisResultsManager.isVariantAnalysisRepoDownloaded(variantAnalysisStoragePath, dummyRepoTask.repository.full_name)).to.equal(true);
expect(await variantAnalysisResultsManager.isVariantAnalysisRepoDownloaded(variantAnalysisStoragePath, dummyRepoTask.repository.fullName)).to.equal(true);
});
});
});

View File

@@ -47,6 +47,7 @@ export function createMockLocalQueryInfo({
const localQuery = new LocalQueryInfo(initialQueryInfo, cancellationToken);
localQuery.failureReason = failureReason;
localQuery.cancel = () => { /**/ };
if (queryWithResults) {
localQuery.completeThisQuery(queryWithResults);

View File

@@ -0,0 +1,19 @@
import { faker } from '@faker-js/faker';
import {
VariantAnalysisRepositoryTask,
VariantAnalysisRepoStatus,
} from '../../../../remote-queries/shared/variant-analysis';
import { createMockRepositoryWithMetadata } from './repository';
export function createMockVariantAnalysisRepositoryTask(data?: Partial<VariantAnalysisRepositoryTask>): VariantAnalysisRepositoryTask {
return {
repository: createMockRepositoryWithMetadata(),
analysisStatus: VariantAnalysisRepoStatus.Pending,
resultCount: faker.datatype.number(),
artifactSizeInBytes: faker.datatype.number(),
databaseCommitSha: faker.git.commitSha(),
sourceLocationPrefix: faker.system.filePath(),
artifactUrl: faker.internet.url(),
...data,
};
}

View File

@@ -21,7 +21,7 @@ export function createMockVariantAnalysis({
skippedRepos?: VariantAnalysisSkippedRepositories,
executionStartTime?: number | undefined
}): VariantAnalysis {
const variantAnalysis: VariantAnalysis = {
return {
id: faker.datatype.number(),
controllerRepo: {
...createMockRepository(),
@@ -46,6 +46,4 @@ export function createMockVariantAnalysis({
scannedRepos: scannedRepos,
skippedRepos: skippedRepos
};
return variantAnalysis;
}

View File

@@ -32,6 +32,8 @@ import { createMockVariantAnalysisHistoryItem } from '../factories/remote-querie
import { VariantAnalysisHistoryItem } from '../../remote-queries/variant-analysis-history-item';
import { QueryStatus } from '../../query-status';
import { VariantAnalysisStatus } from '../../remote-queries/shared/variant-analysis';
import * as ghActionsApiClient from '../../remote-queries/gh-api/gh-actions-api-client';
import { Credentials } from '../../authentication';
describe('query-history', () => {
const mockExtensionLocation = path.join(tmpDir.name, 'mock-extension-location');
@@ -92,10 +94,18 @@ describe('query-history', () => {
} as any as VariantAnalysisManager;
localQueryHistory = [
// completed
createMockLocalQueryInfo({ dbName: 'a', queryWithResults: createMockQueryWithResults({ sandbox, didRunSuccessfully: true }) }),
// completed
createMockLocalQueryInfo({ dbName: 'b', queryWithResults: createMockQueryWithResults({ sandbox, didRunSuccessfully: true }) }),
// failed
createMockLocalQueryInfo({ dbName: 'a', queryWithResults: createMockQueryWithResults({ sandbox, didRunSuccessfully: false }) }),
// completed
createMockLocalQueryInfo({ dbName: 'a', queryWithResults: createMockQueryWithResults({ sandbox, didRunSuccessfully: true }) }),
// in progress
createMockLocalQueryInfo({ resultCount: 0 }),
// in progress
createMockLocalQueryInfo({ resultCount: 0 })
];
remoteQueryHistory = [
createMockRemoteQueryHistoryItem({ status: QueryStatus.Completed }),
@@ -325,6 +335,168 @@ describe('query-history', () => {
});
});
describe('handleCancel', () => {
let mockCredentials: Credentials;
let mockCancelRemoteQuery: sinon.SinonStub;
let mockCancelVariantAnalysis: sinon.SinonStub;
let getOctokitStub: sinon.SinonStub;
beforeEach(async () => {
mockCredentials = {
getOctokit: () => Promise.resolve({
request: getOctokitStub
})
} as unknown as Credentials;
sandbox.stub(Credentials, 'initialize').resolves(mockCredentials);
mockCancelRemoteQuery = sandbox.stub(ghActionsApiClient, 'cancelRemoteQuery');
mockCancelVariantAnalysis = sandbox.stub(ghActionsApiClient, 'cancelVariantAnalysis');
});
describe('if the item is in progress', async () => {
it('should cancel a single local query', async () => {
queryHistoryManager = await createMockQueryHistory(localQueryHistory);
// cancelling the selected item
const inProgress1 = localQueryHistory[4];
const cancelSpy = sandbox.spy(inProgress1, 'cancel');
await queryHistoryManager.handleCancel(inProgress1, [inProgress1]);
expect(cancelSpy).to.have.been.calledOnce;
});
it('should cancel multiple local queries', async () => {
queryHistoryManager = await createMockQueryHistory(localQueryHistory);
// cancelling the selected item
const inProgress1 = localQueryHistory[4];
const inProgress2 = localQueryHistory[5];
const cancelSpy1 = sandbox.spy(inProgress1, 'cancel');
const cancelSpy2 = sandbox.spy(inProgress2, 'cancel');
await queryHistoryManager.handleCancel(inProgress1, [inProgress1, inProgress2]);
expect(cancelSpy1).to.have.been.called;
expect(cancelSpy2).to.have.been.called;
});
it('should cancel a single remote query', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const inProgress1 = remoteQueryHistory[2];
await queryHistoryManager.handleCancel(inProgress1, [inProgress1]);
expect(mockCancelRemoteQuery).to.have.been.calledWith(mockCredentials, inProgress1.remoteQuery);
});
it('should cancel multiple remote queries', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const inProgress1 = remoteQueryHistory[2];
const inProgress2 = remoteQueryHistory[3];
await queryHistoryManager.handleCancel(inProgress1, [inProgress1, inProgress2]);
expect(mockCancelRemoteQuery).to.have.been.calledWith(mockCredentials, inProgress1.remoteQuery);
expect(mockCancelRemoteQuery).to.have.been.calledWith(mockCredentials, inProgress2.remoteQuery);
});
it('should cancel a single variant analysis', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const inProgress1 = variantAnalysisHistory[1];
await queryHistoryManager.handleCancel(inProgress1, [inProgress1]);
expect(mockCancelVariantAnalysis).to.have.been.calledWith(mockCredentials, inProgress1.variantAnalysis);
});
it('should cancel multiple variant analyses', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const inProgress1 = variantAnalysisHistory[1];
const inProgress2 = variantAnalysisHistory[3];
await queryHistoryManager.handleCancel(inProgress1, [inProgress1, inProgress2]);
expect(mockCancelVariantAnalysis).to.have.been.calledWith(mockCredentials, inProgress1.variantAnalysis);
expect(mockCancelVariantAnalysis).to.have.been.calledWith(mockCredentials, inProgress2.variantAnalysis);
});
});
describe('if the item is not in progress', async () => {
it('should not cancel a single local query', async () => {
queryHistoryManager = await createMockQueryHistory(localQueryHistory);
// cancelling the selected item
const completed = localQueryHistory[0];
const cancelSpy = sandbox.spy(completed, 'cancel');
await queryHistoryManager.handleCancel(completed, [completed]);
expect(cancelSpy).to.not.have.been.calledOnce;
});
it('should not cancel multiple local queries', async () => {
queryHistoryManager = await createMockQueryHistory(localQueryHistory);
// cancelling the selected item
const completed = localQueryHistory[0];
const failed = localQueryHistory[2];
const cancelSpy = sandbox.spy(completed, 'cancel');
const cancelSpy2 = sandbox.spy(failed, 'cancel');
await queryHistoryManager.handleCancel(completed, [completed, failed]);
expect(cancelSpy).to.not.have.been.calledOnce;
expect(cancelSpy2).to.not.have.been.calledOnce;
});
it('should not cancel a single remote query', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const completed = remoteQueryHistory[0];
await queryHistoryManager.handleCancel(completed, [completed]);
expect(mockCancelRemoteQuery).to.not.have.been.calledWith(mockCredentials, completed.remoteQuery);
});
it('should not cancel multiple remote queries', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const completed = remoteQueryHistory[0];
const failed = remoteQueryHistory[1];
await queryHistoryManager.handleCancel(completed, [completed, failed]);
expect(mockCancelRemoteQuery).to.not.have.been.calledWith(mockCredentials, completed.remoteQuery);
expect(mockCancelRemoteQuery).to.not.have.been.calledWith(mockCredentials, failed.remoteQuery);
});
it('should not cancel a single variant analysis', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const completedVariantAnalysis = variantAnalysisHistory[0];
await queryHistoryManager.handleCancel(completedVariantAnalysis, [completedVariantAnalysis]);
expect(mockCancelVariantAnalysis).to.not.have.been.calledWith(mockCredentials, completedVariantAnalysis.variantAnalysis);
});
it('should not cancel multiple variant analyses', async () => {
queryHistoryManager = await createMockQueryHistory(allHistory);
// cancelling the selected item
const completedVariantAnalysis = variantAnalysisHistory[0];
const failedVariantAnalysis = variantAnalysisHistory[2];
await queryHistoryManager.handleCancel(completedVariantAnalysis, [completedVariantAnalysis, failedVariantAnalysis]);
expect(mockCancelVariantAnalysis).to.not.have.been.calledWith(mockCredentials, completedVariantAnalysis.variantAnalysis);
expect(mockCancelVariantAnalysis).to.not.have.been.calledWith(mockCredentials, failedVariantAnalysis.variantAnalysis);
});
});
});
describe('determineSelection', () => {
const singleItem = 'a';
const multipleItems = ['b', 'c', 'd'];

View File

@@ -2,8 +2,14 @@ import { fail } from 'assert';
import { expect } from 'chai';
import * as sinon from 'sinon';
import { Credentials } from '../../../../authentication';
import { cancelRemoteQuery, getRepositoriesMetadata } from '../../../../remote-queries/gh-api/gh-actions-api-client';
import {
cancelRemoteQuery,
cancelVariantAnalysis,
getRepositoriesMetadata
} from '../../../../remote-queries/gh-api/gh-actions-api-client';
import { RemoteQuery } from '../../../../remote-queries/remote-query';
import { createMockVariantAnalysis } from '../../../factories/remote-queries/shared/variant-analysis';
import { VariantAnalysis } from '../../../../remote-queries/shared/variant-analysis';
describe('gh-actions-api-client mock responses', () => {
let sandbox: sinon.SinonSandbox;
@@ -50,6 +56,29 @@ describe('gh-actions-api-client mock responses', () => {
} as unknown as RemoteQuery;
}
});
describe('cancelVariantAnalysis', () => {
let variantAnalysis: VariantAnalysis;
before(() => {
variantAnalysis = createMockVariantAnalysis({});
});
it('should cancel a variant analysis', async () => {
mockResponse = sinon.stub().resolves({ status: 202 });
await cancelVariantAnalysis(mockCredentials, variantAnalysis);
expect(mockResponse.calledOnce).to.be.true;
expect(mockResponse.firstCall.args[0]).to.equal(`POST /repos/${variantAnalysis.controllerRepo.fullName}/actions/runs/${variantAnalysis.actionsWorkflowRunId}/cancel`);
});
it('should fail to cancel a variant analysis', async () => {
mockResponse = sinon.stub().resolves({ status: 409, data: { message: 'Uh oh!' } });
await expect(cancelVariantAnalysis(mockCredentials, variantAnalysis)).to.be.rejectedWith(/Error cancelling variant analysis: 409 Uh oh!/);
expect(mockResponse.calledOnce).to.be.true;
expect(mockResponse.firstCall.args[0]).to.equal(`POST /repos/${variantAnalysis.controllerRepo.fullName}/actions/runs/${variantAnalysis.actionsWorkflowRunId}/cancel`);
});
});
});
describe('gh-actions-api-client real responses', function() {

View File

@@ -1,4 +1,5 @@
import { expect } from 'chai';
import { faker } from '@faker-js/faker';
import {
VariantAnalysisScannedRepository as ApiVariantAnalysisScannedRepository
} from '../../../src/remote-queries/gh-api/variant-analysis';
@@ -7,8 +8,15 @@ import {
VariantAnalysisScannedRepository,
VariantAnalysisRepoStatus
} from '../../../src/remote-queries/shared/variant-analysis';
import { processVariantAnalysis } from '../../../src/remote-queries/variant-analysis-processor';
import { createMockScannedRepos } from '../../../src/vscode-tests/factories/remote-queries/gh-api/scanned-repositories';
import {
processScannedRepository,
processVariantAnalysis,
processVariantAnalysisRepositoryTask
} from '../../../src/remote-queries/variant-analysis-processor';
import {
createMockScannedRepo,
createMockScannedRepos
} from '../../../src/vscode-tests/factories/remote-queries/gh-api/scanned-repositories';
import { createMockSkippedRepos } from '../../../src/vscode-tests/factories/remote-queries/gh-api/skipped-repositories';
import {
createMockApiResponse
@@ -16,8 +24,11 @@ import {
import {
createMockSubmission
} from '../../../src/vscode-tests/factories/remote-queries/shared/variant-analysis-submission';
import {
createMockVariantAnalysisRepoTask
} from '../../../src/vscode-tests/factories/remote-queries/gh-api/variant-analysis-repo-task';
describe('Variant Analysis processor', function() {
describe(processVariantAnalysis.name, function() {
const scannedRepos = createMockScannedRepos();
const skippedRepos = createMockSkippedRepos();
const mockApiResponse = createMockApiResponse('completed', scannedRepos, skippedRepos);
@@ -147,3 +158,44 @@ describe('Variant Analysis processor', function() {
};
}
});
describe(processVariantAnalysisRepositoryTask.name, () => {
const mockApiResponse = createMockVariantAnalysisRepoTask();
it('should return the correct result', () => {
expect(processVariantAnalysisRepositoryTask(mockApiResponse)).to.deep.eq({
repository: {
id: mockApiResponse.repository.id,
fullName: mockApiResponse.repository.full_name,
private: mockApiResponse.repository.private,
},
analysisStatus: 'succeeded',
resultCount: mockApiResponse.result_count,
artifactSizeInBytes: mockApiResponse.artifact_size_in_bytes,
failureMessage: mockApiResponse.failure_message,
databaseCommitSha: mockApiResponse.database_commit_sha,
sourceLocationPrefix: mockApiResponse.source_location_prefix,
artifactUrl: mockApiResponse.artifact_url,
});
});
});
describe(processScannedRepository.name, () => {
const mockApiResponse = createMockScannedRepo(faker.random.word(), faker.datatype.boolean(), VariantAnalysisRepoStatus.Pending);
it('should return the correct result', () => {
expect(processScannedRepository(mockApiResponse)).to.deep.eq({
repository: {
id: mockApiResponse.repository.id,
fullName: mockApiResponse.repository.full_name,
private: mockApiResponse.repository.private,
stargazersCount: mockApiResponse.repository.stargazers_count,
updatedAt: mockApiResponse.repository.updated_at,
},
analysisStatus: 'pending',
resultCount: mockApiResponse.result_count,
artifactSizeInBytes: mockApiResponse.artifact_size_in_bytes,
failureMessage: mockApiResponse.failure_message,
});
});
});

View File

@@ -0,0 +1,49 @@
{
"type": "object",
"properties": {
"$schema": {
"type": "string"
},
"remote": {
"type": "object",
"properties": {
"repositoryLists": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"repositories": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-zA-Z0-9-_\\.]+/[a-zA-Z0-9-_\\.]+$"
}
}
},
"required": ["name", "repositories"],
"additionalProperties": false
}
},
"owners": {
"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
}
}
}