feat(github): live repo search with /user/repos pagination and connection gear UI

- searchGithubRepositories action fetches up to 300 repos via paginated
  /user/repos, filters by name, upserts results into gitRepositories so
  createProjectFromRepository works unchanged; token never leaves backend
- connectGithub now persists grantedScopesJson from getAccessToken().scopes
- listProviderAccounts and list queries return grantedScopesJson
- Gear/settings popover on connected GitHub chip shows scopes and triggers
  linkSocial with expanded scope set [repo, read:org, read:user, user:email]
- RepositorySelector merges debounced live search results with synced snapshot
- Restored CONVEX_SITE_URL env var (removed in prior commit but still used)
This commit is contained in:
-Puter
2026-08-01 20:33:55 +05:30
parent 95c23db4fc
commit ce16813fbc
9 changed files with 145 additions and 56 deletions

View File

@@ -1,3 +1,4 @@
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -19,6 +20,9 @@ export const AddProjectPanel = ({
}) => { }) => {
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
const githubConnectionId = accounts?.find(
(account) => account.provider === "github" && account.status === "active"
)?.id as Id<"gitConnections"> | undefined;
useEffect(() => { useEffect(() => {
const dialog = dialogRef.current; const dialog = dialogRef.current;
dialog?.showModal(); dialog?.showModal();
@@ -59,6 +63,7 @@ export const AddProjectPanel = ({
<RepositorySelector <RepositorySelector
existingSourceUrls={existingSourceUrls} existingSourceUrls={existingSourceUrls}
githubConnectionId={githubConnectionId}
onProjectCreated={onProjectCreated} onProjectCreated={onProjectCreated}
/> />
</div> </div>

View File

@@ -1,11 +1,6 @@
import { authClient } from "@code/auth/web"; import { authClient } from "@code/auth/web";
import { Button } from "@code/ui/components/button"; import { Button } from "@code/ui/components/button";
import { import { LoaderCircle, RefreshCw, ShieldCheck, X } from "lucide-react";
LoaderCircle,
RefreshCw,
ShieldCheck,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
/** Expanded scope set for repository + organization visibility. */ /** Expanded scope set for repository + organization visibility. */
@@ -155,9 +150,9 @@ export const GithubConnectionSettings = ({
)} )}
<p className="mt-3 text-xs leading-5 text-[#68665e]"> <p className="mt-3 text-xs leading-5 text-[#68665e]">
Reauthorization refreshes GitHub scopes and re-syncs your Reauthorization refreshes GitHub scopes and re-syncs your accessible
accessible repositories, including organization repos GitHub repositories, including organization repos GitHub returns for your
returns for your account. account.
</p> </p>
<p className="mt-2 text-[11px] leading-4 text-[#858277]"> <p className="mt-2 text-[11px] leading-4 text-[#858277]">

View File

@@ -126,7 +126,11 @@ export const ProviderChips = ({ accounts }: ProviderChipsProps) => {
}; };
const accountsLoading = accounts === undefined; const accountsLoading = accounts === undefined;
const githubConnected =
githubAccount !== undefined && isHealthy(githubAccount.status);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ProviderChip <ProviderChip

View File

@@ -4,17 +4,20 @@ import { useMutation, useQuery } from "convex/react";
import { makeFunctionReference } from "convex/server"; import { makeFunctionReference } from "convex/server";
import { import {
ArrowUpRight, ArrowUpRight,
LoaderCircle,
FolderGit2, FolderGit2,
GitBranch, GitBranch,
Globe2, Globe2,
LoaderCircle,
Lock, Lock,
Search, Search,
Server, Server,
} from "lucide-react"; } from "lucide-react";
import { useMemo } from "react";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { useGithubRepoSearch } from "@/hooks/use-github-repo-search";
import type { GithubRepositorySearchResult } from "@/hooks/use-github-repo-search";
import { useRepositoryPicker } from "@/hooks/use-repository-picker"; import { useRepositoryPicker } from "@/hooks/use-repository-picker";
import type { GitRepositoryOption } from "@/hooks/use-repository-picker"; import type { GitRepositoryOption } from "@/hooks/use-repository-picker";
@@ -42,9 +45,11 @@ const providerLabels = {
export const RepositorySelector = ({ export const RepositorySelector = ({
existingSourceUrls, existingSourceUrls,
githubConnectionId,
onProjectCreated, onProjectCreated,
}: { }: {
readonly existingSourceUrls: readonly string[]; readonly existingSourceUrls: readonly string[];
readonly githubConnectionId: Id<"gitConnections"> | undefined;
readonly onProjectCreated: (projectId: string) => void; readonly onProjectCreated: (projectId: string) => void;
}) => { }) => {
const repositories = useQuery(listRepositoriesRef, {}); const repositories = useQuery(listRepositoriesRef, {});
@@ -64,6 +69,56 @@ export const RepositorySelector = ({
repositories, 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<readonly GithubRepositorySearchResult[]>(() => {
if (githubSearch.kind !== "results") {
return [];
}
return githubSearch.results;
}, [githubSearch]);
const displayRepositories = useMemo<readonly GitRepositoryOption[]>(() => {
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) => { const create = async (repository: GitRepositoryOption) => {
if (pending) { if (pending) {
return; return;
@@ -191,9 +246,27 @@ export const RepositorySelector = ({
</p> </p>
) : null} ) : null}
{availableRepositories.length > 0 ? ( {githubSearch.kind === "loading" ? (
<p className="mt-3 flex items-center gap-1.5 text-xs text-[#747168]">
<LoaderCircle className="size-3 animate-spin" />
Searching GitHub
</p>
) : null}
{githubSearch.kind === "error" ? (
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
{githubSearch.message}
</p>
) : null}
{githubSearch.kind === "reauth" ? (
<p className="mt-3 border border-amber-300 bg-amber-50 p-2 text-xs leading-5 text-amber-700">
GitHub access needs reauthorization. Use the settings control on the
GitHub chip to reconnect.
</p>
) : null}
{displayRepositories.length > 0 ? (
<div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]"> <div className="mt-3 min-h-0 flex-1 divide-y divide-[#e8e6dc] overflow-y-auto rounded border border-[#d7d3c7] bg-[#fffefa]">
{availableRepositories.map((repository) => { {displayRepositories.map((repository) => {
const ProviderIcon = const ProviderIcon =
repository.provider === "github" ? GitBranch : Server; repository.provider === "github" ? GitBranch : Server;
const VisibilityIcon = const VisibilityIcon =

View File

@@ -1,7 +1,6 @@
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import type { import type { Id } from "@code/backend/convex/_generated/dataModel";
GithubRepositorySearchResult, import type { GithubRepositorySearchResult } from "@code/backend/convex/gitConnections";
} from "@code/backend/convex/gitConnections";
import { useAction } from "convex/react"; import { useAction } from "convex/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -31,7 +30,7 @@ export const useGithubRepoSearch = ({
enabled, enabled,
query, query,
}: { }: {
readonly connectionId: string | undefined; readonly connectionId: Id<"gitConnections"> | undefined;
readonly enabled: boolean; readonly enabled: boolean;
readonly query: string; readonly query: string;
}): GithubSearchState => { }): GithubSearchState => {

View File

@@ -158,7 +158,6 @@ export const updateConnectionState = internalMutation({
}, },
}); });
const getConnectionRef = makeFunctionReference< const getConnectionRef = makeFunctionReference<
"query", "query",
{ connectionId: Id<"gitConnections"> }, { connectionId: Id<"gitConnections"> },
@@ -309,7 +308,6 @@ export const reconcileStaleConnections = action({
}, },
}); });
/** /**
* Returns the active GitHub connection for an organization (used by the * Returns the active GitHub connection for an organization (used by the
* live-repo-search action when no explicit connectionId is supplied). * live-repo-search action when no explicit connectionId is supplied).
@@ -367,4 +365,4 @@ export const getGithubRepoIdsByExternalIds = internalQuery({
} }
return results; return results;
}, },
}); });

View File

@@ -264,7 +264,6 @@ const getGithubRepoIdsByExternalIdsRef = makeFunctionReference<
{ id: string; providerRepositoryId: string }[] { id: string; providerRepositoryId: string }[]
>("gitConnectionHealth:getGithubRepoIdsByExternalIds"); >("gitConnectionHealth:getGithubRepoIdsByExternalIds");
export const syncRepositories = action({ export const syncRepositories = action({
args: { connectionId: v.id("gitConnections") }, args: { connectionId: v.id("gitConnections") },
handler: async (ctx, args): Promise<{ synced: number }> => { handler: async (ctx, args): Promise<{ synced: number }> => {
@@ -378,7 +377,6 @@ const resolveActiveGithubConnection = async (
connection: Doc<"gitConnections">; connection: Doc<"gitConnections">;
organizationId: Id<"organizations">; organizationId: Id<"organizations">;
token: string; token: string;
username: string;
}> => { }> => {
const identity = await ctx.auth.getUserIdentity(); const identity = await ctx.auth.getUserIdentity();
if (!identity) { if (!identity) {
@@ -409,10 +407,7 @@ const resolveActiveGithubConnection = async (
connection.credentialCiphertext, connection.credentialCiphertext,
connection.credentialIv connection.credentialIv
); );
const username = return { connection, organizationId, token };
connection.username ??
(await fetchGithubUser(token)).externalUsername;
return { connection, organizationId, token, username };
}; };
export interface GithubRepositorySearchResult { export interface GithubRepositorySearchResult {
@@ -447,49 +442,63 @@ export const searchGithubRepositories = action({
if (query.length < MIN_SEARCH_QUERY_LENGTH) { if (query.length < MIN_SEARCH_QUERY_LENGTH) {
return []; return [];
} }
const { connection, organizationId, token, username } = const { connection, organizationId, token } =
await resolveActiveGithubConnection(ctx, args); await resolveActiveGithubConnection(ctx, args);
// GitHub search: scope to repos accessible by this user. Using `user:` and // Use /user/repos (not /search/repositories) because it naturally returns
// `org:` qualifiers would require enumerating orgs first. The search API // only repos the token has access to — personal, collaborator, AND org
// with the token naturally returns only repos the user has access to. // 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( const perPage = Math.min(
Math.max(args.perPage ?? DEFAULT_SEARCH_PER_PAGE, 1), Math.max(args.perPage ?? DEFAULT_SEARCH_PER_PAGE, 1),
MAX_SEARCH_PER_PAGE MAX_SEARCH_PER_PAGE
); );
// Build query: match name/description, restrict to repos the user can see. const searchTerm = query.toLowerCase();
// 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 response = await fetch( // Fetch up to 3 pages of 100 repos (300 total) and filter by the search
`https://api.github.com/search/repositories?q=${searchQuery}&per_page=${perPage}&sort=updated`, // term. This covers users with many repos while staying within rate limits.
{ const matchingRepos: GithubSearchRepo[] = [];
headers: { const maxPages = 3;
accept: "application/vnd.github+json", for (let page = 1; page <= maxPages; page += 1) {
authorization: `Bearer ${token}`, 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) { const pageRepos = (await response.json()) as GithubSearchRepo[];
throw new ConvexError( if (pageRepos.length === 0) {
"GitHub rejected the stored token; reauthorize the connection" break;
); }
}
if (!response.ok) { const matched = pageRepos.filter(
throw new ConvexError( (repo) =>
`GitHub search failed (${response.status})` 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 { const repos = matchingRepos.slice(0, perPage);
items: GithubSearchRepo[];
total_count: number;
};
const repos = body.items ?? [];
if (repos.length === 0) { if (repos.length === 0) {
return []; return [];

View File

@@ -206,6 +206,11 @@ const ensurePuterWebhook = async (
"GITEA_WEBHOOK_SECRET is not configured; cannot create Puter webhook" "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`; const webhookUrl = `${env.CONVEX_SITE_URL.replace(/\/+$/u, "")}/api/git/webhooks/puter`;
// Check if a webhook already exists for this repo. // Check if a webhook already exists for this repo.
const listResponse = await fetch( const listResponse = await fetch(

View File

@@ -6,6 +6,7 @@ export const env = createEnv({
runtimeEnv: process.env, runtimeEnv: process.env,
server: { server: {
AGENT_BACKEND_URL: z.url().optional(), AGENT_BACKEND_URL: z.url().optional(),
CONVEX_SITE_URL: z.url().optional(),
FLUE_DB_TOKEN: z.string().min(1), FLUE_DB_TOKEN: z.string().min(1),
FLUE_URL: z.url().optional(), FLUE_URL: z.url().optional(),
GITEA_TOKEN: z.string().min(1).optional(), GITEA_TOKEN: z.string().min(1).optional(),