Skip to content

Organization-recommended repositories #20559

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading