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

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

View File

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

View File

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

View File

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

View File

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

View File

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