Merge pull request #3188 from github/robertbrignull/releases-refactor

Move ReleasesApiConsumer to a separate file and do simple refactors
This commit is contained in:
Robert 2024-01-03 16:24:28 +00:00 коммит произвёл GitHub
Родитель 47929943ba 1ce2b365a6
Коммит 92bebef49d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 493 добавлений и 534 удалений

Просмотреть файл

@ -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,6 +25,8 @@ import {
} from "../common/logging";
import { unzipToDirectoryConcurrently } from "../common/unzip-concurrently";
import { reportUnzipProgress } from "../common/vscode/unzip-progress";
import { Release } from "./distribution/release";
import { ReleasesApiConsumer } from "./distribution/releases-api-consumer";
/**
* distribution.ts
@ -36,30 +36,14 @@ import { reportUnzipProgress } from "../common/vscode/unzip-progress";
*/
/**
* Default value for the owner name of the extension-managed distribution on GitHub.
*
* We set the default here rather than as a default config value so that this default is invoked
* upon blanking the setting.
* Repository name with owner of the stable version of the extension-managed distribution on GitHub.
*/
const DEFAULT_DISTRIBUTION_OWNER_NAME = "github";
const STABLE_DISTRIBUTION_REPOSITORY_NWO = "github/codeql-cli-binaries";
/**
* Default value for the repository name of the extension-managed distribution on GitHub.
*
* We set the default here rather than as a default config value so that this default is invoked
* upon blanking the setting.
* Repository name with owner of the nightly version of the extension-managed distribution on GitHub.
*/
const DEFAULT_DISTRIBUTION_REPOSITORY_NAME = "codeql-cli-binaries";
/**
* Owner name of the nightly version of the extension-managed distribution on GitHub.
*/
const NIGHTLY_DISTRIBUTION_OWNER_NAME = "dsp-testing";
/**
* Repository name of the nightly version of the extension-managed distribution on GitHub.
*/
const NIGHTLY_DISTRIBUTION_REPOSITORY_NAME = "codeql-cli-nightlies";
const NIGHTLY_DISTRIBUTION_REPOSITORY_NWO = "dsp-testing/codeql-cli-nightlies";
/**
* Range of versions of the CLI that are compatible with the extension.
@ -505,32 +489,22 @@ class ExtensionSpecificDistributionManager {
private createReleasesApiConsumer(): ReleasesApiConsumer {
return new ReleasesApiConsumer(
this.distributionOwnerName,
this.distributionRepositoryName,
this.distributionRepositoryNwo,
this.config.personalAccessToken,
);
}
private get distributionOwnerName(): string {
private get distributionRepositoryNwo(): string {
if (this.config.channel === "nightly") {
return NIGHTLY_DISTRIBUTION_OWNER_NAME;
return NIGHTLY_DISTRIBUTION_REPOSITORY_NWO;
} else {
return DEFAULT_DISTRIBUTION_OWNER_NAME;
}
}
private get distributionRepositoryName(): string {
if (this.config.channel === "nightly") {
return NIGHTLY_DISTRIBUTION_REPOSITORY_NAME;
} else {
return DEFAULT_DISTRIBUTION_REPOSITORY_NAME;
return STABLE_DISTRIBUTION_REPOSITORY_NWO;
}
}
private get usingNightlyReleases(): boolean {
return (
this.distributionOwnerName === NIGHTLY_DISTRIBUTION_OWNER_NAME &&
this.distributionRepositoryName === NIGHTLY_DISTRIBUTION_REPOSITORY_NAME
this.distributionRepositoryNwo === NIGHTLY_DISTRIBUTION_REPOSITORY_NWO
);
}
@ -588,173 +562,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.
*/
@ -905,116 +712,3 @@ function warnDeprecatedLauncher() {
`Please use "${codeQlLauncherName()}" instead. It is recommended to update to the latest CodeQL binaries.`,
);
}
/**
* A release on GitHub.
*/
interface Release {
assets: ReleaseAsset[];
/**
* The creation date of the release on GitHub.
*/
createdAt: string;
/**
* The id associated with the release on GitHub.
*/
id: number;
/**
* The name associated with the release on GitHub.
*/
name: string;
}
/**
* An asset corresponding to a release on GitHub.
*/
interface ReleaseAsset {
/**
* 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;
}
/**
* 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;
}
export class GithubApiError extends Error {
constructor(
public status: number,
public body: string,
) {
super(`API call failed with status code ${status}, body: ${body}`);
}
}
export class GithubRateLimitedError extends GithubApiError {
constructor(
public status: number,
public body: string,
public rateLimitResetDate: Date,
) {
super(status, body);
}
}

Просмотреть файл

@ -0,0 +1,18 @@
export class GithubApiError extends Error {
constructor(
public status: number,
public body: string,
) {
super(`API call failed with status code ${status}, body: ${body}`);
}
}
export class GithubRateLimitedError extends GithubApiError {
constructor(
public status: number,
public body: string,
public rateLimitResetDate: Date,
) {
super(status, body);
}
}

Просмотреть файл

@ -0,0 +1,48 @@
/**
* A release of the CodeQL CLI hosted on GitHub.
*/
export interface Release {
/**
* The assets associated with the release on GitHub.
*/
assets: ReleaseAsset[];
/**
* The creation date of the release on GitHub.
*
* This is the date that the release was uploaded to GitHub, and not the date
* when we downloaded it or the date when we fetched the data from the GitHub API.
*/
createdAt: string;
/**
* The id associated with the release on GitHub.
*/
id: number;
/**
* The name associated with the release on GitHub.
*/
name: string;
}
/**
* An asset attached to a release on GitHub.
* Each release may have multiple assets, and each asset can be downloaded independently.
*/
export interface ReleaseAsset {
/**
* 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;
}

Просмотреть файл

@ -0,0 +1,196 @@
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";
/**
* Communicates with the GitHub API to determine the latest compatible release and download assets.
*/
export class ReleasesApiConsumer {
private static readonly apiBase = "https://api.github.com";
private static readonly maxRedirects = 20;
private readonly defaultHeaders: { [key: string]: string } = {};
constructor(
private readonly repositoryNwo: string,
personalAccessToken?: string,
) {
// Specify version of the GitHub API
this.defaultHeaders["accept"] = "application/vnd.github.v3+json";
if (personalAccessToken) {
this.defaultHeaders["authorization"] = `token ${personalAccessToken}`;
}
}
public async getLatestRelease(
versionRange: semver.Range | undefined,
orderBySemver = true,
includePrerelease = false,
additionalCompatibilityCheck?: (release: GithubRelease) => boolean,
): Promise<Release> {
const apiPath = `/repos/${this.repositoryNwo}/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.repositoryNwo}/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;
}
}
function isRedirectStatusCode(statusCode: number): boolean {
return (
statusCode === 301 ||
statusCode === 302 ||
statusCode === 303 ||
statusCode === 307 ||
statusCode === 308
);
}
/**
* The json returned from github for a release.
* See https://docs.github.com/en/rest/releases/releases#get-a-release for example response and response schema.
*
* This type must match the format of the GitHub API and is not intended to be used outside of this file except for tests. Please use the `Release` type instead.
*/
export interface GithubRelease {
assets: GithubReleaseAsset[];
created_at: string;
id: number;
name: string;
prerelease: boolean;
tag_name: string;
}
/**
* The json returned by github for an asset in a release.
* See https://docs.github.com/en/rest/releases/releases#get-a-release for example response and response schema.
*
* This type must match the format of the GitHub API and is not intended to be used outside of this file except for tests. Please use the `ReleaseAsset` type instead.
*/
export interface GithubReleaseAsset {
id: number;
name: string;
size: number;
}

Просмотреть файл

@ -53,9 +53,11 @@ import {
DistributionUpdateCheckResultKind,
FindDistributionResult,
FindDistributionResultKind,
} from "./codeql-cli/distribution";
import {
GithubApiError,
GithubRateLimitedError,
} from "./codeql-cli/distribution";
} from "./codeql-cli/distribution/github-api-error";
import { tmpDir, tmpDirDisposal } from "./tmp-dir";
import { prepareCodeTour } from "./code-tour/code-tour";
import {

Просмотреть файл

@ -0,0 +1,217 @@
import * as fetch from "node-fetch";
import { Range } from "semver";
import {
GithubRelease,
GithubReleaseAsset,
ReleasesApiConsumer,
} from "../../../../src/codeql-cli/distribution/releases-api-consumer";
describe("Releases API consumer", () => {
const repositoryNwo = "someowner/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/${repositoryNwo}/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(repositoryNwo);
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(repositoryNwo);
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(repositoryNwo);
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(repositoryNwo);
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(repositoryNwo);
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(repositoryNwo);
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(repositoryNwo);
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(repositoryNwo);
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/${repositoryNwo}/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(repositoryNwo);
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);
});
});
});

Просмотреть файл

@ -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>;