Pull out ReleasesApiConsumer to its own file
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import * as fetch from "node-fetch";
|
||||
import { pathExists, mkdtemp, createWriteStream, remove } from "fs-extra";
|
||||
import { tmpdir } from "os";
|
||||
import { delimiter, dirname, join } from "path";
|
||||
import * as semver from "semver";
|
||||
import { URL } from "url";
|
||||
import { ExtensionContext, Event } from "vscode";
|
||||
import { DistributionConfig } from "../config";
|
||||
import { extLogger } from "../common/logging/vscode";
|
||||
@@ -27,8 +25,8 @@ import {
|
||||
} from "../common/logging";
|
||||
import { unzipToDirectoryConcurrently } from "../common/unzip-concurrently";
|
||||
import { reportUnzipProgress } from "../common/vscode/unzip-progress";
|
||||
import { Release, ReleaseAsset } from "./release";
|
||||
import { GithubRateLimitedError, GithubApiError } from "./github-api-error";
|
||||
import { Release } from "./release";
|
||||
import { ReleasesApiConsumer } from "./releases-api-consumer";
|
||||
|
||||
/**
|
||||
* distribution.ts
|
||||
@@ -590,173 +588,6 @@ class ExtensionSpecificDistributionManager {
|
||||
private static readonly _codeQlExtractedFolderName = "codeql";
|
||||
}
|
||||
|
||||
export class ReleasesApiConsumer {
|
||||
constructor(
|
||||
ownerName: string,
|
||||
repoName: string,
|
||||
personalAccessToken?: string,
|
||||
) {
|
||||
// Specify version of the GitHub API
|
||||
this._defaultHeaders["accept"] = "application/vnd.github.v3+json";
|
||||
|
||||
if (personalAccessToken) {
|
||||
this._defaultHeaders["authorization"] = `token ${personalAccessToken}`;
|
||||
}
|
||||
|
||||
this._ownerName = ownerName;
|
||||
this._repoName = repoName;
|
||||
}
|
||||
|
||||
public async getLatestRelease(
|
||||
versionRange: semver.Range | undefined,
|
||||
orderBySemver = true,
|
||||
includePrerelease = false,
|
||||
additionalCompatibilityCheck?: (release: GithubRelease) => boolean,
|
||||
): Promise<Release> {
|
||||
const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases`;
|
||||
const allReleases: GithubRelease[] = await (
|
||||
await this.makeApiCall(apiPath)
|
||||
).json();
|
||||
const compatibleReleases = allReleases.filter((release) => {
|
||||
if (release.prerelease && !includePrerelease) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (versionRange !== undefined) {
|
||||
const version = semver.parse(release.tag_name);
|
||||
if (
|
||||
version === null ||
|
||||
!semver.satisfies(version, versionRange, { includePrerelease })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
!additionalCompatibilityCheck || additionalCompatibilityCheck(release)
|
||||
);
|
||||
});
|
||||
// Tag names must all be parsable to semvers due to the previous filtering step.
|
||||
const latestRelease = compatibleReleases.sort((a, b) => {
|
||||
const versionComparison = orderBySemver
|
||||
? semver.compare(semver.parse(b.tag_name)!, semver.parse(a.tag_name)!)
|
||||
: b.id - a.id;
|
||||
if (versionComparison !== 0) {
|
||||
return versionComparison;
|
||||
}
|
||||
return b.created_at.localeCompare(a.created_at, "en-US");
|
||||
})[0];
|
||||
if (latestRelease === undefined) {
|
||||
throw new Error(
|
||||
"No compatible CodeQL CLI releases were found. " +
|
||||
"Please check that the CodeQL extension is up to date.",
|
||||
);
|
||||
}
|
||||
const assets: ReleaseAsset[] = latestRelease.assets.map((asset) => {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
assets,
|
||||
createdAt: latestRelease.created_at,
|
||||
id: latestRelease.id,
|
||||
name: latestRelease.name,
|
||||
};
|
||||
}
|
||||
|
||||
public async streamBinaryContentOfAsset(
|
||||
asset: ReleaseAsset,
|
||||
): Promise<fetch.Response> {
|
||||
const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases/assets/${asset.id}`;
|
||||
|
||||
return await this.makeApiCall(apiPath, {
|
||||
accept: "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
protected async makeApiCall(
|
||||
apiPath: string,
|
||||
additionalHeaders: { [key: string]: string } = {},
|
||||
): Promise<fetch.Response> {
|
||||
const response = await this.makeRawRequest(
|
||||
ReleasesApiConsumer._apiBase + apiPath,
|
||||
Object.assign({}, this._defaultHeaders, additionalHeaders),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for rate limiting
|
||||
const rateLimitResetValue = response.headers.get("X-RateLimit-Reset");
|
||||
if (response.status === 403 && rateLimitResetValue) {
|
||||
const secondsToMillisecondsFactor = 1000;
|
||||
const rateLimitResetDate = new Date(
|
||||
parseInt(rateLimitResetValue, 10) * secondsToMillisecondsFactor,
|
||||
);
|
||||
throw new GithubRateLimitedError(
|
||||
response.status,
|
||||
await response.text(),
|
||||
rateLimitResetDate,
|
||||
);
|
||||
}
|
||||
throw new GithubApiError(response.status, await response.text());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async makeRawRequest(
|
||||
requestUrl: string,
|
||||
headers: { [key: string]: string },
|
||||
redirectCount = 0,
|
||||
): Promise<fetch.Response> {
|
||||
const response = await fetch.default(requestUrl, {
|
||||
headers,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const redirectUrl = response.headers.get("location");
|
||||
if (
|
||||
isRedirectStatusCode(response.status) &&
|
||||
redirectUrl &&
|
||||
redirectCount < ReleasesApiConsumer._maxRedirects
|
||||
) {
|
||||
const parsedRedirectUrl = new URL(redirectUrl);
|
||||
if (parsedRedirectUrl.protocol !== "https:") {
|
||||
throw new Error("Encountered a non-https redirect, rejecting");
|
||||
}
|
||||
if (parsedRedirectUrl.host !== "api.github.com") {
|
||||
// Remove authorization header if we are redirected outside of the GitHub API.
|
||||
//
|
||||
// This is necessary to stream release assets since AWS fails if more than one auth
|
||||
// mechanism is provided.
|
||||
delete headers["authorization"];
|
||||
}
|
||||
return await this.makeRawRequest(redirectUrl, headers, redirectCount + 1);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private readonly _defaultHeaders: { [key: string]: string } = {};
|
||||
private readonly _ownerName: string;
|
||||
private readonly _repoName: string;
|
||||
|
||||
private static readonly _apiBase = "https://api.github.com";
|
||||
private static readonly _maxRedirects = 20;
|
||||
}
|
||||
|
||||
function isRedirectStatusCode(statusCode: number): boolean {
|
||||
return (
|
||||
statusCode === 301 ||
|
||||
statusCode === 302 ||
|
||||
statusCode === 303 ||
|
||||
statusCode === 307 ||
|
||||
statusCode === 308
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Types and helper functions relating to those types.
|
||||
*/
|
||||
@@ -907,55 +738,3 @@ function warnDeprecatedLauncher() {
|
||||
`Please use "${codeQlLauncherName()}" instead. It is recommended to update to the latest CodeQL binaries.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The json returned from github for a release.
|
||||
*/
|
||||
export interface GithubRelease {
|
||||
assets: GithubReleaseAsset[];
|
||||
|
||||
/**
|
||||
* The creation date of the release on GitHub, in ISO 8601 format.
|
||||
*/
|
||||
created_at: string;
|
||||
|
||||
/**
|
||||
* The id associated with the release on GitHub.
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The name associated with the release on GitHub.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Whether the release is a prerelease.
|
||||
*/
|
||||
prerelease: boolean;
|
||||
|
||||
/**
|
||||
* The tag name. This should be the version.
|
||||
*/
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The json returned by github for an asset in a release.
|
||||
*/
|
||||
export interface GithubReleaseAsset {
|
||||
/**
|
||||
* The id associated with the asset on GitHub.
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The name associated with the asset on GitHub.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The size of the asset in bytes.
|
||||
*/
|
||||
size: number;
|
||||
}
|
||||
|
||||
224
extensions/ql-vscode/src/codeql-cli/releases-api-consumer.ts
Normal file
224
extensions/ql-vscode/src/codeql-cli/releases-api-consumer.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import * as fetch from "node-fetch";
|
||||
import * as semver from "semver";
|
||||
import { URL } from "url";
|
||||
import { Release, ReleaseAsset } from "./release";
|
||||
import { GithubRateLimitedError, GithubApiError } from "./github-api-error";
|
||||
|
||||
export class ReleasesApiConsumer {
|
||||
constructor(
|
||||
ownerName: string,
|
||||
repoName: string,
|
||||
personalAccessToken?: string,
|
||||
) {
|
||||
// Specify version of the GitHub API
|
||||
this._defaultHeaders["accept"] = "application/vnd.github.v3+json";
|
||||
|
||||
if (personalAccessToken) {
|
||||
this._defaultHeaders["authorization"] = `token ${personalAccessToken}`;
|
||||
}
|
||||
|
||||
this._ownerName = ownerName;
|
||||
this._repoName = repoName;
|
||||
}
|
||||
|
||||
public async getLatestRelease(
|
||||
versionRange: semver.Range | undefined,
|
||||
orderBySemver = true,
|
||||
includePrerelease = false,
|
||||
additionalCompatibilityCheck?: (release: GithubRelease) => boolean,
|
||||
): Promise<Release> {
|
||||
const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases`;
|
||||
const allReleases: GithubRelease[] = await (
|
||||
await this.makeApiCall(apiPath)
|
||||
).json();
|
||||
const compatibleReleases = allReleases.filter((release) => {
|
||||
if (release.prerelease && !includePrerelease) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (versionRange !== undefined) {
|
||||
const version = semver.parse(release.tag_name);
|
||||
if (
|
||||
version === null ||
|
||||
!semver.satisfies(version, versionRange, { includePrerelease })
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
!additionalCompatibilityCheck || additionalCompatibilityCheck(release)
|
||||
);
|
||||
});
|
||||
// Tag names must all be parsable to semvers due to the previous filtering step.
|
||||
const latestRelease = compatibleReleases.sort((a, b) => {
|
||||
const versionComparison = orderBySemver
|
||||
? semver.compare(semver.parse(b.tag_name)!, semver.parse(a.tag_name)!)
|
||||
: b.id - a.id;
|
||||
if (versionComparison !== 0) {
|
||||
return versionComparison;
|
||||
}
|
||||
return b.created_at.localeCompare(a.created_at, "en-US");
|
||||
})[0];
|
||||
if (latestRelease === undefined) {
|
||||
throw new Error(
|
||||
"No compatible CodeQL CLI releases were found. " +
|
||||
"Please check that the CodeQL extension is up to date.",
|
||||
);
|
||||
}
|
||||
const assets: ReleaseAsset[] = latestRelease.assets.map((asset) => {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
assets,
|
||||
createdAt: latestRelease.created_at,
|
||||
id: latestRelease.id,
|
||||
name: latestRelease.name,
|
||||
};
|
||||
}
|
||||
|
||||
public async streamBinaryContentOfAsset(
|
||||
asset: ReleaseAsset,
|
||||
): Promise<fetch.Response> {
|
||||
const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases/assets/${asset.id}`;
|
||||
|
||||
return await this.makeApiCall(apiPath, {
|
||||
accept: "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
protected async makeApiCall(
|
||||
apiPath: string,
|
||||
additionalHeaders: { [key: string]: string } = {},
|
||||
): Promise<fetch.Response> {
|
||||
const response = await this.makeRawRequest(
|
||||
ReleasesApiConsumer._apiBase + apiPath,
|
||||
Object.assign({}, this._defaultHeaders, additionalHeaders),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Check for rate limiting
|
||||
const rateLimitResetValue = response.headers.get("X-RateLimit-Reset");
|
||||
if (response.status === 403 && rateLimitResetValue) {
|
||||
const secondsToMillisecondsFactor = 1000;
|
||||
const rateLimitResetDate = new Date(
|
||||
parseInt(rateLimitResetValue, 10) * secondsToMillisecondsFactor,
|
||||
);
|
||||
throw new GithubRateLimitedError(
|
||||
response.status,
|
||||
await response.text(),
|
||||
rateLimitResetDate,
|
||||
);
|
||||
}
|
||||
throw new GithubApiError(response.status, await response.text());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async makeRawRequest(
|
||||
requestUrl: string,
|
||||
headers: { [key: string]: string },
|
||||
redirectCount = 0,
|
||||
): Promise<fetch.Response> {
|
||||
const response = await fetch.default(requestUrl, {
|
||||
headers,
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
const redirectUrl = response.headers.get("location");
|
||||
if (
|
||||
isRedirectStatusCode(response.status) &&
|
||||
redirectUrl &&
|
||||
redirectCount < ReleasesApiConsumer._maxRedirects
|
||||
) {
|
||||
const parsedRedirectUrl = new URL(redirectUrl);
|
||||
if (parsedRedirectUrl.protocol !== "https:") {
|
||||
throw new Error("Encountered a non-https redirect, rejecting");
|
||||
}
|
||||
if (parsedRedirectUrl.host !== "api.github.com") {
|
||||
// Remove authorization header if we are redirected outside of the GitHub API.
|
||||
//
|
||||
// This is necessary to stream release assets since AWS fails if more than one auth
|
||||
// mechanism is provided.
|
||||
delete headers["authorization"];
|
||||
}
|
||||
return await this.makeRawRequest(redirectUrl, headers, redirectCount + 1);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private readonly _defaultHeaders: { [key: string]: string } = {};
|
||||
private readonly _ownerName: string;
|
||||
private readonly _repoName: string;
|
||||
|
||||
private static readonly _apiBase = "https://api.github.com";
|
||||
private static readonly _maxRedirects = 20;
|
||||
}
|
||||
|
||||
function isRedirectStatusCode(statusCode: number): boolean {
|
||||
return (
|
||||
statusCode === 301 ||
|
||||
statusCode === 302 ||
|
||||
statusCode === 303 ||
|
||||
statusCode === 307 ||
|
||||
statusCode === 308
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The json returned from github for a release.
|
||||
*/
|
||||
interface GithubRelease {
|
||||
assets: GithubReleaseAsset[];
|
||||
|
||||
/**
|
||||
* The creation date of the release on GitHub, in ISO 8601 format.
|
||||
*/
|
||||
created_at: string;
|
||||
|
||||
/**
|
||||
* The id associated with the release on GitHub.
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The name associated with the release on GitHub.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Whether the release is a prerelease.
|
||||
*/
|
||||
prerelease: boolean;
|
||||
|
||||
/**
|
||||
* The tag name. This should be the version.
|
||||
*/
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The json returned by github for an asset in a release.
|
||||
*/
|
||||
interface GithubReleaseAsset {
|
||||
/**
|
||||
* The id associated with the asset on GitHub.
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The name associated with the asset on GitHub.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The size of the asset in bytes.
|
||||
*/
|
||||
size: number;
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import * as fetch from "node-fetch";
|
||||
import { Range } from "semver";
|
||||
|
||||
import * as log from "../../../../src/common/logging/notifications";
|
||||
import { extLogger } from "../../../../src/common/logging/vscode";
|
||||
import * as fs from "fs-extra";
|
||||
@@ -11,9 +8,6 @@ import { DirectoryResult } from "tmp-promise";
|
||||
import {
|
||||
DistributionManager,
|
||||
getExecutableFromDirectory,
|
||||
GithubRelease,
|
||||
GithubReleaseAsset,
|
||||
ReleasesApiConsumer,
|
||||
} from "../../../../src/codeql-cli/distribution";
|
||||
import {
|
||||
showAndLogErrorMessage,
|
||||
@@ -30,216 +24,6 @@ jest.mock("os", () => {
|
||||
|
||||
const mockedOS = jest.mocked(os);
|
||||
|
||||
describe("Releases API consumer", () => {
|
||||
const owner = "someowner";
|
||||
const repo = "somerepo";
|
||||
const unconstrainedVersionRange = new Range("*");
|
||||
|
||||
describe("picking the latest release", () => {
|
||||
const sampleReleaseResponse: GithubRelease[] = [
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-09-01T00:00:00Z",
|
||||
id: 1,
|
||||
name: "",
|
||||
prerelease: false,
|
||||
tag_name: "v2.1.0",
|
||||
},
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-10T00:00:00Z",
|
||||
id: 2,
|
||||
name: "",
|
||||
prerelease: false,
|
||||
tag_name: "v3.1.1",
|
||||
},
|
||||
{
|
||||
assets: [
|
||||
{
|
||||
id: 1,
|
||||
name: "exampleAsset.txt",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
created_at: "2019-09-05T00:00:00Z",
|
||||
id: 3,
|
||||
name: "",
|
||||
prerelease: false,
|
||||
tag_name: "v2.0.0",
|
||||
},
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-11T00:00:00Z",
|
||||
id: 4,
|
||||
name: "",
|
||||
prerelease: true,
|
||||
tag_name: "v3.1.2-pre-1.1",
|
||||
},
|
||||
// Release ID 5 is older than release ID 4 but its version has a higher precedence, so release
|
||||
// ID 5 should be picked over release ID 4.
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-09T00:00:00Z",
|
||||
id: 5,
|
||||
name: "",
|
||||
prerelease: true,
|
||||
tag_name: "v3.1.2-pre-2.0",
|
||||
},
|
||||
// Has a tag_name that is not valid semver
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-010T00:00:00Z",
|
||||
id: 6,
|
||||
name: "",
|
||||
prerelease: true,
|
||||
tag_name: "codeql-bundle-20231220",
|
||||
},
|
||||
];
|
||||
|
||||
class MockReleasesApiConsumer extends ReleasesApiConsumer {
|
||||
protected async makeApiCall(apiPath: string): Promise<fetch.Response> {
|
||||
if (apiPath === `/repos/${owner}/${repo}/releases`) {
|
||||
return Promise.resolve(
|
||||
new fetch.Response(JSON.stringify(sampleReleaseResponse)),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unknown API path: ${apiPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
it("picked release is non-prerelease with the highest semver", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
unconstrainedVersionRange,
|
||||
true,
|
||||
);
|
||||
expect(latestRelease.id).toBe(2);
|
||||
});
|
||||
|
||||
it("picked release is non-prerelease with highest id", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
unconstrainedVersionRange,
|
||||
false,
|
||||
);
|
||||
expect(latestRelease.id).toBe(3);
|
||||
});
|
||||
|
||||
it("version of picked release is within the version range", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(new Range("2.*.*"));
|
||||
expect(latestRelease.id).toBe(1);
|
||||
});
|
||||
|
||||
it("fails if none of the releases are within the version range", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
await expect(
|
||||
consumer.getLatestRelease(new Range("5.*.*")),
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it("picked release passes additional compatibility test if an additional compatibility test is specified", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
new Range("2.*.*"),
|
||||
true,
|
||||
true,
|
||||
(release) =>
|
||||
release.assets.some((asset) => asset.name === "exampleAsset.txt"),
|
||||
);
|
||||
expect(latestRelease.id).toBe(3);
|
||||
});
|
||||
|
||||
it("fails if none of the releases pass the additional compatibility test", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
await expect(
|
||||
consumer.getLatestRelease(new Range("2.*.*"), true, true, (release) =>
|
||||
release.assets.some(
|
||||
(asset) => asset.name === "otherExampleAsset.txt",
|
||||
),
|
||||
),
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it("picked release is the most recent prerelease when includePrereleases is set", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
unconstrainedVersionRange,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(latestRelease.id).toBe(5);
|
||||
});
|
||||
|
||||
it("ignores invalid semver and picks (pre-)release with highest id", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(latestRelease.id).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
it("gets correct assets for a release", async () => {
|
||||
const expectedAssets: GithubReleaseAsset[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "firstAsset",
|
||||
size: 11,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "secondAsset",
|
||||
size: 12,
|
||||
},
|
||||
];
|
||||
|
||||
class MockReleasesApiConsumer extends ReleasesApiConsumer {
|
||||
protected async makeApiCall(apiPath: string): Promise<fetch.Response> {
|
||||
if (apiPath === `/repos/${owner}/${repo}/releases`) {
|
||||
const responseBody: GithubRelease[] = [
|
||||
{
|
||||
assets: expectedAssets,
|
||||
created_at: "2019-09-01T00:00:00Z",
|
||||
id: 1,
|
||||
name: "Release 1",
|
||||
prerelease: false,
|
||||
tag_name: "v2.0.0",
|
||||
},
|
||||
];
|
||||
|
||||
return Promise.resolve(
|
||||
new fetch.Response(JSON.stringify(responseBody)),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unknown API path: ${apiPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const assets = (await consumer.getLatestRelease(unconstrainedVersionRange))
|
||||
.assets;
|
||||
|
||||
expect(assets.length).toBe(expectedAssets.length);
|
||||
expectedAssets.map((expectedAsset, index) => {
|
||||
expect(assets[index].id).toBe(expectedAsset.id);
|
||||
expect(assets[index].name).toBe(expectedAsset.name);
|
||||
expect(assets[index].size).toBe(expectedAsset.size);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Launcher path", () => {
|
||||
let warnSpy: jest.SpiedFunction<typeof showAndLogWarningMessage>;
|
||||
let errorSpy: jest.SpiedFunction<typeof showAndLogErrorMessage>;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import * as fetch from "node-fetch";
|
||||
import { Range } from "semver";
|
||||
|
||||
import {
|
||||
GithubRelease,
|
||||
GithubReleaseAsset,
|
||||
ReleasesApiConsumer,
|
||||
} from "../../../../src/codeql-cli/releases-api-consumer";
|
||||
|
||||
describe("Releases API consumer", () => {
|
||||
const owner = "someowner";
|
||||
const repo = "somerepo";
|
||||
const unconstrainedVersionRange = new Range("*");
|
||||
|
||||
describe("picking the latest release", () => {
|
||||
const sampleReleaseResponse: GithubRelease[] = [
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-09-01T00:00:00Z",
|
||||
id: 1,
|
||||
name: "",
|
||||
prerelease: false,
|
||||
tag_name: "v2.1.0",
|
||||
},
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-10T00:00:00Z",
|
||||
id: 2,
|
||||
name: "",
|
||||
prerelease: false,
|
||||
tag_name: "v3.1.1",
|
||||
},
|
||||
{
|
||||
assets: [
|
||||
{
|
||||
id: 1,
|
||||
name: "exampleAsset.txt",
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
created_at: "2019-09-05T00:00:00Z",
|
||||
id: 3,
|
||||
name: "",
|
||||
prerelease: false,
|
||||
tag_name: "v2.0.0",
|
||||
},
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-11T00:00:00Z",
|
||||
id: 4,
|
||||
name: "",
|
||||
prerelease: true,
|
||||
tag_name: "v3.1.2-pre-1.1",
|
||||
},
|
||||
// Release ID 5 is older than release ID 4 but its version has a higher precedence, so release
|
||||
// ID 5 should be picked over release ID 4.
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-09T00:00:00Z",
|
||||
id: 5,
|
||||
name: "",
|
||||
prerelease: true,
|
||||
tag_name: "v3.1.2-pre-2.0",
|
||||
},
|
||||
// Has a tag_name that is not valid semver
|
||||
{
|
||||
assets: [],
|
||||
created_at: "2019-08-010T00:00:00Z",
|
||||
id: 6,
|
||||
name: "",
|
||||
prerelease: true,
|
||||
tag_name: "codeql-bundle-20231220",
|
||||
},
|
||||
];
|
||||
|
||||
class MockReleasesApiConsumer extends ReleasesApiConsumer {
|
||||
protected async makeApiCall(apiPath: string): Promise<fetch.Response> {
|
||||
if (apiPath === `/repos/${owner}/${repo}/releases`) {
|
||||
return Promise.resolve(
|
||||
new fetch.Response(JSON.stringify(sampleReleaseResponse)),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unknown API path: ${apiPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
it("picked release is non-prerelease with the highest semver", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
unconstrainedVersionRange,
|
||||
true,
|
||||
);
|
||||
expect(latestRelease.id).toBe(2);
|
||||
});
|
||||
|
||||
it("picked release is non-prerelease with highest id", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
unconstrainedVersionRange,
|
||||
false,
|
||||
);
|
||||
expect(latestRelease.id).toBe(3);
|
||||
});
|
||||
|
||||
it("version of picked release is within the version range", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(new Range("2.*.*"));
|
||||
expect(latestRelease.id).toBe(1);
|
||||
});
|
||||
|
||||
it("fails if none of the releases are within the version range", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
await expect(
|
||||
consumer.getLatestRelease(new Range("5.*.*")),
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it("picked release passes additional compatibility test if an additional compatibility test is specified", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
new Range("2.*.*"),
|
||||
true,
|
||||
true,
|
||||
(release) =>
|
||||
release.assets.some((asset) => asset.name === "exampleAsset.txt"),
|
||||
);
|
||||
expect(latestRelease.id).toBe(3);
|
||||
});
|
||||
|
||||
it("fails if none of the releases pass the additional compatibility test", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
await expect(
|
||||
consumer.getLatestRelease(new Range("2.*.*"), true, true, (release) =>
|
||||
release.assets.some(
|
||||
(asset) => asset.name === "otherExampleAsset.txt",
|
||||
),
|
||||
),
|
||||
).rejects.toThrowError();
|
||||
});
|
||||
|
||||
it("picked release is the most recent prerelease when includePrereleases is set", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
unconstrainedVersionRange,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
expect(latestRelease.id).toBe(5);
|
||||
});
|
||||
|
||||
it("ignores invalid semver and picks (pre-)release with highest id", async () => {
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const latestRelease = await consumer.getLatestRelease(
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
expect(latestRelease.id).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
it("gets correct assets for a release", async () => {
|
||||
const expectedAssets: GithubReleaseAsset[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "firstAsset",
|
||||
size: 11,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "secondAsset",
|
||||
size: 12,
|
||||
},
|
||||
];
|
||||
|
||||
class MockReleasesApiConsumer extends ReleasesApiConsumer {
|
||||
protected async makeApiCall(apiPath: string): Promise<fetch.Response> {
|
||||
if (apiPath === `/repos/${owner}/${repo}/releases`) {
|
||||
const responseBody: GithubRelease[] = [
|
||||
{
|
||||
assets: expectedAssets,
|
||||
created_at: "2019-09-01T00:00:00Z",
|
||||
id: 1,
|
||||
name: "Release 1",
|
||||
prerelease: false,
|
||||
tag_name: "v2.0.0",
|
||||
},
|
||||
];
|
||||
|
||||
return Promise.resolve(
|
||||
new fetch.Response(JSON.stringify(responseBody)),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unknown API path: ${apiPath}`));
|
||||
}
|
||||
}
|
||||
|
||||
const consumer = new MockReleasesApiConsumer(owner, repo);
|
||||
|
||||
const assets = (await consumer.getLatestRelease(unconstrainedVersionRange))
|
||||
.assets;
|
||||
|
||||
expect(assets.length).toBe(expectedAssets.length);
|
||||
expectedAssets.map((expectedAsset, index) => {
|
||||
expect(assets[index].id).toBe(expectedAsset.id);
|
||||
expect(assets[index].name).toBe(expectedAsset.name);
|
||||
expect(assets[index].size).toBe(expectedAsset.size);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user