feat(releases-widget): define providers as integrations (#3253)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Andre Silva
2025-07-11 19:54:17 +01:00
committed by GitHub
parent 9020440193
commit 5d8126d71e
72 changed files with 1573 additions and 662 deletions

View File

@@ -1,4 +1,12 @@
import { IconGrid3x3, IconKey, IconMessage, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import {
IconGrid3x3,
IconKey,
IconMessage,
IconPassword,
IconPasswordUser,
IconServer,
IconUser,
} from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -9,5 +17,6 @@ export const integrationSecretIcons = {
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser,
topic: IconMessage,
} satisfies Record<IntegrationSecretKind, TablerIcon>;

View File

@@ -21,7 +21,13 @@ import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions, getIconUrl, getIntegrationName, integrationDefs } from "@homarr/definitions";
import {
getAllSecretKindOptions,
getIconUrl,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
} from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -54,7 +60,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
const form = useZodForm(formSchema, {
initialValues: {
name: searchParams.name ?? getIntegrationName(searchParams.kind),
url: searchParams.url ?? "",
url: searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "",
secrets: secretKinds[0].map((kind) => ({
kind,
value: "",

View File

@@ -19,6 +19,7 @@ import {
getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationCategories,
integrationDefs,
integrationKinds,
integrationSecretKindObject,
@@ -129,6 +130,57 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
allOfGivenCategory: publicProcedure
.input(
z.object({
category: z.enum(integrationCategories),
}),
)
.query(async ({ ctx, input }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
});
const intergrationKinds = getIntegrationKindsByCategory(input.category);
const integrationsFromDb = await ctx.db.query.integrations.findMany({
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId),
),
},
},
where: inArray(integrations.kind, intergrationKinds),
});
return integrationsFromDb
.map((integration) => {
const permissions = integration.userPermissions
.map(({ permission }) => permission)
.concat(integration.groupPermissions.map(({ permission }) => permission));
return {
id: integration.id,
name: integration.name,
kind: integration.kind,
url: integration.url,
permissions: {
hasUseAccess:
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
hasFullAccess: permissions.includes("full"),
},
};
})
.sort(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {

View File

@@ -1,8 +1,10 @@
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
@@ -23,31 +25,31 @@ const releaseVersionFilterSchema = z.object({
export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
repositories: z.array(
z.object({
providerKey: z.string(),
id: z.string(),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
}),
),
}),
)
.query(async ({ input }) => {
const result = await Promise.all(
.query(async ({ ctx, input }) => {
return await Promise.all(
input.repositories.map(async (repository) => {
const innerHandler = releasesRequestHandler.handler({
providerKey: repository.providerKey,
const innerHandler = releasesRequestHandler.handler(ctx.integration, {
id: repository.id,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
});
return await innerHandler.getCachedOrUpdatedDataAsync({
forceUpdate: false,
});
}),
);
return result;
}),
});

View File

@@ -3,12 +3,14 @@
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { IntegrationKind } from "@homarr/definitions";
interface IntegrationContextProps {
integrations: {
id: string;
name: string;
url: string;
kind: string;
kind: IntegrationKind;
permissions: {
hasFullAccess: boolean;
hasInteractAccess: boolean;

View File

@@ -29,6 +29,7 @@
"dependencies": {
"@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.13",
"next": "15.3.5",
"react": "19.1.0",

View File

@@ -0,0 +1 @@
export { createId } from "@paralleldrive/cuid2";

View File

@@ -5,6 +5,7 @@ export * from "./array";
export * from "./date";
export * from "./stopwatch";
export * from "./hooks";
export * from "./id";
export * from "./url";
export * from "./number";
export * from "./error";

View File

@@ -0,0 +1,72 @@
import SuperJSON from "superjson";
import { createId } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { eq } from "../..";
import type { Database } from "../..";
import { items } from "../../schema";
export async function migrateReleaseWidgetProviderToOptionsAsync(db: Database) {
const existingItems = await db.query.items.findMany({
where: (items, { eq }) => eq(items.kind, "releases"),
});
const integrationKinds = getIntegrationKindsByCategory("releasesProvider");
const providerIntegrations = await db.query.integrations.findMany({
where: (integrations, { inArray }) => inArray(integrations.kind, integrationKinds),
columns: {
id: true,
kind: true,
},
});
const providerIntegrationMap = new Map<IntegrationKind, string>(
providerIntegrations.map((integration) => [integration.kind, integration.id]),
);
const updates: {
id: string;
options: object;
}[] = [];
for (const item of existingItems) {
const options = SuperJSON.parse<object>(item.options);
if (!("repositories" in options)) continue;
if (!Array.isArray(options.repositories)) continue;
if (options.repositories.length === 0) continue;
if (!options.repositories.some((repository) => "providerKey" in repository)) continue;
const updatedRepositories = options.repositories.map(
({ providerKey, ...otherFields }: { providerKey: string; [key: string]: unknown }) => {
// Ensure providerKey is camelCase
const provider = providerKey.charAt(0).toLowerCase() + providerKey.slice(1);
return {
id: createId(),
providerIntegrationId: providerIntegrationMap.get(provider as IntegrationKind) ?? null,
...otherFields,
};
},
);
updates.push({
id: item.id,
options: {
...options,
repositories: updatedRepositories,
},
});
}
for (const update of updates) {
await db
.update(items)
.set({
options: SuperJSON.stringify(update.options),
})
.where(eq(items.id, update.id));
}
console.log(`Migrated release widget providers to integrations count="${updates.length}"`);
}

View File

@@ -0,0 +1,6 @@
import type { Database } from "../..";
import { migrateReleaseWidgetProviderToOptionsAsync } from "./0000_release_widget_provider_to_options";
export const applyCustomMigrationsAsync = async (db: Database) => {
await migrateReleaseWidgetProviderToOptionsAsync(db);
};

View File

@@ -0,0 +1,12 @@
import { applyCustomMigrationsAsync } from ".";
import { database } from "../../driver";
applyCustomMigrationsAsync(database)
.then(() => {
console.log("Custom migrations applied successfully");
process.exit(0);
})
.catch((err) => {
console.log("Failed to apply custom migrations\n\t", err);
process.exit(1);
});

View File

@@ -5,6 +5,7 @@ import mysql from "mysql2";
import type { Database } from "../..";
import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
@@ -30,6 +31,7 @@ const migrateAsync = async () => {
await migrate(db, { migrationsFolder });
await seedDataAsync(db as unknown as Database);
await applyCustomMigrationsAsync(db as unknown as Database);
};
migrateAsync()

View File

@@ -1,5 +1,11 @@
import { objectKeys } from "@homarr/common";
import { createDocumentationLink, everyoneGroup } from "@homarr/definitions";
import {
createDocumentationLink,
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationKinds,
} from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
@@ -9,7 +15,8 @@ import {
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
import { onboarding, searchEngines } from "../schema";
import { integrations, onboarding, searchEngines } from "../schema";
import type { Integration } from "../schema";
import { groups } from "../schema/mysql";
export const seedDataAsync = async (db: Database) => {
@@ -17,6 +24,7 @@ export const seedDataAsync = async (db: Database) => {
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -131,3 +139,53 @@ const seedServerSettingsAsync = async (db: Database) => {
console.log(`Updated serverSetting through seed key=${settingsKey}`);
}
};
const seedDefaultIntegrationsAsync = async (db: Database) => {
const defaultIntegrations = integrationKinds.reduce<Integration[]>((acc, kind) => {
const name = getIntegrationName(kind);
const defaultUrl = getIntegrationDefaultUrl(kind);
if (defaultUrl !== undefined) {
acc.push({
id: "new",
name: `${name} Default`,
url: defaultUrl,
kind,
});
}
return acc;
}, []);
if (defaultIntegrations.length === 0) {
console.warn("No default integrations found to seed");
return;
}
let createdCount = 0;
await Promise.all(
defaultIntegrations.map(async (integration) => {
const existingKind = await db.$count(integrations, eq(integrations.kind, integration.kind));
if (existingKind > 0) {
console.log(`Skipping seeding of default ${integration.kind} integration as one already exists`);
return;
}
const newIntegration = {
...integration,
id: createId(),
};
await db.insert(integrations).values(newIntegration);
createdCount++;
}),
);
if (createdCount === 0) {
console.log("No default integrations were created as they already exist");
return;
}
console.log(`Created ${createdCount} default integrations through seeding process`);
};

View File

@@ -4,6 +4,7 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? ".";
@@ -16,6 +17,7 @@ const migrateAsync = async () => {
migrate(db, { migrationsFolder });
await seedDataAsync(db);
await applyCustomMigrationsAsync(db);
};
migrateAsync()

View File

@@ -23,12 +23,13 @@
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"migration:custom": "pnpm with-env tsx ./migrations/custom/run-custom.ts",
"migration:mysql:drop": "pnpm with-env drizzle-kit drop --config ./configs/mysql.config.ts",
"migration:mysql:generate": "pnpm with-env drizzle-kit generate --config ./configs/mysql.config.ts",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed",
"migration:mysql:run": "pnpm with-env drizzle-kit migrate --config ./configs/mysql.config.ts && pnpm run seed && pnpm run migration:custom",
"migration:sqlite:drop": "pnpm with-env drizzle-kit drop --config ./configs/sqlite.config.ts",
"migration:sqlite:generate": "pnpm with-env drizzle-kit generate --config ./configs/sqlite.config.ts",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed",
"migration:sqlite:run": "pnpm with-env drizzle-kit migrate --config ./configs/sqlite.config.ts && pnpm run seed && pnpm run migration:custom",
"push:mysql": "pnpm with-env drizzle-kit push --config ./configs/mysql.config.ts",
"push:sqlite": "pnpm with-env drizzle-kit push --config ./configs/sqlite.config.ts",
"seed": "pnpm with-env tsx ./migrations/run-seed.ts",
@@ -52,7 +53,8 @@
"drizzle-kit": "^0.31.4",
"drizzle-orm": "^0.44.2",
"drizzle-zod": "^0.7.1",
"mysql2": "3.14.2"
"mysql2": "3.14.2",
"superjson": "2.2.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",

View File

@@ -7,6 +7,7 @@ export const integrationSecretKindObject = {
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
personalAccessToken: { isPublic: false },
topic: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>;
@@ -17,6 +18,7 @@ interface integrationDefinition {
iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
defaultUrl?: string; // optional default URL for the integration
}
export const integrationDefs = {
@@ -170,6 +172,41 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
},
github: {
name: "Github",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",
},
dockerHub: {
name: "Docker Hub",
secretKinds: [[], ["username", "personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/docker.svg",
category: ["releasesProvider"],
defaultUrl: "https://hub.docker.com",
},
gitlab: {
name: "Gitlab",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg",
category: ["releasesProvider"],
defaultUrl: "https://gitlab.com",
},
npm: {
name: "NPM",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/npm.svg",
category: ["releasesProvider"],
defaultUrl: "https://registry.npmjs.org",
},
codeberg: {
name: "Codeberg",
secretKinds: [[], ["personalAccessToken"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/codeberg.svg",
category: ["releasesProvider"],
defaultUrl: "https://codeberg.org",
},
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],
@@ -209,6 +246,11 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds;
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
const definition = integrationDefs[integration];
return "defaultUrl" in definition ? definition.defaultUrl : undefined;
};
/**
* Get all integration kinds that share a category, typed only by the kinds belonging to the category
* @param category Category to filter by, belonging to IntegrationCategory
@@ -234,20 +276,25 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs;
export type IntegrationCategory =
| "dnsHole"
| "mediaService"
| "calendar"
| "mediaSearch"
| "mediaRequest"
| "downloadClient"
| "usenet"
| "torrent"
| "miscellaneous"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring"
| "search"
| "mediaTranscoding"
| "networkController"
| "notifications";
export const integrationCategories = [
"dnsHole",
"mediaService",
"calendar",
"mediaSearch",
"mediaRequest",
"downloadClient",
"usenet",
"torrent",
"miscellaneous",
"smartHomeServer",
"indexerManager",
"healthMonitoring",
"search",
"mediaTranscoding",
"networkController",
"releasesProvider",
"notifications",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];

View File

@@ -28,6 +28,7 @@
"@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.6.0",
"@ctrl/transmission": "^7.2.0",
"@gitbeaker/rest": "^42.5.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
@@ -40,6 +41,7 @@
"@jellyfin/sdk": "^0.11.0",
"maria2": "^0.4.1",
"node-ical": "^0.20.1",
"octokit": "^5.0.3",
"proxmox-api": "1.1.1",
"tsdav": "^2.1.5",
"undici": "7.11.0",

View File

@@ -4,7 +4,9 @@ import type { Integration as DbIntegration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
@@ -12,6 +14,8 @@ import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorre
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
@@ -22,6 +26,7 @@ import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
@@ -94,6 +99,11 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,
npm: NPMIntegration,
codeberg: CodebergIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;

View File

@@ -0,0 +1,143 @@
import type { RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
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 { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
const localLogger = logger.child({ module: "CodebergIntegration" });
export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("personalAccessToken")) return await callback({});
return await callback({
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await this.withHeadersAsync(async (headers) => {
return await input.fetchAsync(this.url("/version"), {
headers,
});
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.identifier.split("/");
if (!owner || !name) {
localLogger.warn(
`Invalid identifier format. Expected 'owner/name', for ${repository.identifier} with Codeberg integration`,
{
identifier: repository.identifier,
},
);
return {
id: repository.id,
error: { code: "invalidIdentifier" },
};
}
const details = await this.getDetailsAsync(owner, name);
const releasesResponse = await this.withHeadersAsync(async (headers) => {
return fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
{ headers },
);
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
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);
}
}
protected async getDetailsAsync(owner: string, name: string): Promise<DetailsProviderResponse | undefined> {
const response = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
{
headers,
},
);
});
if (!response.ok) {
localLogger.warn(`Failed to get details response for ${owner}/${name} with Codeberg integration`, {
owner,
name,
error: response.statusText,
});
return undefined;
}
const responseJson = await response.json();
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
if (!success) {
localLogger.warn(`Failed to parse details response for ${owner}/${name} with Codeberg integration`, {
owner,
name,
error,
});
return undefined;
}
return {
projectUrl: data.html_url,
projectDescription: data.description,
isFork: data.fork,
isArchived: data.archived,
createdAt: data.created_at,
starsCount: data.stars_count,
openIssues: data.open_issues_count,
forksCount: data.forks_count,
};
}
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
export const releasesResponseSchema = 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(),
}),
);
export const detailsResponseSchema = 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(),
});

View File

@@ -0,0 +1,190 @@
import type { fetch, RequestInit, Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { logger } from "@homarr/log";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
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 { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas";
const localLogger = logger.child({ module: "DockerHubIntegration" });
export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration {
private readonly sessionStore: SessionStore<string>;
constructor(integration: IntegrationInput) {
super(integration);
this.sessionStore = createSessionStore(integration);
}
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken")) return await callback({});
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
localLogger.debug("Using stored session for request", { integrationId: this.integration.id });
const response = await callback({
Authorization: `Bearer ${storedSession}`,
});
if (response.status !== 401) {
return response;
}
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const accessToken = await this.getSessionAsync();
await this.sessionStore.setAsync(accessToken);
return await callback({
Authorization: `Bearer ${accessToken}`,
});
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken");
if (hasAuth) {
localLogger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id });
await this.getSessionAsync(input.fetchAsync);
} else {
localLogger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id });
const response = await input.fetchAsync(this.url("/v2/repositories/library"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
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,
});
});
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
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);
}
}
private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> {
const response = await this.withHeadersAsync(async (headers) => {
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), {
headers,
});
});
if (!response.ok) {
localLogger.warn(`Failed to get details response for ${relativeUrl} with DockerHub integration`, {
relativeUrl,
error: response.statusText,
});
return undefined;
}
const responseJson = await response.json();
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
if (!success) {
localLogger.warn(`Failed to parse details response for ${relativeUrl} with DockerHub integration`, {
relativeUrl,
error,
});
return undefined;
}
return {
projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`,
projectDescription: data.description,
createdAt: data.date_registered,
starsCount: data.star_count,
};
}
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> {
const response = await fetchAsync(this.url("/v2/auth/token"), {
method: "POST",
body: JSON.stringify({
identifier: this.getSecretValue("username"),
secret: this.getSecretValue("personalAccessToken"),
}),
});
if (!response.ok) throw new ResponseError(response);
const data = await response.json();
const result = await accessTokenResponseSchema.parseAsync(data);
if (!result.access_token) {
throw new ResponseError({ status: 401, url: response.url });
}
localLogger.info("Received session successfully", { integrationId: this.integration.id });
return result.access_token;
}
}

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
export const accessTokenResponseSchema = z.object({
access_token: z.string(),
});
export const releasesResponseSchema = z.object({
results: z.array(
z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
latestRelease: tag.name,
latestReleaseAt: tag.last_updated,
})),
),
});
export const detailsResponseSchema = z.object({
name: z.string(),
namespace: z.string(),
description: z.string(),
star_count: z.number(),
date_registered: z.string().transform((value) => new Date(value)),
});

View File

@@ -0,0 +1,152 @@
import { Octokit, RequestError } from "octokit";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
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 { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GithubIntegration" });
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const headers: RequestInit["headers"] = {
"User-Agent": GithubIntegration.userAgent,
};
if (this.hasSecretValue("personalAccessToken"))
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
const response = await input.fetchAsync(this.url("/octocat"), {
headers,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const [owner, name] = repository.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" },
};
}
const api = this.getApi();
const details = await this.getDetailsAsync(api, owner, name);
try {
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,
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.published_at) return acc;
acc.push({
latestRelease: release.tag_name,
latestReleaseAt: new Date(release.published_at),
releaseUrl: release.html_url,
releaseDescription: release.body ?? undefined,
isPreRelease: release.prerelease,
});
return acc;
}, []);
return getLatestRelease(releasesProviderResponse, repository, details);
} catch (error) {
const errorMessage = error instanceof RequestError ? 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 },
};
}
}
protected async getDetailsAsync(
api: Octokit,
owner: string,
name: string,
): Promise<DetailsProviderResponse | undefined> {
try {
const response = await api.rest.repos.get({
owner,
repo: name,
});
return {
projectUrl: response.data.html_url,
projectDescription: response.data.description ?? undefined,
isFork: response.data.fork,
isArchived: response.data.archived,
createdAt: new Date(response.data.created_at),
starsCount: response.data.stargazers_count,
openIssues: response.data.open_issues_count,
forksCount: response.data.forks_count,
};
} catch (error) {
localLogger.warn(`Failed to get details for ${owner}\\${name} with Github integration`, {
owner,
name,
error: error instanceof RequestError ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
return new Octokit({
baseUrl: this.url("/").origin,
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
userAgent: GithubIntegration.userAgent,
throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
});
}
}

View File

@@ -0,0 +1,159 @@
import type { Gitlab as CoreGitlab } from "@gitbeaker/core";
import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils";
import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils";
import { Gitlab } from "@gitbeaker/rest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
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 { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type {
DetailsProviderResponse,
ReleaseProviderResponse,
ReleasesRepository,
ReleasesResponse,
} from "../interfaces/releases-providers/releases-providers-types";
const localLogger = logger.child({ module: "GitlabIntegration" });
export class GitlabIntegration extends Integration implements ReleasesProviderIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v4/projects"), {
headers: {
...(this.hasSecretValue("personalAccessToken")
? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` }
: {}),
},
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const api = this.getApi();
const details = await this.getDetailsAsync(api, repository.identifier);
try {
const releasesResponse = await api.ProjectReleases.all(repository.identifier, {
perPage: 100,
});
if (releasesResponse instanceof Error) {
localLogger.warn(`Failed to get releases for ${repository.identifier} with Gitlab integration`, {
identifier: repository.identifier,
error: releasesResponse.message,
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
const releasesProviderResponse = releasesResponse.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.released_at) return acc;
const releaseDate = new Date(release.released_at);
acc.push({
latestRelease: release.name ?? release.tag_name,
latestReleaseAt: releaseDate,
releaseUrl: release._links.self,
releaseDescription: release.description ?? undefined,
isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730)
});
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),
});
return {
id: repository.id,
error: { code: "noReleasesFound" },
};
}
}
protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise<DetailsProviderResponse | undefined> {
try {
const response = await api.Projects.show(identifier);
if (response instanceof Error) {
localLogger.warn(`Failed to get details for ${identifier} with Gitlab integration`, {
identifier,
error: response.message,
});
return undefined;
}
if (!response.web_url) {
localLogger.warn(`No web URL found for ${identifier} with Gitlab integration`, {
identifier,
});
return undefined;
}
return {
projectUrl: response.web_url,
projectDescription: response.description,
isFork: response.forked_from_project !== null,
isArchived: response.archived,
createdAt: new Date(response.created_at),
starsCount: response.star_count,
openIssues: response.open_issues_count,
forksCount: response.forks_count,
};
} catch (error) {
localLogger.warn(`Failed to get details for ${identifier} with Gitlab integration`, {
identifier,
error: error instanceof Error ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
return new Gitlab({
host: this.url("/").origin,
requesterFn: createRequesterFn(
async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions),
async (endpoint: string, options?: Record<string, unknown>): Promise<FormattedResponse> => {
if (options === undefined) {
throw new Error("Gitlab library is not configured correctly. Options must be provided.");
}
const response = await fetchWithTrustedCertificatesAsync(
`${options.prefixUrl as string}${endpoint}`,
options,
);
const headers = Object.fromEntries(response.headers.entries());
return {
status: response.status,
headers,
body: await response.json(),
} as FormattedResponse;
},
),
...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}),
});
}
}

View File

@@ -27,6 +27,7 @@ export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
@@ -37,6 +38,7 @@ export type {
TdarrStatistics,
TdarrWorker,
} from "./interfaces/media-transcoding/media-transcoding-types";
export type { ReleasesResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
// Schemas

View File

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

View File

@@ -0,0 +1,53 @@
import type { TranslationObject } from "@homarr/translation";
export interface DetailsProviderResponse {
projectUrl?: string;
projectDescription?: string;
isFork?: boolean;
isArchived?: boolean;
createdAt?: Date;
starsCount?: number;
openIssues?: number;
forksCount?: number;
}
export interface ReleaseProviderResponse {
latestRelease: string;
latestReleaseAt: Date;
releaseUrl?: string;
releaseDescription?: string;
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;
error?:
| {
code: ReleasesErrorKeys;
}
| {
message: string;
};
}

View File

@@ -0,0 +1,56 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { IntegrationTestingInput } from "../base/integration";
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 { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
import type { ReleasesRepository, ReleasesResponse } from "../interfaces/releases-providers/releases-providers-types";
import { releasesResponseSchema } from "./npm-schemas";
export class NPMIntegration extends Integration implements ReleasesProviderIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/"));
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getLatestMatchingReleaseAsync(repository: ReleasesRepository): Promise<ReleasesResponse> {
const releasesResponse = await fetchWithTrustedCertificatesAsync(
this.url(`/${encodeURIComponent(repository.identifier)}`),
);
if (!releasesResponse.ok) {
return {
id: repository.id,
error: { message: releasesResponse.statusText },
};
}
const releasesResponseJson: unknown = await releasesResponse.json();
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
if (!success) {
return {
id: repository.id,
error: {
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);
}
}
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
export const releasesResponseSchema = z.object({
time: z.record(z.string().transform((value) => new Date(value))).transform((version) =>
Object.entries(version).map(([key, value]) => ({
latestRelease: key,
latestReleaseAt: value,
})),
),
versions: z.record(z.object({ description: z.string() })),
name: z.string(),
});

View File

@@ -155,7 +155,7 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea
return response;
}
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const session = await this.getSessionAsync();

View File

@@ -128,7 +128,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
return response;
}
localLogger.info("Session expired, getting new session", { integrationId: this.integration.id });
localLogger.debug("Session expired, getting new session", { integrationId: this.integration.id });
}
const sessionId = await this.getSessionAsync();

View File

@@ -1,304 +0,0 @@
import { z } from "zod";
export interface ReleasesProvider {
getDetailsUrl: (identifier: string) => string | undefined;
parseDetailsResponse: (response: unknown) => z.SafeParseReturnType<unknown, DetailsResponse> | undefined;
getReleasesUrl: (identifier: string) => string;
parseReleasesResponse: (response: unknown) => z.SafeParseReturnType<unknown, ReleasesResponse[]>;
}
interface ProvidersProps {
[key: string]: ReleasesProvider;
DockerHub: ReleasesProvider;
Github: ReleasesProvider;
Gitlab: ReleasesProvider;
Npm: ReleasesProvider;
Codeberg: ReleasesProvider;
}
export const Providers: ProvidersProps = {
DockerHub: {
getDetailsUrl(identifier) {
if (identifier.indexOf("/") > 0) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "";
}
return `https://hub.docker.com/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`;
} else {
return `https://hub.docker.com/v2/repositories/library/${encodeURIComponent(identifier)}`;
}
},
parseDetailsResponse(response) {
return z
.object({
name: z.string(),
namespace: z.string(),
description: z.string(),
star_count: z.number(),
date_registered: z.string().transform((value) => new Date(value)),
})
.transform((resp) => ({
projectUrl: `https://hub.docker.com/r/${resp.namespace === "library" ? "_" : resp.namespace}/${resp.name}`,
projectDescription: resp.description,
createdAt: resp.date_registered,
starsCount: resp.star_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/tags?page_size=200`;
},
parseReleasesResponse(response) {
return z
.object({
results: z.array(
z
.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) })
.transform((tag) => ({
identifier: "",
latestRelease: tag.name,
latestReleaseAt: tag.last_updated,
})),
),
})
.transform((resp) => {
return resp.results;
})
.safeParse(response);
},
},
Github: {
getDetailsUrl(identifier) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "";
}
return `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
},
parseDetailsResponse(response) {
return z
.object({
html_url: z.string(),
description: z.string().nullable(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stargazers_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
})
.transform((resp) => ({
projectUrl: resp.html_url,
projectDescription: resp.description ?? undefined,
isFork: resp.fork,
isArchived: resp.archived,
createdAt: resp.created_at,
starsCount: resp.stargazers_count,
openIssues: resp.open_issues_count,
forksCount: resp.forks_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/releases`;
},
parseReleasesResponse(response) {
return z
.array(
z
.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
html_url: z.string(),
body: z.string().nullable(),
prerelease: z.boolean(),
})
.transform((tag) => ({
identifier: "",
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.html_url,
releaseDescription: tag.body ?? undefined,
isPreRelease: tag.prerelease,
})),
)
.safeParse(response);
},
},
Gitlab: {
getDetailsUrl(identifier) {
return `https://gitlab.com/api/v4/projects/${encodeURIComponent(identifier)}`;
},
parseDetailsResponse(response) {
return z
.object({
web_url: z.string(),
description: z.string(),
forked_from_project: z.object({ id: z.number() }).optional(),
archived: z.boolean().optional(),
created_at: z.string().transform((value) => new Date(value)),
star_count: z.number(),
open_issues_count: z.number().optional(),
forks_count: z.number(),
})
.transform((resp) => ({
projectUrl: resp.web_url,
projectDescription: resp.description,
isFork: resp.forked_from_project !== undefined,
isArchived: resp.archived,
createdAt: resp.created_at,
starsCount: resp.star_count,
openIssues: resp.open_issues_count,
forksCount: resp.forks_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/releases`;
},
parseReleasesResponse(response) {
return z
.array(
z
.object({
name: z.string(),
released_at: z.string().transform((value) => new Date(value)),
description: z.string(),
_links: z.object({ self: z.string() }),
upcoming_release: z.boolean(),
})
.transform((tag) => ({
identifier: "",
latestRelease: tag.name,
latestReleaseAt: tag.released_at,
releaseUrl: tag._links.self,
releaseDescription: tag.description,
isPreRelease: tag.upcoming_release,
})),
)
.safeParse(response);
},
},
Npm: {
getDetailsUrl(_) {
return undefined;
},
parseDetailsResponse(_) {
return undefined;
},
getReleasesUrl(identifier) {
return `https://registry.npmjs.org/${encodeURIComponent(identifier)}`;
},
parseReleasesResponse(response) {
return z
.object({
time: z.record(z.string().transform((value) => new Date(value))).transform((version) =>
Object.entries(version).map(([key, value]) => ({
identifier: "",
latestRelease: key,
latestReleaseAt: value,
})),
),
versions: z.record(z.object({ description: z.string() })),
name: z.string(),
})
.transform((resp) => {
return resp.time.map((release) => ({
...release,
releaseUrl: `https://www.npmjs.com/package/${resp.name}/v/${release.latestRelease}`,
releaseDescription: resp.versions[release.latestRelease]?.description ?? "",
}));
})
.safeParse(response);
},
},
Codeberg: {
getDetailsUrl(identifier) {
const [owner, name] = identifier.split("/");
if (!owner || !name) {
return "";
}
return `https://codeberg.org/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`;
},
parseDetailsResponse(response) {
return z
.object({
html_url: z.string(),
description: z.string(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stars_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
})
.transform((resp) => ({
projectUrl: resp.html_url,
projectDescription: resp.description,
isFork: resp.fork,
isArchived: resp.archived,
createdAt: resp.created_at,
starsCount: resp.stars_count,
openIssues: resp.open_issues_count,
forksCount: resp.forks_count,
}))
.safeParse(response);
},
getReleasesUrl(identifier) {
return `${this.getDetailsUrl(identifier)}/releases`;
},
parseReleasesResponse(response) {
return z
.array(
z
.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
url: z.string(),
body: z.string(),
prerelease: z.boolean(),
})
.transform((tag) => ({
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.url,
releaseDescription: tag.body,
isPreRelease: tag.prerelease,
})),
)
.safeParse(response);
},
},
};
const _detailsSchema = z
.object({
projectUrl: z.string().optional(),
projectDescription: z.string().optional(),
isFork: z.boolean().optional(),
isArchived: z.boolean().optional(),
createdAt: z.date().optional(),
starsCount: z.number().optional(),
openIssues: z.number().optional(),
forksCount: z.number().optional(),
})
.optional();
const _releasesSchema = z.object({
latestRelease: z.string(),
latestReleaseAt: z.date(),
releaseUrl: z.string().optional(),
releaseDescription: z.string().optional(),
isPreRelease: z.boolean().optional(),
error: z
.object({
code: z.string().optional(),
message: z.string().optional(),
})
.optional(),
});
export type DetailsResponse = z.infer<typeof _detailsSchema>;
export type ReleasesResponse = z.infer<typeof _releasesSchema>;

View File

@@ -1,122 +1,37 @@
import dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTimeout } from "@homarr/common";
import { logger } from "@homarr/log";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIconUrl } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { ReleasesResponse } from "@homarr/integrations";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { Providers } from "./releases-providers";
import type { DetailsResponse } from "./releases-providers";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const errorSchema = z.object({
code: z.string().optional(),
message: z.string().optional(),
});
export const releasesRequestHandler = createCachedIntegrationRequestHandler<
ReleasesResponse,
IntegrationKindByCategory<"releasesProvider">,
{
id: string;
identifier: string;
versionRegex?: string;
}
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const response = await integrationInstance.getLatestMatchingReleaseAsync({
id: input.id,
identifier: input.identifier,
versionRegex: input.versionRegex,
});
type ReleasesError = z.infer<typeof errorSchema>;
const _reponseSchema = z.object({
identifier: z.string(),
providerKey: z.string(),
latestRelease: z.string().optional(),
latestReleaseAt: z.date().optional(),
releaseUrl: z.string().optional(),
releaseDescription: z.string().optional(),
isPreRelease: z.boolean().optional(),
projectUrl: z.string().optional(),
projectDescription: z.string().optional(),
isFork: z.boolean().optional(),
isArchived: z.boolean().optional(),
createdAt: z.date().optional(),
starsCount: z.number().optional(),
openIssues: z.number().optional(),
forksCount: z.number().optional(),
error: errorSchema.optional(),
});
const formatErrorRelease = (identifier: string, providerKey: string, error: ReleasesError) => ({
identifier,
providerKey,
latestRelease: undefined,
latestReleaseAt: undefined,
releaseUrl: undefined,
releaseDescription: undefined,
isPreRelease: undefined,
projectUrl: undefined,
projectDescription: undefined,
isFork: undefined,
isArchived: undefined,
createdAt: undefined,
starsCount: undefined,
openIssues: undefined,
forksCount: undefined,
error,
});
export const releasesRequestHandler = createCachedWidgetRequestHandler({
queryKey: "releasesApiResult",
widgetKind: "releases",
async requestAsync(input: { providerKey: string; identifier: string; versionRegex: string | undefined }) {
const provider = Providers[input.providerKey];
if (!provider) return undefined;
let detailsResult: DetailsResponse;
const detailsUrl = provider.getDetailsUrl(input.identifier);
if (detailsUrl !== undefined) {
const detailsResponse = await fetchWithTimeout(detailsUrl);
const parsedDetails = provider.parseDetailsResponse(await detailsResponse.json());
if (parsedDetails?.success) {
detailsResult = parsedDetails.data;
} else {
detailsResult = undefined;
logger.warn(`Failed to parse details response for ${input.identifier} on ${input.providerKey}`, {
provider: input.providerKey,
identifier: input.identifier,
detailsUrl,
error: parsedDetails?.error,
});
}
}
const releasesResponse = await fetchWithTimeout(provider.getReleasesUrl(input.identifier));
const releasesResponseJson: unknown = await releasesResponse.json();
const releasesResult = provider.parseReleasesResponse(releasesResponseJson);
if (!releasesResult.success) {
return formatErrorRelease(input.identifier, input.providerKey, {
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : releasesResult.error.message,
});
} else {
const releases = releasesResult.data.filter((result) =>
input.versionRegex && result.latestRelease ? new RegExp(input.versionRegex).test(result.latestRelease) : true,
);
const latest =
releases.length === 0
? formatErrorRelease(input.identifier, input.providerKey, { code: "noMatchingVersion" })
: releases.reduce(
(latest, result) => {
return {
...detailsResult,
...(result.latestReleaseAt > latest.latestReleaseAt ? result : latest),
identifier: input.identifier,
providerKey: input.providerKey,
};
},
{
identifier: "",
providerKey: "",
latestRelease: "",
latestReleaseAt: new Date(0),
},
);
return latest;
}
return {
...response,
integration: {
name: integration.name,
iconUrl: getIconUrl(integration.kind),
},
};
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
});
export type ReleaseResponse = z.infer<typeof _reponseSchema>;

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "已创建",
"error": {
"label": "错误",
"options": {
"messages": {
"noMatchingVersion": "没有找到匹配的版本"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "Oprettet",
"error": {
"label": "Fejl",
"options": {
"messages": {
"noMatchingVersion": "Ingen matchende version fundet"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "Erstellt",
"error": {
"label": "Fehler",
"options": {
"messages": {
"noMatchingVersion": "Keine passende Version gefunden"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -937,6 +937,10 @@
"label": "Realm",
"newLabel": "New realm"
},
"personalAccessToken": {
"label": "Personal Access Token",
"newLabel": "New Personal Access Token"
},
"topic": {
"label": "Topic",
"newLabel": "New topic"
@@ -2288,7 +2292,11 @@
"example": {
"label": "Example"
},
"invalid": "Invalid repository definition, please check the values"
"invalid": "Invalid repository definition, please check the values",
"noProvider": {
"label": "No Provider",
"tooltip": "The provider could not be parsed, please manually set it after importing the images"
}
}
},
"not-found": "Not Found",
@@ -2304,8 +2312,12 @@
"created": "Created",
"error": {
"label": "Error",
"options": {
"noMatchingVersion": "No matching version found"
"messages": {
"invalidIdentifier": "Invalid identifier",
"noMatchingVersion": "No matching version found",
"noReleasesFound": "No releases found",
"noProviderSeleceted": "No provider selected",
"noProviderResponse": "No response from provider"
}
}
},

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "נוצר",
"error": {
"label": "שגיאה",
"options": {
"messages": {
"noMatchingVersion": "לא נמצאה גרסה תואמת"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "作成日",
"error": {
"label": "エラー",
"options": {
"messages": {
"noMatchingVersion": "一致するバージョンが見つかりません"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "Vytvorené",
"error": {
"label": "Chyba",
"options": {
"messages": {
"noMatchingVersion": "Nenašla sa žiadna zodpovedajúca verzia"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "Oluşturuldu",
"error": {
"label": "Hata",
"options": {
"messages": {
"noMatchingVersion": "Eşleşen sürüm bulunamadı"
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "",
"error": {
"label": "",
"options": {
"messages": {
"noMatchingVersion": ""
}
}

View File

@@ -2304,7 +2304,7 @@
"created": "已創建",
"error": {
"label": "錯誤",
"options": {
"messages": {
"noMatchingVersion": "找不到匹配的版本"
}
}

View File

@@ -23,6 +23,7 @@ import type { CheckboxProps } from "@mantine/core";
import type { FormErrors } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangleFilled,
IconBrandDocker,
IconEdit,
IconPlus,
@@ -35,13 +36,17 @@ import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui";
import { isProviderKey, Providers } from "../releases/releases-providers";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository";
import { WidgetIntegrationSelect } from "../widget-integration-select";
import type { IntegrationSelectOption } from "../widget-integration-select";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
@@ -51,6 +56,10 @@ interface FormValidation {
errors: FormErrors;
}
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
@@ -68,9 +77,34 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const integrationsApi = clientApi.integration.allOfGivenCategory.useQuery(
{
category: "releasesProvider",
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
const integrations = useMemo(
() =>
integrationsApi.data?.reduce<Record<string, Integration>>((acc, integration) => {
acc[integration.id] = {
id: integration.id,
name: integration.name,
url: integration.url,
kind: integration.kind,
iconUrl: getIconUrl(integration.kind),
};
return acc;
}, {}) ?? {},
[integrationsApi],
);
const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => {
form.setFieldValue(`options.${property}.${index}.providerKey`, repository.providerKey);
form.setFieldValue(`options.${property}.${index}.providerIntegrationId`, repository.providerIntegrationId);
form.setFieldValue(`options.${property}.${index}.identifier`, repository.identifier);
form.setFieldValue(`options.${property}.${index}.name`, repository.name);
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
@@ -94,7 +128,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const addNewRepository = () => {
const repository: ReleasesRepository = {
providerKey: "DockerHub",
id: createId(),
identifier: "",
};
@@ -117,6 +151,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions,
integrations,
});
};
@@ -147,6 +182,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onClick={() =>
openImportModal({
repositories,
integrations,
versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return;
@@ -173,11 +209,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Divider my="sm" />
{repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
return (
<Stack key={`${repository.providerKey}.${repository.identifier}`} gap={5}>
<Stack key={repository.id} gap={5}>
<Group align="center" gap="xs">
<Image
src={repository.iconUrl ?? Providers[repository.providerKey].iconUrl}
src={repository.iconUrl ?? integration?.iconUrl ?? null}
style={{
height: "1.2em",
width: "1.2em",
@@ -185,7 +224,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
/>
<Text c="dimmed" fw={100} size="xs">
{Providers[repository.providerKey].name}
{integration?.name ?? ""}
</Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -202,6 +241,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
repository,
onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions,
integrations,
})
}
variant="light"
@@ -253,6 +293,7 @@ interface RepositoryEditProps {
onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
}
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
@@ -260,6 +301,10 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
const integrationSelectOptions: IntegrationSelectOption[] = useMemo(
() => Object.values(innerProps.integrations),
[innerProps.integrations],
);
// Allows user to not select an icon by removing the url from the input,
// will only try and get an icon if the name or identifier changes
@@ -313,23 +358,20 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
return (
<Stack>
<Group align="center" wrap="nowrap">
<Select
withAsterisk
label={tRepository("provider.label")}
data={Object.entries(Providers).map(([key, provider]) => ({
value: key,
label: provider.name,
}))}
value={tempRepository.providerKey}
error={formErrors[`${innerProps.fieldPath}.providerKey`]}
onChange={(value) => {
if (value && isProviderKey(value)) {
handleChange({ providerKey: value });
}
}}
style={{ flex: 1, flexBasis: "40%" }}
/>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<div style={{ flex: 0.3 }}>
<WidgetIntegrationSelect
canSelectMultiple={false}
withAsterisk
label={tRepository("provider.label")}
data={integrationSelectOptions}
value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
onChange={(value) => {
handleChange({ providerIntegrationId: value.length > 0 ? value[0] : undefined });
}}
/>
</div>
<TextInput
withAsterisk
@@ -350,11 +392,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.identifier`]}
w="100%"
style={{ flex: 0.7 }}
/>
</Group>
<Group align="center" wrap="nowrap">
<Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput
label={tRepository("name.label")}
value={tempRepository.name ?? ""}
@@ -364,22 +406,24 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.name`]}
style={{ flex: 1, flexBasis: "40%" }}
style={{ flex: 0.3 }}
/>
<IconPicker
withAsterisk={false}
value={tempRepository.iconUrl ?? ""}
onChange={(url) => {
if (url === "") {
setAutoSetIcon(false);
handleChange({ iconUrl: undefined });
} else {
handleChange({ iconUrl: url });
}
}}
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
/>
<div style={{ flex: 0.7 }}>
<IconPicker
withAsterisk={false}
value={tempRepository.iconUrl ?? ""}
onChange={(url) => {
if (url === "") {
setAutoSetIcon(false);
handleChange({ iconUrl: undefined });
} else {
handleChange({ iconUrl: url });
}
}}
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
/>
</div>
</Group>
<Fieldset legend={tRepository("versionFilter.label")}>
@@ -467,12 +511,14 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
interface ContainerImageSelectorProps {
containerImage: ReleasesRepositoryImport;
integration?: Integration;
versionFilterPrecisionOptions: string[];
onImageSelectionChanged?: (isSelected: boolean) => void;
}
const ContainerImageSelector = ({
containerImage,
integration,
versionFilterPrecisionOptions,
onImageSelectionChanged,
}: ContainerImageSelectorProps) => {
@@ -487,11 +533,7 @@ const ContainerImageSelector = ({
};
return (
<Group
key={`${Providers[containerImage.providerKey].name}/${containerImage.identifier}`}
gap="xl"
justify="space-between"
>
<Group gap="xl" justify="space-between">
<Group gap="md">
<Checkbox
label={
@@ -524,25 +566,33 @@ const ContainerImageSelector = ({
)}
</Group>
<Group>
<MaskedImage
color="dimmed"
imageUrl={Providers[containerImage.providerKey].iconUrl}
style={{
height: "1em",
width: "1em",
}}
/>
<Text ff="monospace" c="dimmed" size="sm">
{Providers[containerImage.providerKey].name}
</Text>
</Group>
<Tooltip label={tRepository("noProvider.tooltip")} disabled={!integration} withArrow>
<Group>
{integration ? (
<MaskedImage
color="dimmed"
imageUrl={integration.iconUrl}
style={{
height: "1em",
width: "1em",
}}
/>
) : (
<IconAlertTriangleFilled />
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
</Group>
);
};
interface RepositoryImportProps {
repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean;
@@ -563,26 +613,38 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
const containersImages: ReleasesRepositoryImport[] = useMemo(
() =>
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, containerImage) => {
const providerKey = containerImage.image.startsWith("ghcr.io/") ? "Github" : "DockerHub";
const [identifier, version] = containerImage.image.replace(/^(ghcr\.io\/|docker\.io\/)/, "").split(":");
const imageParts = containerImage.image.split("/");
const source = imageParts.length > 1 ? imageParts[0] : "docker.io";
const identifierImage = imageParts.length > 1 ? imageParts[1] : imageParts[0];
if (!identifier) return acc;
if (!source || !identifierImage) return acc;
if (acc.some((item) => item.providerKey === providerKey && item.identifier === identifier)) return acc;
const providerKey = source in containerImageToProviderKind ? containerImageToProviderKind[source] : "dockerHub";
const integrationId = Object.values(innerProps.integrations).find(
(integration) => integration.kind === providerKey,
)?.id;
const [identifier, version] = identifierImage.split(":");
if (!identifier || !integrationId) return acc;
if (acc.some((item) => item.providerIntegrationId === integrationId && item.identifier === identifier))
return acc;
acc.push({
providerKey,
id: createId(),
providerIntegrationId: integrationId,
identifier,
iconUrl: containerImage.iconUrl ?? undefined,
name: formatIdentifierName(identifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some(
(item) => item.providerKey === providerKey && item.identifier === identifier,
(item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
),
});
return acc;
}, []) ?? [],
[docker.data, innerProps.repositories],
[docker.data, innerProps.repositories, innerProps.integrations],
);
const handleConfirm = useCallback(() => {
@@ -635,10 +697,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
containersImages
.filter((containerImage) => !containerImage.alreadyImported)
.map((containerImage) => {
const integration = containerImage.providerIntegrationId
? innerProps.integrations[containerImage.providerIntegrationId]
: undefined;
return (
<ContainerImageSelector
key={`${containerImage.providerKey}/${containerImage.identifier}`}
key={containerImage.id}
containerImage={containerImage}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
onImageSelectionChanged={(isSelected) =>
isSelected
@@ -659,10 +726,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
containersImages
.filter((containerImage) => containerImage.alreadyImported)
.map((containerImage) => {
const integration = containerImage.providerIntegrationId
? innerProps.integrations[containerImage.providerIntegrationId]
: undefined;
return (
<ContainerImageSelector
key={`${containerImage.providerKey}/${containerImage.identifier}`}
key={containerImage.id}
containerImage={containerImage}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
/>
);
@@ -691,6 +763,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl",
});
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
"docker.io": "dockerHub",
};
const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {
const version = /(?<=\D|^)\d+(?:\.\d+)*(?![\d.])/.exec(imageVersion)?.[0];

View File

@@ -24,8 +24,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss";
import { Providers } from "./releases-providers";
import type { ReleasesRepositoryResponse } from "./releases-repository";
import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
const formatRelativeDate = (value: string): string => {
const isMonths = /\d+m/g.test(value);
@@ -38,7 +37,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
const now = useNow();
const formatter = useFormatter();
const board = useRequiredBoard();
const [expandedRepository, setExpandedRepository] = useState({ providerKey: "", identifier: "" });
const [expandedRepositoryId, setExpandedRepositoryId] = useState<string | null>(null);
const hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]);
const relativeDateOptions = useMemo(
() => ({
@@ -48,12 +47,38 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[options.newReleaseWithin, options.staleReleaseWithin],
);
const batchedRepositories = useMemo(() => splitToChunksWithNItems(options.repositories, 5), [options.repositories]);
// Group repositories by integration
const groupedRepositories = useMemo(() => {
return options.repositories.reduce(
(acc, repo) => {
const key = repo.providerIntegrationId;
if (!key) return acc;
acc[key] ??= [];
acc[key].push(repo);
return acc;
},
{} as Record<string, ReleasesRepository[]>,
);
}, [options.repositories]);
// For each group, split into chunks of 5
const batchedRepositories = useMemo(() => {
return Object.entries(groupedRepositories).flatMap(([integrationId, group]) =>
splitToChunksWithNItems(group, 5).map((chunk) => ({
integrationId,
repositories: chunk,
})),
);
}, [groupedRepositories]);
const [results] = clientApi.useSuspenseQueries((t) =>
batchedRepositories.flatMap((chunk) =>
batchedRepositories.flatMap(({ integrationId, repositories }) =>
t.widget.releases.getLatest({
repositories: chunk.map((repository) => ({
providerKey: repository.providerKey,
integrationId,
repositories: repositories.map((repository) => ({
id: repository.id,
identifier: repository.identifier,
versionFilter: repository.versionFilter,
})),
@@ -62,41 +87,56 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
);
const repositories = useMemo(() => {
const formattedResults = results
.flat()
.map(({ data }) => {
if (data === undefined) return undefined;
const formattedResults = options.repositories
.map((repository) => {
if (repository.providerIntegrationId === undefined) {
return {
...repository,
isNewRelease: false,
isStaleRelease: false,
latestReleaseAt: undefined,
error: {
code: "noProviderSeleceted",
},
};
}
const repository = options.repositories.find(
(repository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier,
);
const response = results.flat().find(({ data }) => data.id === repository.id)?.data;
if (repository === undefined) return undefined;
if (response === undefined)
return {
...repository,
isNewRelease: false,
isStaleRelease: false,
latestReleaseAt: undefined,
error: {
code: "noProviderResponse",
},
};
return {
...repository,
...data,
...response,
isNewRelease:
relativeDateOptions.newReleaseWithin !== "" && data.latestReleaseAt
? isDateWithin(data.latestReleaseAt, relativeDateOptions.newReleaseWithin)
relativeDateOptions.newReleaseWithin !== "" && response.latestReleaseAt
? isDateWithin(response.latestReleaseAt, relativeDateOptions.newReleaseWithin)
: false,
isStaleRelease:
relativeDateOptions.staleReleaseWithin !== "" && data.latestReleaseAt
? !isDateWithin(data.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
relativeDateOptions.staleReleaseWithin !== "" && response.latestReleaseAt
? !isDateWithin(response.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
: false,
};
})
.filter(
(repository) =>
repository !== undefined &&
(repository.error !== undefined ||
!options.showOnlyHighlighted ||
repository.isNewRelease ||
repository.isStaleRelease),
repository.error !== undefined ||
!options.showOnlyHighlighted ||
repository.isNewRelease ||
repository.isStaleRelease,
)
.sort((repoA, repoB) => {
if (repoA?.latestReleaseAt === undefined) return 1;
if (repoB?.latestReleaseAt === undefined) return -1;
if (repoA.latestReleaseAt === undefined) return -1;
if (repoB.latestReleaseAt === undefined) return 1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepositoryResponse[];
@@ -115,34 +155,24 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
]);
const toggleExpandedRepository = useCallback(
(repository: ReleasesRepositoryResponse) => {
if (
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier
) {
setExpandedRepository({ providerKey: "", identifier: "" });
} else {
setExpandedRepository({ providerKey: repository.providerKey, identifier: repository.identifier });
}
},
[expandedRepository],
(repository: ReleasesRepositoryResponse) =>
setExpandedRepositoryId(expandedRepositoryId === repository.id ? "" : repository.id),
[expandedRepositoryId],
);
return (
<Stack gap={0} className="releases">
{repositories.map((repository: ReleasesRepositoryResponse) => {
const isActive =
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier;
const isActive = expandedRepositoryId === repository.id;
const hasError = repository.error !== undefined;
return (
<Stack
key={`${repository.providerKey}.${repository.identifier}`}
key={repository.id}
className={combineClasses(
"releases-repository",
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
`releases-repository-${repository.providerKey}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
`releases-repository-${repository.integration?.name ?? "error"}-${repository.name || repository.identifier.replace(/[^a-zA-Z0-9]/g, "_")}`,
classes.releasesRepository,
)}
gap={0}
@@ -156,7 +186,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
>
<MaskedOrNormalImage
className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? Providers[repository.providerKey].iconUrl}
imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
@@ -471,20 +501,27 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
{repository.identifier}
</Text>
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={Providers[repository.providerKey].iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="iconColor" ff="monospace">
{Providers[repository.providerKey].name}
</Text>
</Group>
{repository.integration && (
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
<MaskedOrNormalImage
className="releases-repository-expanded-header-provider-icon"
imageUrl={repository.integration.iconUrl}
hasColor={hasIconColor}
style={{
width: "1em",
aspectRatio: "1/1",
}}
/>
<Text
className="releases-repository-expanded-header-provider-name"
size="xs"
c="iconColor"
ff="monospace"
>
{repository.integration.name}
</Text>
</Group>
)}
</Group>
{repository.createdAt && (
@@ -531,7 +568,7 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
c="red"
style={{ whiteSpace: "pre-wrap" }}
>
{repository.error.code ? t(`error.options.${repository.error.code}` as never) : repository.error.message}
{repository.error.code ? t(`error.messages.${repository.error.code}` as never) : repository.error.message}
</Text>
</>
)}

View File

@@ -39,7 +39,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
defaultValue: [],
validate: z.array(
z.object({
providerKey: z.string().min(1),
providerIntegrationId: z.string().optional(),
identifier: z.string().min(1),
name: z.string().optional(),
versionFilter: z

View File

@@ -1,33 +0,0 @@
export interface ReleasesProvider {
name: string;
iconUrl: string;
}
export const Providers = {
DockerHub: {
name: "Docker Hub",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/docker.svg",
},
Github: {
name: "Github",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/github-dark.svg",
},
Gitlab: {
name: "Gitlab",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitlab.svg",
},
Npm: {
name: "Npm",
iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets//assets/npm.svg",
},
Codeberg: {
name: "Codeberg",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/codeberg.svg",
},
} as const satisfies Record<string, ReleasesProvider>;
export type ProviderKey = keyof typeof Providers;
export const isProviderKey = (key: string): key is ProviderKey => {
return key in Providers;
};

View File

@@ -1,5 +1,3 @@
import type { ProviderKey } from "./releases-providers";
export interface ReleasesVersionFilter {
prefix?: string;
precision: number;
@@ -7,7 +5,8 @@ export interface ReleasesVersionFilter {
}
export interface ReleasesRepository {
providerKey: ProviderKey;
id: string;
providerIntegrationId?: string;
identifier: string;
name?: string;
versionFilter?: ReleasesVersionFilter;
@@ -33,5 +32,10 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository {
forksCount?: number;
openIssues?: number;
integration?: {
name: string;
iconUrl?: string;
};
error?: { code?: string; message?: string };
}

82
pnpm-lock.yaml generated
View File

@@ -826,6 +826,9 @@ importers:
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
dayjs:
specifier: ^1.11.13
version: 1.11.13
@@ -1089,6 +1092,9 @@ importers:
mysql2:
specifier: 3.14.2
version: 3.14.2
superjson:
specifier: 2.2.2
version: 2.2.2
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
@@ -1330,6 +1336,9 @@ importers:
'@ctrl/transmission':
specifier: ^7.2.0
version: 7.2.0
'@gitbeaker/rest':
specifier: ^42.5.0
version: 42.5.0
'@homarr/certificates':
specifier: workspace:^0.1.0
version: link:../certificates
@@ -1366,6 +1375,9 @@ importers:
node-ical:
specifier: ^0.20.1
version: 0.20.1
octokit:
specifier: ^5.0.3
version: 5.0.3
proxmox-api:
specifier: 1.1.1
version: 1.1.1
@@ -3275,6 +3287,18 @@ packages:
'@formatjs/intl-localematcher@0.5.5':
resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==}
'@gitbeaker/core@42.5.0':
resolution: {integrity: sha512-rMWpOPaZi1iLiifnOIoVO57p2EmQQdfIwP4txqNyMvG4WjYP5Ez0U7jRD9Nra41x6K5kTPBZkuQcAdxVWRJcEQ==}
engines: {node: '>=18.20.0'}
'@gitbeaker/requester-utils@42.5.0':
resolution: {integrity: sha512-HLdLS9LPBMVQumvroQg/4qkphLDtwDB+ygEsrD2u4oYCMUtXV4V1xaVqU4yTXjbTJ5sItOtdB43vYRkBcgueBw==}
engines: {node: '>=18.20.0'}
'@gitbeaker/rest@42.5.0':
resolution: {integrity: sha512-oC5cM6jS7aFOp0luTw5mWSRuMgdxwHRLZQ/aWkI+ETMfsprR/HyxsXfljlMY/XJ/fRxTbRJiodR5Axf66WjO3w==}
engines: {node: '>=18.20.0'}
'@grpc/grpc-js@1.12.5':
resolution: {integrity: sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==}
engines: {node: '>=12.10.0'}
@@ -8696,6 +8720,10 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch-browser@2.2.6:
resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==}
engines: {node: '>=8.6'}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
@@ -8994,6 +9022,9 @@ packages:
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
rate-limiter-flexible@4.0.1:
resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==}
raw-body@2.5.2:
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
engines: {node: '>= 0.8'}
@@ -10762,6 +10793,9 @@ packages:
utf-8-validate:
optional: true
xcase@2.0.1:
resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==}
xdg-basedir@4.0.0:
resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==}
engines: {node: '>=8'}
@@ -11698,6 +11732,24 @@ snapshots:
dependencies:
tslib: 2.8.1
'@gitbeaker/core@42.5.0':
dependencies:
'@gitbeaker/requester-utils': 42.5.0
qs: 6.13.1
xcase: 2.0.1
'@gitbeaker/requester-utils@42.5.0':
dependencies:
picomatch-browser: 2.2.6
qs: 6.13.1
rate-limiter-flexible: 4.0.1
xcase: 2.0.1
'@gitbeaker/rest@42.5.0':
dependencies:
'@gitbeaker/core': 42.5.0
'@gitbeaker/requester-utils': 42.5.0
'@grpc/grpc-js@1.12.5':
dependencies:
'@grpc/proto-loader': 0.7.13
@@ -12131,7 +12183,7 @@ snapshots:
'@octokit/core': 7.0.2
'@octokit/oauth-app': 8.0.1
'@octokit/plugin-paginate-rest': 13.0.0(@octokit/core@7.0.2)
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
'@octokit/webhooks': 14.0.0
'@octokit/auth-app@8.0.1':
@@ -12140,7 +12192,7 @@ snapshots:
'@octokit/auth-oauth-user': 6.0.0
'@octokit/request': 10.0.2
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
toad-cache: 3.7.0
universal-github-app-jwt: 2.2.0
universal-user-agent: 7.0.2
@@ -12150,14 +12202,14 @@ snapshots:
'@octokit/auth-oauth-device': 8.0.1
'@octokit/auth-oauth-user': 6.0.0
'@octokit/request': 10.0.2
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
universal-user-agent: 7.0.2
'@octokit/auth-oauth-device@8.0.1':
dependencies:
'@octokit/oauth-methods': 6.0.0
'@octokit/request': 10.0.2
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
universal-user-agent: 7.0.2
'@octokit/auth-oauth-user@6.0.0':
@@ -12165,7 +12217,7 @@ snapshots:
'@octokit/auth-oauth-device': 8.0.1
'@octokit/oauth-methods': 6.0.0
'@octokit/request': 10.0.2
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
universal-user-agent: 7.0.2
'@octokit/auth-token@6.0.0': {}
@@ -12173,7 +12225,7 @@ snapshots:
'@octokit/auth-unauthenticated@7.0.1':
dependencies:
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
'@octokit/core@7.0.2':
dependencies:
@@ -12187,13 +12239,13 @@ snapshots:
'@octokit/endpoint@11.0.0':
dependencies:
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
universal-user-agent: 7.0.2
'@octokit/graphql@9.0.1':
dependencies:
'@octokit/request': 10.0.2
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
universal-user-agent: 7.0.2
'@octokit/oauth-app@8.0.1':
@@ -12214,7 +12266,7 @@ snapshots:
'@octokit/oauth-authorization-url': 8.0.0
'@octokit/request': 10.0.2
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
'@octokit/openapi-types@25.0.0': {}
@@ -12251,13 +12303,13 @@ snapshots:
'@octokit/request-error@7.0.0':
dependencies:
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
'@octokit/request@10.0.2':
dependencies:
'@octokit/endpoint': 11.0.0
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
fast-content-type-parse: 3.0.0
universal-user-agent: 7.0.2
@@ -17757,7 +17809,7 @@ snapshots:
'@octokit/plugin-retry': 8.0.1(@octokit/core@7.0.2)
'@octokit/plugin-throttling': 11.0.1(@octokit/core@7.0.2)
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
'@octokit/webhooks': 14.0.0
ofetch@1.4.1:
@@ -18009,6 +18061,8 @@ snapshots:
picocolors@1.1.1: {}
picomatch-browser@2.2.6: {}
picomatch@2.3.1: {}
picomatch@4.0.2: {}
@@ -18368,6 +18422,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
rate-limiter-flexible@4.0.1: {}
raw-body@2.5.2:
dependencies:
bytes: 3.1.2
@@ -20529,6 +20585,8 @@ snapshots:
ws@8.18.3: {}
xcase@2.0.1: {}
xdg-basedir@4.0.0: {}
xml-but-prettier@1.0.1: