From 1a9e1f14d79ca7b2be9242f351b2a8b1fe9c457f Mon Sep 17 00:00:00 2001 From: Kir_Antipov Date: Wed, 3 May 2023 14:01:22 +0000 Subject: [PATCH] Made base class for uploaders --- src/platforms/generic-platform-uploader.ts | 197 +++++++++++++++++++++ src/publishing/mod-publisher.ts | 134 -------------- 2 files changed, 197 insertions(+), 134 deletions(-) create mode 100644 src/platforms/generic-platform-uploader.ts delete mode 100644 src/publishing/mod-publisher.ts diff --git a/src/platforms/generic-platform-uploader.ts b/src/platforms/generic-platform-uploader.ts new file mode 100644 index 0000000..e5ee383 --- /dev/null +++ b/src/platforms/generic-platform-uploader.ts @@ -0,0 +1,197 @@ +import { Dependency, DependencyType } from "@/dependencies"; +import { retry } from "@/utils/async-utils"; +import { ArgumentNullError, isSoftError } from "@/utils/errors"; +import { FileInfo } from "@/utils/io"; +import { JavaVersion } from "@/utils/java"; +import { Logger, LoggingStopwatch, NULL_LOGGER } from "@/utils/logging"; +import { SecureString } from "@/utils/security"; +import { VersionType } from "@/utils/versioning"; +import { CurseForgeUploaderOptions } from "./curseforge/curseforge-uploader"; +import { GitHubUploaderOptions } from "./github/github-uploader"; +import { ModrinthUploaderOptions } from "./modrinth/modrinth-uploader"; +import { PlatformType } from "./platform-type"; +import { PlatformUploader } from "./platform-uploader"; + +/** + * Options for configuring a generic platform uploader. + */ +export interface GenericPlatformUploaderOptions { + /** + * An optional logger that can be used for recording log messages. + */ + logger?: Logger; +} + +/** + * Represents all known options for a generic platform uploader. + */ +export type KnownPlatformUploaderOptions = + & ModrinthUploaderOptions + & GitHubUploaderOptions + & CurseForgeUploaderOptions; + +/** + * Represents a request for uploading to a platform. + */ +export interface GenericPlatformUploadRequest { + /** + * The unique identifier of the project on the target platform. + */ + id?: string; + + /** + * A secure token used for authenticating with the platform's API. + */ + token?: SecureString; + + /** + * An array of files to be uploaded to the platform. + */ + files?: FileInfo[]; + + /** + * The name for the new version of the project to be uploaded. + */ + name?: string; + + /** + * The new version identifier for the project. + */ + version?: string; + + /** + * The specified type of the version (e.g., 'release', 'beta', 'alpha'). + */ + versionType?: VersionType; + + /** + * The changelog detailing the updates in the new version. + */ + changelog?: string; + + /** + * An array of loaders that the project is compatible with. + */ + loaders?: string[]; + + /** + * An array of game versions that the project is compatible with. + */ + gameVersions?: string[]; + + /** + * An array of dependencies required by this version of the project. + */ + dependencies?: Dependency[]; + + /** + * An array of Java versions that the project supports. + */ + java?: JavaVersion[]; + + /** + * The maximum number of attempts to publish assets to the platform. + */ + retryAttempts?: number; + + /** + * Time delay (in milliseconds) between each attempt to publish assets. + */ + retryDelay?: number; +} + +/** + * The default number of retry attempts for a failed upload. + */ +const DEFAULT_RETRY_ATTEMPTS = 2; + +/** + * The default delay time (in milliseconds) between retry attempts for a failed upload. + */ +const DEFAULT_RETRY_DELAY = 1000; + +/** + * Base class for platform uploaders. + * + * @template TOptions - The type of options that the uploader can utilize. + * @template TRequest - The type of content that can be uploaded using the uploader. + * @template TReport - The type of report that is returned after the upload process. + */ +export abstract class GenericPlatformUploader implements PlatformUploader { + /** + * The logger used by the uploader. + */ + protected readonly _logger: Logger; + + /** + * Constructs a new {@link PlatformUploader} instance. + * + * @param options - The options to use for the uploader. + */ + protected constructor(options?: TOptions) { + this._logger = options?.logger || NULL_LOGGER; + } + + /** + * @inheritdoc + */ + abstract get platform(): PlatformType; + + /** + * @inheritdoc + */ + async upload(request: TRequest): Promise { + ArgumentNullError.throwIfNull(request, "request"); + ArgumentNullError.throwIfNull(request.token, "request.token"); + ArgumentNullError.throwIfNullOrEmpty(request.files, "request.files"); + + const platformName = PlatformType.friendlyNameOf(this.platform); + const maxAttempts = request.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS; + const delay = request.retryDelay ?? DEFAULT_RETRY_DELAY; + + const stopwatch = LoggingStopwatch.startNew(this._logger, + () => `📤 Uploading assets to ${platformName}`, + ms => `✅ Successfully published assets to ${platformName} in ${ms} ms` + ); + const onError = (error: Error) => { + if (isSoftError(error)) { + this._logger.error(error); + this._logger.info(`🔂 Facing difficulties, republishing assets to ${platformName} in ${delay} ms`); + return true; + } + return false; + }; + + const report = await retry( + () => this.uploadCore(request), + { maxAttempts, delay, onError } + ); + + stopwatch.stop(); + return report; + } + + /** + * Processes the specified upload request. + * + * @param request - The request to process. + * + * @returns A report generated after the upload. + */ + protected abstract uploadCore(request: TRequest): Promise; + + /** + * Converts the specified dependencies to a simpler format. + * + * @param dependencies - The list of dependencies to convert. + * @param typeConverter - The function to use for converting dependency types. + * + * @returns An array of dependencies in a simplified format. + */ + protected convertToSimpleDependencies(dependencies: Dependency[], typeConverter: (type: DependencyType) => T): [id: string, type: T][] { + return (dependencies || []) + .filter(x => x && !x.isIgnored(this.platform)) + .map(x => [x.getProjectId(this.platform), typeConverter(x.type)] as [string, T]) + .filter(([id, type]) => id && type); + } +} diff --git a/src/publishing/mod-publisher.ts b/src/publishing/mod-publisher.ts deleted file mode 100644 index b2b895a..0000000 --- a/src/publishing/mod-publisher.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { context } from "@actions/github"; -import { parseVersionName, parseVersionNameFromFileVersion } from "../utils/minecraft"; -import File from "../utils/io/file"; -import Publisher from "./publisher"; -import PublisherTarget from "./publisher-target"; -import MinecraftVersionResolver from "../utils/minecraft/minecraft-version-resolver"; -import ModMetadataReader from "../metadata/mod-metadata-reader"; -import Dependency from "../metadata/dependency"; -import Version from "../utils/versioning/version"; -import VersionType from "../utils/versioning/version-type"; -import DependencyKind from "../metadata/dependency-kind"; -import path from "path"; - -interface ModPublisherOptions { - id: string; - token: string; - versionType?: "alpha" | "beta" | "release"; - loaders?: string | string[]; - name?: string; - version?: string; - changelog?: string; - changelogFile?: string; - versionResolver?: string; - gameVersions?: string | string[]; - java?: string | string[]; - dependencies?: string | string[]; -} - -function processMultilineInput(input: string | string[], splitter?: RegExp): string[] { - if (!input) { - return []; - } - return (typeof input === "string" ? input.split(splitter || /(\r?\n)+/) : input).map(x => x.trim()).filter(x => x); -} - -function processDependenciesInput(input: string | string[], inputSplitter?: RegExp, entrySplitter?: RegExp): Dependency[] { - return processMultilineInput(input, inputSplitter).map(x => { - const parts = x.split(entrySplitter || /\|/); - const id = parts[0].trim(); - return Dependency.create({ - id, - kind: parts[1] && DependencyKind.parse(parts[1].trim()) || DependencyKind.Depends, - version: parts[2]?.trim() || "*" - }); - }); -} - -async function readChangelog(changelogPath: string): Promise { - const file = (await File.getFiles(changelogPath))[0]; - if (!file) { - throw new Error("Changelog file was not found"); - } - return (await file.getBuffer()).toString("utf8"); -} - -export default abstract class ModPublisher extends Publisher { - protected get requiresId(): boolean { - return true; - } - - protected get requiresModLoaders(): boolean { - return true; - } - - protected get requiresGameVersions(): boolean { - return true; - } - - public async publish(files: File[], options: ModPublisherOptions): Promise { - this.validateOptions(options); - const releaseInfo = context.payload.release; - - if (!Array.isArray(files) || !files.length) { - throw new Error("No upload files were specified"); - } - - const token = options.token; - if (!token) { - throw new Error(`Token is required to publish your assets to ${PublisherTarget.toString(this.target)}`); - } - - const metadata = await ModMetadataReader.readMetadata(files[0].path); - - const id = options.id || metadata?.getProjectId(this.target); - if (!id && this.requiresId) { - throw new Error(`Project id is required to publish your assets to ${PublisherTarget.toString(this.target)}`); - } - - const filename = path.parse(files[0].path).name; - const version = (typeof options.version === "string" && options.version) || releaseInfo?.tag_name || metadata?.version || Version.fromName(filename); - const versionType = options.versionType?.toLowerCase() || VersionType.fromName(metadata?.version || filename); - const name = typeof options.name === "string" ? options.name : (releaseInfo?.name || version); - const changelog = typeof options.changelog === "string" - ? options.changelog - : typeof options.changelogFile === "string" - ? await readChangelog(options.changelogFile) - : releaseInfo?.body || ""; - - const loaders = processMultilineInput(options.loaders, /\s+/); - if (!loaders.length && this.requiresModLoaders) { - if (metadata) { - loaders.push(...metadata.loaders); - } - if (!loaders.length) { - throw new Error("At least one mod loader should be specified"); - } - } - - const gameVersions = processMultilineInput(options.gameVersions); - if (!gameVersions.length && this.requiresGameVersions) { - const minecraftVersion = - metadata?.dependencies.filter(x => x.id === "minecraft").map(x => parseVersionName(x.version))[0] || - parseVersionNameFromFileVersion(version); - - if (minecraftVersion) { - const resolver = options.versionResolver && MinecraftVersionResolver.byName(options.versionResolver) || MinecraftVersionResolver.releasesIfAny; - gameVersions.push(...(await resolver.resolve(minecraftVersion)).map(x => x.id)); - } - if (!gameVersions.length) { - throw new Error("At least one game version should be specified"); - } - } - - const java = processMultilineInput(options.java); - const dependencies = typeof options.dependencies === "string" - ? processDependenciesInput(options.dependencies) - : metadata?.dependencies || []; - const uniqueDependencies = dependencies.filter((x, i, self) => !x.ignore && self.findIndex(y => y.id === x.id && y.kind === x.kind) === i); - - await this.publishMod(id, token, name, version, versionType, loaders, gameVersions, java, changelog, files, uniqueDependencies, >options); - } - - protected abstract publishMod(id: string, token: string, name: string, version: string, versionType: string, loaders: string[], gameVersions: string[], java: string[], changelog: string, files: File[], dependencies: Dependency[], options: Record): Promise; -} \ No newline at end of file