From 18f84b8f0c91b95ab47429fe3cbfcc86fd8754ed Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Thu, 6 Nov 2025 14:17:39 +0100 Subject: [PATCH 01/16] feat: improve integrations and code repos UX * Generate repository APIs code automatically * Show better feedback on code repositories' status * Block session start for unavailable or unaccessible repos --- client/.eslintrc.json | 1 + client/package.json | 2 + client/scripts/update_api_spec.js | 9 + .../CodeRepositoryDisplay.tsx | 893 ++++++++---------- .../CodeRepositories/repositories.utils.ts | 23 +- .../api/repositories.api-config.ts | 33 + .../repositories/api/repositories.api.ts | 122 +++ .../api/repositories.empty-api.ts | 26 + .../api/repositories.generated-api.ts | 108 +++ .../api/repositories.openapi.json | 313 ++++++ .../features/repositories/repositories.api.ts | 118 --- .../repositories/repositories.types.ts | 53 -- .../sessionsV2/SessionRepositoriesModal.tsx | 174 ++++ .../features/sessionsV2/SessionStartPage.tsx | 56 +- .../SessionForm/BuilderEnvironmentFields.tsx | 18 +- .../SessionForm/CodeRepositorySelector.tsx | 54 +- .../sessionsV2/startSessionOptionsV2.slice.ts | 4 + .../sessionsV2/startSessionOptionsV2.types.ts | 1 + .../sessionsV2/useSessionLaunchState.hook.ts | 29 +- client/src/utils/helpers/EnhancedState.ts | 2 +- tests/cypress/e2e/projectV2Session.spec.ts | 103 +- tests/cypress/e2e/projectV2setup.spec.ts | 112 ++- .../repository-metadata-inaccessible.json | 14 + .../repository-metadata-readonly.json | 19 + ...epository-metadata-requestintegration.json | 7 + .../repository-metadata-required.json | 9 + .../repository-metadata-token-error.json | 6 - .../repositories/repository-metadata.json | 23 +- .../renkulab-fixtures/connectedServices.ts | 2 +- 29 files changed, 1596 insertions(+), 738 deletions(-) create mode 100644 client/src/features/repositories/api/repositories.api-config.ts create mode 100644 client/src/features/repositories/api/repositories.api.ts create mode 100644 client/src/features/repositories/api/repositories.empty-api.ts create mode 100644 client/src/features/repositories/api/repositories.generated-api.ts create mode 100644 client/src/features/repositories/api/repositories.openapi.json delete mode 100644 client/src/features/repositories/repositories.api.ts delete mode 100644 client/src/features/repositories/repositories.types.ts create mode 100644 client/src/features/sessionsV2/SessionRepositoriesModal.tsx create mode 100644 tests/cypress/fixtures/repositories/repository-metadata-inaccessible.json create mode 100644 tests/cypress/fixtures/repositories/repository-metadata-readonly.json create mode 100644 tests/cypress/fixtures/repositories/repository-metadata-requestintegration.json create mode 100644 tests/cypress/fixtures/repositories/repository-metadata-required.json delete mode 100644 tests/cypress/fixtures/repositories/repository-metadata-token-error.json 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..8652addc1b 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 { useGetRepositoriesQuery } 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 } = useGetRepositoriesQuery({ + 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 } = useGetRepositoriesQuery({ + 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 } = useGetRepositoriesQuery({ + 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..266c69d051 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 { + GetRepositoriesApiResponse, + 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: GetRepositoriesApiResponse +): 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..8528d43695 --- /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 { + GetRepositoriesApiResponse, + repositoriesGeneratedApi, +} from "./repositories.generated-api"; + +export type RepositoryInterrupts = { + interruptAlways: boolean; + interruptOwner: boolean; +}; + +export type RepositoriesApiResponseWithInterrupts = GetRepositoriesApiResponse & + 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: "/repositories", + 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 GetRepositoriesApiResponse + ); + result.push({ + ...(response.data as GetRepositoriesApiResponse), + ...interrupts, + error: false, + url: repositoryUrl, + }); + } + } + + return { data: result }; + }, + }), + }), +}); + +const withTagHandling = withResponseRewrite.enhanceEndpoints({ + addTagTypes: ["Repository"], + endpoints: { + getRepositories: { + 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, useGetRepositoriesQuery } = + 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..acaa14ea5e --- /dev/null +++ b/client/src/features/repositories/api/repositories.generated-api.ts @@ -0,0 +1,108 @@ +import { repositoriesEmptyApi as api } from "./repositories.empty-api"; + +const injectedRtkApi = api.injectEndpoints({ + endpoints: (build) => ({ + getRepositories: build.query< + GetRepositoriesApiResponse, + GetRepositoriesApiArg + >({ + query: (queryArg) => ({ + url: `/repositories`, + params: { url: queryArg.url }, + }), + }), + getRepositoriesByRepositoryUrl: build.query< + GetRepositoriesByRepositoryUrlApiResponse, + GetRepositoriesByRepositoryUrlApiArg + >({ + query: (queryArg) => ({ url: `/repositories/${queryArg.repositoryUrl}` }), + }), + getRepositoriesProbe: build.query< + GetRepositoriesProbeApiResponse, + GetRepositoriesProbeApiArg + >({ + query: (queryArg) => ({ + url: `/repositories/probe`, + params: { url: queryArg.url }, + }), + }), + getRepositoriesByRepositoryUrlProbe: build.query< + GetRepositoriesByRepositoryUrlProbeApiResponse, + GetRepositoriesByRepositoryUrlProbeApiArg + >({ + query: (queryArg) => ({ + url: `/repositories/${queryArg.repositoryUrl}/probe`, + }), + }), + }), + overrideExisting: false, +}); +export { injectedRtkApi as repositoriesGeneratedApi }; +export type GetRepositoriesApiResponse = + /** status 200 The repository metadata. */ RepositoryProviderData; +export type GetRepositoriesApiArg = { + url: string; +}; +export type GetRepositoriesByRepositoryUrlApiResponse = + /** status 200 The repository metadata. */ RepositoryProviderData; +export type GetRepositoriesByRepositoryUrlApiArg = { + repositoryUrl: string; +}; +export type GetRepositoriesProbeApiResponse = + /** status 200 The repository seems to be available. */ void; +export type GetRepositoriesProbeApiArg = { + url: string; +}; +export type GetRepositoriesByRepositoryUrlProbeApiResponse = + /** status 200 The repository seems to be available. */ void; +export type GetRepositoriesByRepositoryUrlProbeApiArg = { + repositoryUrl: 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 { + useGetRepositoriesQuery, + useGetRepositoriesByRepositoryUrlQuery, + useGetRepositoriesProbeQuery, + useGetRepositoriesByRepositoryUrlProbeQuery, +} = 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..b89ff2dfb5 --- /dev/null +++ b/client/src/features/repositories/api/repositories.openapi.json @@ -0,0 +1,313 @@ +{ + "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": { + "/repositories": { + "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"] + } + }, + "/repositories/{repository_url}": { + "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": "path", + "name": "repository_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"] + } + }, + "/repositories/probe": { + "get": { + "summary": "Probe a repository to check if it is publicly available", + "description": "Probe a repository URL to see if it implements the git+http\nprotocol. In this case we assume that the repository can be\ncloned and pulled.\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 seems to be available." + }, + "404": { + "description": "The repository is not available." + }, + "default": { + "$ref": "#/components/responses/Error" + } + }, + "tags": ["repositories"] + } + }, + "/repositories/{repository_url}/probe": { + "get": { + "summary": "Probe a repository to check if it is publicly available", + "description": "Probe a repository URL to see if it implements the git+http\nprotocol. In this case we assume that the repository can be\ncloned and pulled.\n\nNote that only HTTP(S) URLs are supported.\n", + "parameters": [ + { + "in": "path", + "name": "repository_url", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The repository seems to be available." + }, + "404": { + "description": "The repository is not available." + }, + "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..fede0e9ee4 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/repositories?url=${encodeURIComponent(repositoryUrl)}`, { body: repoMetadata, statusCode, From 07087f286785a5a1feb3700b4956e0e875b1c267 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 25 Nov 2025 10:10:13 +0100 Subject: [PATCH 02/16] chore: update API code according to backend changes --- .../CodeRepositoryDisplay.tsx | 8 +- .../CodeRepositories/repositories.utils.ts | 4 +- .../repositories/api/repositories.api.ts | 12 +-- .../api/repositories.generated-api.ts | 56 +---------- .../api/repositories.openapi.json | 93 +------------------ 5 files changed, 18 insertions(+), 155 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index 8652addc1b..2930f335c5 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -52,7 +52,7 @@ 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 { useGetRepositoriesQuery } from "~/features/repositories/api/repositories.api"; +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"; @@ -463,7 +463,7 @@ export function RepositoryPermissionsBadge({ hasWriteAccess, repositoryUrl, }: RepositoryPermissionsProps) { - const { data, isLoading, error } = useGetRepositoriesQuery({ + const { data, isLoading, error } = useGetRepositoryQuery({ url: repositoryUrl, }); @@ -553,7 +553,7 @@ function RepositoryView({ toggleDetails, }: RepositoryViewProps) { const { pathname, hash } = useLocation(); - const { data, isLoading, error } = useGetRepositoriesQuery({ + const { data, isLoading, error } = useGetRepositoryQuery({ url: repositoryUrl, }); @@ -732,7 +732,7 @@ export function RepositoryCallToActionAlert({ repositoryUrl, }: RepositoryCallToActionAlertProps) { const { pathname, hash } = useLocation(); - const { data, isLoading, error } = useGetRepositoriesQuery({ + const { data, isLoading, error } = useGetRepositoryQuery({ url: repositoryUrl, }); diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts index 266c69d051..d77f31265a 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts @@ -17,7 +17,7 @@ */ import { - GetRepositoriesApiResponse, + GetRepositoryApiResponse, RepositoryInterrupts, } from "~/features/repositories/api/repositories.api"; import { safeNewUrl } from "../../../../utils/helpers/safeNewUrl.utils"; @@ -75,7 +75,7 @@ export function detectSSHRepository(repositoryURL: string): boolean { } export function shouldInterrupt( - repositoryData: GetRepositoriesApiResponse + repositoryData: GetRepositoryApiResponse ): RepositoryInterrupts { const interruptAlways = !!( !repositoryData?.metadata?.pull_permission && diff --git a/client/src/features/repositories/api/repositories.api.ts b/client/src/features/repositories/api/repositories.api.ts index 8528d43695..8d3babc588 100644 --- a/client/src/features/repositories/api/repositories.api.ts +++ b/client/src/features/repositories/api/repositories.api.ts @@ -36,7 +36,7 @@ import { shouldInterrupt } from "~/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; import { - GetRepositoriesApiResponse, + GetRepositoryApiResponse, repositoriesGeneratedApi, } from "./repositories.generated-api"; @@ -45,7 +45,7 @@ export type RepositoryInterrupts = { interruptOwner: boolean; }; -export type RepositoriesApiResponseWithInterrupts = GetRepositoriesApiResponse & +export type RepositoriesApiResponseWithInterrupts = GetRepositoryApiResponse & RepositoryInterrupts & { error: boolean; url: string; @@ -80,10 +80,10 @@ const withResponseRewrite = repositoriesGeneratedApi.injectEndpoints({ }); else if (response.data) { const interrupts = shouldInterrupt( - response.data as GetRepositoriesApiResponse + response.data as GetRepositoryApiResponse ); result.push({ - ...(response.data as GetRepositoriesApiResponse), + ...(response.data as GetRepositoryApiResponse), ...interrupts, error: false, url: repositoryUrl, @@ -100,7 +100,7 @@ const withResponseRewrite = repositoriesGeneratedApi.injectEndpoints({ const withTagHandling = withResponseRewrite.enhanceEndpoints({ addTagTypes: ["Repository"], endpoints: { - getRepositories: { + getRepository: { providesTags: (result, _error, { url }) => result ? [{ type: "Repository" as const, id: url }] : [], }, @@ -117,6 +117,6 @@ const withTagHandling = withResponseRewrite.enhanceEndpoints({ }); export { withTagHandling as repositoriesApi }; -export const { useGetRepositoriesArrayQuery, useGetRepositoriesQuery } = +export const { useGetRepositoriesArrayQuery, useGetRepositoryQuery } = withTagHandling; export type * from "./repositories.generated-api"; diff --git a/client/src/features/repositories/api/repositories.generated-api.ts b/client/src/features/repositories/api/repositories.generated-api.ts index acaa14ea5e..c10f8cbfe2 100644 --- a/client/src/features/repositories/api/repositories.generated-api.ts +++ b/client/src/features/repositories/api/repositories.generated-api.ts @@ -2,62 +2,21 @@ import { repositoriesEmptyApi as api } from "./repositories.empty-api"; const injectedRtkApi = api.injectEndpoints({ endpoints: (build) => ({ - getRepositories: build.query< - GetRepositoriesApiResponse, - GetRepositoriesApiArg - >({ + getRepository: build.query({ query: (queryArg) => ({ - url: `/repositories`, + url: `/repository`, params: { url: queryArg.url }, }), }), - getRepositoriesByRepositoryUrl: build.query< - GetRepositoriesByRepositoryUrlApiResponse, - GetRepositoriesByRepositoryUrlApiArg - >({ - query: (queryArg) => ({ url: `/repositories/${queryArg.repositoryUrl}` }), - }), - getRepositoriesProbe: build.query< - GetRepositoriesProbeApiResponse, - GetRepositoriesProbeApiArg - >({ - query: (queryArg) => ({ - url: `/repositories/probe`, - params: { url: queryArg.url }, - }), - }), - getRepositoriesByRepositoryUrlProbe: build.query< - GetRepositoriesByRepositoryUrlProbeApiResponse, - GetRepositoriesByRepositoryUrlProbeApiArg - >({ - query: (queryArg) => ({ - url: `/repositories/${queryArg.repositoryUrl}/probe`, - }), - }), }), overrideExisting: false, }); export { injectedRtkApi as repositoriesGeneratedApi }; -export type GetRepositoriesApiResponse = - /** status 200 The repository metadata. */ RepositoryProviderData; -export type GetRepositoriesApiArg = { - url: string; -}; -export type GetRepositoriesByRepositoryUrlApiResponse = +export type GetRepositoryApiResponse = /** status 200 The repository metadata. */ RepositoryProviderData; -export type GetRepositoriesByRepositoryUrlApiArg = { - repositoryUrl: string; -}; -export type GetRepositoriesProbeApiResponse = - /** status 200 The repository seems to be available. */ void; -export type GetRepositoriesProbeApiArg = { +export type GetRepositoryApiArg = { url: string; }; -export type GetRepositoriesByRepositoryUrlProbeApiResponse = - /** status 200 The repository seems to be available. */ void; -export type GetRepositoriesByRepositoryUrlProbeApiArg = { - repositoryUrl: string; -}; export type Ulid = string; export type ProviderId = string; export type ProviderConnection = { @@ -100,9 +59,4 @@ export type ErrorResponse = { message: string; }; }; -export const { - useGetRepositoriesQuery, - useGetRepositoriesByRepositoryUrlQuery, - useGetRepositoriesProbeQuery, - useGetRepositoriesByRepositoryUrlProbeQuery, -} = injectedRtkApi; +export const { useGetRepositoryQuery } = injectedRtkApi; diff --git a/client/src/features/repositories/api/repositories.openapi.json b/client/src/features/repositories/api/repositories.openapi.json index b89ff2dfb5..86b5a10755 100644 --- a/client/src/features/repositories/api/repositories.openapi.json +++ b/client/src/features/repositories/api/repositories.openapi.json @@ -11,7 +11,7 @@ } ], "paths": { - "/repositories": { + "/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", @@ -45,97 +45,6 @@ }, "tags": ["repositories"] } - }, - "/repositories/{repository_url}": { - "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": "path", - "name": "repository_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"] - } - }, - "/repositories/probe": { - "get": { - "summary": "Probe a repository to check if it is publicly available", - "description": "Probe a repository URL to see if it implements the git+http\nprotocol. In this case we assume that the repository can be\ncloned and pulled.\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 seems to be available." - }, - "404": { - "description": "The repository is not available." - }, - "default": { - "$ref": "#/components/responses/Error" - } - }, - "tags": ["repositories"] - } - }, - "/repositories/{repository_url}/probe": { - "get": { - "summary": "Probe a repository to check if it is publicly available", - "description": "Probe a repository URL to see if it implements the git+http\nprotocol. In this case we assume that the repository can be\ncloned and pulled.\n\nNote that only HTTP(S) URLs are supported.\n", - "parameters": [ - { - "in": "path", - "name": "repository_url", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "The repository seems to be available." - }, - "404": { - "description": "The repository is not available." - }, - "default": { - "$ref": "#/components/responses/Error" - } - }, - "tags": ["repositories"] - } } }, "components": { From e82afd0109bac9435fe49086c9e0f6e705deb0c4 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 25 Nov 2025 10:31:19 +0100 Subject: [PATCH 03/16] update tests --- tests/cypress/support/renkulab-fixtures/connectedServices.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cypress/support/renkulab-fixtures/connectedServices.ts b/tests/cypress/support/renkulab-fixtures/connectedServices.ts index fede0e9ee4..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?url=${encodeURIComponent(repositoryUrl)}`, + `/api/data/repository?url=${encodeURIComponent(repositoryUrl)}`, { body: repoMetadata, statusCode, From 21f25863af7d88d563608c2160f0ee52c334016e Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 25 Nov 2025 10:58:10 +0100 Subject: [PATCH 04/16] fix --- client/src/features/repositories/api/repositories.api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/repositories/api/repositories.api.ts b/client/src/features/repositories/api/repositories.api.ts index 8d3babc588..cb5d2de25c 100644 --- a/client/src/features/repositories/api/repositories.api.ts +++ b/client/src/features/repositories/api/repositories.api.ts @@ -62,7 +62,7 @@ const withResponseRewrite = repositoriesGeneratedApi.injectEndpoints({ const result: RepositoriesApiResponseWithInterrupts[] = []; const promises = queryArg.map((repository) => fetchWithBQ({ - url: "/repositories", + url: "/repository", params: { url: repository }, }) ); From 301dc52bdb19637bd4fce152c509a55a7861eb08 Mon Sep 17 00:00:00 2001 From: Lorenzo Cavazzi <43481553+lorenzo-cavazzi@users.noreply.github.com> Date: Tue, 2 Dec 2025 11:18:13 +0100 Subject: [PATCH 05/16] review: update logic to replace `.git` ending Co-authored-by: Flora Thiebaut --- .../ProjectPageContent/CodeRepositories/repositories.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts index d77f31265a..a3fec7375e 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts @@ -92,7 +92,7 @@ export function shouldInterrupt( } export function getRepositoryName(repositoryURL: string): string { - const canonicalUrlStr = `${repositoryURL.replace(/(?:\.git|\/)$/i, "")}`; + const canonicalUrlStr = `${repositoryURL.replace(/[/]$/, "").replace(/[.]git$/i, "")}`; const canonicalUrl = safeNewUrl(canonicalUrlStr); return canonicalUrl?.pathname.split("/").pop() || canonicalUrlStr; } From e814211c8314bbf4e2333890917df7f417e57c80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:09:51 +0100 Subject: [PATCH 06/16] build(deps): bump actions/checkout from 5 to 6 (#3917) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/acceptance-tests.yml | 2 +- .github/workflows/api-specs-up-to-date.yml | 2 +- .github/workflows/cleanup-old-deployments.yml | 2 +- .github/workflows/prepare-release.yml | 2 +- .github/workflows/test-and-ci.yml | 12 ++++++------ 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/acceptance-tests.yml b/.github/workflows/acceptance-tests.yml index d579aa6d12..6f59c8072e 100644 --- a/.github/workflows/acceptance-tests.yml +++ b/.github/workflows/acceptance-tests.yml @@ -54,7 +54,7 @@ jobs: # url: https://renku-ci-ui-${{ github.event.number }}.dev.renku.ch steps: - name: Checkout renku repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: SwissDataScienceCenter/renku sparse-checkout: | diff --git a/.github/workflows/api-specs-up-to-date.yml b/.github/workflows/api-specs-up-to-date.yml index 43490fe67e..48d0c55024 100644 --- a/.github/workflows/api-specs-up-to-date.yml +++ b/.github/workflows/api-specs-up-to-date.yml @@ -29,7 +29,7 @@ jobs: working-directory: ./client steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up node 22 uses: actions/setup-node@v6 with: diff --git a/.github/workflows/cleanup-old-deployments.yml b/.github/workflows/cleanup-old-deployments.yml index a659958352..a7238ad16d 100644 --- a/.github/workflows/cleanup-old-deployments.yml +++ b/.github/workflows/cleanup-old-deployments.yml @@ -7,7 +7,7 @@ jobs: cleanup-old-deployments-from-github: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/github-script@v8 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index e79fe94cff..0c14885072 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -20,7 +20,7 @@ jobs: create-release-pr: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: token: "${{ secrets.RENKUBOT_GITHUB_TOKEN }}" - name: Setup Git diff --git a/.github/workflows/test-and-ci.yml b/.github/workflows/test-and-ci.yml index 7a91f7c2bb..007c1b675e 100644 --- a/.github/workflows/test-and-ci.yml +++ b/.github/workflows/test-and-ci.yml @@ -17,7 +17,7 @@ jobs: run: working-directory: ./client steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up node ${{ matrix.node-version }} uses: actions/setup-node@v6 with: @@ -46,7 +46,7 @@ jobs: run: working-directory: ./server steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up node ${{ matrix.node-version }} uses: actions/setup-node@v6 with: @@ -74,7 +74,7 @@ jobs: run: working-directory: ./client steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: "22" @@ -91,7 +91,7 @@ jobs: run: working-directory: ./tests steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up node ${{ matrix.node-version }} uses: actions/setup-node@v6 with: @@ -108,7 +108,7 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install client uses: cypress-io/github-action@v6 with: @@ -147,7 +147,7 @@ jobs: runs-on: ubuntu-24.04 if: "startsWith(github.ref, 'refs/tags/')" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up QEMU From ea7596a09e791fdff2be472c6fdd0b57257ccfaa Mon Sep 17 00:00:00 2001 From: Flora Thiebaut Date: Thu, 27 Nov 2025 13:57:35 +0100 Subject: [PATCH 07/16] build(deps): bump body-parser from 2.2.0 to 2.2.1 in /client (#3923) * build(deps): bump body-parser from 2.2.0 to 2.2.1 in /client Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.0 to 2.2.1. - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.0...v2.2.1) --- updated-dependencies: - dependency-name: body-parser dependency-version: 2.2.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] * build: update to body-parser v2.2.1 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/package-lock.json | 86 ++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 5db59bf000..7481a861b0 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11206,35 +11206,43 @@ } }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/bootstrap": { @@ -13762,9 +13770,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -17285,19 +17293,23 @@ } }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy": { @@ -31937,30 +31949,34 @@ } }, "node_modules/raw-body": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", - "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.6.3", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/rc": { @@ -36650,9 +36666,9 @@ "license": "MIT" }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" From acc8fbd0dc143fa7c3a332e47bbf9e64aca839b7 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 2 Dec 2025 10:55:04 +0100 Subject: [PATCH 08/16] review: blend together color+text badge logic --- .../CodeRepositoryDisplay.tsx | 100 +++++++++--------- 1 file changed, 49 insertions(+), 51 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index 2930f335c5..a8081311a6 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -467,25 +467,55 @@ export function RepositoryPermissionsBadge({ url: repositoryUrl, }); - 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"; + let badgeColor: "light" | "danger" | "warning" | "success"; + let badgeText: string; + + if (isLoading) { + badgeColor = "light"; + badgeText = "Loading..."; + } else if (error) { + badgeColor = "danger"; + badgeText = "Error"; + } else if (!data?.metadata?.pull_permission && !data?.provider?.id) { + badgeColor = "danger"; + badgeText = "Inaccessible"; + } else if ( + !data?.metadata?.pull_permission && + data?.connection?.status !== "connected" + ) { + badgeColor = "danger"; + badgeText = "Integration required"; + } else if ( + !data?.metadata?.pull_permission && + data?.connection?.status === "connected" + ) { + badgeColor = "danger"; + badgeText = "Inaccessible"; + } else if (!data?.metadata?.push_permission && !data?.provider?.id) { + badgeText = hasWriteAccess ? "Request integration" : "Read only"; + badgeColor = hasWriteAccess ? "warning" : "success"; + } else if ( + !data?.metadata?.push_permission && + data?.connection?.status !== "connected" + ) { + badgeText = hasWriteAccess ? "Integration recommended" : "Read only"; + badgeColor = hasWriteAccess ? "warning" : "success"; + } else if ( + !data?.metadata?.push_permission && + data?.connection?.status === "connected" + ) { + badgeColor = "success"; + badgeText = "Read only"; + } else if ( + data?.metadata?.push_permission && + data?.connection?.status === "connected" + ) { + badgeColor = "success"; + badgeText = "Read & write"; + } else { + badgeColor = "light"; + badgeText = "Unexpected"; + } const badgeIcon = isLoading ? ( @@ -493,38 +523,6 @@ export function RepositoryPermissionsBadge({ ); - const badgeText = isLoading - ? "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 ( Date: Tue, 2 Dec 2025 11:06:45 +0100 Subject: [PATCH 09/16] review: use external link component --- .../CodeRepositories/CodeRepositoryDisplay.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index a8081311a6..fc94abbdf0 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -19,7 +19,6 @@ import cx from "classnames"; import { useCallback, useEffect, useMemo, useState } from "react"; import { - BoxArrowUpRight, CircleFill, FileCode, Pencil, @@ -50,6 +49,7 @@ import { import { ErrorAlert, InfoAlert, WarnAlert } from "~/components/Alert"; import { CommandCopy } from "~/components/commandCopy/CommandCopy"; +import { ExternalLink } from "~/components/ExternalLinks"; import RenkuBadge from "~/components/renkuBadge/RenkuBadge"; import RepositoryGitLabWarnBadge from "~/features/legacy/RepositoryGitLabWarnBadge"; import { useGetRepositoryQuery } from "~/features/repositories/api/repositories.api"; @@ -614,15 +614,12 @@ function RepositoryView({

