fix(releases-widget): Search multiple docker hub pages for releases (#4158)

This commit is contained in:
castielwaverly
2025-10-29 14:33:50 -04:00
committed by GitHub
parent 9e4b93abcf
commit e0526c5ea7
14 changed files with 280 additions and 390 deletions

View File

@@ -40,15 +40,22 @@ export const releasesRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await Promise.all( return await Promise.all(
input.repositories.map(async (repository) => { input.repositories.map(async (repository) => {
const innerHandler = releasesRequestHandler.handler(ctx.integration, { const response = await releasesRequestHandler
id: repository.id, .handler(ctx.integration, {
identifier: repository.identifier, id: repository.id,
versionRegex: formatVersionFilterRegex(repository.versionFilter), identifier: repository.identifier,
}); versionRegex: formatVersionFilterRegex(repository.versionFilter),
})
.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
return await innerHandler.getCachedOrUpdatedDataAsync({ return {
forceUpdate: false, id: repository.id,
}); integration: { name: ctx.integration.name, kind: ctx.integration.kind },
timestamp: response.timestamp,
...response.data,
};
}), }),
); );
}), }),

View File

@@ -11,8 +11,7 @@ import type { ReleasesProviderIntegration } from "../interfaces/releases-provide
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type { import type {
DetailsProviderResponse, DetailsProviderResponse,
ReleasesRepository, ReleaseResponse,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types"; } from "../interfaces/releases-providers/releases-providers-types";
import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas"; import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
@@ -43,22 +42,23 @@ export class CodebergIntegration extends Integration implements ReleasesProvider
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { private parseIdentifier(identifier: string) {
const [owner, name] = repository.identifier.split("/"); const [owner, name] = identifier.split("/");
if (!owner || !name) { if (!owner || !name) {
localLogger.warn( localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Codeberg integration`, `Invalid identifier format. Expected 'owner/name', for ${identifier} with Codeberg integration`,
{ { identifier },
identifier: repository.identifier,
},
); );
return { return null;
id: repository.id,
error: { code: "invalidIdentifier" },
};
} }
return { owner, name };
}
const details = await this.getDetailsAsync(owner, name); public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
const parsedIdentifier = this.parseIdentifier(identifier);
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
const { owner, name } = parsedIdentifier;
const releasesResponse = await this.withHeadersAsync(async (headers) => { const releasesResponse = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync( return await fetchWithTrustedCertificatesAsync(
@@ -66,34 +66,36 @@ export class CodebergIntegration extends Integration implements ReleasesProvider
{ headers }, { headers },
); );
}); });
if (!releasesResponse.ok) { if (!releasesResponse.ok) {
return { return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
id: repository.id,
error: { message: releasesResponse.statusText },
};
} }
const releasesResponseJson: unknown = await releasesResponse.json(); const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) { if (!success) {
return { return {
id: repository.id, success: false,
error: { error: {
code: "unexpected",
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, 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<DetailsProviderResponse | undefined> { protected async getDetailsAsync(owner: string, name: string): Promise<DetailsProviderResponse | undefined> {

View File

@@ -14,8 +14,7 @@ import type { ReleasesProviderIntegration } from "../interfaces/releases-provide
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type { import type {
DetailsProviderResponse, DetailsProviderResponse,
ReleasesRepository, ReleaseResponse,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types"; } from "../interfaces/releases-providers/releases-providers-types";
import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas"; 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<ReleasesResponse> { private parseIdentifier(identifier: string) {
const relativeUrl = this.getRelativeUrl(repository.identifier); if (!identifier.includes("/")) return { owner: "", name: identifier };
if (relativeUrl === "/") { const [owner, name] = identifier.split("/");
localLogger.warn( if (!owner || !name) {
`Invalid identifier format. Expected 'owner/name' or 'name', for ${repository.identifier} on DockerHub`, localLogger.warn(`Invalid identifier format. Expected 'owner/name' or 'name', for ${identifier} on DockerHub`, {
{ identifier,
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,
}); });
}); return null;
}
return { owner, name };
}
if (!releasesResponse.ok) { public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
return { const parsedIdentifier = this.parseIdentifier(identifier);
id: repository.id, if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
error: { message: releasesResponse.statusText },
}; 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(); return { success: false, error: { code: "noMatchingVersion" } };
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);
}
} }
private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> { private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> {
@@ -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<string> { private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise<string> {
const response = await fetchAsync(this.url("/v2/auth/token"), { const response = await fetchAsync(this.url("/v2/auth/token"), {
method: "POST", method: "POST",

View File

@@ -15,8 +15,7 @@ import { getLatestRelease } from "../interfaces/releases-providers/releases-prov
import type { import type {
DetailsProviderResponse, DetailsProviderResponse,
ReleaseProviderResponse, ReleaseProviderResponse,
ReleasesRepository, ReleaseResponse,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types"; } from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" }); const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" });
@@ -43,23 +42,24 @@ export class GitHubContainerRegistryIntegration extends Integration implements R
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { private parseIdentifier(identifier: string) {
const [owner, name] = repository.identifier.split("/"); const [owner, name] = identifier.split("/");
if (!owner || !name) { if (!owner || !name) {
localLogger.warn( localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with GitHub Container Registry integration`, `Invalid identifier format. Expected 'owner/name', for ${identifier} with GitHub Container Registry integration`,
{ { identifier },
identifier: repository.identifier,
},
); );
return { return null;
id: repository.id,
error: { code: "invalidIdentifier" },
};
} }
return { owner, name };
}
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
const parsedIdentifier = this.parseIdentifier(identifier);
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
const { owner, name } = parsedIdentifier;
const api = this.getApi(); const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try { try {
const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({ const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
@@ -83,20 +83,20 @@ export class GitHubContainerRegistryIntegration extends Integration implements R
return acc; 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) { } catch (error) {
const errorMessage = error instanceof RequestError ? error.message : String(error); const errorMessage = error instanceof RequestError ? error.message : String(error);
localLogger.warn(`Failed to get releases for ${owner}\\${name} with GitHub Container Registry integration`, { localLogger.warn(`Failed to get releases for ${owner}\\${name} with GitHub Container Registry integration`, {
owner, owner,
name, name,
error: errorMessage, error: errorMessage,
}); });
return { success: false, error: { code: "unexpected", message: errorMessage } };
return {
id: repository.id,
error: { message: errorMessage },
};
} }
} }

View File

@@ -15,8 +15,7 @@ import { getLatestRelease } from "../interfaces/releases-providers/releases-prov
import type { import type {
DetailsProviderResponse, DetailsProviderResponse,
ReleaseProviderResponse, ReleaseProviderResponse,
ReleasesRepository, ReleaseResponse,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types"; } from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GithubIntegration" }); const localLogger = logger.child({ module: "GithubIntegration" });
@@ -43,38 +42,32 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { private parseIdentifier(identifier: string) {
const [owner, name] = repository.identifier.split("/"); const [owner, name] = identifier.split("/");
if (!owner || !name) { if (!owner || !name) {
localLogger.warn( localLogger.warn(`Invalid identifier format. Expected 'owner/name', for ${identifier} with Github integration`, {
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Github integration`, identifier,
{ });
identifier: repository.identifier, return null;
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
} }
return { owner, name };
}
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
const parsedIdentifier = this.parseIdentifier(identifier);
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
const { owner, name } = parsedIdentifier;
const api = this.getApi(); const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try { try {
const releasesResponse = await api.rest.repos.listReleases({ const releasesResponse = await api.rest.repos.listReleases({ owner, repo: name });
owner,
repo: name,
});
if (releasesResponse.data.length === 0) { if (releasesResponse.data.length === 0) {
localLogger.warn(`No releases found, for ${repository.identifier} with Github integration`, { localLogger.warn(`No releases found, for ${owner}/${name} with Github integration`, {
identifier: repository.identifier, identifier: `${owner}/${name}`,
}); });
return { return { success: false, error: { code: "noMatchingVersion" } };
id: repository.id,
error: { code: "noReleasesFound" },
};
} }
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => { const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
@@ -90,20 +83,20 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
return acc; 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) { } catch (error) {
const errorMessage = error instanceof OctokitRequestError ? error.message : String(error); const errorMessage = error instanceof OctokitRequestError ? error.message : String(error);
localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, { localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, {
owner, owner,
name, name,
error: errorMessage, error: errorMessage,
}); });
return { success: false, error: { code: "unexpected", message: errorMessage } };
return {
id: repository.id,
error: { message: errorMessage },
};
} }
} }

View File

@@ -15,8 +15,7 @@ import { getLatestRelease } from "../interfaces/releases-providers/releases-prov
import type { import type {
DetailsProviderResponse, DetailsProviderResponse,
ReleaseProviderResponse, ReleaseProviderResponse,
ReleasesRepository, ReleaseResponse,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types"; } from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GitlabIntegration" }); const localLogger = logger.child({ module: "GitlabIntegration" });
@@ -40,25 +39,20 @@ export class GitlabIntegration extends Integration implements ReleasesProviderIn
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
const api = this.getApi(); const api = this.getApi();
const details = await this.getDetailsAsync(api, repository.identifier);
try { try {
const releasesResponse = await api.ProjectReleases.all(repository.identifier, { const releasesResponse = await api.ProjectReleases.all(identifier, {
perPage: 100, perPage: 100,
}); });
if (releasesResponse instanceof Error) { if (releasesResponse instanceof Error) {
localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, { localLogger.warn(`Failed to get releases for ${identifier} with Gitlab integration`, {
identifier: repository.identifier, identifier,
error: releasesResponse.message, error: releasesResponse.message,
}); });
return { return { success: false, error: { code: "noReleasesFound" } };
id: repository.id,
error: { code: "noReleasesFound" },
};
} }
const releasesProviderResponse = releasesResponse.reduce<ReleaseProviderResponse[]>((acc, release) => { const releasesProviderResponse = releasesResponse.reduce<ReleaseProviderResponse[]>((acc, release) => {
@@ -76,17 +70,19 @@ export class GitlabIntegration extends Integration implements ReleasesProviderIn
return acc; return acc;
}, []); }, []);
return getLatestRelease(releasesProviderResponse, repository, details); const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
} catch (error) { if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, {
identifier: repository.identifier,
error: error instanceof Error ? error.message : String(error),
});
return { const details = await this.getDetailsAsync(api, identifier);
id: repository.id,
error: { code: "noReleasesFound" }, 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 } };
} }
} }

View File

@@ -47,7 +47,7 @@ export type {
TdarrStatistics, TdarrStatistics,
TdarrWorker, TdarrWorker,
} from "./interfaces/media-transcoding/media-transcoding-types"; } 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"; export type { Notification } from "./interfaces/notifications/notification-types";
// Schemas // Schemas

View File

@@ -1,47 +1,21 @@
import type { import type { ReleaseProviderResponse, ReleaseResponse } from "./releases-providers-types";
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "./releases-providers-types";
export const getLatestRelease = ( export const getLatestRelease = (
releases: ReleaseProviderResponse[], releases: ReleaseProviderResponse[],
repository: ReleasesRepository, versionRegex?: string,
details?: DetailsProviderResponse, ): ReleaseProviderResponse | null => {
): ReleasesResponse => {
const validReleases = releases.filter((result) => { const validReleases = releases.filter((result) => {
if (result.latestRelease) { 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; return true;
}); });
const latest = return validReleases.length === 0
validReleases.length === 0 ? null
? ({ : validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest));
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;
}; };
export interface ReleasesProviderIntegration { export interface ReleasesProviderIntegration {
getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse>; getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse>;
} }

View File

@@ -1,5 +1,11 @@
import type { TranslationObject } from "@homarr/translation"; import type { TranslationObject } from "@homarr/translation";
export interface ReleasesRepository extends Record<string, unknown> {
id: string;
identifier: string;
versionRegex?: string;
}
export interface DetailsProviderResponse { export interface DetailsProviderResponse {
projectUrl?: string; projectUrl?: string;
projectDescription?: string; projectDescription?: string;
@@ -19,35 +25,10 @@ export interface ReleaseProviderResponse {
isPreRelease?: boolean; isPreRelease?: boolean;
} }
export interface ReleasesRepository {
id: string;
identifier: string;
versionRegex?: string;
}
type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"]; type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"];
export interface ReleasesResponse { export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse;
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;
error?: export type ReleaseError = { code: ReleasesErrorKeys } | { code: "unexpected"; message: string };
| {
code: ReleasesErrorKeys; export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
}
| {
message: string;
};
}

View File

@@ -6,7 +6,7 @@ import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error"; import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; 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"; import { releasesResponseSchema } from "./linuxserverio-schemas";
const localLogger = logger.child({ module: "LinuxServerIOsIntegration" }); const localLogger = logger.child({ module: "LinuxServerIOsIntegration" });
@@ -24,56 +24,44 @@ export class LinuxServerIOIntegration extends Integration implements ReleasesPro
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { private parseIdentifier(identifier: string) {
const [owner, name] = repository.identifier.split("/"); const [owner, name] = identifier.split("/");
if (!owner || !name) { if (!owner || !name) {
localLogger.warn( localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`, `Invalid identifier format. Expected 'owner/name', for ${identifier} with LinuxServerIO integration`,
{ { identifier },
identifier: repository.identifier,
},
); );
return { return null;
id: repository.id,
error: { code: "invalidIdentifier" },
};
} }
return { owner, name };
}
public async getLatestMatchingReleaseAsync(identifier: string): Promise<ReleaseResponse> {
const { name } = this.parseIdentifier(identifier) ?? {};
if (!name) return { success: false, error: { code: "invalidIdentifier" } };
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images")); const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images"));
if (!releasesResponse.ok) { if (!releasesResponse.ok) {
return { return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
id: repository.id,
error: { message: releasesResponse.statusText },
};
} }
const releasesResponseJson: unknown = await releasesResponse.json(); const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) { if (!success) {
return { return { success: false, error: { code: "unexpected", message: error.message } };
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 { const release = data.data.repositories.linuxserver.find((repo) => repo.name === name);
id: repository.id, if (!release) {
error: { code: "noReleasesFound" }, localLogger.warn(`Repository ${name} not found on provider, with LinuxServerIO integration`, {
}; name,
} });
return { success: false, error: { code: "noMatchingVersion" } };
}
return { return {
id: repository.id, success: true,
data: {
latestRelease: release.version, latestRelease: release.version,
latestReleaseAt: release.version_timestamp, latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc, releaseDescription: release.changelog?.shift()?.desc,
@@ -82,7 +70,7 @@ export class LinuxServerIOIntegration extends Integration implements ReleasesPro
isArchived: release.deprecated, isArchived: release.deprecated,
createdAt: release.initial_date ? new Date(release.initial_date) : undefined, createdAt: release.initial_date ? new Date(release.initial_date) : undefined,
starsCount: release.stars, starsCount: release.stars,
}; },
} };
} }
} }

