diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index 1cd1a4f89..3d355b26c 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -40,15 +40,22 @@ export const releasesRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { return await Promise.all( input.repositories.map(async (repository) => { - const innerHandler = releasesRequestHandler.handler(ctx.integration, { - id: repository.id, - identifier: repository.identifier, - versionRegex: formatVersionFilterRegex(repository.versionFilter), - }); + const response = await releasesRequestHandler + .handler(ctx.integration, { + id: repository.id, + identifier: repository.identifier, + versionRegex: formatVersionFilterRegex(repository.versionFilter), + }) + .getCachedOrUpdatedDataAsync({ + forceUpdate: false, + }); - return await innerHandler.getCachedOrUpdatedDataAsync({ - forceUpdate: false, - }); + return { + id: repository.id, + integration: { name: ctx.integration.name, kind: ctx.integration.kind }, + timestamp: response.timestamp, + ...response.data, + }; }), ); }), diff --git a/packages/integrations/src/codeberg/codeberg-integration.ts b/packages/integrations/src/codeberg/codeberg-integration.ts index 2836917a0..ea6543cbb 100644 --- a/packages/integrations/src/codeberg/codeberg-integration.ts +++ b/packages/integrations/src/codeberg/codeberg-integration.ts @@ -11,8 +11,7 @@ import type { ReleasesProviderIntegration } from "../interfaces/releases-provide import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; import type { DetailsProviderResponse, - ReleasesRepository, - ReleasesResponse, + ReleaseResponse, } from "../interfaces/releases-providers/releases-providers-types"; import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas"; @@ -43,22 +42,23 @@ export class CodebergIntegration extends Integration implements ReleasesProvider }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const [owner, name] = repository.identifier.split("/"); + private parseIdentifier(identifier: string) { + const [owner, name] = identifier.split("/"); if (!owner || !name) { localLogger.warn( - `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Codeberg integration`, - { - identifier: repository.identifier, - }, + `Invalid identifier format. Expected 'owner/name', for ${identifier} with Codeberg integration`, + { identifier }, ); - return { - id: repository.id, - error: { code: "invalidIdentifier" }, - }; + return null; } + return { owner, name }; + } - const details = await this.getDetailsAsync(owner, name); + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { + const parsedIdentifier = this.parseIdentifier(identifier); + if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; + + const { owner, name } = parsedIdentifier; const releasesResponse = await this.withHeadersAsync(async (headers) => { return await fetchWithTrustedCertificatesAsync( @@ -66,34 +66,36 @@ export class CodebergIntegration extends Integration implements ReleasesProvider { headers }, ); }); - if (!releasesResponse.ok) { - return { - id: repository.id, - error: { message: releasesResponse.statusText }, - }; + return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; } const releasesResponseJson: unknown = await releasesResponse.json(); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { return { - id: repository.id, + success: false, error: { + code: "unexpected", message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, }, }; - } else { - const formattedReleases = data.map((tag) => ({ - latestRelease: tag.tag_name, - latestReleaseAt: tag.published_at, - releaseUrl: tag.url, - releaseDescription: tag.body, - isPreRelease: tag.prerelease, - })); - return getLatestRelease(formattedReleases, repository, details); } + + const formattedReleases = data.map((tag) => ({ + latestRelease: tag.tag_name, + latestReleaseAt: tag.published_at, + releaseUrl: tag.url, + releaseDescription: tag.body, + isPreRelease: tag.prerelease, + })); + + const latestRelease = getLatestRelease(formattedReleases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + const details = await this.getDetailsAsync(owner, name); + + return { success: true, data: { ...details, ...latestRelease } }; } protected async getDetailsAsync(owner: string, name: string): Promise { diff --git a/packages/integrations/src/docker-hub/docker-hub-integration.ts b/packages/integrations/src/docker-hub/docker-hub-integration.ts index a961ddc38..d0e99c0e6 100644 --- a/packages/integrations/src/docker-hub/docker-hub-integration.ts +++ b/packages/integrations/src/docker-hub/docker-hub-integration.ts @@ -14,8 +14,7 @@ import type { ReleasesProviderIntegration } from "../interfaces/releases-provide import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; import type { DetailsProviderResponse, - ReleasesRepository, - ReleasesResponse, + ReleaseResponse, } from "../interfaces/releases-providers/releases-providers-types"; import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas"; @@ -73,49 +72,61 @@ export class DockerHubIntegration extends Integration implements ReleasesProvide }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const relativeUrl = this.getRelativeUrl(repository.identifier); - if (relativeUrl === "/") { - localLogger.warn( - `Invalid identifier format. Expected 'owner/name' or 'name', for ${repository.identifier} on DockerHub`, - { - identifier: repository.identifier, - }, - ); - return { - id: repository.id, - error: { code: "invalidIdentifier" }, - }; - } - - const details = await this.getDetailsAsync(relativeUrl); - - const releasesResponse = await this.withHeadersAsync(async (headers) => { - return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100`), { - headers, + private parseIdentifier(identifier: string) { + if (!identifier.includes("/")) return { owner: "", name: identifier }; + const [owner, name] = identifier.split("/"); + if (!owner || !name) { + localLogger.warn(`Invalid identifier format. Expected 'owner/name' or 'name', for ${identifier} on DockerHub`, { + identifier, }); - }); + return null; + } + return { owner, name }; + } - if (!releasesResponse.ok) { - return { - id: repository.id, - error: { message: releasesResponse.statusText }, - }; + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { + const parsedIdentifier = this.parseIdentifier(identifier); + if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; + + const { owner, name } = parsedIdentifier; + + const relativeUrl: `/${string}` = owner + ? `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}` + : `/v2/repositories/library/${encodeURIComponent(name)}`; + + for (let page = 0; page <= 5; page++) { + const releasesResponse = await this.withHeadersAsync(async (headers) => { + return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100&page=${page}`), { + headers, + }); + }); + if (!releasesResponse.ok) { + return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; + } + + const releasesResponseJson: unknown = await releasesResponse.json(); + const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson); + if (!releasesResult.success) { + return { + success: false, + error: { + code: "unexpected", + message: releasesResponseJson + ? JSON.stringify(releasesResponseJson, null, 2) + : releasesResult.error.message, + }, + }; + } + + const latestRelease = getLatestRelease(releasesResult.data.results, versionRegex); + if (!latestRelease) continue; + + const details = await this.getDetailsAsync(relativeUrl); + + return { success: true, data: { ...details, ...latestRelease } }; } - const releasesResponseJson: unknown = await releasesResponse.json(); - const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson); - - if (!releasesResult.success) { - return { - id: repository.id, - error: { - message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message, - }, - }; - } else { - return getLatestRelease(releasesResult.data.results, repository, details); - } + return { success: false, error: { code: "noMatchingVersion" } }; } private async getDetailsAsync(relativeUrl: `/${string}`): Promise { @@ -154,18 +165,6 @@ export class DockerHubIntegration extends Integration implements ReleasesProvide }; } - private getRelativeUrl(identifier: string): `/${string}` { - if (identifier.indexOf("/") > 0) { - const [owner, name] = identifier.split("/"); - if (!owner || !name) { - return "/"; - } - return `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`; - } else { - return `/v2/repositories/library/${encodeURIComponent(identifier)}`; - } - } - private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise { const response = await fetchAsync(this.url("/v2/auth/token"), { method: "POST", diff --git a/packages/integrations/src/github-container-registry/github-container-registry-integration.ts b/packages/integrations/src/github-container-registry/github-container-registry-integration.ts index bcb496acd..00a7b27c4 100644 --- a/packages/integrations/src/github-container-registry/github-container-registry-integration.ts +++ b/packages/integrations/src/github-container-registry/github-container-registry-integration.ts @@ -15,8 +15,7 @@ import { getLatestRelease } from "../interfaces/releases-providers/releases-prov import type { DetailsProviderResponse, ReleaseProviderResponse, - ReleasesRepository, - ReleasesResponse, + ReleaseResponse, } from "../interfaces/releases-providers/releases-providers-types"; const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" }); @@ -43,23 +42,24 @@ export class GitHubContainerRegistryIntegration extends Integration implements R }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const [owner, name] = repository.identifier.split("/"); + private parseIdentifier(identifier: string) { + const [owner, name] = identifier.split("/"); if (!owner || !name) { localLogger.warn( - `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with GitHub Container Registry integration`, - { - identifier: repository.identifier, - }, + `Invalid identifier format. Expected 'owner/name', for ${identifier} with GitHub Container Registry integration`, + { identifier }, ); - return { - id: repository.id, - error: { code: "invalidIdentifier" }, - }; + return null; } + return { owner, name }; + } + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { + const parsedIdentifier = this.parseIdentifier(identifier); + if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; + + const { owner, name } = parsedIdentifier; const api = this.getApi(); - const details = await this.getDetailsAsync(api, owner, name); try { const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({ @@ -83,20 +83,20 @@ export class GitHubContainerRegistryIntegration extends Integration implements R return acc; }, []); - return getLatestRelease(releasesProviderResponse, repository, details); + const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + const details = await this.getDetailsAsync(api, owner, name); + + return { success: true, data: { ...details, ...latestRelease } }; } catch (error) { const errorMessage = error instanceof RequestError ? error.message : String(error); - localLogger.warn(`Failed to get releases for ${owner}\\${name} with GitHub Container Registry integration`, { owner, name, error: errorMessage, }); - - return { - id: repository.id, - error: { message: errorMessage }, - }; + return { success: false, error: { code: "unexpected", message: errorMessage } }; } } diff --git a/packages/integrations/src/github/github-integration.ts b/packages/integrations/src/github/github-integration.ts index 92fbcd802..65e103c38 100644 --- a/packages/integrations/src/github/github-integration.ts +++ b/packages/integrations/src/github/github-integration.ts @@ -15,8 +15,7 @@ import { getLatestRelease } from "../interfaces/releases-providers/releases-prov import type { DetailsProviderResponse, ReleaseProviderResponse, - ReleasesRepository, - ReleasesResponse, + ReleaseResponse, } from "../interfaces/releases-providers/releases-providers-types"; const localLogger = logger.child({ module: "GithubIntegration" }); @@ -43,38 +42,32 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const [owner, name] = repository.identifier.split("/"); + private parseIdentifier(identifier: string) { + const [owner, name] = identifier.split("/"); if (!owner || !name) { - localLogger.warn( - `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Github integration`, - { - identifier: repository.identifier, - }, - ); - return { - id: repository.id, - error: { code: "invalidIdentifier" }, - }; + localLogger.warn(`Invalid identifier format. Expected 'owner/name', for ${identifier} with Github integration`, { + identifier, + }); + return null; } + return { owner, name }; + } + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { + const parsedIdentifier = this.parseIdentifier(identifier); + if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; + + const { owner, name } = parsedIdentifier; const api = this.getApi(); - const details = await this.getDetailsAsync(api, owner, name); try { - const releasesResponse = await api.rest.repos.listReleases({ - owner, - repo: name, - }); + const releasesResponse = await api.rest.repos.listReleases({ owner, repo: name }); if (releasesResponse.data.length === 0) { - localLogger.warn(`No releases found, for ${repository.identifier} with Github integration`, { - identifier: repository.identifier, + localLogger.warn(`No releases found, for ${owner}/${name} with Github integration`, { + identifier: `${owner}/${name}`, }); - return { - id: repository.id, - error: { code: "noReleasesFound" }, - }; + return { success: false, error: { code: "noMatchingVersion" } }; } const releasesProviderResponse = releasesResponse.data.reduce((acc, release) => { @@ -90,20 +83,20 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn return acc; }, []); - return getLatestRelease(releasesProviderResponse, repository, details); + const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + const details = await this.getDetailsAsync(api, owner, name); + + return { success: true, data: { ...details, ...latestRelease } }; } catch (error) { const errorMessage = error instanceof OctokitRequestError ? error.message : String(error); - localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, { owner, name, error: errorMessage, }); - - return { - id: repository.id, - error: { message: errorMessage }, - }; + return { success: false, error: { code: "unexpected", message: errorMessage } }; } } diff --git a/packages/integrations/src/gitlab/gitlab-integration.ts b/packages/integrations/src/gitlab/gitlab-integration.ts index 1e42eea48..fdd2520c8 100644 --- a/packages/integrations/src/gitlab/gitlab-integration.ts +++ b/packages/integrations/src/gitlab/gitlab-integration.ts @@ -15,8 +15,7 @@ import { getLatestRelease } from "../interfaces/releases-providers/releases-prov import type { DetailsProviderResponse, ReleaseProviderResponse, - ReleasesRepository, - ReleasesResponse, + ReleaseResponse, } from "../interfaces/releases-providers/releases-providers-types"; const localLogger = logger.child({ module: "GitlabIntegration" }); @@ -40,25 +39,20 @@ export class GitlabIntegration extends Integration implements ReleasesProviderIn }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { const api = this.getApi(); - const details = await this.getDetailsAsync(api, repository.identifier); - try { - const releasesResponse = await api.ProjectReleases.all(repository.identifier, { + const releasesResponse = await api.ProjectReleases.all(identifier, { perPage: 100, }); if (releasesResponse instanceof Error) { - localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, { - identifier: repository.identifier, + localLogger.warn(`Failed to get releases for ${identifier} with Gitlab integration`, { + identifier, error: releasesResponse.message, }); - return { - id: repository.id, - error: { code: "noReleasesFound" }, - }; + return { success: false, error: { code: "noReleasesFound" } }; } const releasesProviderResponse = releasesResponse.reduce((acc, release) => { @@ -76,17 +70,19 @@ export class GitlabIntegration extends Integration implements ReleasesProviderIn return acc; }, []); - return getLatestRelease(releasesProviderResponse, repository, details); - } catch (error) { - localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, { - identifier: repository.identifier, - error: error instanceof Error ? error.message : String(error), - }); + const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; - return { - id: repository.id, - error: { code: "noReleasesFound" }, - }; + const details = await this.getDetailsAsync(api, identifier); + + return { success: true, data: { ...details, ...latestRelease } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + localLogger.warn(`Failed to get releases for ${identifier} with Gitlab integration`, { + identifier, + error: errorMessage, + }); + return { success: false, error: { code: "unexpected", message: errorMessage } }; } } diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 9e35a4932..5f55faa73 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -47,7 +47,7 @@ export type { TdarrStatistics, TdarrWorker, } from "./interfaces/media-transcoding/media-transcoding-types"; -export type { ReleasesResponse } from "./interfaces/releases-providers/releases-providers-types"; +export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; // Schemas diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts index 442c607f1..e94f555fc 100644 --- a/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts +++ b/packages/integrations/src/interfaces/releases-providers/releases-providers-integration.ts @@ -1,47 +1,21 @@ -import type { - DetailsProviderResponse, - ReleaseProviderResponse, - ReleasesRepository, - ReleasesResponse, -} from "./releases-providers-types"; +import type { ReleaseProviderResponse, ReleaseResponse } from "./releases-providers-types"; export const getLatestRelease = ( releases: ReleaseProviderResponse[], - repository: ReleasesRepository, - details?: DetailsProviderResponse, -): ReleasesResponse => { + versionRegex?: string, +): ReleaseProviderResponse | null => { const validReleases = releases.filter((result) => { if (result.latestRelease) { - return repository.versionRegex ? new RegExp(repository.versionRegex).test(result.latestRelease) : true; + return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true; } - return true; }); - const latest = - validReleases.length === 0 - ? ({ - id: repository.id, - error: { code: "noMatchingVersion" }, - } as ReleasesResponse) - : validReleases.reduce( - (latest, result) => { - return { - ...details, - ...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest), - id: repository.id, - }; - }, - { - id: "", - latestRelease: "", - latestReleaseAt: new Date(0), - }, - ); - - return latest; + return validReleases.length === 0 + ? null + : validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest)); }; export interface ReleasesProviderIntegration { - getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise; + getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise; } diff --git a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts b/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts index 335c64dcb..fb89b0fcb 100644 --- a/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts +++ b/packages/integrations/src/interfaces/releases-providers/releases-providers-types.ts @@ -1,5 +1,11 @@ import type { TranslationObject } from "@homarr/translation"; +export interface ReleasesRepository extends Record { + id: string; + identifier: string; + versionRegex?: string; +} + export interface DetailsProviderResponse { projectUrl?: string; projectDescription?: string; @@ -19,35 +25,10 @@ export interface ReleaseProviderResponse { isPreRelease?: boolean; } -export interface ReleasesRepository { - id: string; - identifier: string; - versionRegex?: string; -} - type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"]; -export interface ReleasesResponse { - id: string; - latestRelease?: string; - latestReleaseAt?: Date; - releaseUrl?: string; - releaseDescription?: string; - isPreRelease?: boolean; - projectUrl?: string; - projectDescription?: string; - isFork?: boolean; - isArchived?: boolean; - createdAt?: Date; - starsCount?: number; - openIssues?: number; - forksCount?: number; +export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse; - error?: - | { - code: ReleasesErrorKeys; - } - | { - message: string; - }; -} +export type ReleaseError = { code: ReleasesErrorKeys } | { code: "unexpected"; message: string }; + +export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError }; diff --git a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts b/packages/integrations/src/linuxserverio/linuxserverio-integration.ts index b3039881d..8b5d1a69b 100644 --- a/packages/integrations/src/linuxserverio/linuxserverio-integration.ts +++ b/packages/integrations/src/linuxserverio/linuxserverio-integration.ts @@ -6,7 +6,7 @@ import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; -import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types"; +import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types"; import { releasesResponseSchema } from "./linuxserverio-schemas"; const localLogger = logger.child({ module: "LinuxServerIOsIntegration" }); @@ -24,56 +24,44 @@ export class LinuxServerIOIntegration extends Integration implements ReleasesPro }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const [owner, name] = repository.identifier.split("/"); + private parseIdentifier(identifier: string) { + const [owner, name] = identifier.split("/"); if (!owner || !name) { localLogger.warn( - `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`, - { - identifier: repository.identifier, - }, + `Invalid identifier format. Expected 'owner/name', for ${identifier} with LinuxServerIO integration`, + { identifier }, ); - return { - id: repository.id, - error: { code: "invalidIdentifier" }, - }; + return null; } + return { owner, name }; + } + + public async getLatestMatchingReleaseAsync(identifier: string): Promise { + const { name } = this.parseIdentifier(identifier) ?? {}; + if (!name) return { success: false, error: { code: "invalidIdentifier" } }; const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images")); - if (!releasesResponse.ok) { - return { - id: repository.id, - error: { message: releasesResponse.statusText }, - }; + return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; } const releasesResponseJson: unknown = await releasesResponse.json(); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { - return { - id: repository.id, - error: { - message: error.message, - }, - }; - } else { - const release = data.data.repositories.linuxserver.find((repo) => repo.name === name); - if (!release) { - localLogger.warn(`Repository ${name} not found on provider, with LinuxServerIO integration`, { - owner, - name, - }); + return { success: false, error: { code: "unexpected", message: error.message } }; + } - return { - id: repository.id, - error: { code: "noReleasesFound" }, - }; - } + const release = data.data.repositories.linuxserver.find((repo) => repo.name === name); + if (!release) { + localLogger.warn(`Repository ${name} not found on provider, with LinuxServerIO integration`, { + name, + }); + return { success: false, error: { code: "noMatchingVersion" } }; + } - return { - id: repository.id, + return { + success: true, + data: { latestRelease: release.version, latestReleaseAt: release.version_timestamp, releaseDescription: release.changelog?.shift()?.desc, @@ -82,7 +70,7 @@ export class LinuxServerIOIntegration extends Integration implements ReleasesPro isArchived: release.deprecated, createdAt: release.initial_date ? new Date(release.initial_date) : undefined, starsCount: release.stars, - }; - } + }, + }; } } diff --git a/packages/integrations/src/npm/npm-integration.ts b/packages/integrations/src/npm/npm-integration.ts index f82e0ba22..13a307467 100644 --- a/packages/integrations/src/npm/npm-integration.ts +++ b/packages/integrations/src/npm/npm-integration.ts @@ -6,7 +6,7 @@ import { TestConnectionError } from "../base/test-connection/test-connection-err import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; -import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types"; +import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types"; import { releasesResponseSchema } from "./npm-schemas"; export class NPMIntegration extends Integration implements ReleasesProviderIntegration { @@ -22,35 +22,35 @@ export class NPMIntegration extends Integration implements ReleasesProviderInteg }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const releasesResponse = await fetchWithTrustedCertificatesAsync( - this.url(`/${encodeURIComponent(repository.identifier)}`), - ); + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { + if (!identifier) return { success: false, error: { code: "invalidIdentifier" } }; + const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url(`/${encodeURIComponent(identifier)}`)); if (!releasesResponse.ok) { - return { - id: repository.id, - error: { message: releasesResponse.statusText }, - }; + return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; } const releasesResponseJson: unknown = await releasesResponse.json(); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { return { - id: repository.id, + success: false, error: { + code: "unexpected", message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, }, }; - } else { - const formattedReleases = data.time.map((tag) => ({ - ...tag, - releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`, - releaseDescription: data.versions[tag.latestRelease]?.description ?? "", - })); - return getLatestRelease(formattedReleases, repository); } + + const formattedReleases = data.time.map((tag) => ({ + ...tag, + releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(data.name)}/v/${encodeURIComponent(tag.latestRelease)}`, + releaseDescription: data.versions[tag.latestRelease]?.description ?? "", + })); + + const latestRelease = getLatestRelease(formattedReleases, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + return { success: true, data: latestRelease }; } } diff --git a/packages/integrations/src/quay/quay-integration.ts b/packages/integrations/src/quay/quay-integration.ts index f84052315..f27349e6b 100644 --- a/packages/integrations/src/quay/quay-integration.ts +++ b/packages/integrations/src/quay/quay-integration.ts @@ -11,8 +11,7 @@ import type { ReleasesProviderIntegration } from "../interfaces/releases-provide import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; import type { ReleaseProviderResponse, - ReleasesRepository, - ReleasesResponse, + ReleaseResponse, } from "../interfaces/releases-providers/releases-providers-types"; import { releasesResponseSchema } from "./quay-schemas"; @@ -43,20 +42,22 @@ export class QuayIntegration extends Integration implements ReleasesProviderInte }; } - public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise { - const [owner, name] = repository.identifier.split("/"); + private parseIdentifier(identifier: string) { + const [owner, name] = identifier.split("/"); if (!owner || !name) { - localLogger.warn( - `Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`, - { - identifier: repository.identifier, - }, - ); - return { - id: repository.id, - error: { code: "invalidIdentifier" }, - }; + localLogger.warn(`Invalid identifier format. Expected 'owner/name', for ${identifier} with Quay integration`, { + identifier, + }); + return null; } + return { owner, name }; + } + + public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise { + const parsedIdentifier = this.parseIdentifier(identifier); + if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } }; + + const { owner, name } = parsedIdentifier; const releasesResponse = await this.withHeadersAsync(async (headers) => { return await fetchWithTrustedCertificatesAsync( @@ -68,42 +69,29 @@ export class QuayIntegration extends Integration implements ReleasesProviderInte }, ); }); - if (!releasesResponse.ok) { - return { - id: repository.id, - error: { message: releasesResponse.statusText }, - }; + return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } }; } const releasesResponseJson: unknown = await releasesResponse.json(); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); - if (!success) { - return { - id: repository.id, - error: { - message: error.message, - }, - }; - } else { - const details = { - projectDescription: data.description, - }; - - const releasesProviderResponse = Object.entries(data.tags).reduce((acc, [_, tag]) => { - if (!tag.name || !tag.last_modified) return acc; - - acc.push({ - latestRelease: tag.name, - latestReleaseAt: new Date(tag.last_modified), - releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`, - }); - - return acc; - }, []); - - return getLatestRelease(releasesProviderResponse, repository, details); + return { success: false, error: { code: "unexpected", message: error.message } }; } + + const releasesProviderResponse = Object.entries(data.tags).reduce((acc, [_, tag]) => { + if (!tag.name || !tag.last_modified) return acc; + acc.push({ + latestRelease: tag.name, + latestReleaseAt: new Date(tag.last_modified), + releaseUrl: `https://quay.io/repository/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/tag/${encodeURIComponent(tag.name)}`, + }); + return acc; + }, []); + + const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex); + if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } }; + + return { success: true, data: { projectDescription: data.description, ...latestRelease } }; } } diff --git a/packages/request-handler/src/releases.ts b/packages/request-handler/src/releases.ts index 74158c05f..7bfd29348 100644 --- a/packages/request-handler/src/releases.ts +++ b/packages/request-handler/src/releases.ts @@ -1,36 +1,19 @@ import dayjs from "dayjs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { getIconUrl } from "@homarr/definitions"; -import type { ReleasesResponse } from "@homarr/integrations"; +import type { ReleaseResponse, ReleasesRepository } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations"; import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; export const releasesRequestHandler = createCachedIntegrationRequestHandler< - ReleasesResponse, + ReleaseResponse, IntegrationKindByCategory<"releasesProvider">, - { - id: string; - identifier: string; - versionRegex?: string; - } + ReleasesRepository >({ - async requestAsync(integration, input) { - const integrationInstance = await createIntegrationAsync(integration); - const response = await integrationInstance.getLatestMatchingReleaseAsync({ - id: input.id, - identifier: input.identifier, - versionRegex: input.versionRegex, - }); - - return { - ...response, - integration: { - name: integration.name, - iconUrl: getIconUrl(integration.kind), - }, - }; + requestAsync: async (integration, input) => { + const instance = await createIntegrationAsync(integration); + return instance.getLatestMatchingReleaseAsync(input.identifier, input.versionRegex); }, cacheDuration: dayjs.duration(5, "minutes"), queryKey: "repositoriesReleases", diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 51cefebd0..fdd9f1f0b 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -21,6 +21,7 @@ import ReactMarkdown from "react-markdown"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common"; +import { getIconUrl } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; @@ -96,55 +97,33 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas const repositories = useMemo(() => { const formattedResults = options.repositories .map((repository) => { - if (repository.providerIntegrationId === undefined) { - return { - ...repository, - isNewRelease: false, - isStaleRelease: false, - latestReleaseAt: undefined, - error: { - code: "noProviderSeleceted", - }, - }; - } + if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } }; - const response = results.flat().find(({ data }) => data.id === repository.id)?.data; + const repositoryResult = results.flat().find(({ id }) => id === repository.id); + if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } }; + if (!repositoryResult.success) return { ...repository, error: repositoryResult.error }; - if (response === undefined) - return { - ...repository, - isNewRelease: false, - isStaleRelease: false, - latestReleaseAt: undefined, - error: { - code: "noProviderResponse", - }, - }; + const { data: release, integration } = repositoryResult; + + const isReleaseWithin = (relativeDate: string) => + Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate); return { ...repository, - ...response, - isNewRelease: - relativeDateOptions.newReleaseWithin !== "" && response.latestReleaseAt - ? isDateWithin(response.latestReleaseAt, relativeDateOptions.newReleaseWithin) - : false, - isStaleRelease: - relativeDateOptions.staleReleaseWithin !== "" && response.latestReleaseAt - ? !isDateWithin(response.latestReleaseAt, relativeDateOptions.staleReleaseWithin) - : false, - viewed: releasesViewedList[repository.id] === response.latestRelease, + ...release, + integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) }, + isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin), + isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin), + viewed: releasesViewedList[repository.id] === release.latestRelease, }; }) .filter( (repository) => - repository.error !== undefined || - !options.showOnlyHighlighted || - repository.isNewRelease || - repository.isStaleRelease, + "error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease, ) .sort((repoA, repoB) => { - if (repoA.latestReleaseAt === undefined) return -1; - if (repoB.latestReleaseAt === undefined) return 1; + if ("error" in repoA) return -1; + if ("error" in repoB) return 1; return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1; }) as ReleasesRepositoryResponse[];