feat(releases-widget): define providers as integrations (#3253)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
1
packages/common/src/id.ts
Normal file
1
packages/common/src/id.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createId } from "@paralleldrive/cuid2";
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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}"`);
|
||||||
|
}
|
||||||
6
packages/db/migrations/custom/index.ts
Normal file
6
packages/db/migrations/custom/index.ts
Normal 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);
|
||||||
|
};
|
||||||
12
packages/db/migrations/custom/run-custom.ts
Normal file
12
packages/db/migrations/custom/run-custom.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>]>;
|
||||||
|
|||||||
143
packages/integrations/src/codeberg/codeberg-integration.ts
Normal file
143
packages/integrations/src/codeberg/codeberg-integration.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/integrations/src/codeberg/codeberg-schemas.ts
Normal file
22
packages/integrations/src/codeberg/codeberg-schemas.ts
Normal 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(),
|
||||||
|
});
|
||||||
190
packages/integrations/src/docker-hub/docker-hub-integration.ts
Normal file
190
packages/integrations/src/docker-hub/docker-hub-integration.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
packages/integrations/src/docker-hub/docker-hub-schemas.ts
Normal file
22
packages/integrations/src/docker-hub/docker-hub-schemas.ts
Normal 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)),
|
||||||
|
});
|
||||||
152
packages/integrations/src/github/github-integration.ts
Normal file
152
packages/integrations/src/github/github-integration.ts
Normal 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") } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
159
packages/integrations/src/gitlab/gitlab-integration.ts
Normal file
159
packages/integrations/src/gitlab/gitlab-integration.ts
Normal 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") } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
56
packages/integrations/src/npm/npm-integration.ts
Normal file
56
packages/integrations/src/npm/npm-integration.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/integrations/src/npm/npm-schemas.ts
Normal file
12
packages/integrations/src/npm/npm-schemas.ts
Normal 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(),
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -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>;
|
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "已创建",
|
"created": "已创建",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "错误",
|
"label": "错误",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": "没有找到匹配的版本"
|
"noMatchingVersion": "没有找到匹配的版本"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "נוצר",
|
"created": "נוצר",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "שגיאה",
|
"label": "שגיאה",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": "לא נמצאה גרסה תואמת"
|
"noMatchingVersion": "לא נמצאה גרסה תואמת"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "作成日",
|
"created": "作成日",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "エラー",
|
"label": "エラー",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": "一致するバージョンが見つかりません"
|
"noMatchingVersion": "一致するバージョンが見つかりません"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ı"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "",
|
"created": "",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "",
|
"label": "",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": ""
|
"noMatchingVersion": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2304,7 +2304,7 @@
|
|||||||
"created": "已創建",
|
"created": "已創建",
|
||||||
"error": {
|
"error": {
|
||||||
"label": "錯誤",
|
"label": "錯誤",
|
||||||
"options": {
|
"messages": {
|
||||||
"noMatchingVersion": "找不到匹配的版本"
|
"noMatchingVersion": "找不到匹配的版本"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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
82
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user