Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/data/git-providers/unified-repositories-search-query.ts
2501 views
1
/**
2
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3
* Licensed under the GNU Affero General Public License (AGPL).
4
* See License.AGPL.txt in the project root for license information.
5
*/
6
7
import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
8
import { useSearchRepositories } from "./search-repositories-query";
9
import { useSuggestedRepositories } from "./suggested-repositories-query";
10
import { useMemo } from "react";
11
import { useListConfigurations } from "../configurations/configuration-queries";
12
import type { UseInfiniteQueryResult } from "@tanstack/react-query";
13
import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
14
import { parseUrl } from "../../utils";
15
16
export const flattenPagedConfigurations = (
17
data: UseInfiniteQueryResult<{ configurations: Configuration[] }>["data"],
18
): Configuration[] => {
19
return data?.pages.flatMap((p) => p.configurations) ?? [];
20
};
21
22
type UnifiedRepositorySearchArgs = {
23
searchString: string;
24
// If true, excludes configurations and only shows 1 entry per repo
25
excludeConfigurations?: boolean;
26
// If true, only shows entries with a corresponding configuration
27
onlyConfigurations?: boolean;
28
};
29
// Combines the suggested repositories and the search repositories query into one hook
30
export const useUnifiedRepositorySearch = ({
31
searchString,
32
excludeConfigurations = false,
33
onlyConfigurations = false,
34
}: UnifiedRepositorySearchArgs) => {
35
// 1st data source: suggested SCM repos + up to 100 imported repos.
36
// todo(ft): look into deduplicating and merging these on the server
37
const suggestedQuery = useSuggestedRepositories({ excludeConfigurations });
38
const searchLimit = 30;
39
// 2nd data source: SCM repos according to `searchString`
40
const searchQuery = useSearchRepositories({ searchString, limit: searchLimit });
41
// 3rd data source: imported repos according to `searchString`
42
const configurationSearch = useListConfigurations({
43
sortBy: "name",
44
sortOrder: "desc",
45
pageSize: searchLimit,
46
searchTerm: searchString,
47
});
48
const flattenedConfigurations = useMemo(() => {
49
if (excludeConfigurations) {
50
return [];
51
}
52
53
const flattened = flattenPagedConfigurations(configurationSearch.data);
54
return flattened.map(
55
(repo) =>
56
new SuggestedRepository({
57
configurationId: repo.id,
58
configurationName: repo.name,
59
url: repo.cloneUrl,
60
}),
61
);
62
}, [configurationSearch.data, excludeConfigurations]);
63
64
const filteredRepos = useMemo(() => {
65
const repos = [suggestedQuery.data || [], flattenedConfigurations ?? [], searchQuery.data || []].flat();
66
return deduplicateAndFilterRepositories(searchString, excludeConfigurations, onlyConfigurations, repos);
67
}, [
68
searchString,
69
suggestedQuery.data,
70
searchQuery.data,
71
flattenedConfigurations,
72
excludeConfigurations,
73
onlyConfigurations,
74
]);
75
76
return {
77
data: filteredRepos,
78
hasMore: (searchQuery.data?.length ?? 0) >= searchLimit,
79
isLoading: suggestedQuery.isLoading,
80
isSearching: searchQuery.isFetching,
81
isError: suggestedQuery.isError || searchQuery.isError || configurationSearch.isError,
82
error: suggestedQuery.error || searchQuery.error || configurationSearch.error,
83
};
84
};
85
86
export function deduplicateAndFilterRepositories(
87
searchString: string,
88
excludeConfigurations = false,
89
onlyConfigurations = false,
90
suggestedRepos: SuggestedRepository[],
91
): SuggestedRepository[] {
92
const collected = new Set<string>();
93
const results: SuggestedRepository[] = [];
94
const reposWithConfiguration = new Set<string>();
95
if (!excludeConfigurations) {
96
suggestedRepos.forEach((r) => {
97
if (r.configurationId) {
98
reposWithConfiguration.add(r.url);
99
}
100
});
101
}
102
for (const repo of suggestedRepos) {
103
// normalize URLs
104
if (repo.url.endsWith(".git")) {
105
repo.url = repo.url.slice(0, -4);
106
}
107
108
// filter out configuration-less entries if an entry with a configuration exists, and we're not excluding configurations
109
if (!repo.configurationId) {
110
if (reposWithConfiguration.has(repo.url) || onlyConfigurations) {
111
continue;
112
}
113
}
114
115
// filter out entries that don't match the search string
116
if (!`${repo.url}${repo.configurationName ?? ""}`.toLowerCase().includes(searchString.trim().toLowerCase())) {
117
continue;
118
}
119
// filter out duplicates
120
const key = `${repo.url}:${excludeConfigurations ? "" : repo.configurationId || "no-configuration"}`;
121
if (collected.has(key)) {
122
continue;
123
}
124
collected.add(key);
125
results.push(repo);
126
}
127
128
if (results.length === 0) {
129
// If the searchString is a URL, and it's not present in the proposed results, "artificially" add it here.
130
if (isValidGitUrl(searchString)) {
131
results.push(
132
new SuggestedRepository({
133
url: searchString,
134
}),
135
);
136
}
137
}
138
139
// Limit what we show to 200 results
140
return results.slice(0, 200);
141
}
142
143
const ALLOWED_GIT_PROTOCOLS = ["ssh:", "git:", "http:", "https:"];
144
/**
145
* An opinionated git URL validator
146
*
147
* Assumptions:
148
* - Git hosts are not themselves TLDs (like .com) or reserved names like `localhost`
149
* - Git clone URLs can operate over ssh://, git:// and http(s)://
150
* - Git clone URLs (both SSH and HTTP ones) must have a nonempty path
151
*/
152
export const isValidGitUrl = (input: string): boolean => {
153
const url = parseUrl(input);
154
if (!url) {
155
// SSH URLs with no protocol, such as [email protected]:gitpod-io/gitpod.git
156
const sshMatch = input.match(/^\w+@([^:]+):(.+)$/);
157
if (!sshMatch) return false;
158
159
const [, host, path] = sshMatch;
160
161
// Check if the path is not empty
162
if (!path || path.trim().length === 0) return false;
163
164
if (path.includes(":")) return false;
165
166
return isHostValid(host);
167
}
168
169
if (!url) return false;
170
171
if (!ALLOWED_GIT_PROTOCOLS.includes(url.protocol)) return false;
172
if (url.pathname.length <= 1) return false; // make sure we have some path
173
174
return isHostValid(url.host);
175
};
176
177
const isHostValid = (input?: string): boolean => {
178
if (!input) return false;
179
180
const hostSegments = input.split(".");
181
if (hostSegments.length < 2 || hostSegments.some((chunk) => chunk === "")) return false; // check that there are no consecutive periods as well as no leading or trailing ones
182
183
return true;
184
};
185
186