diff --git a/apps/web/src/components/projects/add-project-panel.tsx b/apps/web/src/components/projects/add-project-panel.tsx index 6a3efa8..cd81656 100644 --- a/apps/web/src/components/projects/add-project-panel.tsx +++ b/apps/web/src/components/projects/add-project-panel.tsx @@ -1,3 +1,4 @@ +import type { Id } from "@code/backend/convex/_generated/dataModel"; import { X } from "lucide-react"; import { useEffect, useRef } from "react"; import { createPortal } from "react-dom"; @@ -19,6 +20,9 @@ export const AddProjectPanel = ({ }) => { const dialogRef = useRef(null); + const githubConnectionId = accounts?.find( + (account) => account.provider === "github" && account.status === "active" + )?.id as Id<"gitConnections"> | undefined; useEffect(() => { const dialog = dialogRef.current; dialog?.showModal(); @@ -59,6 +63,7 @@ export const AddProjectPanel = ({ diff --git a/apps/web/src/components/projects/github-connection-settings.tsx b/apps/web/src/components/projects/github-connection-settings.tsx index 3fb32d7..4b1d168 100644 --- a/apps/web/src/components/projects/github-connection-settings.tsx +++ b/apps/web/src/components/projects/github-connection-settings.tsx @@ -1,11 +1,6 @@ import { authClient } from "@code/auth/web"; import { Button } from "@code/ui/components/button"; -import { - LoaderCircle, - RefreshCw, - ShieldCheck, - X, -} from "lucide-react"; +import { LoaderCircle, RefreshCw, ShieldCheck, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; /** Expanded scope set for repository + organization visibility. */ @@ -155,9 +150,9 @@ export const GithubConnectionSettings = ({ )}

- Reauthorization refreshes GitHub scopes and re-syncs your - accessible repositories, including organization repos GitHub - returns for your account. + Reauthorization refreshes GitHub scopes and re-syncs your accessible + repositories, including organization repos GitHub returns for your + account.

diff --git a/apps/web/src/components/projects/provider-chips.tsx b/apps/web/src/components/projects/provider-chips.tsx index 63acbe5..86c4119 100644 --- a/apps/web/src/components/projects/provider-chips.tsx +++ b/apps/web/src/components/projects/provider-chips.tsx @@ -126,7 +126,11 @@ export const ProviderChips = ({ accounts }: ProviderChipsProps) => { }; const accountsLoading = accounts === undefined; + const githubConnected = + githubAccount !== undefined && isHealthy(githubAccount.status); + return ( +

| undefined; readonly onProjectCreated: (projectId: string) => void; }) => { const repositories = useQuery(listRepositoriesRef, {}); @@ -64,6 +69,56 @@ export const RepositorySelector = ({ repositories, }); + // Live GitHub search: active when GitHub provider is selectable (filter is + // "all" or "github") and a GitHub connection exists. The search action uses + // the server-side encrypted credential — the token never reaches the browser. + const githubSearchEnabled = + githubConnectionId !== undefined && + (providerFilter === "all" || providerFilter === "github"); + const githubSearch = useGithubRepoSearch({ + connectionId: githubConnectionId, + enabled: githubSearchEnabled, + query: search, + }); + + // Merge live search results with the synced snapshot. Live results take + // priority by webUrl to avoid duplicate rows. + const liveResults = useMemo(() => { + if (githubSearch.kind !== "results") { + return []; + } + return githubSearch.results; + }, [githubSearch]); + + const displayRepositories = useMemo(() => { + const base = availableRepositories ?? []; + if (liveResults.length === 0) { + return base; + } + const existingUrls = new Set(base.map((repo) => repo.webUrl)); + const merged: GitRepositoryOption[] = [...base]; + for (const result of liveResults) { + if (!result.gitRepositoryId || existingUrls.has(result.webUrl)) { + continue; + } + existingUrls.add(result.webUrl); + merged.push({ + cloneUrl: result.cloneUrl, + defaultBranch: result.defaultBranch, + fullName: result.fullName, + id: result.gitRepositoryId, + name: result.name, + owner: result.owner, + privacy: result.privacy, + provider: result.provider, + webUrl: result.webUrl, + }); + } + return merged.toSorted((left, right) => + left.fullName.localeCompare(right.fullName) + ); + }, [availableRepositories, liveResults]); + const create = async (repository: GitRepositoryOption) => { if (pending) { return; @@ -191,9 +246,27 @@ export const RepositorySelector = ({

) : null} - {availableRepositories.length > 0 ? ( + {githubSearch.kind === "loading" ? ( +

+ + Searching GitHub… +

+ ) : null} + {githubSearch.kind === "error" ? ( +

+ {githubSearch.message} +

+ ) : null} + {githubSearch.kind === "reauth" ? ( +

+ GitHub access needs reauthorization. Use the settings control on the + GitHub chip to reconnect. +

+ ) : null} + + {displayRepositories.length > 0 ? (
- {availableRepositories.map((repository) => { + {displayRepositories.map((repository) => { const ProviderIcon = repository.provider === "github" ? GitBranch : Server; const VisibilityIcon = diff --git a/apps/web/src/hooks/use-github-repo-search.ts b/apps/web/src/hooks/use-github-repo-search.ts index b9078e7..d7ab7e3 100644 --- a/apps/web/src/hooks/use-github-repo-search.ts +++ b/apps/web/src/hooks/use-github-repo-search.ts @@ -1,7 +1,6 @@ import { api } from "@code/backend/convex/_generated/api"; -import type { - GithubRepositorySearchResult, -} from "@code/backend/convex/gitConnections"; +import type { Id } from "@code/backend/convex/_generated/dataModel"; +import type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections"; import { useAction } from "convex/react"; import { useEffect, useRef, useState } from "react"; @@ -31,7 +30,7 @@ export const useGithubRepoSearch = ({ enabled, query, }: { - readonly connectionId: string | undefined; + readonly connectionId: Id<"gitConnections"> | undefined; readonly enabled: boolean; readonly query: string; }): GithubSearchState => { diff --git a/packages/backend/convex/gitConnectionHealth.ts b/packages/backend/convex/gitConnectionHealth.ts index 06404a4..9fa084d 100644 --- a/packages/backend/convex/gitConnectionHealth.ts +++ b/packages/backend/convex/gitConnectionHealth.ts @@ -158,7 +158,6 @@ export const updateConnectionState = internalMutation({ }, }); - const getConnectionRef = makeFunctionReference< "query", { connectionId: Id<"gitConnections"> }, @@ -309,7 +308,6 @@ export const reconcileStaleConnections = action({ }, }); - /** * Returns the active GitHub connection for an organization (used by the * live-repo-search action when no explicit connectionId is supplied). @@ -367,4 +365,4 @@ export const getGithubRepoIdsByExternalIds = internalQuery({ } return results; }, -}); \ No newline at end of file +}); diff --git a/packages/backend/convex/gitConnections.ts b/packages/backend/convex/gitConnections.ts index 8536902..ff3386d 100644 --- a/packages/backend/convex/gitConnections.ts +++ b/packages/backend/convex/gitConnections.ts @@ -264,7 +264,6 @@ const getGithubRepoIdsByExternalIdsRef = makeFunctionReference< { id: string; providerRepositoryId: string }[] >("gitConnectionHealth:getGithubRepoIdsByExternalIds"); - export const syncRepositories = action({ args: { connectionId: v.id("gitConnections") }, handler: async (ctx, args): Promise<{ synced: number }> => { @@ -378,7 +377,6 @@ const resolveActiveGithubConnection = async ( connection: Doc<"gitConnections">; organizationId: Id<"organizations">; token: string; - username: string; }> => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { @@ -409,10 +407,7 @@ const resolveActiveGithubConnection = async ( connection.credentialCiphertext, connection.credentialIv ); - const username = - connection.username ?? - (await fetchGithubUser(token)).externalUsername; - return { connection, organizationId, token, username }; + return { connection, organizationId, token }; }; export interface GithubRepositorySearchResult { @@ -447,49 +442,63 @@ export const searchGithubRepositories = action({ if (query.length < MIN_SEARCH_QUERY_LENGTH) { return []; } - const { connection, organizationId, token, username } = + const { connection, organizationId, token } = await resolveActiveGithubConnection(ctx, args); - // GitHub search: scope to repos accessible by this user. Using `user:` and - // `org:` qualifiers would require enumerating orgs first. The search API - // with the token naturally returns only repos the user has access to. + // Use /user/repos (not /search/repositories) because it naturally returns + // only repos the token has access to — personal, collaborator, AND org + // member repos. The search API would return public repos the user doesn't + // own, which we don't want to persist. We paginate and filter by name. const perPage = Math.min( Math.max(args.perPage ?? DEFAULT_SEARCH_PER_PAGE, 1), MAX_SEARCH_PER_PAGE ); - // Build query: match name/description, restrict to repos the user can see. - // The `@USER` qualifier isn't valid; we use `user:` for their personal - // repos plus broad name search which GitHub filters to accessible repos. - const searchQuery = encodeURIComponent( - `${query} user:${username}` - ).replace(/%3A/gu, ":"); + const searchTerm = query.toLowerCase(); - const response = await fetch( - `https://api.github.com/search/repositories?q=${searchQuery}&per_page=${perPage}&sort=updated`, - { - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - }, + // Fetch up to 3 pages of 100 repos (300 total) and filter by the search + // term. This covers users with many repos while staying within rate limits. + const matchingRepos: GithubSearchRepo[] = []; + const maxPages = 3; + for (let page = 1; page <= maxPages; page += 1) { + const response = await fetch( + `https://api.github.com/user/repos?sort=updated&per_page=100&page=${page}`, + { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + }, + } + ); + + if (response.status === 401 || response.status === 403) { + throw new ConvexError( + "GitHub rejected the stored token; reauthorize the connection" + ); + } + if (!response.ok) { + throw new ConvexError( + `GitHub repository listing failed (${response.status})` + ); } - ); - if (response.status === 401 || response.status === 403) { - throw new ConvexError( - "GitHub rejected the stored token; reauthorize the connection" - ); - } - if (!response.ok) { - throw new ConvexError( - `GitHub search failed (${response.status})` + const pageRepos = (await response.json()) as GithubSearchRepo[]; + if (pageRepos.length === 0) { + break; + } + + const matched = pageRepos.filter( + (repo) => + repo.name.toLowerCase().includes(searchTerm) || + repo.full_name.toLowerCase().includes(searchTerm) ); + matchingRepos.push(...matched); + + if (matchingRepos.length >= perPage || pageRepos.length < 100) { + break; + } } - const body = (await response.json()) as { - items: GithubSearchRepo[]; - total_count: number; - }; - const repos = body.items ?? []; + const repos = matchingRepos.slice(0, perPage); if (repos.length === 0) { return []; diff --git a/packages/backend/convex/gitProvisioning.ts b/packages/backend/convex/gitProvisioning.ts index 822e60b..2a1fbf7 100644 --- a/packages/backend/convex/gitProvisioning.ts +++ b/packages/backend/convex/gitProvisioning.ts @@ -206,6 +206,11 @@ const ensurePuterWebhook = async ( "GITEA_WEBHOOK_SECRET is not configured; cannot create Puter webhook" ); } + if (!env.CONVEX_SITE_URL) { + throw new ConvexError( + "CONVEX_SITE_URL is not configured; cannot create Puter webhook" + ); + } const webhookUrl = `${env.CONVEX_SITE_URL.replace(/\/+$/u, "")}/api/git/webhooks/puter`; // Check if a webhook already exists for this repo. const listResponse = await fetch( diff --git a/packages/env/src/convex.ts b/packages/env/src/convex.ts index 05c8b1c..effbcda 100644 --- a/packages/env/src/convex.ts +++ b/packages/env/src/convex.ts @@ -6,6 +6,7 @@ export const env = createEnv({ runtimeEnv: process.env, server: { AGENT_BACKEND_URL: z.url().optional(), + CONVEX_SITE_URL: z.url().optional(), FLUE_DB_TOKEN: z.string().min(1), FLUE_URL: z.url().optional(), GITEA_TOKEN: z.string().min(1).optional(),