feat: add releases widget (#2497)
Co-authored-by: Andre Silva <asilva01@acuitysso.com> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com> Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
This commit is contained in:
306
packages/request-handler/src/releases-providers.ts
Normal file
306
packages/request-handler/src/releases-providers.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
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,
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
createdAt: resp.date_registered,
|
||||
starsCount: resp.star_count,
|
||||
openIssues: 0,
|
||||
forksCount: 0,
|
||||
}))
|
||||
.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.map((release) => ({
|
||||
...release,
|
||||
releaseUrl: "",
|
||||
releaseDescription: "",
|
||||
isPreRelease: false,
|
||||
}));
|
||||
})
|
||||
.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(),
|
||||
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,
|
||||
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(),
|
||||
prerelease: z.boolean(),
|
||||
})
|
||||
.transform((tag) => ({
|
||||
identifier: "",
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.html_url,
|
||||
releaseDescription: tag.body,
|
||||
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() }).nullable(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
star_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
})
|
||||
.transform((resp) => ({
|
||||
projectUrl: resp.web_url,
|
||||
projectDescription: resp.description,
|
||||
isFork: resp.forked_from_project !== null,
|
||||
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 ?? "",
|
||||
isPreRelease: false,
|
||||
}));
|
||||
})
|
||||
.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(),
|
||||
projectDescription: z.string(),
|
||||
isFork: z.boolean(),
|
||||
isArchived: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
starsCount: z.number(),
|
||||
openIssues: z.number(),
|
||||
forksCount: z.number(),
|
||||
});
|
||||
|
||||
const _releasesSchema = z.object({
|
||||
latestRelease: z.string(),
|
||||
latestReleaseAt: z.date(),
|
||||
releaseUrl: z.string(),
|
||||
releaseDescription: z.string(),
|
||||
isPreRelease: z.boolean(),
|
||||
});
|
||||
|
||||
export type DetailsResponse = z.infer<typeof _detailsSchema>;
|
||||
|
||||
export type ReleasesResponse = z.infer<typeof _releasesSchema>;
|
||||
105
packages/request-handler/src/releases.ts
Normal file
105
packages/request-handler/src/releases.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fetchWithTimeout } from "@homarr/common";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
import { Providers } from "./releases-providers";
|
||||
import type { DetailsResponse } from "./releases-providers";
|
||||
|
||||
const _reponseSchema = z.object({
|
||||
identifier: z.string(),
|
||||
providerKey: z.string(),
|
||||
latestRelease: z.string(),
|
||||
latestReleaseAt: z.date(),
|
||||
releaseUrl: z.string(),
|
||||
releaseDescription: z.string(),
|
||||
isPreRelease: z.boolean(),
|
||||
projectUrl: z.string(),
|
||||
projectDescription: z.string(),
|
||||
isFork: z.boolean(),
|
||||
isArchived: z.boolean(),
|
||||
createdAt: z.date(),
|
||||
starsCount: z.number(),
|
||||
openIssues: z.number(),
|
||||
forksCount: z.number(),
|
||||
});
|
||||
|
||||
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 = {
|
||||
projectUrl: "",
|
||||
projectDescription: "",
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(0),
|
||||
starsCount: 0,
|
||||
openIssues: 0,
|
||||
forksCount: 0,
|
||||
};
|
||||
|
||||
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 {
|
||||
logger.warn("Failed to parse details response", {
|
||||
provider: input.providerKey,
|
||||
identifier: input.identifier,
|
||||
detailsUrl,
|
||||
error: parsedDetails?.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier));
|
||||
const releasesResult = provider.parseReleasesResponse(await releasesResponse.json());
|
||||
|
||||
if (!releasesResult.success) return undefined;
|
||||
|
||||
const latest: ResponseResponse = releasesResult.data
|
||||
.filter((result) => (input.versionRegex ? new RegExp(input.versionRegex).test(result.latestRelease) : true))
|
||||
.reduce(
|
||||
(latest, result) => {
|
||||
return {
|
||||
...detailsResult,
|
||||
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
|
||||
identifier: input.identifier,
|
||||
providerKey: input.providerKey,
|
||||
};
|
||||
},
|
||||
{
|
||||
identifier: "",
|
||||
providerKey: "",
|
||||
latestRelease: "",
|
||||
latestReleaseAt: new Date(0),
|
||||
releaseUrl: "",
|
||||
releaseDescription: "",
|
||||
isPreRelease: false,
|
||||
projectUrl: "",
|
||||
projectDescription: "",
|
||||
isFork: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(0),
|
||||
starsCount: 0,
|
||||
openIssues: 0,
|
||||
forksCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
return latest;
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "minutes"),
|
||||
});
|
||||
|
||||
export type ResponseResponse = z.infer<typeof _reponseSchema>;
|
||||
Reference in New Issue
Block a user