Files
vscode-codeql/extensions/ql-vscode/src/codeql-cli/distribution.ts
2023-05-31 13:47:59 +01:00

966 lines
28 KiB
TypeScript

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 { showAndLogErrorMessage, showAndLogWarningMessage } from "../helpers";
import { extLogger } from "../common";
import { getCodeQlCliVersion } from "./cli-version";
import {
ProgressCallback,
reportStreamProgress,
} from "../common/vscode/progress";
import {
codeQlLauncherName,
deprecatedCodeQlLauncherName,
extractZipArchive,
getRequiredAssetName,
} from "../pure/distribution";
import {
InvocationRateLimiter,
InvocationRateLimiterResultKind,
} from "../common/invocation-rate-limiter";
/**
* distribution.ts
* ------------
*
* Management of CodeQL CLI binaries.
*/
/**
* 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.
*/
const DEFAULT_DISTRIBUTION_OWNER_NAME = "github";
/**
* 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.
*/
const DEFAULT_DISTRIBUTION_REPOSITORY_NAME = "codeql-cli-binaries";
/**
* Range of versions of the CLI that are compatible with the extension.
*
* This applies to both extension-managed and CLI distributions.
*/
export const DEFAULT_DISTRIBUTION_VERSION_RANGE: semver.Range =
new semver.Range("2.x");
export interface DistributionProvider {
getCodeQlPathWithoutVersionCheck(): Promise<string | undefined>;
onDidChangeDistribution?: Event<void>;
getDistribution(): Promise<FindDistributionResult>;
}
export class DistributionManager implements DistributionProvider {
constructor(
public readonly config: DistributionConfig,
private readonly versionRange: semver.Range,
extensionContext: ExtensionContext,
) {
this._onDidChangeDistribution = config.onDidChangeConfiguration;
this.extensionSpecificDistributionManager =
new ExtensionSpecificDistributionManager(
config,
versionRange,
extensionContext,
);
this.updateCheckRateLimiter = new InvocationRateLimiter(
extensionContext.globalState,
"extensionSpecificDistributionUpdateCheck",
() =>
this.extensionSpecificDistributionManager.checkForUpdatesToDistribution(),
);
}
/**
* Look up a CodeQL launcher binary.
*/
public async getDistribution(): Promise<FindDistributionResult> {
const distribution = await this.getDistributionWithoutVersionCheck();
if (distribution === undefined) {
return {
kind: FindDistributionResultKind.NoDistribution,
};
}
const version = await getCodeQlCliVersion(
distribution.codeQlPath,
extLogger,
);
if (version === undefined) {
return {
distribution,
kind: FindDistributionResultKind.UnknownCompatibilityDistribution,
};
}
/**
* Specifies whether prerelease versions of the CodeQL CLI should be accepted.
*
* Suppose a user sets the includePrerelease config option, obtains a prerelease, then decides
* they no longer want a prerelease, so unsets the includePrerelease config option.
* Unsetting the includePrerelease config option should trigger an update check, and this
* update check should present them an update that returns them back to a non-prerelease
* version.
*
* Therefore, we adopt the following:
*
* - If the user is managing their own CLI, they can use a prerelease without specifying the
* includePrerelease option.
* - If the user is using an extension-managed CLI, then prereleases are only accepted when the
* includePrerelease config option is set.
*/
const includePrerelease =
distribution.kind !== DistributionKind.ExtensionManaged ||
this.config.includePrerelease;
if (!semver.satisfies(version, this.versionRange, { includePrerelease })) {
return {
distribution,
kind: FindDistributionResultKind.IncompatibleDistribution,
version,
};
}
return {
distribution,
kind: FindDistributionResultKind.CompatibleDistribution,
version,
};
}
public async hasDistribution(): Promise<boolean> {
const result = await this.getDistribution();
return result.kind !== FindDistributionResultKind.NoDistribution;
}
public async getCodeQlPathWithoutVersionCheck(): Promise<string | undefined> {
const distribution = await this.getDistributionWithoutVersionCheck();
return distribution?.codeQlPath;
}
/**
* Returns the path to a possibly-compatible CodeQL launcher binary, or undefined if a binary not be found.
*/
async getDistributionWithoutVersionCheck(): Promise<
Distribution | undefined
> {
// Check config setting, then extension specific distribution, then PATH.
if (this.config.customCodeQlPath) {
if (!(await pathExists(this.config.customCodeQlPath))) {
void showAndLogErrorMessage(
`The CodeQL executable path is specified as "${this.config.customCodeQlPath}" ` +
"by a configuration setting, but a CodeQL executable could not be found at that path. Please check " +
"that a CodeQL executable exists at the specified path or remove the setting.",
);
return undefined;
}
// emit a warning if using a deprecated launcher and a non-deprecated launcher exists
if (
deprecatedCodeQlLauncherName() &&
this.config.customCodeQlPath.endsWith(
deprecatedCodeQlLauncherName()!,
) &&
(await this.hasNewLauncherName())
) {
warnDeprecatedLauncher();
}
return {
codeQlPath: this.config.customCodeQlPath,
kind: DistributionKind.CustomPathConfig,
};
}
const extensionSpecificCodeQlPath =
await this.extensionSpecificDistributionManager.getCodeQlPathWithoutVersionCheck();
if (extensionSpecificCodeQlPath !== undefined) {
return {
codeQlPath: extensionSpecificCodeQlPath,
kind: DistributionKind.ExtensionManaged,
};
}
if (process.env.PATH) {
for (const searchDirectory of process.env.PATH.split(delimiter)) {
const expectedLauncherPath = await getExecutableFromDirectory(
searchDirectory,
);
if (expectedLauncherPath) {
return {
codeQlPath: expectedLauncherPath,
kind: DistributionKind.PathEnvironmentVariable,
};
}
}
void extLogger.log("INFO: Could not find CodeQL on path.");
}
return undefined;
}
/**
* Check for updates to the extension-managed distribution. If one has not already been installed,
* this will return an update available result with the latest available release.
*
* Returns a failed promise if an unexpected error occurs during installation.
*/
public async checkForUpdatesToExtensionManagedDistribution(
minSecondsSinceLastUpdateCheck: number,
): Promise<DistributionUpdateCheckResult> {
const distribution = await this.getDistributionWithoutVersionCheck();
if (distribution === undefined) {
minSecondsSinceLastUpdateCheck = 0;
}
const extensionManagedCodeQlPath =
await this.extensionSpecificDistributionManager.getCodeQlPathWithoutVersionCheck();
if (distribution?.codeQlPath !== extensionManagedCodeQlPath) {
// A distribution is present but it isn't managed by the extension.
return createInvalidLocationResult();
}
const updateCheckResult =
await this.updateCheckRateLimiter.invokeFunctionIfIntervalElapsed(
minSecondsSinceLastUpdateCheck,
);
switch (updateCheckResult.kind) {
case InvocationRateLimiterResultKind.Invoked:
return updateCheckResult.result;
case InvocationRateLimiterResultKind.RateLimited:
return createAlreadyCheckedRecentlyResult();
}
}
/**
* Installs a release of the extension-managed distribution.
*
* Returns a failed promise if an unexpected error occurs during installation.
*/
public installExtensionManagedDistributionRelease(
release: Release,
progressCallback?: ProgressCallback,
): Promise<void> {
return this.extensionSpecificDistributionManager.installDistributionRelease(
release,
progressCallback,
);
}
public get onDidChangeDistribution(): Event<void> | undefined {
return this._onDidChangeDistribution;
}
/**
* @return true if the non-deprecated launcher name exists on the file system
* in the same directory as the specified launcher only if using an external
* installation. False otherwise.
*/
private async hasNewLauncherName(): Promise<boolean> {
if (!this.config.customCodeQlPath) {
// not managed externally
return false;
}
const dir = dirname(this.config.customCodeQlPath);
const newLaunderPath = join(dir, codeQlLauncherName());
return await pathExists(newLaunderPath);
}
private readonly extensionSpecificDistributionManager: ExtensionSpecificDistributionManager;
private readonly updateCheckRateLimiter: InvocationRateLimiter<DistributionUpdateCheckResult>;
private readonly _onDidChangeDistribution: Event<void> | undefined;
}
class ExtensionSpecificDistributionManager {
constructor(
private readonly config: DistributionConfig,
private readonly versionRange: semver.Range,
private readonly extensionContext: ExtensionContext,
) {
/**/
}
public async getCodeQlPathWithoutVersionCheck(): Promise<string | undefined> {
if (this.getInstalledRelease() !== undefined) {
// An extension specific distribution has been installed.
const expectedLauncherPath = await getExecutableFromDirectory(
this.getDistributionRootPath(),
true,
);
if (expectedLauncherPath) {
return expectedLauncherPath;
}
try {
await this.removeDistribution();
} catch (e) {
void extLogger.log(
"WARNING: Tried to remove corrupted CodeQL CLI at " +
`${this.getDistributionStoragePath()} but encountered an error: ${e}.`,
);
}
}
return undefined;
}
/**
* Check for updates to the extension-managed distribution. If one has not already been installed,
* this will return an update available result with the latest available release.
*
* Returns a failed promise if an unexpected error occurs during installation.
*/
public async checkForUpdatesToDistribution(): Promise<DistributionUpdateCheckResult> {
const codeQlPath = await this.getCodeQlPathWithoutVersionCheck();
const extensionSpecificRelease = this.getInstalledRelease();
const latestRelease = await this.getLatestRelease();
// v2.12.3 was released with a bug that causes the extension to fail
// so we force the extension to ignore it.
if (
extensionSpecificRelease &&
extensionSpecificRelease.name === "v2.12.3"
) {
return createUpdateAvailableResult(latestRelease);
}
if (
extensionSpecificRelease !== undefined &&
codeQlPath !== undefined &&
latestRelease.id === extensionSpecificRelease.id
) {
return createAlreadyUpToDateResult();
}
return createUpdateAvailableResult(latestRelease);
}
/**
* Installs a release of the extension-managed distribution.
*
* Returns a failed promise if an unexpected error occurs during installation.
*/
public async installDistributionRelease(
release: Release,
progressCallback?: ProgressCallback,
): Promise<void> {
await this.downloadDistribution(release, progressCallback);
// Store the installed release within the global extension state.
await this.storeInstalledRelease(release);
}
private async downloadDistribution(
release: Release,
progressCallback?: ProgressCallback,
): Promise<void> {
try {
await this.removeDistribution();
} catch (e) {
void extLogger.log(
`Tried to clean up old version of CLI at ${this.getDistributionStoragePath()} ` +
`but encountered an error: ${e}.`,
);
}
// Filter assets to the unique one that we require.
const requiredAssetName = getRequiredAssetName();
const assets = release.assets.filter(
(asset) => asset.name === requiredAssetName,
);
if (assets.length === 0) {
throw new Error(
`Invariant violation: chose a release to install that didn't have ${requiredAssetName}`,
);
}
if (assets.length > 1) {
void extLogger.log(
`WARNING: chose a release with more than one asset to install, found ${assets
.map((asset) => asset.name)
.join(", ")}`,
);
}
const assetStream =
await this.createReleasesApiConsumer().streamBinaryContentOfAsset(
assets[0],
);
const tmpDirectory = await mkdtemp(join(tmpdir(), "vscode-codeql"));
try {
const archivePath = join(tmpDirectory, "distributionDownload.zip");
const archiveFile = createWriteStream(archivePath);
const contentLength = assetStream.headers.get("content-length");
const totalNumBytes = contentLength
? parseInt(contentLength, 10)
: undefined;
reportStreamProgress(
assetStream.body,
`Downloading CodeQL CLI ${release.name}`,
totalNumBytes,
progressCallback,
);
await new Promise((resolve, reject) =>
assetStream.body
.pipe(archiveFile)
.on("finish", resolve)
.on("error", reject),
);
await this.bumpDistributionFolderIndex();
void extLogger.log(
`Extracting CodeQL CLI to ${this.getDistributionStoragePath()}`,
);
await extractZipArchive(archivePath, this.getDistributionStoragePath());
} finally {
await remove(tmpDirectory);
}
}
/**
* Remove the extension-managed distribution.
*
* This should not be called for a distribution that is currently in use, as remove may fail.
*/
private async removeDistribution(): Promise<void> {
await this.storeInstalledRelease(undefined);
if (await pathExists(this.getDistributionStoragePath())) {
await remove(this.getDistributionStoragePath());
}
}
private async getLatestRelease(): Promise<Release> {
const requiredAssetName = getRequiredAssetName();
void extLogger.log(
`Searching for latest release including ${requiredAssetName}.`,
);
return this.createReleasesApiConsumer().getLatestRelease(
this.versionRange,
this.config.includePrerelease,
(release) => {
// v2.12.3 was released with a bug that causes the extension to fail
// so we force the extension to ignore it.
if (release.name === "v2.12.3") {
return false;
}
const matchingAssets = release.assets.filter(
(asset) => asset.name === requiredAssetName,
);
if (matchingAssets.length === 0) {
// For example, this could be a release with no platform-specific assets.
void extLogger.log(
`INFO: Ignoring a release with no assets named ${requiredAssetName}`,
);
return false;
}
if (matchingAssets.length > 1) {
void extLogger.log(
`WARNING: Ignoring a release with more than one asset named ${requiredAssetName}`,
);
return false;
}
return true;
},
);
}
private createReleasesApiConsumer(): ReleasesApiConsumer {
const ownerName = this.config.ownerName
? this.config.ownerName
: DEFAULT_DISTRIBUTION_OWNER_NAME;
const repositoryName = this.config.repositoryName
? this.config.repositoryName
: DEFAULT_DISTRIBUTION_REPOSITORY_NAME;
return new ReleasesApiConsumer(
ownerName,
repositoryName,
this.config.personalAccessToken,
);
}
private async bumpDistributionFolderIndex(): Promise<void> {
const index = this.extensionContext.globalState.get(
ExtensionSpecificDistributionManager._currentDistributionFolderIndexStateKey,
0,
);
await this.extensionContext.globalState.update(
ExtensionSpecificDistributionManager._currentDistributionFolderIndexStateKey,
index + 1,
);
}
private getDistributionStoragePath(): string {
// Use an empty string for the initial distribution for backwards compatibility.
const distributionFolderIndex =
this.extensionContext.globalState.get(
ExtensionSpecificDistributionManager._currentDistributionFolderIndexStateKey,
0,
) || "";
return join(
this.extensionContext.globalStorageUri.fsPath,
ExtensionSpecificDistributionManager._currentDistributionFolderBaseName +
distributionFolderIndex,
);
}
private getDistributionRootPath(): string {
return join(
this.getDistributionStoragePath(),
ExtensionSpecificDistributionManager._codeQlExtractedFolderName,
);
}
private getInstalledRelease(): Release | undefined {
return this.extensionContext.globalState.get(
ExtensionSpecificDistributionManager._installedReleaseStateKey,
);
}
private async storeInstalledRelease(
release: Release | undefined,
): Promise<void> {
await this.extensionContext.globalState.update(
ExtensionSpecificDistributionManager._installedReleaseStateKey,
release,
);
}
private static readonly _currentDistributionFolderBaseName = "distribution";
private static readonly _currentDistributionFolderIndexStateKey =
"distributionFolderIndex";
private static readonly _installedReleaseStateKey = "distributionRelease";
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,
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;
}
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 = semver.compare(
semver.parse(b.tag_name)!,
semver.parse(a.tag_name)!,
);
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.
*/
export enum DistributionKind {
CustomPathConfig,
ExtensionManaged,
PathEnvironmentVariable,
}
export interface Distribution {
codeQlPath: string;
kind: DistributionKind;
}
export enum FindDistributionResultKind {
CompatibleDistribution,
UnknownCompatibilityDistribution,
IncompatibleDistribution,
NoDistribution,
}
export type FindDistributionResult =
| CompatibleDistributionResult
| UnknownCompatibilityDistributionResult
| IncompatibleDistributionResult
| NoDistributionResult;
/**
* A result representing a distribution of the CodeQL CLI that may or may not be compatible with
* the extension.
*/
interface DistributionResult {
distribution: Distribution;
kind: FindDistributionResultKind;
}
interface CompatibleDistributionResult extends DistributionResult {
kind: FindDistributionResultKind.CompatibleDistribution;
version: semver.SemVer;
}
interface UnknownCompatibilityDistributionResult extends DistributionResult {
kind: FindDistributionResultKind.UnknownCompatibilityDistribution;
}
interface IncompatibleDistributionResult extends DistributionResult {
kind: FindDistributionResultKind.IncompatibleDistribution;
version: semver.SemVer;
}
interface NoDistributionResult {
kind: FindDistributionResultKind.NoDistribution;
}
export enum DistributionUpdateCheckResultKind {
AlreadyCheckedRecentlyResult,
AlreadyUpToDate,
InvalidLocation,
UpdateAvailable,
}
type DistributionUpdateCheckResult =
| AlreadyCheckedRecentlyResult
| AlreadyUpToDateResult
| InvalidLocationResult
| UpdateAvailableResult;
export interface AlreadyCheckedRecentlyResult {
kind: DistributionUpdateCheckResultKind.AlreadyCheckedRecentlyResult;
}
export interface AlreadyUpToDateResult {
kind: DistributionUpdateCheckResultKind.AlreadyUpToDate;
}
/**
* The distribution could not be installed or updated because it is not managed by the extension.
*/
export interface InvalidLocationResult {
kind: DistributionUpdateCheckResultKind.InvalidLocation;
}
export interface UpdateAvailableResult {
kind: DistributionUpdateCheckResultKind.UpdateAvailable;
updatedRelease: Release;
}
function createAlreadyCheckedRecentlyResult(): AlreadyCheckedRecentlyResult {
return {
kind: DistributionUpdateCheckResultKind.AlreadyCheckedRecentlyResult,
};
}
function createAlreadyUpToDateResult(): AlreadyUpToDateResult {
return {
kind: DistributionUpdateCheckResultKind.AlreadyUpToDate,
};
}
function createInvalidLocationResult(): InvalidLocationResult {
return {
kind: DistributionUpdateCheckResultKind.InvalidLocation,
};
}
function createUpdateAvailableResult(
updatedRelease: Release,
): UpdateAvailableResult {
return {
kind: DistributionUpdateCheckResultKind.UpdateAvailable,
updatedRelease,
};
}
// Exported for testing
export async function getExecutableFromDirectory(
directory: string,
warnWhenNotFound = false,
): Promise<string | undefined> {
const expectedLauncherPath = join(directory, codeQlLauncherName());
const deprecatedLauncherName = deprecatedCodeQlLauncherName();
const alternateExpectedLauncherPath = deprecatedLauncherName
? join(directory, deprecatedLauncherName)
: undefined;
if (await pathExists(expectedLauncherPath)) {
return expectedLauncherPath;
} else if (
alternateExpectedLauncherPath &&
(await pathExists(alternateExpectedLauncherPath))
) {
warnDeprecatedLauncher();
return alternateExpectedLauncherPath;
}
if (warnWhenNotFound) {
void extLogger.log(
`WARNING: Expected to find a CodeQL CLI executable at ${expectedLauncherPath} but one was not found. ` +
"Will try PATH.",
);
}
return undefined;
}
function warnDeprecatedLauncher() {
void showAndLogWarningMessage(
`The "${deprecatedCodeQlLauncherName()!}" launcher has been deprecated and will be removed in a future version. ` +
`Please use "${codeQlLauncherName()}" instead. It is recommended to update to the latest CodeQL binaries.`,
);
}
/**
* A release on GitHub.
*/
export 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.
*/
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;
}
/**
* 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);
}
}