Merge branch 'main' into elena/yer-a-flag-query

This commit is contained in:
Elena Tanasoiu
2023-04-19 12:27:52 +01:00
committed by GitHub
23 changed files with 806 additions and 240 deletions

View File

@@ -2,6 +2,7 @@
## [UNRELEASED]
- Added ability to filter repositories for a variant analysis to only those that have results [#2343](https://github.com/github/vscode-codeql/pull/2343)
- Add new configuration option to allow downloading databases from http, non-secure servers. [#2332](https://github.com/github/vscode-codeql/pull/2332)
## 1.8.2 - 12 April 2023

View File

@@ -340,6 +340,12 @@
"type": "boolean",
"default": false,
"description": "Allow database to be downloaded via HTTP. Warning: enabling this option will allow downloading from insecure servers."
},
"codeQL.createQuery.folder": {
"type": "string",
"default": "",
"patternErrorMessage": "Please enter a valid folder",
"markdownDescription": "The name of the folder where we want to create queries and query packs via the \"CodeQL: Create Query\" command. The folder should exist."
}
}
},

View File

@@ -619,3 +619,19 @@ export const ALLOW_HTTP_SETTING = new Setting(
export function allowHttp(): boolean {
return ALLOW_HTTP_SETTING.getValue<boolean>() || false;
}
/**
* The name of the folder where we want to create skeleton wizard QL packs.
**/
const SKELETON_WIZARD_FOLDER = new Setting(
"folder",
new Setting("createQuery", ROOT_SETTING),
);
export function getSkeletonWizardFolder(): string | undefined {
return SKELETON_WIZARD_FOLDER.getValue<string>() || undefined;
}
export async function setSkeletonWizardFolder(folder: string | undefined) {
await SKELETON_WIZARD_FOLDER.updateValue(folder, ConfigurationTarget.Global);
}

View File

@@ -35,7 +35,7 @@ import { readQueryResults, runQuery } from "./external-api-usage-query";
import { createDataExtensionYaml, loadDataExtensionYaml } from "./yaml";
import { ExternalApiUsage } from "./external-api-usage";
import { ModeledMethod } from "./modeled-method";
import { ExtensionPackModelFile } from "./extension-pack-picker";
import { ExtensionPackModelFile } from "./shared/extension-pack";
function getQlSubmoduleFolder(): WorkspaceFolder | undefined {
const workspaceFolder = workspace.workspaceFolders?.find(
@@ -118,7 +118,7 @@ export class DataExtensionsEditorView extends AbstractWebview<
msg.externalApiUsages,
msg.modeledMethods,
);
await this.loadExternalApiUsages();
await Promise.all([this.setViewState(), this.loadExternalApiUsages()]);
break;
case "generateExternalApi":
@@ -134,16 +134,22 @@ export class DataExtensionsEditorView extends AbstractWebview<
super.onWebViewLoaded();
await Promise.all([
this.postMessage({
t: "setDataExtensionEditorInitialData",
extensionPackName: this.modelFile.extensionPack.name,
modelFilename: this.modelFile.filename,
}),
this.setViewState(),
this.loadExternalApiUsages(),
this.loadExistingModeledMethods(),
]);
}
private async setViewState(): Promise<void> {
await this.postMessage({
t: "setDataExtensionEditorViewState",
viewState: {
extensionPackModelFile: this.modelFile,
modelFileExists: await pathExists(this.modelFile.filename),
},
});
}
protected async jumpToUsage(
location: ResolvableLocationValue,
): Promise<void> {

View File

@@ -13,6 +13,7 @@ import { ProgressCallback } from "../progress";
import { DatabaseItem } from "../local-databases";
import { getQlPackPath, QLPACK_FILENAMES } from "../pure/ql";
import { getErrorMessage } from "../pure/helpers-pure";
import { ExtensionPack, ExtensionPackModelFile } from "./shared/extension-pack";
const maxStep = 3;
@@ -22,22 +23,6 @@ const packNameRegex = new RegExp(
);
const packNameLength = 128;
export interface ExtensionPack {
path: string;
yamlPath: string;
name: string;
version: string;
extensionTargets: Record<string, string>;
dataExtensions: string[];
}
export interface ExtensionPackModelFile {
filename: string;
extensionPack: ExtensionPack;
}
export async function pickExtensionPackModelFile(
cliServer: Pick<CodeQLCliServer, "resolveQlpacks" | "resolveExtensions">,
databaseItem: Pick<DatabaseItem, "name" | "language">,

View File

@@ -0,0 +1,15 @@
export interface ExtensionPack {
path: string;
yamlPath: string;
name: string;
version: string;
extensionTargets: Record<string, string>;
dataExtensions: string[];
}
export interface ExtensionPackModelFile {
filename: string;
extensionPack: ExtensionPack;
}

View File

@@ -0,0 +1,6 @@
import { ExtensionPackModelFile } from "./extension-pack";
export interface DataExtensionEditorViewState {
extensionPackModelFile: ExtensionPackModelFile;
modelFileExists: boolean;
}

View File

@@ -317,13 +317,15 @@ async function databaseArchiveFetcher(
});
await ensureZippedSourceLocation(dbPath);
const makeSelected = true;
const item = await databaseManager.openDatabase(
progress,
token,
Uri.file(dbPath),
makeSelected,
nameOverride,
);
await databaseManager.setCurrentDatabaseItem(item);
return item;
} else {
throw new Error("Database not found in archive.");

View File

@@ -306,18 +306,21 @@ export class DatabaseUI extends DisposableObject {
`${workspace.workspaceFolders[0].uri}/.tours/codeql-tutorial-database`,
);
let databaseItem = this.databaseManager.findDatabaseItem(uri);
const isTutorialDatabase = true;
const databaseItem = this.databaseManager.findDatabaseItem(uri);
if (databaseItem === undefined) {
databaseItem = await this.databaseManager.openDatabase(
const makeSelected = true;
const nameOverride = "CodeQL Tutorial Database";
const isTutorialDatabase = true;
await this.databaseManager.openDatabase(
progress,
token,
uri,
"CodeQL Tutorial Database",
makeSelected,
nameOverride,
isTutorialDatabase,
);
}
await this.databaseManager.setCurrentDatabaseItem(databaseItem);
await this.handleTourDependencies();
}
} catch (e) {
@@ -630,7 +633,7 @@ export class DatabaseUI extends DisposableObject {
this.queryServer?.cliServer,
);
} else {
await this.setCurrentDatabase(progress, token, uri);
await this.databaseManager.openDatabase(progress, token, uri);
}
} catch (e) {
// rethrow and let this be handled by default error handling.
@@ -752,24 +755,6 @@ export class DatabaseUI extends DisposableObject {
return this.databaseManager.currentDatabaseItem;
}
private async setCurrentDatabase(
progress: ProgressCallback,
token: CancellationToken,
uri: Uri,
): Promise<DatabaseItem | undefined> {
let databaseItem = this.databaseManager.findDatabaseItem(uri);
if (databaseItem === undefined) {
databaseItem = await this.databaseManager.openDatabase(
progress,
token,
uri,
);
}
await this.databaseManager.setCurrentDatabaseItem(databaseItem);
return databaseItem;
}
/**
* Ask the user for a database directory. Returns the chosen database, or `undefined` if the
* operation was canceled.
@@ -789,7 +774,11 @@ export class DatabaseUI extends DisposableObject {
if (byFolder) {
const fixedUri = await this.fixDbUri(uri);
// we are selecting a database folder
return await this.setCurrentDatabase(progress, token, fixedUri);
return await this.databaseManager.openDatabase(
progress,
token,
fixedUri,
);
} else {
// we are selecting a database archive. Must unzip into a workspace-controlled area
// before importing.

View File

@@ -621,6 +621,7 @@ export class DatabaseManager extends DisposableObject {
progress: ProgressCallback,
token: vscode.CancellationToken,
uri: vscode.Uri,
makeSelected = false,
displayName?: string,
isTutorialDatabase?: boolean,
): Promise<DatabaseItem> {
@@ -629,6 +630,7 @@ export class DatabaseManager extends DisposableObject {
return await this.addExistingDatabaseItem(
databaseItem,
progress,
makeSelected,
token,
isTutorialDatabase,
);
@@ -643,6 +645,7 @@ export class DatabaseManager extends DisposableObject {
public async addExistingDatabaseItem(
databaseItem: DatabaseItem,
progress: ProgressCallback,
makeSelected = true,
token: vscode.CancellationToken,
isTutorialDatabase?: boolean,
): Promise<DatabaseItem> {
@@ -652,6 +655,9 @@ export class DatabaseManager extends DisposableObject {
}
await this.addDatabaseItem(progress, token, databaseItem);
if (makeSelected) {
await this.setCurrentDatabaseItem(databaseItem);
}
await this.addDatabaseSourceArchiveFolder(databaseItem);
if (isCodespacesTemplate() && !isTutorialDatabase) {

View File

@@ -16,6 +16,7 @@ import { ErrorLike } from "./errors";
import { DataFlowPaths } from "../variant-analysis/shared/data-flow-paths";
import { ExternalApiUsage } from "../data-extensions-editor/external-api-usage";
import { ModeledMethod } from "../data-extensions-editor/modeled-method";
import { DataExtensionEditorViewState } from "../data-extensions-editor/shared/view-state";
/**
* This module contains types and code that are shared between
@@ -481,10 +482,9 @@ export type ToDataFlowPathsMessage = SetDataFlowPathsMessage;
export type FromDataFlowPathsMessage = CommonFromViewMessages;
export interface SetDataExtensionEditorInitialDataMessage {
t: "setDataExtensionEditorInitialData";
extensionPackName: string;
modelFilename: string;
export interface SetExtensionPackStateMessage {
t: "setDataExtensionEditorViewState";
viewState: DataExtensionEditorViewState;
}
export interface SetExternalApiUsagesMessage {
@@ -536,7 +536,7 @@ export interface GenerateExternalApiMessage {
}
export type ToDataExtensionsEditorMessage =
| SetDataExtensionEditorInitialDataMessage
| SetExtensionPackStateMessage
| SetExternalApiUsagesMessage
| ShowProgressMessage
| AddModeledMethodsMessage;

View File

@@ -3,6 +3,12 @@ import {
RepositoryWithMetadata,
} from "../variant-analysis/shared/repository";
import { parseDate } from "./date";
import { assertNever } from "./helpers-pure";
export enum FilterKey {
All = "all",
WithResults = "withResults",
}
export enum SortKey {
Name = "name",
@@ -13,6 +19,7 @@ export enum SortKey {
export type RepositoriesFilterSortState = {
searchValue: string;
filterKey: FilterKey;
sortKey: SortKey;
};
@@ -22,20 +29,43 @@ export type RepositoriesFilterSortStateWithIds = RepositoriesFilterSortState & {
export const defaultFilterSortState: RepositoriesFilterSortState = {
searchValue: "",
filterKey: FilterKey.All,
sortKey: SortKey.Name,
};
export function matchesFilter(
repo: Pick<Repository, "fullName">,
item: FilterAndSortableResult,
filterSortState: RepositoriesFilterSortState | undefined,
): boolean {
if (!filterSortState) {
return true;
}
return repo.fullName
.toLowerCase()
.includes(filterSortState.searchValue.toLowerCase());
return (
matchesSearch(item.repository, filterSortState.searchValue) &&
matchesFilterKey(item.resultCount, filterSortState.filterKey)
);
}
function matchesSearch(
repository: SortableRepository,
searchValue: string,
): boolean {
return repository.fullName.toLowerCase().includes(searchValue.toLowerCase());
}
function matchesFilterKey(
resultCount: number | undefined,
filterKey: FilterKey,
): boolean {
switch (filterKey) {
case FilterKey.All:
return true;
case FilterKey.WithResults:
return resultCount !== undefined && resultCount > 0;
default:
assertNever(filterKey);
}
}
type SortableRepository = Pick<Repository, "fullName"> &
@@ -71,17 +101,22 @@ export function compareRepository(
};
}
type SortableResult = {
type FilterAndSortableResult = {
repository: SortableRepository;
resultCount?: number;
};
type FilterAndSortableResultWithIds = {
repository: SortableRepository & Pick<Repository, "id">;
resultCount?: number;
};
export function compareWithResults(
filterSortState: RepositoriesFilterSortState | undefined,
): (left: SortableResult, right: SortableResult) => number {
): (left: FilterAndSortableResult, right: FilterAndSortableResult) => number {
const fallbackSort = compareRepository(filterSortState);
return (left: SortableResult, right: SortableResult) => {
return (left: FilterAndSortableResult, right: FilterAndSortableResult) => {
// Highest to lowest
if (filterSortState?.sortKey === SortKey.ResultsCount) {
const resultCount = (right.resultCount ?? 0) - (left.resultCount ?? 0);
@@ -95,7 +130,7 @@ export function compareWithResults(
}
export function filterAndSortRepositoriesWithResultsByName<
T extends SortableResult,
T extends FilterAndSortableResult,
>(
repositories: T[] | undefined,
filterSortState: RepositoriesFilterSortState | undefined,
@@ -105,11 +140,13 @@ export function filterAndSortRepositoriesWithResultsByName<
}
return repositories
.filter((repo) => matchesFilter(repo.repository, filterSortState))
.filter((repo) => matchesFilter(repo, filterSortState))
.sort(compareWithResults(filterSortState));
}
export function filterAndSortRepositoriesWithResults<T extends SortableResult>(
export function filterAndSortRepositoriesWithResults<
T extends FilterAndSortableResultWithIds,
>(
repositories: T[] | undefined,
filterSortState: RepositoriesFilterSortStateWithIds | undefined,
): T[] | undefined {
@@ -117,6 +154,7 @@ export function filterAndSortRepositoriesWithResults<T extends SortableResult>(
return undefined;
}
// If repository IDs are given, then ignore the search value and filter key
if (
filterSortState?.repositoryIds &&
filterSortState.repositoryIds.length > 0

View File

@@ -14,7 +14,12 @@ import { QlPackGenerator } from "./qlpack-generator";
import { DatabaseItem, DatabaseManager } from "./local-databases";
import { ProgressCallback, UserCancellationException } from "./progress";
import { askForGitHubRepo, downloadGitHubDatabase } from "./databaseFetcher";
import { existsSync } from "fs";
import {
getSkeletonWizardFolder,
isCodespacesTemplate,
setSkeletonWizardFolder,
} from "./config";
import { existsSync } from "fs-extra";
type QueryLanguagesToDatabaseMap = Record<string, string>;
@@ -55,7 +60,7 @@ export class SkeletonQueryWizard {
return;
}
this.qlPackStoragePath = getFirstWorkspaceFolder();
this.qlPackStoragePath = await this.determineStoragePath();
const skeletonPackAlreadyExists =
existsSync(join(this.qlPackStoragePath, this.folderName)) ||
@@ -64,15 +69,14 @@ export class SkeletonQueryWizard {
if (skeletonPackAlreadyExists) {
// just create a new example query file in skeleton QL pack
await this.createExampleFile();
// select existing database for language
await this.selectExistingDatabase();
} else {
// generate a new skeleton QL pack with query file
await this.createQlPack();
// download database based on language and select it
await this.downloadDatabase();
}
// select existing database for language or download a new one
await this.selectOrDownloadDatabase();
// open a query file
try {
@@ -98,6 +102,38 @@ export class SkeletonQueryWizard {
});
}
public async determineStoragePath() {
const firstStorageFolder = getFirstWorkspaceFolder();
if (isCodespacesTemplate()) {
return firstStorageFolder;
}
let storageFolder = getSkeletonWizardFolder();
if (storageFolder === undefined || !existsSync(storageFolder)) {
storageFolder = await Window.showInputBox({
title:
"Please choose a folder in which to create your new query pack. You can change this in the extension settings.",
value: firstStorageFolder,
ignoreFocusOut: true,
});
}
if (storageFolder === undefined) {
throw new UserCancellationException("No storage folder entered.");
}
if (!existsSync(storageFolder)) {
throw new UserCancellationException(
"Invalid folder. Must be a folder that already exists.",
);
}
await setSkeletonWizardFolder(storageFolder);
return storageFolder;
}
private async chooseLanguage() {
this.progress({
message: "Choose language",
@@ -216,7 +252,7 @@ export class SkeletonQueryWizard {
);
}
private async selectExistingDatabase() {
private async selectOrDownloadDatabase() {
if (this.language === undefined) {
throw new Error("Language is undefined");
}
@@ -225,65 +261,83 @@ export class SkeletonQueryWizard {
throw new Error("QL Pack storage path is undefined");
}
const databaseNwo = QUERY_LANGUAGE_TO_DATABASE_REPO[this.language];
const existingDatabaseItem = await this.findDatabaseItemByNwo(
this.language,
databaseNwo,
this.databaseManager.databaseItems,
);
const existingDatabaseItem =
await SkeletonQueryWizard.findExistingDatabaseItem(
this.language,
this.databaseManager.databaseItems,
);
if (existingDatabaseItem) {
// select the found database
await this.databaseManager.setCurrentDatabaseItem(existingDatabaseItem);
} else {
const sameLanguageDatabaseItem = await this.findDatabaseItemByLanguage(
this.language,
this.databaseManager.databaseItems,
);
if (sameLanguageDatabaseItem) {
// select the found database
await this.databaseManager.setCurrentDatabaseItem(
sameLanguageDatabaseItem,
);
} else {
// download new database and select it
await this.downloadDatabase();
}
// download new database and select it
await this.downloadDatabase();
}
}
public async findDatabaseItemByNwo(
public static async findDatabaseItemByNwo(
language: string,
databaseNwo: string,
databaseItems: readonly DatabaseItem[],
): Promise<DatabaseItem | undefined> {
const dbItems = databaseItems || [];
const dbs = dbItems.filter(
(db) =>
db.language === language &&
db.name === databaseNwo &&
db.error === undefined,
const dbs = databaseItems.filter(
(db) => db.language === language && db.name === databaseNwo,
);
if (dbs.length === 0) {
return undefined;
}
return dbs[0];
return dbs.pop();
}
public async findDatabaseItemByLanguage(
public static async findDatabaseItemByLanguage(
language: string,
databaseItems: readonly DatabaseItem[],
): Promise<DatabaseItem | undefined> {
const dbItems = databaseItems || [];
const dbs = dbItems.filter(
(db) => db.language === language && db.error === undefined,
const dbs = databaseItems.filter((db) => db.language === language);
return dbs.pop();
}
public static async findExistingDatabaseItem(
language: string,
databaseItems: readonly DatabaseItem[],
): Promise<DatabaseItem | undefined> {
const defaultDatabaseNwo = QUERY_LANGUAGE_TO_DATABASE_REPO[language];
const dbItems = await SkeletonQueryWizard.sortDatabaseItemsByDateAdded(
databaseItems,
);
if (dbs.length === 0) {
return undefined;
const defaultDatabaseItem = await SkeletonQueryWizard.findDatabaseItemByNwo(
language,
defaultDatabaseNwo,
dbItems,
);
if (defaultDatabaseItem !== undefined) {
return defaultDatabaseItem;
}
return dbs[0];
return await SkeletonQueryWizard.findDatabaseItemByLanguage(
language,
dbItems,
);
}
public static async sortDatabaseItemsByDateAdded(
databaseItems: readonly DatabaseItem[],
) {
const validDbItems = databaseItems.filter((db) => db.error === undefined);
return validDbItems.sort((a, b) => {
if (a.dateAdded === undefined) {
return -1;
}
if (b.dateAdded === undefined) {
return 1;
}
return a.dateAdded - b.dateAdded;
});
}
}

View File

@@ -15,9 +15,22 @@ const Template: ComponentStory<typeof DataExtensionsEditorComponent> = (
export const DataExtensionsEditor = Template.bind({});
DataExtensionsEditor.args = {
initialExtensionPackName: "codeql/sql2o-models",
initialModelFilename:
"/home/user/vscode-codeql-starter/codeql-custom-queries-java/sql2o/models/sql2o.yml",
initialViewState: {
extensionPackModelFile: {
extensionPack: {
path: "/home/user/vscode-codeql-starter/codeql-custom-queries-java/sql2o",
yamlPath:
"/home/user/vscode-codeql-starter/codeql-custom-queries-java/sql2o/codeql-pack.yml",
name: "codeql/sql2o-models",
version: "0.0.0",
extensionTargets: {},
dataExtensions: [],
},
filename:
"/home/user/vscode-codeql-starter/codeql-custom-queries-java/sql2o/models/sql2o.yml",
},
modelFileExists: true,
},
initialExternalApiUsages: [
{
signature: "org.sql2o.Connection#createQuery(String)",

View File

@@ -0,0 +1,25 @@
import * as React from "react";
import { useState } from "react";
import { ComponentMeta } from "@storybook/react";
import { RepositoriesFilter as RepositoriesFilterComponent } from "../../view/variant-analysis/RepositoriesFilter";
import { FilterKey } from "../../pure/variant-analysis-filter-sort";
export default {
title: "Variant Analysis/Repositories Filter",
component: RepositoriesFilterComponent,
argTypes: {
value: {
control: {
disable: true,
},
},
},
} as ComponentMeta<typeof RepositoriesFilterComponent>;
export const RepositoriesFilter = () => {
const [value, setValue] = useState(FilterKey.All);
return <RepositoriesFilterComponent value={value} onChange={setValue} />;
};

View File

@@ -20,6 +20,7 @@ import { calculateModeledPercentage } from "./modeled";
import { LinkIconButton } from "../variant-analysis/LinkIconButton";
import { basename } from "../common/path";
import { ViewTitle } from "../common";
import { DataExtensionEditorViewState } from "../../data-extensions-editor/shared/view-state";
const DataExtensionsEditorContainer = styled.div`
margin-top: 1rem;
@@ -31,6 +32,12 @@ const DetailsContainer = styled.div`
align-items: center;
`;
const NonExistingModelFileContainer = styled.div`
display: flex;
gap: 0.2em;
align-items: center;
`;
const EditorContainer = styled.div`
margin-top: 1rem;
`;
@@ -47,24 +54,19 @@ const ProgressBar = styled.div<ProgressBarProps>`
`;
type Props = {
initialExtensionPackName?: string;
initialModelFilename?: string;
initialViewState?: DataExtensionEditorViewState;
initialExternalApiUsages?: ExternalApiUsage[];
initialModeledMethods?: Record<string, ModeledMethod>;
};
export function DataExtensionsEditor({
initialExtensionPackName,
initialModelFilename,
initialViewState,
initialExternalApiUsages = [],
initialModeledMethods = {},
}: Props): JSX.Element {
const [extensionPackName, setExtensionPackName] = useState<
string | undefined
>(initialExtensionPackName);
const [modelFilename, setModelFilename] = useState<string | undefined>(
initialModelFilename,
);
const [viewState, setViewState] = useState<
DataExtensionEditorViewState | undefined
>(initialViewState);
const [externalApiUsages, setExternalApiUsages] = useState<
ExternalApiUsage[]
@@ -83,9 +85,8 @@ export function DataExtensionsEditor({
if (evt.origin === window.origin) {
const msg: ToDataExtensionsEditorMessage = evt.data;
switch (msg.t) {
case "setDataExtensionEditorInitialData":
setExtensionPackName(msg.extensionPackName);
setModelFilename(msg.modelFilename);
case "setDataExtensionEditorViewState":
setViewState(msg.viewState);
break;
case "setExternalApiUsages":
setExternalApiUsages(msg.externalApiUsages);
@@ -181,17 +182,27 @@ export function DataExtensionsEditor({
<>
<ViewTitle>Data extensions editor</ViewTitle>
<DetailsContainer>
{extensionPackName && (
<LinkIconButton onClick={onOpenExtensionPackClick}>
<span slot="start" className="codicon codicon-package"></span>
{extensionPackName}
</LinkIconButton>
)}
{modelFilename && (
<LinkIconButton onClick={onOpenModelFileClick}>
<span slot="start" className="codicon codicon-file-code"></span>
{basename(modelFilename)}
</LinkIconButton>
{viewState?.extensionPackModelFile && (
<>
<LinkIconButton onClick={onOpenExtensionPackClick}>
<span slot="start" className="codicon codicon-package"></span>
{viewState.extensionPackModelFile.extensionPack.name}
</LinkIconButton>
{viewState.modelFileExists ? (
<LinkIconButton onClick={onOpenModelFileClick}>
<span
slot="start"
className="codicon codicon-file-code"
></span>
{basename(viewState.extensionPackModelFile.filename)}
</LinkIconButton>
) : (
<NonExistingModelFileContainer>
<span className="codicon codicon-file-code"></span>
{basename(viewState.extensionPackModelFile.filename)}
</NonExistingModelFileContainer>
)}
</>
)}
<div>{modeledPercentage.toFixed(2)}% modeled</div>
<div>{unModeledPercentage.toFixed(2)}% unmodeled</div>

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { useCallback } from "react";
import styled from "styled-components";
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react";
import { Codicon } from "../common";
import { FilterKey } from "../../pure/variant-analysis-filter-sort";
const Dropdown = styled(VSCodeDropdown)`
width: 100%;
`;
type Props = {
value: FilterKey;
onChange: (value: FilterKey) => void;
className?: string;
};
export const RepositoriesFilter = ({ value, onChange, className }: Props) => {
const handleInput = useCallback(
(e: InputEvent) => {
const target = e.target as HTMLSelectElement;
onChange(target.value as FilterKey);
},
[onChange],
);
return (
<Dropdown value={value} onInput={handleInput} className={className}>
<Codicon name="list-filter" label="Filter..." slot="indicator" />
<VSCodeOption value={FilterKey.All}>All</VSCodeOption>
<VSCodeOption value={FilterKey.WithResults}>With results</VSCodeOption>
</Dropdown>
);
};

View File

@@ -2,11 +2,13 @@ import * as React from "react";
import { Dispatch, SetStateAction, useCallback } from "react";
import styled from "styled-components";
import {
FilterKey,
RepositoriesFilterSortState,
SortKey,
} from "../../pure/variant-analysis-filter-sort";
import { RepositoriesSearch } from "./RepositoriesSearch";
import { RepositoriesSort } from "./RepositoriesSort";
import { RepositoriesFilter } from "./RepositoriesFilter";
type Props = {
value: RepositoriesFilterSortState;
@@ -25,6 +27,10 @@ const RepositoriesSearchColumn = styled(RepositoriesSearch)`
flex: 3;
`;
const RepositoriesFilterColumn = styled(RepositoriesFilter)`
flex: 1;
`;
const RepositoriesSortColumn = styled(RepositoriesSort)`
flex: 1;
`;
@@ -40,6 +46,16 @@ export const RepositoriesSearchSortRow = ({ value, onChange }: Props) => {
[onChange],
);
const handleFilterKeyChange = useCallback(
(filterKey: FilterKey) => {
onChange((oldValue) => ({
...oldValue,
filterKey,
}));
},
[onChange],
);
const handleSortKeyChange = useCallback(
(sortKey: SortKey) => {
onChange((oldValue) => ({
@@ -56,6 +72,10 @@ export const RepositoriesSearchSortRow = ({ value, onChange }: Props) => {
value={value.searchValue}
onChange={handleSearchValueChange}
/>
<RepositoriesFilterColumn
value={value.filterKey}
onChange={handleFilterKeyChange}
/>
<RepositoriesSortColumn
value={value.sortKey}
onChange={handleSortKeyChange}

View File

@@ -56,8 +56,8 @@ export const VariantAnalysisSkippedRepositoriesTab = ({
}: VariantAnalysisSkippedRepositoriesTabProps) => {
const repositories = useMemo(() => {
return skippedRepositoryGroup.repositories
?.filter((repo) => {
return matchesFilter(repo, filterSortState);
?.filter((repository) => {
return matchesFilter({ repository }, filterSortState);
})
?.sort(compareRepository(filterSortState));
}, [filterSortState, skippedRepositoryGroup.repositories]);

View File

@@ -4,6 +4,7 @@ import {
defaultFilterSortState,
filterAndSortRepositoriesWithResults,
filterAndSortRepositoriesWithResultsByName,
FilterKey,
matchesFilter,
SortKey,
} from "../../src/pure/variant-analysis-filter-sort";
@@ -13,32 +14,93 @@ describe(matchesFilter.name, () => {
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 },
];
describe("searchValue", () => {
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 }) => {
test.each(testCases)(
"returns $matches if searching for $searchValue",
({ searchValue, matches }) => {
expect(
matchesFilter(
{ repository },
{
...defaultFilterSortState,
searchValue,
},
),
).toBe(matches);
},
);
});
describe("filterKey", () => {
it("returns true if filterKey is all and resultCount is positive", () => {
expect(
matchesFilter(repository, {
...defaultFilterSortState,
searchValue,
}),
).toBe(matches);
},
);
matchesFilter(
{ repository, resultCount: 1 },
{ ...defaultFilterSortState, filterKey: FilterKey.All },
),
).toBe(true);
});
it("returns true if filterKey is all and resultCount is zero", () => {
expect(
matchesFilter(
{ repository, resultCount: 0 },
{ ...defaultFilterSortState, filterKey: FilterKey.All },
),
).toBe(true);
});
it("returns true if filterKey is all and resultCount is undefined", () => {
expect(
matchesFilter(
{ repository },
{ ...defaultFilterSortState, filterKey: FilterKey.All },
),
).toBe(true);
});
it("returns true if filterKey is withResults and resultCount is positive", () => {
expect(
matchesFilter(
{ repository, resultCount: 1 },
{ ...defaultFilterSortState, filterKey: FilterKey.WithResults },
),
).toBe(true);
});
it("returns false if filterKey is withResults and resultCount is zero", () => {
expect(
matchesFilter(
{ repository, resultCount: 0 },
{ ...defaultFilterSortState, filterKey: FilterKey.WithResults },
),
).toBe(false);
});
it("returns false if filterKey is withResults and resultCount is undefined", () => {
expect(
matchesFilter(
{ repository },
{ ...defaultFilterSortState, filterKey: FilterKey.WithResults },
),
).toBe(false);
});
});
});
describe(compareRepository.name, () => {
@@ -349,7 +411,7 @@ describe(filterAndSortRepositoriesWithResultsByName.name, () => {
},
];
describe("when sort key is given without filter", () => {
describe("when sort key is given without search or filter", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResultsByName(repositories, {
@@ -365,7 +427,7 @@ describe(filterAndSortRepositoriesWithResultsByName.name, () => {
});
});
describe("when sort key and search filter are given", () => {
describe("when sort key and search are given without filter", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResultsByName(repositories, {
@@ -376,6 +438,30 @@ describe(filterAndSortRepositoriesWithResultsByName.name, () => {
).toEqual([repositories[2], repositories[0]]);
});
});
describe("when sort key and filter withResults are given without search", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResultsByName(repositories, {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
filterKey: FilterKey.WithResults,
}),
).toEqual([repositories[3], repositories[2], repositories[0]]);
});
});
describe("when sort key, search, and filter withResults are given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResultsByName(repositories, {
sortKey: SortKey.ResultsCount,
filterKey: FilterKey.WithResults,
searchValue: "r",
}),
).toEqual([repositories[3]]);
});
});
});
describe(filterAndSortRepositoriesWithResults.name, () => {
@@ -410,7 +496,7 @@ describe(filterAndSortRepositoriesWithResults.name, () => {
},
];
describe("when sort key is given without filter", () => {
describe("when sort key is given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResults(repositories, {
@@ -426,7 +512,7 @@ describe(filterAndSortRepositoriesWithResults.name, () => {
});
});
describe("when sort key and search filter are given", () => {
describe("when sort key and search are given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResults(repositories, {
@@ -438,12 +524,49 @@ describe(filterAndSortRepositoriesWithResults.name, () => {
});
});
describe("when sort key, search filter, and repository ids are given", () => {
describe("when sort key and filter withResults are given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResults(repositories, {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
filterKey: FilterKey.WithResults,
}),
).toEqual([repositories[3], repositories[2], repositories[0]]);
});
});
describe("when sort key and filter withResults are given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResults(repositories, {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
filterKey: FilterKey.WithResults,
}),
).toEqual([repositories[3], repositories[2], repositories[0]]);
});
});
describe("when sort key, search, and filter withResults are given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResults(repositories, {
...defaultFilterSortState,
sortKey: SortKey.ResultsCount,
filterKey: FilterKey.WithResults,
searchValue: "r",
}),
).toEqual([repositories[3]]);
});
});
describe("when sort key, search, filter withResults, and repository ids are given", () => {
it("returns the correct results", () => {
expect(
filterAndSortRepositoriesWithResults(repositories, {
sortKey: SortKey.ResultsCount,
filterKey: FilterKey.WithResults,
searchValue: "la",
repositoryIds: [
repositories[1].repository.id,

View File

@@ -21,6 +21,7 @@ import {
import * as databaseFetcher from "../../../src/databaseFetcher";
import { createMockDB } from "../../factories/databases/databases";
import { asError } from "../../../src/pure/helpers-pure";
import { Setting } from "../../../src/config";
describe("SkeletonQueryWizard", () => {
let mockCli: CodeQLCliServer;
@@ -29,6 +30,7 @@ describe("SkeletonQueryWizard", () => {
let dir: tmp.DirResult;
let storagePath: string;
let quickPickSpy: jest.SpiedFunction<typeof window.showQuickPick>;
let showInputBoxSpy: jest.SpiedFunction<typeof window.showInputBox>;
let generateSpy: jest.SpiedFunction<
typeof QlPackGenerator.prototype.generate
>;
@@ -93,6 +95,9 @@ describe("SkeletonQueryWizard", () => {
quickPickSpy = jest
.spyOn(window, "showQuickPick")
.mockResolvedValueOnce(mockedQuickPickItem(chosenLanguage));
showInputBoxSpy = jest
.spyOn(window, "showInputBox")
.mockResolvedValue(storagePath);
generateSpy = jest
.spyOn(QlPackGenerator.prototype, "generate")
.mockResolvedValue(undefined);
@@ -315,7 +320,7 @@ describe("SkeletonQueryWizard", () => {
jest.spyOn(mockDbItem, "name", "get").mockReturnValue("mock-name");
const databaseItem = await wizard.findDatabaseItemByNwo(
const databaseItem = await SkeletonQueryWizard.findDatabaseItemByNwo(
mockDbItem.language,
mockDbItem.name,
[mockDbItem, mockDbItem2],
@@ -325,37 +330,6 @@ describe("SkeletonQueryWizard", () => {
JSON.stringify(mockDbItem),
);
});
it("should ignore databases with errors", async () => {
const mockDbItem = createMockDB(dir, {
language: "ruby",
dateAdded: 123,
} as FullDatabaseOptions);
const mockDbItem2 = createMockDB(dir, {
language: "javascript",
} as FullDatabaseOptions);
const mockDbItem3 = createMockDB(dir, {
language: "ruby",
dateAdded: 345,
} as FullDatabaseOptions);
jest.spyOn(mockDbItem, "name", "get").mockReturnValue("mock-name");
jest.spyOn(mockDbItem3, "name", "get").mockReturnValue(mockDbItem.name);
jest
.spyOn(mockDbItem, "error", "get")
.mockReturnValue(asError("database go boom!"));
const databaseItem = await wizard.findDatabaseItemByNwo(
mockDbItem.language,
mockDbItem.name,
[mockDbItem, mockDbItem2, mockDbItem3],
);
expect(JSON.stringify(databaseItem)).toEqual(
JSON.stringify(mockDbItem3),
);
});
});
describe("when the item doesn't exist", () => {
@@ -363,7 +337,7 @@ describe("SkeletonQueryWizard", () => {
const mockDbItem = createMockDB(dir);
const mockDbItem2 = createMockDB(dir);
const databaseItem = await wizard.findDatabaseItemByNwo(
const databaseItem = await SkeletonQueryWizard.findDatabaseItemByNwo(
"ruby",
"mock-nwo",
[mockDbItem, mockDbItem2],
@@ -384,39 +358,14 @@ describe("SkeletonQueryWizard", () => {
language: "javascript",
} as FullDatabaseOptions);
const databaseItem = await wizard.findDatabaseItemByLanguage("ruby", [
mockDbItem,
mockDbItem2,
]);
const databaseItem =
await SkeletonQueryWizard.findDatabaseItemByLanguage("ruby", [
mockDbItem,
mockDbItem2,
]);
expect(databaseItem).toEqual(mockDbItem);
});
it("should ignore databases with errors", async () => {
const mockDbItem = createMockDB(dir, {
language: "ruby",
} as FullDatabaseOptions);
const mockDbItem2 = createMockDB(dir, {
language: "javascript",
} as FullDatabaseOptions);
const mockDbItem3 = createMockDB(dir, {
language: "ruby",
} as FullDatabaseOptions);
jest
.spyOn(mockDbItem, "error", "get")
.mockReturnValue(asError("database go boom!"));
const databaseItem = await wizard.findDatabaseItemByLanguage("ruby", [
mockDbItem,
mockDbItem2,
mockDbItem3,
]);
expect(JSON.stringify(databaseItem)).toEqual(
JSON.stringify(mockDbItem3),
);
});
});
describe("when the item doesn't exist", () => {
@@ -424,13 +373,258 @@ describe("SkeletonQueryWizard", () => {
const mockDbItem = createMockDB(dir);
const mockDbItem2 = createMockDB(dir);
const databaseItem = await wizard.findDatabaseItemByLanguage("ruby", [
mockDbItem,
mockDbItem2,
]);
const databaseItem =
await SkeletonQueryWizard.findDatabaseItemByLanguage("ruby", [
mockDbItem,
mockDbItem2,
]);
expect(databaseItem).toBeUndefined();
});
});
});
describe("determineStoragePath", () => {
it("should prompt the user to provide a storage path", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(showInputBoxSpy).toHaveBeenCalledWith(
expect.objectContaining({ value: storagePath }),
);
expect(chosenPath).toEqual(storagePath);
});
it("should write the chosen folder to settings", async () => {
const updateValueSpy = jest.spyOn(Setting.prototype, "updateValue");
await wizard.determineStoragePath();
expect(updateValueSpy).toHaveBeenCalledWith(storagePath, 1);
});
describe("when the user is using the codespace template", () => {
let originalValue: any;
let storedPath: string;
beforeEach(async () => {
storedPath = join(dir.name, "pickles-folder");
ensureDirSync(storedPath);
originalValue = workspace
.getConfiguration("codeQL.createQuery")
.get("folder");
// Set isCodespacesTemplate to true to indicate we are in the codespace template
await workspace
.getConfiguration("codeQL")
.update("codespacesTemplate", true);
});
afterEach(async () => {
await workspace
.getConfiguration("codeQL")
.update("codespacesTemplate", originalValue);
});
it("should not prompt the user", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(showInputBoxSpy).not.toHaveBeenCalled();
expect(chosenPath).toEqual(storagePath);
});
});
describe("when there is already a saved storage path in settings", () => {
describe("when the saved storage path exists", () => {
let originalValue: any;
let storedPath: string;
beforeEach(async () => {
storedPath = join(dir.name, "pickles-folder");
ensureDirSync(storedPath);
originalValue = workspace
.getConfiguration("codeQL.createQuery")
.get("folder");
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", storedPath);
});
afterEach(async () => {
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", originalValue);
});
it("should return it and not prompt the user", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(showInputBoxSpy).not.toHaveBeenCalled();
expect(chosenPath).toEqual(storedPath);
});
});
describe("when the saved storage path does not exist", () => {
let originalValue: any;
let storedPath: string;
beforeEach(async () => {
storedPath = join(dir.name, "this-folder-does-not-exist");
originalValue = workspace
.getConfiguration("codeQL.createQuery")
.get("folder");
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", storedPath);
});
afterEach(async () => {
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", originalValue);
});
it("should prompt the user for to provide a new folder name", async () => {
const chosenPath = await wizard.determineStoragePath();
expect(showInputBoxSpy).toHaveBeenCalled();
expect(chosenPath).toEqual(storagePath);
});
});
});
});
describe("sortDatabaseItemsByDateAdded", () => {
describe("should return a sorted list", () => {
it("should sort the items by dateAdded", async () => {
const mockDbItem = createMockDB(dir, {
dateAdded: 678,
} as FullDatabaseOptions);
const mockDbItem2 = createMockDB(dir, {
dateAdded: 123,
} as FullDatabaseOptions);
const mockDbItem3 = createMockDB(dir, {
dateAdded: undefined,
} as FullDatabaseOptions);
const mockDbItem4 = createMockDB(dir, {
dateAdded: 345,
} as FullDatabaseOptions);
const sortedList =
await SkeletonQueryWizard.sortDatabaseItemsByDateAdded([
mockDbItem,
mockDbItem2,
mockDbItem3,
mockDbItem4,
]);
expect(sortedList).toEqual([
mockDbItem3,
mockDbItem2,
mockDbItem4,
mockDbItem,
]);
});
it("should ignore databases with errors", async () => {
const mockDbItem = createMockDB(dir, {
dateAdded: 678,
} as FullDatabaseOptions);
const mockDbItem2 = createMockDB(dir, {
dateAdded: undefined,
} as FullDatabaseOptions);
const mockDbItem3 = createMockDB(dir, {
dateAdded: 345,
} as FullDatabaseOptions);
const mockDbItem4 = createMockDB(dir, {
dateAdded: 123,
} as FullDatabaseOptions);
jest
.spyOn(mockDbItem, "error", "get")
.mockReturnValue(asError("database go boom!"));
const sortedList =
await SkeletonQueryWizard.sortDatabaseItemsByDateAdded([
mockDbItem,
mockDbItem2,
mockDbItem3,
mockDbItem4,
]);
expect(sortedList).toEqual([mockDbItem2, mockDbItem4, mockDbItem3]);
});
});
});
describe("findExistingDatabaseItem", () => {
describe("when there are multiple items with the same name", () => {
it("should choose the latest one", async () => {
const mockDbItem = createMockDB(dir, {
language: "javascript",
dateAdded: 456,
} as FullDatabaseOptions);
const mockDbItem2 = createMockDB(dir, {
language: "ruby",
dateAdded: 789,
} as FullDatabaseOptions);
const mockDbItem3 = createMockDB(dir, {
language: "javascript",
dateAdded: 123,
} as FullDatabaseOptions);
const mockDbItem4 = createMockDB(dir, {
language: "javascript",
dateAdded: undefined,
} as FullDatabaseOptions);
jest
.spyOn(mockDbItem, "name", "get")
.mockReturnValue(QUERY_LANGUAGE_TO_DATABASE_REPO["javascript"]);
jest
.spyOn(mockDbItem2, "name", "get")
.mockReturnValue(QUERY_LANGUAGE_TO_DATABASE_REPO["javascript"]);
const databaseItem = await SkeletonQueryWizard.findExistingDatabaseItem(
"javascript",
[mockDbItem, mockDbItem2, mockDbItem3, mockDbItem4],
);
expect(JSON.stringify(databaseItem)).toEqual(
JSON.stringify(mockDbItem),
);
});
});
describe("when there are multiple items with the same language", () => {
it("should choose the latest one", async () => {
const mockDbItem = createMockDB(dir, {
language: "ruby",
dateAdded: 789,
} as FullDatabaseOptions);
const mockDbItem2 = createMockDB(dir, {
language: "javascript",
dateAdded: 456,
} as FullDatabaseOptions);
const mockDbItem3 = createMockDB(dir, {
language: "ruby",
dateAdded: 123,
} as FullDatabaseOptions);
const mockDbItem4 = createMockDB(dir, {
language: "javascript",
dateAdded: undefined,
} as FullDatabaseOptions);
const databaseItem = await SkeletonQueryWizard.findExistingDatabaseItem(
"javascript",
[mockDbItem, mockDbItem2, mockDbItem3, mockDbItem4],
);
expect(JSON.stringify(databaseItem)).toEqual(
JSON.stringify(mockDbItem2),
);
});
});
});
});

View File

@@ -708,6 +708,7 @@ describe("local databases", () => {
describe("openDatabase", () => {
let createSkeletonPacksSpy: jest.SpyInstance;
let resolveDatabaseContentsSpy: jest.SpyInstance;
let setCurrentDatabaseItemSpy: jest.SpyInstance;
let addDatabaseSourceArchiveFolderSpy: jest.SpyInstance;
let mockDbItem: DatabaseItemImpl;
@@ -722,6 +723,11 @@ describe("local databases", () => {
.spyOn(DatabaseResolver, "resolveDatabaseContents")
.mockResolvedValue({} as DatabaseContentsWithDbScheme);
setCurrentDatabaseItemSpy = jest.spyOn(
databaseManager,
"setCurrentDatabaseItem",
);
addDatabaseSourceArchiveFolderSpy = jest.spyOn(
databaseManager,
"addDatabaseSourceArchiveFolder",
@@ -746,6 +752,19 @@ describe("local databases", () => {
expect(resolveDatabaseContentsSpy).toBeCalledTimes(1);
});
it("should set the database as the currently selected one", async () => {
const makeSelected = true;
await databaseManager.openDatabase(
{} as ProgressCallback,
{} as CancellationToken,
mockDbItem.databaseUri,
makeSelected,
);
expect(setCurrentDatabaseItemSpy).toBeCalledTimes(1);
});
it("should add database source archive folder", async () => {
await databaseManager.openDatabase(
{} as ProgressCallback,
@@ -762,12 +781,15 @@ describe("local databases", () => {
jest.spyOn(Setting.prototype, "getValue").mockReturnValue(true);
const isTutorialDatabase = true;
const makeSelected = true;
const nameOverride = "CodeQL Tutorial Database";
await databaseManager.openDatabase(
{} as ProgressCallback,
{} as CancellationToken,
mockDbItem.databaseUri,
"CodeQL Tutorial Database",
makeSelected,
nameOverride,
isTutorialDatabase,
);

View File

@@ -6,10 +6,8 @@ import { dir } from "tmp-promise";
import { QlpacksInfo, ResolveExtensionsResult } from "../../../../src/cli";
import * as helpers from "../../../../src/helpers";
import {
ExtensionPack,
pickExtensionPackModelFile,
} from "../../../../src/data-extensions-editor/extension-pack-picker";
import { pickExtensionPackModelFile } from "../../../../src/data-extensions-editor/extension-pack-picker";
import { ExtensionPack } from "../../../../src/data-extensions-editor/shared/extension-pack";
describe("pickExtensionPackModelFile", () => {
let tmpDir: string;