Skip to content

Commit fa3010a

Browse files
Adding getSuggestedRepositories method (#18681)
* Adding getSuggestedRepositories method * accounting for lastUse better * limit to org * remove repoName for now * add checks * cleanup * remove repositoryName for now * cleanup * removing repo name for now * adding tracing
1 parent 843290d commit fa3010a

File tree

7 files changed

+199
-5
lines changed

7 files changed

+199
-5
lines changed

components/dashboard/src/components/RepositoryFinder.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { useCallback, useEffect, useState } from "react";
7+
import { useCallback, useEffect, useMemo, useState } from "react";
88
import { getGitpodService } from "../service/service";
99
import { DropDown2, DropDown2Element, DropDown2SelectedElement } from "./DropDown2";
1010
import Repository from "../icons/Repository.svg";
11+
import { useSuggestedRepositories } from "../data/git-providers/suggested-repositories-query";
12+
import { useFeatureFlag } from "../data/featureflag-query";
1113

1214
const LOCAL_STORAGE_KEY = "open-in-gitpod-search-data";
1315

@@ -28,7 +30,22 @@ function stripOffProtocol(url: string): string {
2830
}
2931

3032
export default function RepositoryFinder(props: RepositoryFinderProps) {
33+
const includeProjectsOnCreateWorkspace = useFeatureFlag("includeProjectsOnCreateWorkspace");
34+
3135
const [suggestedContextURLs, setSuggestedContextURLs] = useState<string[]>(loadSearchData());
36+
const { data: suggestedRepos } = useSuggestedRepositories();
37+
38+
const suggestedRepoURLs = useMemo(() => {
39+
// If the flag is disabled continue to use suggestedContextURLs
40+
if (!includeProjectsOnCreateWorkspace) {
41+
return suggestedContextURLs;
42+
}
43+
44+
// For now, convert the suggestedRepos to a list of URLs
45+
// We'll follow up with updating the UI with the new data
46+
return suggestedRepos?.map((repo) => repo.url) || [];
47+
}, [suggestedContextURLs, suggestedRepos, includeProjectsOnCreateWorkspace]);
48+
3249
useEffect(() => {
3350
getGitpodService()
3451
.server.getSuggestedContextURLs()
@@ -43,21 +60,21 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
4360
let result: string[];
4461
searchString = searchString.trim();
4562
if (searchString.length > 1) {
46-
result = suggestedContextURLs.filter((e) => e.toLowerCase().indexOf(searchString.toLowerCase()) !== -1);
63+
result = suggestedRepoURLs.filter((e) => e.toLowerCase().indexOf(searchString.toLowerCase()) !== -1);
4764
if (result.length > 200) {
4865
result = result.slice(0, 200);
4966
}
5067
if (result.length === 0) {
5168
try {
5269
// If the searchString is a URL, and it's not present in the proposed results, "artificially" add it here.
5370
new URL(searchString);
54-
if (!suggestedContextURLs.includes(searchString)) {
71+
if (!suggestedRepoURLs.includes(searchString)) {
5572
result.push(searchString);
5673
}
5774
} catch {}
5875
}
5976
} else {
60-
result = suggestedContextURLs.slice(0, 200);
77+
result = suggestedRepoURLs.slice(0, 200);
6178
}
6279

6380
return result.map(
@@ -79,7 +96,7 @@ export default function RepositoryFinder(props: RepositoryFinderProps) {
7996
} as DropDown2Element),
8097
);
8198
},
82-
[suggestedContextURLs],
99+
[suggestedRepoURLs],
83100
);
84101

85102
const element = (

components/dashboard/src/data/featureflag-query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const featureFlags = {
2727
supervisor_live_git_status: false,
2828
enabledOrbitalDiscoveries: "",
2929
newProjectIncrementalRepoSearchBBS: false,
30+
includeProjectsOnCreateWorkspace: false,
3031
};
3132

3233
type FeatureFlags = typeof featureFlags;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 { useQuery } from "@tanstack/react-query";
8+
import { useCurrentOrg } from "../organizations/orgs-query";
9+
import { getGitpodService } from "../../service/service";
10+
11+
export const useSuggestedRepositories = () => {
12+
const { data: org } = useCurrentOrg();
13+
14+
return useQuery(["suggested-repositories", { orgId: org?.id }], async () => {
15+
if (!org) {
16+
throw new Error("No org selected");
17+
}
18+
19+
return await getGitpodService().server.getSuggestedRepositories(org.id);
20+
});
21+
};

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
WorkspaceTimeoutSetting,
3131
WorkspaceContext,
3232
LinkedInProfile,
33+
SuggestedRepository,
3334
} from "./protocol";
3435
import {
3536
Team,
@@ -98,6 +99,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
9899
getWorkspaceUsers(workspaceId: string): Promise<WorkspaceInstanceUser[]>;
99100
getFeaturedRepositories(): Promise<WhitelistedRepository[]>;
100101
getSuggestedContextURLs(): Promise<string[]>;
102+
getSuggestedRepositories(organizationId: string): Promise<SuggestedRepository[]>;
101103
/**
102104
* **Security:**
103105
* Sensitive information like an owner token is erased, since it allows access for all team members.

components/gitpod-protocol/src/protocol.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,3 +1615,9 @@ export interface LinkedInProfile {
16151615
profilePicture: string;
16161616
emailAddress: string;
16171617
}
1618+
1619+
export type SuggestedRepository = {
1620+
url: string;
1621+
projectId?: string;
1622+
projectName?: string;
1623+
};

components/server/src/auth/rate-limiter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const defaultFunctions: FunctionsConfig = {
6565
getWorkspaceUsers: { group: "default", points: 1 },
6666
getFeaturedRepositories: { group: "default", points: 1 },
6767
getSuggestedContextURLs: { group: "default", points: 1 },
68+
getSuggestedRepositories: { group: "default", points: 1 },
6869
getWorkspace: { group: "default", points: 1 },
6970
isWorkspaceOwner: { group: "default", points: 1 },
7071
getOwnerToken: { group: "default", points: 1 },

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
RoleOrPermission,
6868
WorkspaceInstanceRepoStatus,
6969
GetProviderRepositoriesParams,
70+
SuggestedRepository,
7071
} from "@gitpod/gitpod-protocol";
7172
import { BlockedRepository } from "@gitpod/gitpod-protocol/lib/blocked-repositories-protocol";
7273
import {
@@ -1739,6 +1740,151 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
17391740
.map((s) => s.url);
17401741
}
17411742

1743+
public async getSuggestedRepositories(ctx: TraceContext, organizationId: string): Promise<SuggestedRepository[]> {
1744+
traceAPIParams(ctx, { organizationId });
1745+
1746+
const user = await this.checkAndBlockUser("getSuggestedRepositories");
1747+
1748+
if (!uuidValidate(organizationId)) {
1749+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId must be a valid UUID");
1750+
}
1751+
1752+
const logCtx: LogContext = { userId: user.id };
1753+
1754+
type SuggestedRepositoryWithSorting = SuggestedRepository & {
1755+
priority: number;
1756+
lastUse?: string;
1757+
};
1758+
1759+
const fetchProjects = async (): Promise<SuggestedRepositoryWithSorting[]> => {
1760+
const span = TraceContext.startSpan("getSuggestedRepositories.fetchProjects", ctx);
1761+
const projects = await this.projectsService.getProjects(user.id, organizationId);
1762+
1763+
const projectRepos = projects.map((project) => ({
1764+
url: project.cloneUrl.replace(/\.git$/, ""),
1765+
projectId: project.id,
1766+
projectName: project.name,
1767+
priority: 1,
1768+
}));
1769+
1770+
span.finish();
1771+
1772+
return projectRepos;
1773+
};
1774+
1775+
// Load user repositories (from Git hosts directly)
1776+
const fetchUserRepos = async (): Promise<SuggestedRepositoryWithSorting[]> => {
1777+
const span = TraceContext.startSpan("getSuggestedRepositories.fetchUserRepos", ctx);
1778+
const authProviders = await this.getAuthProviders(ctx);
1779+
1780+
const providerRepos = await Promise.all(
1781+
authProviders.map(async (p): Promise<SuggestedRepositoryWithSorting[]> => {
1782+
try {
1783+
span.setTag("host", p.host);
1784+
1785+
const hostContext = this.hostContextProvider.get(p.host);
1786+
const services = hostContext?.services;
1787+
if (!services) {
1788+
log.error(logCtx, "Unsupported repository host: " + p.host);
1789+
return [];
1790+
}
1791+
const userRepos = await services.repositoryProvider.getUserRepos(user);
1792+
1793+
return userRepos.map((r) => ({
1794+
url: r.replace(/\.git$/, ""),
1795+
priority: 5,
1796+
}));
1797+
} catch (error) {
1798+
log.debug(logCtx, "Could not get user repositories from host " + p.host, error);
1799+
}
1800+
1801+
return [];
1802+
}),
1803+
);
1804+
1805+
span.finish();
1806+
1807+
return providerRepos.flat();
1808+
};
1809+
1810+
const fetchRecentRepos = async (): Promise<SuggestedRepositoryWithSorting[]> => {
1811+
const span = TraceContext.startSpan("getSuggestedRepositories.fetchRecentRepos", ctx);
1812+
1813+
const workspaces = await this.getWorkspaces(ctx, { organizationId });
1814+
const recentRepos: SuggestedRepositoryWithSorting[] = [];
1815+
1816+
for (const ws of workspaces) {
1817+
let repoUrl;
1818+
if (CommitContext.is(ws.workspace.context)) {
1819+
repoUrl = ws.workspace.context?.repository?.cloneUrl?.replace(/\.git$/, "");
1820+
}
1821+
if (!repoUrl) {
1822+
repoUrl = ws.workspace.contextURL;
1823+
}
1824+
if (repoUrl) {
1825+
const lastUse = WorkspaceInfo.lastActiveISODate(ws);
1826+
1827+
recentRepos.push({
1828+
url: repoUrl,
1829+
projectId: ws.workspace.projectId,
1830+
priority: 10,
1831+
lastUse,
1832+
});
1833+
}
1834+
}
1835+
1836+
span.finish();
1837+
1838+
return recentRepos;
1839+
};
1840+
1841+
const repoResults = await Promise.allSettled([
1842+
fetchProjects().catch((e) => log.error(logCtx, "Could not fetch projects", e)),
1843+
fetchUserRepos().catch((e) => log.error(logCtx, "Could not fetch user repositories", e)),
1844+
fetchRecentRepos().catch((e) => log.error(logCtx, "Could not fetch recent repositories", e)),
1845+
]);
1846+
1847+
const sortedRepos = repoResults
1848+
.map((r) => (r.status === "fulfilled" ? r.value || [] : []))
1849+
.flat()
1850+
.sort((a, b) => {
1851+
// priority first
1852+
if (a.priority !== b.priority) {
1853+
return a.priority < b.priority ? 1 : -1;
1854+
}
1855+
// Most recently used second
1856+
if (b.lastUse || a.lastUse) {
1857+
const la = a.lastUse || "";
1858+
const lb = b.lastUse || "";
1859+
return la < lb ? 1 : la === lb ? 0 : -1;
1860+
}
1861+
// Otherwise, alphasort
1862+
const ua = a.url.toLowerCase();
1863+
const ub = b.url.toLowerCase();
1864+
return ua > ub ? 1 : ua === ub ? 0 : -1;
1865+
});
1866+
1867+
const uniqueRepositories = new Map<string, SuggestedRepositoryWithSorting>();
1868+
1869+
for (const repo of sortedRepos) {
1870+
const existingRepo = uniqueRepositories.get(repo.url);
1871+
1872+
uniqueRepositories.set(repo.url, {
1873+
...(existingRepo || {}),
1874+
...repo,
1875+
});
1876+
}
1877+
1878+
// Convert to return type
1879+
return Array.from(uniqueRepositories.values()).map(
1880+
(repo): SuggestedRepository => ({
1881+
url: repo.url,
1882+
projectId: repo.projectId,
1883+
projectName: repo.projectName,
1884+
}),
1885+
);
1886+
}
1887+
17421888
public async setWorkspaceTimeout(
17431889
ctx: TraceContext,
17441890
workspaceId: string,

0 commit comments

Comments
 (0)