import { CheckIcon, ChevronDownIcon, ExternalLinkIcon, GitBranchIcon, GitPullRequestIcon, Loader2Icon, LockIcon, PlusIcon, RefreshCwIcon, Trash2Icon, } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { toast } from "../lib/toast"; import { REPOSITORY_PANEL_CLASS } from "./repository-picker-layout"; import { ComposerMenuHeader, ComposerMenuRowsSkeleton, ComposerMenuSwitch, } from "../../ui/composer-menu"; import { MENU_BUTTON_CLASS, MENU_LABEL_CLASS, MENU_NOTE_CLASS } from "../../ui/menu-styles"; import { ManualRepositoryEditor, type ManualRepositoryAttachResult, } from "./manual-repository-editor"; import { RepositoryRefInput } from "./repository-ref-input"; import { Button } from "../../ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../ui/collapsible"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "../../ui/dropdown-menu"; import { Input } from "../../ui/input"; import { Label } from "../../ui/label"; import { MetaChip } from "../../ui/meta-chip"; import { userErrorText } from "../../ui/api-error"; import { repoCountLabel } from "../lib/format"; import { attachedManualRepositoryCount } from "../lib/manual-repositories"; import { gitHubRepositoryResource, isRepositoryResourceForGitHubRepo, sameRepositoryUri, type RepoDraft, type RepositoryGroup, } from "../lib/session-tools"; import { cn } from "../lib/utils"; import type { GitHubAppSetupMode, GitHubBindingStatus, GitHubInstallationBinding, GitHubRepository, PersonalGitHubConnectionStatusResponse, PersonalGitHubRepositoryCatalogItem, ResourceRef, } from "../types"; export function repositoryBindingPresentation( status: GitHubBindingStatus, installUrl: string | null, setupMode: GitHubAppSetupMode = "operator", ): { setupDescription: string; emptyDescription: string; connectUrl: string | null; connectLabel: string; healthy: boolean; canRefresh: boolean; } { if (status === "bound") { return { setupDescription: setupMode === "platform" ? "This workspace is connected to Opengeni's managed GitHub App." : "This workspace has a live GitHub App binding with an explicit repository allowlist.", emptyDescription: "This workspace has an active GitHub App binding, but none of its explicitly allowed repositories are currently shared by GitHub. Reconfigure the installation or refresh after policy approval.", connectUrl: installUrl, connectLabel: "Connect another account", healthy: true, canRefresh: true, }; } if (status === "unbound") { return { setupDescription: setupMode === "platform" ? "Install Opengeni on a GitHub account you own, or connect an existing installation." : "GitHub App server credentials are configured, but this workspace has no usable installation binding.", emptyDescription: setupMode === "platform" ? "Connect as the personal account owner or an organization owner. GitHub repository administrators and collaborators cannot connect an installation." : "GitHub App server credentials exist, but this workspace has no active installation binding. Connect as the personal owner or an organization owner; repository administrators and collaborators cannot bind.", connectUrl: installUrl, connectLabel: "Connect workspace App", healthy: false, canRefresh: false, }; } return { setupDescription: setupMode === "platform" ? "GitHub is temporarily unavailable for this Opengeni deployment." : "Create a prefilled app, add the generated values to your .env, then restart the API and worker.", emptyDescription: setupMode === "platform" ? "GitHub integration is unavailable for this Opengeni deployment." : "GitHub App server credentials are not configured. App registration alone does not connect repositories.", connectUrl: null, connectLabel: "Connect GitHub", healthy: false, canRefresh: false, }; } export type RepositoryContextPickerProps = { setupMode: GitHubAppSetupMode; configured: boolean; status: GitHubBindingStatus; installUrl: string | null; linkUrl: string | null; installations: GitHubInstallationBinding[]; repositories: GitHubRepository[]; personalGitHubStatus?: PersonalGitHubConnectionStatusResponse | null | undefined; personalGitHubRepositories?: PersonalGitHubRepositoryCatalogItem[] | undefined; selectedPersonalGitHubRepoIds?: Set | undefined; selectedPersonalGitHubRepoRefs?: Record | undefined; personalGitHubBusy?: boolean | undefined; onConnectPersonalGitHub?: (() => void) | undefined; onTogglePersonalGitHubRepo?: ((repo: PersonalGitHubRepositoryCatalogItem) => void) | undefined; onPersonalGitHubRefChange?: ((repositoryId: string, ref: string) => void) | undefined; groups: RepositoryGroup[]; selectedRepoIds: Set; selectedRepoRefs: Record; selectedInstallationId: number | null; manualRepos: RepoDraft[]; manualOpen: boolean; githubAppOpen: boolean; org: string; pending: boolean; repoBusy: boolean; githubAppBusy: boolean; onRefresh: () => Promise; /** Passive open refresh. Caller owns provider-sync permissions and throttling. */ onOpenRefresh?: (() => Promise) | undefined; /** False when the current principal cannot read either repository catalog. */ refreshAllowed?: boolean | undefined; onToggleRepo: (repo: GitHubRepository) => void; onRefChange: (repoId: number, ref: string) => void; onManualOpenChange: (open: boolean) => void; onManualAdd: () => void; onManualUpdate: (id: number, patch: Partial) => void; onManualRemove: (id: number) => void; onManualAttach?: ((repository: RepoDraft) => Promise) | undefined; onLoadGitHubBranches?: | ((repository: GitHubRepository) => Promise) | undefined; onLoadPersonalGitHubBranches?: | (( repository: PersonalGitHubRepositoryCatalogItem, ) => Promise) | undefined; onGitHubAppOpenChange: (open: boolean) => void; onOrgChange: (value: string) => void; onStartGitHubApp: () => void; /** * Starts workspace App setup. The link is minted on click: `installUrl` only * gates whether this principal may connect, because a page-load link expires. */ onConnectWorkspaceApp: () => void; /** Opens one installation's GitHub repository settings through a fresh link. */ onConfigureInstallation: (installationId: number) => Promise; onDisconnectInstallation: (installationId: number) => Promise; /** Repositories already mounted on an additive surface cannot be removed or retargeted. */ lockedRepoIds?: ReadonlySet | undefined; /** Personal repositories already mounted on an additive surface cannot be removed or retargeted. */ lockedPersonalGitHubRepoIds?: ReadonlySet | undefined; /** Manual rows already mounted on an additive surface are rendered read-only. */ lockedManualRepoIds?: ReadonlySet | undefined; /** Mounted personal sources may outlive their currently authorized catalog entry. */ unavailableMountedRepositories?: ReadonlyArray<{ uri: string; ref: string }> | undefined; /** Inline validation for pending manual repository additions. */ validationError?: string | null | undefined; /** New-chat route used to explain immutable mounted follow-up resources. */ newChatUrl?: string | undefined; /** Optional back control when embedded in the mobile “+” drill-in. */ leading?: ReactNode | undefined; /** Extra classes on the bar trigger (e.g. `max-sm:hidden` when opened from +). */ triggerClassName?: string | undefined; }; /** Shared picker body — desktop dropdown and mobile “+” drill-in. */ export function RepositoryContextMenuBody(props: RepositoryContextPickerProps) { const [search, setSearch] = useState(""); const [refreshError, setRefreshError] = useState(null); const [refreshBusy, setRefreshBusy] = useState(false); const refreshedOnOpen = useRef(false); const openRefresh = useRef(props.onOpenRefresh); openRefresh.current = props.onOpenRefresh; useEffect(() => { let active = true; void Promise.resolve() .then(() => { if (!active || refreshedOnOpen.current || !openRefresh.current) return; refreshedOnOpen.current = true; return openRefresh.current(); }) .catch((error: unknown) => { if (active) setRefreshError(userErrorText(error)); }); return () => { active = false; }; }, []); const matchesSearch = (name: string) => name.toLowerCase().includes(search.trim().toLowerCase()); const personalRepositories = (props.personalGitHubRepositories ?? []).filter( (repository) => repository.selectedAccess !== null && matchesSearch(repository.fullName), ); const manualCount = attachedManualRepositoryCount(props.manualRepos); const hasRepos = props.repositories.length > 0; const bindingPresentation = repositoryBindingPresentation( props.status, props.installUrl, props.setupMode, ); const canRefresh = props.refreshAllowed !== false && (bindingPresentation.canRefresh || props.personalGitHubStatus?.enabled === true); async function refreshList() { if (!canRefresh || props.repoBusy || refreshBusy) return; setRefreshBusy(true); setRefreshError(null); try { await props.onRefresh(); } catch (error) { setRefreshError(userErrorText(error)); } finally { setRefreshBusy(false); } } const [confirmDisconnectInstallationId, setConfirmDisconnectInstallationId] = useState< number | null >(null); const [configuringInstallationId, setConfiguringInstallationId] = useState(null); const [linkError, setLinkError] = useState(null); function configureInstallation(installationId: number) { setConfiguringInstallationId(installationId); setLinkError(null); props .onConfigureInstallation(installationId) .catch((error: unknown) => setLinkError(`Couldn't open GitHub. ${userErrorText(error, "Try again.")}`), ) .finally(() => setConfiguringInstallationId(null)); } // Platform deployments expose only installation/connection. Operator // deployments additionally expose App registration and environment setup. const setupHasActions = (props.setupMode === "operator" && !props.configured) || Boolean(bindingPresentation.connectUrl) || props.installations.length > 0 || Boolean(linkError); // Under the empty note the note already says why, so a platform deployment // skips the second sentence; an operator keeps the setup instructions. const renderSetupForm = (showDescription: boolean) => (
{showDescription ? (

{bindingPresentation.setupDescription}

) : null}
{props.setupMode === "operator" && !props.configured ? (
props.onOrgChange(event.target.value)} placeholder="Optional org login" disabled={props.githubAppBusy} className="mt-1 h-8 text-xs" />
) : null}
{props.setupMode === "operator" && !props.configured ? ( ) : null} {bindingPresentation.connectUrl ? ( ) : null}
{props.installations.length > 0 ? (
{props.installations.map((installation) => { const confirming = confirmDisconnectInstallationId === installation.installationId; return (
{installation.accountLogin ?? `Installation ${installation.installationId}`}
{installation.lifecycle !== "active" ? `GitHub status: ${installation.lifecycle}` : installation.repositoryScope === "all" ? "All installation repositories (legacy)" : `${installation.repositoryCount} workspace repositories`}
{confirming ? (
) : (
{installation.configureUrl ? ( ) : null}
)}
); })}
) : null} {linkError ? (

{linkError}

) : null}
); const setupForm = renderSetupForm(true); const emptyNote = (

No repositories connected

{bindingPresentation.emptyDescription}

); // Stored bindings remain inspectable and unlinkable when provider lifecycle // changes leave them unbound. const settingsDisclosure = (
{setupForm}
); return (
event.stopPropagation()} className="flex min-h-0 flex-1 flex-col">
{props.repositories.length + (props.personalGitHubRepositories?.length ?? 0) > 5 ? ( setSearch(event.target.value)} className="h-8 text-xs" /> ) : null} {search.trim() && !props.repositories.some((repo) => matchesSearch(repo.fullName)) && personalRepositories.length === 0 ? (

No repositories match your search.

) : null} {props.personalGitHubStatus?.enabled ? ( props.personalGitHubStatus.connection?.status === "active" ? (
Your GitHub identity
@ {String( props.personalGitHubStatus.connection.metadata.githubLogin ?? "connected", )}

Writes appear as you.

{props.personalGitHubBusy ? (
Loading your repositories
) : personalRepositories.length === 0 ? (
{search.trim() ? "No personal repositories match your search." : "No personal repositories are allowed yet. Choose them from Integrations."}
) : (
{personalRepositories.map((repo) => { const locked = props.lockedPersonalGitHubRepoIds?.has(repo.repositoryId) === true; const checked = locked || props.selectedPersonalGitHubRepoIds?.has(repo.repositoryId) === true; return (
{repo.fullName} {repo.private ? ( ) : null} {props.selectedPersonalGitHubRepoRefs?.[repo.repositoryId] ?? repo.defaultBranch} {" · "} {repo.selectedAccess === "write" ? "Read and write" : "Read only"} {locked ? ( ) : checked ? ( As you ) : null} props.onTogglePersonalGitHubRepo?.(repo)} label={ locked ? `${repo.fullName} mounted as you` : `Use ${repo.fullName} as your GitHub identity` } />
{checked && !locked ? (
props.onPersonalGitHubRefChange?.(repo.repositoryId, value) } disabled={props.pending || locked} loadBranches={props.onLoadPersonalGitHubBranches?.bind(null, repo)} />
) : null}
); })}
)}
) : (
Use your GitHub identity
Approve, review, and merge as yourself.
) ) : null} {props.status === "disabled" ? (
{emptyNote} {setupHasActions ? (
{renderSetupForm(props.setupMode === "operator")}
) : null}
) : props.repoBusy && !hasRepos ? ( // First load only: rows that hold the menu's size. A refresh with // repositories already listed updates them in place. ) : !hasRepos ? (
{emptyNote} {props.setupMode === "platform" ? ( setupHasActions ? (
{renderSetupForm(false)}
) : null ) : ( settingsDisclosure )}
) : ( <>
{props.groups .filter((group) => group.repositories.some((repo) => matchesSearch(repo.fullName)), ) .map((group) => (
{group.label}
{group.repositories .filter((repo) => matchesSearch(repo.fullName)) .map((repo) => { const locked = props.lockedRepoIds?.has(repo.id) === true; const checked = locked || props.selectedRepoIds.has(repo.id); const blocked = props.selectedInstallationId !== null && props.selectedInstallationId !== repo.installationId && !checked; return (
{repo.name} {repo.private ? ( ) : null} {props.selectedRepoRefs[repo.id] ?? repo.defaultBranch} {locked ? ( ) : blocked ? ( Other app ) : null} props.onToggleRepo(repo)} label={ locked ? `${repo.fullName} mounted` : `Select ${repo.fullName}` } />
{checked && !locked ? (
props.onRefChange(repo.id, value)} disabled={props.pending || locked} loadBranches={props.onLoadGitHubBranches?.bind(null, repo)} />
) : null}
); })}
))}
{settingsDisclosure} )} {props.unavailableMountedRepositories?.map((repo) => (
{repo.uri}
{repo.ref} · Unavailable in catalog
{}} />
))} {props.manualRepos .filter((repo) => props.lockedManualRepoIds?.has(repo.id)) .map((repo) => (
{repo.url}
{repo.ref}
{}} />
))} {props.manualRepos.length === 0 ? ( ) : (
{props.manualRepos.every((repo) => props.lockedManualRepoIds?.has(repo.id)) ? (

Public HTTPS repositories only. Private GitHub repositories require the workspace App or your personal identity.

) : ( props.manualRepos .filter((repo) => !props.lockedManualRepoIds?.has(repo.id)) .map((repo) => ( props.onManualUpdate(repo.id, patch)} onRemove={() => props.onManualRemove(repo.id)} onAttach={ props.onManualAttach ?? (async () => { throw new Error("Repository attachment is unavailable."); }) } /> )) )} {props.validationError ? (

{props.validationError}

) : null}
)} {/* A list that can't be refreshed (GitHub unavailable) has no Refresh action. */} {canRefresh ? ( ) : null} {refreshError ? (

Couldn't refresh the list. {refreshError}

) : null} {props.newChatUrl && (props.lockedRepoIds?.size ?? 0) + (props.lockedPersonalGitHubRepoIds?.size ?? 0) + (props.lockedManualRepoIds?.size ?? 0) > 0 ? (

Mounted repositories cannot be removed or retargeted.{" "} Start a new chat {" "} to change them.

) : null} {props.validationError && (!props.manualOpen || props.manualRepos.length === 0) ? (

{props.validationError}

) : null}
); } export function RepositoryContextPicker(props: RepositoryContextPickerProps) { const selectedInstalledCount = props.selectedRepoIds.size; const selectedPersonalCount = props.selectedPersonalGitHubRepoIds?.size ?? 0; const manualCount = attachedManualRepositoryCount(props.manualRepos); const selectedCount = selectedInstalledCount + selectedPersonalCount + manualCount; const bindingPresentation = repositoryBindingPresentation( props.status, props.installUrl, props.setupMode, ); const personalGitHubActive = props.personalGitHubStatus?.connection?.status === "active"; const selectedInstalled = props.repositories.filter((repo) => props.selectedRepoIds.has(repo.id)); const selectedManual = props.manualRepos.filter( (repo) => repo.attached !== false && repo.url.trim().length > 0, ); const selectedNames = [ ...selectedInstalled.map((repo) => repo.fullName), ...selectedManual.map((repo) => repo.url.trim()), ...(props.personalGitHubRepositories ?? []) .filter((repo) => props.selectedPersonalGitHubRepoIds?.has(repo.repositoryId)) .map((repo) => repo.fullName), ]; const selectedLabel = selectedCount === 1 ? (selectedNames[0] ?? repoCountLabel(1)) : repoCountLabel(selectedCount); return ( ); } export function ScheduledTaskRepositoryPicker(props: { status: GitHubBindingStatus; repositories: GitHubRepository[]; groups: RepositoryGroup[]; resources: ResourceRef[]; busy: boolean; repoBusy: boolean; onRefresh: () => Promise; onResourcesChange: (resources: ResourceRef[]) => void; }) { const repositoryResources = props.resources.filter( (resource): resource is Extract => resource.kind === "repository", ); const fileResources = props.resources.filter((resource) => resource.kind === "file"); const preservedRepositoryResources = repositoryResources.filter( (resource) => !props.repositories.some((repo) => isRepositoryResourceForGitHubRepo(resource, repo)), ); const selectedInstallationId = repositoryResources.find((resource) => typeof resource.githubInstallationId === "number") ?.githubInstallationId ?? null; function toggleRepo(repo: GitHubRepository) { const existing = props.resources.find( (resource) => resource.kind === "repository" && isRepositoryResourceForGitHubRepo(resource, repo), ); if (existing) { props.onResourcesChange(props.resources.filter((resource) => resource !== existing)); return; } if (selectedInstallationId !== null && selectedInstallationId !== repo.installationId) { toast.info("Scheduled tasks use one GitHub token", { description: "Clear selected repositories to choose repositories from another account.", }); return; } try { const nextResource = gitHubRepositoryResource(repo, repo.defaultBranch); props.onResourcesChange([ ...props.resources.filter((resource) => !sameRepositoryUri(resource, nextResource.uri)), nextResource, ]); } catch (error) { toast.error("Couldn't select the repository", { description: userErrorText(error) }); } } function updateRef(repo: GitHubRepository, ref: string) { props.onResourcesChange( props.resources.map((resource) => { if (resource.kind !== "repository" || !isRepositoryResourceForGitHubRepo(resource, repo)) { return resource; } return { ...resource, ref }; }), ); } return (
Repositories
{repoCountLabel(repositoryResources.length)} attached to this task
{props.status !== "bound" ? (
{props.status === "disabled" ? "GitHub App server credentials are not configured." : "GitHub App credentials exist, but this workspace has no active installation binding."}
) : props.repoBusy ? (
Loading repositories
) : props.repositories.length === 0 ? (
The active binding currently shares no explicitly allowed repositories.
) : (
{props.groups.map((group) => (
{group.label}
{group.repositories.length} repos
{group.repositories.map((repo) => { const resource = repositoryResources.find((item) => isRepositoryResourceForGitHubRepo(item, repo), ); const checked = Boolean(resource); const blocked = selectedInstallationId !== null && selectedInstallationId !== repo.installationId && !checked; return (
{resource ? (
updateRef(repo, event.target.value)} onClick={(event) => event.stopPropagation()} disabled={props.busy} placeholder={repo.defaultBranch} aria-label={`${repo.fullName} scheduled task ref`} className="h-7 text-xs" />
) : null}
); })}
))}
)} {preservedRepositoryResources.length > 0 || fileResources.length > 0 ? (
Preserving {preservedRepositoryResources.length} manual repository resource {preservedRepositoryResources.length === 1 ? "" : "s"} {fileResources.length > 0 ? ` and ${fileResources.length} file resource${fileResources.length === 1 ? "" : "s"}` : ""} .
) : null}
); }