diff --git a/client/.eslintrc.json b/client/.eslintrc.json index a2a9b3488b..44743ba3ea 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -186,6 +186,7 @@ "mathbf", "mathjax", "mergerequests", + "metadata_oauth", "mongodb", "monospace", "morgan", diff --git a/client/package.json b/client/package.json index e915f46f15..688f5ce98e 100644 --- a/client/package.json +++ b/client/package.json @@ -30,6 +30,7 @@ "generate-api:platform": "rtk-query-codegen-openapi src/features/platform/api/platform.api-config.ts", "generate-api:projectCloudStorage": "rtk-query-codegen-openapi src/features/project/components/cloudStorage/api/projectCloudStorage.api-config.ts", "generate-api:projectV2": "rtk-query-codegen-openapi src/features/projectsV2/api/projectV2.api-config.ts", + "generate-api:repositories": "rtk-query-codegen-openapi src/features/repositories/api/repositories.api-config.ts", "generate-api:searchV2": "rtk-query-codegen-openapi src/features/searchV2/api/searchV2.api-config.ts", "generate-api:sessionLaunchersV2": "rtk-query-codegen-openapi src/features/sessionsV2/api/sessionLaunchersV2.api-config.ts", "generate-api:sessionsV2": "rtk-query-codegen-openapi src/features/sessionsV2/api/sessionsV2.api-config.ts", @@ -42,6 +43,7 @@ "update-api:platform": "node scripts/update_api_spec.js platform", "update-api:projectCloudStorage": "node scripts/update_api_spec.js projectCloudStorage", "update-api:projectV2": "node scripts/update_api_spec.js projectV2", + "update-api:repositories": "node scripts/update_api_spec.js repositories", "update-api:searchV2": "node scripts/update_api_spec.js searchV2", "update-api:sessionLaunchersV2": "node scripts/update_api_spec.js sessionLaunchersV2", "update-api:sessionsV2": "node scripts/update_api_spec.js sessionsV2", diff --git a/client/scripts/update_api_spec.js b/client/scripts/update_api_spec.js index 5c4786f02a..e689ba2526 100644 --- a/client/scripts/update_api_spec.js +++ b/client/scripts/update_api_spec.js @@ -42,6 +42,8 @@ async function main() { updateProjectCloudStorageApi(); } else if (arg.trim() === "projectV2") { updateProjectV2Api(); + } else if (arg.trim() === "repositories") { + updateRepositoriesApi(); } else if (arg.trim() === "searchV2") { updateSearchV2Api(); } else if (arg.trim() === "sessionLaunchersV2") { @@ -105,6 +107,13 @@ async function updateProjectV2Api() { }); } +async function updateRepositoriesApi() { + updateApiFiles({ + specFile: "components/renku_data_services/repositories/api.spec.yaml", + destFile: "src/features/repositories/api/repositories.openapi.json", + }); +} + async function updateSearchV2Api() { updateApiFiles({ specFile: "components/renku_data_services/search/api.spec.yaml", diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index dd406d0540..2930f335c5 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -16,8 +16,6 @@ * limitations under the License. */ -import type { SerializedError } from "@reduxjs/toolkit"; -import { skipToken, type FetchBaseQueryError } from "@reduxjs/toolkit/query"; import cx from "classnames"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -25,13 +23,14 @@ import { CircleFill, FileCode, Pencil, + Plugin, + Send, Trash, XLg, } from "react-bootstrap-icons"; import { Controller, useForm } from "react-hook-form"; -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import { - Badge, Button, Col, DropdownItem, @@ -49,34 +48,19 @@ import { Row, } from "reactstrap"; +import { ErrorAlert, InfoAlert, WarnAlert } from "~/components/Alert"; +import { CommandCopy } from "~/components/commandCopy/CommandCopy"; +import RenkuBadge from "~/components/renkuBadge/RenkuBadge"; import RepositoryGitLabWarnBadge from "~/features/legacy/RepositoryGitLabWarnBadge"; -import { useLoginUrl } from "../../../../authentication/useLoginUrl.hook"; -import { - ErrorAlert, - RenkuAlert, - WarnAlert, -} from "../../../../components/Alert"; +import { useGetRepositoryQuery } from "~/features/repositories/api/repositories.api"; +import { useGetUserQueryState } from "~/features/usersV2/api/users.api"; +import { ABSOLUTE_ROUTES } from "~/routing/routes.constants"; import { ButtonWithMenuV2 } from "../../../../components/buttons/Button"; import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; import { Loader } from "../../../../components/Loader"; -import { ABSOLUTE_ROUTES } from "../../../../routing/routes.constants"; -import useLegacySelector from "../../../../utils/customHooks/useLegacySelector.hook"; -import { safeNewUrl } from "../../../../utils/helpers/safeNewUrl.utils"; -import { - connectedServicesApi, - useGetOauth2ProvidersQuery, - type Provider, -} from "../../../connectedServices/api/connectedServices.api"; -import { INTERNAL_GITLAB_PROVIDER_ID } from "../../../connectedServices/connectedServices.constants"; -import { ConnectButton } from "../../../connectedServices/ConnectedServicesPage"; import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; import { Project } from "../../../projectsV2/api/projectV2.api"; import { usePatchProjectsByProjectIdMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; -import repositoriesApi, { - useGetRepositoryMetadataQuery, - useGetRepositoryProbeQuery, -} from "../../../repositories/repositories.api"; -import { NotebooksErrorResponse } from "../../../session/sessions.types"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; import { SshRepositoryUrlWarning } from "./AddCodeRepositoryModal"; import { @@ -393,56 +377,6 @@ function CodeRepositoryActions({ ); } -interface CodeRepositoryErrorProps { - error: FetchBaseQueryError | SerializedError; - provider: Pick | undefined; -} - -function CodeRepositoryError({ error, provider }: CodeRepositoryErrorProps) { - if (!("data" in error)) - return ; - if (typeof error.data != "object") - return ; - if (error.data == null) - return ; - if (!("error" in error.data)) - return ; - const errorData = error.data as NotebooksErrorResponse; - - if (errorData.error.code == 1401) { - if (provider == null) - return ( - - There is a problem with the integration to the repository host. You - can check the{" "} - connected services{" "} - for more details. - - ); - - return ( - -
- There is a problem with the integration to the repository host. You - can try to reconnect or check the{" "} - connected services{" "} - for more details. -
-
- -
-
- ); - } - return ; -} - interface RepositoryItemProps { project: Project; readonly?: boolean; @@ -453,31 +387,16 @@ export function RepositoryItem({ readonly = false, url, }: RepositoryItemProps) { + const projectPermissions = useProjectPermissions({ projectId: project.id }); const [showDetails, setShowDetails] = useState(false); const toggleDetails = useCallback(() => { setShowDetails((open) => !open); }, []); - const canonicalUrlStr = useMemo(() => `${url.replace(/.git$/i, "")}`, [url]); - + const canonicalUrlStr = useMemo( + () => `${url.replace(/(?:\.git|\/)$/i, "")}`, + [url] + ); const title = getRepositoryName(url); - // ! Product team wants this restored -- keeping the code for the next iteration - // const urlDisplay = ( - //
- // - //
- // {canonicalUrl?.hostname && ( - // {canonicalUrl.hostname} - // )} - // - // {title || canonicalUrlStr} - // - // - //
- //
- // ); const listGroupProps = !readonly ? { @@ -495,12 +414,18 @@ export function RepositoryItem({
- + {title || canonicalUrlStr || ( Unknown repository )} - +
{!readonly && ( @@ -530,63 +455,37 @@ export function RepositoryItem({ ); } -interface RepositoryIconProps { - className?: string; - provider?: string | null; -} - -function RepositoryIcon({ className, provider }: RepositoryIconProps) { - const iconUrl = useMemo( - // eslint-disable-next-line spellcheck/spell-checker - () => (provider != null ? safeNewUrl("/favicon.ico", provider) : null), - [provider] - ); - - if (iconUrl == null) { - return null; - } - - return ( - - ); -} - interface RepositoryPermissionsProps { + hasWriteAccess?: boolean; repositoryUrl: string; } +export function RepositoryPermissionsBadge({ + hasWriteAccess, + repositoryUrl, +}: RepositoryPermissionsProps) { + const { data, isLoading, error } = useGetRepositoryQuery({ + url: repositoryUrl, + }); -function RepositoryPermissions({ repositoryUrl }: RepositoryPermissionsProps) { - const { - data: repositoryProviderMatch, - isLoading: isLoadingRepositoryProviderMatch, - error, - } = useGetRepositoryMetadataQuery({ repositoryUrl }); - - const isNotFound = error != null && "status" in error && error.status == 404; - - const { data: repositoryProbe, isLoading: isLoadingRepositoryProbe } = - useGetRepositoryProbeQuery(isNotFound ? { repositoryUrl } : skipToken); - - const isLoading = - isLoadingRepositoryProviderMatch || isLoadingRepositoryProbe; - - const permissions = useMemo(() => { - if (isNotFound && repositoryProbe) { - return { pull: true, push: false }; - } - const { pull, push } = repositoryProviderMatch?.repository_metadata - ?.permissions ?? { pull: false, push: false }; - return { pull, push }; - }, [ - isNotFound, - repositoryProbe, - repositoryProviderMatch?.repository_metadata?.permissions, - ]); + const badgeColor = isLoading + ? "light" + : error + ? "danger" + : !data?.metadata?.pull_permission + ? "danger" + : data?.metadata?.push_permission + ? "success" + : data?.connection?.status === "connected" + ? "success" + : data?.provider?.id && hasWriteAccess + ? "warning" + : data?.provider?.id && !hasWriteAccess + ? "success" + : data?.metadata?.pull_permission && hasWriteAccess + ? "warning" + : data?.metadata?.pull_permission && !hasWriteAccess + ? "success" + : "light"; const badgeIcon = isLoading ? ( @@ -595,26 +494,47 @@ function RepositoryPermissions({ repositoryUrl }: RepositoryPermissionsProps) { ); const badgeText = isLoading - ? null - : permissions.push - ? "Push & pull" - : permissions.pull - ? "Pull only" - : "No access"; - - const badgeColorClasses = isLoading - ? ["border-dark-subtle", "bg-light", "text-dark-emphasis"] - : permissions.push - ? ["border-success", "bg-success-subtle", "text-success-emphasis"] - : permissions.pull - ? ["border-warning", "bg-warning-subtle", "text-warning-emphasis"] - : ["border-danger", "bg-danger-subtle", "text-danger-emphasis"]; + ? "Loading..." + : error + ? "Error" + : !data?.metadata?.pull_permission && !data?.provider?.id + ? "Inaccessible" + : !data?.metadata?.pull_permission && + data?.connection?.status !== "connected" + ? "Integration required" + : !data?.metadata?.pull_permission && + data?.connection?.status === "connected" + ? "Inaccessible" + : !data?.metadata?.push_permission && !data?.provider?.id && hasWriteAccess + ? "Request integration" + : !data?.metadata?.push_permission && !data?.provider?.id && !hasWriteAccess + ? "Read only" + : !data?.metadata?.push_permission && + data?.connection?.status !== "connected" && + hasWriteAccess + ? "Integration recommended" + : !data?.metadata?.push_permission && + data?.connection?.status !== "connected" && + !hasWriteAccess + ? "Read only" + : !data?.metadata?.push_permission && + data?.connection?.status === "connected" + ? "Read only" + : data?.metadata?.push_permission && + data?.connection?.status === "connected" + ? "Read & write" + : "Unexpected"; return ( - + {badgeIcon} - {badgeText && {badgeText}} - + {badgeText} + ); } @@ -632,49 +552,23 @@ function RepositoryView({ title, toggleDetails, }: RepositoryViewProps) { - const { - data: repositoryProviderMatch, - isLoading: isLoadingRepositoryProviderMatch, - error, - } = repositoriesApi.endpoints.getRepositoryMetadata.useQueryState({ - repositoryUrl, + const { pathname, hash } = useLocation(); + const { data, isLoading, error } = useGetRepositoryQuery({ + url: repositoryUrl, }); - const { isLoading: isLoadingProviders, error: providersError } = - useGetOauth2ProvidersQuery(); - const isNotFound = error != null && "status" in error && error.status == 404; + const webUrl = useMemo(() => { + return data?.metadata?.web_url ? data.metadata.web_url : repositoryUrl; + }, [data, repositoryUrl]); - const { data: repositoryProbe, isLoading: isLoadingRepositoryProbe } = - repositoriesApi.endpoints.getRepositoryProbe.useQueryState( - isNotFound ? { repositoryUrl } : skipToken - ); + const search = useMemo(() => { + return `?${new URLSearchParams({ + targetProvider: data?.provider?.id ?? "", + source: `${pathname}${hash}`, + }).toString()}`; + }, [data, pathname, hash]); - const isLoading = - isLoadingRepositoryProviderMatch || - isLoadingProviders || - isLoadingRepositoryProbe; - - const permissions = useMemo(() => { - if (isNotFound && repositoryProbe) { - return { pull: true, push: false }; - } - const { pull, push } = repositoryProviderMatch?.repository_metadata - ?.permissions ?? { pull: false, push: false }; - return { pull, push }; - }, [ - isNotFound, - repositoryProbe, - repositoryProviderMatch?.repository_metadata?.permissions, - ]); - - const canonicalUrlStr = useMemo( - () => `${repositoryUrl.replace(/.git$/i, "")}`, - [repositoryUrl] - ); - const canonicalUrl = useMemo( - () => safeNewUrl(canonicalUrlStr), - [canonicalUrlStr] - ); + const projectPermissions = useProjectPermissions({ projectId: project.id }); return ( - +
-
-
-

Repository

-

- URL:{" "} - - {repositoryUrl} - - -

-
- {canonicalUrl && ( -

- From:{" "} - - - {canonicalUrl?.hostname} - -

- )} - {providersError && ( - - )} - {error && !isNotFound && ( - - )} - {!isLoading && !permissions.push && ( - - )} + {isLoading ? ( + + ) : (
-

Permissions

- - - Clone, Pull:{" "} - {isLoading ? ( - - ) : permissions.pull ? ( - - ) : ( - - )} - - - Push:{" "} - {isLoading ? ( - - ) : permissions.push ? ( - - ) : ( - - )} - - +
+

Repository

+

+ URL:{" "} + + {webUrl} + + +

+ {data?.metadata?.git_url && ( +
+ Git command: + +
+ )} +
+ +
+

Permissions

+ {error ? ( + + ) : ( + <> +
+
+ +
+ +
+ +
+ + Pull:{" "} + + +
+ +
+ + Push:{" "} + + +
+ +

+ Integration:{" "} + {!data?.provider?.id ? ( + "None" + ) : ( + + {data?.connection?.status === "connected" + ? "connected" + : "not connected"}{" "} + ( + + check details + + ) + + )} +

+ + )} +
- - - -
+ )}
); } -function RepositoryPermissionsAlert({ - repositoryUrl, -}: RepositoryPermissionsProps) { - const userLogged = useLegacySelector( - (state) => state.stateModel.user.logged +function LogInWarning() { + return ( +

+ You need to be logged in to activate integrations and access private + repositories. +

); +} - const { data: repositoryProviderMatch, error } = - repositoriesApi.endpoints.getRepositoryMetadata.useQueryState({ - repositoryUrl, - }); - const { data: providers } = - connectedServicesApi.endpoints.getOauth2Providers.useQueryState(); - - const isNotFound = error != null && "status" in error && error.status == 404; - - const { data: repositoryProbe } = - repositoriesApi.endpoints.getRepositoryProbe.useQueryState( - isNotFound ? { repositoryUrl } : skipToken - ); - - const permissions = useMemo(() => { - if (isNotFound && repositoryProbe) { - return { pull: true, push: false }; - } - const { pull, push } = repositoryProviderMatch?.repository_metadata - ?.permissions ?? { pull: false, push: false }; - return { pull, push }; - }, [ - isNotFound, - repositoryProbe, - repositoryProviderMatch?.repository_metadata?.permissions, - ]); +interface RepositoryCallToActionAlertProps { + hasWriteAccess: boolean; + repositoryUrl: string; +} +export function RepositoryCallToActionAlert({ + hasWriteAccess, + repositoryUrl, +}: RepositoryCallToActionAlertProps) { + const { pathname, hash } = useLocation(); + const { data, isLoading, error } = useGetRepositoryQuery({ + url: repositoryUrl, + }); - const provider = useMemo( - () => - repositoryProviderMatch?.provider_id === INTERNAL_GITLAB_PROVIDER_ID - ? { id: INTERNAL_GITLAB_PROVIDER_ID, display_name: "Internal GitLab" } - : providers?.find( - ({ id }) => id === repositoryProviderMatch?.provider_id - ), - [providers, repositoryProviderMatch?.provider_id] - ); + const { data: userInfo } = useGetUserQueryState(); + const anonymousUser = useMemo(() => { + return userInfo && !userInfo?.isLoggedIn; + }, [userInfo]); - const status = - repositoryProviderMatch?.connection_id || - (userLogged && - repositoryProviderMatch?.provider_id === INTERNAL_GITLAB_PROVIDER_ID) - ? "connected" - : "not-connected"; + const search = useMemo(() => { + return `?${new URLSearchParams({ + targetProvider: data?.provider?.id ?? "", + source: `${pathname}${hash}`, + }).toString()}`; + }, [data, pathname, hash]); - const loginUrl = useLoginUrl(); + if (isLoading) return null; - if (error && isNotFound) { - const color = permissions.pull ? "warning" : "danger"; + if (error) return ; + if (!data?.metadata?.pull_permission) { return ( - - -

No git provider found for this repository.

- {permissions.pull ? ( -

- This repository seems to be publicly available so you may be able - to clone and pull. + + {data?.provider?.id ? ( + <> +

+ Either the repository does not exist, or you do not have access to + it.

- ) : ( -

- This repository does not exist or RenkuLab cannot access it. + {anonymousUser ? ( + + ) : ( + <> +

+ If you think you should have access, check your integration{" "} + {data.provider.name}. +

+ + + View integration + + + )} + + ) : ( + <> +

+ The repository URL is invalid or points to a version control + platform we currently do not support. + {hasWriteAccess && ( + <> + {" "} + Please verify the URL and check if the platform is in the + currently supported{" "} + + + integrations list. + + + )}

- )} -
- - ); - } - if (error == null && !permissions.pull) { - return ( - - -

- This repository does not exist or you do not have access to it. -

- {!userLogged ? ( -

- You need to log in to perform pushes - to git repositories. -

- ) : provider && status === "not-connected" ? ( -

- Your user account is not currently connected to{" "} - {provider.display_name}. See{" "} - - connected services - - . -

- ) : null} -
- + {hasWriteAccess && ( +

+ If you're certain the URL is correct,{" "} + + + contact us + {" "} + about adding an integration. +

+ )} + + )} + ); } - if (error == null && !permissions.push) { + if ( + !data?.metadata?.push_permission && + !data?.provider?.id && + hasWriteAccess + ) { return ( - - -

- You are not allowed to push to this repository. -

- {!userLogged ? ( -

- You need to log in to perform pushes - to git repositories. -

- ) : provider && status === "not-connected" ? ( -

- Your user account is not currently connected to{" "} - {provider.display_name}. See{" "} - - connected services - - . -

- ) : null} -
- + +

+ The repository URL is valid. However, we don't currently support + this version control platform and you won't have the credentials + to push your code. +

+ +

+ If you want a smooth experience,{" "} + + + contact us + {" "} + about adding an integration. +

+
); } - return null; -} - -function YesBadge() { - return ( - - - Yes - - ); -} - -function NoBadge() { - return ( - - - No - - ); -} - -function RepositoryProviderDetails({ - repositoryUrl, -}: RepositoryPermissionsProps) { - const userLogged = useLegacySelector( - (state) => state.stateModel.user.logged - ); - - const { - data: repositoryProviderMatch, - isLoading: isLoadingRepositoryProviderMatch, - error: repositoryProviderMatchError, - } = repositoriesApi.endpoints.getRepositoryMetadata.useQueryState({ - repositoryUrl, - }); - const { - data: providers, - isLoading: isLoadingProviders, - error: providersError, - } = connectedServicesApi.endpoints.getOauth2Providers.useQueryState(); - - const isLoading = isLoadingRepositoryProviderMatch || isLoadingProviders; - const error = repositoryProviderMatchError ?? providersError; - - const isNotFound = - repositoryProviderMatchError != null && - "status" in repositoryProviderMatchError && - repositoryProviderMatchError.status == 404; - - const provider = useMemo(() => { - const canonicalUrl = safeNewUrl(repositoryUrl); - const providerId = - repositoryProviderMatch?.provider_id ?? canonicalUrl?.host; - if (repositoryProviderMatch?.provider_id === INTERNAL_GITLAB_PROVIDER_ID) - return { - id: INTERNAL_GITLAB_PROVIDER_ID, - display_name: "Internal GitLab", - kind: "gitlab" as const, - }; - return providers?.find(({ id }) => id === providerId); - }, [providers, repositoryProviderMatch?.provider_id, repositoryUrl]); - - const status = - repositoryProviderMatch?.connection_id || - (userLogged && - repositoryProviderMatch?.provider_id === INTERNAL_GITLAB_PROVIDER_ID) - ? "Connected" - : "Not connected"; - - if (isLoading) { + if ( + !data?.metadata?.push_permission && + data?.provider?.id && + data?.connection?.status !== "connected" && + hasWriteAccess + ) { return ( - <> - - Loading git provider details... - + +

+ You can log in through the integration{" "} + {data.provider.name} to enable + pushing to repositories for which you have permissions. +

+ + + View integration + +
); } - if (error && isNotFound) { - return null; - } - - if (error) { - return ; - } - - if (provider) { + if ( + !data?.metadata?.push_permission && + data?.provider?.id && + data?.connection?.status !== "connected" && + !hasWriteAccess + ) { return ( -
-
Git provider
-

{provider.display_name}

-

Status: {status}

-
+ +

+ If you want to enable pushing to repositories for which you have + permissions, you can log in through the integration{" "} + {data.provider.name}. +

+ {anonymousUser ? ( + + ) : ( + + + View integration + + )} +
); } return null; } + +interface YesNoBadgeProps { + value: boolean; +} +function YesNoBadge({ value }: YesNoBadgeProps) { + return value ? ( + + Yes + + ) : ( + + No + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts index f35d916d3a..d77f31265a 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts @@ -16,6 +16,10 @@ * limitations under the License. */ +import { + GetRepositoryApiResponse, + RepositoryInterrupts, +} from "~/features/repositories/api/repositories.api"; import { safeNewUrl } from "../../../../utils/helpers/safeNewUrl.utils"; /** @@ -70,8 +74,25 @@ export function detectSSHRepository(repositoryURL: string): boolean { return cleaned.match(gitUrlRegex) != null; } +export function shouldInterrupt( + repositoryData: GetRepositoryApiResponse +): RepositoryInterrupts { + const interruptAlways = !!( + !repositoryData?.metadata?.pull_permission && + !(repositoryData?.connection?.status === "connected") + ); + const interruptOwner = !!( + (!repositoryData?.metadata?.pull_permission && + !(repositoryData?.connection?.status === "connected")) || + (repositoryData?.metadata?.pull_permission && + !repositoryData?.metadata?.push_permission && + !(repositoryData?.connection?.status === "connected")) + ); + return { interruptAlways, interruptOwner }; +} + export function getRepositoryName(repositoryURL: string): string { - const canonicalUrlStr = `${repositoryURL.replace(/.git$/i, "")}`; + const canonicalUrlStr = `${repositoryURL.replace(/(?:\.git|\/)$/i, "")}`; const canonicalUrl = safeNewUrl(canonicalUrlStr); return canonicalUrl?.pathname.split("/").pop() || canonicalUrlStr; } diff --git a/client/src/features/repositories/api/repositories.api-config.ts b/client/src/features/repositories/api/repositories.api-config.ts new file mode 100644 index 0000000000..0166b296fb --- /dev/null +++ b/client/src/features/repositories/api/repositories.api-config.ts @@ -0,0 +1,33 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Run `npm run generate-api:repositories` to generate the API + +import path from "path"; +import type { ConfigFile } from "@rtk-query/codegen-openapi"; + +const config: ConfigFile = { + apiFile: "./repositories.empty-api.ts", + apiImport: "repositoriesEmptyApi", + outputFile: "./repositories.generated-api.ts", + exportName: "repositoriesGeneratedApi", + hooks: true, + schemaFile: path.join(__dirname, "repositories.openapi.json"), +}; + +export default config; diff --git a/client/src/features/repositories/api/repositories.api.ts b/client/src/features/repositories/api/repositories.api.ts new file mode 100644 index 0000000000..cb5d2de25c --- /dev/null +++ b/client/src/features/repositories/api/repositories.api.ts @@ -0,0 +1,122 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { shouldInterrupt } from "~/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; +import { + GetRepositoryApiResponse, + repositoriesGeneratedApi, +} from "./repositories.generated-api"; + +export type RepositoryInterrupts = { + interruptAlways: boolean; + interruptOwner: boolean; +}; + +export type RepositoriesApiResponseWithInterrupts = GetRepositoryApiResponse & + RepositoryInterrupts & { + error: boolean; + url: string; + }; + +const withResponseRewrite = repositoriesGeneratedApi.injectEndpoints({ + overrideExisting: true, + endpoints: (build) => ({ + getRepositoriesArray: build.query< + RepositoriesApiResponseWithInterrupts[], + string[] + >({ + async queryFn(queryArg, _api, _options, fetchWithBQ) { + const result: RepositoriesApiResponseWithInterrupts[] = []; + const promises = queryArg.map((repository) => + fetchWithBQ({ + url: "/repository", + params: { url: repository }, + }) + ); + const responses = await Promise.all(promises); + for (let i = 0; i < queryArg.length; i++) { + const repositoryUrl = queryArg[i]; + const response = responses[i]; + if (response.error) + result.push({ + error: true, + interruptAlways: true, + interruptOwner: true, + status: "unknown", + url: repositoryUrl, + }); + else if (response.data) { + const interrupts = shouldInterrupt( + response.data as GetRepositoryApiResponse + ); + result.push({ + ...(response.data as GetRepositoryApiResponse), + ...interrupts, + error: false, + url: repositoryUrl, + }); + } + } + + return { data: result }; + }, + }), + }), +}); + +const withTagHandling = withResponseRewrite.enhanceEndpoints({ + addTagTypes: ["Repository"], + endpoints: { + getRepository: { + providesTags: (result, _error, { url }) => + result ? [{ type: "Repository" as const, id: url }] : [], + }, + getRepositoriesArray: { + providesTags: (result) => + result != null + ? result.map(({ url }) => ({ + type: "Repository" as const, + id: url, + })) + : [], + }, + }, +}); + +export { withTagHandling as repositoriesApi }; +export const { useGetRepositoriesArrayQuery, useGetRepositoryQuery } = + withTagHandling; +export type * from "./repositories.generated-api"; diff --git a/client/src/features/repositories/api/repositories.empty-api.ts b/client/src/features/repositories/api/repositories.empty-api.ts new file mode 100644 index 0000000000..dfc11530b1 --- /dev/null +++ b/client/src/features/repositories/api/repositories.empty-api.ts @@ -0,0 +1,26 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; + +// initialize an empty api service that we'll inject endpoints into later as needed +export const repositoriesEmptyApi = createApi({ + baseQuery: fetchBaseQuery({ baseUrl: "/api/data" }), + endpoints: () => ({}), + reducerPath: "repositories", +}); diff --git a/client/src/features/repositories/api/repositories.generated-api.ts b/client/src/features/repositories/api/repositories.generated-api.ts new file mode 100644 index 0000000000..c10f8cbfe2 --- /dev/null +++ b/client/src/features/repositories/api/repositories.generated-api.ts @@ -0,0 +1,62 @@ +import { repositoriesEmptyApi as api } from "./repositories.empty-api"; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getRepository: build.query({ + query: (queryArg) => ({ + url: `/repository`, + params: { url: queryArg.url }, + }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as repositoriesGeneratedApi }; +export type GetRepositoryApiResponse = + /** status 200 The repository metadata. */ RepositoryProviderData; +export type GetRepositoryApiArg = { + url: string; +}; +export type Ulid = string; +export type ProviderId = string; +export type ProviderConnection = { + id: Ulid; + provider_id: ProviderId; + status: string; +}; +export type ProviderData = { + id: ProviderId; + name: string; + url: string; +}; +export type Metadata = { + git_url: string; + web_url?: string; + pull_permission: boolean; + push_permission?: boolean; +}; +export type RepositoryProviderData = { + status: "valid" | "invalid" | "unknown"; + connection?: ProviderConnection; + provider?: ProviderData; + metadata?: Metadata; + error_code?: + | "no_url_scheme" + | "no_url_host" + | "no_git_repo" + | "no_url_path" + | "invalid_url_scheme" + | "invalid_git_url" + | "metadata_unauthorized" + | "metadata_oauth" + | "metadata_unknown" + | "metadata_validation"; +}; +export type ErrorResponse = { + error: { + code: number; + detail?: string; + message: string; + }; +}; +export const { useGetRepositoryQuery } = injectedRtkApi; diff --git a/client/src/features/repositories/api/repositories.openapi.json b/client/src/features/repositories/api/repositories.openapi.json new file mode 100644 index 0000000000..86b5a10755 --- /dev/null +++ b/client/src/features/repositories/api/repositories.openapi.json @@ -0,0 +1,222 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Renku Data Services API", + "description": "This service is the main backend for Renku. It provides information about users, projects,\ncloud storage, access to compute resources and many other things.\n", + "version": "v1" + }, + "servers": [ + { + "url": "/api/data" + } + ], + "paths": { + "/repository": { + "get": { + "summary": "Get the metadata available about a repository", + "description": "The repository URL will be matched against the set of\navailable OAuth2 clients to determine which service to connect\nto. If a match is found, the corresponding service API will be\nused to fetch the repository metadata.\n\nIf no provider is found, the given url is checked for being a\npublic git repository. If this succeeds, the repository is\nlikely clonable.\n\nNote that only HTTP(S) URLs are supported.\n", + "parameters": [ + { + "in": "query", + "name": "url", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The repository metadata.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryProviderData" + } + } + } + }, + "404": { + "description": "There is no available provider for this repository." + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["repositories"] + } + } + }, + "components": { + "schemas": { + "Metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "git_url": { + "type": "string" + }, + "web_url": { + "type": "string" + }, + "pull_permission": { + "type": "boolean" + }, + "push_permission": { + "type": "boolean" + } + }, + "required": ["git_url", "pull_permission"] + }, + "RepositoryProviderData": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "enum": ["valid", "invalid", "unknown"] + }, + "connection": { + "$ref": "#/components/schemas/ProviderConnection" + }, + "provider": { + "$ref": "#/components/schemas/ProviderData" + }, + "metadata": { + "$ref": "#/components/schemas/Metadata" + }, + "error_code": { + "type": "string", + "enum": [ + "no_url_scheme", + "no_url_host", + "no_git_repo", + "no_url_path", + "invalid_url_scheme", + "invalid_git_url", + "metadata_unauthorized", + "metadata_oauth", + "metadata_unknown", + "metadata_validation" + ] + } + }, + "required": ["status"] + }, + "ProviderConnection": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/Ulid" + }, + "provider_id": { + "$ref": "#/components/schemas/ProviderId" + }, + "status": { + "type": "string" + } + }, + "required": ["id", "provider_id", "status"] + }, + "ProviderData": { + "type": "object", + "properties": { + "id": { + "$ref": "#/components/schemas/ProviderId" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["id", "name", "url"] + }, + "RepositoryMetadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "git_http_url": { + "$ref": "#/components/schemas/WebUrl" + }, + "web_url": { + "$ref": "#/components/schemas/WebUrl" + }, + "permissions": { + "$ref": "#/components/schemas/RepositoryPermissions" + } + }, + "required": ["git_http_url", "web_url", "permissions"] + }, + "RepositoryPermissions": { + "type": "object", + "additionalProperties": false, + "properties": { + "pull": { + "type": "boolean" + }, + "push": { + "type": "boolean" + } + }, + "required": ["pull", "push"] + }, + "Ulid": { + "description": "ULID identifier", + "type": "string", + "minLength": 26, + "maxLength": 26, + "pattern": "^[0-7][0-9A-HJKMNP-TV-Z]{25}$" + }, + "ProviderId": { + "description": "ID of a OAuth2 provider, e.g. \"gitlab.com\".", + "type": "string", + "example": "some-id" + }, + "WebUrl": { + "description": "A URL which can be opened in a browser, i.e. a web page.", + "type": "string", + "example": "https://example.org" + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "example": 1404 + }, + "detail": { + "type": "string", + "example": "A more detailed optional message showing what the problem was" + }, + "message": { + "type": "string", + "example": "Something went wrong - please try again later" + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"] + } + }, + "responses": { + "Error": { + "description": "The schema for all 4xx and 5xx responses", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } +} diff --git a/client/src/features/repositories/repositories.api.ts b/client/src/features/repositories/repositories.api.ts deleted file mode 100644 index c44b57ac52..0000000000 --- a/client/src/features/repositories/repositories.api.ts +++ /dev/null @@ -1,118 +0,0 @@ -/*! - * Copyright 2024 - Swiss Data Science Center (SDSC) - * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and - * Eidgenössische Technische Hochschule Zürich (ETHZ). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; - -import { - GetRepositoriesProbesParams, - GetRepositoriesProbesResponse, - GetRepositoryMetadataParams, - GetRepositoryProbeParams, - RepositoryProviderMatch, -} from "./repositories.types"; - -const repositoriesApi = createApi({ - reducerPath: "repositoriesApi", - baseQuery: fetchBaseQuery({ - baseUrl: "/api/data/repositories", - }), - tagTypes: ["Repository", "RepositoryProbe"], - endpoints: (builder) => ({ - getRepositoryMetadata: builder.query< - RepositoryProviderMatch, - GetRepositoryMetadataParams - >({ - query: ({ repositoryUrl }) => { - return { - url: encodeURIComponent(repositoryUrl), - }; - }, - providesTags: (result, _error, { repositoryUrl }) => - result ? [{ type: "Repository" as const, id: repositoryUrl }] : [], - }), - getRepositoryProbe: builder.query({ - query: ({ repositoryUrl }) => { - return { - url: `${encodeURIComponent(repositoryUrl)}/probe`, - validateStatus: (response) => { - return ( - (response.status >= 200 && response.status < 300) || - response.status == 404 - ); - }, - }; - }, - transformResponse(_result, meta) { - const status = meta?.response?.status; - return status != null && status >= 200 && status < 300; - }, - providesTags: (result, _error, { repositoryUrl }) => - result != null - ? [{ type: "RepositoryProbe" as const, id: repositoryUrl }] - : [], - }), - getRepositoriesProbes: builder.query< - GetRepositoriesProbesResponse, - GetRepositoriesProbesParams - >({ - async queryFn(queryArg, _api, _options, fetchWithBQ) { - const { repositoriesUrls } = queryArg; - const result: GetRepositoriesProbesResponse = []; - const promises = repositoriesUrls.map((repositoryUrl) => - fetchWithBQ({ - url: `${encodeURIComponent(repositoryUrl)}/probe`, - validateStatus: (response) => { - return ( - (response.status >= 200 && response.status < 300) || - response.status == 404 - ); - }, - }) - ); - const responses = await Promise.all(promises); - for (let i = 0; i < repositoriesUrls.length; i++) { - const repositoryUrl = repositoriesUrls[i]; - const response = responses[i]; - if (response.error) return response; - const status = response.meta?.response?.status; - const probe = status != null && status >= 200 && status < 300; - result.push({ - repositoryUrl, - probe, - }); - } - - return { data: result }; - }, - providesTags: (result) => - result != null - ? result.map(({ repositoryUrl }) => ({ - type: "RepositoryProbe" as const, - id: repositoryUrl, - })) - : [], - }), - }), -}); - -export default repositoriesApi; -export const { - useGetRepositoryMetadataQuery, - useGetRepositoryProbeQuery, - useGetRepositoriesProbesQuery, -} = repositoriesApi; diff --git a/client/src/features/repositories/repositories.types.ts b/client/src/features/repositories/repositories.types.ts deleted file mode 100644 index 605c420eaa..0000000000 --- a/client/src/features/repositories/repositories.types.ts +++ /dev/null @@ -1,53 +0,0 @@ -/*! - * Copyright 2024 - Swiss Data Science Center (SDSC) - * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and - * Eidgenössische Technische Hochschule Zürich (ETHZ). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export interface RepositoryProviderMatch { - provider_id: string; - connection_id?: string; - repository_metadata?: RepositoryMetadata; -} - -export interface RepositoryMetadata { - git_http_url: string; - web_url: string; - permissions: RepositoryPermissions; -} - -export interface RepositoryPermissions { - pull: boolean; - push: boolean; -} - -export interface RepositoryWithProbe { - repositoryUrl: string; - probe: boolean; -} - -export interface GetRepositoryMetadataParams { - repositoryUrl: string; -} - -export interface GetRepositoryProbeParams { - repositoryUrl: string; -} - -export type GetRepositoriesProbesResponse = RepositoryWithProbe[]; - -export interface GetRepositoriesProbesParams { - repositoriesUrls: string[]; -} diff --git a/client/src/features/sessionsV2/SessionRepositoriesModal.tsx b/client/src/features/sessionsV2/SessionRepositoriesModal.tsx new file mode 100644 index 0000000000..616c607237 --- /dev/null +++ b/client/src/features/sessionsV2/SessionRepositoriesModal.tsx @@ -0,0 +1,174 @@ +/*! + * Copyright 2025 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback, useMemo } from "react"; +import { SkipForward, XLg } from "react-bootstrap-icons"; +import { generatePath, useNavigate } from "react-router"; +import { + Button, + ListGroup, + ListGroupItem, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; + +import { RtkOrNotebooksError } from "~/components/errors/RtkErrorAlert"; +import { Loader } from "~/components/Loader"; +import ScrollableModal from "~/components/modal/ScrollableModal"; +import { ABSOLUTE_ROUTES } from "~/routing/routes.constants"; +import useAppDispatch from "~/utils/customHooks/useAppDispatch.hook"; +import { + RepositoryCallToActionAlert, + RepositoryPermissionsBadge, +} from "../ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay"; +import { getRepositoryName } from "../ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; +import useProjectPermissions from "../ProjectPageV2/utils/useProjectPermissions.hook"; +import type { Project } from "../projectsV2/api/projectV2.api"; +import { + RepositoriesApiResponseWithInterrupts, + useGetRepositoriesArrayQuery, +} from "../repositories/api/repositories.api"; +import startSessionOptionsV2Slice from "./startSessionOptionsV2.slice"; + +interface SessionRepositoriesModalProps { + isOpen: boolean; + project: Project; +} +export default function SessionRepositoriesModal({ + isOpen, + project, +}: SessionRepositoriesModalProps) { + const navigate = useNavigate(); + const onCancel = useCallback(() => { + const url = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: project.namespace, + slug: project.slug, + }); + navigate(url); + }, [navigate, project.namespace, project.slug]); + + const projectPermissions = useProjectPermissions({ projectId: project.id }); + const interruptProperty = projectPermissions?.write + ? "interruptOwner" + : "interruptAlways"; + const { data, error, isLoading } = useGetRepositoriesArrayQuery( + project.repositories ?? [] + ); + + const repoWithInterruptions = useMemo(() => { + if (isLoading || !data) return []; + return data.filter((repo) => repo[interruptProperty]) ?? []; + }, [data, interruptProperty, isLoading]); + + const dispatch = useAppDispatch(); + const onSkip = useCallback(() => { + dispatch(startSessionOptionsV2Slice.actions.setRepositoriesReady(true)); + }, [dispatch]); + + const content = + isLoading || !data ? ( + + ) : error ? ( +
+

+ An error occurred while checking the project repositories. You can try + to reload the page. +

+ +
+ ) : ( + <> +

+ There{" "} + {repoWithInterruptions.length === 1 + ? `is ${repoWithInterruptions.length} repository that requires` + : `are ${repoWithInterruptions.length} repositories that require`}{" "} + your attention before launching the session: +

+ + {repoWithInterruptions.map((repository) => ( + + ))} + + + ); + + return ( + + Session repositories not accessible + {content} + + + + + + ); +} + +interface SessionRepositoryWarningProps { + hasWriteAccess: boolean; + repository: RepositoriesApiResponseWithInterrupts; +} +function SessionRepositoryWarning({ + hasWriteAccess, + repository, +}: SessionRepositoryWarningProps) { + const title = getRepositoryName(repository.url); + + return ( + +

{title}

+

URL: {repository.url}

+
+ +
+ +
+ ); +} diff --git a/client/src/features/sessionsV2/SessionStartPage.tsx b/client/src/features/sessionsV2/SessionStartPage.tsx index a7c809ec18..4fb96b3440 100644 --- a/client/src/features/sessionsV2/SessionStartPage.tsx +++ b/client/src/features/sessionsV2/SessionStartPage.tsx @@ -62,6 +62,7 @@ import DataConnectorSecretsModal from "./DataConnectorSecretsModal"; import { CUSTOM_LAUNCH_SEARCH_PARAM } from "./session.constants"; import { validateEnvVariableName } from "./session.utils"; import SessionImageModal from "./SessionImageModal"; +import SessionRepositoriesModal from "./SessionRepositoriesModal"; import SessionSecretsModal from "./SessionSecretsModal"; import startSessionOptionsV2Slice from "./startSessionOptionsV2.slice"; import type { @@ -453,6 +454,7 @@ function StartSessionFromLauncher({ const startSessionOptionsV2 = useAppSelector( ({ startSessionOptionsV2 }) => startSessionOptionsV2 ); + const { containerImage, isFetchingOrLoadingStorages, @@ -462,6 +464,7 @@ function StartSessionFromLauncher({ sessionSecretSlotsWithSecrets, isLoadingSessionImage, sessionImage, + isFetchingRepositories, } = useSessionLaunchState({ launcher, project, @@ -481,11 +484,13 @@ function StartSessionFromLauncher({ startSessionOptionsV2.sessionClass !== 0 && startSessionOptionsV2.dataConnectors != null && !isFetchingOrLoadingStorages && + !isFetchingRepositories && !isFetchingSessionSecrets && !isLoadingSessionImage; const fetchingApi = isFetchingOrLoadingStorages || + isFetchingRepositories || isFetchingSessionSecrets || isLoadingSessionImage; @@ -516,10 +521,11 @@ function StartSessionFromLauncher({ if ( allDataFetched && !needsCredentials && - startSessionOptionsV2.dataConnectors && !shouldSaveCredentials && - startSessionOptionsV2.userSecretsReady && + startSessionOptionsV2.dataConnectors && startSessionOptionsV2.imageReady && + startSessionOptionsV2.repositoriesReady && + startSessionOptionsV2.userSecretsReady && !sessionStarted ) { setSessionStarted(true); @@ -531,6 +537,7 @@ function StartSessionFromLauncher({ shouldSaveCredentials, startSessionOptionsV2.dataConnectors, startSessionOptionsV2.imageReady, + startSessionOptionsV2.repositoriesReady, startSessionOptionsV2.userSecretsReady, ]); @@ -566,6 +573,12 @@ function StartSessionFromLauncher({ return ; } + if (!fetchingApi && !startSessionOptionsV2.repositoriesReady) { + return ( + + ); + } + if ( !fetchingApi && sessionSecretSlotsWithSecrets && @@ -749,3 +762,42 @@ function StartSessionImageModal({ ); } + +function StartSessionRepositoriesModal({ + launcher, + project, +}: StartSessionFromLauncherProps) { + const startSessionOptionsV2 = useAppSelector( + ({ startSessionOptionsV2 }) => startSessionOptionsV2 + ); + + const showModal = !startSessionOptionsV2.repositoriesReady; + + const steps = [ + { + id: 0, + status: StatusStepProgressBar.EXECUTING, + step: "Loading session configuration", + }, + { + id: 1, + status: StatusStepProgressBar.WAITING, + step: "Requesting session", + }, + ]; + + return ( +
+
+ + +
+
+ ); +} diff --git a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx index b7dc4d95fa..a7f70eb994 100644 --- a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx +++ b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx @@ -27,7 +27,7 @@ import { Loader } from "../../../../components/Loader"; import AppContext from "../../../../utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "../../../../utils/context/appParams.constants"; import { useProject } from "../../../ProjectPageV2/ProjectPageContainer/ProjectPageContainer"; -import { useGetRepositoriesProbesQuery } from "../../../repositories/repositories.api"; +import { useGetRepositoriesArrayQuery } from "../../../repositories/api/repositories.api"; import type { SessionLauncherForm } from "../../sessionsV2.types"; import BuilderFrontendSelector from "./BuilderFrontendSelector"; import BuilderTypeSelector from "./BuilderTypeSelector"; @@ -50,17 +50,13 @@ export default function BuilderEnvironmentFields({ const { project } = useProject(); const repositories = project.repositories ?? []; - const { - data: repositoriesDetails, - isLoading, - error, - } = useGetRepositoriesProbesQuery( - repositories.length > 0 ? { repositoriesUrls: repositories } : skipToken + const { data, isLoading, error } = useGetRepositoriesArrayQuery( + repositories.length > 0 ? repositories : skipToken ); const firstEligibleRepository = useMemo( - () => repositoriesDetails?.findIndex(({ probe }) => probe), - [repositoriesDetails] + () => data?.findIndex((repo) => repo.status === "valid"), + [data] ); if (!imageBuildersEnabled) { @@ -82,7 +78,7 @@ export default function BuilderEnvironmentFields({ No repositories found in this project. Add a repository first before creating a session environment from one. - ) : error || repositoriesDetails == null ? ( + ) : error || !data ? ( <>

Error: could not check code repositories.

{error && } @@ -98,7 +94,7 @@ export default function BuilderEnvironmentFields({ diff --git a/client/src/features/sessionsV2/components/SessionForm/CodeRepositorySelector.tsx b/client/src/features/sessionsV2/components/SessionForm/CodeRepositorySelector.tsx index 767bbfd2c5..842734fa9c 100644 --- a/client/src/features/sessionsV2/components/SessionForm/CodeRepositorySelector.tsx +++ b/client/src/features/sessionsV2/components/SessionForm/CodeRepositorySelector.tsx @@ -37,14 +37,14 @@ import Select, { } from "react-select"; import { Label } from "reactstrap"; +import { RepositoriesApiResponseWithInterrupts } from "~/features/repositories/api/repositories.api"; import { getRepositoryName } from "../../../ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; -import type { RepositoryWithProbe } from "../../../repositories/repositories.types"; import styles from "./Select.module.scss"; interface CodeRepositorySelectorProps extends UseControllerProps { - repositoriesDetails: RepositoryWithProbe[]; + repositoriesDetails: RepositoriesApiResponseWithInterrupts[]; } export default function CodeRepositorySelector({ @@ -55,7 +55,7 @@ export default function CodeRepositorySelector({ () => controllerProps.defaultValue ? controllerProps.defaultValue - : repositoriesDetails.find(({ probe }) => probe)?.repositoryUrl, + : repositoriesDetails.find((repo) => repo.status === "valid")?.url, [controllerProps.defaultValue, repositoriesDetails] ); @@ -107,11 +107,8 @@ export default function CodeRepositorySelector({ interface CodeRepositorySelectProps { name: string; - defaultValue?: string; - - options: RepositoryWithProbe[]; - + options: RepositoriesApiResponseWithInterrupts[]; onChange?: (newValue?: string) => void; onBlur?: () => void; value: string; @@ -128,22 +125,22 @@ function CodeRepositorySelect({ disabled, }: CodeRepositorySelectProps) { const defaultValue = useMemo( - () => options.find(({ repositoryUrl }) => repositoryUrl === defaultValue_), + () => options.find((repository) => repository.url === defaultValue_), [defaultValue_, options] ); const value = useMemo( - () => options.find(({ repositoryUrl }) => repositoryUrl === value_), + () => options.find((repository) => repository.url === value_), [options, value_] ); const onChange = useCallback( ( newValue: SingleValue<{ - repositoryUrl: string; - probe: boolean; + url: string; + status: string; }> ) => { - onChange_?.(newValue?.repositoryUrl); + onChange_?.(newValue?.url); }, [onChange_] ); @@ -163,10 +160,10 @@ function CodeRepositorySelect({ isClearable={false} isSearchable={false} options={options} - getOptionLabel={(option) => option.repositoryUrl} - getOptionValue={(option) => option.repositoryUrl} + getOptionLabel={(option) => option.url} + getOptionValue={(option) => option.url} unstyled - isOptionDisabled={(option) => !option.probe} + isOptionDisabled={(option) => option.status !== "valid"} onChange={onChange} onBlur={onBlur} value={value} @@ -178,7 +175,10 @@ function CodeRepositorySelect({ ); } -const selectClassNames: ClassNamesConfig = { +const selectClassNames: ClassNamesConfig< + RepositoriesApiResponseWithInterrupts, + false +> = { control: ({ menuIsOpen }) => cx(menuIsOpen ? "rounded-top" : "rounded", "border", styles.control), dropdownIndicator: () => cx("pe-3"), @@ -201,7 +201,7 @@ const selectClassNames: ClassNamesConfig = { }; interface OptionOrSingleValueContentProps { - option: RepositoryWithProbe; + option: RepositoriesApiResponseWithInterrupts; } function OptionOrSingleValueContent({ @@ -209,8 +209,8 @@ function OptionOrSingleValueContent({ }: OptionOrSingleValueContentProps) { return ( <> - {option.repositoryUrl} - {!option.probe && ( + {option.url} + {option.status !== "valid" && ( No public access @@ -221,9 +221,9 @@ function OptionOrSingleValueContent({ } const selectComponents: SelectComponentsConfig< - RepositoryWithProbe, + RepositoriesApiResponseWithInterrupts, false, - GroupBase + GroupBase > = { DropdownIndicator: (props) => { return ( @@ -234,25 +234,25 @@ const selectComponents: SelectComponentsConfig< }, Option: ( props: OptionProps< - RepositoryWithProbe, + RepositoriesApiResponseWithInterrupts, false, - GroupBase + GroupBase > ) => { const { data } = props; - const title = getRepositoryName(data.repositoryUrl); + const title = getRepositoryName(data.url); return (
{title}
-
{data.repositoryUrl}
+
{data.url}
); }, SingleValue: ( props: SingleValueProps< - RepositoryWithProbe, + RepositoriesApiResponseWithInterrupts, false, - GroupBase + GroupBase > ) => { const { data } = props; diff --git a/client/src/features/sessionsV2/startSessionOptionsV2.slice.ts b/client/src/features/sessionsV2/startSessionOptionsV2.slice.ts index c7c6b725b9..8d70800a5e 100644 --- a/client/src/features/sessionsV2/startSessionOptionsV2.slice.ts +++ b/client/src/features/sessionsV2/startSessionOptionsV2.slice.ts @@ -33,6 +33,7 @@ const initialState: StartSessionOptionsV2 = { imageReady: false, lfsAutoFetch: false, repositories: [], + repositoriesReady: false, sessionClass: 0, storage: MIN_SESSION_STORAGE_GB, userSecretsReady: false, @@ -84,6 +85,9 @@ const startSessionOptionsV2Slice = createSlice({ setRepositories: (state, action: PayloadAction) => { state.repositories.splice(0, Infinity, ...action.payload); }, + setRepositoriesReady: (state, action: PayloadAction) => { + state.repositoriesReady = action.payload; + }, setSessionClass: (state, action: PayloadAction) => { state.sessionClass = action.payload; }, diff --git a/client/src/features/sessionsV2/startSessionOptionsV2.types.ts b/client/src/features/sessionsV2/startSessionOptionsV2.types.ts index 56364495c3..6d0de59cc5 100644 --- a/client/src/features/sessionsV2/startSessionOptionsV2.types.ts +++ b/client/src/features/sessionsV2/startSessionOptionsV2.types.ts @@ -41,6 +41,7 @@ export interface StartSessionOptionsV2 { imageReady: boolean; lfsAutoFetch: boolean; repositories: SessionRepository[]; + repositoriesReady: boolean; sessionClass: number; storage: number; userSecretsReady: boolean; diff --git a/client/src/features/sessionsV2/useSessionLaunchState.hook.ts b/client/src/features/sessionsV2/useSessionLaunchState.hook.ts index 5d9108d659..ebfeb35c4e 100644 --- a/client/src/features/sessionsV2/useSessionLaunchState.hook.ts +++ b/client/src/features/sessionsV2/useSessionLaunchState.hook.ts @@ -26,7 +26,9 @@ import { useGetProjectsByProjectIdDataConnectorLinksQuery, } from "../dataConnectorsV2/api/data-connectors.enhanced-api"; import useDataConnectorConfiguration from "../dataConnectorsV2/components/useDataConnectorConfiguration.hook"; +import useProjectPermissions from "../ProjectPageV2/utils/useProjectPermissions.hook"; import type { Project } from "../projectsV2/api/projectV2.api"; +import { useGetRepositoriesArrayQuery } from "../repositories/api/repositories.api"; import { useGetResourcePoolsQuery } from "./api/computeResources.api"; import type { SessionLauncher } from "./api/sessionLaunchersV2.api"; import { useGetSessionsImagesQuery } from "./api/sessionsV2.api"; @@ -141,6 +143,11 @@ export default function useSessionLauncherState({ isReadyDataConnectorConfigs, } = useDataConnectorConfiguration({ dataConnectors }); + const { data: repositories, isFetching: isFetchingRepositories } = + useGetRepositoriesArrayQuery(project.repositories ?? []); + + const projectPermissions = useProjectPermissions({ projectId: project.id }); + const isFetchingOrLoadingStorages = isFetchingDataConnectorLinks || isLoadingDataConnectorLinks || @@ -167,8 +174,8 @@ export default function useSessionLauncherState({ isReadyDataConnectorConfigs, ]); + // check session image availability -- it should block only for external images useEffect(() => { - // check session image availability -- it should block only for external images if ( !isExternalImageEnvironment || (!!sessionImage && sessionImage.accessible) @@ -177,14 +184,34 @@ export default function useSessionLauncherState({ } }, [dispatch, isExternalImageEnvironment, sessionImage]); + // Check for code repos availability -- it should only block if any repo requires it + useEffect(() => { + const interruptProperty = projectPermissions?.write + ? "interruptOwner" + : "interruptAlways"; + const shouldInterrupt = !!repositories?.find( + (repo) => repo[interruptProperty] + ); + if (!isFetchingRepositories && !shouldInterrupt) { + dispatch(startSessionOptionsV2Slice.actions.setRepositoriesReady(true)); + } + }, [ + dispatch, + isFetchingRepositories, + projectPermissions?.write, + repositories, + ]); + return { containerImage, sessionImage, defaultSessionClass, isFetchingOrLoadingStorages, + isFetchingRepositories, isFetchingSessionSecrets, isLoadingSessionImage, isPendingResourceClass, + repositories, resourcePools, sessionSecretSlotsWithSecrets, setResourceClass, diff --git a/client/src/utils/helpers/EnhancedState.ts b/client/src/utils/helpers/EnhancedState.ts index 00025b0de0..23a2c391b4 100644 --- a/client/src/utils/helpers/EnhancedState.ts +++ b/client/src/utils/helpers/EnhancedState.ts @@ -50,7 +50,7 @@ import { projectKgApi } from "../../features/project/projectKg.api"; import { projectsApi } from "../../features/projects/projects.api"; import { projectV2Api } from "../../features/projectsV2/api/projectV2.enhanced-api"; import { recentUserActivityApi } from "../../features/recentUserActivity/RecentUserActivityApi"; -import repositoriesApi from "../../features/repositories/repositories.api"; +import { repositoriesApi } from "../../features/repositories/api/repositories.api"; import { searchV2EmptyApi as searchV2Api } from "../../features/searchV2/api/searchV2-empty.api"; import { searchV2Slice } from "../../features/searchV2/searchV2.slice"; import sessionsApi from "../../features/session/sessions.api"; diff --git a/tests/cypress/e2e/projectV2Session.spec.ts b/tests/cypress/e2e/projectV2Session.spec.ts index 1e7d58d042..af73695b3e 100644 --- a/tests/cypress/e2e/projectV2Session.spec.ts +++ b/tests/cypress/e2e/projectV2Session.spec.ts @@ -52,15 +52,24 @@ describe("launch sessions with data connectors", () => { }) .sessionSecrets({ fixture: "projectV2SessionSecrets/empty_list.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo1.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); cy.wait("@readProjectV2"); }); it("launch session with public data connector", () => { - fixtures.testCloudStorage().listProjectDataConnectors().getDataConnector({ - fixture: "dataConnector/data-connector-public.json", - }); + fixtures + .testCloudStorage() + .listProjectDataConnectors() + .getDataConnector({ + fixture: "dataConnector/data-connector-public.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", + }); cy.visit("/p/user1-uuid/test-2-v2-project"); cy.wait("@readProjectV2"); @@ -121,6 +130,9 @@ describe("launch sessions with data connectors", () => { .getDataConnector() .dataConnectorSecrets({ fixture: "dataConnector/data-connector-secrets-empty.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -227,6 +239,9 @@ describe("launch sessions with data connectors", () => { value: "secret key", }, ], + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -315,6 +330,9 @@ describe("launch sessions with data connectors", () => { value: "secret key", }, ], + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -375,7 +393,10 @@ describe("launch sessions with data connectors", () => { .sessionServersEmptyV2() .listProjectDataConnectors() .getDataConnector() - .dataConnectorSecrets(); + .dataConnectorSecrets() + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", + }); cy.visit("/p/user1-uuid/test-2-v2-project"); cy.wait("@readProjectV2"); @@ -411,6 +432,9 @@ describe("launch sessions with data connectors", () => { .getDataConnector() .dataConnectorSecrets({ fixture: "dataConnector/data-connector-secrets-partial.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -460,6 +484,62 @@ describe("launch sessions with data connectors", () => { cy.url().should("match", /\/p\/.*\/sessions\/show\/.*/); }); + it("show warning on launch", () => { + fixtures.testCloudStorage().listProjectDataConnectors().getDataConnector({ + fixture: "dataConnector/data-connector-public.json", + }); + + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@sessionServersEmptyV2"); + cy.wait("@sessionLaunchers"); + cy.wait("@listProjectDataConnectors"); + + // ensure the data connector is there + cy.getDataCy("data-connector-name").should( + "contain.text", + "example storage" + ); + cy.getDataCy("data-connector-name").click(); + cy.getDataCy("data-connector-title").should( + "contain.text", + "example storage" + ); + cy.getDataCy("requires-credentials-section") + .contains("No") + .should("be.visible"); + cy.getDataCy("data-connector-view-back-button").click(); + + // ensure the session launcher is there + cy.getDataCy("session-launcher-item") + .first() + .within(() => { + cy.getDataCy("session-name").should("contain.text", "Session-custom"); + cy.getDataCy("start-session-button").should("contain.text", "Launch"); + }); + + fixtures.dataConnectorSecrets({ + dataConnectorId: "ULID-1", + fixture: "dataConnector/data-connector-secrets-empty.json", + }); + // start session + cy.fixture("sessions/sessionV2.json").then((session) => { + // eslint-disable-next-line max-nested-callbacks + cy.intercept("POST", "/api/data/sessions", (req) => { + const dcOverrides = req.body.data_connectors_overrides; + expect(dcOverrides).to.have.length(0); + req.reply({ body: session, delay: 2000 }); + }).as("createSession"); + }); + fixtures.getSessionsV2({ fixture: "sessions/sessionsV2.json" }); + cy.getDataCy("session-launcher-item").within(() => { + cy.getDataCy("start-session-button").click(); + }); + cy.wait("@getResourceClass"); + cy.url().should("match", /\/p\/.*\/sessions\/.*\/start$/); + cy.getDataCy("session-repositories-warning"); + }); + it.skip("launch session multiple data connectors requiring multiple credentials, saving all", () => { fixtures .testCloudStorage({ success: false }) @@ -501,6 +581,9 @@ describe("launch sessions with data connectors", () => { value: "webDav pass", }, ], + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -585,6 +668,9 @@ describe("launch sessions with data connectors", () => { }) .dataConnectorSecrets({ fixture: "dataConnector/data-connector-secrets-empty.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -680,6 +766,9 @@ describe("launch sessions with data connectors", () => { }) .dataConnectorSecrets({ fixture: "dataConnector/data-connector-secrets-empty.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); @@ -781,6 +870,12 @@ describe("launch sessions with secrets", () => { .environments() .listProjectDataConnectors({ fixture: "dataConnector/empty-list.json", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo1.git", + }) + .getRepositoryMetadata({ + repositoryUrl: "https://domain.name/repo2.git", }); cy.visit("/p/user1-uuid/test-2-v2-project"); cy.wait("@readProjectV2"); diff --git a/tests/cypress/e2e/projectV2setup.spec.ts b/tests/cypress/e2e/projectV2setup.spec.ts index e0d91e4ef4..5a118acec1 100644 --- a/tests/cypress/e2e/projectV2setup.spec.ts +++ b/tests/cypress/e2e/projectV2setup.spec.ts @@ -795,7 +795,7 @@ describe("Repository connection cases", () => { }); }); - it("handle connected", () => { + it("read and write", () => { fixtures .getRepositoryMetadata({ repositoryUrl: "https://github.com/renku/url-repo.git", @@ -807,21 +807,24 @@ describe("Repository connection cases", () => { cy.wait("@getRepositoryMetadata"); // check badge - cy.getDataCy("code-repository-item") - .contains("Push & pull") + cy.getDataCy("code-repository-permission-badge") + .contains("Read & write") .should("be.visible"); cy.getDataCy("code-repository-item").click(); - cy.contains("Clone, Pull: Yes").should("be.visible"); - cy.contains("Push: Yes").should("be.visible"); + cy.getDataCy("code-repository-push-permission") + .contains("Yes") + .should("be.visible"); + cy.getDataCy("code-repository-pull-permission") + .contains("Yes") + .should("be.visible"); }); - it("handle token error", () => { + it("read only", () => { fixtures .getRepositoryMetadata({ repositoryUrl: "https://github.com/renku/url-repo.git", - fixture: "repositories/repository-metadata-token-error.json", - statusCode: 400, + fixture: "repositories/repository-metadata-readonly.json", }) .listConnectedServicesConnections() .listConnectedServicesProviders(); @@ -830,15 +833,94 @@ describe("Repository connection cases", () => { cy.wait("@getRepositoryMetadata"); // check badge - cy.getDataCy("code-repository-item") - .contains("No access") + cy.getDataCy("code-repository-permission-badge") + .contains("Read only") .should("be.visible"); cy.getDataCy("code-repository-item").click(); - cy.contains("Clone, Pull: No").should("be.visible"); - cy.contains( - "There is a problem with the integration to the repository host." - ).should("be.visible"); - cy.get("a").contains("Reconnect").should("be.visible"); + cy.getDataCy("code-repository-push-permission") + .contains("No") + .should("be.visible"); + cy.getDataCy("code-repository-pull-permission") + .contains("Yes") + .should("be.visible"); + }); + + it("inaccessible", () => { + fixtures + .getRepositoryMetadata({ + repositoryUrl: "https://github.com/renku/url-repo.git", + fixture: "repositories/repository-metadata-inaccessible.json", + }) + .listConnectedServicesConnections() + .listConnectedServicesProviders(); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2WithoutDocumentation"); + cy.wait("@getRepositoryMetadata"); + + // check badge + cy.getDataCy("code-repository-permission-badge") + .contains("Inaccessible") + .should("be.visible"); + + cy.getDataCy("code-repository-item").click(); + cy.getDataCy("code-repository-push-permission") + .contains("No") + .should("be.visible"); + cy.getDataCy("code-repository-pull-permission") + .contains("No") + .should("be.visible"); + }); + + it("request integration", () => { + fixtures + .getRepositoryMetadata({ + repositoryUrl: "https://github.com/renku/url-repo.git", + fixture: "repositories/repository-metadata-requestintegration.json", + }) + .listConnectedServicesConnections() + .listConnectedServicesProviders(); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2WithoutDocumentation"); + cy.wait("@getRepositoryMetadata"); + + // check badge + cy.getDataCy("code-repository-permission-badge") + .contains("Request integration") + .should("be.visible"); + + cy.getDataCy("code-repository-item").click(); + cy.getDataCy("code-repository-push-permission") + .contains("No") + .should("be.visible"); + cy.getDataCy("code-repository-pull-permission") + .contains("Yes") + .should("be.visible"); + }); + + it("integration required", () => { + fixtures + .getRepositoryMetadata({ + repositoryUrl: "https://github.com/renku/url-repo.git", + fixture: "repositories/repository-metadata-required.json", + }) + .listConnectedServicesConnections() + .listConnectedServicesProviders(); + cy.visit("/p/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2WithoutDocumentation"); + cy.wait("@getRepositoryMetadata"); + + // check badge + cy.getDataCy("code-repository-permission-badge") + .contains("Integration required") + .should("be.visible"); + + cy.getDataCy("code-repository-item").click(); + cy.getDataCy("code-repository-push-permission") + .contains("No") + .should("be.visible"); + cy.getDataCy("code-repository-pull-permission") + .contains("No") + .should("be.visible"); }); }); diff --git a/tests/cypress/fixtures/repositories/repository-metadata-inaccessible.json b/tests/cypress/fixtures/repositories/repository-metadata-inaccessible.json new file mode 100644 index 0000000000..9e20cacd7b --- /dev/null +++ b/tests/cypress/fixtures/repositories/repository-metadata-inaccessible.json @@ -0,0 +1,14 @@ +{ + "status": "invalid", + "connection": { + "id": "0100933QTQVNX4R5CC8P177VTC", + "provider_id": "github.com", + "status": "connected" + }, + "provider": { + "id": "github.com", + "name": "GitHub.com", + "url": "https://github.com" + }, + "error_code": "metadata_unknown" +} diff --git a/tests/cypress/fixtures/repositories/repository-metadata-readonly.json b/tests/cypress/fixtures/repositories/repository-metadata-readonly.json new file mode 100644 index 0000000000..5d8a9fd03f --- /dev/null +++ b/tests/cypress/fixtures/repositories/repository-metadata-readonly.json @@ -0,0 +1,19 @@ +{ + "status": "valid", + "connection": { + "id": "0100933QTQVNX4R5CC8P177VTC", + "provider_id": "github.com", + "status": "connected" + }, + "provider": { + "id": "github.com", + "name": "GitHub.com", + "url": "https://github.com" + }, + "metadata": { + "git_url": "https://github.com/renku/url-repo.git", + "web_url": "https://github.com/renku/url-repo", + "pull_permission": true, + "push_permission": false + } +} diff --git a/tests/cypress/fixtures/repositories/repository-metadata-requestintegration.json b/tests/cypress/fixtures/repositories/repository-metadata-requestintegration.json new file mode 100644 index 0000000000..06778ec53b --- /dev/null +++ b/tests/cypress/fixtures/repositories/repository-metadata-requestintegration.json @@ -0,0 +1,7 @@ +{ + "status": "valid", + "metadata": { + "git_url": "https://github.com/renku/url-repo", + "pull_permission": true + } +} diff --git a/tests/cypress/fixtures/repositories/repository-metadata-required.json b/tests/cypress/fixtures/repositories/repository-metadata-required.json new file mode 100644 index 0000000000..54864d4e44 --- /dev/null +++ b/tests/cypress/fixtures/repositories/repository-metadata-required.json @@ -0,0 +1,9 @@ +{ + "status": "invalid", + "provider": { + "id": "github.com", + "name": "GitHub.com", + "url": "https://github.com" + }, + "error_code": "metadata_unknown" +} diff --git a/tests/cypress/fixtures/repositories/repository-metadata-token-error.json b/tests/cypress/fixtures/repositories/repository-metadata-token-error.json deleted file mode 100644 index 3c1fbd41b4..0000000000 --- a/tests/cypress/fixtures/repositories/repository-metadata-token-error.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "error": { - "code": 1401, - "message": "The refresh token for the repository has expired or is invalid." - } -} diff --git a/tests/cypress/fixtures/repositories/repository-metadata.json b/tests/cypress/fixtures/repositories/repository-metadata.json index 9b8ac0031c..449efcf012 100644 --- a/tests/cypress/fixtures/repositories/repository-metadata.json +++ b/tests/cypress/fixtures/repositories/repository-metadata.json @@ -1,12 +1,19 @@ { - "provider_id": "github.com", - "connection_id": "0100933QTQVNX4R5CC8P177VTC", - "repository_metadata": { - "git_http_url": "https://github.com/renku/url-repo.git", + "status": "valid", + "connection": { + "id": "0100933QTQVNX4R5CC8P177VTC", + "provider_id": "github.com", + "status": "connected" + }, + "provider": { + "id": "github.com", + "name": "GitHub.com", + "url": "https://github.com" + }, + "metadata": { + "git_url": "https://github.com/renku/url-repo.git", "web_url": "https://github.com/renku/url-repo", - "permissions": { - "pull": true, - "push": true - } + "pull_permission": true, + "push_permission": true } } diff --git a/tests/cypress/support/renkulab-fixtures/connectedServices.ts b/tests/cypress/support/renkulab-fixtures/connectedServices.ts index 711b1ee6f4..6ded8bc8d0 100644 --- a/tests/cypress/support/renkulab-fixtures/connectedServices.ts +++ b/tests/cypress/support/renkulab-fixtures/connectedServices.ts @@ -41,7 +41,7 @@ export function ConnectedServices(Parent: T) { cy.fixture(fixture).then((repoMetadata) => { cy.intercept( "GET", - `/api/data/repositories/${encodeURIComponent(repositoryUrl)}`, + `/api/data/repository?url=${encodeURIComponent(repositoryUrl)}`, { body: repoMetadata, statusCode,