Merge pull request #3188 from github/robertbrignull/releases-refactor
Move ReleasesApiConsumer to a separate file and do simple refactors
This commit is contained in:
Коммит
92bebef49d
|
@ -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>;
|
||||
|
|
Загрузка…
Ссылка в новой задаче