import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ExternalLink, Loader2 } from "lucide-react"; import type { GitHubAppWizardState } from "@paperclipai/shared"; import { agentsApi } from "@/api/agents"; import { chatEndpointsApi } from "@/api/chatEndpoints"; import { githubChatApi } from "@/api/githubChat"; import { projectsApi } from "@/api/projects"; import { toolsApi } from "@/api/tools"; import { AgentSelect } from "@/components/AgentMultiSelect"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; import { SetupWizardNavigation, SetupWizardFooter, } from "@/components/SetupWizard"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useNavigate, useSearchParams, Link } from "@/lib/router"; import { buildPermissionsForTrustPreset } from "@/lib/trust-policy-ui"; import { queryKeys } from "@/lib/queryKeys"; import { GitHubAppRecovery } from "./GitHubConnectionRecovery"; import type { GitHubConnectionCompleteLocationState } from "./GitHubConnectionComplete"; /** Restrict native manifest submission to GitHub registration endpoints. */ export function gitHubAppManifestAction( registration: NonNullable, ) { const url = new URL(registration.registrationUrl); if ( url.origin !== "https://github.com" || url.username || url.password || url.hash || !/^\/(settings|organizations\/[A-Za-z0-9-]+\/settings)\/apps\/new$/.test( url.pathname, ) ) throw new Error("GitHub returned an invalid registration address"); return url.toString(); } function GitHubAppManifestForm({ registration, onSaveExit, disabled, autoSubmit, onSubmit, }: { registration: NonNullable; onSaveExit: () => void; disabled: boolean; autoSubmit: boolean; onSubmit: () => void; }) { const form = useRef(null); const requested = useRef(false); useEffect(() => { if (!autoSubmit) requested.current = false; if (autoSubmit && !disabled && !requested.current && form.current) { requested.current = true; form.current.requestSubmit(); } }, [autoSubmit, disabled]); return (
); } export function GitHubChatSetup() { const [params, setParams] = useSearchParams(); const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const resume = params.get("resume"); const identityOnly = params.get("stage") === "identity"; const reconnect = params.get("reconnect") === "1"; const [agentId, setAgentId] = useState(params.get("agentId") ?? ""); const [ownerType, setOwnerType] = useState<"personal" | "organization">( "personal", ); const [ownerLogin, setOwnerLogin] = useState(""); const [customOrganization, setCustomOrganization] = useState(false); const [name, setName] = useState(""); const [busy, setBusy] = useState(false); const [submitManifest, setSubmitManifest] = useState(false); const [error, setError] = useState(""); const [existing, setExisting] = useState(params.get("reconnect") === "1"); const [appNotCreated, setAppNotCreated] = useState(false); const [legacyAccount, setLegacyAccount] = useState(""); const [identityLinked, setIdentityLinked] = useState(false); const [legacyIdentity, setLegacyIdentity] = useState > | null>(null); const agents = useQuery({ queryKey: ["github-setup-agents", selectedCompanyId], queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && !identityOnly, }); const current = useQuery({ queryKey: ["github-setup", resume], queryFn: () => chatEndpointsApi.get(resume!), enabled: !!resume, refetchInterval: 3000, }); const bot = current.data; const progress = useQuery({ queryKey: ["github-wizard", resume], queryFn: () => githubChatApi.advance(resume!), enabled: !!bot && !existing && !identityOnly, refetchInterval: (query) => ["connected", "create", "identity", "enrollment", "recovery"].includes( query.state.data?.state ?? "", ) ? false : 5000, retry: false, }); const accounts = useQuery({ queryKey: ["github-personal-connections", resume], queryFn: () => githubChatApi.personalConnections(resume!), enabled: !!bot && (identityOnly || progress.data?.state === "identity"), }); const selectedAgent = agents.data?.find( (agent) => agent.id === (bot?.assignedAgentId ?? agentId), ); const state = identityOnly ? undefined : progress.data; const choosingAccount = !!bot && !identityOnly && !existing && state?.state === "create"; const repositories = useQuery({ queryKey: ["project-repositories", selectedCompanyId], queryFn: () => projectsApi.repositoryOptions(selectedCompanyId!), enabled: !!selectedCompanyId && choosingAccount, staleTime: 30_000, }); const connectedBots = useQuery({ queryKey: queryKeys.chatEndpoints.list(selectedCompanyId!), queryFn: () => chatEndpointsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId && choosingAccount, staleTime: 30_000, }); const organizations = new Map(); for (const repository of repositories.data?.repositories ?? []) { if (repository.ownerType !== "organization") continue; const login = repository.fullName.split("/")[0]; organizations.set(login.toLowerCase(), login); } for (const endpoint of connectedBots.data ?? []) { const github = endpoint.setup?.github; if (endpoint.provider === "github" && ["active", "paused"].includes(endpoint.status) && github?.ownerType === "organization" && github.ownerLogin) { organizations.set(github.ownerLogin.toLowerCase(), github.ownerLogin); } } const knownOrganizations = [...organizations.values()].sort((a, b) => a.localeCompare(b)); const knownOwner = organizations.get(ownerLogin.toLowerCase()); const accountChoice = ownerType === "personal" ? "personal" : !customOrganization && knownOwner ? `organization:${knownOwner}` : "organization"; const deliveryVerifiedWhileChecking = state?.state === "verify" && !!bot?.setup?.webhookVerifiedAt && state.verification?.checks.some( (check) => !check.ok && ["webhook", "delivery"].includes(check.key), ); const existingIdentityMethod = identityOnly || state?.identityMethod === "existing_connection"; const connected = state?.state === "connected" && !existing; useEffect(() => { if (!connected || !bot) return; queryClient.setQueryData(queryKeys.chatEndpoints.detail(bot.id), bot); if (selectedAgent) queryClient.setQueryData(queryKeys.agents.detail(selectedAgent.id), selectedAgent); navigate(`/apps/chat/${bot.id}/settings`, { replace: true, state: { githubConnectionComplete: { endpointId: bot.id, runtimeChecks: state.runtimeChecks }, } satisfies GitHubConnectionCompleteLocationState, }); }, [connected, bot, selectedAgent, state, queryClient, navigate]); useEffect(() => { setBreadcrumbs([ { label: "Connectors", href: "/apps" }, { label: "GitHub Code Review Bot" }, ]); return () => setBreadcrumbs([]); }, [setBreadcrumbs]); useEffect(() => { if (bot) { setAgentId(bot.assignedAgentId); if (bot.setup?.github?.ownerType) setOwnerType(bot.setup.github.ownerType); setOwnerLogin(bot.setup?.github?.ownerLogin ?? ""); setCustomOrganization(false); if (bot.setup?.github?.appName) setName(bot.setup.github.appName); } }, [ bot?.id, bot?.setup?.github?.ownerType, bot?.setup?.github?.ownerLogin, bot?.setup?.github?.appName, ]); async function run(action: () => Promise) { setBusy(true); setError(""); try { await action(); } catch (error) { setError( error instanceof Error ? error.message : "Could not connect GitHub. Try again.", ); } finally { setBusy(false); } } async function refresh() { const verification = !identityOnly ? await progress.refetch() : undefined; const updated = await current.refetch(); if (verification?.error) throw verification.error; if (updated.error) throw updated.error; } const appInput = () => ({ name: ( name || selectedAgent?.name || bot?.assignedAgentName || "GitHub Reviewer" ).slice(0, 34), ownerType, ...(ownerType === "organization" ? { ownerLogin: ownerLogin.trim() } : {}), }); const exit = () => void run(async () => { if ( !identityOnly && !reconnect && bot && !bot.botExternalId && !state?.registration && state?.state !== "recovery" ) { await githubChatApi.saveDraft(bot.id, appInput()); await current.refetch(); } navigate("/apps"); }); const footer = ( label?: string, action?: () => Promise, disabled = false, ) => ( {label && action && ( )} ); if (resume && !bot) return (

Resume GitHub setup

{current.error ? (

Could not load this connection. Try again to resume your draft.

) : (

Loading your draft…

)} {footer(current.error ? "Try again" : undefined, async () => { await current.refetch(); })}
); if (connected) return null; return (
{!identityOnly && ( {}} disabled takeover /> )}

{!bot ? "Choose agent" : identityOnly || state?.state === "identity" ? "Connect your account" : reconnect && existing ? "Reconnect GitHub" : "Connect GitHub"}

{(error || (!identityOnly && !existing && progress.error) || accounts.error) && (

{error || (accounts.error instanceof Error ? accounts.error.message : undefined) || (progress.error instanceof Error ? progress.error.message : "Could not check GitHub setup. Try again.")}

)} {!bot ? ( <> {selectedAgent && ( void run(async () => { const permissions = buildPermissionsForTrustPreset( selectedAgent.permissions, "low_trust_review", ); await agentsApi.updatePermissions( selectedAgent.id, { ...permissions, canCreateAgents: false, canCreateSkills: false, canAssignTasks: typeof selectedAgent.permissions.canAssignTasks === "boolean" ? selectedAgent.permissions.canAssignTasks : true, }, selectedCompanyId!, ); await queryClient.invalidateQueries({ queryKey: ["github-setup-agents", selectedCompanyId], }); }) } /> )} {footer( "Continue", async () => { const created = await chatEndpointsApi.create( selectedCompanyId!, { provider: "github", assignedAgentId: agentId }, ); setName((selectedAgent?.name ?? "GitHub Reviewer").slice(0, 34)); const next = new URLSearchParams(params); next.set("resume", created.id); setParams(next, { replace: true }); queryClient.setQueryData(["github-setup", created.id], created); }, !agentId || !selectedCompanyId, )} ) : !identityOnly && !existing && progress.isPending ? ( <>

Checking your GitHub connection…

{footer()} ) : existing ? ( { if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) { await chatEndpointsApi.setup(bot.id, { action: "reconnect", ...(credentials ? { credentials: { appId: credentials.appId.trim(), privateKey: credentials.privateKey } } : {}), }); } else if (credentials) { await githubChatApi.connectApp(bot.id, credentials); } queryClient.removeQueries({ queryKey: ["github-wizard", bot.id] }); await queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.detail(bot.id) }); await queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.list(bot.companyId) }); await refresh(); setExisting(false); }} /> ) : state?.state === "identity" || identityOnly ? ( <> {identityOnly && identityLinked ? ( <>

GitHub account {legacyIdentity?.login}{" "} connected. You can now mention this bot on GitHub.

{footer()} ) : state?.identity ? ( <>

Connect GitHub account {state.identity.login}{" "} to your Paperclip account.

{footer("Confirm my account", async () => { await githubChatApi.confirmIdentity( bot.id, state.identity!.githubUserId, ); await refresh(); })} ) : ( <>

Link your GitHub account so your mentions use your Paperclip permissions.

{(accounts.data?.length ?? 0) > 0 && ( <> {legacyIdentity && (

GitHub account: {legacyIdentity.login}

)} )} {existingIdentityMethod && !legacyAccount && ( Add a personal GitHub connection, then return to this draft )} {footer( legacyAccount ? legacyIdentity ? "Confirm my account" : "Check my account" : "Connect my GitHub account", async () => { if (legacyAccount) { const observed = await githubChatApi.identity( bot.id, legacyAccount, legacyIdentity?.githubUserId, ); setLegacyIdentity(observed); if (legacyIdentity) { setIdentityLinked(true); await refresh(); } } else { const result = await githubChatApi.startIdentity(bot.id); window.location.assign(result.authorizationUrl); } }, existingIdentityMethod && !legacyAccount, )} )} ) : state?.state === "install" ? ( <>

Waiting for GitHub installation or administrator approval…

{state.installationUrl && ( Continue installation on GitHub )} {footer()} ) : state?.state === "enrollment" ? ( <>

Paperclip Cloud receives GitHub events and securely delivers them to this instance.

{footer("Connect Paperclip Cloud", async () => { const result = await toolsApi.startCloudConnectorEnrollment( selectedCompanyId!, undefined, `/apps/chat/connect${window.location.search}`, ); if (!result.verificationUrl) throw new Error("Cloud enrollment could not be started"); window.location.assign(result.verificationUrl); })} ) : state?.state === "recovery" ? ( <>

{state.message}

{state.restartableRegistrationId && (
setAppNotCreated(checked === true)} />
)} {state.restartableRegistrationId ? footer("Continue to GitHub", async () => { const result = await githubChatApi.restartRegistration(bot.id, state.restartableRegistrationId!); setAppNotCreated(false); queryClient.setQueryData(["github-wizard", bot.id], result); if (result.registration) setSubmitManifest(true); }, !appNotCreated) : footer("Try again", refresh)} ) : state?.state === "verify" ? ( <>

{deliveryVerifiedWhileChecking ? "GitHub delivery verified. Checking App and repository access…" : state.message ?? "Checking GitHub access…"}

{state.verification?.checks .filter( (check) => !check.ok && !(deliveryVerifiedWhileChecking && ["webhook", "delivery"].includes(check.key)), ) .map((check) => (

{check.detail}

))} Connection settings {footer("Try again", refresh)} ) : ( <>
{(repositories.isError || connectedBots.isError) && (

Could not load some connected GitHub accounts. Choose Another organization to enter a name.

)}
{accountChoice === "organization" && (
setOwnerLogin(event.target.value)} placeholder="GitHub organization name" />
)}
setName(event.target.value)} />
{state?.registration ? ( setSubmitManifest(false)} /> ) : footer( "Continue to GitHub", async () => { const result = await githubChatApi.registration(bot.id, appInput()); queryClient.setQueryData(["github-wizard", bot.id], result); if (result.registration) setSubmitManifest(true); }, progress.isPending || (ownerType === "organization" && !ownerLogin.trim()), )} )}
); }