merge: origin/dev (widget-only integration refactor)

Resolve conflicts keeping our simplified request handlers (with TTL
cache) and adopting dev's new release-provider + timetable direct
implementations. Remove per-query staleTime from timetable widget.
This commit is contained in:
Thomas Camlong committed 2026-07-02 18:19:46 +02:00
commit 356fadb9d6
91 files changed
+2306 -2545

No files matched your search

+70 -31
View File
@@ -1,14 +1,15 @@
import SuperJSON from "superjson";
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "releases" });
import { throwIfActionForbiddenAsync } from "../board/board-access";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -28,45 +29,83 @@ const releaseVersionFilterSchema = z.object({
export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("releasesProvider")))
.input(
z.object({
itemId: z.string().optional(),
repositories: z.array(
z.object({
id: z.string(),
id: z.string().optional(),
provider: z.enum(releaseProviderKinds),
identifier: z.string(),
versionFilter: releaseVersionFilterSchema.optional(),
providerUrl: z.string().url().optional(),
}),
),
}),
)
.query(async ({ ctx, input }) => {
const settled = await Promise.allSettled(
input.repositories.map(async (repository) => {
const response = await releasesRequestHandler
.handler(ctx.integration, {
id: repository.id,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
})
.getDataAsync();
const tokensByProvider = new Map<string, string>();
let allowedRepoIds: Set<string> | null = null;
return {
id: repository.id,
integration: { name: ctx.integration.name, kind: ctx.integration.kind },
timestamp: response.timestamp,
...response.data,
};
if (input.itemId) {
try {
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
if (item && item.kind === "releases") {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
const repos = options.repositories as Array<{ id?: string }> | undefined;
if (repos) {
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
}
const secrets = await ctx.db.query.widgetSecrets.findMany({
where: eq(widgetSecrets.itemId, input.itemId),
});
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch {
// Skip corrupt secrets
}
}
}
} catch {
// Access denied or table not yet migrated -- proceed without tokens
}
}
return await Promise.all(
input.repositories.map(async (repository) => {
try {
const useToken = allowedRepoIds === null || allowedRepoIds.has(repository.id);
const response = await releasesRequestHandler
.handler({
id: repository.id,
provider: repository.provider,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
})
.getDataAsync();
return {
id: repository.id,
provider: repository.provider,
timestamp: response.timestamp,
...response.data,
};
} catch (error) {
return {
id: repository.id,
provider: repository.provider,
timestamp: new Date(),
success: false as const,
error: { code: "unexpected" as const, message: String(error) },
};
}
}),
);
return settled.flatMap((result, index) => {
if (result.status === "fulfilled") return [result.value];
logger.warn("Release fetch failed", {
repository: input.repositories[index]?.identifier,
error: result.reason,
});
return [];
});
}),
});