mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-14 21:35:25 +00:00
skip stripe checkout for trial + fix indexing in progress UI + additional schema validation (#214)
* add additional config validation * wip bypass stripe checkout for trial * fix stripe trial checkout bypass * fix indexing in progress ui on home page * add subscription checks, more schema validation, and fix issue with complete page * dont display if no indexed repos
This commit is contained in:
parent
386a3b52d7
commit
4869137d1e
17 changed files with 357 additions and 126 deletions
|
|
@ -21,10 +21,11 @@ import { getUser } from "@/data/user";
|
||||||
import { Session } from "next-auth";
|
import { Session } from "next-auth";
|
||||||
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL, AUTH_URL } from "@/lib/environment";
|
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL, AUTH_URL } from "@/lib/environment";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { OnboardingSteps } from "./lib/constants";
|
|
||||||
import { render } from "@react-email/components";
|
import { render } from "@react-email/components";
|
||||||
import InviteUserEmail from "./emails/inviteUserEmail";
|
import InviteUserEmail from "./emails/inviteUserEmail";
|
||||||
import { createTransport } from "nodemailer";
|
import { createTransport } from "nodemailer";
|
||||||
|
import { repositoryQuerySchema } from "./lib/schemas";
|
||||||
|
import { RepositoryQuery } from "./lib/types";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
|
|
@ -115,7 +116,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const completeOnboarding = async (stripeCheckoutSessionId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
const org = await prisma.org.findUnique({
|
const org = await prisma.org.findUnique({
|
||||||
|
|
@ -126,25 +127,9 @@ export const completeOnboarding = async (stripeCheckoutSessionId: string, domain
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripe();
|
const subscription = await fetchSubscription(domain);
|
||||||
const stripeSession = await stripe.checkout.sessions.retrieve(stripeCheckoutSessionId);
|
if (isServiceError(subscription)) {
|
||||||
const stripeCustomerId = stripeSession.customer as string;
|
return subscription;
|
||||||
|
|
||||||
// Catch the case where the customer ID doesn't match the org's customer ID
|
|
||||||
if (org.stripeCustomerId !== stripeCustomerId) {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
|
||||||
message: "Invalid Stripe customer ID",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stripeSession.payment_status !== 'paid') {
|
|
||||||
return {
|
|
||||||
statusCode: StatusCodes.BAD_REQUEST,
|
|
||||||
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
|
||||||
message: "Payment failed",
|
|
||||||
} satisfies ServiceError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.org.update({
|
await prisma.org.update({
|
||||||
|
|
@ -317,7 +302,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) =>
|
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}): Promise<RepositoryQuery[] | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
const repos = await prisma.repo.findMany({
|
const repos = await prisma.repo.findMany({
|
||||||
|
|
@ -339,9 +324,11 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return repos.map((repo) => ({
|
return repos.map((repo) => repositoryQuerySchema.parse({
|
||||||
|
codeHostType: repo.external_codeHostType,
|
||||||
repoId: repo.id,
|
repoId: repo.id,
|
||||||
repoName: repo.name,
|
repoName: repo.name,
|
||||||
|
repoCloneUrl: repo.cloneUrl,
|
||||||
linkedConnections: repo.connections.map((connection) => connection.connectionId),
|
linkedConnections: repo.connections.map((connection) => connection.connectionId),
|
||||||
imageUrl: repo.imageUrl ?? undefined,
|
imageUrl: repo.imageUrl ?? undefined,
|
||||||
indexedAt: repo.indexedAt ?? undefined,
|
indexedAt: repo.indexedAt ?? undefined,
|
||||||
|
|
@ -814,7 +801,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createOnboardingStripeCheckoutSession = async (domain: string) =>
|
export const createOnboardingSubscription = async (domain: string) =>
|
||||||
withAuth(async (session) =>
|
withAuth(async (session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
const org = await prisma.org.findUnique({
|
const org = await prisma.org.findUnique({
|
||||||
|
|
@ -833,7 +820,6 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripe();
|
const stripe = getStripe();
|
||||||
const origin = (await headers()).get('origin');
|
|
||||||
|
|
||||||
// @nocheckin
|
// @nocheckin
|
||||||
const test_clock = await stripe.testHelpers.testClocks.create({
|
const test_clock = await stripe.testHelpers.testClocks.create({
|
||||||
|
|
@ -865,45 +851,59 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) =>
|
||||||
return customer.id;
|
return customer.id;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const existingSubscription = await fetchSubscription(domain);
|
||||||
|
if (existingSubscription && !isServiceError(existingSubscription)) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS,
|
||||||
|
message: "Attemped to create a trial subscription for an organization that already has an active subscription",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const prices = await stripe.prices.list({
|
const prices = await stripe.prices.list({
|
||||||
product: STRIPE_PRODUCT_ID,
|
product: STRIPE_PRODUCT_ID,
|
||||||
expand: ['data.product'],
|
expand: ['data.product'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const stripeSession = await stripe.checkout.sessions.create({
|
try {
|
||||||
customer: customerId,
|
const subscription = await stripe.subscriptions.create({
|
||||||
line_items: [
|
customer: customerId,
|
||||||
{
|
items: [{
|
||||||
price: prices.data[0].id,
|
price: prices.data[0].id,
|
||||||
quantity: 1
|
}],
|
||||||
}
|
trial_period_days: 14,
|
||||||
],
|
|
||||||
mode: 'subscription',
|
|
||||||
subscription_data: {
|
|
||||||
trial_period_days: 7,
|
|
||||||
trial_settings: {
|
trial_settings: {
|
||||||
end_behavior: {
|
end_behavior: {
|
||||||
missing_payment_method: 'cancel',
|
missing_payment_method: 'cancel',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
payment_settings: {
|
||||||
payment_method_collection: 'if_required',
|
save_default_payment_method: 'on_subscription',
|
||||||
success_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Complete}&stripe_session_id={CHECKOUT_SESSION_ID}`,
|
},
|
||||||
cancel_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Checkout}`,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (!stripeSession.url) {
|
if (!subscription) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||||
|
message: "Failed to create subscription",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
return {
|
return {
|
||||||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||||
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||||
message: "Failed to create checkout session",
|
message: "Failed to create subscription",
|
||||||
} satisfies ServiceError;
|
} satisfies ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
url: stripeSession.url,
|
|
||||||
}
|
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,22 @@ interface GerritConnectionCreationFormProps {
|
||||||
onCreated?: (id: number) => void;
|
onCreated?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalConfigValidation = (config: GerritConnectionConfig): { message: string, isValid: boolean } => {
|
||||||
|
const hasProjects = config.projects && config.projects.length > 0;
|
||||||
|
|
||||||
|
if (!hasProjects) {
|
||||||
|
return {
|
||||||
|
message: "At least one project must be specified",
|
||||||
|
isValid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Valid",
|
||||||
|
isValid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
|
export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
|
||||||
const defaultConfig: GerritConnectionConfig = {
|
const defaultConfig: GerritConnectionConfig = {
|
||||||
type: 'gerrit',
|
type: 'gerrit',
|
||||||
|
|
@ -24,6 +40,7 @@ export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCrea
|
||||||
}}
|
}}
|
||||||
schema={gerritSchema}
|
schema={gerritSchema}
|
||||||
quickActions={gerritQuickActions}
|
quickActions={gerritQuickActions}
|
||||||
|
additionalConfigValidation={additionalConfigValidation}
|
||||||
onCreated={onCreated}
|
onCreated={onCreated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,24 @@ interface GiteaConnectionCreationFormProps {
|
||||||
onCreated?: (id: number) => void;
|
onCreated?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalConfigValidation = (config: GiteaConnectionConfig): { message: string, isValid: boolean } => {
|
||||||
|
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
|
||||||
|
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
|
||||||
|
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
|
||||||
|
|
||||||
|
if (!hasOrgs && !hasUsers && !hasRepos) {
|
||||||
|
return {
|
||||||
|
message: "At least one organization, user, or repository must be specified",
|
||||||
|
isValid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Valid",
|
||||||
|
isValid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
|
export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
|
||||||
const defaultConfig: GiteaConnectionConfig = {
|
const defaultConfig: GiteaConnectionConfig = {
|
||||||
type: 'gitea',
|
type: 'gitea',
|
||||||
|
|
@ -23,6 +41,7 @@ export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreati
|
||||||
}}
|
}}
|
||||||
schema={giteaSchema}
|
schema={giteaSchema}
|
||||||
quickActions={giteaQuickActions}
|
quickActions={giteaQuickActions}
|
||||||
|
additionalConfigValidation={additionalConfigValidation}
|
||||||
onCreated={onCreated}
|
onCreated={onCreated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,24 @@ interface GitHubConnectionCreationFormProps {
|
||||||
onCreated?: (id: number) => void;
|
onCreated?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalConfigValidation = (config: GithubConnectionConfig): { message: string, isValid: boolean } => {
|
||||||
|
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
|
||||||
|
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
|
||||||
|
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
|
||||||
|
|
||||||
|
if (!hasRepos && !hasOrgs && !hasUsers) {
|
||||||
|
return {
|
||||||
|
message: "At least one repository, organization, or user must be specified",
|
||||||
|
isValid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Valid",
|
||||||
|
isValid: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
|
export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
|
||||||
const defaultConfig: GithubConnectionConfig = {
|
const defaultConfig: GithubConnectionConfig = {
|
||||||
type: 'github',
|
type: 'github',
|
||||||
|
|
@ -22,6 +40,7 @@ export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCrea
|
||||||
config: JSON.stringify(defaultConfig, null, 2),
|
config: JSON.stringify(defaultConfig, null, 2),
|
||||||
}}
|
}}
|
||||||
schema={githubSchema}
|
schema={githubSchema}
|
||||||
|
additionalConfigValidation={additionalConfigValidation}
|
||||||
quickActions={githubQuickActions}
|
quickActions={githubQuickActions}
|
||||||
onCreated={onCreated}
|
onCreated={onCreated}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,24 @@ interface GitLabConnectionCreationFormProps {
|
||||||
onCreated?: (id: number) => void;
|
onCreated?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const additionalConfigValidation = (config: GitlabConnectionConfig): { message: string, isValid: boolean } => {
|
||||||
|
const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
|
||||||
|
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
|
||||||
|
const hasGroups = config.groups && config.groups.length > 0 && config.groups.some(g => g.trim().length > 0);
|
||||||
|
|
||||||
|
if (!hasProjects && !hasUsers && !hasGroups) {
|
||||||
|
return {
|
||||||
|
message: "At least one project, user, or group must be specified",
|
||||||
|
isValid: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Valid",
|
||||||
|
isValid: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
|
export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
|
||||||
const defaultConfig: GitlabConnectionConfig = {
|
const defaultConfig: GitlabConnectionConfig = {
|
||||||
type: 'gitlab',
|
type: 'gitlab',
|
||||||
|
|
@ -23,6 +41,7 @@ export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCrea
|
||||||
}}
|
}}
|
||||||
schema={gitlabSchema}
|
schema={gitlabSchema}
|
||||||
quickActions={gitlabQuickActions}
|
quickActions={gitlabQuickActions}
|
||||||
|
additionalConfigValidation={additionalConfigValidation}
|
||||||
onCreated={onCreated}
|
onCreated={onCreated}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ interface SharedConnectionCreationFormProps<T> {
|
||||||
}[],
|
}[],
|
||||||
className?: string;
|
className?: string;
|
||||||
onCreated?: (id: number) => void;
|
onCreated?: (id: number) => void;
|
||||||
|
additionalConfigValidation?: (config: T) => { message: string, isValid: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
quickActions,
|
quickActions,
|
||||||
className,
|
className,
|
||||||
onCreated,
|
onCreated,
|
||||||
|
additionalConfigValidation
|
||||||
}: SharedConnectionCreationFormProps<T>) {
|
}: SharedConnectionCreationFormProps<T>) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
@ -56,7 +58,7 @@ export default function SharedConnectionCreationForm<T>({
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
return z.object({
|
return z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
config: createZodConnectionConfigValidator(schema),
|
config: createZodConnectionConfigValidator(schema, additionalConfigValidation),
|
||||||
secretKey: z.string().optional().refine(async (secretKey) => {
|
secretKey: z.string().optional().refine(async (secretKey) => {
|
||||||
if (!secretKey) {
|
if (!secretKey) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ import {
|
||||||
CarouselItem,
|
CarouselItem,
|
||||||
} from "@/components/ui/carousel";
|
} from "@/components/ui/carousel";
|
||||||
import Autoscroll from "embla-carousel-auto-scroll";
|
import Autoscroll from "embla-carousel-auto-scroll";
|
||||||
import { getRepoCodeHostInfo } from "@/lib/utils";
|
import { getRepoQueryCodeHostInfo } from "@/lib/utils";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { FileIcon } from "@radix-ui/react-icons";
|
import { FileIcon } from "@radix-ui/react-icons";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Repository } from "@/lib/types";
|
import { RepositoryQuery } from "@/lib/types";
|
||||||
|
|
||||||
interface RepositoryCarouselProps {
|
interface RepositoryCarouselProps {
|
||||||
repos: Repository[];
|
repos: RepositoryQuery[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepositoryCarousel = ({
|
export const RepositoryCarousel = ({
|
||||||
|
|
@ -50,14 +50,14 @@ export const RepositoryCarousel = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RepositoryBadgeProps {
|
interface RepositoryBadgeProps {
|
||||||
repo: Repository;
|
repo: RepositoryQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RepositoryBadge = ({
|
const RepositoryBadge = ({
|
||||||
repo
|
repo
|
||||||
}: RepositoryBadgeProps) => {
|
}: RepositoryBadgeProps) => {
|
||||||
const { repoIcon, displayName, repoLink } = (() => {
|
const { repoIcon, displayName, repoLink } = (() => {
|
||||||
const info = getRepoCodeHostInfo(repo);
|
const info = getRepoQueryCodeHostInfo(repo);
|
||||||
|
|
||||||
if (info) {
|
if (info) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -73,7 +73,7 @@ const RepositoryBadge = ({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
repoIcon: <FileIcon className="w-4 h-4" />,
|
repoIcon: <FileIcon className="w-4 h-4" />,
|
||||||
displayName: repo.Name,
|
displayName: repo.repoName.split('/').slice(-2).join('/'),
|
||||||
repoLink: undefined,
|
repoLink: undefined,
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
122
packages/web/src/app/[domain]/components/repositorySnapshot.tsx
Normal file
122
packages/web/src/app/[domain]/components/repositorySnapshot.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { RepositoryCarousel } from "./repositoryCarousel";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { unwrapServiceError } from "@/lib/utils";
|
||||||
|
import { getRepos } from "@/actions";
|
||||||
|
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
} from "@/components/ui/carousel";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||||
|
import { SymbolIcon } from "@radix-ui/react-icons";
|
||||||
|
|
||||||
|
export function EmptyRepoState({ domain }: { domain: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">No repositories found</span>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg">
|
||||||
|
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Create a{" "}
|
||||||
|
<Link href={`/${domain}/connections`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
|
||||||
|
connection
|
||||||
|
</Link>{" "}
|
||||||
|
to start indexing repositories
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RepoSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
{/* Skeleton for "Search X repositories" text */}
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Skeleton className="h-4 w-14" /> {/* "Search X" */}
|
||||||
|
<Skeleton className="h-4 w-24" /> {/* "repositories" */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skeleton for repository carousel */}
|
||||||
|
<Carousel
|
||||||
|
opts={{
|
||||||
|
align: "start",
|
||||||
|
loop: true,
|
||||||
|
}}
|
||||||
|
className="w-full max-w-lg"
|
||||||
|
>
|
||||||
|
<CarouselContent>
|
||||||
|
{[1, 2, 3].map((_, index) => (
|
||||||
|
<CarouselItem key={index} className="basis-auto">
|
||||||
|
<div className="flex flex-row items-center gap-2 border rounded-md p-2">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-sm" /> {/* Icon */}
|
||||||
|
<Skeleton className="h-4 w-32" /> {/* Repository name */}
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function RepositorySnapshot() {
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
|
const { data: repos, isPending, isError } = useQuery({
|
||||||
|
queryKey: ['repos', domain],
|
||||||
|
queryFn: () => unwrapServiceError(getRepos(domain)),
|
||||||
|
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending || isError || !repos) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<RepoSkeleton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED);
|
||||||
|
if (repos.length === 0 || indexedRepos.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyRepoState domain={domain} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numIndexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED).length;
|
||||||
|
const numIndexingRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE).length;
|
||||||
|
if (numIndexedRepos === 0 && numIndexingRepos > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row items-center gap-3">
|
||||||
|
<SymbolIcon className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm">indexing in progress...</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{`Search ${indexedRepos.length} `}
|
||||||
|
<Link
|
||||||
|
href={`${domain}/repos`}
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
{repos.length > 1 ? 'repositories' : 'repository'}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
<RepositoryCarousel repos={indexedRepos} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Ajv, { Schema } from "ajv";
|
import Ajv, { Schema } from "ajv";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const createZodConnectionConfigValidator = (jsonSchema: Schema) => {
|
export const createZodConnectionConfigValidator = <T>(jsonSchema: Schema, additionalConfigValidation?: (config: T) => { message: string, isValid: boolean }) => {
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
});
|
});
|
||||||
|
|
@ -29,5 +29,12 @@ export const createZodConnectionConfigValidator = (jsonSchema: Schema) => {
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
addIssue(ajv.errorsText(validate.errors));
|
addIssue(ajv.errorsText(validate.errors));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (additionalConfigValidation) {
|
||||||
|
const result = additionalConfigValidation(parsed as T);
|
||||||
|
if (!result.isValid) {
|
||||||
|
addIssue(result.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createOnboardingStripeCheckoutSession } from "@/actions";
|
import { createOnboardingSubscription } from "@/actions";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -11,7 +11,7 @@ import { isServiceError } from "@/lib/utils";
|
||||||
import { Check, Loader2 } from "lucide-react";
|
import { Check, Loader2 } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { TEAM_FEATURES } from "@/lib/constants";
|
import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
|
||||||
export const Checkout = () => {
|
export const Checkout = () => {
|
||||||
|
|
@ -37,7 +37,7 @@ export const Checkout = () => {
|
||||||
|
|
||||||
const onCheckout = useCallback(() => {
|
const onCheckout = useCallback(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
createOnboardingStripeCheckoutSession(domain)
|
createOnboardingSubscription(domain)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (isServiceError(response)) {
|
if (isServiceError(response)) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -48,8 +48,8 @@ export const Checkout = () => {
|
||||||
error: response.errorCode,
|
error: response.errorCode,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
router.push(response.url);
|
|
||||||
captureEvent('wa_onboard_checkout_success', {});
|
captureEvent('wa_onboard_checkout_success', {});
|
||||||
|
router.push(`/${domain}/onboard?step=${OnboardingSteps.Complete}`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
@ -63,7 +63,7 @@ export const Checkout = () => {
|
||||||
className="h-16"
|
className="h-16"
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-2xl font-semibold">Start your 7 day free trial</h1>
|
<h1 className="text-2xl font-semibold">Start your 14 day free trial</h1>
|
||||||
<p className="text-muted-foreground mt-2">Cancel anytime. No credit card required.</p>
|
<p className="text-muted-foreground mt-2">Cancel anytime. No credit card required.</p>
|
||||||
<ul className="space-y-4 mb-6 mt-10">
|
<ul className="space-y-4 mb-6 mt-10">
|
||||||
{TEAM_FEATURES.map((feature, index) => (
|
{TEAM_FEATURES.map((feature, index) => (
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import { completeOnboarding } from "@/actions";
|
import { completeOnboarding } from "@/actions";
|
||||||
import { OnboardingSteps } from "@/lib/constants";
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { redirect } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface CompleteOnboardingProps {
|
export const CompleteOnboarding = () => {
|
||||||
searchParams: {
|
const router = useRouter();
|
||||||
stripe_session_id?: string;
|
const domain = useDomain();
|
||||||
}
|
|
||||||
params: {
|
useEffect(() => {
|
||||||
domain: string;
|
const complete = async () => {
|
||||||
}
|
const response = await completeOnboarding(domain);
|
||||||
}
|
if (isServiceError(response)) {
|
||||||
|
router.push(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`);
|
||||||
export const CompleteOnboarding = async ({ searchParams, params: { domain } }: CompleteOnboardingProps) => {
|
return;
|
||||||
if (!searchParams.stripe_session_id) {
|
}
|
||||||
return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}`);
|
|
||||||
}
|
router.push(`/${domain}`);
|
||||||
const { stripe_session_id } = searchParams;
|
router.refresh();
|
||||||
|
};
|
||||||
const response = await completeOnboarding(stripe_session_id, domain);
|
|
||||||
if (isServiceError(response)) {
|
complete();
|
||||||
return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`);
|
}, [domain, router]);
|
||||||
}
|
|
||||||
|
return null;
|
||||||
return redirect(`/${domain}`);
|
|
||||||
}
|
}
|
||||||
|
|
@ -7,7 +7,6 @@ import { InviteTeam } from "./components/inviteTeam";
|
||||||
import { CompleteOnboarding } from "./components/completeOnboarding";
|
import { CompleteOnboarding } from "./components/completeOnboarding";
|
||||||
import { Checkout } from "./components/checkout";
|
import { Checkout } from "./components/checkout";
|
||||||
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
import { SkipOnboardingButton } from "./components/skipOnboardingButton";
|
|
||||||
interface OnboardProps {
|
interface OnboardProps {
|
||||||
params: {
|
params: {
|
||||||
domain: string
|
domain: string
|
||||||
|
|
@ -54,10 +53,6 @@ export default async function Onboard({ params, searchParams }: OnboardProps) {
|
||||||
<ConnectCodeHost
|
<ConnectCodeHost
|
||||||
nextStep={OnboardingSteps.InviteTeam}
|
nextStep={OnboardingSteps.InviteTeam}
|
||||||
/>
|
/>
|
||||||
<SkipOnboardingButton
|
|
||||||
currentStep={step as OnboardingSteps}
|
|
||||||
lastRequiredStep={lastRequiredStep}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{step === OnboardingSteps.InviteTeam && (
|
{step === OnboardingSteps.InviteTeam && (
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { getOrgFromDomain } from "@/data/org";
|
||||||
import { PageNotFound } from "./components/pageNotFound";
|
import { PageNotFound } from "./components/pageNotFound";
|
||||||
import { Footer } from "./components/footer";
|
import { Footer } from "./components/footer";
|
||||||
import { SourcebotLogo } from "../components/sourcebotLogo";
|
import { SourcebotLogo } from "../components/sourcebotLogo";
|
||||||
|
import { RepositorySnapshot } from "./components/repositorySnapshot";
|
||||||
|
|
||||||
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
|
export default async function Home({ params: { domain } }: { params: { domain: string } }) {
|
||||||
const org = await getOrgFromDomain(domain);
|
const org = await getOrgFromDomain(domain);
|
||||||
|
|
@ -38,10 +38,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s
|
||||||
/>
|
/>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<Suspense fallback={<div>...</div>}>
|
<Suspense fallback={<div>...</div>}>
|
||||||
<RepositoryList
|
<RepositorySnapshot />
|
||||||
orgId={org.id}
|
|
||||||
domain={domain}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center w-fit gap-6">
|
<div className="flex flex-col items-center w-fit gap-6">
|
||||||
|
|
@ -104,40 +101,6 @@ export default async function Home({ params: { domain } }: { params: { domain: s
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RepositoryList = async ({ orgId, domain }: { orgId: number, domain: string }) => {
|
|
||||||
const _repos = await listRepositories(orgId);
|
|
||||||
|
|
||||||
if (isServiceError(_repos)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const repos = _repos.List.Repos.map((repo) => repo.Repository);
|
|
||||||
|
|
||||||
if (repos.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-row items-center gap-3">
|
|
||||||
<SymbolIcon className="h-4 w-4 animate-spin" />
|
|
||||||
<span className="text-sm">indexing in progress...</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<span className="text-sm">
|
|
||||||
{`Search ${repos.length} `}
|
|
||||||
<Link
|
|
||||||
href={`${domain}/repos`}
|
|
||||||
className="text-blue-500"
|
|
||||||
>
|
|
||||||
{repos.length > 1 ? 'repositories' : 'repository'}
|
|
||||||
</Link>
|
|
||||||
</span>
|
|
||||||
<RepositoryCarousel repos={repos} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,5 @@ export enum ErrorCode {
|
||||||
INVALID_INVITE = 'INVALID_INVITE',
|
INVALID_INVITE = 'INVALID_INVITE',
|
||||||
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
||||||
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
|
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
|
||||||
|
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { RepoIndexingStatus } from "@sourcebot/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const searchRequestSchema = z.object({
|
export const searchRequestSchema = z.object({
|
||||||
query: z.string(),
|
query: z.string(),
|
||||||
maxMatchDisplayCount: z.number(),
|
maxMatchDisplayCount: z.number(),
|
||||||
|
|
@ -162,6 +162,16 @@ export const listRepositoriesResponseSchema = z.object({
|
||||||
Stats: repoStatsSchema,
|
Stats: repoStatsSchema,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
export const repositoryQuerySchema = z.object({
|
||||||
|
codeHostType: z.string(),
|
||||||
|
repoId: z.number(),
|
||||||
|
repoName: z.string(),
|
||||||
|
repoCloneUrl: z.string(),
|
||||||
|
linkedConnections: z.array(z.number()),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
indexedAt: z.date().optional(),
|
||||||
|
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
|
||||||
|
});
|
||||||
|
|
||||||
export const verifyCredentialsRequestSchema = z.object({
|
export const verifyCredentialsRequestSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, searchRequestSchema, searchResponseSchema, symbolSchema } from "./schemas";
|
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, repositoryQuerySchema, searchRequestSchema, searchResponseSchema, symbolSchema } from "./schemas";
|
||||||
|
|
||||||
export type KeymapType = "default" | "vim";
|
export type KeymapType = "default" | "vim";
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
|
||||||
|
|
||||||
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
|
||||||
export type Repository = z.infer<typeof repositorySchema>;
|
export type Repository = z.infer<typeof repositorySchema>;
|
||||||
|
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
|
||||||
export type Symbol = z.infer<typeof symbolSchema>;
|
export type Symbol = z.infer<typeof symbolSchema>;
|
||||||
|
|
||||||
export enum SearchQueryParams {
|
export enum SearchQueryParams {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import gitlabLogo from "@/public/gitlab.svg";
|
||||||
import giteaLogo from "@/public/gitea.svg";
|
import giteaLogo from "@/public/gitea.svg";
|
||||||
import gerritLogo from "@/public/gerrit.svg";
|
import gerritLogo from "@/public/gerrit.svg";
|
||||||
import { ServiceError } from "./serviceError";
|
import { ServiceError } from "./serviceError";
|
||||||
import { Repository } from "./types";
|
import { Repository, RepositoryQuery } from "./types";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|
@ -102,6 +102,60 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getRepoQueryCodeHostInfo = (repo?: RepositoryQuery): CodeHostInfo | undefined => {
|
||||||
|
if (!repo) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = repo.repoName.split('/').slice(-2).join('/');
|
||||||
|
switch (repo.codeHostType) {
|
||||||
|
case 'github': {
|
||||||
|
const { src, className } = getCodeHostIcon('github')!;
|
||||||
|
return {
|
||||||
|
type: "github",
|
||||||
|
displayName: displayName,
|
||||||
|
costHostName: "GitHub",
|
||||||
|
repoLink: repo.repoCloneUrl,
|
||||||
|
icon: src,
|
||||||
|
iconClassName: className,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'gitlab': {
|
||||||
|
const { src, className } = getCodeHostIcon('gitlab')!;
|
||||||
|
return {
|
||||||
|
type: "gitlab",
|
||||||
|
displayName: displayName,
|
||||||
|
costHostName: "GitLab",
|
||||||
|
repoLink: repo.repoCloneUrl,
|
||||||
|
icon: src,
|
||||||
|
iconClassName: className,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'gitea': {
|
||||||
|
const { src, className } = getCodeHostIcon('gitea')!;
|
||||||
|
return {
|
||||||
|
type: "gitea",
|
||||||
|
displayName: displayName,
|
||||||
|
costHostName: "Gitea",
|
||||||
|
repoLink: repo.repoCloneUrl,
|
||||||
|
icon: src,
|
||||||
|
iconClassName: className,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'gitiles': {
|
||||||
|
const { src, className } = getCodeHostIcon('gerrit')!;
|
||||||
|
return {
|
||||||
|
type: "gerrit",
|
||||||
|
displayName: displayName,
|
||||||
|
costHostName: "Gerrit",
|
||||||
|
repoLink: repo.repoCloneUrl,
|
||||||
|
icon: src,
|
||||||
|
iconClassName: className,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } | null => {
|
export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } | null => {
|
||||||
switch (codeHostType) {
|
switch (codeHostType) {
|
||||||
case "github":
|
case "github":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue