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 { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
@@ -9,5 +17,6 @@ export const integrationSecretIcons = {
password: IconPassword, password: IconPassword,
realm: IconServer, realm: IconServer,
tokenId: IconGrid3x3, tokenId: IconGrid3x3,
personalAccessToken: IconPasswordUser,
topic: IconMessage, topic: IconMessage,
} satisfies Record<IntegrationSecretKind, TablerIcon>; } satisfies Record<IntegrationSecretKind, TablerIcon>;

View File

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

View File

@@ -19,6 +19,7 @@ import {
getIconUrl, getIconUrl,
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
integrationCategories,
integrationDefs, integrationDefs,
integrationKinds, integrationKinds,
integrationSecretKindObject, integrationSecretKindObject,
@@ -129,6 +130,57 @@ export const integrationRouter = createTRPCRouter({
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), 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 search: protectedProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) })) .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {

View File

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

View File

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

View File

@@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@homarr/env": "workspace:^0.1.0", "@homarr/env": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"next": "15.3.5", "next": "15.3.5",
"react": "19.1.0", "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 "./date";
export * from "./stopwatch"; export * from "./stopwatch";
export * from "./hooks"; export * from "./hooks";
export * from "./id";
export * from "./url"; export * from "./url";
export * from "./number"; export * from "./number";
export * from "./error"; 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 type { Database } from "../..";
import { env } from "../../env"; import { env } from "../../env";
import * as mysqlSchema from "../../schema/mysql"; import * as mysqlSchema from "../../schema/mysql";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed"; import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? "."; const migrationsFolder = process.argv[2] ?? ".";
@@ -30,6 +31,7 @@ const migrateAsync = async () => {
await migrate(db, { migrationsFolder }); await migrate(db, { migrationsFolder });
await seedDataAsync(db as unknown as Database); await seedDataAsync(db as unknown as Database);
await applyCustomMigrationsAsync(db as unknown as Database);
}; };
migrateAsync() migrateAsync()

View File

