Skip to content

Commit

Permalink
Organization-recommended repositories (#20559)
Browse files Browse the repository at this point in the history
* [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
  • Loading branch information
filiptronicek authored Feb 4, 2025
1 parent 71e2b01 commit 754dc9e
Show file tree
Hide file tree
Showing 20 changed files with 1,318 additions and 677 deletions.
2 changes: 1 addition & 1 deletion components/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"js-cookie": "^3.0.1",
"lite-youtube-embed": "^0.3.2",
"lodash": "^4.17.21",
"lucide-react": "^0.287.0",
"lucide-react": "^0.474.0",
"pretty-bytes": "^6.1.0",
"process": "^0.11.10",
"query-string": "^7.1.1",
Expand Down
76 changes: 46 additions & 30 deletions components/dashboard/src/components/RepositoryFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { Combobox, ComboboxElement, ComboboxSelectedItem } from "./podkit/combobox/Combobox";
import RepositorySVG from "../icons/Repository.svg";
import { ReactComponent as RepositoryIcon } from "../icons/RepositoryWithColor.svg";
import { ReactComponent as GitpodRepositoryTemplate } from "../icons/GitpodRepositoryTemplate.svg";
import GitpodRepositoryTemplateSVG from "../icons/GitpodRepositoryTemplate.svg";
import { MiddleDot } from "./typography/MiddleDot";
import {
Expand All @@ -25,17 +24,48 @@ import { useConfiguration, useListConfigurations } from "../data/configurations/
import { useUserLoader } from "../hooks/use-user-loader";
import { conjunctScmProviders, getDeduplicatedScmProviders } from "../utils";
import { cn } from "@podkit/lib/cn";
import { useOrgSuggestedRepos } from "../data/organizations/suggested-repositories-query";
import { toRemoteURL } from "../projects/render-utils";

const isPredefined = (repo: SuggestedRepository): boolean => {
return PREDEFINED_REPOS.some((predefined) => predefined.url === repo.url) && !repo.configurationId;
type PredefinedRepoOption = typeof PREDEFINED_REPOS[number];
const isPredefined = (repo: SuggestedRepository | PredefinedRepoOption): boolean => {
return (
PREDEFINED_REPOS.some((predefined) => predefined.url === repo.url) &&
!(repo as SuggestedRepository).configurationId
);
};

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

interface RepositoryFinderProps {
type PredefinedRepositoryOptionProps = {
repo: PredefinedRepoOption;
};
const PredefinedRepositoryOption: FC<PredefinedRepositoryOptionProps> = ({ repo }) => {
const prettyUrl = toRemoteURL(repo.url);
const icon = resolveIcon(repo.url);

return (
<div className="flex flex-col overflow-hidden" aria-label={`Demo: ${repo.url}`}>
<div className="flex items-center">
<img className={cn("w-5 mr-2 text-pk-content-secondary")} src={icon} alt="" />
<span className="text-sm font-semibold">{repo.repoName}</span>
<MiddleDot className="px-0.5 text-pk-content-secondary" />
<span
className="text-sm whitespace-nowrap truncate overflow-ellipsis text-pk-content-secondary"
title={prettyUrl}
>
{prettyUrl}
</span>
</div>
<span className="text-xs text-pk-content-secondary ml-7">{repo.description}</span>
</div>
);
};

type RepositoryFinderProps = {
selectedContextURL?: string;
selectedConfigurationId?: string;
disabled?: boolean;
Expand All @@ -44,8 +74,7 @@ interface RepositoryFinderProps {
onlyConfigurations?: boolean;
showExamples?: boolean;
onChange?: (repo: SuggestedRepository) => void;
}

};
export default function RepositoryFinder({
selectedContextURL,
selectedConfigurationId,
Expand All @@ -70,6 +99,8 @@ export default function RepositoryFinder({
onlyConfigurations,
});

const { data: orgSuggestedRepos } = useOrgSuggestedRepos();

// We search for the current context URL in order to have data for the selected suggestion
const selectedItemSearch = useListConfigurations({
sortBy: "name",
Expand Down Expand Up @@ -162,29 +193,6 @@ export default function RepositoryFinder({
const [hasStartedSearching, setHasStartedSearching] = useState(false);
const [isShowingExamples, setIsShowingExamples] = useState(showExamples);

type PredefinedRepositoryOptionProps = {
repo: typeof PREDEFINED_REPOS[number];
};

const PredefinedRepositoryOption: FC<PredefinedRepositoryOptionProps> = ({ repo }) => {
return (
<div className="flex flex-col overflow-hidden" aria-label={`Demo: ${repo.url}`}>
<div className="flex items-center">
<GitpodRepositoryTemplate className="w-5 h-5 text-pk-content-secondary mr-2" />
<span className="text-sm font-semibold">{repo.repoName}</span>
<MiddleDot className="px-0.5 text-pk-content-secondary" />
<span
className="text-sm whitespace-nowrap truncate overflow-ellipsis text-pk-content-secondary"
title={repo.repoPath}
>
{repo.repoPath}
</span>
</div>
<span className="text-xs text-pk-content-secondary ml-7">{repo.description}</span>
</div>
);
};

// Resolve the selected context url & configurationId id props to a suggestion entry
useEffect(() => {
let match = repos?.find((repo) => {
Expand Down Expand Up @@ -267,13 +275,21 @@ export default function RepositoryFinder({
};

const filteredPredefinedRepos = useMemo(() => {
if (orgSuggestedRepos?.length) {
return orgSuggestedRepos.map((repo) => ({
url: repo.url,
repoName: repo.repoName,
description: "",
}));
}

return PREDEFINED_REPOS.filter((repo) => {
const url = new URL(repo.url);
const isMatchingAuthProviderAvailable =
authProviders.data?.some((provider) => provider.host === url.host) ?? false;
return isMatchingAuthProviderAvailable;
});
}, [authProviders.data]);
}, [authProviders.data, orgSuggestedRepos]);

const getElements = useCallback(
(searchString: string): ComboboxElement[] => {
Expand Down
24 changes: 24 additions & 0 deletions components/dashboard/src/components/podkit/dropdown/DropDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ const DropdownMenuItem = React.forwardRef<
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;

const DropdownLinkMenuItem = React.forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuItem asChild>
<a
href={props.href}
className={cn(
"relative flex cursor-default select-none items-center px-2 py-1.5 text-sm",
"outline-none bg-pk-surface-primary focus:text-pk-content-primary focus:bg-pk-surface-tertiary",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50 rounded-none",
className,
)}
{...props}
>
{props.children}
</a>
</DropdownMenuItem>
));
DropdownLinkMenuItem.displayName = "DropdownLinkMenuItem";

const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
Expand Down Expand Up @@ -189,4 +212,5 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownLinkMenuItem,
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ export const PREDEFINED_REPOS = [
url: "https://github.com/gitpod-demos/voting-app",
repoName: "demo-docker",
description: "A fully configured demo with Docker Compose, Redis and Postgres",
repoPath: "github.com/gitpod-demos/voting-app",
},
{
url: "https://github.com/gitpod-demos/spring-petclinic",
repoName: "demo-java",
description: "A fully configured demo with Java, Maven and Spring Boot",
repoPath: "github.com/gitpod-demos/spring-petclinic",
},
] as const;
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { configurationClient, organizationClient } from "../../service/public-api";
import { useCurrentOrg } from "./orgs-query";
import { SuggestedRepository } from "@gitpod/public-api/lib/gitpod/v1/scm_pb";
import { PlainMessage } from "@bufbuild/protobuf";
import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";

export function useOrgRepoSuggestionsInvalidator() {
const organizationId = useCurrentOrg().data?.id;
const queryClient = useQueryClient();
return useCallback(() => {
queryClient.invalidateQueries(getQueryKey(organizationId));
}, [organizationId, queryClient]);
}

export type SuggestedOrgRepository = PlainMessage<SuggestedRepository> & {
orgSuggested: true;
configuration: Configuration;
};

export function useOrgSuggestedRepos() {
const organizationId = useCurrentOrg().data?.id;
const query = useQuery<SuggestedOrgRepository[], Error>(
getQueryKey(organizationId),
async () => {
const response = await organizationClient.getOrganizationSettings({
organizationId,
});
const repos = response.settings?.onboardingSettings?.recommendedRepositories ?? [];

const suggestions: SuggestedOrgRepository[] = [];
for (const configurationId of repos) {
const { configuration } = await configurationClient.getConfiguration({
configurationId: configurationId,
});
if (!configuration) {
continue;
}
const suggestion: SuggestedOrgRepository = {
configurationId: configurationId,
configurationName: configuration.name ?? "",
repoName: configuration.name ?? "",
url: configuration.cloneUrl ?? "",
orgSuggested: true,
configuration,
};

suggestions.push(suggestion);
}

return suggestions;
},
{
enabled: !!organizationId,
cacheTime: 1000 * 60 * 60 * 24 * 7, // 1 week
staleTime: 1000 * 60 * 5, // 5 minutes
},
);
return query;
}

export function getQueryKey(organizationId: string | undefined) {
return ["org-suggested-repositories", organizationId ?? "undefined"];
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organizat
import { ErrorCode } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { useOrgWorkspaceClassesQueryInvalidator } from "./org-workspace-classes-query";
import { PlainMessage } from "@bufbuild/protobuf";
import { useOrgRepoSuggestionsInvalidator } from "./suggested-repositories-query";

type UpdateOrganizationSettingsArgs = Partial<
Pick<
Expand All @@ -34,7 +35,8 @@ export const useUpdateOrgSettingsMutation = () => {
const org = useCurrentOrg().data;
const invalidateOrgSettings = useOrgSettingsQueryInvalidator();
const invalidateWorkspaceClasses = useOrgWorkspaceClassesQueryInvalidator();
const teamId = org?.id ?? "";
const invalidateOrgRepoSuggestions = useOrgRepoSuggestionsInvalidator();
const organizationId = org?.id ?? "";

return useMutation<OrganizationSettings, Error, UpdateOrganizationSettingsArgs>({
mutationFn: async ({
Expand All @@ -51,7 +53,7 @@ export const useUpdateOrgSettingsMutation = () => {
annotateGitCommits,
}) => {
const settings = await organizationClient.updateOrganizationSettings({
organizationId: teamId,
organizationId,
workspaceSharingDisabled: workspaceSharingDisabled ?? false,
defaultWorkspaceImage,
allowedWorkspaceClasses,
Expand All @@ -72,6 +74,7 @@ export const useUpdateOrgSettingsMutation = () => {
onSuccess: () => {
invalidateOrgSettings();
invalidateWorkspaceClasses();
invalidateOrgRepoSuggestions();
},
onError: (err) => {
if (!ErrorCode.isUserError((err as any)?.["code"])) {
Expand Down
66 changes: 61 additions & 5 deletions components/dashboard/src/repositories/list/RepoListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@ import { TextMuted } from "@podkit/typography/TextMuted";
import { Text } from "@podkit/typography/Text";
import { LinkButton } from "@podkit/buttons/LinkButton";
import type { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react";
import { AlertTriangleIcon, CheckCircle2Icon, SquareArrowOutUpRight, Ellipsis } from "lucide-react";
import { TableCell, TableRow } from "@podkit/tables/Table";
import { Button } from "@podkit/buttons/Button";
import {
DropdownLinkMenuItem,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@podkit/dropdown/DropDown";
import PillLabel from "../../components/PillLabel";

type Props = {
configuration: Configuration;
isSuggested: boolean;
handleModifySuggestedRepository?: (configurationId: string, suggested: boolean) => void;
};
export const RepositoryListItem: FC<Props> = ({ configuration }) => {
export const RepositoryListItem: FC<Props> = ({ configuration, isSuggested, handleModifySuggestedRepository }) => {
const url = usePrettyRepoURL(configuration.cloneUrl);
const prebuildsEnabled = !!configuration.prebuildSettings?.enabled;
const created =
Expand All @@ -27,8 +38,18 @@ export const RepositoryListItem: FC<Props> = ({ configuration }) => {
return (
<TableRow>
<TableCell>
<div className="flex flex-col gap-1 break-words w-52">
<Text className="font-semibold">{configuration.name}</Text>
<div className="flex flex-col gap-1 break-words w-auto md:w-64">
<Text className="font-semibold flex items-center justify-between gap-1">
{configuration.name}
{isSuggested && (
<PillLabel
className="capitalize bg-kumquat-light shrink-0 text-sm hidden xl:block"
type="warn"
>
Suggested
</PillLabel>
)}
</Text>
{/* We show the url on a 2nd line for smaller screens since we hide the column */}
<TextMuted className="inline md:hidden text-sm break-all">{url}</TextMuted>
</div>
Expand All @@ -52,10 +73,45 @@ export const RepositoryListItem: FC<Props> = ({ configuration }) => {
</div>
</TableCell>

<TableCell>
<TableCell className="flex items-center gap-4">
<LinkButton href={`/repositories/${configuration.id}`} variant="secondary">
View
</LinkButton>
{handleModifySuggestedRepository && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost">
<Ellipsis size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52">
{isSuggested ? (
<DropdownMenuItem
onClick={() => handleModifySuggestedRepository(configuration.id, false)}
>
Remove from suggested repos
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
onClick={() => handleModifySuggestedRepository(configuration.id, true)}
>
Add to suggested repos
</DropdownMenuItem>
<DropdownLinkMenuItem
href="https://www.gitpod.io/docs/configure/orgs/onboarding#suggested-repositories"
className="gap-1 text-xs"
target="_blank"
rel="noreferrer"
>
Learn about suggestions
<SquareArrowOutUpRight size={12} />
</DropdownLinkMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
Expand Down
Loading

0 comments on commit 754dc9e

Please sign in to comment.