Skip to content

Commit b36de34

Browse files
experiment: Self-serve repository indexing for public GitHub repositories (#468)
1 parent c304e34 commit b36de34

File tree

10 files changed

+350
-97
lines changed

10 files changed

+350
-97
lines changed

packages/web/src/actions.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { env } from "@/env.mjs";
44
import { ErrorCode } from "@/lib/errorCodes";
55
import { notAuthenticated, notFound, orgNotFound, secretAlreadyExists, ServiceError, ServiceErrorException, unexpectedError } from "@/lib/serviceError";
6-
import { CodeHostType, isServiceError } from "@/lib/utils";
6+
import { CodeHostType, isHttpError, isServiceError } from "@/lib/utils";
77
import { prisma } from "@/prisma";
88
import { render } from "@react-email/components";
99
import * as Sentry from '@sentry/nextjs';
@@ -22,6 +22,7 @@ import { StatusCodes } from "http-status-codes";
2222
import { cookies, headers } from "next/headers";
2323
import { createTransport } from "nodemailer";
2424
import { auth } from "./auth";
25+
import { Octokit } from "octokit";
2526
import { getConnection } from "./data/connection";
2627
import { IS_BILLING_ENABLED } from "./ee/features/billing/stripe";
2728
import InviteUserEmail from "./emails/inviteUserEmail";
@@ -790,6 +791,144 @@ export const createConnection = async (name: string, type: CodeHostType, connect
790791
}, OrgRole.OWNER)
791792
));
792793

794+
export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: string, domain: string): Promise<{ connectionId: number } | ServiceError> => sew(() =>
795+
withAuth((userId) =>
796+
withOrgMembership(userId, domain, async ({ org }) => {
797+
if (env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED !== 'true') {
798+
return {
799+
statusCode: StatusCodes.BAD_REQUEST,
800+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
801+
message: "This feature is not enabled.",
802+
} satisfies ServiceError;
803+
}
804+
805+
// Parse repository URL to extract owner/repo
806+
const repoInfo = (() => {
807+
const url = repositoryUrl.trim();
808+
809+
// Handle various GitHub URL formats
810+
const patterns = [
811+
// https://github.com/owner/repo or https://github.com/owner/repo.git
812+
/^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
813+
// github.com/owner/repo
814+
/^github\.com\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+?)(?:\.git)?\/?$/,
815+
// owner/repo
816+
/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/
817+
];
818+
819+
for (const pattern of patterns) {
820+
const match = url.match(pattern);
821+
if (match) {
822+
return {
823+
owner: match[1],
824+
repo: match[2]
825+
};
826+
}
827+
}
828+
829+
return null;
830+
})();
831+
832+
if (!repoInfo) {
833+
return {
834+
statusCode: StatusCodes.BAD_REQUEST,
835+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
836+
message: "Invalid repository URL format. Please use 'owner/repo' or 'https://github.com/owner/repo' format.",
837+
} satisfies ServiceError;
838+
}
839+
840+
const { owner, repo } = repoInfo;
841+
842+
// Use GitHub API to fetch repository information and get the external_id
843+
const octokit = new Octokit({
844+
auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
845+
});
846+
847+
let githubRepo;
848+
try {
849+
const response = await octokit.rest.repos.get({
850+
owner,
851+
repo,
852+
});
853+
githubRepo = response.data;
854+
} catch (error) {
855+
if (isHttpError(error, 404)) {
856+
return {
857+
statusCode: StatusCodes.NOT_FOUND,
858+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
859+
message: `Repository '${owner}/${repo}' not found or is private. Only public repositories can be added.`,
860+
} satisfies ServiceError;
861+
}
862+
863+
if (isHttpError(error, 403)) {
864+
return {
865+
statusCode: StatusCodes.FORBIDDEN,
866+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
867+
message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
868+
} satisfies ServiceError;
869+
}
870+
871+
return {
872+
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
873+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
874+
message: `Failed to fetch repository information: ${error instanceof Error ? error.message : 'Unknown error'}`,
875+
} satisfies ServiceError;
876+
}
877+
878+
if (githubRepo.private) {
879+
return {
880+
statusCode: StatusCodes.BAD_REQUEST,
881+
errorCode: ErrorCode.INVALID_REQUEST_BODY,
882+
message: "Only public repositories can be added.",
883+
} satisfies ServiceError;
884+
}
885+
886+
// Check if this repository is already connected using the external_id
887+
const existingRepo = await prisma.repo.findFirst({
888+
where: {
889+
orgId: org.id,
890+
external_id: githubRepo.id.toString(),
891+
external_codeHostType: 'github',
892+
external_codeHostUrl: 'https://github.com',
893+
}
894+
});
895+
896+
if (existingRepo) {
897+
return {
898+
statusCode: StatusCodes.BAD_REQUEST,
899+
errorCode: ErrorCode.CONNECTION_ALREADY_EXISTS,
900+
message: "This repository already exists.",
901+
} satisfies ServiceError;
902+
}
903+
904+
const connectionName = `${owner}-${repo}-${Date.now()}`;
905+
906+
// Create GitHub connection config
907+
const connectionConfig: GithubConnectionConfig = {
908+
type: "github" as const,
909+
repos: [`${owner}/${repo}`],
910+
...(env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN ? {
911+
token: {
912+
env: 'EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN'
913+
}
914+
} : {})
915+
};
916+
917+
const connection = await prisma.connection.create({
918+
data: {
919+
orgId: org.id,
920+
name: connectionName,
921+
config: connectionConfig as unknown as Prisma.InputJsonValue,
922+
connectionType: 'github',
923+
}
924+
});
925+
926+
return {
927+
connectionId: connection.id,
928+
}
929+
}, OrgRole.GUEST), /* allowAnonymousAccess = */ true
930+
));
931+
793932
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> => sew(() =>
794933
withAuth((userId) =>
795934
withOrgMembership(userId, domain, async ({ org }) => {

packages/web/src/app/[domain]/repos/addRepoButton.tsx

Lines changed: 0 additions & 64 deletions
This file was deleted.

packages/web/src/app/[domain]/repos/columns.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
99
import { cn, getRepoImageSrc } from "@/lib/utils"
1010
import { RepoIndexingStatus } from "@sourcebot/db";
1111
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
12-
import { AddRepoButton } from "./addRepoButton"
1312

1413
export type RepositoryColumnInfo = {
1514
repoId: number
@@ -97,12 +96,7 @@ const StatusIndicator = ({ status }: { status: RepoIndexingStatus }) => {
9796
export const columns = (domain: string): ColumnDef<RepositoryColumnInfo>[] => [
9897
{
9998
accessorKey: "name",
100-
header: () => (
101-
<div className="flex items-center w-[400px]">
102-
<span>Repository</span>
103-
<AddRepoButton />
104-
</div>
105-
),
99+
header: 'Repository',
106100
cell: ({ row }) => {
107101
const repo = row.original
108102
const url = repo.url
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
'use client';
2+
3+
import { Button } from "@/components/ui/button";
4+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
5+
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
6+
import { Input } from "@/components/ui/input";
7+
import { zodResolver } from "@hookform/resolvers/zod";
8+
import { useForm } from "react-hook-form";
9+
import { z } from "zod";
10+
import { experimental_addGithubRepositoryByUrl } from "@/actions";
11+
import { useDomain } from "@/hooks/useDomain";
12+
import { isServiceError } from "@/lib/utils";
13+
import { useToast } from "@/components/hooks/use-toast";
14+
import { useRouter } from "next/navigation";
15+
16+
interface AddRepositoryDialogProps {
17+
isOpen: boolean;
18+
onOpenChange: (open: boolean) => void;
19+
}
20+
21+
// Validation schema for repository URLs
22+
const formSchema = z.object({
23+
repositoryUrl: z.string()
24+
.min(1, "Repository URL is required")
25+
.refine((url) => {
26+
// Allow various GitHub URL formats:
27+
// - https://github.com/owner/repo
28+
// - github.com/owner/repo
29+
// - owner/repo
30+
const patterns = [
31+
/^https?:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+\/?$/,
32+
/^github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+\/?$/,
33+
/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/
34+
];
35+
return patterns.some(pattern => pattern.test(url.trim()));
36+
}, "Please enter a valid GitHub repository URL (e.g., owner/repo or https://github.com/owner/repo)"),
37+
});
38+
39+
export const AddRepositoryDialog = ({ isOpen, onOpenChange }: AddRepositoryDialogProps) => {
40+
const domain = useDomain();
41+
const { toast } = useToast();
42+
const router = useRouter();
43+
44+
const form = useForm<z.infer<typeof formSchema>>({
45+
resolver: zodResolver(formSchema),
46+
defaultValues: {
47+
repositoryUrl: "",
48+
},
49+
});
50+
51+
const { isSubmitting } = form.formState;
52+
53+
const onSubmit = async (data: z.infer<typeof formSchema>) => {
54+
55+
const result = await experimental_addGithubRepositoryByUrl(data.repositoryUrl.trim(), domain);
56+
if (isServiceError(result)) {
57+
toast({
58+
title: "Error adding repository",
59+
description: result.message,
60+
variant: "destructive",
61+
});
62+
} else {
63+
toast({
64+
title: "Repository added successfully!",
65+
description: "It will be indexed shortly.",
66+
});
67+
form.reset();
68+
onOpenChange(false);
69+
router.refresh();
70+
}
71+
};
72+
73+
const handleCancel = () => {
74+
form.reset();
75+
onOpenChange(false);
76+
};
77+
78+
return (
79+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
80+
<DialogContent className="sm:max-w-md">
81+
<DialogHeader>
82+
<DialogTitle>Add a public repository from GitHub</DialogTitle>
83+
<DialogDescription>
84+
Paste the repo URL - the code will be indexed and available in search.
85+
</DialogDescription>
86+
</DialogHeader>
87+
88+
<Form {...form}>
89+
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
90+
<FormField
91+
control={form.control}
92+
name="repositoryUrl"
93+
render={({ field }) => (
94+
<FormItem>
95+
<FormLabel>Repository URL</FormLabel>
96+
<FormControl>
97+
<Input
98+
{...field}
99+
placeholder="https://github.com/user/project"
100+
disabled={isSubmitting}
101+
/>
102+
</FormControl>
103+
<FormMessage />
104+
</FormItem>
105+
)}
106+
/>
107+
</form>
108+
</Form>
109+
110+
<DialogFooter>
111+
<Button
112+
variant="outline"
113+
onClick={handleCancel}
114+
disabled={isSubmitting}
115+
>
116+
Cancel
117+
</Button>
118+
<Button
119+
onClick={form.handleSubmit(onSubmit)}
120+
disabled={isSubmitting}
121+
>
122+
{isSubmitting ? "Adding..." : "Add Repository"}
123+
</Button>
124+
</DialogFooter>
125+
</DialogContent>
126+
</Dialog>
127+
);
128+
};

packages/web/src/app/[domain]/repos/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { RepositoryTable } from "./repositoryTable";
22
import { getOrgFromDomain } from "@/data/org";
33
import { PageNotFound } from "../components/pageNotFound";
44
import { Header } from "../components/header";
5+
import { env } from "@/env.mjs";
56

67
export default async function ReposPage({ params: { domain } }: { params: { domain: string } }) {
78
const org = await getOrgFromDomain(domain);
@@ -16,7 +17,9 @@ export default async function ReposPage({ params: { domain } }: { params: { doma
1617
</Header>
1718
<div className="flex flex-col items-center">
1819
<div className="w-full">
19-
<RepositoryTable />
20+
<RepositoryTable
21+
isAddReposButtonVisible={env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED === 'true'}
22+
/>
2023
</div>
2124
</div>
2225
</div>

0 commit comments

Comments
 (0)