@@ -1,5 +1,11 @@
import { objectKeys } from "@homarr/common"; 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 { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from ".."; import type { Database } from "..";
@@ -9,7 +15,8 @@ import {
insertServerSettingByKeyAsync, insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync, updateServerSettingByKeyAsync,
} from "../queries/server-setting"; } 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"; import { groups } from "../schema/mysql";
export const seedDataAsync = async (db: Database) => { export const seedDataAsync = async (db: Database) => {
@@ -17,6 +24,7 @@ export const seedDataAsync = async (db: Database) => {
await seedOnboardingAsync(db); await seedOnboardingAsync(db);
await seedServerSettingsAsync(db); await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db); await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
}; };
const seedEveryoneGroupAsync = async (db: Database) => { const seedEveryoneGroupAsync = async (db: Database) => {
@@ -131,3 +139,53 @@ const seedServerSettingsAsync = async (db: Database) => {
console.log(`Updated serverSetting through seed key=${settingsKey}`); 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 { env } from "../../env";
import * as sqliteSchema from "../../schema/sqlite"; import * as sqliteSchema from "../../schema/sqlite";
import { applyCustomMigrationsAsync } from "../custom";
import { seedDataAsync } from "../seed"; import { seedDataAsync } from "../seed";
const migrationsFolder = process.argv[2] ?? "."; const migrationsFolder = process.argv[2] ?? ".";
@@ -16,6 +17,7 @@ const migrateAsync = async () => {
migrate(db, { migrationsFolder }); migrate(db, { migrationsFolder });
await seedDataAsync(db); await seedDataAsync(db);
await applyCustomMigrationsAsync(db);
}; };
migrateAsync() migrateAsync()

View File

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

View File

@@ -7,6 +7,7 @@ export const integrationSecretKindObject = {
password: { isPublic: false }, password: { isPublic: false },
tokenId: { isPublic: true }, tokenId: { isPublic: true },
realm: { isPublic: true }, realm: { isPublic: true },
personalAccessToken: { isPublic: false },
topic: { isPublic: true }, topic: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>; } satisfies Record<string, { isPublic: boolean }>;
@@ -17,6 +18,7 @@ interface integrationDefinition {
iconUrl: string; iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>; category: AtLeastOneOf<IntegrationCategory>;
defaultUrl?: string; // optional default URL for the integration
} }
export const integrationDefs = { export const integrationDefs = {
@@ -170,6 +172,41 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"], 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: { ntfy: {
name: "ntfy", name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]], secretKinds: [["topic"], ["topic", "apiKey"]],
@@ -209,6 +246,11 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> => export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds; 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 * 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 * @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 IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs; export type IntegrationKind = keyof typeof integrationDefs;
export type IntegrationCategory =
| "dnsHole" export const integrationCategories = [
| "mediaService" "dnsHole",
| "calendar" "mediaService",
| "mediaSearch" "calendar",
| "mediaRequest" "mediaSearch",
| "downloadClient" "mediaRequest",
| "usenet" "downloadClient",
| "torrent" "usenet",
| "miscellaneous" "torrent",
| "smartHomeServer" "miscellaneous",
| "indexerManager" "smartHomeServer",
| "healthMonitoring" "indexerManager",
| "search" "healthMonitoring",
| "mediaTranscoding" "search",
| "networkController" "mediaTranscoding",
| "notifications"; "networkController",
"releasesProvider",
"notifications",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];

View File

@@ -28,6 +28,7 @@
"@ctrl/deluge": "^7.1.0", "@ctrl/deluge": "^7.1.0",
"@ctrl/qbittorrent": "^9.6.0", "@ctrl/qbittorrent": "^9.6.0",
"@ctrl/transmission": "^7.2.0", "@ctrl/transmission": "^7.2.0",
"@gitbeaker/rest": "^42.5.0",
"@homarr/certificates": "workspace:^0.1.0", "@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
@@ -40,6 +41,7 @@
"@jellyfin/sdk": "^0.11.0", "@jellyfin/sdk": "^0.11.0",
"maria2": "^0.4.1", "maria2": "^0.4.1",
"node-ical": "^0.20.1", "node-ical": "^0.20.1",
"octokit": "^5.0.3",
"proxmox-api": "1.1.1", "proxmox-api": "1.1.1",
"tsdav": "^2.1.5", "tsdav": "^2.1.5",
"undici": "7.11.0", "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 type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DockerHubIntegration } from "../docker-hub/docker-hub-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration"; import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-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 { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-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 { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-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 { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration"; import { MockIntegration } from "../mock/mock-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration"; import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration";
@@ -94,6 +99,11 @@ export const integrationCreators = {
emby: EmbyIntegration, emby: EmbyIntegration,
nextcloud: NextcloudIntegration, nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration, unifiController: UnifiControllerIntegration,
github: GithubIntegration,
dockerHub: DockerHubIntegration,
gitlab: GitlabIntegration,
npm: NPMIntegration,
codeberg: CodebergIntegration,
ntfy: NTFYIntegration, ntfy: NTFYIntegration,
mock: MockIntegration, mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>; } 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 { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types"; export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types"; export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } 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, TdarrStatistics,
TdarrWorker, TdarrWorker,
} from "./interfaces/media-transcoding/media-transcoding-types"; } from "./interfaces/media-transcoding/media-transcoding-types";
export type { ReleasesResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types"; export type { Notification } from "./interfaces/notifications/notification-types";
// Schemas // 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; 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(); const session = await this.getSessionAsync();

View File

@@ -128,7 +128,7 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
return response; 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(); 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 dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTimeout } from "@homarr/common"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { logger } from "@homarr/log"; 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 { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { Providers } from "./releases-providers";
import type { DetailsResponse } from "./releases-providers";
const errorSchema = z.object({ export const releasesRequestHandler = createCachedIntegrationRequestHandler<
code: z.string().optional(), ReleasesResponse,
message: z.string().optional(), 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>; return {
...response,
const _reponseSchema = z.object({ integration: {
identifier: z.string(), name: integration.name,
providerKey: z.string(), iconUrl: getIconUrl(integration.kind),
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;
}
}, },
cacheDuration: dayjs.duration(5, "minutes"), cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
}); });
export type ReleaseResponse = z.infer<typeof _reponseSchema>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -937,6 +937,10 @@
"label": "Realm", "label": "Realm",
"newLabel": "New realm" "newLabel": "New realm"
}, },
"personalAccessToken": {
"label": "Personal Access Token",
"newLabel": "New Personal Access Token"
},
"topic": { "topic": {
"label": "Topic", "label": "Topic",
"newLabel": "New topic" "newLabel": "New topic"
@@ -2288,7 +2292,11 @@
"example": { "example": {
"label": "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", "not-found": "Not Found",
@@ -2304,8 +2312,12 @@
"created": "Created", "created": "Created",
"error": { "error": {
"label": "Error", "label": "Error",
"options": { "messages": {
"noMatchingVersion": "No matching version found" "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": "", "created": "",
"error": { "error": {
"label": "", "label": "",
"options": { "messages": {
"noMatchingVersion": "" "noMatchingVersion": ""
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ import type { CheckboxProps } from "@mantine/core";
import type { FormErrors } from "@mantine/form"; import type { FormErrors } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { import {
IconAlertTriangleFilled,
IconBrandDocker, IconBrandDocker,
IconEdit, IconEdit,
IconPlus, IconPlus,
@@ -35,13 +36,17 @@ import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/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 { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals"; import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { MaskedImage } from "@homarr/ui"; import { MaskedImage } from "@homarr/ui";
import { isProviderKey, Providers } from "../releases/releases-providers";
import type { ReleasesRepository, ReleasesVersionFilter } from "../releases/releases-repository"; 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 type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common"; import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form"; import { useFormContext } from "./form";
@@ -51,6 +56,10 @@ interface FormValidation {
errors: FormErrors; errors: FormErrors;
} }
interface Integration extends IntegrationSelectOption {
iconUrl: string;
}
export const WidgetMultiReleasesRepositoriesInput = ({ export const WidgetMultiReleasesRepositoriesInput = ({
property, property,
kind, kind,
@@ -68,9 +77,34 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const { data: session } = useSession(); const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false; 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( const onRepositorySave = useCallback(
(repository: ReleasesRepository, index: number): FormValidation => { (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}.identifier`, repository.identifier);
form.setFieldValue(`options.${property}.${index}.name`, repository.name); form.setFieldValue(`options.${property}.${index}.name`, repository.name);
form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter); form.setFieldValue(`options.${property}.${index}.versionFilter`, repository.versionFilter);
@@ -94,7 +128,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
const addNewRepository = () => { const addNewRepository = () => {
const repository: ReleasesRepository = { const repository: ReleasesRepository = {
providerKey: "DockerHub", id: createId(),
identifier: "", identifier: "",
}; };
@@ -117,6 +151,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositorySave: (saved) => onRepositorySave(saved, index),
onRepositoryCancel: () => onRepositoryRemove(index), onRepositoryCancel: () => onRepositoryRemove(index),
versionFilterPrecisionOptions, versionFilterPrecisionOptions,
integrations,
}); });
}; };
@@ -147,6 +182,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
onClick={() => onClick={() =>
openImportModal({ openImportModal({
repositories, repositories,
integrations,
versionFilterPrecisionOptions, versionFilterPrecisionOptions,
onConfirm: (selectedRepositories) => { onConfirm: (selectedRepositories) => {
if (!selectedRepositories.length) return; if (!selectedRepositories.length) return;
@@ -173,11 +209,14 @@ export const WidgetMultiReleasesRepositoriesInput = ({
<Divider my="sm" /> <Divider my="sm" />
{repositories.map((repository, index) => { {repositories.map((repository, index) => {
const integration = repository.providerIntegrationId
? integrations[repository.providerIntegrationId]
: undefined;
return ( return (
<Stack key={`${repository.providerKey}.${repository.identifier}`} gap={5}> <Stack key={repository.id} gap={5}>
<Group align="center" gap="xs"> <Group align="center" gap="xs">
<Image <Image
src={repository.iconUrl ?? Providers[repository.providerKey].iconUrl} src={repository.iconUrl ?? integration?.iconUrl ?? null}
style={{ style={{
height: "1.2em", height: "1.2em",
width: "1.2em", width: "1.2em",
@@ -185,7 +224,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
/> />
<Text c="dimmed" fw={100} size="xs"> <Text c="dimmed" fw={100} size="xs">
{Providers[repository.providerKey].name} {integration?.name ?? ""}
</Text> </Text>
<Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}> <Group justify="space-between" align="center" style={{ flex: 1 }} gap={5}>
@@ -202,6 +241,7 @@ export const WidgetMultiReleasesRepositoriesInput = ({
repository, repository,
onRepositorySave: (saved) => onRepositorySave(saved, index), onRepositorySave: (saved) => onRepositorySave(saved, index),
versionFilterPrecisionOptions, versionFilterPrecisionOptions,
integrations,
}) })
} }
variant="light" variant="light"
@@ -253,6 +293,7 @@ interface RepositoryEditProps {
onRepositorySave: (repository: ReleasesRepository) => FormValidation; onRepositorySave: (repository: ReleasesRepository) => FormValidation;
onRepositoryCancel?: () => void; onRepositoryCancel?: () => void;
versionFilterPrecisionOptions: string[]; versionFilterPrecisionOptions: string[];
integrations: Record<string, Integration>;
} }
const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => { const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, actions }) => {
@@ -260,6 +301,10 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository })); const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({}); 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, // 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 // will only try and get an icon if the name or identifier changes
@@ -313,23 +358,20 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
return ( return (
<Stack> <Stack>
<Group align="center" wrap="nowrap"> <Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
<Select <div style={{ flex: 0.3 }}>
withAsterisk <WidgetIntegrationSelect
label={tRepository("provider.label")} canSelectMultiple={false}
data={Object.entries(Providers).map(([key, provider]) => ({ withAsterisk
value: key, label={tRepository("provider.label")}
label: provider.name, data={integrationSelectOptions}
}))} value={tempRepository.providerIntegrationId ? [tempRepository.providerIntegrationId] : []}
value={tempRepository.providerKey} error={formErrors[`${innerProps.fieldPath}.providerIntegrationId`] as string}
error={formErrors[`${innerProps.fieldPath}.providerKey`]} onChange={(value) => {
onChange={(value) => { handleChange({ providerIntegrationId: value.length > 0 ? value[0] : undefined });
if (value && isProviderKey(value)) { }}
handleChange({ providerKey: value }); />
} </div>
}}
style={{ flex: 1, flexBasis: "40%" }}
/>
<TextInput <TextInput
withAsterisk withAsterisk
@@ -350,11 +392,11 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
if (event.currentTarget.value) setAutoSetIcon(true); if (event.currentTarget.value) setAutoSetIcon(true);
}} }}
error={formErrors[`${innerProps.fieldPath}.identifier`]} error={formErrors[`${innerProps.fieldPath}.identifier`]}
w="100%" style={{ flex: 0.7 }}
/> />
</Group> </Group>
<Group align="center" wrap="nowrap"> <Group align="center" wrap="nowrap" grow preventGrowOverflow={false}>
<TextInput <TextInput
label={tRepository("name.label")} label={tRepository("name.label")}
value={tempRepository.name ?? ""} value={tempRepository.name ?? ""}
@@ -364,22 +406,24 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
if (event.currentTarget.value) setAutoSetIcon(true); if (event.currentTarget.value) setAutoSetIcon(true);
}} }}
error={formErrors[`${innerProps.fieldPath}.name`]} error={formErrors[`${innerProps.fieldPath}.name`]}
style={{ flex: 1, flexBasis: "40%" }} style={{ flex: 0.3 }}
/> />
<IconPicker <div style={{ flex: 0.7 }}>
withAsterisk={false} <IconPicker
value={tempRepository.iconUrl ?? ""} withAsterisk={false}
onChange={(url) => { value={tempRepository.iconUrl ?? ""}
if (url === "") { onChange={(url) => {
setAutoSetIcon(false); if (url === "") {
handleChange({ iconUrl: undefined }); setAutoSetIcon(false);
} else { handleChange({ iconUrl: undefined });
handleChange({ iconUrl: url }); } else {
} handleChange({ iconUrl: url });
}} }
error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string} }}
/> error={formErrors[`${innerProps.fieldPath}.iconUrl`] as string}
/>
</div>
</Group> </Group>
<Fieldset legend={tRepository("versionFilter.label")}> <Fieldset legend={tRepository("versionFilter.label")}>
@@ -467,12 +511,14 @@ interface ReleasesRepositoryImport extends ReleasesRepository {
interface ContainerImageSelectorProps { interface ContainerImageSelectorProps {
containerImage: ReleasesRepositoryImport; containerImage: ReleasesRepositoryImport;
integration?: Integration;
versionFilterPrecisionOptions: string[]; versionFilterPrecisionOptions: string[];
onImageSelectionChanged?: (isSelected: boolean) => void; onImageSelectionChanged?: (isSelected: boolean) => void;
} }
const ContainerImageSelector = ({ const ContainerImageSelector = ({
containerImage, containerImage,
integration,
versionFilterPrecisionOptions, versionFilterPrecisionOptions,
onImageSelectionChanged, onImageSelectionChanged,
}: ContainerImageSelectorProps) => { }: ContainerImageSelectorProps) => {
@@ -487,11 +533,7 @@ const ContainerImageSelector = ({
}; };
return ( return (
<Group <Group gap="xl" justify="space-between">
key={`${Providers[containerImage.providerKey].name}/${containerImage.identifier}`}
gap="xl"
justify="space-between"
>
<Group gap="md"> <Group gap="md">
<Checkbox <Checkbox
label={ label={
@@ -524,25 +566,33 @@ const ContainerImageSelector = ({
)} )}
</Group> </Group>
<Group> <Tooltip label={tRepository("noProvider.tooltip")} disabled={!integration} withArrow>
<MaskedImage <Group>
color="dimmed" {integration ? (
imageUrl={Providers[containerImage.providerKey].iconUrl} <MaskedImage
style={{ color="dimmed"
height: "1em", imageUrl={integration.iconUrl}
width: "1em", style={{
}} height: "1em",
/> width: "1em",
<Text ff="monospace" c="dimmed" size="sm"> }}
{Providers[containerImage.providerKey].name} />
</Text> ) : (
</Group> <IconAlertTriangleFilled />
)}
<Text ff="monospace" c="dimmed" size="sm">
{integration?.name ?? tRepository("noProvider.label")}
</Text>
</Group>
</Tooltip>
</Group> </Group>
); );
}; };
interface RepositoryImportProps { interface RepositoryImportProps {
repositories: ReleasesRepository[]; repositories: ReleasesRepository[];
integrations: Record<string, Integration>;
versionFilterPrecisionOptions: string[]; versionFilterPrecisionOptions: string[];
onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void; onConfirm: (selectedRepositories: ReleasesRepositoryImport[]) => void;
isAdmin: boolean; isAdmin: boolean;
@@ -563,26 +613,38 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
const containersImages: ReleasesRepositoryImport[] = useMemo( const containersImages: ReleasesRepositoryImport[] = useMemo(
() => () =>
docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, containerImage) => { docker.data?.containers.reduce<ReleasesRepositoryImport[]>((acc, containerImage) => {
const providerKey = containerImage.image.startsWith("ghcr.io/") ? "Github" : "DockerHub"; const imageParts = containerImage.image.split("/");
const [identifier, version] = containerImage.image.replace(/^(ghcr\.io\/|docker\.io\/)/, "").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({ acc.push({
providerKey, id: createId(),
providerIntegrationId: integrationId,
identifier, identifier,
iconUrl: containerImage.iconUrl ?? undefined, iconUrl: containerImage.iconUrl ?? undefined,
name: formatIdentifierName(identifier), name: formatIdentifierName(identifier),
versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined, versionFilter: version ? parseImageVersionToVersionFilter(version) : undefined,
alreadyImported: innerProps.repositories.some( alreadyImported: innerProps.repositories.some(
(item) => item.providerKey === providerKey && item.identifier === identifier, (item) => item.providerIntegrationId === integrationId && item.identifier === identifier,
), ),
}); });
return acc; return acc;
}, []) ?? [], }, []) ?? [],
[docker.data, innerProps.repositories], [docker.data, innerProps.repositories, innerProps.integrations],
); );
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
@@ -635,10 +697,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
containersImages containersImages
.filter((containerImage) => !containerImage.alreadyImported) .filter((containerImage) => !containerImage.alreadyImported)
.map((containerImage) => { .map((containerImage) => {
const integration = containerImage.providerIntegrationId
? innerProps.integrations[containerImage.providerIntegrationId]
: undefined;
return ( return (
<ContainerImageSelector <ContainerImageSelector
key={`${containerImage.providerKey}/${containerImage.identifier}`} key={containerImage.id}
containerImage={containerImage} containerImage={containerImage}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions} versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
onImageSelectionChanged={(isSelected) => onImageSelectionChanged={(isSelected) =>
isSelected isSelected
@@ -659,10 +726,15 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
containersImages containersImages
.filter((containerImage) => containerImage.alreadyImported) .filter((containerImage) => containerImage.alreadyImported)
.map((containerImage) => { .map((containerImage) => {
const integration = containerImage.providerIntegrationId
? innerProps.integrations[containerImage.providerIntegrationId]
: undefined;
return ( return (
<ContainerImageSelector <ContainerImageSelector
key={`${containerImage.providerKey}/${containerImage.identifier}`} key={containerImage.id}
containerImage={containerImage} containerImage={containerImage}
integration={integration}
versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions} versionFilterPrecisionOptions={innerProps.versionFilterPrecisionOptions}
/> />
); );
@@ -691,6 +763,11 @@ const RepositoryImportModal = createModal<RepositoryImportProps>(({ innerProps,
size: "xl", size: "xl",
}); });
const containerImageToProviderKind: Record<string, IntegrationKind> = {
"ghcr.io": "github",
"docker.io": "dockerHub",
};
const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => { const parseImageVersionToVersionFilter = (imageVersion: string): ReleasesVersionFilter | undefined => {
const version = /(?<=\D|^)\d+(?:\.\d+)*(?![\d.])/.exec(imageVersion)?.[0]; 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 type { WidgetComponentProps } from "../definition";
import classes from "./component.module.scss"; import classes from "./component.module.scss";
import { Providers } from "./releases-providers"; import type { ReleasesRepository, ReleasesRepositoryResponse } from "./releases-repository";
import type { ReleasesRepositoryResponse } from "./releases-repository";
const formatRelativeDate = (value: string): string => { const formatRelativeDate = (value: string): string => {
const isMonths = /\d+m/g.test(value); const isMonths = /\d+m/g.test(value);
@@ -38,7 +37,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
const now = useNow(); const now = useNow();
const formatter = useFormatter(); const formatter = useFormatter();
const board = useRequiredBoard(); 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 hasIconColor = useMemo(() => board.iconColor !== null, [board.iconColor]);
const relativeDateOptions = useMemo( const relativeDateOptions = useMemo(
() => ({ () => ({
@@ -48,12 +47,38 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
[options.newReleaseWithin, options.staleReleaseWithin], [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) => const [results] = clientApi.useSuspenseQueries((t) =>
batchedRepositories.flatMap((chunk) => batchedRepositories.flatMap(({ integrationId, repositories }) =>
t.widget.releases.getLatest({ t.widget.releases.getLatest({
repositories: chunk.map((repository) => ({ integrationId,
providerKey: repository.providerKey, repositories: repositories.map((repository) => ({
id: repository.id,
identifier: repository.identifier, identifier: repository.identifier,
versionFilter: repository.versionFilter, versionFilter: repository.versionFilter,
})), })),
@@ -62,41 +87,56 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
); );
const repositories = useMemo(() => { const repositories = useMemo(() => {
const formattedResults = results const formattedResults = options.repositories
.flat() .map((repository) => {
.map(({ data }) => { if (repository.providerIntegrationId === undefined) {
if (data === undefined) return undefined; return {
...repository,
isNewRelease: false,
isStaleRelease: false,
latestReleaseAt: undefined,
error: {
code: "noProviderSeleceted",
},
};
}
const repository = options.repositories.find( const response = results.flat().find(({ data }) => data.id === repository.id)?.data;
(repository) => repository.providerKey === data.providerKey && repository.identifier === data.identifier,
);
if (repository === undefined) return undefined; if (response === undefined)
return {
...repository,
isNewRelease: false,
isStaleRelease: false,
latestReleaseAt: undefined,
error: {
code: "noProviderResponse",
},
};
return { return {
...repository, ...repository,
...data, ...response,
isNewRelease: isNewRelease:
relativeDateOptions.newReleaseWithin !== "" && data.latestReleaseAt relativeDateOptions.newReleaseWithin !== "" && response.latestReleaseAt
? isDateWithin(data.latestReleaseAt, relativeDateOptions.newReleaseWithin) ? isDateWithin(response.latestReleaseAt, relativeDateOptions.newReleaseWithin)
: false, : false,
isStaleRelease: isStaleRelease:
relativeDateOptions.staleReleaseWithin !== "" && data.latestReleaseAt relativeDateOptions.staleReleaseWithin !== "" && response.latestReleaseAt
? !isDateWithin(data.latestReleaseAt, relativeDateOptions.staleReleaseWithin) ? !isDateWithin(response.latestReleaseAt, relativeDateOptions.staleReleaseWithin)
: false, : false,
}; };
}) })
.filter( .filter(
(repository) => (repository) =>
repository !== undefined && repository.error !== undefined ||
(repository.error !== undefined || !options.showOnlyHighlighted ||
!options.showOnlyHighlighted || repository.isNewRelease ||
repository.isNewRelease || repository.isStaleRelease,
repository.isStaleRelease),
) )
.sort((repoA, repoB) => { .sort((repoA, repoB) => {
if (repoA?.latestReleaseAt === undefined) return 1; if (repoA.latestReleaseAt === undefined) return -1;
if (repoB?.latestReleaseAt === undefined) return -1; if (repoB.latestReleaseAt === undefined) return 1;
return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1; return repoA.latestReleaseAt > repoB.latestReleaseAt ? -1 : 1;
}) as ReleasesRepositoryResponse[]; }) as ReleasesRepositoryResponse[];
@@ -115,34 +155,24 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
]); ]);
const toggleExpandedRepository = useCallback( const toggleExpandedRepository = useCallback(
(repository: ReleasesRepositoryResponse) => { (repository: ReleasesRepositoryResponse) =>
if ( setExpandedRepositoryId(expandedRepositoryId === repository.id ? "" : repository.id),
expandedRepository.providerKey === repository.providerKey && [expandedRepositoryId],
expandedRepository.identifier === repository.identifier
) {
setExpandedRepository({ providerKey: "", identifier: "" });
} else {
setExpandedRepository({ providerKey: repository.providerKey, identifier: repository.identifier });
}
},
[expandedRepository],
); );
return ( return (
<Stack gap={0} className="releases"> <Stack gap={0} className="releases">
{repositories.map((repository: ReleasesRepositoryResponse) => { {repositories.map((repository: ReleasesRepositoryResponse) => {
const isActive = const isActive = expandedRepositoryId === repository.id;
expandedRepository.providerKey === repository.providerKey &&
expandedRepository.identifier === repository.identifier;
const hasError = repository.error !== undefined; const hasError = repository.error !== undefined;
return ( return (
<Stack <Stack
key={`${repository.providerKey}.${repository.identifier}`} key={repository.id}
className={combineClasses( className={combineClasses(
"releases-repository", "releases-repository",
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // 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, classes.releasesRepository,
)} )}
gap={0} gap={0}
@@ -156,7 +186,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
> >
<MaskedOrNormalImage <MaskedOrNormalImage
className="releases-repository-header-icon" className="releases-repository-header-icon"
imageUrl={repository.iconUrl ?? Providers[repository.providerKey].iconUrl} imageUrl={repository.iconUrl ?? repository.integration?.iconUrl}
hasColor={hasIconColor} hasColor={hasIconColor}
style={{ style={{
width: "1em", width: "1em",
@@ -471,20 +501,27 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
{repository.identifier} {repository.identifier}
</Text> </Text>
<Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center"> {repository.integration && (
<MaskedOrNormalImage <Group className="releases-repository-expanded-header-provider-wrapper" gap={5} align="center">
className="releases-repository-expanded-header-provider-icon" <MaskedOrNormalImage
imageUrl={Providers[repository.providerKey].iconUrl} className="releases-repository-expanded-header-provider-icon"
hasColor={hasIconColor} imageUrl={repository.integration.iconUrl}
style={{ hasColor={hasIconColor}
width: "1em", style={{
aspectRatio: "1/1", width: "1em",
}} aspectRatio: "1/1",
/> }}
<Text className="releases-repository-expanded-header-provider-name" size="xs" c="iconColor" ff="monospace"> />
{Providers[repository.providerKey].name} <Text
</Text> className="releases-repository-expanded-header-provider-name"
</Group> size="xs"
c="iconColor"
ff="monospace"
>
{repository.integration.name}
</Text>
</Group>
)}
</Group> </Group>
{repository.createdAt && ( {repository.createdAt && (
@@ -531,7 +568,7 @@ const ExpandedDisplay = ({ repository, hasIconColor }: ExtendedDisplayProps) =>
c="red" c="red"
style={{ whiteSpace: "pre-wrap" }} 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> </Text>
</> </>
)} )}

View File

@@ -39,7 +39,7 @@ export const { definition, componentLoader } = createWidgetDefinition("releases"
defaultValue: [], defaultValue: [],
validate: z.array( validate: z.array(
z.object({ z.object({
providerKey: z.string().min(1), providerIntegrationId: z.string().optional(),
identifier: z.string().min(1), identifier: z.string().min(1),
name: z.string().optional(), name: z.string().optional(),
versionFilter: z 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 { export interface ReleasesVersionFilter {
prefix?: string; prefix?: string;
precision: number; precision: number;
@@ -7,7 +5,8 @@ export interface ReleasesVersionFilter {
} }
export interface ReleasesRepository { export interface ReleasesRepository {
providerKey: ProviderKey; id: string;
providerIntegrationId?: string;
identifier: string; identifier: string;
name?: string; name?: string;
versionFilter?: ReleasesVersionFilter; versionFilter?: ReleasesVersionFilter;
@@ -33,5 +32,10 @@ export interface ReleasesRepositoryResponse extends ReleasesRepository {
forksCount?: number; forksCount?: number;
openIssues?: number; openIssues?: number;
integration?: {
name: string;
iconUrl?: string;
};
error?: { code?: string; message?: string }; error?: { code?: string; message?: string };
} }

82
pnpm-lock.yaml generated
View File

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