920 lines
25 KiB
TypeScript
920 lines
25 KiB
TypeScript
import { sha1 } from "hash-wasm";
|
|
import { FileDef } from "#types/fileDef.ts";
|
|
import fs from "fs";
|
|
import buildConfig from "#buildConfig";
|
|
import upath from "upath";
|
|
import { compareBufferToHashDef } from "./hashes.ts";
|
|
import { execSync } from "child_process";
|
|
import {
|
|
ExternalDependency,
|
|
ModpackManifest,
|
|
ModpackManifestFile,
|
|
} from "#types/modpackManifest.ts";
|
|
import {
|
|
fetchFileInfo,
|
|
fetchProject,
|
|
fetchProjectsBulk,
|
|
} from "./curseForgeAPI.ts";
|
|
import { VersionManifest } from "#types/versionManifest.ts";
|
|
import { VersionsManifest } from "#types/versionsManifest.ts";
|
|
import { pathspec, SimpleGit, simpleGit } from "simple-git";
|
|
import { Commit, ModChangeInfo } from "#types/changelogTypes.ts";
|
|
import { modpackManifest, repoName, repoOwner, rootDirectory } from "#globals";
|
|
import { Octokit } from "@octokit/rest";
|
|
import logInfo, { logError, logWarn } from "./log.ts";
|
|
import lodash from "lodash";
|
|
import axios, {
|
|
AxiosError,
|
|
AxiosInstance,
|
|
AxiosRequestConfig,
|
|
AxiosResponse,
|
|
AxiosStatic,
|
|
} from "axios";
|
|
import axiosRetry, {
|
|
DEFAULT_OPTIONS,
|
|
IAxiosRetryConfig,
|
|
IAxiosRetryConfigExtended,
|
|
namespace,
|
|
} from "axios-retry";
|
|
import stream from "node:stream";
|
|
import { NomiConfig } from "#types/axios.ts";
|
|
import { BuildData } from "#types/transformFiles.js";
|
|
import { retry } from "@octokit/plugin-retry";
|
|
import { throttling } from "@octokit/plugin-throttling";
|
|
|
|
const LIBRARY_REG = /^(.+?):(.+?):(.+?)$/;
|
|
|
|
// Make git commands run in root dir
|
|
export const git: SimpleGit = simpleGit(rootDirectory);
|
|
|
|
const RetryOctokit = Octokit.plugin(retry, throttling);
|
|
let shouldTryGetInfo = true;
|
|
export const octokit = new RetryOctokit({
|
|
auth: process.env.GITHUB_TOKEN,
|
|
throttle: {
|
|
onRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
logError(
|
|
`Request Quota Exhausted for Request ${options.method} ${options.url}!`,
|
|
);
|
|
|
|
if (retryCount < buildConfig.downloaderMaxRetries) {
|
|
if (retryAfter < buildConfig.changelogRequestRetryMaxSeconds) {
|
|
logInfo(`Retrying after ${retryAfter} seconds.`);
|
|
return true;
|
|
}
|
|
logError(
|
|
`Reset Time of ${retryAfter} Seconds is Too Long! Not Retrying!`,
|
|
);
|
|
shouldTryGetInfo = false;
|
|
return false;
|
|
}
|
|
},
|
|
onSecondaryRateLimit: (retryAfter, options, _octokit, retryCount) => {
|
|
logError(
|
|
`Secondary Rate Limit Hit for Request ${options.method} ${options.url}!`,
|
|
);
|
|
|
|
if (retryCount < buildConfig.downloaderMaxRetries) {
|
|
if (retryAfter < buildConfig.changelogRequestRetryMaxSeconds) {
|
|
logInfo(`Retrying after ${retryAfter} seconds.`);
|
|
return true;
|
|
}
|
|
logError(
|
|
`Reset Time of ${retryAfter} Seconds is Too Long! Not Retrying!`,
|
|
);
|
|
shouldTryGetInfo = false;
|
|
return false;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
const retryCfg: IAxiosRetryConfig = {
|
|
retries: 10,
|
|
retryDelay: (count) => count * 100,
|
|
onRetry: (count, error, cfg) =>
|
|
logWarn(
|
|
`Retrying HTTP Request of URL ${cfg.url} in 100ms. (${error.message}) (${count} Times)`,
|
|
),
|
|
onMaxRetryTimesExceeded: (error) => {
|
|
throw error;
|
|
},
|
|
};
|
|
|
|
axiosRetry(axios, retryCfg);
|
|
|
|
const fileDownloader = axios.create();
|
|
axiosRetry(fileDownloader, retryCfg);
|
|
fileDownloader.interceptors.response.use(async (response) => {
|
|
if (response.status < 200 || response.status > 299) return response; // Error, Probably Handled by Axios Retry
|
|
if (!response.data || !(response.data instanceof stream.Stream))
|
|
return retryOrThrow(response, "No Response Error");
|
|
|
|
const buf: Uint8Array[] = [];
|
|
const dataStream = response.data as stream.Stream;
|
|
const buffer = await new Promise<Buffer>((resolve) => {
|
|
dataStream.on("data", (chunk) => buf.push(chunk));
|
|
dataStream.on("end", () => {
|
|
resolve(Buffer.concat(buf));
|
|
});
|
|
});
|
|
|
|
const nomiCfg = response.config.nomiCfg as Required<NomiConfig>;
|
|
const url = response.config.url ?? nomiCfg.fileDef.url;
|
|
|
|
// If Buffer Does not Match, Retry
|
|
if (!buffer)
|
|
throw new Error(`Failed to Download File from ${url}, no Buffer Returned!`);
|
|
if (nomiCfg.fileDef.hashes) {
|
|
let success = true;
|
|
for (const hash of nomiCfg.fileDef.hashes) {
|
|
if (await compareBufferToHashDef(buffer, hash)) continue;
|
|
success = false;
|
|
break;
|
|
}
|
|
if (!success)
|
|
return retryOrThrow(
|
|
response,
|
|
`Failed to Download File from ${url}, File Checksum Checking Failed!`,
|
|
);
|
|
}
|
|
|
|
response.data = buffer;
|
|
return response;
|
|
}, null);
|
|
|
|
export function getAxios(): AxiosStatic {
|
|
return axios;
|
|
}
|
|
|
|
/**
|
|
* Parses the library name into path following the standard package naming convention.
|
|
*
|
|
* Turns `package:name:version` into `package/name/version/name-version`.
|
|
*/
|
|
export const libraryToPath = (library: string): string => {
|
|
const parsedLibrary = LIBRARY_REG.exec(library);
|
|
if (parsedLibrary) {
|
|
const pkg = parsedLibrary[1].replace(/\./g, "/");
|
|
const name = parsedLibrary[2];
|
|
const version = parsedLibrary[3];
|
|
|
|
return `${pkg}/${name}/${version}/${name}-${version}`;
|
|
}
|
|
return "";
|
|
};
|
|
|
|
/**
|
|
* Checks if given environmental variables are set. Throws otherwise.
|
|
*/
|
|
export const checkEnvironmentalVariables = (vars: string[]): void => {
|
|
vars.forEach((vari) => {
|
|
if (!isEnvVariableSet(vari)) {
|
|
throw new Error(`Environmental variable ${vari} is unset.`);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns true if given variable set, false otherwise.
|
|
*/
|
|
export const isEnvVariableSet = (env: string): boolean => {
|
|
return Boolean(process.env[env]) && process.env[env] != "";
|
|
};
|
|
|
|
/**
|
|
* Check if given git tag exists. Throws otherwise.
|
|
*/
|
|
export const checkGitTag = (tag: string): void => {
|
|
// The below command returns an empty buffer if the given tag does not exist.
|
|
const tagBuffer = execSync(`git tag --list ${tag}`);
|
|
|
|
if (!tagBuffer || tagBuffer.toString().trim() != tag) {
|
|
throw new Error(`Tag ${tag} could not be found.`);
|
|
}
|
|
};
|
|
|
|
export enum RetrievedFileDefReason {
|
|
Downloaded,
|
|
CacheHit,
|
|
}
|
|
|
|
export interface RetrievedFileDef {
|
|
reason: RetrievedFileDefReason;
|
|
cachePath: string;
|
|
}
|
|
|
|
/**
|
|
* Downloads/fetches files from the Interwebs.
|
|
*
|
|
* Internally hashes the URL of the provided FileDef and looks it up in the cache directory.
|
|
* In case of no cache hit, downloads the file and stores within the cache directory for later use.
|
|
* <p>
|
|
* @param fileDef The file def to download or retrieve.
|
|
*/
|
|
export async function downloadOrRetrieveFileDef(
|
|
fileDef: FileDef,
|
|
): Promise<RetrievedFileDef> {
|
|
const fileNameSha = await sha1(fileDef.url);
|
|
|
|
const cachedFilePath = upath.join(
|
|
buildConfig.downloaderCacheDirectory,
|
|
fileNameSha,
|
|
);
|
|
if (fs.existsSync(cachedFilePath)) {
|
|
const file = await fs.promises.readFile(cachedFilePath);
|
|
|
|
if (file.length !== 0) {
|
|
const rFileDef = {
|
|
reason: RetrievedFileDefReason.CacheHit,
|
|
cachePath: cachedFilePath,
|
|
};
|
|
|
|
// Check hashes.
|
|
if (fileDef.hashes) {
|
|
let success = true;
|
|
for (const hash of fileDef.hashes) {
|
|
if (await compareBufferToHashDef(file, hash)) continue;
|
|
success = false;
|
|
break;
|
|
}
|
|
if (success) return rFileDef;
|
|
} else {
|
|
return rFileDef;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!fs.existsSync(buildConfig.downloaderCacheDirectory)) {
|
|
await fs.promises.mkdir(buildConfig.downloaderCacheDirectory, {
|
|
recursive: true,
|
|
});
|
|
}
|
|
|
|
let handle: fs.promises.FileHandle | undefined = undefined;
|
|
try {
|
|
handle = await fs.promises.open(cachedFilePath, "w");
|
|
|
|
await handle.write(await downloadFileDef(fileDef));
|
|
await handle.close();
|
|
|
|
return {
|
|
reason: RetrievedFileDefReason.Downloaded,
|
|
cachePath: cachedFilePath,
|
|
};
|
|
} catch (err) {
|
|
// noinspection PointlessBooleanExpressionJS,JSObjectNullOrUndefined
|
|
if (handle && (await handle.stat()).isFile()) {
|
|
logInfo(
|
|
`Couldn't download ${upath.basename(fileDef.url)}, cleaning up ${fileNameSha}...`,
|
|
);
|
|
|
|
await handle.close();
|
|
await fs.promises.unlink(cachedFilePath);
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Similar to downloadOrRetrieveFileDef, but does not check cache.
|
|
*/
|
|
export async function downloadFileDef(fileDef: FileDef): Promise<Buffer> {
|
|
const response = await fileDownloader({
|
|
method: "get",
|
|
responseType: "stream",
|
|
url: fileDef.url,
|
|
nomiCfg: {
|
|
fileDef: fileDef,
|
|
},
|
|
});
|
|
|
|
return response.data as Buffer;
|
|
}
|
|
|
|
function fixConfig(
|
|
axiosInstance: AxiosInstance | AxiosStatic,
|
|
config: AxiosRequestConfig,
|
|
) {
|
|
// @ts-expect-error agent non-existent in type declaration`
|
|
if (axiosInstance.defaults.agent === config.agent) {
|
|
// @ts-expect-error agent non-existent in type declaration
|
|
delete config.agent;
|
|
}
|
|
if (axiosInstance.defaults.httpAgent === config.httpAgent) {
|
|
delete config.httpAgent;
|
|
}
|
|
if (axiosInstance.defaults.httpsAgent === config.httpsAgent) {
|
|
delete config.httpsAgent;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Use Axios Retry API to retry if Hash Failed or No Response.
|
|
*/
|
|
function retryOrThrow(
|
|
response: AxiosResponse<unknown, unknown>,
|
|
error: string,
|
|
) {
|
|
const currentState = {
|
|
...DEFAULT_OPTIONS,
|
|
...retryCfg,
|
|
...response.config[namespace],
|
|
};
|
|
const config = { ...response.config };
|
|
|
|
currentState.retryCount = currentState.retryCount || 0;
|
|
currentState.lastRequestTime = currentState.lastRequestTime || Date.now();
|
|
config[namespace] = currentState;
|
|
|
|
const axiosError = new AxiosError<unknown, unknown>();
|
|
|
|
const retryState = currentState as Required<IAxiosRetryConfigExtended>;
|
|
if (retryState.retryCount < retryState.retries) {
|
|
retryState.retryCount++;
|
|
const delay = retryState.retryDelay(retryState.retryCount, axiosError);
|
|
fixConfig(fileDownloader, config);
|
|
if (
|
|
!retryState.shouldResetTimeout &&
|
|
config.timeout &&
|
|
currentState.lastRequestTime
|
|
) {
|
|
const lastRequestDuration = Date.now() - currentState.lastRequestTime;
|
|
const timeout = config.timeout - lastRequestDuration - delay;
|
|
if (timeout <= 0) throw new Error(error);
|
|
config.timeout = timeout;
|
|
}
|
|
config.transformRequest = [(data) => data];
|
|
axiosError.message = error;
|
|
retryState.onRetry(retryState.retryCount, axiosError, config);
|
|
|
|
return new Promise<AxiosResponse<unknown, unknown>>((resolve) => {
|
|
setTimeout(() => resolve(fileDownloader(config)), delay);
|
|
});
|
|
}
|
|
|
|
throw new Error(error);
|
|
}
|
|
|
|
/**
|
|
* Returns artifact name body depending on environment variables.
|
|
* Mostly intended to be called by CI/CD.
|
|
*/
|
|
export function makeArtifactNameBody(baseName: string): string {
|
|
return `${baseName}-${new BuildData().rawVersion}`;
|
|
}
|
|
|
|
/**
|
|
* Returns and fetches the last tag known to Git using the current branch.
|
|
* @param before Tag to get the tag before.
|
|
* @returns string Git tag.
|
|
* @throws
|
|
*/
|
|
export function getLastGitTag(before?: string): string {
|
|
if (before) {
|
|
before = `"${before}^"`;
|
|
}
|
|
|
|
return execSync(`git describe --abbrev=0 --tags ${before || ""}`)
|
|
.toString()
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Generates a changelog based on the two provided Git refs.
|
|
* @param since Lower boundary Git ref.
|
|
* @param to Upper boundary Git ref.
|
|
* @param dirs Optional scopes. These are of the perspective of the root dir.
|
|
* @returns changelog Object Array of Changelog
|
|
*/
|
|
export async function getChangelog(
|
|
since = "HEAD",
|
|
to = "HEAD",
|
|
dirs: string[] | undefined = undefined,
|
|
): Promise<Commit[]> {
|
|
const options: string[] = ["--no-merges", `${since}..${to}`];
|
|
if (dirs) {
|
|
dirs.forEach((dir) => {
|
|
options.push(pathspec(dir));
|
|
});
|
|
}
|
|
|
|
const commitList: Commit[] = [];
|
|
await git.log(options, (err, output) => {
|
|
if (err) {
|
|
logError(err.toString());
|
|
throw new Error();
|
|
}
|
|
|
|
// Cannot simply set commitList as output.all as is read only, must do this
|
|
output.all.forEach((commit) => commitList.push(commit));
|
|
});
|
|
|
|
return commitList;
|
|
}
|
|
|
|
/**
|
|
* Gets the list of tags that are at or before a certain ref point.
|
|
* @param ref The ref point. Can be a tag or a commit sha. If not set, defaults to HEAD.
|
|
* @returns tags An array of all the tags
|
|
*/
|
|
export async function getTags(ref = "HEAD"): Promise<string[]> {
|
|
const options: string[] = ["--merged", ref];
|
|
const test = await git.tags(options);
|
|
return test.all;
|
|
}
|
|
|
|
/**
|
|
* Gets the file at a certain point in time.
|
|
* @param path The path to the file
|
|
* @param revision The git ref point. Can also be a commit SHA.
|
|
*/
|
|
export async function getFileAtRevision(
|
|
path: string,
|
|
revision = "HEAD",
|
|
): Promise<string> {
|
|
let output: string = "";
|
|
await git.show(`${revision}:./${path}`, (err, file) => {
|
|
if (err) {
|
|
logError(err.toString());
|
|
throw new Error();
|
|
}
|
|
output = file.trim();
|
|
});
|
|
return output;
|
|
}
|
|
|
|
export interface ManifestFileListComparisonResult {
|
|
removed: ModChangeInfo[];
|
|
modified: ModChangeInfo[];
|
|
added: ModChangeInfo[];
|
|
}
|
|
|
|
async function getModName(projectID: number): Promise<string> {
|
|
return fetchProject(projectID).then((info) => info.name);
|
|
}
|
|
|
|
async function getFileName(projectID: number, fileID: number): Promise<string> {
|
|
return fetchFileInfo(projectID, fileID).then((info) => info.displayName);
|
|
}
|
|
|
|
export async function compareAndExpandManifestDependencies(
|
|
oldFiles: ModpackManifest,
|
|
newFiles: ModpackManifest,
|
|
): Promise<ManifestFileListComparisonResult> {
|
|
// Map inputs for efficient joining.
|
|
const oldFileMap: { [key: number]: ModpackManifestFile } =
|
|
oldFiles.files.reduce((map: Record<number, ModpackManifestFile>, file) => {
|
|
map[file.projectID] = file;
|
|
return map;
|
|
}, {});
|
|
const newFileMap: { [key: number]: ModpackManifestFile } =
|
|
newFiles.files.reduce((map: Record<number, ModpackManifestFile>, file) => {
|
|
map[file.projectID] = file;
|
|
return map;
|
|
}, {});
|
|
|
|
const removed: ModChangeInfo[] = [],
|
|
modified: ModChangeInfo[] = [],
|
|
added: ModChangeInfo[] = [];
|
|
|
|
// Create a distinct map of project IDs.
|
|
const projectIDs = Array.from(
|
|
new Set([
|
|
...oldFiles.files.map((f) => f.projectID),
|
|
...newFiles.files.map((f) => f.projectID),
|
|
]),
|
|
);
|
|
|
|
// Fetch projects in bulk and discard the result.
|
|
// Future calls to fetchProject() and fetchProjectsBulk() will hit the cache.
|
|
await fetchProjectsBulk(projectIDs);
|
|
|
|
const toFetch: Promise<void>[] = [];
|
|
for (const projectID of projectIDs) {
|
|
const oldFileInfo = oldFileMap[projectID];
|
|
const newFileInfo = newFileMap[projectID];
|
|
|
|
// Doesn't exist in new, but exists in old. Removed. Left outer join.
|
|
if (!newFileInfo && oldFileInfo) {
|
|
const names = Promise.all([
|
|
getModName(projectID),
|
|
getFileName(projectID, oldFileInfo.fileID),
|
|
]);
|
|
toFetch.push(
|
|
names.then(([mod, file]) => {
|
|
removed.push({
|
|
modName: mod,
|
|
projectID: projectID,
|
|
oldVersion: file,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
// Doesn't exist in old, but exists in new. Added. Right outer join.
|
|
else if (newFileInfo && !oldFileInfo) {
|
|
const names = Promise.all([
|
|
getModName(projectID),
|
|
getFileName(projectID, newFileInfo.fileID),
|
|
]);
|
|
toFetch.push(
|
|
names.then(([mod, file]) => {
|
|
added.push({
|
|
modName: mod,
|
|
projectID: projectID,
|
|
newVersion: file,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
// Exists in both. Modified? Inner join.
|
|
else if (oldFileInfo.fileID != newFileInfo.fileID) {
|
|
const names = Promise.all([
|
|
getModName(projectID),
|
|
getFileName(projectID, oldFileInfo.fileID),
|
|
getFileName(projectID, newFileInfo.fileID),
|
|
]);
|
|
toFetch.push(
|
|
names.then(([mod, oldFile, newFile]) => {
|
|
modified.push({
|
|
modName: mod,
|
|
projectID: projectID,
|
|
oldVersion: oldFile,
|
|
newVersion: newFile,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
// Fetch All Modifications Async
|
|
await Promise.all(toFetch);
|
|
|
|
// Compare external dependencies the same way.
|
|
const oldExternalMap: { [key: string]: ExternalDependency } = (
|
|
oldFiles.externalDependencies || []
|
|
).reduce((map: Record<string, ExternalDependency>, file) => {
|
|
map[file.name] = file;
|
|
return map;
|
|
}, {});
|
|
const newExternalMap: { [key: string]: ExternalDependency } = (
|
|
newFiles.externalDependencies || []
|
|
).reduce((map: Record<string, ExternalDependency>, file) => {
|
|
map[file.name] = file;
|
|
return map;
|
|
}, {});
|
|
|
|
const externalNames = Array.from(
|
|
new Set([
|
|
...(oldFiles.externalDependencies || []).map((dep) => dep.name),
|
|
...(newFiles.externalDependencies || []).map((dep) => dep.name),
|
|
]),
|
|
);
|
|
|
|
externalNames.forEach((name) => {
|
|
const oldDep = oldExternalMap[name];
|
|
const newDep = newExternalMap[name];
|
|
|
|
// Doesn't exist in new, but exists in old. Removed. Left outer join.
|
|
if (!newDep && oldDep) {
|
|
removed.push({ modName: oldDep.name });
|
|
}
|
|
// Doesn't exist in old, but exists in new. Added. Right outer join.
|
|
else if (newDep && !oldDep) {
|
|
added.push({ modName: newDep.name });
|
|
}
|
|
// Exists in both. Modified? Inner join.
|
|
else if (oldDep.url != newDep.url || oldDep.name != newDep.name) {
|
|
modified.push({ modName: newDep.name });
|
|
}
|
|
});
|
|
|
|
return {
|
|
removed: removed,
|
|
modified: modified,
|
|
added: added,
|
|
};
|
|
}
|
|
|
|
const LAUNCHERMETA_VERSION_MANIFEST =
|
|
"https://launchermeta.mojang.com/mc/game/version_manifest.json";
|
|
|
|
/**
|
|
* Fetches the version manifest associated with the provided Minecraft version.
|
|
*
|
|
* @param minecraftVersion Minecraft version. (e. g., "1.12.2")
|
|
*/
|
|
export async function getVersionManifest(
|
|
minecraftVersion: string,
|
|
): Promise<VersionManifest | undefined> {
|
|
/**
|
|
* Fetch the manifest file of all Minecraft versions.
|
|
*/
|
|
const manifest: VersionsManifest = (
|
|
await axios({
|
|
url: LAUNCHERMETA_VERSION_MANIFEST,
|
|
method: "get",
|
|
responseType: "json",
|
|
})
|
|
)?.data;
|
|
|
|
if (!manifest) return undefined;
|
|
|
|
const version = manifest.versions.find((x) => x.id == minecraftVersion);
|
|
if (!version) {
|
|
return;
|
|
}
|
|
|
|
return (
|
|
await axios({
|
|
url: version.url,
|
|
method: "get",
|
|
responseType: "json",
|
|
})
|
|
)?.data;
|
|
}
|
|
|
|
/**
|
|
* Cleans up a file's display name, and returns the version. Works for all tested mods!
|
|
* @param version The filename/version to cleanup.
|
|
*/
|
|
export function cleanupVersion(version?: string): string {
|
|
if (!version) return "";
|
|
|
|
if (!version.replace(/[\d+.?]+/g, "")) return version;
|
|
|
|
version = version.replace(/1\.12\.2|1\.12|\.jar/g, "");
|
|
const list = version.match(/[\d+.?]+/g);
|
|
if (!list) return version;
|
|
|
|
if (list[list.length - 1] == "0") return version;
|
|
|
|
return list[list.length - 1];
|
|
}
|
|
|
|
const issueURLCache: Map<number, string> = new Map<number, string>();
|
|
|
|
/**
|
|
* Gets all closed issue/PR URLs of the repo, sorted by updated, and saves it to the cache.
|
|
*/
|
|
export async function getIssueURLs(): Promise<void> {
|
|
if (issueURLCache.size > 0) return;
|
|
if (!shouldTryGetInfo) {
|
|
logError("Skipping Get Issues because of Rate Limits!");
|
|
return;
|
|
}
|
|
try {
|
|
let page = 1;
|
|
const issues = await octokit.paginate(
|
|
octokit.issues.listForRepo,
|
|
{
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
per_page: 100,
|
|
state: "closed",
|
|
sort: "updated",
|
|
},
|
|
(response, done) => {
|
|
if (page++ >= buildConfig.changelogCacheMaxPages) {
|
|
logInfo(
|
|
`Fetched ${buildConfig.changelogCacheMaxPages} Pages of 100 Issues! Final Issue Fetched: #${response.data.at(-1)?.number ?? 0}`,
|
|
);
|
|
done();
|
|
}
|
|
return response.data;
|
|
},
|
|
);
|
|
issues.forEach((issue) => {
|
|
if (!issueURLCache.has(issue.number))
|
|
issueURLCache.set(issue.number, issue.html_url);
|
|
});
|
|
} catch (e) {
|
|
logError(
|
|
"Failed to get all Issue URLs of Repo. This may be because there are no issues, or because of rate limits.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the specified Issue URL from the cache, or retrieves it.
|
|
*/
|
|
export async function getIssueURL(issueNumber: number): Promise<string> {
|
|
if (issueURLCache.has(issueNumber))
|
|
return issueURLCache.get(issueNumber) ?? "";
|
|
if (!shouldTryGetInfo) return "";
|
|
try {
|
|
// Try to retrieve, might be open
|
|
const issueInfo = await octokit.issues.get({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
issue_number: issueNumber,
|
|
});
|
|
if (issueInfo.status !== 200) {
|
|
logError(
|
|
`Failed to get the Issue/PR Info for Issue/PR #${issueNumber}. Returned Status Code ${issueInfo.status}, expected Status 200.`,
|
|
);
|
|
issueURLCache.set(issueNumber, "");
|
|
return "";
|
|
}
|
|
logInfo(
|
|
`No Issue URL Cache for Issue Number ${issueNumber}. Retrieved Specifically.`,
|
|
);
|
|
issueURLCache.set(issueNumber, issueInfo.data.html_url);
|
|
return issueInfo.data.html_url;
|
|
} catch (e) {
|
|
logError(
|
|
`Failed to get the Issue/PR Info for Issue/PR #${issueNumber}. This may be because this is not a PR or Issue, it was deleted, or because of rate limits.`,
|
|
);
|
|
issueURLCache.set(issueNumber, "");
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Map of Commit SHA -> Formatted Author
|
|
const commitAuthorCache: Map<string, string> = new Map<string, string>();
|
|
|
|
/**
|
|
* Fills the Commit Author Cache.
|
|
*/
|
|
export async function getCommitAuthors(): Promise<void> {
|
|
if (commitAuthorCache.size > 0) return;
|
|
if (!shouldTryGetInfo) {
|
|
logError("Skipping Get Commits because of Rate Limits!");
|
|
return;
|
|
}
|
|
try {
|
|
let page = 1;
|
|
const commits = await octokit.paginate(
|
|
octokit.repos.listCommits,
|
|
{
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
per_page: 100,
|
|
},
|
|
(response, done) => {
|
|
if (page++ >= buildConfig.changelogCacheMaxPages) {
|
|
logInfo(
|
|
`Fetched ${buildConfig.changelogCacheMaxPages} Pages of 100 Commits! Final Commit Fetched: ${response.data.at(-1)?.sha ?? ""}`,
|
|
);
|
|
done();
|
|
}
|
|
return response.data;
|
|
},
|
|
);
|
|
commits.forEach((commit) => {
|
|
if (!commitAuthorCache.has(commit.sha))
|
|
commitAuthorCache.set(commit.sha, commit.author?.login ?? "");
|
|
});
|
|
} catch (e) {
|
|
logError(
|
|
"Failed to get all Commit Authors of Repo. This may be because there are no commits, or because of rate limits.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the Author, in mentionable form (@login), or default (**Display Name**), from a Commit.
|
|
*/
|
|
export async function formatAuthor(commit: Commit) {
|
|
const defaultFormat = `**${commit.author_name}**`;
|
|
|
|
if (commitAuthorCache.has(commit.hash)) {
|
|
const login = commitAuthorCache.get(commit.hash);
|
|
if (login) return `@${login}`;
|
|
return defaultFormat;
|
|
}
|
|
|
|
if (!shouldTryGetInfo) return defaultFormat;
|
|
|
|
try {
|
|
// Try to retrieve, just in case
|
|
const commitInfo = await octokit.repos.getCommit({
|
|
owner: repoOwner,
|
|
repo: repoName,
|
|
ref: commit.hash,
|
|
});
|
|
if (commitInfo.status !== 200) {
|
|
logError(
|
|
`Failed to get the Author Info for Commit ${commit.hash}. Returned Status Code ${commitInfo.status}, expected Status 200.`,
|
|
);
|
|
commitAuthorCache.set(commit.hash, "");
|
|
return defaultFormat;
|
|
}
|
|
if (!commitInfo.data.author?.login) {
|
|
logError(
|
|
`Failed to get the Author Info for Commit ${commit.hash}. Returned Null Data, Author or Login.`,
|
|
);
|
|
commitAuthorCache.set(commit.hash, "");
|
|
return defaultFormat;
|
|
}
|
|
logInfo(
|
|
`No Author Cache for Commit ${commit.hash}. Retrieved Specifically.`,
|
|
);
|
|
return `@${commitInfo.data.author.login}`;
|
|
} catch (e) {
|
|
logError(
|
|
`Failed to get Commit Author for Commit ${commit.hash}. This may be because there are no commits, or because of rate limits.`,
|
|
);
|
|
commitAuthorCache.set(commit.hash, "");
|
|
return defaultFormat;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all Remotes in the Git Repository.
|
|
*/
|
|
export async function listRemotes(): Promise<string[]> {
|
|
return git.getRemotes().then((r) => r.map((re) => re.name));
|
|
}
|
|
|
|
export const FORGE_VERSION_REG = /forge-(.+)/;
|
|
export const FORGE_MAVEN = "https://files.minecraftforge.net/maven/";
|
|
|
|
export async function getForgeJar(): Promise<{
|
|
forgeJar: Buffer;
|
|
forgeInstallerPath: string;
|
|
forgeUniversalPath: string;
|
|
}> {
|
|
const minecraft = modpackManifest.minecraft;
|
|
|
|
/**
|
|
* Break down the Forge version defined in manifest.json.
|
|
*/
|
|
const parsedForgeEntry = FORGE_VERSION_REG.exec(
|
|
(
|
|
minecraft.modLoaders.find((x) => x.id && x.id.indexOf("forge") != -1) ||
|
|
{}
|
|
).id || "",
|
|
);
|
|
|
|
if (!parsedForgeEntry) {
|
|
throw new Error("Malformed Forge version in manifest.json.");
|
|
}
|
|
|
|
/**
|
|
* Transform Forge version into Maven library path.
|
|
*/
|
|
const forgeMavenLibrary = `net.minecraftforge:forge:${minecraft.version}-${parsedForgeEntry[1]}`;
|
|
const forgeInstallerPath =
|
|
libraryToPath(forgeMavenLibrary) + "-installer.jar";
|
|
const forgeUniversalPath = upath.join(
|
|
"maven",
|
|
libraryToPath(forgeMavenLibrary) + ".jar",
|
|
);
|
|
|
|
/**
|
|
* Fetch the Forge installer
|
|
*/
|
|
const forgeJar = await fs.promises.readFile(
|
|
(
|
|
await downloadOrRetrieveFileDef({
|
|
url: FORGE_MAVEN + forgeInstallerPath,
|
|
})
|
|
).cachePath,
|
|
);
|
|
|
|
return { forgeJar, forgeInstallerPath, forgeUniversalPath };
|
|
}
|
|
|
|
export interface ArrayUnique<T> {
|
|
arr1Unique: T[];
|
|
arr2Unique: T[];
|
|
}
|
|
|
|
/**
|
|
* Returns the values unique to each array, via lodash.
|
|
*/
|
|
export function getUniqueToArray<T>(
|
|
arr1: T[],
|
|
arr2: T[],
|
|
mappingFunc?: (val: T) => unknown,
|
|
): ArrayUnique<T> {
|
|
if (mappingFunc)
|
|
return {
|
|
arr1Unique: lodash.differenceBy(arr1, arr2, mappingFunc),
|
|
arr2Unique: lodash.differenceBy(arr2, arr1, mappingFunc),
|
|
};
|
|
return {
|
|
arr1Unique: lodash.difference(arr1, arr2),
|
|
arr2Unique: lodash.difference(arr2, arr1),
|
|
};
|
|
}
|
|
|
|
export function shouldSkipChangelog(): boolean {
|
|
if (!isEnvVariableSet("SKIP_CHANGELOG")) return false;
|
|
|
|
let skip = false;
|
|
try {
|
|
skip = JSON.parse((process.env.SKIP_CHANGELOG ?? "false").toLowerCase());
|
|
} catch (err) {
|
|
throw new Error("Skip Changelog Env Variable set to Invalid Value.");
|
|
}
|
|
|
|
if (skip) logInfo("Skipping Changelogs...");
|
|
return skip;
|
|
}
|
|
|
|
export function promiseStream(stream: NodeJS.ReadWriteStream): Promise<void> {
|
|
return new Promise((resolve) => stream.on("end", resolve));
|
|
}
|