Repository

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

{data?.metadata?.git_url && (
From 3c2de48a1d3d3994cf03ce90ce96fb7634c42c28 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 2 Dec 2025 11:13:58 +0100 Subject: [PATCH 10/16] review: use the variable for our contact email. Mind that this doesnt fully address the review comment. That will require #3925 --- .../CodeRepositories/CodeRepositoryDisplay.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index fc94abbdf0..14b660b3d0 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -55,6 +55,7 @@ import RepositoryGitLabWarnBadge from "~/features/legacy/RepositoryGitLabWarnBad import { useGetRepositoryQuery } from "~/features/repositories/api/repositories.api"; import { useGetUserQueryState } from "~/features/usersV2/api/users.api"; import { ABSOLUTE_ROUTES } from "~/routing/routes.constants"; +import { RenkuContactEmail } from "~/utils/constants/Docs"; import { ButtonWithMenuV2 } from "../../../../components/buttons/Button"; import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; import { Loader } from "../../../../components/Loader"; @@ -810,7 +811,7 @@ export function RepositoryCallToActionAlert({ contact us From 26c9b3c3bae16132c094fe5abdaf99343778e6e3 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 2 Dec 2025 11:35:20 +0100 Subject: [PATCH 11/16] format --- .../ProjectPageContent/CodeRepositories/repositories.utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts index a3fec7375e..097e75d855 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts @@ -92,7 +92,9 @@ export function shouldInterrupt( } export function getRepositoryName(repositoryURL: string): string { - const canonicalUrlStr = `${repositoryURL.replace(/[/]$/, "").replace(/[.]git$/i, "")}`; + const canonicalUrlStr = `${repositoryURL + .replace(/[/]$/, "") + .replace(/[.]git$/i, "")}`; const canonicalUrl = safeNewUrl(canonicalUrlStr); return canonicalUrl?.pathname.split("/").pop() || canonicalUrlStr; } From 91c75552696f9cce1d9324db3fe44d13a33cf512 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 2 Dec 2025 11:47:26 +0100 Subject: [PATCH 12/16] remove target --- .../CodeRepositories/CodeRepositoryDisplay.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index 14b660b3d0..99f92210d4 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -156,9 +156,7 @@ function EditCodeRepositoryModal({ - + Date: Tue, 2 Dec 2025 16:05:56 +0100 Subject: [PATCH 13/16] undo the fix from 3c2de48a --- .../CodeRepositories/CodeRepositoryDisplay.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index 99f92210d4..14b660b3d0 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -156,7 +156,9 @@ function EditCodeRepositoryModal({ - + Date: Tue, 2 Dec 2025 16:48:43 +0100 Subject: [PATCH 14/16] review: avoid embedding shouldInterrupt logic into api responses --- .../CodeRepositories/repositories.utils.ts | 28 +++++++------ .../repositories/api/repositories.api.ts | 27 ++++++------- .../sessionsV2/SessionRepositoriesModal.tsx | 26 ++++++++----- .../SessionForm/BuilderEnvironmentFields.tsx | 6 +-- .../SessionForm/CodeRepositorySelector.tsx | 39 ++++++++----------- .../sessionsV2/useSessionLaunchState.hook.ts | 16 ++++---- 6 files changed, 67 insertions(+), 75 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts index 097e75d855..ee77bfbf0b 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils.ts @@ -16,10 +16,7 @@ * limitations under the License. */ -import { - GetRepositoryApiResponse, - RepositoryInterrupts, -} from "~/features/repositories/api/repositories.api"; +import { GetRepositoryApiResponse } from "~/features/repositories/api/repositories.api"; import { safeNewUrl } from "../../../../utils/helpers/safeNewUrl.utils"; /** @@ -75,20 +72,21 @@ export function detectSSHRepository(repositoryURL: string): boolean { } export function shouldInterrupt( - repositoryData: GetRepositoryApiResponse -): RepositoryInterrupts { - const interruptAlways = !!( + repositoryData: GetRepositoryApiResponse, + hasProjectWritePermission: boolean +): boolean { + if (hasProjectWritePermission) + return !!( + (!repositoryData?.metadata?.pull_permission && + !(repositoryData?.connection?.status === "connected")) || + (repositoryData?.metadata?.pull_permission && + !repositoryData?.metadata?.push_permission && + !(repositoryData?.connection?.status === "connected")) + ); + return !!( !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 { diff --git a/client/src/features/repositories/api/repositories.api.ts b/client/src/features/repositories/api/repositories.api.ts index cb5d2de25c..6ba6dc60a6 100644 --- a/client/src/features/repositories/api/repositories.api.ts +++ b/client/src/features/repositories/api/repositories.api.ts @@ -34,7 +34,6 @@ * limitations under the License. */ -import { shouldInterrupt } from "~/features/ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; import { GetRepositoryApiResponse, repositoriesGeneratedApi, @@ -51,15 +50,18 @@ export type RepositoriesApiResponseWithInterrupts = GetRepositoryApiResponse & url: string; }; +export type GetRepositoriesApiResponse = { + data?: GetRepositoryApiResponse; + error: boolean; + url: string; +}; + const withResponseRewrite = repositoriesGeneratedApi.injectEndpoints({ overrideExisting: true, endpoints: (build) => ({ - getRepositoriesArray: build.query< - RepositoriesApiResponseWithInterrupts[], - string[] - >({ + getRepositories: build.query({ async queryFn(queryArg, _api, _options, fetchWithBQ) { - const result: RepositoriesApiResponseWithInterrupts[] = []; + const result: GetRepositoriesApiResponse[] = []; const promises = queryArg.map((repository) => fetchWithBQ({ url: "/repository", @@ -73,18 +75,11 @@ const withResponseRewrite = repositoriesGeneratedApi.injectEndpoints({ 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, + data: response.data as GetRepositoryApiResponse, error: false, url: repositoryUrl, }); @@ -104,7 +99,7 @@ const withTagHandling = withResponseRewrite.enhanceEndpoints({ providesTags: (result, _error, { url }) => result ? [{ type: "Repository" as const, id: url }] : [], }, - getRepositoriesArray: { + getRepositories: { providesTags: (result) => result != null ? result.map(({ url }) => ({ @@ -117,6 +112,6 @@ const withTagHandling = withResponseRewrite.enhanceEndpoints({ }); export { withTagHandling as repositoriesApi }; -export const { useGetRepositoriesArrayQuery, useGetRepositoryQuery } = +export const { useGetRepositoriesQuery, useGetRepositoryQuery } = withTagHandling; export type * from "./repositories.generated-api"; diff --git a/client/src/features/sessionsV2/SessionRepositoriesModal.tsx b/client/src/features/sessionsV2/SessionRepositoriesModal.tsx index 616c607237..74741a5b80 100644 --- a/client/src/features/sessionsV2/SessionRepositoriesModal.tsx +++ b/client/src/features/sessionsV2/SessionRepositoriesModal.tsx @@ -38,12 +38,15 @@ import { RepositoryCallToActionAlert, RepositoryPermissionsBadge, } from "../ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay"; -import { getRepositoryName } from "../ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; +import { + getRepositoryName, + shouldInterrupt, +} from "../ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; import useProjectPermissions from "../ProjectPageV2/utils/useProjectPermissions.hook"; import type { Project } from "../projectsV2/api/projectV2.api"; import { - RepositoriesApiResponseWithInterrupts, - useGetRepositoriesArrayQuery, + GetRepositoriesApiResponse, + useGetRepositoriesQuery, } from "../repositories/api/repositories.api"; import startSessionOptionsV2Slice from "./startSessionOptionsV2.slice"; @@ -65,17 +68,20 @@ export default function SessionRepositoriesModal({ }, [navigate, project.namespace, project.slug]); const projectPermissions = useProjectPermissions({ projectId: project.id }); - const interruptProperty = projectPermissions?.write - ? "interruptOwner" - : "interruptAlways"; - const { data, error, isLoading } = useGetRepositoriesArrayQuery( + const { data, error, isLoading } = useGetRepositoriesQuery( project.repositories ?? [] ); const repoWithInterruptions = useMemo(() => { if (isLoading || !data) return []; - return data.filter((repo) => repo[interruptProperty]) ?? []; - }, [data, interruptProperty, isLoading]); + return ( + data.filter( + (repo) => + repo.error || + (repo.data && shouldInterrupt(repo.data, !!projectPermissions?.write)) + ) ?? [] + ); + }, [data, isLoading, projectPermissions?.write]); const dispatch = useAppDispatch(); const onSkip = useCallback(() => { @@ -147,7 +153,7 @@ export default function SessionRepositoriesModal({ interface SessionRepositoryWarningProps { hasWriteAccess: boolean; - repository: RepositoriesApiResponseWithInterrupts; + repository: GetRepositoriesApiResponse; } function SessionRepositoryWarning({ hasWriteAccess, diff --git a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx index a7f70eb994..41c3233e42 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 { useGetRepositoriesArrayQuery } from "../../../repositories/api/repositories.api"; +import { useGetRepositoriesQuery } from "../../../repositories/api/repositories.api"; import type { SessionLauncherForm } from "../../sessionsV2.types"; import BuilderFrontendSelector from "./BuilderFrontendSelector"; import BuilderTypeSelector from "./BuilderTypeSelector"; @@ -50,12 +50,12 @@ export default function BuilderEnvironmentFields({ const { project } = useProject(); const repositories = project.repositories ?? []; - const { data, isLoading, error } = useGetRepositoriesArrayQuery( + const { data, isLoading, error } = useGetRepositoriesQuery( repositories.length > 0 ? repositories : skipToken ); const firstEligibleRepository = useMemo( - () => data?.findIndex((repo) => repo.status === "valid"), + () => data?.findIndex((repo) => repo.data?.status === "valid"), [data] ); diff --git a/client/src/features/sessionsV2/components/SessionForm/CodeRepositorySelector.tsx b/client/src/features/sessionsV2/components/SessionForm/CodeRepositorySelector.tsx index 842734fa9c..2f806db610 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 { GetRepositoriesApiResponse } from "~/features/repositories/api/repositories.api"; import { getRepositoryName } from "../../../ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; import styles from "./Select.module.scss"; interface CodeRepositorySelectorProps extends UseControllerProps { - repositoriesDetails: RepositoriesApiResponseWithInterrupts[]; + repositoriesDetails: GetRepositoriesApiResponse[]; } export default function CodeRepositorySelector({ @@ -55,7 +55,8 @@ export default function CodeRepositorySelector({ () => controllerProps.defaultValue ? controllerProps.defaultValue - : repositoriesDetails.find((repo) => repo.status === "valid")?.url, + : repositoriesDetails.find((repo) => repo.data?.status === "valid") + ?.url, [controllerProps.defaultValue, repositoriesDetails] ); @@ -108,7 +109,7 @@ export default function CodeRepositorySelector({ interface CodeRepositorySelectProps { name: string; defaultValue?: string; - options: RepositoriesApiResponseWithInterrupts[]; + options: GetRepositoriesApiResponse[]; onChange?: (newValue?: string) => void; onBlur?: () => void; value: string; @@ -134,12 +135,7 @@ function CodeRepositorySelect({ ); const onChange = useCallback( - ( - newValue: SingleValue<{ - url: string; - status: string; - }> - ) => { + (newValue: SingleValue) => { onChange_?.(newValue?.url); }, [onChange_] @@ -163,7 +159,7 @@ function CodeRepositorySelect({ getOptionLabel={(option) => option.url} getOptionValue={(option) => option.url} unstyled - isOptionDisabled={(option) => option.status !== "valid"} + isOptionDisabled={(option) => option.data?.status !== "valid"} onChange={onChange} onBlur={onBlur} value={value} @@ -175,10 +171,7 @@ function CodeRepositorySelect({ ); } -const selectClassNames: ClassNamesConfig< - RepositoriesApiResponseWithInterrupts, - false -> = { +const selectClassNames: ClassNamesConfig = { control: ({ menuIsOpen }) => cx(menuIsOpen ? "rounded-top" : "rounded", "border", styles.control), dropdownIndicator: () => cx("pe-3"), @@ -201,7 +194,7 @@ const selectClassNames: ClassNamesConfig< }; interface OptionOrSingleValueContentProps { - option: RepositoriesApiResponseWithInterrupts; + option: GetRepositoriesApiResponse; } function OptionOrSingleValueContent({ @@ -210,7 +203,7 @@ function OptionOrSingleValueContent({ return ( <> {option.url} - {option.status !== "valid" && ( + {option.data?.status !== "valid" && ( No public access @@ -221,9 +214,9 @@ function OptionOrSingleValueContent({ } const selectComponents: SelectComponentsConfig< - RepositoriesApiResponseWithInterrupts, + GetRepositoriesApiResponse, false, - GroupBase + GroupBase > = { DropdownIndicator: (props) => { return ( @@ -234,9 +227,9 @@ const selectComponents: SelectComponentsConfig< }, Option: ( props: OptionProps< - RepositoriesApiResponseWithInterrupts, + GetRepositoriesApiResponse, false, - GroupBase + GroupBase > ) => { const { data } = props; @@ -250,9 +243,9 @@ const selectComponents: SelectComponentsConfig< }, SingleValue: ( props: SingleValueProps< - RepositoriesApiResponseWithInterrupts, + GetRepositoriesApiResponse, false, - GroupBase + GroupBase > ) => { const { data } = props; diff --git a/client/src/features/sessionsV2/useSessionLaunchState.hook.ts b/client/src/features/sessionsV2/useSessionLaunchState.hook.ts index ebfeb35c4e..7c4872b8ed 100644 --- a/client/src/features/sessionsV2/useSessionLaunchState.hook.ts +++ b/client/src/features/sessionsV2/useSessionLaunchState.hook.ts @@ -26,9 +26,10 @@ import { useGetProjectsByProjectIdDataConnectorLinksQuery, } from "../dataConnectorsV2/api/data-connectors.enhanced-api"; import useDataConnectorConfiguration from "../dataConnectorsV2/components/useDataConnectorConfiguration.hook"; +import { shouldInterrupt } from "../ProjectPageV2/ProjectPageContent/CodeRepositories/repositories.utils"; import useProjectPermissions from "../ProjectPageV2/utils/useProjectPermissions.hook"; import type { Project } from "../projectsV2/api/projectV2.api"; -import { useGetRepositoriesArrayQuery } from "../repositories/api/repositories.api"; +import { useGetRepositoriesQuery } from "../repositories/api/repositories.api"; import { useGetResourcePoolsQuery } from "./api/computeResources.api"; import type { SessionLauncher } from "./api/sessionLaunchersV2.api"; import { useGetSessionsImagesQuery } from "./api/sessionsV2.api"; @@ -144,7 +145,7 @@ export default function useSessionLauncherState({ } = useDataConnectorConfiguration({ dataConnectors }); const { data: repositories, isFetching: isFetchingRepositories } = - useGetRepositoriesArrayQuery(project.repositories ?? []); + useGetRepositoriesQuery(project.repositories ?? []); const projectPermissions = useProjectPermissions({ projectId: project.id }); @@ -186,13 +187,12 @@ export default function useSessionLauncherState({ // 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] + const interrupt = !!repositories?.find( + (repo) => + repo.error || + (repo.data && shouldInterrupt(repo.data, !!projectPermissions?.write)) ); - if (!isFetchingRepositories && !shouldInterrupt) { + if (!isFetchingRepositories && !interrupt) { dispatch(startSessionOptionsV2Slice.actions.setRepositoriesReady(true)); } }, [ From e6122d4d0d059efb41b2c86045cf31113840cb16 Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 2 Dec 2025 16:57:06 +0100 Subject: [PATCH 15/16] review: update generate-api and update-api --- client/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/package.json b/client/package.json index 688f5ce98e..eb5e1e181f 100644 --- a/client/package.json +++ b/client/package.json @@ -21,7 +21,7 @@ "storybook-wait-server": "wait-on http://127.0.0.1:6006", "storybook-test": "test-storybook", "storybook-compile-and-test": "concurrently -k -s first -n 'BUILD,TEST' -c 'magenta,blue' 'npm run storybook-build && npm run storybook-start-server' 'npm run storybook-wait-server && npm run storybook-test'", - "generate-api": "npm run generate-api:computeResources && npm run generate-api:connectedServices && npm run generate-api:data-connectors && npm run generate-api:doiResolver && npm run generate-api:namespaceV2 && npm run generate-api:platform & npm run generate-api:projectCloudStorage && npm run generate-api:projectV2 && npm run generate-api:searchV2 && npm run generate-api:sessionLaunchersV2 && npm run generate-api:sessionsV2 && npm run generate-api:users", + "generate-api": "npm run generate-api:computeResources && npm run generate-api:connectedServices && npm run generate-api:data-connectors && npm run generate-api:doiResolver && npm run generate-api:namespaceV2 && npm run generate-api:platform & npm run generate-api:projectCloudStorage && npm run generate-api:projectV2 && npm run generate-api:repositories && npm run generate-api:searchV2 && npm run generate-api:sessionLaunchersV2 && npm run generate-api:sessionsV2 && npm run generate-api:users", "generate-api:computeResources": "rtk-query-codegen-openapi src/features/sessionsV2/api/computeResources.api-config.ts", "generate-api:connectedServices": "rtk-query-codegen-openapi src/features/connectedServices/api/connectedServices.api-config.ts", "generate-api:data-connectors": "rtk-query-codegen-openapi src/features/dataConnectorsV2/api/data-connectors.api-config.ts", @@ -35,7 +35,7 @@ "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", "generate-api:users": "rtk-query-codegen-openapi src/features/usersV2/api/users.api-config.ts", - "update-api": "node scripts/update_api_spec.js computeResources connectedServices dataConnectors namespaceV2 platform projectCloudStorage projectV2 searchV2 sessionLaunchersV2 sessionsV2 users", + "update-api": "node scripts/update_api_spec.js computeResources connectedServices dataConnectors namespaceV2 platform projectCloudStorage projectV2 repositories searchV2 sessionLaunchersV2 sessionsV2 users", "update-api:computeResources": "node scripts/update_api_spec.js computeResources", "update-api:connectedServices": "node scripts/update_api_spec.js connectedServices", "update-api:dataConnectors": "node scripts/update_api_spec.js dataConnectors", From 69cb87bd0bcacf795e2d591e45cba4c9fb8a4b8f Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Tue, 2 Dec 2025 17:07:34 +0100 Subject: [PATCH 16/16] review: prevent opening offcanvas when clicking on the edit form --- .../CodeRepositories/CodeRepositoryDisplay.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx index 14b660b3d0..3c3dc39b2d 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/CodeRepositories/CodeRepositoryDisplay.tsx @@ -155,7 +155,14 @@ function EditCodeRepositoryModal({

Specify a code repository by its URL.

- + { + // ? Prevent offcanvas from toggling when clicking inside the form group + e.stopPropagation(); + }} + >