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
@@ -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;
},
});
});
+46 -37
View File
@@ -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 [];
@@ -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(
+1
View File
@@ -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(),