Skip to content

Commit 754dc9e

Browse files
Organization-recommended repositories (#20559)
* [server] implement `recommendedRepositories` API field Tool: gitpod/catfood.gitpod.cloud * [dashboard] implement org-suggested repositories Tool: gitpod/catfood.gitpod.cloud * [dashboard] Spacing and pill labels Tool: gitpod/catfood.gitpod.cloud * update copy Tool: gitpod/catfood.gitpod.cloud * Get rid of debug fluff Tool: gitpod/catfood.gitpod.cloud * Fix onboarding page heading Tool: gitpod/catfood.gitpod.cloud * minor nitfix Tool: gitpod/catfood.gitpod.cloud * Cascade project deletions to repo recommendations Tool: gitpod/catfood.gitpod.cloud * Fix db tests Tool: gitpod/catfood.gitpod.cloud * Fix docs link Tool: gitpod/catfood.gitpod.cloud * add db test Tool: gitpod/catfood.gitpod.cloud * Fix showing suggested repos even with no user contributions Tool: gitpod/catfood.gitpod.cloud * Add organization suggested repositories to RepositoryFinder Tool: gitpod/catfood.gitpod.cloud * Don't add recommended repos to workspace list for now Tool: gitpod/catfood.gitpod.cloud * Regular repo icon for org-suggested repos Tool: gitpod/catfood.gitpod.cloud
1 parent 71e2b01 commit 754dc9e

File tree

20 files changed

+1318
-677
lines changed

20 files changed

+1318
-677
lines changed

components/dashboard/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"js-cookie": "^3.0.1",
4242
"lite-youtube-embed": "^0.3.2",
4343
"lodash": "^4.17.21",
44-
"lucide-react": "^0.287.0",
44+
"lucide-react": "^0.474.0",
4545
"pretty-bytes": "^6.1.0",
4646
"process": "^0.11.10",
4747
"query-string": "^7.1.1",

components/dashboard/src/components/RepositoryFinder.tsx

Lines changed: 46 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react";
88
import { Combobox, ComboboxElement, ComboboxSelectedItem } from "./podkit/combobox/Combobox";
99
import RepositorySVG from "../icons/Repository.svg";
1010
import { ReactComponent as RepositoryIcon } from "../icons/RepositoryWithColor.svg";
11-
import { ReactComponent as GitpodRepositoryTemplate } from "../icons/GitpodRepositoryTemplate.svg";
1211
import GitpodRepositoryTemplateSVG from "../icons/GitpodRepositoryTemplate.svg";
1312
import { MiddleDot } from "./typography/MiddleDot";
1413
import {
@@ -25,17 +24,48 @@ import { useConfiguration, useListConfigurations } from "../data/configurations/
2524
import { useUserLoader } from "../hooks/use-user-loader";
2625
import { conjunctScmProviders, getDeduplicatedScmProviders } from "../utils";
2726
import { cn } from "@podkit/lib/cn";
27+
import { useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query";
28+
import { toRemoteURL } from "../projects/render-utils";
2829

29-
const isPredefined = (repo: SuggestedRepository): boolean => {
30-
return PREDEFINED_REPOS.some((predefined) => predefined.url === repo.url) && !repo.configurationId;
30+
type PredefinedRepoOption = typeof PREDEFINED_REPOS[number];
31+
const isPredefined = (repo: SuggestedRepository | PredefinedRepoOption): boolean => {
32+
return (
33+
PREDEFINED_REPOS.some((predefined) => predefined.url === repo.url) &&
34+
!(repo as SuggestedRepository).configurationId
35+
);
3136
};
3237

3338
const resolveIcon = (contextUrl?: string): string => {
3439
if (!contextUrl) return RepositorySVG;
3540
return PREDEFINED_REPOS.some((repo) => repo.url === contextUrl) ? GitpodRepositoryTemplateSVG : RepositorySVG;
3641
};
3742

38-
interface RepositoryFinderProps {
43+
type PredefinedRepositoryOptionProps = {
44+
repo: PredefinedRepoOption;
45+
};
46+
const PredefinedRepositoryOption: FC<PredefinedRepositoryOptionProps> = ({ repo }) => {
47+
const prettyUrl = toRemoteURL(repo.url);
48+
const icon = resolveIcon(repo.url);
49+
50+
return (
51+
<div className="flex flex-col overflow-hidden" aria-label={`Demo: ${repo.url}`}>
52+
<div className="flex items-center">
53+
<img className={cn("w-5 mr-2 text-pk-content-secondary")} src={icon} alt="" />
54+
<span className="text-sm font-semibold">{repo.repoName}</span>
55+
<MiddleDot className="px-0.5 text-pk-content-secondary" />
56+
<span
57+
className="text-sm whitespace-nowrap truncate overflow-ellipsis text-pk-content-secondary"
58+
title={prettyUrl}
59+
>
60+
{prettyUrl}
61+
</span>
62+
</div>
63+
<span className="text-xs text-pk-content-secondary ml-7">{repo.description}</span>
64+
</div>
65+
);
66+
};
67+
68+
type RepositoryFinderProps = {
3969
selectedContextURL?: string;
4070
selectedConfigurationId?: string;
4171
disabled?: boolean;
@@ -44,8 +74,7 @@ interface RepositoryFinderProps {
4474
onlyConfigurations?: boolean;
4575
showExamples?: boolean;
4676
onChange?: (repo: SuggestedRepository) => void;
47-
}
48-
77+
};
4978
export default function RepositoryFinder({
5079
selectedContextURL,
5180
selectedConfigurationId,
@@ -70,6 +99,8 @@ export default function RepositoryFinder({
7099
onlyConfigurations,
71100
});
72101

102+
const { data: orgSuggestedRepos } = useOrgSuggestedRepos();
103+
73104
// We search for the current context URL in order to have data for the selected suggestion
74105
const selectedItemSearch = useListConfigurations({
75106
sortBy: "name",
@@ -162,29 +193,6 @@ export default function RepositoryFinder({
162193
const [hasStartedSearching, setHasStartedSearching] = useState(false);
163194
const [isShowingExamples, setIsShowingExamples] = useState(showExamples);
164195

165-
type PredefinedRepositoryOptionProps = {
166-
repo: typeof PREDEFINED_REPOS[number];
167-
};
168-
169-
const PredefinedRepositoryOption: FC<PredefinedRepositoryOptionProps> = ({ repo }) => {
170-
return (
171-
<div className="flex flex-col overflow-hidden" aria-label={`Demo: ${repo.url}`}>
172-
<div className="flex items-center">
173-
<GitpodRepositoryTemplate className="w-5 h-5 text-pk-content-secondary mr-2" />
174-
<span className="text-sm font-semibold">{repo.repoName}</span>
175-
<MiddleDot className="px-0.5 text-pk-content-secondary" />
176-
<span
177-
className="text-sm whitespace-nowrap truncate overflow-ellipsis text-pk-content-secondary"
178-
title={repo.repoPath}
179-
>
180-
{repo.repoPath}
181-
</span>
182-
</div>
183-
<span className="text-xs text-pk-content-secondary ml-7">{repo.description}</span>
184-
</div>
185-
);
186-
};
187-
188196
// Resolve the selected context url & configurationId id props to a suggestion entry
189197
useEffect(() => {
190198
let match = repos?.find((repo) => {
@@ -267,13 +275,21 @@ export default function RepositoryFinder({
267275
};
268276

269277
const filteredPredefinedRepos = useMemo(() => {
278+
if (orgSuggestedRepos?.length) {
279+
return orgSuggestedRepos.map((repo) => ({
280+
url: repo.url,
281+
repoName: repo.repoName,
282+
description: "",
283+
}));
284+
}
285+
270286
return PREDEFINED_REPOS.filter((repo) => {
271287
const url = new URL(repo.url);
272288
const isMatchingAuthProviderAvailable =
273289
authProviders.data?.some((provider) => provider.host === url.host) ?? false;
274290
return isMatchingAuthProviderAvailable;
275291
});
276-
}, [authProviders.data]);
292+
}, [authProviders.data, orgSuggestedRepos]);
277293

278294
const getElements = useCallback(
279295
(searchString: string): ComboboxElement[] => {

components/dashboard/src/components/podkit/dropdown/DropDown.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ const DropdownMenuItem = React.forwardRef<
9797
));
9898
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
9999

100+
const DropdownLinkMenuItem = React.forwardRef<
101+
HTMLAnchorElement,
102+
React.AnchorHTMLAttributes<HTMLAnchorElement> & {
103+
inset?: boolean;
104+
}
105+
>(({ className, inset, ...props }, ref) => (
106+
<DropdownMenuItem asChild>
107+
<a
108+
href={props.href}
109+
className={cn(
110+
"relative flex cursor-default select-none items-center px-2 py-1.5 text-sm",
111+
"outline-none bg-pk-surface-primary focus:text-pk-content-primary focus:bg-pk-surface-tertiary",
112+
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 rounded-none",
113+
className,
114+
)}
115+
{...props}
116+
>
117+
{props.children}
118+
</a>
119+
</DropdownMenuItem>
120+
));
121+
DropdownLinkMenuItem.displayName = "DropdownLinkMenuItem";
122+
100123
const DropdownMenuCheckboxItem = React.forwardRef<
101124
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
102125
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
@@ -189,4 +212,5 @@ export {
189212
DropdownMenuSubContent,
190213
DropdownMenuSubTrigger,
191214
DropdownMenuRadioGroup,
215+
DropdownLinkMenuItem,
192216
};

components/dashboard/src/data/git-providers/predefined-repos.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,10 @@ export const PREDEFINED_REPOS = [
99
url: "https://github.com/gitpod-demos/voting-app",
1010
repoName: "demo-docker",
1111
description: "A fully configured demo with Docker Compose, Redis and Postgres",
12-
repoPath: "github.com/gitpod-demos/voting-app",
1312
},
1413
{
1514
url: "https://github.com/gitpod-demos/spring-petclinic",
1615
repoName: "demo-java",
1716
description: "A fully configured demo with Java, Maven and Spring Boot",
18-
repoPath: "github.com/gitpod-demos/spring-petclinic",
1917
},
20-
] as const;
18+
];
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Copyright (c) 2025 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, useQueryClient } from "@tanstack/react-query";
8+
import { useCallback } from "react";
9+
import { configurationClient, organizationClient } from "../../service/public-api";
10+
import { useCurrentOrg } from "./orgs-query";
11+
import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
12+
import { PlainMessage } from "@bufbuild/protobuf";
13+
import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
14+
15+
export function useOrgRepoSuggestionsInvalidator() {
16+
const organizationId = useCurrentOrg().data?.id;
17+
const queryClient = useQueryClient();
18+
return useCallback(() => {
19+
queryClient.invalidateQueries(getQueryKey(organizationId));
20+
}, [organizationId, queryClient]);
21+
}
22+
23+
export type SuggestedOrgRepository = PlainMessage<SuggestedRepository> & {
24+
orgSuggested: true;
25+
configuration: Configuration;
26+
};
27+
28+
export function useOrgSuggestedRepos() {
29+
const organizationId = useCurrentOrg().data?.id;
30+
const query = useQuery<SuggestedOrgRepository[], Error>(
31+
getQueryKey(organizationId),
32+
async () => {
33+
const response = await organizationClient.getOrganizationSettings({
34+
organizationId,
35+
});
36+
const repos = response.settings?.onboardingSettings?.recommendedRepositories ?? [];
37+
38+
const suggestions: SuggestedOrgRepository[] = [];
39+
for (const configurationId of repos) {
40+
const { configuration } = await configurationClient.getConfiguration({
41+
configurationId: configurationId,
42+
});
43+
if (!configuration) {
44+
continue;
45+
}
46+
const suggestion: SuggestedOrgRepository = {
47+
configurationId: configurationId,
48+
configurationName: configuration.name ?? "",
49+
repoName: configuration.name ?? "",
50+
url: configuration.cloneUrl ?? "",
51+
orgSuggested: true,
52+
configuration,
53+
};
54+
55+
suggestions.push(suggestion);
56+
}
57+
58+
return suggestions;
59+
},
60+
{
61+
enabled: !!organizationId,
62+
cacheTime: 1000 * 60 * 60 * 24 * 7, // 1 week
63+
staleTime: 1000 * 60 * 5, // 5 minutes
64+
},
65+
);
66+
return query;
67+
}
68+
69+
export function getQueryKey(organizationId: string | undefined) {
70+
return ["org-suggested-repositories", organizationId ?? "undefined"];
71+
}

components/dashboard/src/data/organizations/update-org-settings-mutation.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organizat
1212
import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
1313
import { useOrgWorkspaceClassesQueryInvalidator } from "./org-workspace-classes-query";
1414
import { PlainMessage } from "@bufbuild/protobuf";
15+
import { useOrgRepoSuggestionsInvalidator } from "./suggested-repositories-query";
1516

1617
type UpdateOrganizationSettingsArgs = Partial<
1718
Pick<
@@ -34,7 +35,8 @@ export const useUpdateOrgSettingsMutation = () => {
3435
const org = useCurrentOrg().data;
3536
const invalidateOrgSettings = useOrgSettingsQueryInvalidator();
3637
const invalidateWorkspaceClasses = useOrgWorkspaceClassesQueryInvalidator();
37-
const teamId = org?.id ?? "";
38+
const invalidateOrgRepoSuggestions = useOrgRepoSuggestionsInvalidator();
39+
const organizationId = org?.id ?? "";
3840

3941
return useMutation<OrganizationSettings, Error, UpdateOrganizationSettingsArgs>({
4042
mutationFn: async ({
@@ -51,7 +53,7 @@ export const useUpdateOrgSettingsMutation = () => {
5153
annotateGitCommits,
5254
}) => {
5355
const settings = await organizationClient.updateOrganizationSettings({
54-
organizationId: teamId,
56+
organizationId,
5557
workspaceSharingDisabled: workspaceSharingDisabled ?? false,
5658
defaultWorkspaceImage,
5759
allowedWorkspaceClasses,
@@ -72,6 +74,7 @@ export const useUpdateOrgSettingsMutation = () => {
7274
onSuccess: () => {
7375
invalidateOrgSettings();
7476
invalidateWorkspaceClasses();
77+
invalidateOrgRepoSuggestions();
7578
},
7679
onError: (err) => {
7780
if (!ErrorCode.isUserError((err as any)?.["code"])) {

components/dashboard/src/repositories/list/RepoListItem.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,24 @@ import { TextMuted } from "@podkit/typography/TextMuted";
1010
import { Text } from "@podkit/typography/Text";
1111
import { LinkButton } from "@podkit/buttons/LinkButton";
1212
import type { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
13-
import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react";
13+
import { AlertTriangleIcon, CheckCircle2Icon, SquareArrowOutUpRight, Ellipsis } from "lucide-react";
1414
import { TableCell, TableRow } from "@podkit/tables/Table";
15+
import { Button } from "@podkit/buttons/Button";
16+
import {
17+
DropdownLinkMenuItem,
18+
DropdownMenu,
19+
DropdownMenuContent,
20+
DropdownMenuItem,
21+
DropdownMenuTrigger,
22+
} from "@podkit/dropdown/DropDown";
23+
import PillLabel from "../../components/PillLabel";
1524

1625
type Props = {
1726
configuration: Configuration;
27+
isSuggested: boolean;
28+
handleModifySuggestedRepository?: (configurationId: string, suggested: boolean) => void;
1829
};
19-
export const RepositoryListItem: FC<Props> = ({ configuration }) => {
30+
export const RepositoryListItem: FC<Props> = ({ configuration, isSuggested, handleModifySuggestedRepository }) => {
2031
const url = usePrettyRepoURL(configuration.cloneUrl);
2132
const prebuildsEnabled = !!configuration.prebuildSettings?.enabled;
2233
const created =
@@ -27,8 +38,18 @@ export const RepositoryListItem: FC<Props> = ({ configuration }) => {
2738
return (
2839
<TableRow>
2940
<TableCell>
30-
<div className="flex flex-col gap-1 break-words w-52">
31-
<Text className="font-semibold">{configuration.name}</Text>
41+
<div className="flex flex-col gap-1 break-words w-auto md:w-64">
42+
<Text className="font-semibold flex items-center justify-between gap-1">
43+
{configuration.name}
44+
{isSuggested && (
45+
<PillLabel
46+
className="capitalize bg-kumquat-light shrink-0 text-sm hidden xl:block"
47+
type="warn"
48+
>
49+
Suggested
50+
</PillLabel>
51+
)}
52+
</Text>
3253
{/* We show the url on a 2nd line for smaller screens since we hide the column */}
3354
<TextMuted className="inline md:hidden text-sm break-all">{url}</TextMuted>
3455
</div>
@@ -52,10 +73,45 @@ export const RepositoryListItem: FC<Props> = ({ configuration }) => {
5273
</div>
5374
</TableCell>
5475

55-
<TableCell>
76+
<TableCell className="flex items-center gap-4">
5677
<LinkButton href={`/repositories/${configuration.id}`} variant="secondary">
5778
View
5879
</LinkButton>
80+
{handleModifySuggestedRepository && (
81+
<DropdownMenu>
82+
<DropdownMenuTrigger asChild>
83+
<Button variant="ghost">
84+
<Ellipsis size={20} />
85+
</Button>
86+
</DropdownMenuTrigger>
87+
<DropdownMenuContent className="w-52">
88+
{isSuggested ? (
89+
<DropdownMenuItem
90+
onClick={() => handleModifySuggestedRepository(configuration.id, false)}
91+
>
92+
Remove from suggested repos
93+
</DropdownMenuItem>
94+
) : (
95+
<>
96+
<DropdownMenuItem
97+
onClick={() => handleModifySuggestedRepository(configuration.id, true)}
98+
>
99+
Add to suggested repos
100+
</DropdownMenuItem>
101+
<DropdownLinkMenuItem
102+
href="https://www.gitpod.io/docs/configure/orgs/onboarding#suggested-repositories"
103+
className="gap-1 text-xs"
104+
target="_blank"
105+
rel="noreferrer"
106+
>
107+
Learn about suggestions
108+
<SquareArrowOutUpRight size={12} />
109+
</DropdownLinkMenuItem>
110+
</>
111+
)}
112+
</DropdownMenuContent>
113+
</DropdownMenu>
114+
)}
59115
</TableCell>
60116
</TableRow>
61117
);

0 commit comments

Comments
 (0)