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:
@@ -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>
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
1
packages/env/src/convex.ts
vendored
1
packages/env/src/convex.ts
vendored
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user