View File

@@ -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 { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration"; import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } 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"; import { releasesResponseSchema } from "./npm-schemas";
export class NPMIntegration extends Integration implements ReleasesProviderIntegration { export class NPMIntegration extends Integration implements ReleasesProviderIntegration {
@@ -22,35 +22,35 @@ export class NPMIntegration extends Integration implements ReleasesProviderInteg
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
const releasesResponse = await fetchWithTrustedCertificatesAsync( if (!identifier) return { success: false, error: { code: "invalidIdentifier" } };
this.url(`/${encodeURIComponent(repository.identifier)}`),
);
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url(`/${encodeURIComponent(identifier)}`));
if (!releasesResponse.ok) { if (!releasesResponse.ok) {
return { return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
id: repository.id,
error: { message: releasesResponse.statusText },
};
} }
const releasesResponseJson: unknown = await releasesResponse.json(); const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) { if (!success) {
return { return {
id: repository.id, success: false,
error: { error: {
code: "unexpected",
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message, 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 };
} }
} }

View File

@@ -11,8 +11,7 @@ import type { ReleasesProviderIntegration } from "../interfaces/releases-provide
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration"; import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type { import type {
ReleaseProviderResponse, ReleaseProviderResponse,
ReleasesRepository, ReleaseResponse,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types"; } from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./quay-schemas"; import { releasesResponseSchema } from "./quay-schemas";
@@ -43,20 +42,22 @@ export class QuayIntegration extends Integration implements ReleasesProviderInte
}; };
} }
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> { private parseIdentifier(identifier: string) {
const [owner, name] = repository.identifier.split("/"); const [owner, name] = identifier.split("/");
if (!owner || !name) { if (!owner || !name) {
localLogger.warn( localLogger.warn(`Invalid identifier format. Expected 'owner/name', for ${identifier} with Quay integration`, {
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with LinuxServerIO integration`, identifier,
{ });
identifier: repository.identifier, return null;
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
} }
return { owner, name };
}
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
const parsedIdentifier = this.parseIdentifier(identifier);
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
const { owner, name } = parsedIdentifier;
const releasesResponse = await this.withHeadersAsync(async (headers) => { const releasesResponse = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync( return await fetchWithTrustedCertificatesAsync(
@@ -68,42 +69,29 @@ export class QuayIntegration extends Integration implements ReleasesProviderInte
}, },
); );
}); });
if (!releasesResponse.ok) { if (!releasesResponse.ok) {
return { return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
id: repository.id,
error: { message: releasesResponse.statusText },
};
} }
const releasesResponseJson: unknown = await releasesResponse.json(); const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson); const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) { if (!success) {
return { return { success: false, error: { code: "unexpected", message: error.message } };
id: repository.id,
error: {
message: error.message,
},
};
} else {
const details = {
projectDescription: data.description,
};
const releasesProviderResponse = Object.entries(data.tags).reduce<ReleaseProviderResponse[]>((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);
} }
const releasesProviderResponse = Object.entries(data.tags).reduce<ReleaseProviderResponse[]>((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 } };
} }
} }

View File

@@ -1,36 +1,19 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions"; import type { ReleaseResponse, ReleasesRepository } from "@homarr/integrations";
import type { ReleasesResponse } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const releasesRequestHandler = createCachedIntegrationRequestHandler< export const releasesRequestHandler = createCachedIntegrationRequestHandler<
ReleasesResponse, ReleaseResponse,
IntegrationKindByCategory<"releasesProvider">, IntegrationKindByCategory<"releasesProvider">,
{ ReleasesRepository
id: string;
identifier: string;
versionRegex?: string;
}
>({ >({
async requestAsync(integration, input) { requestAsync: async (integration, input) => {
const integrationInstance = await createIntegrationAsync(integration); const instance = await createIntegrationAsync(integration);
const response = await integrationInstance.getLatestMatchingReleaseAsync({ return instance.getLatestMatchingReleaseAsync(input.identifier, input.versionRegex);
id: input.id,
identifier: input.identifier,
versionRegex: input.versionRegex,
});
return {
...response,
integration: {
name: integration.name,
iconUrl: getIconUrl(integration.kind),
},
};
}, },
cacheDuration: dayjs.duration(5, "minutes"), cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases", queryKey: "repositoriesReleases",

View File

@@ -21,6 +21,7 @@ import ReactMarkdown from "react-markdown";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common"; import { isDateWithin, isNullOrWhitespace, splitToChunksWithNItems } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui"; import { MaskedOrNormalImage } from "@homarr/ui";
@@ -96,55 +97,33 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
const repositories = useMemo(() => { const repositories = useMemo(() => {
const formattedResults = options.repositories const formattedResults = options.repositories
.map((repository) => { .map((repository) => {
if (repository.providerIntegrationId === undefined) { if (!repository.providerIntegrationId) return { ...repository, error: { code: "noProviderSeleceted" } };
return {
...repository,
isNewRelease: false,
isStaleRelease: false,
latestReleaseAt: undefined,
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) const { data: release, integration } = repositoryResult;
return {
...repository, const isReleaseWithin = (relativeDate: string) =>
isNewRelease: false, Boolean(relativeDate) && isDateWithin(release.latestReleaseAt, relativeDate);
isStaleRelease: false,
latestReleaseAt: undefined,
error: {
code: "noProviderResponse",
},
};
return { return {
...repository, ...repository,
...response, ...release,
isNewRelease: integration: { name: integration.name, iconUrl: getIconUrl(integration.kind) },
relativeDateOptions.newReleaseWithin !== "" && response.latestReleaseAt isNewRelease: isReleaseWithin(relativeDateOptions.newReleaseWithin),
? isDateWithin(response.latestReleaseAt, relativeDateOptions.newReleaseWithin) isStaleRelease: !isReleaseWithin(relativeDateOptions.staleReleaseWithin),
: false, viewed: releasesViewedList[repository.id] === release.latestRelease,
isStaleRelease:
relativeDateOptions.staleReleaseWithin !== "" && response.latestReleaseAt
? !isDateWithin(response.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
: false,
viewed: releasesViewedList[repository.id] === response.latestRelease,
}; };
}) })
.filter( .filter(
(repository) => (repository) =>
repository.error !== undefined || "error" in repository || !options.showOnlyHighlighted || repository.isNewRelease || repository.isStaleRelease,
!options.showOnlyHighlighted ||
repository.isNewRelease ||
repository.isStaleRelease,
) )
.sort((repoA, repoB) => { .sort((repoA, repoB) => {
if (repoA.latestReleaseAt === undefined) return -1; if ("error" in repoA) return -1;
if (repoB.latestReleaseAt === undefined) return 1; if ("error" in repoB) return 1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1; return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepositoryResponse[]; }) as ReleasesRepositoryResponse[];