Merge pull request #2359 from github/yer-a-ternary-choice-query

Add configuration option to turn off skeleton pack generation
This commit is contained in:
Elena Tanasoiu
2023-05-04 08:53:17 +01:00
committed by GitHub
8 changed files with 225 additions and 40 deletions

View File

@@ -371,11 +371,23 @@
"default": false,
"description": "Allow database to be downloaded via HTTP. Warning: enabling this option will allow downloading from insecure servers."
},
"codeQL.createQuery.folder": {
"codeQL.createQuery.qlPackLocation": {
"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."
"markdownDescription": "The name of the folder where we want to create queries and QL packs via the \"CodeQL: Create Query\" command. The folder should exist."
},
"codeQL.createQuery.autogenerateQlPacks": {
"type": "string",
"default": "ask",
"enum": [
"ask",
"never"
],
"enumDescriptions": [
"Ask to create a QL pack when a new CodeQL database is added.",
"Never create a QL pack when a new CodeQL database is added."
],
"description": "Ask the user to generate a QL pack when a new CodeQL database is downloaded."
}
}
},

View File

@@ -666,17 +666,39 @@ export function allowHttp(): boolean {
}
/**
* The name of the folder where we want to create skeleton wizard QL packs.
* Parent setting for all settings related to the "Create Query" command.
*/
const CREATE_QUERY_COMMAND = new Setting("createQuery", ROOT_SETTING);
/**
* The name of the folder where we want to create QL packs.
**/
const SKELETON_WIZARD_FOLDER = new Setting(
"folder",
new Setting("createQuery", ROOT_SETTING),
const QL_PACK_LOCATION = new Setting("qlPackLocation", CREATE_QUERY_COMMAND);
export function getQlPackLocation(): string | undefined {
return QL_PACK_LOCATION.getValue<string>() || undefined;
}
export async function setQlPackLocation(folder: string | undefined) {
await QL_PACK_LOCATION.updateValue(folder, ConfigurationTarget.Global);
}
/**
* Whether to ask the user to autogenerate a QL pack. The options are "ask" and "never".
**/
const AUTOGENERATE_QL_PACKS = new Setting(
"autogenerateQlPacks",
CREATE_QUERY_COMMAND,
);
export function getSkeletonWizardFolder(): string | undefined {
return SKELETON_WIZARD_FOLDER.getValue<string>() || undefined;
const AutogenerateQLPacksValues = ["ask", "never"] as const;
type AutogenerateQLPacks = typeof AutogenerateQLPacksValues[number];
export function getAutogenerateQlPacks(): AutogenerateQLPacks {
const value = AUTOGENERATE_QL_PACKS.getValue<AutogenerateQLPacks>();
return AutogenerateQLPacksValues.includes(value) ? value : "ask";
}
export async function setSkeletonWizardFolder(folder: string | undefined) {
await SKELETON_WIZARD_FOLDER.updateValue(folder, ConfigurationTarget.Global);
export async function setAutogenerateQlPacks(choice: AutogenerateQLPacks) {
await AUTOGENERATE_QL_PACKS.updateValue(choice, ConfigurationTarget.Global);
}

View File

@@ -10,8 +10,8 @@ import {
isLikelyDatabaseRoot,
showAndLogExceptionWithTelemetry,
isFolderAlreadyInWorkspace,
showBinaryChoiceDialog,
getFirstWorkspaceFolder,
showNeverAskAgainDialog,
} from "../helpers";
import { ProgressCallback, withProgress } from "../common/vscode/progress";
import {
@@ -26,7 +26,11 @@ import { asError, getErrorMessage } from "../pure/helpers-pure";
import { QueryRunner } from "../query-server";
import { pathsEqual } from "../pure/files";
import { redactableError } from "../pure/errors";
import { isCodespacesTemplate } from "../config";
import {
getAutogenerateQlPacks,
isCodespacesTemplate,
setAutogenerateQlPacks,
} from "../config";
import { QlPackGenerator } from "../qlpack-generator";
import { QueryLanguage } from "../common/query-language";
import { App } from "../common/app";
@@ -745,11 +749,20 @@ export class DatabaseManager extends DisposableObject {
return;
}
const answer = await showBinaryChoiceDialog(
if (getAutogenerateQlPacks() === "never") {
return;
}
const answer = await showNeverAskAgainDialog(
`We've noticed you don't have a CodeQL pack available to analyze this database. Can we set up a query pack for you?`,
);
if (!answer) {
if (answer === "No") {
return;
}
if (answer === "No, and never ask me again") {
await setAutogenerateQlPacks("never");
return;
}

View File

@@ -256,6 +256,46 @@ export function isWorkspaceFolderOnDisk(
return workspaceFolder.uri.scheme === "file";
}
/**
* Opens a modal dialog for the user to make a choice between yes/no/never be asked again.
*
* @param message The message to show.
* @param modal If true (the default), show a modal dialog box, otherwise dialog is non-modal and can
* be closed even if the user does not make a choice.
* @param yesTitle The text in the box indicating the affirmative choice.
* @param noTitle The text in the box indicating the negative choice.
* @param neverTitle The text in the box indicating the opt out choice.
*
* @return
* `Yes` if the user clicks 'Yes',
* `No` if the user clicks 'No' or cancels the dialog,
* `No, and never ask me again` if the user clicks 'No, and never ask me again',
* `undefined` if the dialog is closed without the user making a choice.
*/
export async function showNeverAskAgainDialog(
message: string,
modal = true,
yesTitle = "Yes",
noTitle = "No",
neverAskAgainTitle = "No, and never ask me again",
): Promise<string | undefined> {
const yesItem = { title: yesTitle, isCloseAffordance: true };
const noItem = { title: noTitle, isCloseAffordance: false };
const neverAskAgainItem = {
title: neverAskAgainTitle,
isCloseAffordance: false,
};
const chosenItem = await Window.showInformationMessage(
message,
{ modal },
yesItem,
noItem,
neverAskAgainItem,
);
return chosenItem?.title;
}
/** Gets all active workspace folders that are on the filesystem. */
export function getOnDiskWorkspaceFoldersObjects() {
const workspaceFolders = workspace.workspaceFolders ?? [];

View File

@@ -21,9 +21,9 @@ import {
downloadGitHubDatabase,
} from "./databases/database-fetcher";
import {
getSkeletonWizardFolder,
getQlPackLocation,
isCodespacesTemplate,
setSkeletonWizardFolder,
setQlPackLocation,
} from "./config";
import { existsSync } from "fs-extra";
@@ -115,7 +115,7 @@ export class SkeletonQueryWizard {
return firstStorageFolder;
}
let storageFolder = getSkeletonWizardFolder();
let storageFolder = getQlPackLocation();
if (storageFolder === undefined || !existsSync(storageFolder)) {
storageFolder = await Window.showInputBox({
@@ -136,7 +136,7 @@ export class SkeletonQueryWizard {
);
}
await setSkeletonWizardFolder(storageFolder);
await setQlPackLocation(storageFolder);
return storageFolder;
}

View File

@@ -412,7 +412,7 @@ describe("SkeletonQueryWizard", () => {
originalValue = workspace
.getConfiguration("codeQL.createQuery")
.get("folder");
.get("qlPackLocation");
// Set isCodespacesTemplate to true to indicate we are in the codespace template
await workspace
@@ -421,9 +421,13 @@ describe("SkeletonQueryWizard", () => {
});
afterEach(async () => {
await workspace
.getConfiguration("codeQL.createQuery")
.update("qlPackLocation", originalValue);
await workspace
.getConfiguration("codeQL")
.update("codespacesTemplate", originalValue);
.update("codespacesTemplate", false);
});
it("should not prompt the user", async () => {
@@ -445,16 +449,16 @@ describe("SkeletonQueryWizard", () => {
originalValue = workspace
.getConfiguration("codeQL.createQuery")
.get("folder");
.get("qlPackLocation");
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", storedPath);
.update("qlPackLocation", storedPath);
});
afterEach(async () => {
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", originalValue);
.update("qlPackLocation", originalValue);
});
it("should return it and not prompt the user", async () => {
@@ -474,16 +478,16 @@ describe("SkeletonQueryWizard", () => {
originalValue = workspace
.getConfiguration("codeQL.createQuery")
.get("folder");
.get("qlPackLocation");
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", storedPath);
.update("qlPackLocation", storedPath);
});
afterEach(async () => {
await workspace
.getConfiguration("codeQL.createQuery")
.update("folder", originalValue);
.update("qlPackLocation", originalValue);
});
it("should prompt the user for to provide a new folder name", async () => {

View File

@@ -44,8 +44,8 @@ describe("local databases", () => {
let packAddSpy: jest.Mock<any, []>;
let logSpy: jest.Mock<any, []>;
let showBinaryChoiceDialogSpy: jest.SpiedFunction<
typeof helpers.showBinaryChoiceDialog
let showNeverAskAgainDialogSpy: jest.SpiedFunction<
typeof helpers.showNeverAskAgainDialog
>;
let dir: tmp.DirResult;
@@ -63,9 +63,9 @@ describe("local databases", () => {
/* */
});
showBinaryChoiceDialogSpy = jest
.spyOn(helpers, "showBinaryChoiceDialog")
.mockResolvedValue(true);
showNeverAskAgainDialogSpy = jest
.spyOn(helpers, "showNeverAskAgainDialog")
.mockResolvedValue("Yes");
extensionContextStoragePath = dir.name;
@@ -649,19 +649,31 @@ describe("local databases", () => {
it("should offer the user to set up a skeleton QL pack", async () => {
await (databaseManager as any).createSkeletonPacks(mockDbItem);
expect(showBinaryChoiceDialogSpy).toBeCalledTimes(1);
expect(showNeverAskAgainDialogSpy).toBeCalledTimes(1);
});
it("should return early if the user refuses help", async () => {
showBinaryChoiceDialogSpy = jest
.spyOn(helpers, "showBinaryChoiceDialog")
.mockResolvedValue(false);
showNeverAskAgainDialogSpy = jest
.spyOn(helpers, "showNeverAskAgainDialog")
.mockResolvedValue("No");
await (databaseManager as any).createSkeletonPacks(mockDbItem);
expect(generateSpy).not.toBeCalled();
});
it("should return early and write choice to settings if user wants to never be asked again", async () => {
showNeverAskAgainDialogSpy = jest
.spyOn(helpers, "showNeverAskAgainDialog")
.mockResolvedValue("No, and never ask me again");
const updateValueSpy = jest.spyOn(Setting.prototype, "updateValue");
await (databaseManager as any).createSkeletonPacks(mockDbItem);
expect(generateSpy).not.toBeCalled();
expect(updateValueSpy).toHaveBeenCalledWith("never", 1);
});
it("should create the skeleton QL pack for the user", async () => {
await (databaseManager as any).createSkeletonPacks(mockDbItem);
@@ -694,9 +706,9 @@ describe("local databases", () => {
});
it("should exit early", async () => {
showBinaryChoiceDialogSpy = jest
.spyOn(helpers, "showBinaryChoiceDialog")
.mockResolvedValue(false);
showNeverAskAgainDialogSpy = jest
.spyOn(helpers, "showNeverAskAgainDialog")
.mockResolvedValue("No");
await (databaseManager as any).createSkeletonPacks(mockDbItem);

View File

@@ -36,6 +36,7 @@ import {
showBinaryChoiceDialog,
showBinaryChoiceWithUrlDialog,
showInformationMessageWithAction,
showNeverAskAgainDialog,
walkDirectory,
} from "../../../src/helpers";
import { reportStreamProgress } from "../../../src/common/vscode/progress";
@@ -416,7 +417,7 @@ describe("helpers", () => {
});
});
describe("open dialog", () => {
describe("showBinaryChoiceDialog", () => {
let showInformationMessageSpy: jest.SpiedFunction<
typeof window.showInformationMessage
>;
@@ -445,6 +446,23 @@ describe("helpers", () => {
const val = await showBinaryChoiceDialog("xxx");
expect(val).toBe(false);
});
});
describe("showInformationMessageWithAction", () => {
let showInformationMessageSpy: jest.SpiedFunction<
typeof window.showInformationMessage
>;
beforeEach(() => {
showInformationMessageSpy = jest
.spyOn(window, "showInformationMessage")
.mockResolvedValue(undefined);
});
const resolveArg =
(index: number) =>
(...args: any[]) =>
Promise.resolve(args[index]);
it("should show an info dialog and confirm the action", async () => {
// pretend user chooses to run action
@@ -459,6 +477,23 @@ describe("helpers", () => {
const val = await showInformationMessageWithAction("xxx", "yyy");
expect(val).toBe(false);
});
});
describe("showBinaryChoiceWithUrlDialog", () => {
let showInformationMessageSpy: jest.SpiedFunction<
typeof window.showInformationMessage
>;
beforeEach(() => {
showInformationMessageSpy = jest
.spyOn(window, "showInformationMessage")
.mockResolvedValue(undefined);
});
const resolveArg =
(index: number) =>
(...args: any[]) =>
Promise.resolve(args[index]);
it("should show a binary choice dialog with a url and return `yes`", async () => {
// pretend user clicks on the url twice and then clicks 'yes'
@@ -494,6 +529,53 @@ describe("helpers", () => {
expect(showInformationMessageSpy).toHaveBeenCalledTimes(5);
});
});
describe("showNeverAskAgainDialog", () => {
let showInformationMessageSpy: jest.SpiedFunction<
typeof window.showInformationMessage
>;
beforeEach(() => {
showInformationMessageSpy = jest
.spyOn(window, "showInformationMessage")
.mockResolvedValue(undefined);
});
const resolveArg =
(index: number) =>
(...args: any[]) =>
Promise.resolve(args[index]);
const title =
"We've noticed you don't have a CodeQL pack available to analyze this database. Can we set up a query pack for you?";
it("should show a ternary choice dialog and return `Yes`", async () => {
// pretend user chooses 'Yes'
const yesItem = resolveArg(2);
showInformationMessageSpy.mockImplementationOnce(yesItem);
const answer = await showNeverAskAgainDialog(title);
expect(answer).toBe("Yes");
});
it("should show a ternary choice dialog and return `No`", async () => {
// pretend user chooses 'No'
const noItem = resolveArg(3);
showInformationMessageSpy.mockImplementationOnce(noItem);
const answer = await showNeverAskAgainDialog(title);
expect(answer).toBe("No");
});
it("should show a ternary choice dialog and return `No, and never ask me again`", async () => {
// pretend user chooses 'No, and never ask me again'
const neverAskAgainItem = resolveArg(4);
showInformationMessageSpy.mockImplementationOnce(neverAskAgainItem);
const answer = await showNeverAskAgainDialog(title);
expect(answer).toBe("No, and never ask me again");
});
});
});
describe("walkDirectory", () => {