Move convertGithubNwoToDatabaseUrl to databases/github-databases/api.ts

This commit is contained in:
Robert
2024-03-07 12:46:26 +00:00
parent fe6dc8a7a3
commit 66879ef626
4 changed files with 284 additions and 288 deletions

View File

@@ -33,11 +33,11 @@ import {
} from "../config";
import { showAndLogInformationMessage } from "../common/logging";
import { AppOctokit } from "../common/octokit";
import { getLanguageDisplayName } from "../common/query-language";
import type { DatabaseOrigin } from "./local-databases/database-origin";
import { createTimeoutSignal } from "../common/fetch-stream";
import type { App } from "../common/app";
import { findDirWithFile } from "../common/files";
import { convertGithubNwoToDatabaseUrl } from "./github-databases/api";
/**
* Prompts a user to fetch a database from a remote location. Database is assumed to be an archive file.
@@ -587,95 +587,6 @@ function isFile(databaseUrl: string) {
return Uri.parse(databaseUrl).scheme === "file";
}
export async function convertGithubNwoToDatabaseUrl(
nwo: string,
octokit: Octokit,
progress: ProgressCallback,
language?: string,
): Promise<
| {
databaseUrl: string;
owner: string;
name: string;
databaseId: number;
databaseCreatedAt: string;
commitOid: string | null;
}
| undefined
> {
try {
const [owner, repo] = nwo.split("/");
const response = await octokit.rest.codeScanning.listCodeqlDatabases({
owner,
repo,
});
const languages = response.data.map((db) => db.language);
if (!language || !languages.includes(language)) {
language = await promptForLanguage(languages, progress);
if (!language) {
return;
}
}
const databaseForLanguage = response.data.find(
(db) => db.language === language,
);
if (!databaseForLanguage) {
throw new Error(`No database found for language '${language}'`);
}
return {
databaseUrl: databaseForLanguage.url,
owner,
name: repo,
databaseId: databaseForLanguage.id,
databaseCreatedAt: databaseForLanguage.created_at,
commitOid: databaseForLanguage.commit_oid ?? null,
};
} catch (e) {
void extLogger.log(`Error: ${getErrorMessage(e)}`);
throw new Error(`Unable to get database for '${nwo}'`);
}
}
export async function promptForLanguage(
languages: string[],
progress: ProgressCallback | undefined,
): Promise<string | undefined> {
progress?.({
message: "Choose language",
step: 2,
maxStep: 2,
});
if (!languages.length) {
throw new Error("No databases found");
}
if (languages.length === 1) {
return languages[0];
}
const items = languages
.map((language) => ({
label: getLanguageDisplayName(language),
description: language,
language,
}))
.sort((a, b) => a.label.localeCompare(b.label));
const selectedItem = await window.showQuickPick(items, {
placeHolder: "Select the database language to download:",
ignoreFocusOut: true,
});
if (!selectedItem) {
return undefined;
}
return selectedItem.language;
}
/**
* Databases created by the old odasa tool will not have a zipped
* source location. However, this extension works better if sources

View File

@@ -5,6 +5,11 @@ import { showNeverAskAgainDialog } from "../../common/vscode/dialog";
import type { GitHubDatabaseConfig } from "../../config";
import type { Credentials } from "../../common/authentication";
import { AppOctokit } from "../../common/octokit";
import type { ProgressCallback } from "../../common/vscode/progress";
import { getErrorMessage } from "../../common/helpers-pure";
import { getLanguageDisplayName } from "../../common/query-language";
import { window } from "vscode";
import { extLogger } from "../../common/logging/vscode";
export type CodeqlDatabase =
RestEndpointMethodTypes["codeScanning"]["listCodeqlDatabases"]["response"]["data"][number];
@@ -108,3 +113,92 @@ export async function listDatabases(
octokit,
};
}
export async function convertGithubNwoToDatabaseUrl(
nwo: string,
octokit: Octokit,
progress: ProgressCallback,
language?: string,
): Promise<
| {
databaseUrl: string;
owner: string;
name: string;
databaseId: number;
databaseCreatedAt: string;
commitOid: string | null;
}
| undefined
> {
try {
const [owner, repo] = nwo.split("/");
const response = await octokit.rest.codeScanning.listCodeqlDatabases({
owner,
repo,
});
const languages = response.data.map((db) => db.language);
if (!language || !languages.includes(language)) {
language = await promptForLanguage(languages, progress);
if (!language) {
return;
}
}
const databaseForLanguage = response.data.find(
(db) => db.language === language,
);
if (!databaseForLanguage) {
throw new Error(`No database found for language '${language}'`);
}
return {
databaseUrl: databaseForLanguage.url,
owner,
name: repo,
databaseId: databaseForLanguage.id,
databaseCreatedAt: databaseForLanguage.created_at,
commitOid: databaseForLanguage.commit_oid ?? null,
};
} catch (e) {
void extLogger.log(`Error: ${getErrorMessage(e)}`);
throw new Error(`Unable to get database for '${nwo}'`);
}
}
async function promptForLanguage(
languages: string[],
progress: ProgressCallback | undefined,
): Promise<string | undefined> {
progress?.({
message: "Choose language",
step: 2,
maxStep: 2,
});
if (!languages.length) {
throw new Error("No databases found");
}
if (languages.length === 1) {
return languages[0];
}
const items = languages
.map((language) => ({
label: getLanguageDisplayName(language),
description: language,
language,
}))
.sort((a, b) => a.label.localeCompare(b.label));
const selectedItem = await window.showQuickPick(items, {
placeHolder: "Select the database language to download:",
ignoreFocusOut: true,
});
if (!selectedItem) {
return undefined;
}
return selectedItem.language;
}

View File

@@ -1,197 +0,0 @@
import { window } from "vscode";
import { convertGithubNwoToDatabaseUrl } from "../../../../src/databases/database-fetcher";
import type { Octokit } from "@octokit/rest";
import {
mockedObject,
mockedOctokitFunction,
mockedQuickPickItem,
} from "../../utils/mocking.helpers";
// These tests make API calls and may need extra time to complete.
jest.setTimeout(10000);
describe("database-fetcher", () => {
describe("convertGithubNwoToDatabaseUrl", () => {
let quickPickSpy: jest.SpiedFunction<typeof window.showQuickPick>;
const progressSpy = jest.fn();
const mockListCodeqlDatabases = mockedOctokitFunction<
"codeScanning",
"listCodeqlDatabases"
>();
const octokit = mockedObject<Octokit>({
rest: {
codeScanning: {
listCodeqlDatabases: mockListCodeqlDatabases,
},
},
});
// We can't make the real octokit request (since we need credentials), so we mock the response.
const successfullMockApiResponse = {
data: [
{
id: 1495869,
name: "csharp-database",
language: "csharp",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 55599715,
created_at: "2022-03-24T10:46:24Z",
updated_at: "2022-03-24T10:46:27Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/csharp",
},
{
id: 1100671,
name: "database.zip",
language: "javascript",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 29294434,
created_at: "2022-03-01T16:00:04Z",
updated_at: "2022-03-01T16:00:06Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
},
{
id: 648738,
name: "ql-database",
language: "ql",
uploader: {},
content_type: "application/json; charset=utf-8",
state: "uploaded",
size: 39735500,
created_at: "2022-02-02T09:38:50Z",
updated_at: "2022-02-02T09:38:51Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/ql",
},
],
};
beforeEach(() => {
quickPickSpy = jest
.spyOn(window, "showQuickPick")
.mockResolvedValue(undefined);
});
it("should convert a GitHub nwo to a database url", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
quickPickSpy.mockResolvedValue(
mockedQuickPickItem({
label: "JavaScript",
language: "javascript",
}),
);
const githubRepo = "github/codeql";
const result = await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
);
expect(result).toBeDefined();
if (result === undefined) {
return;
}
const { databaseUrl, name, owner } = result;
expect(databaseUrl).toBe(
"https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
);
expect(name).toBe("codeql");
expect(owner).toBe("github");
expect(quickPickSpy).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
label: "C#",
description: "csharp",
language: "csharp",
}),
expect.objectContaining({
label: "JavaScript",
description: "javascript",
language: "javascript",
}),
expect.objectContaining({
label: "ql",
description: "ql",
language: "ql",
}),
],
expect.anything(),
);
});
// Repository doesn't exist, or the user has no access to the repository.
it("should fail on an invalid/inaccessible repository", async () => {
const mockApiResponse = {
data: {
message: "Not Found",
},
status: 404,
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-not-real";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(0);
});
// User has access to the repository, but there are no databases for any language.
it("should fail on a repository with no databases", async () => {
const mockApiResponse = {
data: [],
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-with-no-dbs";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(1);
});
describe("when language is already provided", () => {
describe("when language is valid", () => {
it("should not prompt the user", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"javascript",
);
expect(quickPickSpy).not.toHaveBeenCalled();
});
});
describe("when language is invalid", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"invalid-language",
);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});
describe("when language is not provided", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});
});

View File

@@ -1,14 +1,19 @@
import {
mockedObject,
mockedOctokitFunction,
mockedQuickPickItem,
} from "../../../utils/mocking.helpers";
import type { GitHubDatabaseConfig } from "../../../../../src/config";
import * as dialog from "../../../../../src/common/vscode/dialog";
import { listDatabases } from "../../../../../src/databases/github-databases/api";
import {
convertGithubNwoToDatabaseUrl,
listDatabases,
} from "../../../../../src/databases/github-databases/api";
import type { Credentials } from "../../../../../src/common/authentication";
import type { Octokit } from "@octokit/rest";
import { AppOctokit } from "../../../../../src/common/octokit";
import { RequestError } from "@octokit/request-error";
import { window } from "vscode";
// Mock the AppOctokit constructor to ensure we aren't making any network requests
jest.mock("../../../../../src/common/octokit", () => ({
@@ -349,3 +354,186 @@ describe("listDatabases", () => {
});
});
});
describe("convertGithubNwoToDatabaseUrl", () => {
let quickPickSpy: jest.SpiedFunction<typeof window.showQuickPick>;
const progressSpy = jest.fn();
const mockListCodeqlDatabases = mockedOctokitFunction<
"codeScanning",
"listCodeqlDatabases"
>();
const octokit = mockedObject<Octokit>({
rest: {
codeScanning: {
listCodeqlDatabases: mockListCodeqlDatabases,
},
},
});
// We can't make the real octokit request (since we need credentials), so we mock the response.
const successfullMockApiResponse = {
data: [
{
id: 1495869,
name: "csharp-database",
language: "csharp",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 55599715,
created_at: "2022-03-24T10:46:24Z",
updated_at: "2022-03-24T10:46:27Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/csharp",
},
{
id: 1100671,
name: "database.zip",
language: "javascript",
uploader: {},
content_type: "application/zip",
state: "uploaded",
size: 29294434,
created_at: "2022-03-01T16:00:04Z",
updated_at: "2022-03-01T16:00:06Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
},
{
id: 648738,
name: "ql-database",
language: "ql",
uploader: {},
content_type: "application/json; charset=utf-8",
state: "uploaded",
size: 39735500,
created_at: "2022-02-02T09:38:50Z",
updated_at: "2022-02-02T09:38:51Z",
url: "https://api.github.com/repositories/143040428/code-scanning/codeql/databases/ql",
},
],
};
beforeEach(() => {
quickPickSpy = jest
.spyOn(window, "showQuickPick")
.mockResolvedValue(undefined);
});
it("should convert a GitHub nwo to a database url", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
quickPickSpy.mockResolvedValue(
mockedQuickPickItem({
label: "JavaScript",
language: "javascript",
}),
);
const githubRepo = "github/codeql";
const result = await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
);
expect(result).toBeDefined();
if (result === undefined) {
return;
}
const { databaseUrl, name, owner } = result;
expect(databaseUrl).toBe(
"https://api.github.com/repositories/143040428/code-scanning/codeql/databases/javascript",
);
expect(name).toBe("codeql");
expect(owner).toBe("github");
expect(quickPickSpy).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
label: "C#",
description: "csharp",
language: "csharp",
}),
expect.objectContaining({
label: "JavaScript",
description: "javascript",
language: "javascript",
}),
expect.objectContaining({
label: "ql",
description: "ql",
language: "ql",
}),
],
expect.anything(),
);
});
// Repository doesn't exist, or the user has no access to the repository.
it("should fail on an invalid/inaccessible repository", async () => {
const mockApiResponse = {
data: {
message: "Not Found",
},
status: 404,
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-not-real";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(0);
});
// User has access to the repository, but there are no databases for any language.
it("should fail on a repository with no databases", async () => {
const mockApiResponse = {
data: [],
};
mockListCodeqlDatabases.mockResolvedValue(mockApiResponse);
const githubRepo = "foo/bar-with-no-dbs";
await expect(
convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy),
).rejects.toThrow(/Unable to get database/);
expect(progressSpy).toHaveBeenCalledTimes(1);
});
describe("when language is already provided", () => {
describe("when language is valid", () => {
it("should not prompt the user", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"javascript",
);
expect(quickPickSpy).not.toHaveBeenCalled();
});
});
describe("when language is invalid", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(
githubRepo,
octokit,
progressSpy,
"invalid-language",
);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});
describe("when language is not provided", () => {
it("should prompt for language", async () => {
mockListCodeqlDatabases.mockResolvedValue(successfullMockApiResponse);
const githubRepo = "github/codeql";
await convertGithubNwoToDatabaseUrl(githubRepo, octokit, progressSpy);
expect(quickPickSpy).toHaveBeenCalled();
});
});
});