feat(releases-widget): define providers as integrations (#3253)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -1,304 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export interface ReleasesProvider {
|
||||
getDetailsUrl: (identifier: string) => string | undefined;
|
||||
parseDetailsResponse: (response: unknown) => z.SafeParseReturnType<unknown, DetailsResponse> | undefined;
|
||||
getReleasesUrl: (identifier: string) => string;
|
||||
parseReleasesResponse: (response: unknown) => z.SafeParseReturnType<unknown, ReleasesResponse[]>;
|
||||
}
|
||||
|
||||
interface ProvidersProps {
|
||||
[key: string]: ReleasesProvider;
|
||||
DockerHub: ReleasesProvider;
|
||||
Github: ReleasesProvider;
|
||||
Gitlab: ReleasesProvider;
|
||||
Npm: ReleasesProvider;
|
||||
Codeberg: ReleasesProvider;
|
||||
}
|
||||
|
||||
export const Providers: ProvidersProps = {
|
||||
DockerHub: {
|
||||
getDetailsUrl(identifier) {
|
||||
if (identifier.indexOf("/") > 0) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
return `https://hub.docker.com/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`;
|
||||
} else {
|
||||
return `https://hub.docker.com/v2/repositories/library/${encodeURIComponent(identifier)}`;
|
||||
}
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string(),
|
||||
namespace: z.string(),
|
||||
description: z.string(),
|
||||
star_count: z.number(),
|
||||
date_registered: z.string().transform((value) => new Date(value)),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: `https://hub.docker.com/r/${resp.namespace === "library" ? "_" : resp.namespace}/${resp.name}`,
|
||||
projectDescription: resp.description,
|
||||
createdAt: resp.date_registered,
|
||||
starsCount: resp.star_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/tags?page_size=200`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
results: z.array(
|
||||
z
|
||||
.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) })
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.last_updated,
|
||||
})),
|
||||
),
|
||||
})
|
||||
.transform((resp) => {
|
||||
return resp.results;
|
||||
})
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
getDetailsUrl(identifier) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
return `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
html_url: z.string(),
|
||||
description: z.string().nullable(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stargazers_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.html_url,
|
||||
projectDescription: resp.description ?? undefined,
|
||||
isFork: resp.fork,
|
||||
isArchived: resp.archived,
|
||||
createdAt: resp.created_at,
|
||||
starsCount: resp.stargazers_count,
|
||||
openIssues: resp.open_issues_count,
|
||||
forksCount: resp.forks_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/releases`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
html_url: z.string(),
|
||||
body: z.string().nullable(),
|
||||
prerelease: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.html_url,
|
||||
releaseDescription: tag.body ?? undefined,
|
||||
isPreRelease: tag.prerelease,
|
||||
})),
|
||||
)
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Gitlab: {
|
||||
getDetailsUrl(identifier) {
|
||||
return `https://gitlab.com/api/v4/projects/${encodeURIComponent(identifier)}`;
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
web_url: z.string(),
|
||||
description: z.string(),
|
||||
forked_from_project: z.object({ id: z.number() }).optional(),
|
||||
archived: z.boolean().optional(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
star_count: z.number(),
|
||||
open_issues_count: z.number().optional(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.web_url,
|
||||
projectDescription: resp.description,
|
||||
isFork: resp.forked_from_project !== undefined,
|
||||
isArchived: resp.archived,
|
||||
createdAt: resp.created_at,
|
||||
starsCount: resp.star_count,
|
||||
openIssues: resp.open_issues_count,
|
||||
forksCount: resp.forks_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/releases`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
released_at: z.string().transform((value) => new Date(value)),
|
||||
description: z.string(),
|
||||
_links: z.object({ self: z.string() }),
|
||||
upcoming_release: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.released_at,
|
||||
releaseUrl: tag._links.self,
|
||||
releaseDescription: tag.description,
|
||||
isPreRelease: tag.upcoming_release,
|
||||
})),
|
||||
)
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Npm: {
|
||||
getDetailsUrl(_) {
|
||||
return undefined;
|
||||
},
|
||||
parseDetailsResponse(_) {
|
||||
return undefined;
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `https://registry.npmjs.org/${encodeURIComponent(identifier)}`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
time: z.record(z.string().transform((value) => new Date(value))).transform((version) =>
|
||||
Object.entries(version).map(([key, value]) => ({
|
||||
identifier: "",
|
||||
latestRelease: key,
|
||||
latestReleaseAt: value,
|
||||
})),
|
||||
),
|
||||
versions: z.record(z.object({ description: z.string() })),
|
||||
name: z.string(),
|
||||
})
|
||||
.transform((resp) => {
|
||||
return resp.time.map((release) => ({
|
||||
...release,
|
||||
releaseUrl: `https://www.npmjs.com/package/${resp.name}/v/${release.latestRelease}`,
|
||||
releaseDescription: resp.versions[release.latestRelease]?.description ?? "",
|
||||
}));
|
||||
})
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
Codeberg: {
|
||||
getDetailsUrl(identifier) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
return "";
|
||||
}
|
||||
return `https://codeberg.org/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
|
||||
},
|
||||
parseDetailsResponse(response) {
|
||||
return z
|
||||
.object({
|
||||
html_url: z.string(),
|
||||
description: z.string(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stars_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.html_url,
|
||||
projectDescription: resp.description,
|
||||
isFork: resp.fork,
|
||||
isArchived: resp.archived,
|
||||
createdAt: resp.created_at,
|
||||
starsCount: resp.stars_count,
|
||||
openIssues: resp.open_issues_count,
|
||||
forksCount: resp.forks_count,
|
||||
}))
|
||||
.safeParse(response);
|
||||
},
|
||||
getReleasesUrl(identifier) {
|
||||
return `${this.getDetailsUrl(identifier)}/releases`;
|
||||
},
|
||||
parseReleasesResponse(response) {
|
||||
return z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
url: z.string(),
|
||||
body: z.string(),
|
||||
prerelease: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.url,
|
||||
releaseDescription: tag.body,
|
||||
isPreRelease: tag.prerelease,
|
||||
})),
|
||||
)
|
||||
.safeParse(response);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const _detailsSchema = z
|
||||
.object({
|
||||
projectUrl: z.string().optional(),
|
||||
projectDescription: z.string().optional(),
|
||||
isFork: z.boolean().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
createdAt: z.date().optional(),
|
||||
starsCount: z.number().optional(),
|
||||
openIssues: z.number().optional(),
|
||||
forksCount: z.number().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
const _releasesSchema = z.object({
|
||||
latestRelease: z.string(),
|
||||
latestReleaseAt: z.date(),
|
||||
releaseUrl: z.string().optional(),
|
||||
releaseDescription: z.string().optional(),
|
||||
isPreRelease: z.boolean().optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type DetailsResponse = z.infer<typeof _detailsSchema>;
|
||||
|
||||
export type ReleasesResponse = z.infer<typeof _releasesSchema>;
|
||||
@@ -1,122 +1,37 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetchWithTimeout } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { ReleasesResponse } from "@homarr/integrations";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { Providers } from "./releases-providers";
|
||||
import type { DetailsResponse } from "./releases-providers";
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
const errorSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
export const releasesRequestHandler = createCachedIntegrationRequestHandler<
|
||||
ReleasesResponse,
|
||||
IntegrationKindByCategory<"releasesProvider">,
|
||||
{
|
||||
id: string;
|
||||
identifier: string;
|
||||
versionRegex?: string;
|
||||
}
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const response = await integrationInstance.getLatestMatchingReleaseAsync({
|
||||
id: input.id,
|
||||
identifier: input.identifier,
|
||||
versionRegex: input.versionRegex,
|
||||
});
|
||||
|
||||
type ReleasesError = z.infer<typeof errorSchema>;
|
||||
|
||||
const _reponseSchema = z.object({
|
||||
identifier: z.string(),
|
||||
providerKey: z.string(),
|
||||
latestRelease: z.string().optional(),
|
||||
latestReleaseAt: z.date().optional(),
|
||||
releaseUrl: z.string().optional(),
|
||||
releaseDescription: z.string().optional(),
|
||||
isPreRelease: z.boolean().optional(),
|
||||
projectUrl: z.string().optional(),
|
||||
projectDescription: z.string().optional(),
|
||||
isFork: z.boolean().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
createdAt: z.date().optional(),
|
||||
starsCount: z.number().optional(),
|
||||
openIssues: z.number().optional(),
|
||||
forksCount: z.number().optional(),
|
||||
error: errorSchema.optional(),
|
||||
});
|
||||
|
||||
const formatErrorRelease = (identifier: string, providerKey: string, error: ReleasesError) => ({
|
||||
identifier,
|
||||
providerKey,
|
||||
latestRelease: undefined,
|
||||
latestReleaseAt: undefined,
|
||||
releaseUrl: undefined,
|
||||
releaseDescription: undefined,
|
||||
isPreRelease: undefined,
|
||||
projectUrl: undefined,
|
||||
projectDescription: undefined,
|
||||
isFork: undefined,
|
||||
isArchived: undefined,
|
||||
createdAt: undefined,
|
||||
starsCount: undefined,
|
||||
openIssues: undefined,
|
||||
forksCount: undefined,
|
||||
error,
|
||||
});
|
||||
|
||||
export const releasesRequestHandler = createCachedWidgetRequestHandler({
|
||||
queryKey: "releasesApiResult",
|
||||
widgetKind: "releases",
|
||||
async requestAsync(input: { providerKey: string; identifier: string; versionRegex: string | undefined }) {
|
||||
const provider = Providers[input.providerKey];
|
||||
|
||||
if (!provider) return undefined;
|
||||
|
||||
let detailsResult: DetailsResponse;
|
||||
const detailsUrl = provider.getDetailsUrl(input.identifier);
|
||||
if (detailsUrl !== undefined) {
|
||||
const detailsResponse = await fetchWithTimeout(detailsUrl);
|
||||
const parsedDetails = provider.parseDetailsResponse(await detailsResponse.json());
|
||||
|
||||
if (parsedDetails?.success) {
|
||||
detailsResult = parsedDetails.data;
|
||||
} else {
|
||||
detailsResult = undefined;
|
||||
logger.warn(`Failed to parse details response for ${input.identifier} on ${input.providerKey}`, {
|
||||
provider: input.providerKey,
|
||||
identifier: input.identifier,
|
||||
detailsUrl,
|
||||
error: parsedDetails?.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier));
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const releasesResult = provider.parseReleasesResponse(releasesResponseJson);
|
||||
|
||||
if (!releasesResult.success) {
|
||||
return formatErrorRelease(input.identifier, input.providerKey, {
|
||||
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message,
|
||||
});
|
||||
} else {
|
||||
const releases = releasesResult.data.filter((result) =>
|
||||
input.versionRegex && result.latestRelease ? new RegExp(input.versionRegex).test(result.latestRelease) : true,
|
||||
);
|
||||
|
||||
const latest =
|
||||
releases.length === 0
|
||||
? formatErrorRelease(input.identifier, input.providerKey, { code: "noMatchingVersion" })
|
||||
: releases.reduce(
|
||||
(latest, result) => {
|
||||
return {
|
||||
...detailsResult,
|
||||
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
|
||||
identifier: input.identifier,
|
||||
providerKey: input.providerKey,
|
||||
};
|
||||
},
|
||||
{
|
||||
identifier: "",
|
||||
providerKey: "",
|
||||
latestRelease: "",
|
||||
latestReleaseAt: new Date(0),
|
||||
},
|
||||
);
|
||||
|
||||
return latest;
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
integration: {
|
||||
name: integration.name,
|
||||
iconUrl: getIconUrl(integration.kind),
|
||||
},
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
queryKey: "repositoriesReleases",
|
||||
});
|
||||
|
||||
export type ReleaseResponse = z.infer<typeof _reponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user