Path: blob/main/components/dashboard/src/data/git-providers/unified-repositories-search-query.ts
2501 views
/**1* Copyright (c) 2023 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";7import { useSearchRepositories } from "./search-repositories-query";8import { useSuggestedRepositories } from "./suggested-repositories-query";9import { useMemo } from "react";10import { useListConfigurations } from "../configurations/configuration-queries";11import type { UseInfiniteQueryResult } from "@tanstack/react-query";12import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";13import { parseUrl } from "../../utils";1415export const flattenPagedConfigurations = (16data: UseInfiniteQueryResult<{ configurations: Configuration[] }>["data"],17): Configuration[] => {18return data?.pages.flatMap((p) => p.configurations) ?? [];19};2021type UnifiedRepositorySearchArgs = {22searchString: string;23// If true, excludes configurations and only shows 1 entry per repo24excludeConfigurations?: boolean;25// If true, only shows entries with a corresponding configuration26onlyConfigurations?: boolean;27};28// Combines the suggested repositories and the search repositories query into one hook29export const useUnifiedRepositorySearch = ({30searchString,31excludeConfigurations = false,32onlyConfigurations = false,33}: UnifiedRepositorySearchArgs) => {34// 1st data source: suggested SCM repos + up to 100 imported repos.35// todo(ft): look into deduplicating and merging these on the server36const suggestedQuery = useSuggestedRepositories({ excludeConfigurations });37const searchLimit = 30;38// 2nd data source: SCM repos according to `searchString`39const searchQuery = useSearchRepositories({ searchString, limit: searchLimit });40// 3rd data source: imported repos according to `searchString`41const configurationSearch = useListConfigurations({42sortBy: "name",43sortOrder: "desc",44pageSize: searchLimit,45searchTerm: searchString,46});47const flattenedConfigurations = useMemo(() => {48if (excludeConfigurations) {49return [];50}5152const flattened = flattenPagedConfigurations(configurationSearch.data);53return flattened.map(54(repo) =>55new SuggestedRepository({56configurationId: repo.id,57configurationName: repo.name,58url: repo.cloneUrl,59}),60);61}, [configurationSearch.data, excludeConfigurations]);6263const filteredRepos = useMemo(() => {64const repos = [suggestedQuery.data || [], flattenedConfigurations ?? [], searchQuery.data || []].flat();65return deduplicateAndFilterRepositories(searchString, excludeConfigurations, onlyConfigurations, repos);66}, [67searchString,68suggestedQuery.data,69searchQuery.data,70flattenedConfigurations,71excludeConfigurations,72onlyConfigurations,73]);7475return {76data: filteredRepos,77hasMore: (searchQuery.data?.length ?? 0) >= searchLimit,78isLoading: suggestedQuery.isLoading,79isSearching: searchQuery.isFetching,80isError: suggestedQuery.isError || searchQuery.isError || configurationSearch.isError,81error: suggestedQuery.error || searchQuery.error || configurationSearch.error,82};83};8485export function deduplicateAndFilterRepositories(86searchString: string,87excludeConfigurations = false,88onlyConfigurations = false,89suggestedRepos: SuggestedRepository[],90): SuggestedRepository[] {91const collected = new Set<string>();92const results: SuggestedRepository[] = [];93const reposWithConfiguration = new Set<string>();94if (!excludeConfigurations) {95suggestedRepos.forEach((r) => {96if (r.configurationId) {97reposWithConfiguration.add(r.url);98}99});100}101for (const repo of suggestedRepos) {102// normalize URLs103if (repo.url.endsWith(".git")) {104repo.url = repo.url.slice(0, -4);105}106107// filter out configuration-less entries if an entry with a configuration exists, and we're not excluding configurations108if (!repo.configurationId) {109if (reposWithConfiguration.has(repo.url) || onlyConfigurations) {110continue;111}112}113114// filter out entries that don't match the search string115if (!`${repo.url}${repo.configurationName ?? ""}`.toLowerCase().includes(searchString.trim().toLowerCase())) {116continue;117}118// filter out duplicates119const key = `${repo.url}:${excludeConfigurations ? "" : repo.configurationId || "no-configuration"}`;120if (collected.has(key)) {121continue;122}123collected.add(key);124results.push(repo);125}126127if (results.length === 0) {128// If the searchString is a URL, and it's not present in the proposed results, "artificially" add it here.129if (isValidGitUrl(searchString)) {130results.push(131new SuggestedRepository({132url: searchString,133}),134);135}136}137138// Limit what we show to 200 results139return results.slice(0, 200);140}141142const ALLOWED_GIT_PROTOCOLS = ["ssh:", "git:", "http:", "https:"];143/**144* An opinionated git URL validator145*146* Assumptions:147* - Git hosts are not themselves TLDs (like .com) or reserved names like `localhost`148* - Git clone URLs can operate over ssh://, git:// and http(s)://149* - Git clone URLs (both SSH and HTTP ones) must have a nonempty path150*/151export const isValidGitUrl = (input: string): boolean => {152const url = parseUrl(input);153if (!url) {154// SSH URLs with no protocol, such as [email protected]:gitpod-io/gitpod.git155const sshMatch = input.match(/^\w+@([^:]+):(.+)$/);156if (!sshMatch) return false;157158const [, host, path] = sshMatch;159160// Check if the path is not empty161if (!path || path.trim().length === 0) return false;162163if (path.includes(":")) return false;164165return isHostValid(host);166}167168if (!url) return false;169170if (!ALLOWED_GIT_PROTOCOLS.includes(url.protocol)) return false;171if (url.pathname.length <= 1) return false; // make sure we have some path172173return isHostValid(url.host);174};175176const isHostValid = (input?: string): boolean => {177if (!input) return false;178179const hostSegments = input.split(".");180if (hostSegments.length < 2 || hostSegments.some((chunk) => chunk === "")) return false; // check that there are no consecutive periods as well as no leading or trailing ones181182return true;183};184185186