Merge pull request #2300 from github/koesie10/create-extension-pack

Add creating extension packs when opening the editor
This commit is contained in:
Koen Vlaswinkel
2023-04-14 11:17:35 +02:00
committed by GitHub
3 changed files with 271 additions and 22 deletions

View File

@@ -1,23 +1,38 @@
import { relative, resolve, sep } from "path";
import { pathExists, readFile } from "fs-extra";
import { load as loadYaml } from "js-yaml";
import { join, relative, resolve, sep } from "path";
import { outputFile, pathExists, readFile } from "fs-extra";
import { dump as dumpYaml, load as loadYaml } from "js-yaml";
import { minimatch } from "minimatch";
import { CancellationToken, window } from "vscode";
import { CodeQLCliServer } from "../cli";
import { getOnDiskWorkspaceFolders, showAndLogErrorMessage } from "../helpers";
import {
getOnDiskWorkspaceFolders,
getOnDiskWorkspaceFoldersObjects,
showAndLogErrorMessage,
} from "../helpers";
import { ProgressCallback } from "../progress";
import { DatabaseItem } from "../local-databases";
import { getQlPackPath, QLPACK_FILENAMES } from "../pure/ql";
const maxStep = 3;
const packNamePartRegex = /[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
const packNameRegex = new RegExp(
`^(?:(?<scope>${packNamePartRegex.source})/)?(?<name>${packNamePartRegex.source})$`,
);
const packNameLength = 128;
export async function pickExtensionPackModelFile(
cliServer: Pick<CodeQLCliServer, "resolveQlpacks" | "resolveExtensions">,
databaseItem: Pick<DatabaseItem, "name">,
progress: ProgressCallback,
token: CancellationToken,
): Promise<string | undefined> {
const extensionPackPath = await pickExtensionPack(cliServer, progress, token);
const extensionPackPath = await pickExtensionPack(
cliServer,
databaseItem,
progress,
token,
);
if (!extensionPackPath) {
return;
}
@@ -38,6 +53,7 @@ export async function pickExtensionPackModelFile(
async function pickExtensionPack(
cliServer: Pick<CodeQLCliServer, "resolveQlpacks">,
databaseItem: Pick<DatabaseItem, "name">,
progress: ProgressCallback,
token: CancellationToken,
): Promise<string | undefined> {
@@ -50,10 +66,20 @@ async function pickExtensionPack(
// Get all existing extension packs in the workspace
const additionalPacks = getOnDiskWorkspaceFolders();
const extensionPacks = await cliServer.resolveQlpacks(additionalPacks, true);
const options = Object.keys(extensionPacks).map((pack) => ({
label: pack,
extensionPack: pack,
}));
if (Object.keys(extensionPacks).length === 0) {
return pickNewExtensionPack(databaseItem, token);
}
const options: Array<{ label: string; extensionPack: string | null }> =
Object.keys(extensionPacks).map((pack) => ({
label: pack,
extensionPack: pack,
}));
options.push({
label: "Create new extension pack",
extensionPack: null,
});
progress({
message: "Choosing extension pack...",
@@ -72,6 +98,10 @@ async function pickExtensionPack(
return undefined;
}
if (!extensionPackOption.extensionPack) {
return pickNewExtensionPack(databaseItem, token);
}
const extensionPackPaths = extensionPacks[extensionPackOption.extensionPack];
if (extensionPackPaths.length !== 1) {
void showAndLogErrorMessage(
@@ -153,6 +183,89 @@ async function pickModelFile(
return pickNewModelFile(databaseItem, extensionPackPath, token);
}
async function pickNewExtensionPack(
databaseItem: Pick<DatabaseItem, "name">,
token: CancellationToken,
): Promise<string | undefined> {
const workspaceFolders = getOnDiskWorkspaceFoldersObjects();
const workspaceFolderOptions = workspaceFolders.map((folder) => ({
label: folder.name,
detail: folder.uri.fsPath,
path: folder.uri.fsPath,
}));
// We're not using window.showWorkspaceFolderPick because that also includes the database source folders while
// we only want to include on-disk workspace folders.
const workspaceFolder = await window.showQuickPick(workspaceFolderOptions, {
title: "Select workspace folder to create extension pack in",
});
if (!workspaceFolder) {
return undefined;
}
const packName = await window.showInputBox(
{
title: "Create new extension pack",
prompt: "Enter name of extension pack",
placeHolder: `e.g. ${databaseItem.name}-extensions`,
validateInput: async (value: string): Promise<string | undefined> => {
if (!value) {
return "Pack name must not be empty";
}
if (value.length > packNameLength) {
return `Pack name must be no longer than ${packNameLength} characters`;
}
const matches = packNameRegex.exec(value);
if (!matches?.groups) {
return "Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens";
}
const packPath = join(workspaceFolder.path, matches.groups.name);
if (await pathExists(packPath)) {
return `A pack already exists at ${packPath}`;
}
return undefined;
},
},
token,
);
if (!packName) {
return undefined;
}
const matches = packNameRegex.exec(packName);
if (!matches?.groups) {
return;
}
const name = matches.groups.name;
const packPath = join(workspaceFolder.path, name);
if (await pathExists(packPath)) {
return undefined;
}
const packYamlPath = join(packPath, "codeql-pack.yml");
await outputFile(
packYamlPath,
dumpYaml({
name,
version: "0.0.0",
library: true,
extensionTargets: {
"codeql/java-all": "*",
},
dataExtensions: ["models/**/*.yml"],
}),
);
return packPath;
}
async function pickNewModelFile(
databaseItem: Pick<DatabaseItem, "name">,
extensionPackPath: string,

View File

@@ -16,6 +16,7 @@ import {
window as Window,
workspace,
env,
WorkspaceFolder,
} from "vscode";
import { CodeQLCliServer, QlpacksInfo } from "./cli";
import { UserCancellationException } from "./progress";
@@ -249,16 +250,21 @@ export async function showInformationMessageWithAction(
}
/** Gets all active workspace folders that are on the filesystem. */
export function getOnDiskWorkspaceFolders() {
export function getOnDiskWorkspaceFoldersObjects() {
const workspaceFolders = workspace.workspaceFolders || [];
const diskWorkspaceFolders: string[] = [];
const diskWorkspaceFolders: WorkspaceFolder[] = [];
for (const workspaceFolder of workspaceFolders) {
if (workspaceFolder.uri.scheme === "file")
diskWorkspaceFolders.push(workspaceFolder.uri.fsPath);
diskWorkspaceFolders.push(workspaceFolder);
}
return diskWorkspaceFolders;
}
/** Gets all active workspace folders that are on the filesystem. */
export function getOnDiskWorkspaceFolders() {
return getOnDiskWorkspaceFoldersObjects().map((folder) => folder.uri.fsPath);
}
/** Check if folder is already present in workspace */
export function isFolderAlreadyInWorkspace(folderName: string) {
const workspaceFolders = workspace.workspaceFolders || [];

View File

@@ -1,6 +1,6 @@
import { CancellationTokenSource, QuickPickItem, window } from "vscode";
import { dump as dumpYaml } from "js-yaml";
import { outputFile } from "fs-extra";
import { dump as dumpYaml, load as loadYaml } from "js-yaml";
import { outputFile, readFile } from "fs-extra";
import { join } from "path";
import { dir } from "tmp-promise";
@@ -84,6 +84,10 @@ describe("pickExtensionPackModelFile", () => {
label: "another-extension-pack",
extensionPack: "another-extension-pack",
},
{
label: expect.stringMatching(/create/i),
extensionPack: null,
},
],
{
title: expect.any(String),
@@ -181,6 +185,10 @@ describe("pickExtensionPackModelFile", () => {
label: "another-extension-pack",
extensionPack: "another-extension-pack",
},
{
label: expect.stringMatching(/create/i),
extensionPack: null,
},
],
{
title: expect.any(String),
@@ -234,7 +242,69 @@ describe("pickExtensionPackModelFile", () => {
expect(cliServer.resolveExtensions).not.toHaveBeenCalled();
});
it("does not show any options when there are no extension packs", async () => {
it("allows user to create an extension pack when there are no extension packs", async () => {
const cliServer = mockCliServer({}, { models: [], data: {} });
const tmpDir = await dir({
unsafeCleanup: true,
});
showQuickPickSpy.mockResolvedValueOnce({
label: "codeql-custom-queries-java",
path: tmpDir.path,
} as QuickPickItem);
showInputBoxSpy.mockResolvedValueOnce("my-extension-pack");
showInputBoxSpy.mockResolvedValue("models/my-model.yml");
expect(
await pickExtensionPackModelFile(
cliServer,
databaseItem,
progress,
token,
),
).toEqual(join(tmpDir.path, "my-extension-pack", "models", "my-model.yml"));
expect(showQuickPickSpy).toHaveBeenCalledTimes(1);
expect(showInputBoxSpy).toHaveBeenCalledTimes(2);
expect(showInputBoxSpy).toHaveBeenCalledWith(
{
title: expect.stringMatching(/extension pack/i),
prompt: expect.stringMatching(/extension pack/i),
placeHolder: expect.stringMatching(/github\/vscode-codeql-extensions/),
validateInput: expect.any(Function),
},
token,
);
expect(showInputBoxSpy).toHaveBeenCalledWith(
{
title: expect.stringMatching(/model file/),
value: "models/github.vscode-codeql.model.yml",
validateInput: expect.any(Function),
},
token,
);
expect(cliServer.resolveQlpacks).toHaveBeenCalled();
expect(cliServer.resolveExtensions).toHaveBeenCalled();
expect(
loadYaml(
await readFile(
join(tmpDir.path, "my-extension-pack", "codeql-pack.yml"),
"utf8",
),
),
).toEqual({
name: "my-extension-pack",
version: "0.0.0",
library: true,
extensionTargets: {
"codeql/java-all": "*",
},
dataExtensions: ["models/**/*.yml"],
});
});
it("allows cancelling the workspace folder selection", async () => {
const cliServer = mockCliServer({}, { models: [], data: {} });
showQuickPickSpy.mockResolvedValueOnce(undefined);
@@ -248,13 +318,30 @@ describe("pickExtensionPackModelFile", () => {
),
).toEqual(undefined);
expect(showQuickPickSpy).toHaveBeenCalledTimes(1);
expect(showQuickPickSpy).toHaveBeenCalledWith(
[],
{
title: expect.any(String),
},
token,
);
expect(showInputBoxSpy).toHaveBeenCalledTimes(0);
expect(cliServer.resolveQlpacks).toHaveBeenCalled();
expect(cliServer.resolveExtensions).not.toHaveBeenCalled();
});
it("allows cancelling the extension pack name input", async () => {
const cliServer = mockCliServer({}, { models: [], data: {} });
showQuickPickSpy.mockResolvedValueOnce({
label: "codeql-custom-queries-java",
path: "/a/b/c",
} as QuickPickItem);
showInputBoxSpy.mockResolvedValueOnce(undefined);
expect(
await pickExtensionPackModelFile(
cliServer,
databaseItem,
progress,
token,
),
).toEqual(undefined);
expect(showQuickPickSpy).toHaveBeenCalledTimes(1);
expect(showInputBoxSpy).toHaveBeenCalledTimes(1);
expect(cliServer.resolveQlpacks).toHaveBeenCalled();
expect(cliServer.resolveExtensions).not.toHaveBeenCalled();
});
@@ -592,6 +679,49 @@ describe("pickExtensionPackModelFile", () => {
expect(cliServer.resolveExtensions).toHaveBeenCalled();
});
it("validates the pack name input", async () => {
const cliServer = mockCliServer({}, { models: [], data: {} });
showQuickPickSpy.mockResolvedValueOnce({
label: "a",
path: "/a/b/c",
} as QuickPickItem);
showInputBoxSpy.mockResolvedValue(undefined);
expect(
await pickExtensionPackModelFile(
cliServer,
databaseItem,
progress,
token,
),
).toEqual(undefined);
const validateFile = showInputBoxSpy.mock.calls[0][0]?.validateInput;
expect(validateFile).toBeDefined();
if (!validateFile) {
return;
}
expect(await validateFile("")).toEqual("Pack name must not be empty");
expect(await validateFile("a".repeat(129))).toEqual(
"Pack name must be no longer than 128 characters",
);
expect(await validateFile("github/vscode-codeql/extensions")).toEqual(
"Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens",
);
expect(await validateFile("VSCODE")).toEqual(
"Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens",
);
expect(await validateFile("github/vscode-codeql-")).toEqual(
"Invalid package name: a pack name must contain only lowercase ASCII letters, ASCII digits, and hyphens",
);
expect(
await validateFile("github/vscode-codeql-extensions"),
).toBeUndefined();
expect(await validateFile("vscode-codeql-extensions")).toBeUndefined();
});
it("validates the file input", async () => {
const tmpDir = await dir({
unsafeCleanup: true,